diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index 3e428651d81a8..b4ba150656183 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -17,12 +17,21 @@ on: paths: - .github/workflows/build-docker.yml +env: + RUFF_BASE_IMG: ghcr.io/${{ github.repository_owner }}/ruff + jobs: - docker-publish: - name: Build Docker image (ghcr.io/astral-sh/ruff) + docker-build: + name: Build Docker image (ghcr.io/astral-sh/ruff) for ${{ matrix.platform }} runs-on: ubuntu-latest environment: name: release + strategy: + fail-fast: false + matrix: + platform: + - linux/amd64 + - linux/arm64 steps: - uses: actions/checkout@v4 with: @@ -36,12 +45,6 @@ jobs: username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Extract metadata (tags, labels) for Docker - id: meta - uses: docker/metadata-action@v5 - with: - images: ghcr.io/astral-sh/ruff - - name: Check tag consistency if: ${{ inputs.plan != '' && !fromJson(inputs.plan).announcement_tag_is_implicit }} run: | @@ -55,14 +58,233 @@ jobs: echo "Releasing ${version}" fi - - name: "Build and push Docker image" + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.RUFF_BASE_IMG }} + # Defining this makes sure the org.opencontainers.image.version OCI label becomes the actual release version and not the branch name + tags: | + type=raw,value=dry-run,enable=${{ inputs.plan == '' || fromJson(inputs.plan).announcement_tag_is_implicit }} + type=pep440,pattern={{ version }},value=${{ inputs.plan != '' && fromJson(inputs.plan).announcement_tag || 'dry-run' }},enable=${{ inputs.plan != '' && !fromJson(inputs.plan).announcement_tag_is_implicit }} + + - name: Normalize Platform Pair (replace / with -) + run: | + platform=${{ matrix.platform }} + echo "PLATFORM_TUPLE=${platform//\//-}" >> $GITHUB_ENV + + # Adapted from https://docs.docker.com/build/ci/github-actions/multi-platform/ + - name: Build and push by digest + id: build + uses: docker/build-push-action@v6 + with: + context: . + platforms: ${{ matrix.platform }} + cache-from: type=gha,scope=ruff-${{ env.PLATFORM_TUPLE }} + cache-to: type=gha,mode=min,scope=ruff-${{ env.PLATFORM_TUPLE }} + labels: ${{ steps.meta.outputs.labels }} + outputs: type=image,name=${{ env.RUFF_BASE_IMG }},push-by-digest=true,name-canonical=true,push=${{ inputs.plan != '' && !fromJson(inputs.plan).announcement_tag_is_implicit }} + + - name: Export digests + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digests + uses: actions/upload-artifact@v4 + with: + name: digests-${{ env.PLATFORM_TUPLE }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + docker-publish: + name: Publish Docker image (ghcr.io/astral-sh/ruff) + runs-on: ubuntu-latest + environment: + name: release + needs: + - docker-build + if: ${{ inputs.plan != '' && !fromJson(inputs.plan).announcement_tag_is_implicit }} + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: /tmp/digests + pattern: digests-* + merge-multiple: true + + - uses: docker/setup-buildx-action@v3 + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.RUFF_BASE_IMG }} + # Order is on purpose such that the label org.opencontainers.image.version has the first pattern with the full version + tags: | + type=pep440,pattern={{ version }},value=${{ fromJson(inputs.plan).announcement_tag }} + type=pep440,pattern={{ major }}.{{ minor }},value=${{ fromJson(inputs.plan).announcement_tag }} + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Adapted from https://docs.docker.com/build/ci/github-actions/multi-platform/ + - name: Create manifest list and push + working-directory: /tmp/digests + # The jq command expands the docker/metadata json "tags" array entry to `-t tag1 -t tag2 ...` for each tag in the array + # The printf will expand the base image with the `@sha256: ...` for each sha256 in the directory + # The final command becomes `docker buildx imagetools create -t tag1 -t tag2 ... @sha256: @sha256: ...` + run: | + docker buildx imagetools create \ + $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ env.RUFF_BASE_IMG }}@sha256:%s ' *) + + docker-publish-extra: + name: Publish additional Docker image based on ${{ matrix.image-mapping }} + runs-on: ubuntu-latest + environment: + name: release + needs: + - docker-publish + if: ${{ inputs.plan != '' && !fromJson(inputs.plan).announcement_tag_is_implicit }} + strategy: + fail-fast: false + matrix: + # Mapping of base image followed by a comma followed by one or more base tags (comma separated) + # Note, org.opencontainers.image.version label will use the first base tag (use the most specific tag first) + image-mapping: + - alpine:3.20,alpine3.20,alpine + - debian:bookworm-slim,bookworm-slim,debian-slim + - buildpack-deps:bookworm,bookworm,debian + steps: + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Generate Dynamic Dockerfile Tags + shell: bash + run: | + set -euo pipefail + + # Extract the image and tags from the matrix variable + IFS=',' read -r BASE_IMAGE BASE_TAGS <<< "${{ matrix.image-mapping }}" + + # Generate Dockerfile content + cat < Dockerfile + FROM ${BASE_IMAGE} + COPY --from=${{ env.RUFF_BASE_IMG }}:latest /ruff /usr/local/bin/ruff + ENTRYPOINT [] + CMD ["/usr/local/bin/ruff"] + EOF + + # Initialize a variable to store all tag docker metadata patterns + TAG_PATTERNS="" + + # Loop through all base tags and append its docker metadata pattern to the list + # Order is on purpose such that the label org.opencontainers.image.version has the first pattern with the full version + IFS=','; for TAG in ${BASE_TAGS}; do + TAG_PATTERNS="${TAG_PATTERNS}type=pep440,pattern={{ version }},suffix=-${TAG},value=${{ fromJson(inputs.plan).announcement_tag }}\n" + TAG_PATTERNS="${TAG_PATTERNS}type=pep440,pattern={{ major }}.{{ minor }},suffix=-${TAG},value=${{ fromJson(inputs.plan).announcement_tag }}\n" + TAG_PATTERNS="${TAG_PATTERNS}type=raw,value=${TAG}\n" + done + + # Remove the trailing newline from the pattern list + TAG_PATTERNS="${TAG_PATTERNS%\\n}" + + # Export image cache name + echo "IMAGE_REF=${BASE_IMAGE//:/-}" >> $GITHUB_ENV + + # Export tag patterns using the multiline env var syntax + { + echo "TAG_PATTERNS<> $GITHUB_ENV + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + # ghcr.io prefers index level annotations + env: + DOCKER_METADATA_ANNOTATIONS_LEVELS: index + with: + images: ${{ env.RUFF_BASE_IMG }} + flavor: | + latest=false + tags: | + ${{ env.TAG_PATTERNS }} + + - name: Build and push uses: docker/build-push-action@v6 with: context: . platforms: linux/amd64,linux/arm64 - # Reuse the builder - cache-from: type=gha - cache-to: type=gha,mode=max - push: ${{ inputs.plan != '' && !fromJson(inputs.plan).announcement_tag_is_implicit }} - tags: ghcr.io/astral-sh/ruff:latest,ghcr.io/astral-sh/ruff:${{ (inputs.plan != '' && fromJson(inputs.plan).announcement_tag) || 'dry-run' }} + # We do not really need to cache here as the Dockerfile is tiny + #cache-from: type=gha,scope=ruff-${{ env.IMAGE_REF }} + #cache-to: type=gha,mode=min,scope=ruff-${{ env.IMAGE_REF }} + push: true + tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + annotations: ${{ steps.meta.outputs.annotations }} + + # This is effectively a duplicate of `docker-publish` to make https://github.com/astral-sh/ruff/pkgs/container/ruff + # show the ruff base image first since GitHub always shows the last updated image digests + # This works by annotating the original digests (previously non-annotated) which triggers an update to ghcr.io + docker-republish: + name: Annotate Docker image (ghcr.io/astral-sh/ruff) + runs-on: ubuntu-latest + environment: + name: release + needs: + - docker-publish-extra + if: ${{ inputs.plan != '' && !fromJson(inputs.plan).announcement_tag_is_implicit }} + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: /tmp/digests + pattern: digests-* + merge-multiple: true + + - uses: docker/setup-buildx-action@v3 + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + env: + DOCKER_METADATA_ANNOTATIONS_LEVELS: index + with: + images: ${{ env.RUFF_BASE_IMG }} + # Order is on purpose such that the label org.opencontainers.image.version has the first pattern with the full version + tags: | + type=pep440,pattern={{ version }},value=${{ fromJson(inputs.plan).announcement_tag }} + type=pep440,pattern={{ major }}.{{ minor }},value=${{ fromJson(inputs.plan).announcement_tag }} + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Adapted from https://docs.docker.com/build/ci/github-actions/multi-platform/ + - name: Create manifest list and push + working-directory: /tmp/digests + # The readarray part is used to make sure the quoting and special characters are preserved on expansion (e.g. spaces) + # The jq command expands the docker/metadata json "tags" array entry to `-t tag1 -t tag2 ...` for each tag in the array + # The printf will expand the base image with the `@sha256: ...` for each sha256 in the directory + # The final command becomes `docker buildx imagetools create -t tag1 -t tag2 ... @sha256: @sha256: ...` + run: | + readarray -t lines <<< "$DOCKER_METADATA_OUTPUT_ANNOTATIONS"; annotations=(); for line in "${lines[@]}"; do annotations+=(--annotation "$line"); done + docker buildx imagetools create \ + "${annotations[@]}" \ + $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ env.RUFF_BASE_IMG }}@sha256:%s ' *) diff --git a/.github/workflows/publish-playground.yml b/.github/workflows/publish-playground.yml index 3c251b419bf0d..97b7c6d4dc576 100644 --- a/.github/workflows/publish-playground.yml +++ b/.github/workflows/publish-playground.yml @@ -47,7 +47,7 @@ jobs: working-directory: playground - name: "Deploy to Cloudflare Pages" if: ${{ env.CF_API_TOKEN_EXISTS == 'true' }} - uses: cloudflare/wrangler-action@v3.7.0 + uses: cloudflare/wrangler-action@v3.9.0 with: apiToken: ${{ secrets.CF_API_TOKEN }} accountId: ${{ secrets.CF_ACCOUNT_ID }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bbecfdf8c7c92..fc47b8fc123ea 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,12 +17,12 @@ exclude: | repos: - repo: https://github.com/abravalheri/validate-pyproject - rev: v0.20.2 + rev: v0.21 hooks: - id: validate-pyproject - repo: https://github.com/executablebooks/mdformat - rev: 0.7.17 + rev: 0.7.18 hooks: - id: mdformat additional_dependencies: @@ -46,16 +46,16 @@ repos: )$ - repo: https://github.com/adamchainz/blacken-docs - rev: 1.19.0 + rev: 1.19.1 hooks: - id: blacken-docs - args: ["--line-length", "130"] + args: ["--pyi", "--line-length", "130"] files: '^crates/.*/resources/mdtest/.*\.md' additional_dependencies: - black==24.10.0 - repo: https://github.com/crate-ci/typos - rev: v1.25.0 + rev: v1.26.0 hooks: - id: typos @@ -69,7 +69,7 @@ repos: pass_filenames: false # This makes it a lot faster - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.9 + rev: v0.7.0 hooks: - id: ruff-format - id: ruff diff --git a/BREAKING_CHANGES.md b/BREAKING_CHANGES.md index 61c777183dd01..f6000f25ea600 100644 --- a/BREAKING_CHANGES.md +++ b/BREAKING_CHANGES.md @@ -1,5 +1,18 @@ # Breaking Changes +## 0.7.0 + +- The pytest rules `PT001` and `PT023` now default to omitting the decorator parentheses when there are no arguments + ([#12838](https://github.com/astral-sh/ruff/pull/12838), [#13292](https://github.com/astral-sh/ruff/pull/13292)). + This was a change that we attempted to make in Ruff v0.6.0, but only partially made due to an error on our part. + See the [blog post](https://astral.sh/blog/ruff-v0.7.0) for more details. +- The `useless-try-except` rule (in our `tryceratops` category) has been recoded from `TRY302` to + `TRY203` ([#13502](https://github.com/astral-sh/ruff/pull/13502)). This ensures Ruff's code is consistent with + the same rule in the [`tryceratops`](https://github.com/guilatrova/tryceratops) linter. +- The `lint.allow-unused-imports` setting has been removed ([#13677](https://github.com/astral-sh/ruff/pull/13677)). Use + [`lint.pyflakes.allow-unused-imports`](https://docs.astral.sh/ruff/settings/#lint_pyflakes_allowed-unused-imports) + instead. + ## 0.6.0 - Detect imports in `src` layouts by default for `isort` rules ([#12848](https://github.com/astral-sh/ruff/pull/12848)) diff --git a/CHANGELOG.md b/CHANGELOG.md index ccbe91404e54e..67858c0b65c90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## 0.7.1 + +### Preview features + +- Fix `E221` and `E222` to flag missing or extra whitespace around `==` operator ([#13890](https://github.com/astral-sh/ruff/pull/13890)) +- Formatter: Alternate quotes for strings inside f-strings in preview ([#13860](https://github.com/astral-sh/ruff/pull/13860)) +- Formatter: Join implicit concatenated strings when they fit on a line ([#13663](https://github.com/astral-sh/ruff/pull/13663)) +- \[`pylint`\] Restrict `iteration-over-set` to only work on sets of literals (`PLC0208`) ([#13731](https://github.com/astral-sh/ruff/pull/13731)) + +### Rule changes + +- \[`flake8-type-checking`\] Support auto-quoting when annotations contain quotes ([#11811](https://github.com/astral-sh/ruff/pull/11811)) + +### Server + +- Avoid indexing the workspace for single-file mode ([#13770](https://github.com/astral-sh/ruff/pull/13770)) + +### Bug fixes + +- Make `ARG002` compatible with `EM101` when raising `NotImplementedError` ([#13714](https://github.com/astral-sh/ruff/pull/13714)) + +### Other changes + +- Introduce more Docker tags for Ruff (similar to uv) ([#13274](https://github.com/astral-sh/ruff/pull/13274)) + ## 0.7.0 Check out the [blog post](https://astral.sh/blog/ruff-v0.7.0) for a migration guide and overview of the changes! diff --git a/Cargo.lock b/Cargo.lock index 0241301062db4..0e488abec5540 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -69,7 +69,7 @@ version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccaf7e9dfbb6ab22c82e473cd1a8a7bd313c19a5b7e40970f3d89ef5a5c9e81e" dependencies = [ - "unicode-width", + "unicode-width 0.1.13", "yansi-term", ] @@ -123,9 +123,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.89" +version = "1.0.90" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86fdf8605db99b54d3cd748a44c6d04df638eb5dafb219b135d0149bd0db01f6" +checksum = "37bf3594c4c988a53154954629820791dde498571819ae4ca50ca811e060cc95" [[package]] name = "append-only-vec" @@ -407,7 +407,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.82", ] [[package]] @@ -491,7 +491,7 @@ dependencies = [ "encode_unicode", "lazy_static", "libc", - "unicode-width", + "unicode-width 0.1.13", "windows-sys 0.52.0", ] @@ -687,7 +687,7 @@ dependencies = [ "proc-macro2", "quote", "strsim 0.10.0", - "syn", + "syn 2.0.82", ] [[package]] @@ -698,7 +698,7 @@ checksum = "a668eda54683121533a393014d8692171709ff57a7d61f187b6e782719f8933f" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.82", ] [[package]] @@ -750,6 +750,27 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dir-test" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c44bdf9319ad5223afb7eb15a7110452b0adf0373ea6756561b2c708eef0dd1" +dependencies = [ + "dir-test-macros", +] + +[[package]] +name = "dir-test-macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "644f96047137dfaa7a09e34d4623f9e52a1926ecc25ba32ad2ba3fc422536b25" +dependencies = [ + "glob", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "dirs" version = "4.0.0" @@ -879,9 +900,9 @@ checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6" [[package]] name = "fern" -version = "0.6.2" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9f0c14694cbd524c8720dd69b0e3179344f04ebb5f90f2e4a440c6ea3b2f1ee" +checksum = "69ff9c9d5fb3e6da8ac2f77ab76fe7e8087d512ce095200f8f29ac5b656cf6dc" dependencies = [ "log", ] @@ -957,7 +978,7 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5" dependencies = [ - "unicode-width", + "unicode-width 0.1.13", ] [[package]] @@ -1160,7 +1181,7 @@ dependencies = [ "instant", "number_prefix", "portable-atomic", - "unicode-width", + "unicode-width 0.1.13", "vt100", ] @@ -1246,7 +1267,7 @@ dependencies = [ "Inflector", "proc-macro2", "quote", - "syn", + "syn 2.0.82", ] [[package]] @@ -1346,9 +1367,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "libc" -version = "0.2.159" +version = "0.2.161" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "561d97a539a36e26a9a5fad1ea11a3039a67714694aaa379433e580854bc3dc5" +checksum = "8e9489c2807c139ffd9c1794f4af0ebe86a828db53ecdc7fea2111d0fed085d1" [[package]] name = "libcst" @@ -1372,7 +1393,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2ae40017ac09cd2c6a53504cb3c871c7f2b41466eac5bc66ba63f39073b467b" dependencies = [ "quote", - "syn", + "syn 2.0.82", ] [[package]] @@ -1760,18 +1781,17 @@ dependencies = [ "once_cell", "regex", "serde", - "unicode-width", + "unicode-width 0.1.13", ] [[package]] name = "pep440_rs" -version = "0.6.6" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "466eada3179c2e069ca897b99006cbb33f816290eaeec62464eea907e22ae385" +checksum = "7c8ee724d21f351f9d47276614ac9710975db827ba9fe2ca5a517ba648193307" dependencies = [ - "once_cell", "serde", - "unicode-width", + "unicode-width 0.2.0", "unscanny", ] @@ -1787,7 +1807,7 @@ dependencies = [ "serde", "thiserror", "tracing", - "unicode-width", + "unicode-width 0.1.13", "url", ] @@ -1828,7 +1848,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn", + "syn 2.0.82", ] [[package]] @@ -1943,9 +1963,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.87" +version = "1.0.88" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3e4daa0dcf6feba26f985457cdf104d4b4256fc5a09547140f3631bb076b19a" +checksum = "7c3a7fc5db1e57d5a779a352c8cdb57b29aa4c40cc69c3a68a7fedc815fbf2f9" dependencies = [ "unicode-ident", ] @@ -2080,6 +2100,7 @@ dependencies = [ "camino", "compact_str", "countme", + "dir-test", "hashbrown 0.15.0", "insta", "itertools 0.13.0", @@ -2087,7 +2108,6 @@ dependencies = [ "ordermap", "red_knot_test", "red_knot_vendored", - "rstest", "ruff_db", "ruff_index", "ruff_python_ast", @@ -2136,7 +2156,7 @@ version = "0.0.0" dependencies = [ "anyhow", "colored", - "once_cell", + "memchr", "red_knot_python_semantic", "red_knot_vendored", "regex", @@ -2154,7 +2174,6 @@ dependencies = [ name = "red_knot_vendored" version = "0.0.0" dependencies = [ - "once_cell", "path-slash", "ruff_db", "walkdir", @@ -2270,12 +2289,6 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" -[[package]] -name = "relative-path" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" - [[package]] name = "ring" version = "0.17.8" @@ -2291,36 +2304,9 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rstest" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b423f0e62bdd61734b67cd21ff50871dfaeb9cc74f869dcd6af974fbcb19936" -dependencies = [ - "rstest_macros", - "rustc_version", -] - -[[package]] -name = "rstest_macros" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e1711e7d14f74b12a58411c542185ef7fb7f2e7f8ee6e2940a883628522b42" -dependencies = [ - "cfg-if", - "glob", - "proc-macro2", - "quote", - "regex", - "relative-path", - "rustc_version", - "syn", - "unicode-ident", -] - [[package]] name = "ruff" -version = "0.7.0" +version = "0.7.1" dependencies = [ "anyhow", "argfile", @@ -2382,7 +2368,6 @@ dependencies = [ "codspeed-criterion-compat", "criterion", "mimalloc", - "once_cell", "rayon", "red_knot_python_semantic", "red_knot_workspace", @@ -2506,7 +2491,7 @@ dependencies = [ "serde", "static_assertions", "tracing", - "unicode-width", + "unicode-width 0.1.13", ] [[package]] @@ -2515,7 +2500,6 @@ version = "0.1.0" dependencies = [ "anyhow", "clap", - "once_cell", "red_knot_python_semantic", "ruff_cache", "ruff_db", @@ -2539,7 +2523,7 @@ dependencies = [ [[package]] name = "ruff_linter" -version = "0.7.0" +version = "0.7.1" dependencies = [ "aho-corasick", "annotate-snippets 0.9.2", @@ -2560,10 +2544,9 @@ dependencies = [ "log", "memchr", "natord", - "once_cell", "path-absolutize", "pathdiff", - "pep440_rs 0.6.6", + "pep440_rs 0.7.1", "pyproject-toml", "quick-junit", "regex", @@ -2594,7 +2577,7 @@ dependencies = [ "toml", "typed-arena", "unicode-normalization", - "unicode-width", + "unicode-width 0.1.13", "unicode_names2", "url", ] @@ -2607,7 +2590,7 @@ dependencies = [ "proc-macro2", "quote", "ruff_python_trivia", - "syn", + "syn 2.0.82", ] [[package]] @@ -2616,7 +2599,6 @@ version = "0.0.0" dependencies = [ "anyhow", "itertools 0.13.0", - "once_cell", "rand", "ruff_diagnostics", "ruff_source_file", @@ -2638,7 +2620,6 @@ dependencies = [ "compact_str", "is-macro", "itertools 0.13.0", - "once_cell", "ruff_cache", "ruff_macros", "ruff_python_trivia", @@ -2664,7 +2645,6 @@ dependencies = [ name = "ruff_python_codegen" version = "0.0.0" dependencies = [ - "once_cell", "ruff_python_ast", "ruff_python_literal", "ruff_python_parser", @@ -2682,7 +2662,6 @@ dependencies = [ "insta", "itertools 0.13.0", "memchr", - "once_cell", "regex", "ruff_cache", "ruff_formatter", @@ -2843,7 +2822,6 @@ name = "ruff_source_file" version = "0.0.0" dependencies = [ "memchr", - "once_cell", "ruff_text_size", "serde", ] @@ -2860,7 +2838,7 @@ dependencies = [ [[package]] name = "ruff_wasm" -version = "0.7.0" +version = "0.7.1" dependencies = [ "console_error_panic_hook", "console_log", @@ -2899,7 +2877,7 @@ dependencies = [ "matchit", "path-absolutize", "path-slash", - "pep440_rs 0.6.6", + "pep440_rs 0.7.1", "regex", "ruff_cache", "ruff_formatter", @@ -2941,15 +2919,6 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "583034fd73374156e66797ed8e5b0d5690409c9226b22d87cb7f19821c05d152" -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - [[package]] name = "rustix" version = "0.38.37" @@ -3010,7 +2979,7 @@ checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1" [[package]] name = "salsa" version = "0.18.0" -source = "git+https://github.com/salsa-rs/salsa.git?rev=b14be5c0392f4c55eca60b92e457a35549372382#b14be5c0392f4c55eca60b92e457a35549372382" +source = "git+https://github.com/salsa-rs/salsa.git?rev=254c749b02cde2fd29852a7463a33e800b771758#254c749b02cde2fd29852a7463a33e800b771758" dependencies = [ "append-only-vec", "arc-swap", @@ -3030,17 +2999,17 @@ dependencies = [ [[package]] name = "salsa-macro-rules" version = "0.1.0" -source = "git+https://github.com/salsa-rs/salsa.git?rev=b14be5c0392f4c55eca60b92e457a35549372382#b14be5c0392f4c55eca60b92e457a35549372382" +source = "git+https://github.com/salsa-rs/salsa.git?rev=254c749b02cde2fd29852a7463a33e800b771758#254c749b02cde2fd29852a7463a33e800b771758" [[package]] name = "salsa-macros" version = "0.18.0" -source = "git+https://github.com/salsa-rs/salsa.git?rev=b14be5c0392f4c55eca60b92e457a35549372382#b14be5c0392f4c55eca60b92e457a35549372382" +source = "git+https://github.com/salsa-rs/salsa.git?rev=254c749b02cde2fd29852a7463a33e800b771758#254c749b02cde2fd29852a7463a33e800b771758" dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.82", "synstructure", ] @@ -3074,7 +3043,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn", + "syn 2.0.82", ] [[package]] @@ -3095,12 +3064,6 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" -[[package]] -name = "semver" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" - [[package]] name = "serde" version = "1.0.210" @@ -3129,7 +3092,7 @@ checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.82", ] [[package]] @@ -3140,14 +3103,14 @@ checksum = "330f01ce65a3a5fe59a60c82f3c9a024b573b8a6e875bd233fe5f934e71d54e3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.82", ] [[package]] name = "serde_json" -version = "1.0.128" +version = "1.0.132" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8" +checksum = "d726bfaff4b320266d395898905d0eba0345aae23b54aee3a737e260fd46db03" dependencies = [ "itoa", "memchr", @@ -3163,7 +3126,7 @@ checksum = "6c64451ba24fc7a6a2d60fc75dd9c83c90903b19028d4eff35e88fc1e86564e9" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.82", ] [[package]] @@ -3204,7 +3167,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn", + "syn 2.0.82", ] [[package]] @@ -3306,7 +3269,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn", + "syn 2.0.82", ] [[package]] @@ -3317,9 +3280,20 @@ checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" [[package]] name = "syn" -version = "2.0.79" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.82" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89132cd0bf050864e1d38dc3bbc07a0eb8e7530af26344d3d2bbbef83499f590" +checksum = "83540f837a8afc019423a8edb95b52a8effe46957ee402287f4292fae35be021" dependencies = [ "proc-macro2", "quote", @@ -3334,7 +3308,7 @@ checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.82", ] [[package]] @@ -3397,7 +3371,7 @@ dependencies = [ "cfg-if", "proc-macro2", "quote", - "syn", + "syn 2.0.82", ] [[package]] @@ -3408,7 +3382,7 @@ checksum = "5c89e72a01ed4c579669add59014b9a524d609c0c88c6a585ce37485879f6ffb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.82", "test-case-core", ] @@ -3429,7 +3403,7 @@ checksum = "08904e7672f5eb876eaaf87e0ce17857500934f4981c4a0ab2b4aa98baac7fc3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.82", ] [[package]] @@ -3541,7 +3515,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.82", ] [[package]] @@ -3705,6 +3679,12 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0336d538f7abc86d282a4189614dfaa90810dfc2c6f6427eaf88e16311dd225d" +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "unicode_names2" version = "1.3.0" @@ -3775,9 +3755,9 @@ checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" [[package]] name = "uuid" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81dfa00651efa65069b0b6b651f4aaa31ba9e3c3ce0137aaad053604ee7e0314" +checksum = "f8c5f0a0af699448548ad1a2fbf920fb4bee257eae39953ba95cb84891a0446a" dependencies = [ "getrandom", "rand", @@ -3787,13 +3767,13 @@ dependencies = [ [[package]] name = "uuid-macro-internal" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee1cd046f83ea2c4e920d6ee9f7c3537ef928d75dce5d84a87c2c5d6b3999a3a" +checksum = "6b91f57fe13a38d0ce9e28a03463d8d3c2468ed03d75375110ec71d93b449a08" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.82", ] [[package]] @@ -3816,7 +3796,7 @@ checksum = "84cd863bf0db7e392ba3bd04994be3473491b31e66340672af5d11943c6274de" dependencies = [ "itoa", "log", - "unicode-width", + "unicode-width 0.1.13", "vte", ] @@ -3879,7 +3859,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn", + "syn 2.0.82", "wasm-bindgen-shared", ] @@ -3913,7 +3893,7 @@ checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.82", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -3947,7 +3927,7 @@ checksum = "c97b2ef2c8d627381e51c071c2ab328eac606d3f69dd82bcbca20a9e389d95f0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.82", ] [[package]] @@ -4235,7 +4215,7 @@ checksum = "9ce1b18ccd8e73a9321186f97e46f9f04b778851177567b1975109d26a08d2a6" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.82", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 57c0712e482ff..681a20eb9ed46 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ resolver = "2" [workspace.package] edition = "2021" -rust-version = "1.76" +rust-version = "1.80" homepage = "https://docs.astral.sh/ruff" documentation = "https://docs.astral.sh/ruff" repository = "https://github.com/astral-sh/ruff" @@ -65,10 +65,11 @@ compact_str = "0.8.0" criterion = { version = "0.5.1", default-features = false } crossbeam = { version = "0.8.4" } dashmap = { version = "6.0.1" } +dir-test = { version = "0.3.0" } drop_bomb = { version = "0.1.5" } env_logger = { version = "0.11.0" } etcetera = { version = "0.8.0" } -fern = { version = "0.6.1" } +fern = { version = "0.7.0" } filetime = { version = "0.2.23" } glob = { version = "0.3.1" } globset = { version = "0.4.14" } @@ -101,12 +102,11 @@ memchr = { version = "2.7.1" } mimalloc = { version = "0.1.39" } natord = { version = "1.0.9" } notify = { version = "6.1.1" } -once_cell = { version = "1.19.0" } ordermap = { version = "0.5.0" } path-absolutize = { version = "3.1.1" } path-slash = { version = "0.2.1" } pathdiff = { version = "0.2.1" } -pep440_rs = { version = "0.6.0", features = ["serde"] } +pep440_rs = { version = "0.7.1" } pretty_assertions = "1.3.0" proc-macro2 = { version = "1.0.79" } pyproject-toml = { version = "0.9.0" } @@ -115,9 +115,8 @@ quote = { version = "1.0.23" } rand = { version = "0.8.5" } rayon = { version = "1.10.0" } regex = { version = "1.10.2" } -rstest = { version = "0.22.0", default-features = false } rustc-hash = { version = "2.0.0" } -salsa = { git = "https://github.com/salsa-rs/salsa.git", rev = "b14be5c0392f4c55eca60b92e457a35549372382" } +salsa = { git = "https://github.com/salsa-rs/salsa.git", rev = "254c749b02cde2fd29852a7463a33e800b771758" } schemars = { version = "0.8.16" } seahash = { version = "4.1.0" } serde = { version = "1.0.197", features = ["derive"] } @@ -203,6 +202,10 @@ get_unwrap = "warn" rc_buffer = "warn" rc_mutex = "warn" rest_pat_in_fully_bound_structs = "warn" +# nursery rules +redundant_clone = "warn" +debug_assert_with_mut_call = "warn" +unused_peekable = "warn" [profile.release] # Note that we set these explicitly, and these values diff --git a/Dockerfile b/Dockerfile index 018367b341a24..85fe0605a8f43 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM --platform=$BUILDPLATFORM ubuntu as build +FROM --platform=$BUILDPLATFORM ubuntu AS build ENV HOME="/root" WORKDIR $HOME diff --git a/README.md b/README.md index 778dc1ce7e06f..2fbde4df169b8 100644 --- a/README.md +++ b/README.md @@ -136,8 +136,8 @@ curl -LsSf https://astral.sh/ruff/install.sh | sh powershell -c "irm https://astral.sh/ruff/install.ps1 | iex" # For a specific version. -curl -LsSf https://astral.sh/ruff/0.7.0/install.sh | sh -powershell -c "irm https://astral.sh/ruff/0.7.0/install.ps1 | iex" +curl -LsSf https://astral.sh/ruff/0.7.1/install.sh | sh +powershell -c "irm https://astral.sh/ruff/0.7.1/install.ps1 | iex" ``` You can also install Ruff via [Homebrew](https://formulae.brew.sh/formula/ruff), [Conda](https://anaconda.org/conda-forge/ruff), @@ -170,7 +170,7 @@ Ruff can also be used as a [pre-commit](https://pre-commit.com/) hook via [`ruff ```yaml - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.7.0 + rev: v0.7.1 hooks: # Run the linter. - id: ruff diff --git a/crates/red_knot/src/main.rs b/crates/red_knot/src/main.rs index 2dd24e5a6af95..2244e5df2d4f3 100644 --- a/crates/red_knot/src/main.rs +++ b/crates/red_knot/src/main.rs @@ -144,7 +144,7 @@ pub fn main() -> ExitStatus { } fn run() -> anyhow::Result { - let args = Args::parse_from(std::env::args().collect::>()); + let args = Args::parse_from(std::env::args()); if matches!(args.command, Some(Command::Server)) { return run_server().map(|()| ExitStatus::Success); diff --git a/crates/red_knot_python_semantic/Cargo.toml b/crates/red_knot_python_semantic/Cargo.toml index 7a74f5c307043..27b7684544ec8 100644 --- a/crates/red_knot_python_semantic/Cargo.toml +++ b/crates/red_knot_python_semantic/Cargo.toml @@ -43,8 +43,8 @@ red_knot_test = { workspace = true } red_knot_vendored = { workspace = true } anyhow = { workspace = true } +dir-test = {workspace = true} insta = { workspace = true } -rstest = { workspace = true } tempfile = { workspace = true } [lints] diff --git a/crates/red_knot_python_semantic/build.rs b/crates/red_knot_python_semantic/build.rs index 10815f6a89199..d9c90fc60b3b6 100644 --- a/crates/red_knot_python_semantic/build.rs +++ b/crates/red_knot_python_semantic/build.rs @@ -1,4 +1,4 @@ /// Rebuild the crate if a test file is added or removed from pub fn main() { - println!("cargo:rerun-if-changed=resources/mdtest"); + println!("cargo::rerun-if-changed=resources/mdtest"); } diff --git a/crates/red_knot_python_semantic/resources/mdtest/assignment/annotations.md b/crates/red_knot_python_semantic/resources/mdtest/assignment/annotations.md index 1e912f7f248b3..923fca96ac53b 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/assignment/annotations.md +++ b/crates/red_knot_python_semantic/resources/mdtest/assignment/annotations.md @@ -22,3 +22,13 @@ x: int = "foo" # error: [invalid-assignment] "Object of type `Literal["foo"]` i x: int x = "foo" # error: [invalid-assignment] "Object of type `Literal["foo"]` is not assignable to `int`" ``` + +## PEP-604 annotations not yet supported + +```py +def f() -> str | None: + return None + +# TODO: should be `str | None` (but Todo is better than `Unknown`) +reveal_type(f()) # revealed: @Todo +``` diff --git a/crates/red_knot_python_semantic/resources/mdtest/assignment/unbound.md b/crates/red_knot_python_semantic/resources/mdtest/assignment/unbound.md index 0a71ad55f2b21..980dd4b510e3d 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/assignment/unbound.md +++ b/crates/red_knot_python_semantic/resources/mdtest/assignment/unbound.md @@ -3,9 +3,12 @@ ## Unbound ```py -x = foo +x = foo # error: [unresolved-reference] "Name `foo` used when not defined" foo = 1 -reveal_type(x) # revealed: Unbound + +# error: [unresolved-reference] +# revealed: Unbound +reveal_type(x) ``` ## Unbound class variable @@ -13,15 +16,17 @@ reveal_type(x) # revealed: Unbound Name lookups within a class scope fall back to globals, but lookups of class attributes don't. ```py -x = 1 +def bool_instance() -> bool: + return True +flag = bool_instance() +x = 1 class C: y = x if flag: x = 2 - reveal_type(C.x) # revealed: Literal[2] reveal_type(C.y) # revealed: Literal[1] ``` diff --git a/crates/red_knot_python_semantic/resources/mdtest/attributes.md b/crates/red_knot_python_semantic/resources/mdtest/attributes.md index a5ee9bf01cf0f..3278a7e48a2d7 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/attributes.md +++ b/crates/red_knot_python_semantic/resources/mdtest/attributes.md @@ -3,16 +3,18 @@ ## Union of attributes ```py -if flag: +def bool_instance() -> bool: + return True + +flag = bool_instance() +if flag: class C: x = 1 else: - class C: x = 2 - reveal_type(C.x) # revealed: Literal[1, 2] ``` diff --git a/crates/red_knot_python_semantic/resources/mdtest/binary/booleans.md b/crates/red_knot_python_semantic/resources/mdtest/binary/booleans.md new file mode 100644 index 0000000000000..7184c1a631b4d --- /dev/null +++ b/crates/red_knot_python_semantic/resources/mdtest/binary/booleans.md @@ -0,0 +1,48 @@ +## Binary operations on booleans + +## Basic Arithmetic + +We try to be precise and all operations except for division will result in Literal type. + +```py +a = True +b = False + +reveal_type(a + a) # revealed: Literal[2] +reveal_type(a + b) # revealed: Literal[1] +reveal_type(b + a) # revealed: Literal[1] +reveal_type(b + b) # revealed: Literal[0] + +reveal_type(a - a) # revealed: Literal[0] +reveal_type(a - b) # revealed: Literal[1] +reveal_type(b - a) # revealed: Literal[-1] +reveal_type(b - b) # revealed: Literal[0] + +reveal_type(a * a) # revealed: Literal[1] +reveal_type(a * b) # revealed: Literal[0] +reveal_type(b * a) # revealed: Literal[0] +reveal_type(b * b) # revealed: Literal[0] + +reveal_type(a % a) # revealed: Literal[0] +reveal_type(b % a) # revealed: Literal[0] + +reveal_type(a // a) # revealed: Literal[1] +reveal_type(b // a) # revealed: Literal[0] + +reveal_type(a**a) # revealed: Literal[1] +reveal_type(a**b) # revealed: Literal[1] +reveal_type(b**a) # revealed: Literal[0] +reveal_type(b**b) # revealed: Literal[1] + +# Division +reveal_type(a / a) # revealed: float +reveal_type(b / a) # revealed: float +b / b # error: [division-by-zero] "Cannot divide object of type `Literal[False]` by zero" +a / b # error: [division-by-zero] "Cannot divide object of type `Literal[True]` by zero" + +# bitwise OR +reveal_type(a | a) # revealed: Literal[True] +reveal_type(a | b) # revealed: Literal[True] +reveal_type(b | a) # revealed: Literal[True] +reveal_type(b | b) # revealed: Literal[False] +``` diff --git a/crates/red_knot_python_semantic/resources/mdtest/binary/instances.md b/crates/red_knot_python_semantic/resources/mdtest/binary/instances.md new file mode 100644 index 0000000000000..9375f197c0be3 --- /dev/null +++ b/crates/red_knot_python_semantic/resources/mdtest/binary/instances.md @@ -0,0 +1,447 @@ +# Binary operations on instances + +Binary operations in Python are implemented by means of magic double-underscore methods. + +For references, see: + +- +- + +## Operations + +We support inference for all Python's binary operators: +`+`, `-`, `*`, `@`, `/`, `//`, `%`, `**`, `<<`, `>>`, `&`, `^`, and `|`. + +```py +class A: + def __add__(self, other) -> A: + return self + + def __sub__(self, other) -> A: + return self + + def __mul__(self, other) -> A: + return self + + def __matmul__(self, other) -> A: + return self + + def __truediv__(self, other) -> A: + return self + + def __floordiv__(self, other) -> A: + return self + + def __mod__(self, other) -> A: + return self + + def __pow__(self, other) -> A: + return self + + def __lshift__(self, other) -> A: + return self + + def __rshift__(self, other) -> A: + return self + + def __and__(self, other) -> A: + return self + + def __xor__(self, other) -> A: + return self + + def __or__(self, other) -> A: + return self + +class B: ... + +reveal_type(A() + B()) # revealed: A +reveal_type(A() - B()) # revealed: A +reveal_type(A() * B()) # revealed: A +reveal_type(A() @ B()) # revealed: A +reveal_type(A() / B()) # revealed: A +reveal_type(A() // B()) # revealed: A +reveal_type(A() % B()) # revealed: A +reveal_type(A() ** B()) # revealed: A +reveal_type(A() << B()) # revealed: A +reveal_type(A() >> B()) # revealed: A +reveal_type(A() & B()) # revealed: A +reveal_type(A() ^ B()) # revealed: A +reveal_type(A() | B()) # revealed: A +``` + +## Reflected + +We also support inference for reflected operations: + +```py +class A: + def __radd__(self, other) -> A: + return self + + def __rsub__(self, other) -> A: + return self + + def __rmul__(self, other) -> A: + return self + + def __rmatmul__(self, other) -> A: + return self + + def __rtruediv__(self, other) -> A: + return self + + def __rfloordiv__(self, other) -> A: + return self + + def __rmod__(self, other) -> A: + return self + + def __rpow__(self, other) -> A: + return self + + def __rlshift__(self, other) -> A: + return self + + def __rrshift__(self, other) -> A: + return self + + def __rand__(self, other) -> A: + return self + + def __rxor__(self, other) -> A: + return self + + def __ror__(self, other) -> A: + return self + +class B: ... + +reveal_type(B() + A()) # revealed: A +reveal_type(B() - A()) # revealed: A +reveal_type(B() * A()) # revealed: A +reveal_type(B() @ A()) # revealed: A +reveal_type(B() / A()) # revealed: A +reveal_type(B() // A()) # revealed: A +reveal_type(B() % A()) # revealed: A +reveal_type(B() ** A()) # revealed: A +reveal_type(B() << A()) # revealed: A +reveal_type(B() >> A()) # revealed: A +reveal_type(B() & A()) # revealed: A +reveal_type(B() ^ A()) # revealed: A +reveal_type(B() | A()) # revealed: A +``` + +## Returning a different type + +The magic methods aren't required to return the type of `self`: + +```py +class A: + def __add__(self, other) -> int: + return 1 + + def __rsub__(self, other) -> int: + return 1 + +class B: ... + +reveal_type(A() + B()) # revealed: int +reveal_type(B() - A()) # revealed: int +``` + +## Non-reflected precedence in general + +In general, if the left-hand side defines `__add__` and the right-hand side +defines `__radd__` and the right-hand side is not a subtype of the left-hand +side, `lhs.__add__` will take precedence: + +```py +class A: + def __add__(self, other: B) -> int: + return 42 + +class B: + def __radd__(self, other: A) -> str: + return "foo" + +reveal_type(A() + B()) # revealed: int + +# Edge case: C is a subtype of C, *but* if the two sides are of *equal* types, +# the lhs *still* takes precedence +class C: + def __add__(self, other: C) -> int: + return 42 + + def __radd__(self, other: C) -> str: + return "foo" + +reveal_type(C() + C()) # revealed: int +``` + +## Reflected precedence for subtypes (in some cases) + +If the right-hand operand is a subtype of the left-hand operand and has a +different implementation of the reflected method, the reflected method on the +right-hand operand takes precedence. + +```py +class A: + def __add__(self, other) -> str: + return "foo" + + def __radd__(self, other) -> str: + return "foo" + +class MyString(str): ... + +class B(A): + def __radd__(self, other) -> MyString: + return MyString() + +reveal_type(A() + B()) # revealed: MyString + +# N.B. Still a subtype of `A`, even though `A` does not appear directly in the class's `__bases__` +class C(B): ... + +# TODO: we currently only understand direct subclasses as subtypes of the superclass. +# We need to iterate through the full MRO rather than just the class's bases; +# if we do, we'll understand `C` as a subtype of `A`, and correctly understand this as being +# `MyString` rather than `str` +reveal_type(A() + C()) # revealed: str +``` + +## Reflected precedence 2 + +If the right-hand operand is a subtype of the left-hand operand, but does not +override the reflected method, the left-hand operand's non-reflected method +still takes precedence: + +```py +class A: + def __add__(self, other) -> str: + return "foo" + + def __radd__(self, other) -> int: + return 42 + +class B(A): ... + +reveal_type(A() + B()) # revealed: str +``` + +## Only reflected supported + +For example, at runtime, `(1).__add__(1.2)` is `NotImplemented`, but +`(1.2).__radd__(1) == 2.2`, meaning that `1 + 1.2` succeeds at runtime +(producing `2.2`). The runtime tries the second one only if the first one +returns `NotImplemented` to signal failure. + +Typeshed and other stubs annotate dunder-method calls that would return +`NotImplemented` as being "illegal" calls. `int.__add__` is annotated as only +"accepting" `int`s, even though it strictly-speaking "accepts" any other object +without raising an exception -- it will simply return `NotImplemented`, +allowing the runtime to try the `__radd__` method of the right-hand operand +as well. + +```py +class A: + def __sub__(self, other: A) -> A: + return A() + +class B: + def __rsub__(self, other: A) -> B: + return B() + +# TODO: this should be `B` (the return annotation of `B.__rsub__`), +# because `A.__sub__` is annotated as only accepting `A`, +# but `B.__rsub__` will accept `A`. +reveal_type(A() - B()) # revealed: A +``` + +## Callable instances as dunders + +Believe it or not, this is supported at runtime: + +```py +class A: + def __call__(self, other) -> int: + return 42 + +class B: + __add__ = A() + +reveal_type(B() + B()) # revealed: int +``` + +## Integration test: numbers from typeshed + +```py +reveal_type(3j + 3.14) # revealed: complex +reveal_type(4.2 + 42) # revealed: float +reveal_type(3j + 3) # revealed: complex + +# TODO should be complex, need to check arg type and fall back to `rhs.__radd__` +reveal_type(3.14 + 3j) # revealed: float + +# TODO should be float, need to check arg type and fall back to `rhs.__radd__` +reveal_type(42 + 4.2) # revealed: int + +# TODO should be complex, need to check arg type and fall back to `rhs.__radd__` +reveal_type(3 + 3j) # revealed: int + +def returns_int() -> int: + return 42 + +def returns_bool() -> bool: + return True + +x = returns_bool() +y = returns_int() + +reveal_type(x + y) # revealed: int +reveal_type(4.2 + x) # revealed: float + +# TODO should be float, need to check arg type and fall back to `rhs.__radd__` +reveal_type(y + 4.12) # revealed: int +``` + +## With literal types + +When we have a literal type for one operand, we're able to fall back to the +instance handling for its instance super-type. + +```py +class A: + def __add__(self, other) -> A: + return self + + def __radd__(self, other) -> A: + return self + +reveal_type(A() + 1) # revealed: A +# TODO should be `A` since `int.__add__` doesn't support `A` instances +reveal_type(1 + A()) # revealed: int + +reveal_type(A() + "foo") # revealed: A +# TODO should be `A` since `str.__add__` doesn't support `A` instances +# TODO overloads +reveal_type("foo" + A()) # revealed: @Todo + +reveal_type(A() + b"foo") # revealed: A +# TODO should be `A` since `bytes.__add__` doesn't support `A` instances +reveal_type(b"foo" + A()) # revealed: bytes + +reveal_type(A() + ()) # revealed: A +# TODO this should be `A`, since `tuple.__add__` doesn't support `A` instances +reveal_type(() + A()) # revealed: @Todo + +literal_string_instance = "foo" * 1_000_000_000 +# the test is not testing what it's meant to be testing if this isn't a `LiteralString`: +reveal_type(literal_string_instance) # revealed: LiteralString + +reveal_type(A() + literal_string_instance) # revealed: A +# TODO should be `A` since `str.__add__` doesn't support `A` instances +# TODO overloads +reveal_type(literal_string_instance + A()) # revealed: @Todo +``` + +## Operations involving instances of classes inheriting from `Any` + +`Any` and `Unknown` represent a set of possible runtime objects, wherein the +bounds of the set are unknown. Whether the left-hand operand's dunder or the +right-hand operand's reflected dunder depends on whether the right-hand operand +is an instance of a class that is a subclass of the left-hand operand's class +and overrides the reflected dunder. In the following example, because of the +unknowable nature of `Any`/`Unknown`, we must consider both possibilities: +`Any`/`Unknown` might resolve to an unknown third class that inherits from `X` +and overrides `__radd__`; but it also might not. Thus, the correct answer here +for the `reveal_type` is `int | Unknown`. + +```py +from does_not_exist import Foo # error: [unresolved-import] + +reveal_type(Foo) # revealed: Unknown + +class X: + def __add__(self, other: object) -> int: + return 42 + +class Y(Foo): ... + +# TODO: Should be `int | Unknown`; see above discussion. +reveal_type(X() + Y()) # revealed: int +``` + +## Unsupported + +### Dunder as instance attribute + +The magic method must exist on the class, not just on the instance: + +```py +def add_impl(self, other) -> int: + return 1 + +class A: + def __init__(self): + self.__add__ = add_impl + +# error: [unsupported-operator] "Operator `+` is unsupported between objects of type `A` and `A`" +# revealed: Unknown +reveal_type(A() + A()) +``` + +### Missing dunder + +```py +class A: ... + +# error: [unsupported-operator] +# revealed: Unknown +reveal_type(A() + A()) +``` + +### Wrong position + +A left-hand dunder method doesn't apply for the right-hand operand, or vice versa: + +```py +class A: + def __add__(self, other) -> int: ... + +class B: + def __radd__(self, other) -> int: ... + +class C: ... + +# error: [unsupported-operator] +# revealed: Unknown +reveal_type(C() + A()) + +# error: [unsupported-operator] +# revealed: Unknown +reveal_type(B() + C()) +``` + +### Reflected dunder is not tried between two objects of the same type + +For the specific case where the left-hand operand is the exact same type as the +right-hand operand, the reflected dunder of the right-hand operand is not +tried; the runtime short-circuits after trying the unreflected dunder of the +left-hand operand. For context, see +[this mailing list discussion](https://mail.python.org/archives/list/python-dev@python.org/thread/7NZUCODEAPQFMRFXYRMGJXDSIS3WJYIV/). + +```py +class Foo: + def __radd__(self, other: Foo) -> Foo: + return self + +# error: [unsupported-operator] +# revealed: Unknown +reveal_type(Foo() + Foo()) +``` + +### Wrong type + +TODO: check signature and error if `other` is the wrong type diff --git a/crates/red_knot_python_semantic/resources/mdtest/binary/integers.md b/crates/red_knot_python_semantic/resources/mdtest/binary/integers.md index 746e8d7f4a83f..0f4fe2d1fbdfc 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/binary/integers.md +++ b/crates/red_knot_python_semantic/resources/mdtest/binary/integers.md @@ -11,6 +11,18 @@ reveal_type(-3 / 3) # revealed: float reveal_type(5 % 3) # revealed: Literal[2] ``` +## Power + +For power if the result fits in the int literal type it will be a Literal type. Otherwise the +outcome is int. + +```py +largest_u32 = 4_294_967_295 +reveal_type(2**2) # revealed: Literal[4] +reveal_type(1 ** (largest_u32 + 1)) # revealed: int +reveal_type(2**largest_u32) # revealed: int +``` + ## Division by Zero This error is really outside the current Python type system, because e.g. `int.__truediv__` and @@ -34,19 +46,25 @@ reveal_type(b) # revealed: int c = 3 % 0 # error: "Cannot reduce object of type `Literal[3]` modulo zero" reveal_type(c) # revealed: int -d = int() / 0 # error: "Cannot divide object of type `int` by zero" -# TODO should be int -reveal_type(d) # revealed: @Todo +# error: "Cannot divide object of type `int` by zero" +# revealed: float +reveal_type(int() / 0) -e = 1.0 / 0 # error: "Cannot divide object of type `float` by zero" -# TODO should be float -reveal_type(e) # revealed: @Todo +# error: "Cannot divide object of type `Literal[1]` by zero" +# revealed: float +reveal_type(1 / False) +# error: [division-by-zero] "Cannot divide object of type `Literal[True]` by zero" +True / False +# error: [division-by-zero] "Cannot divide object of type `Literal[True]` by zero" +bool(1) / False +# error: "Cannot divide object of type `float` by zero" +# revealed: float +reveal_type(1.0 / 0) class MyInt(int): ... - # No error for a subclass of int -# TODO should be float -reveal_type(MyInt(3) / 0) # revealed: @Todo +# revealed: float +reveal_type(MyInt(3) / 0) ``` diff --git a/crates/red_knot_python_semantic/resources/mdtest/call/callable_instance.md b/crates/red_knot_python_semantic/resources/mdtest/call/callable_instance.md index 38a31e2315dcc..1719601372ae3 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/call/callable_instance.md +++ b/crates/red_knot_python_semantic/resources/mdtest/call/callable_instance.md @@ -10,14 +10,11 @@ class Multiplier: def __call__(self, number: float) -> float: return number * self.factor - a = Multiplier(2.0)(3.0) reveal_type(a) # revealed: float - class Unit: ... - b = Unit()(3.0) # error: "Object of type `Unit` is not callable" reveal_type(b) # revealed: Unknown ``` diff --git a/crates/red_knot_python_semantic/resources/mdtest/call/constructor.md b/crates/red_knot_python_semantic/resources/mdtest/call/constructor.md index 4877792b59aad..b9927fe086ee9 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/call/constructor.md +++ b/crates/red_knot_python_semantic/resources/mdtest/call/constructor.md @@ -3,6 +3,5 @@ ```py class Foo: ... - reveal_type(Foo()) # revealed: Foo ``` diff --git a/crates/red_knot_python_semantic/resources/mdtest/call/function.md b/crates/red_knot_python_semantic/resources/mdtest/call/function.md index d8e92d17798f4..a87285a9ed429 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/call/function.md +++ b/crates/red_knot_python_semantic/resources/mdtest/call/function.md @@ -6,7 +6,6 @@ def get_int() -> int: return 42 - reveal_type(get_int()) # revealed: int ``` @@ -16,7 +15,6 @@ reveal_type(get_int()) # revealed: int async def get_int_async() -> int: return 42 - # TODO: we don't yet support `types.CoroutineType`, should be generic `Coroutine[Any, Any, int]` reveal_type(get_int_async()) # revealed: @Todo ``` @@ -26,20 +24,16 @@ reveal_type(get_int_async()) # revealed: @Todo ```py from typing import Callable - def foo() -> int: return 42 - def decorator(func) -> Callable[[], int]: return foo - @decorator def bar() -> str: return "bar" - # TODO: should reveal `int`, as the decorator replaces `bar` with `foo` reveal_type(bar()) # revealed: @Todo ``` diff --git a/crates/red_knot_python_semantic/resources/mdtest/call/union.md b/crates/red_knot_python_semantic/resources/mdtest/call/union.md index f5115cb97f692..911f21947ef3c 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/call/union.md +++ b/crates/red_knot_python_semantic/resources/mdtest/call/union.md @@ -3,6 +3,11 @@ ## Union of return types ```py +def bool_instance() -> bool: + return True + +flag = bool_instance() + if flag: def f() -> int: @@ -13,7 +18,6 @@ else: def f() -> str: return "foo" - reveal_type(f()) # revealed: int | str ``` @@ -22,12 +26,16 @@ reveal_type(f()) # revealed: int | str ```py from nonexistent import f # error: [unresolved-import] "Cannot resolve import `nonexistent`" +def bool_instance() -> bool: + return True + +flag = bool_instance() + if flag: def f() -> int: return 1 - reveal_type(f()) # revealed: Unknown | int ``` @@ -36,6 +44,11 @@ reveal_type(f()) # revealed: Unknown | int Calling a union with a non-callable element should emit a diagnostic. ```py +def bool_instance() -> bool: + return True + +flag = bool_instance() + if flag: f = 1 else: @@ -43,7 +56,6 @@ else: def f() -> int: return 1 - x = f() # error: "Object of type `Literal[1] | Literal[f]` is not callable (due to union element `Literal[1]`)" reveal_type(x) # revealed: Unknown | int ``` @@ -53,6 +65,11 @@ reveal_type(x) # revealed: Unknown | int Calling a union with multiple non-callable elements should mention all of them in the diagnostic. ```py +def bool_instance() -> bool: + return True + +flag, flag2 = bool_instance(), bool_instance() + if flag: f = 1 elif flag2: @@ -62,7 +79,6 @@ else: def f() -> int: return 1 - # error: "Object of type `Literal[1] | Literal["foo"] | Literal[f]` is not callable (due to union elements Literal[1], Literal["foo"])" # revealed: Unknown | int reveal_type(f()) @@ -73,6 +89,11 @@ reveal_type(f()) Calling a union with no callable elements can emit a simpler diagnostic. ```py +def bool_instance() -> bool: + return True + +flag = bool_instance() + if flag: f = 1 else: diff --git a/crates/red_knot_python_semantic/resources/mdtest/comparison/integers.md b/crates/red_knot_python_semantic/resources/mdtest/comparison/integers.md index 01f53b3feff19..b576ce318d896 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/comparison/integers.md +++ b/crates/red_knot_python_semantic/resources/mdtest/comparison/integers.md @@ -19,8 +19,8 @@ reveal_type(1 <= "" and 0 < 1) # revealed: @Todo | Literal[True] ```py # TODO: implement lookup of `__eq__` on typeshed `int` stub. -def int_instance() -> int: ... - +def int_instance() -> int: + return 42 reveal_type(1 == int_instance()) # revealed: @Todo reveal_type(9 < int_instance()) # revealed: bool diff --git a/crates/red_knot_python_semantic/resources/mdtest/comparison/non_boolean_returns.md b/crates/red_knot_python_semantic/resources/mdtest/comparison/non_boolean_returns.md index 60a5ee161954d..e7178fa3efa8d 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/comparison/non_boolean_returns.md +++ b/crates/red_knot_python_semantic/resources/mdtest/comparison/non_boolean_returns.md @@ -21,15 +21,15 @@ Walking through examples: ```py from __future__ import annotations - class A: def __lt__(self, other) -> A: ... + class B: def __lt__(self, other) -> B: ... + class C: def __lt__(self, other) -> C: ... - x = A() < B() < C() reveal_type(x) # revealed: A | B diff --git a/crates/red_knot_python_semantic/resources/mdtest/comparison/strings.md b/crates/red_knot_python_semantic/resources/mdtest/comparison/strings.md index 3951a20c02463..04cfc6771ed21 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/comparison/strings.md +++ b/crates/red_knot_python_semantic/resources/mdtest/comparison/strings.md @@ -5,7 +5,6 @@ ```py def str_instance() -> str: ... - reveal_type("abc" == "abc") # revealed: Literal[True] reveal_type("ab_cd" <= "ab_ce") # revealed: Literal[True] reveal_type("abc" in "ab cd") # revealed: Literal[False] diff --git a/crates/red_knot_python_semantic/resources/mdtest/comparison/tuples.md b/crates/red_knot_python_semantic/resources/mdtest/comparison/tuples.md index 5b3fc43e0c7e5..c80ee4d601ef3 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/comparison/tuples.md +++ b/crates/red_knot_python_semantic/resources/mdtest/comparison/tuples.md @@ -59,8 +59,8 @@ reveal_type(c >= d) # revealed: Literal[True] ```py def bool_instance() -> bool: ... -def int_instance() -> int: ... - +def int_instance() -> int: + return 42 a = (bool_instance(),) b = (int_instance(),) @@ -144,7 +144,6 @@ class A: def __gt__(self, o) -> tuple: ... def __ge__(self, o) -> list: ... - a = (A(), A()) # TODO: All @Todo should be bool @@ -161,8 +160,8 @@ reveal_type(a >= a) # revealed: @Todo "Membership Test Comparisons" refers to the operators `in` and `not in`. ```py -def int_instance() -> int: ... - +def int_instance() -> int: + return 42 a = (1, 2) b = ((3, 4), (1, 2)) diff --git a/crates/red_knot_python_semantic/resources/mdtest/comparison/unions.md b/crates/red_knot_python_semantic/resources/mdtest/comparison/unions.md index 9686196bc022c..2a07827738c50 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/comparison/unions.md +++ b/crates/red_knot_python_semantic/resources/mdtest/comparison/unions.md @@ -5,6 +5,10 @@ Comparisons on union types need to consider all possible cases: ```py +def bool_instance() -> bool: + return True + +flag = bool_instance() one_or_two = 1 if flag else 2 reveal_type(one_or_two <= 2) # revealed: Literal[True] @@ -52,6 +56,10 @@ With unions on both sides, we need to consider the full cross product of options when building the resulting (union) type: ```py +def bool_instance() -> bool: + return True + +flag_s, flag_l = bool_instance(), bool_instance() small = 1 if flag_s else 2 large = 2 if flag_l else 3 @@ -69,6 +77,10 @@ unsupported. For now, we fall back to `bool` for the result type instead of trying to infer something more precise from the other (supported) variants: ```py +def bool_instance() -> bool: + return True + +flag = bool_instance() x = [1, 2] if flag else 1 result = 1 in x # error: "Operator `in` is not supported" diff --git a/crates/red_knot_python_semantic/resources/mdtest/comparison/unsupported.md b/crates/red_knot_python_semantic/resources/mdtest/comparison/unsupported.md index 237bf5a6b2208..f073a19dc5be6 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/comparison/unsupported.md +++ b/crates/red_knot_python_semantic/resources/mdtest/comparison/unsupported.md @@ -1,17 +1,31 @@ # Comparison: Unsupported operators ```py +def bool_instance() -> bool: + return True + a = 1 in 7 # error: "Operator `in` is not supported for types `Literal[1]` and `Literal[7]`" reveal_type(a) # revealed: bool b = 0 not in 10 # error: "Operator `not in` is not supported for types `Literal[0]` and `Literal[10]`" reveal_type(b) # revealed: bool -c = object() < 5 # error: "Operator `<` is not supported for types `object` and `Literal[5]`" +c = object() < 5 # error: "Operator `<` is not supported for types `object` and `int`" reveal_type(c) # revealed: Unknown # TODO should error, need to check if __lt__ signature is valid for right operand d = 5 < object() # TODO: should be `Unknown` reveal_type(d) # revealed: bool + +flag = bool_instance() +int_literal_or_str_literal = 1 if flag else "foo" +# error: "Operator `in` is not supported for types `Literal[42]` and `Literal[1]`, in comparing `Literal[42]` with `Literal[1] | Literal["foo"]`" +e = 42 in int_literal_or_str_literal +reveal_type(e) # revealed: bool + +# TODO: should error, need to check if __lt__ signature is valid for right operand +# error may be "Operator `<` is not supported for types `int` and `str`, in comparing `tuple[Literal[1], Literal[2]]` with `tuple[Literal[1], Literal["hello"]]` +f = (1, 2) < (1, "hello") +reveal_type(f) # revealed: @Todo ``` diff --git a/crates/red_knot_python_semantic/resources/mdtest/conditional/if_expression.md b/crates/red_knot_python_semantic/resources/mdtest/conditional/if_expression.md index c9028c7859d1b..f162a3a39d704 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/conditional/if_expression.md +++ b/crates/red_knot_python_semantic/resources/mdtest/conditional/if_expression.md @@ -3,6 +3,10 @@ ## Simple if-expression ```py +def bool_instance() -> bool: + return True + +flag = bool_instance() x = 1 if flag else 2 reveal_type(x) # revealed: Literal[1, 2] ``` @@ -10,6 +14,10 @@ reveal_type(x) # revealed: Literal[1, 2] ## If-expression with walrus operator ```py +def bool_instance() -> bool: + return True + +flag = bool_instance() y = 0 z = 0 x = (y := 1) if flag else (z := 2) @@ -21,6 +29,10 @@ reveal_type(z) # revealed: Literal[0, 2] ## Nested if-expression ```py +def bool_instance() -> bool: + return True + +flag, flag2 = bool_instance(), bool_instance() x = 1 if flag else 2 if flag2 else 3 reveal_type(x) # revealed: Literal[1, 2, 3] ``` @@ -28,6 +40,10 @@ reveal_type(x) # revealed: Literal[1, 2, 3] ## None ```py +def bool_instance() -> bool: + return True + +flag = bool_instance() x = 1 if flag else None reveal_type(x) # revealed: Literal[1] | None ``` diff --git a/crates/red_knot_python_semantic/resources/mdtest/conditional/if_statement.md b/crates/red_knot_python_semantic/resources/mdtest/conditional/if_statement.md index 73e73f4506d41..a44a58b4cacc7 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/conditional/if_statement.md +++ b/crates/red_knot_python_semantic/resources/mdtest/conditional/if_statement.md @@ -3,6 +3,10 @@ ## Simple if ```py +def bool_instance() -> bool: + return True + +flag = bool_instance() y = 1 y = 2 @@ -15,6 +19,10 @@ reveal_type(y) # revealed: Literal[2, 3] ## Simple if-elif-else ```py +def bool_instance() -> bool: + return True + +flag, flag2 = bool_instance(), bool_instance() y = 1 y = 2 if flag: @@ -28,13 +36,24 @@ else: x = y reveal_type(x) # revealed: Literal[3, 4, 5] -reveal_type(r) # revealed: Unbound | Literal[2] -reveal_type(s) # revealed: Unbound | Literal[5] + +# revealed: Unbound | Literal[2] +# error: [possibly-unresolved-reference] +reveal_type(r) + +# revealed: Unbound | Literal[5] +# error: [possibly-unresolved-reference] +reveal_type(s) ``` ## Single symbol across if-elif-else ```py +def bool_instance() -> bool: + return True + +flag, flag2 = bool_instance(), bool_instance() + if flag: y = 1 elif flag2: @@ -47,6 +66,10 @@ reveal_type(y) # revealed: Literal[1, 2, 3] ## if-elif-else without else assignment ```py +def bool_instance() -> bool: + return True + +flag, flag2 = bool_instance(), bool_instance() y = 0 if flag: y = 1 @@ -60,6 +83,10 @@ reveal_type(y) # revealed: Literal[0, 1, 2] ## if-elif-else with intervening assignment ```py +def bool_instance() -> bool: + return True + +flag, flag2 = bool_instance(), bool_instance() y = 0 if flag: y = 1 @@ -74,6 +101,10 @@ reveal_type(y) # revealed: Literal[0, 1, 2] ## Nested if statement ```py +def bool_instance() -> bool: + return True + +flag, flag2 = bool_instance(), bool_instance() y = 0 if flag: if flag2: @@ -84,6 +115,10 @@ reveal_type(y) # revealed: Literal[0, 1] ## if-elif without else ```py +def bool_instance() -> bool: + return True + +flag, flag2 = bool_instance(), bool_instance() y = 1 y = 2 if flag: diff --git a/crates/red_knot_python_semantic/resources/mdtest/conditional/match.md b/crates/red_knot_python_semantic/resources/mdtest/conditional/match.md index 2ce7c9462d90d..5269bc8b89fcd 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/conditional/match.md +++ b/crates/red_knot_python_semantic/resources/mdtest/conditional/match.md @@ -21,7 +21,9 @@ match 0: case 2: y = 3 -reveal_type(y) # revealed: Unbound | Literal[2, 3] +# revealed: Unbound | Literal[2, 3] +# error: [possibly-unresolved-reference] +reveal_type(y) ``` ## Basic match diff --git a/crates/red_knot_python_semantic/resources/mdtest/declaration/error.md b/crates/red_knot_python_semantic/resources/mdtest/declaration/error.md index b1eded0113077..19fbfa20a5459 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/declaration/error.md +++ b/crates/red_knot_python_semantic/resources/mdtest/declaration/error.md @@ -10,6 +10,10 @@ x: str # error: [invalid-declaration] "Cannot declare type `str` for inferred t ## Incompatible declarations ```py +def bool_instance() -> bool: + return True + +flag = bool_instance() if flag: x: str else: @@ -20,6 +24,10 @@ x = 1 # error: [conflicting-declarations] "Conflicting declared types for `x`: ## Partial declarations ```py +def bool_instance() -> bool: + return True + +flag = bool_instance() if flag: x: int x = 1 # error: [conflicting-declarations] "Conflicting declared types for `x`: Unknown, int" @@ -28,6 +36,10 @@ x = 1 # error: [conflicting-declarations] "Conflicting declared types for `x`: ## Incompatible declarations with bad assignment ```py +def bool_instance() -> bool: + return True + +flag = bool_instance() if flag: x: str else: diff --git a/crates/red_knot_python_semantic/resources/mdtest/exception/basic.md b/crates/red_knot_python_semantic/resources/mdtest/exception/basic.md index 85bc87b7fdcd8..6b67e6a22178e 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/exception/basic.md +++ b/crates/red_knot_python_semantic/resources/mdtest/exception/basic.md @@ -6,7 +6,7 @@ import re try: - x + help() except NameError as e: reveal_type(e) # revealed: NameError except re.error as f: @@ -19,7 +19,7 @@ except re.error as f: from nonexistent_module import foo # error: [unresolved-import] try: - x + help() except foo as e: reveal_type(foo) # revealed: Unknown reveal_type(e) # revealed: Unknown @@ -31,7 +31,7 @@ except foo as e: EXCEPTIONS = (AttributeError, TypeError) try: - x + help() except (RuntimeError, OSError) as e: reveal_type(e) # revealed: RuntimeError | OSError except EXCEPTIONS as f: @@ -43,7 +43,7 @@ except EXCEPTIONS as f: ```py def foo(x: type[AttributeError], y: tuple[type[OSError], type[RuntimeError]], z: tuple[type[BaseException], ...]): try: - w + help() except x as e: # TODO: should be `AttributeError` reveal_type(e) # revealed: @Todo diff --git a/crates/red_knot_python_semantic/resources/mdtest/exception/control_flow.md b/crates/red_knot_python_semantic/resources/mdtest/exception/control_flow.md index f7cf500d0b6f6..a4af8d4140e43 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/exception/control_flow.md +++ b/crates/red_knot_python_semantic/resources/mdtest/exception/control_flow.md @@ -40,7 +40,6 @@ to the `except` suite *after* that redefinition. def could_raise_returns_str() -> str: return "foo" - x = 1 try: @@ -63,7 +62,6 @@ unify and `x` is not inferred as having a union type following the def could_raise_returns_str() -> str: return "foo" - x = 1 try: @@ -88,7 +86,6 @@ possibility when it comes to control-flow analysis. def could_raise_returns_str() -> str: return "foo" - x = 1 try: @@ -119,7 +116,6 @@ the three suites: def could_raise_returns_str() -> str: return "foo" - x = 1 try: @@ -153,7 +149,6 @@ the end of the `except` suite: def could_raise_returns_str() -> str: return "foo" - x = 1 try: @@ -182,7 +177,6 @@ in their entireties: def could_raise_returns_str() -> str: return "foo" - x = 1 try: @@ -216,7 +210,6 @@ therefore `Literal[2]`: def could_raise_returns_str() -> str: return "foo" - x = 1 try: @@ -244,7 +237,6 @@ suites, however; this is still a TODO item for us.) def could_raise_returns_str() -> str: return "foo" - x = 1 try: @@ -277,15 +269,12 @@ following possibilities inside `finally` suites: def could_raise_returns_str() -> str: return "foo" - def could_raise_returns_bytes() -> bytes: return b"foo" - def could_raise_returns_bool() -> bool: return True - x = 1 try: @@ -318,15 +307,12 @@ conclusion of the `finally` suite.) def could_raise_returns_str() -> str: return "foo" - def could_raise_returns_bytes() -> bytes: return b"foo" - def could_raise_returns_bool() -> bool: return True - x = 1 try: @@ -352,23 +338,18 @@ An example with multiple `except` branches and a `finally` branch: def could_raise_returns_str() -> str: return "foo" - def could_raise_returns_bytes() -> bytes: return b"foo" - def could_raise_returns_bool() -> bool: return True - def could_raise_returns_memoryview() -> memoryview: return memoryview(b"") - def could_raise_returns_float() -> float: return 3.14 - x = 1 try: @@ -404,23 +385,18 @@ partway through the `else` suite due to an exception raised *there*. def could_raise_returns_str() -> str: return "foo" - def could_raise_returns_bytes() -> bytes: return b"foo" - def could_raise_returns_bool() -> bool: return True - def could_raise_returns_memoryview() -> memoryview: return memoryview(b"") - def could_raise_returns_float() -> float: return 3.14 - x = 1 try: @@ -452,31 +428,24 @@ The same again, this time with multiple `except` branches: def could_raise_returns_str() -> str: return "foo" - def could_raise_returns_bytes() -> bytes: return b"foo" - def could_raise_returns_bool() -> bool: return True - def could_raise_returns_memoryview() -> memoryview: return memoryview(b"") - def could_raise_returns_float() -> float: return 3.14 - def could_raise_returns_range() -> range: return range(42) - def could_raise_returns_slice() -> slice: return slice(None) - x = 1 try: @@ -524,53 +493,39 @@ prior to the suite running to completion. def could_raise_returns_str() -> str: return "foo" - def could_raise_returns_bytes() -> bytes: return b"foo" - def could_raise_returns_bool() -> bool: return True - def could_raise_returns_memoryview() -> memoryview: return memoryview(b"") - def could_raise_returns_float() -> float: return 3.14 - def could_raise_returns_range() -> range: return range(42) - def could_raise_returns_slice() -> slice: return slice(None) - def could_raise_returns_complex() -> complex: return 3j - def could_raise_returns_bytearray() -> bytearray: return bytearray() - class Foo: ... - - class Bar: ... - def could_raise_returns_Foo() -> Foo: return Foo() - def could_raise_returns_Bar() -> Bar: return Bar() - x = 1 try: @@ -632,23 +587,18 @@ variable by that name in the outer scope: def could_raise_returns_str() -> str: return "foo" - def could_raise_returns_bytes() -> bytes: return b"foo" - def could_raise_returns_range() -> range: return range(42) - def could_raise_returns_bytearray() -> bytearray: return bytearray() - def could_raise_returns_float() -> float: return 3.14 - x = 1 try: @@ -670,7 +620,6 @@ try: # TODO: should be `str | bytes | bytearray | float` reveal_type(x) # revealed: bytes | float reveal_type(x) # revealed: bytes | float - x = foo reveal_type(x) # revealed: Literal[foo] except: diff --git a/crates/red_knot_python_semantic/resources/mdtest/exception/except_star.md b/crates/red_knot_python_semantic/resources/mdtest/exception/except_star.md index 44d701d1f8c0b..b543544e56b35 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/exception/except_star.md +++ b/crates/red_knot_python_semantic/resources/mdtest/exception/except_star.md @@ -4,7 +4,7 @@ ```py try: - x + help() except* BaseException as e: reveal_type(e) # revealed: BaseExceptionGroup ``` @@ -13,7 +13,7 @@ except* BaseException as e: ```py try: - x + help() except* OSError as e: # TODO(Alex): more precise would be `ExceptionGroup[OSError]` reveal_type(e) # revealed: BaseExceptionGroup @@ -23,7 +23,7 @@ except* OSError as e: ```py try: - x + help() except* (TypeError, AttributeError) as e: # TODO(Alex): more precise would be `ExceptionGroup[TypeError | AttributeError]`. reveal_type(e) # revealed: BaseExceptionGroup diff --git a/crates/red_knot_python_semantic/resources/mdtest/expression/boolean.md b/crates/red_knot_python_semantic/resources/mdtest/expression/boolean.md index fad3069bb67ec..a84017ba709ee 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/expression/boolean.md +++ b/crates/red_knot_python_semantic/resources/mdtest/expression/boolean.md @@ -6,7 +6,6 @@ def foo() -> str: pass - reveal_type(True or False) # revealed: Literal[True] reveal_type("x" or "y" or "z") # revealed: Literal["x"] reveal_type("" or "y" or "z") # revealed: Literal["y"] @@ -23,7 +22,6 @@ reveal_type(foo() or True) # revealed: str | Literal[True] def foo() -> str: pass - reveal_type(True and False) # revealed: Literal[False] reveal_type(False and True) # revealed: Literal[False] reveal_type(foo() and False) # revealed: str | Literal[False] @@ -39,7 +37,6 @@ reveal_type("" and "y") # revealed: Literal[""] def returns_bool() -> bool: return True - if returns_bool(): x = True else: @@ -54,7 +51,6 @@ reveal_type(x) # revealed: bool def foo() -> str: pass - reveal_type("x" and "y" or "z") # revealed: Literal["y"] reveal_type("x" or "y" and "z") # revealed: Literal["x"] reveal_type("" and "y" or "z") # revealed: Literal["z"] @@ -70,7 +66,6 @@ reveal_type("x" or "y" and "") # revealed: Literal["x"] ```py path=a.py redefined_builtin_bool = bool - def my_bool(x) -> bool: return True ``` @@ -90,10 +85,8 @@ reveal_type(bool((0,))) # revealed: Literal[True] reveal_type(bool("NON EMPTY")) # revealed: Literal[True] reveal_type(bool(True)) # revealed: Literal[True] - def foo(): ... - reveal_type(bool(foo)) # revealed: Literal[True] ``` diff --git a/crates/red_knot_python_semantic/resources/mdtest/generics.md b/crates/red_knot_python_semantic/resources/mdtest/generics.md index a88b2d4d9bfaf..03660b58b6117 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/generics.md +++ b/crates/red_knot_python_semantic/resources/mdtest/generics.md @@ -6,13 +6,16 @@ Basic PEP 695 generics ```py class MyBox[T]: + # TODO: `T` is defined here + # error: [unresolved-reference] "Name `T` used when not defined" data: T box_model_number = 695 + # TODO: `T` is defined here + # error: [unresolved-reference] "Name `T` used when not defined" def __init__(self, data: T): self.data = data - # TODO not error (should be subscriptable) box: MyBox[int] = MyBox(5) # error: [non-subscriptable] # TODO error differently (str and int don't unify) @@ -27,15 +30,19 @@ reveal_type(MyBox.box_model_number) # revealed: Literal[695] ```py class MyBox[T]: + # TODO: `T` is defined here + # error: [unresolved-reference] "Name `T` used when not defined" data: T + # TODO: `T` is defined here + # error: [unresolved-reference] "Name `T` used when not defined" def __init__(self, data: T): self.data = data - -# TODO not error on the subscripting -class MySecureBox[T](MyBox[T]): ... # error: [non-subscriptable] - +# TODO not error on the subscripting or the use of type param +# error: [unresolved-reference] "Name `T` used when not defined" +# error: [non-subscriptable] +class MySecureBox[T](MyBox[T]): ... secure_box: MySecureBox[int] = MySecureBox(5) reveal_type(secure_box) # revealed: MySecureBox @@ -52,10 +59,8 @@ This should hold true even with generics at play. ```py path=a.pyi class Seq[T]: ... - # TODO not error on the subscripting class S[T](Seq[S]): ... # error: [non-subscriptable] - reveal_type(S) # revealed: Literal[S] ``` diff --git a/crates/red_knot_python_semantic/resources/mdtest/import/conditional.md b/crates/red_knot_python_semantic/resources/mdtest/import/conditional.md index 9fd0e09befc39..76756cb527b7d 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/import/conditional.md +++ b/crates/red_knot_python_semantic/resources/mdtest/import/conditional.md @@ -3,11 +3,22 @@ ## Maybe unbound ```py path=maybe_unbound.py +def bool_instance() -> bool: + return True + +flag = bool_instance() if flag: y = 3 -x = y -reveal_type(x) # revealed: Unbound | Literal[3] -reveal_type(y) # revealed: Unbound | Literal[3] + +x = y # error: [possibly-unresolved-reference] + +# revealed: Unbound | Literal[3] +# error: [possibly-unresolved-reference] +reveal_type(x) + +# revealed: Unbound | Literal[3] +# error: [possibly-unresolved-reference] +reveal_type(y) ``` ```py @@ -20,11 +31,22 @@ reveal_type(y) # revealed: Literal[3] ## Maybe unbound annotated ```py path=maybe_unbound_annotated.py +def bool_instance() -> bool: + return True + +flag = bool_instance() + if flag: y: int = 3 -x = y -reveal_type(x) # revealed: Unbound | Literal[3] -reveal_type(y) # revealed: Unbound | Literal[3] +x = y # error: [possibly-unresolved-reference] + +# revealed: Unbound | Literal[3] +# error: [possibly-unresolved-reference] +reveal_type(x) + +# revealed: Unbound | Literal[3] +# error: [possibly-unresolved-reference] +reveal_type(y) ``` Importing an annotated name prefers the declared type over the inferred type: @@ -43,6 +65,10 @@ def f(): ... ``` ```py path=b.py +def bool_instance() -> bool: + return True + +flag = bool_instance() if flag: from c import f else: @@ -67,6 +93,10 @@ x: int ``` ```py path=b.py +def bool_instance() -> bool: + return True + +flag = bool_instance() if flag: from c import x else: diff --git a/crates/red_knot_python_semantic/resources/mdtest/import/relative.md b/crates/red_knot_python_semantic/resources/mdtest/import/relative.md index be9a3168b119e..4695374e94ed1 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/import/relative.md +++ b/crates/red_knot_python_semantic/resources/mdtest/import/relative.md @@ -102,7 +102,7 @@ reveal_type(X) # revealed: Literal[42] ``` ```py path=package/foo.py -x +x # error: [unresolved-reference] ``` ```py path=package/bar.py diff --git a/crates/red_knot_python_semantic/resources/mdtest/literal/bytes.md b/crates/red_knot_python_semantic/resources/mdtest/literal/bytes.md new file mode 100644 index 0000000000000..0cf6222c534ee --- /dev/null +++ b/crates/red_knot_python_semantic/resources/mdtest/literal/bytes.md @@ -0,0 +1,10 @@ +# Bytes literals + +## Simple + +```py +reveal_type(b"red" b"knot") # revealed: Literal[b"redknot"] +reveal_type(b"hello") # revealed: Literal[b"hello"] +reveal_type(b"world" + b"!") # revealed: Literal[b"world!"] +reveal_type(b"\xff\x00") # revealed: Literal[b"\xff\x00"] +``` diff --git a/crates/red_knot_python_semantic/resources/mdtest/loops/async_for_loops.md b/crates/red_knot_python_semantic/resources/mdtest/loops/async_for_loops.md index ba6c471312e31..a1a55f81d3ede 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/loops/async_for_loops.md +++ b/crates/red_knot_python_semantic/resources/mdtest/loops/async_for_loops.md @@ -17,8 +17,10 @@ async def foo(): async for x in Iterator(): pass - # TODO - reveal_type(x) # revealed: Unbound | @Todo + # TODO: should reveal `Unbound | Unknown` because `__aiter__` is not defined + # revealed: Unbound | @Todo + # error: [possibly-unresolved-reference] + reveal_type(x) ``` ## Basic async for loop @@ -37,5 +39,7 @@ async def foo(): async for x in IntAsyncIterable(): pass - reveal_type(x) # revealed: Unbound | @Todo + # error: [possibly-unresolved-reference] + # revealed: Unbound | @Todo + reveal_type(x) ``` diff --git a/crates/red_knot_python_semantic/resources/mdtest/loops/for_loop.md b/crates/red_knot_python_semantic/resources/mdtest/loops/for_loop.md index 66d7564d0e517..117108928f00f 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/loops/for_loop.md +++ b/crates/red_knot_python_semantic/resources/mdtest/loops/for_loop.md @@ -7,16 +7,16 @@ class IntIterator: def __next__(self) -> int: return 42 - class IntIterable: def __iter__(self) -> IntIterator: return IntIterator() - for x in IntIterable(): pass -reveal_type(x) # revealed: Unbound | int +# revealed: Unbound | int +# error: [possibly-unresolved-reference] +reveal_type(x) ``` ## With previous definition @@ -26,12 +26,10 @@ class IntIterator: def __next__(self) -> int: return 42 - class IntIterable: def __iter__(self) -> IntIterator: return IntIterator() - x = "foo" for x in IntIterable(): @@ -47,12 +45,10 @@ class IntIterator: def __next__(self) -> int: return 42 - class IntIterable: def __iter__(self) -> IntIterator: return IntIterator() - for x in IntIterable(): pass else: @@ -68,12 +64,10 @@ class IntIterator: def __next__(self) -> int: return 42 - class IntIterable: def __iter__(self) -> IntIterator: return IntIterator() - for x in IntIterable(): if x > 5: break @@ -90,11 +84,12 @@ class OldStyleIterable: def __getitem__(self, key: int) -> int: return 42 - for x in OldStyleIterable(): pass -reveal_type(x) # revealed: Unbound | int +# revealed: Unbound | int +# error: [possibly-unresolved-reference] +reveal_type(x) ``` ## With heterogeneous tuple @@ -103,23 +98,31 @@ reveal_type(x) # revealed: Unbound | int for x in (1, "a", b"foo"): pass -reveal_type(x) # revealed: Unbound | Literal[1] | Literal["a"] | Literal[b"foo"] +# revealed: Unbound | Literal[1] | Literal["a"] | Literal[b"foo"] +# error: [possibly-unresolved-reference] +reveal_type(x) ``` ## With non-callable iterator ```py +def bool_instance() -> bool: + return True + +flag = bool_instance() + class NotIterable: if flag: __iter__ = 1 else: __iter__ = None - for x in NotIterable(): # error: "Object of type `NotIterable` is not iterable" pass -reveal_type(x) # revealed: Unbound | Unknown +# revealed: Unbound | Unknown +# error: [possibly-unresolved-reference] +reveal_type(x) ``` ## Invalid iterable @@ -136,10 +139,8 @@ for x in nonsense: # error: "Object of type `Literal[123]` is not iterable" class NotIterable: def __getitem__(self, key: int) -> int: return 42 - __iter__ = None - for x in NotIterable(): # error: "Object of type `NotIterable` is not iterable" pass ``` diff --git a/crates/red_knot_python_semantic/resources/mdtest/loops/iterators.md b/crates/red_knot_python_semantic/resources/mdtest/loops/iterators.md index 2dfbd026b8c3c..8ec99c9e40484 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/loops/iterators.md +++ b/crates/red_knot_python_semantic/resources/mdtest/loops/iterators.md @@ -5,16 +5,13 @@ ```py class NotIterable: ... - class Iterator: def __next__(self) -> int: return 42 - class Iterable: def __iter__(self) -> Iterator: ... - def generator_function(): yield from Iterable() yield from NotIterable() # error: "Object of type `NotIterable` is not iterable" diff --git a/crates/red_knot_python_semantic/resources/mdtest/loops/while_loop.md b/crates/red_knot_python_semantic/resources/mdtest/loops/while_loop.md index daba14ac9819a..51a123edc232c 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/loops/while_loop.md +++ b/crates/red_knot_python_semantic/resources/mdtest/loops/while_loop.md @@ -3,6 +3,10 @@ ## Basic While Loop ```py +def bool_instance() -> bool: + return True + +flag = bool_instance() x = 1 while flag: x = 2 @@ -13,6 +17,10 @@ reveal_type(x) # revealed: Literal[1, 2] ## While with else (no break) ```py +def bool_instance() -> bool: + return True + +flag = bool_instance() x = 1 while flag: x = 2 @@ -26,6 +34,10 @@ reveal_type(x) # revealed: Literal[3] ## While with Else (may break) ```py +def bool_instance() -> bool: + return True + +flag, flag2 = bool_instance(), bool_instance() x = 1 y = 0 while flag: diff --git a/crates/red_knot_python_semantic/resources/mdtest/narrow/conditionals_elif_else.md b/crates/red_knot_python_semantic/resources/mdtest/narrow/conditionals_elif_else.md new file mode 100644 index 0000000000000..7ac16f4b8d597 --- /dev/null +++ b/crates/red_knot_python_semantic/resources/mdtest/narrow/conditionals_elif_else.md @@ -0,0 +1,57 @@ +# Narrowing for conditionals with elif and else + +## Positive contributions become negative in elif-else blocks + +```py +def int_instance() -> int: + return 42 + +x = int_instance() + +if x == 1: + # cannot narrow; could be a subclass of `int` + reveal_type(x) # revealed: int +elif x == 2: + reveal_type(x) # revealed: int & ~Literal[1] +elif x != 3: + reveal_type(x) # revealed: int & ~Literal[1] & ~Literal[2] & ~Literal[3] +``` + +## Positive contributions become negative in elif-else blocks, with simplification + +```py +def bool_instance() -> bool: + return True + +x = 1 if bool_instance() else 2 if bool_instance() else 3 + +if x == 1: + # TODO should be Literal[1] + reveal_type(x) # revealed: Literal[1, 2, 3] +elif x == 2: + # TODO should be Literal[2] + reveal_type(x) # revealed: Literal[2, 3] +else: + reveal_type(x) # revealed: Literal[3] +``` + +## Multiple negative contributions using elif, with simplification + +```py +def bool_instance() -> bool: + return True + +x = 1 if bool_instance() else 2 if bool_instance() else 3 + +if x != 1: + reveal_type(x) # revealed: Literal[2, 3] +elif x != 2: + # TODO should be `Literal[1]` + reveal_type(x) # revealed: Literal[1, 3] +elif x == 3: + # TODO should be Never + reveal_type(x) # revealed: Literal[1, 2, 3] +else: + # TODO should be Never + reveal_type(x) # revealed: Literal[1, 2] +``` diff --git a/crates/red_knot_python_semantic/resources/mdtest/narrow/conditionals_is.md b/crates/red_knot_python_semantic/resources/mdtest/narrow/conditionals_is.md index 3d9af15a647f3..b9cd22897d18d 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/narrow/conditionals_is.md +++ b/crates/red_knot_python_semantic/resources/mdtest/narrow/conditionals_is.md @@ -3,10 +3,16 @@ ## `is None` ```py +def bool_instance() -> bool: + return True + +flag = bool_instance() x = None if flag else 1 if x is None: reveal_type(x) # revealed: None +else: + reveal_type(x) # revealed: Literal[1] reveal_type(x) # revealed: None | Literal[1] ``` @@ -14,14 +20,60 @@ reveal_type(x) # revealed: None | Literal[1] ## `is` for other types ```py -class A: ... +def bool_instance() -> bool: + return True +flag = bool_instance() + +class A: ... x = A() y = x if flag else None if y is x: reveal_type(y) # revealed: A +else: + reveal_type(y) # revealed: A | None reveal_type(y) # revealed: A | None ``` + +## `is` in chained comparisons + +```py +def bool_instance() -> bool: + return True + +x_flag, y_flag = bool_instance(), bool_instance() +x = True if x_flag else False +y = True if y_flag else False + +reveal_type(x) # revealed: bool +reveal_type(y) # revealed: bool + +if y is x is False: # Interpreted as `(y is x) and (x is False)` + reveal_type(x) # revealed: Literal[False] + reveal_type(y) # revealed: bool +else: + # The negation of the clause above is (y is not x) or (x is not False) + # So we can't narrow the type of x or y here, because each arm of the `or` could be true + reveal_type(x) # revealed: bool + reveal_type(y) # revealed: bool +``` + +## `is` in elif clause + +```py +def bool_instance() -> bool: + return True + +x = None if bool_instance() else (1 if bool_instance() else True) + +reveal_type(x) # revealed: None | Literal[1] | Literal[True] +if x is None: + reveal_type(x) # revealed: None +elif x is True: + reveal_type(x) # revealed: Literal[True] +else: + reveal_type(x) # revealed: Literal[1] +``` diff --git a/crates/red_knot_python_semantic/resources/mdtest/narrow/conditionals_is_not.md b/crates/red_knot_python_semantic/resources/mdtest/narrow/conditionals_is_not.md index dc094096a94a5..f23bae8e1e8c5 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/narrow/conditionals_is_not.md +++ b/crates/red_knot_python_semantic/resources/mdtest/narrow/conditionals_is_not.md @@ -5,10 +5,16 @@ The type guard removes `None` from the union type: ```py +def bool_instance() -> bool: + return True + +flag = bool_instance() x = None if flag else 1 if x is not None: reveal_type(x) # revealed: Literal[1] +else: + reveal_type(x) # revealed: None reveal_type(x) # revealed: None | Literal[1] ``` @@ -16,11 +22,17 @@ reveal_type(x) # revealed: None | Literal[1] ## `is not` for other singleton types ```py +def bool_instance() -> bool: + return True + +flag = bool_instance() x = True if flag else False reveal_type(x) # revealed: bool if x is not False: reveal_type(x) # revealed: Literal[True] +else: + reveal_type(x) # revealed: Literal[False] ``` ## `is not` for non-singleton types @@ -35,4 +47,51 @@ y = 345 if x is not y: reveal_type(x) # revealed: Literal[345] +else: + reveal_type(x) # revealed: Literal[345] +``` + +## `is not` for other types + +```py +def bool_instance() -> bool: + return True + +class A: ... + +x = A() +y = x if bool_instance() else None + +if y is not x: + reveal_type(y) # revealed: A | None +else: + reveal_type(y) # revealed: A + +reveal_type(y) # revealed: A | None +``` + +## `is not` in chained comparisons + +The type guard removes `False` from the union type of the tested value only. + +```py +def bool_instance() -> bool: + return True + +x_flag, y_flag = bool_instance(), bool_instance() +x = True if x_flag else False +y = True if y_flag else False + +reveal_type(x) # revealed: bool +reveal_type(y) # revealed: bool + +if y is not x is not False: # Interpreted as `(y is not x) and (x is not False)` + reveal_type(x) # revealed: Literal[True] + reveal_type(y) # revealed: bool +else: + # The negation of the clause above is (y is x) or (x is False) + # So we can't narrow the type of x or y here, because each arm of the `or` could be true + + reveal_type(x) # revealed: bool + reveal_type(y) # revealed: bool ``` diff --git a/crates/red_knot_python_semantic/resources/mdtest/narrow/conditionals_nested.md b/crates/red_knot_python_semantic/resources/mdtest/narrow/conditionals_nested.md new file mode 100644 index 0000000000000..cc0f79165e742 --- /dev/null +++ b/crates/red_knot_python_semantic/resources/mdtest/narrow/conditionals_nested.md @@ -0,0 +1,56 @@ +# Narrowing for nested conditionals + +## Multiple negative contributions + +```py +def int_instance() -> int: + return 42 + +x = int_instance() + +if x != 1: + if x != 2: + if x != 3: + reveal_type(x) # revealed: int & ~Literal[1] & ~Literal[2] & ~Literal[3] +``` + +## Multiple negative contributions with simplification + +```py +def bool_instance() -> bool: + return True + +flag1, flag2 = bool_instance(), bool_instance() +x = 1 if flag1 else 2 if flag2 else 3 + +if x != 1: + reveal_type(x) # revealed: Literal[2, 3] + if x != 2: + reveal_type(x) # revealed: Literal[3] +``` + +## elif-else blocks + +```py +def bool_instance() -> bool: + return True + +x = 1 if bool_instance() else 2 if bool_instance() else 3 + +if x != 1: + reveal_type(x) # revealed: Literal[2, 3] + if x == 2: + # TODO should be `Literal[2]` + reveal_type(x) # revealed: Literal[2, 3] + elif x == 3: + reveal_type(x) # revealed: Literal[3] + else: + reveal_type(x) # revealed: Never + +elif x != 2: + # TODO should be Literal[1] + reveal_type(x) # revealed: Literal[1, 3] +else: + # TODO should be Never + reveal_type(x) # revealed: Literal[1, 2, 3] +``` diff --git a/crates/red_knot_python_semantic/resources/mdtest/narrow/conditionals_not_eq.md b/crates/red_knot_python_semantic/resources/mdtest/narrow/conditionals_not_eq.md new file mode 100644 index 0000000000000..3ad8ebcb68e10 --- /dev/null +++ b/crates/red_knot_python_semantic/resources/mdtest/narrow/conditionals_not_eq.md @@ -0,0 +1,119 @@ +# Narrowing for `!=` conditionals + +## `x != None` + +```py +def bool_instance() -> bool: + return True + +flag = bool_instance() +x = None if flag else 1 + +if x != None: + reveal_type(x) # revealed: Literal[1] +else: + # TODO should be None + reveal_type(x) # revealed: None | Literal[1] +``` + +## `!=` for other singleton types + +```py +def bool_instance() -> bool: + return True + +flag = bool_instance() +x = True if flag else False + +if x != False: + reveal_type(x) # revealed: Literal[True] +else: + # TODO should be Literal[False] + reveal_type(x) # revealed: bool +``` + +## `x != y` where `y` is of literal type + +```py +def bool_instance() -> bool: + return True + +flag = bool_instance() +x = 1 if flag else 2 + +if x != 1: + reveal_type(x) # revealed: Literal[2] +``` + +## `x != y` where `y` is a single-valued type + +```py +def bool_instance() -> bool: + return True + +flag = bool_instance() + +class A: ... +class B: ... + +C = A if flag else B + +if C != A: + reveal_type(C) # revealed: Literal[B] +else: + # TODO should be Literal[A] + reveal_type(C) # revealed: Literal[A, B] +``` + +## `x != y` where `y` has multiple single-valued options + +```py +def bool_instance() -> bool: + return True + +x = 1 if bool_instance() else 2 +y = 2 if bool_instance() else 3 + +if x != y: + reveal_type(x) # revealed: Literal[1, 2] +else: + # TODO should be Literal[2] + reveal_type(x) # revealed: Literal[1, 2] +``` + +## `!=` for non-single-valued types + +Only single-valued types should narrow the type: + +```py +def bool_instance() -> bool: + return True + +def int_instance() -> int: + return 42 + +flag = bool_instance() +x = int_instance() if flag else None +y = int_instance() + +if x != y: + reveal_type(x) # revealed: int | None +``` + +## Mix of single-valued and non-single-valued types + +```py +def int_instance() -> int: + return 42 + +def bool_instance() -> bool: + return True + +x = 1 if bool_instance() else 2 +y = 2 if bool_instance() else int_instance() + +if x != y: + reveal_type(x) # revealed: Literal[1, 2] +else: + reveal_type(x) # revealed: Literal[1, 2] +``` diff --git a/crates/red_knot_python_semantic/resources/mdtest/narrow/isinstance.md b/crates/red_knot_python_semantic/resources/mdtest/narrow/isinstance.md new file mode 100644 index 0000000000000..4b07d5648a760 --- /dev/null +++ b/crates/red_knot_python_semantic/resources/mdtest/narrow/isinstance.md @@ -0,0 +1,209 @@ +# Narrowing for `isinstance` checks + +Narrowing for `isinstance(object, classinfo)` expressions. + +## `classinfo` is a single type + +```py +def bool_instance() -> bool: + return True + +flag = bool_instance() + +x = 1 if flag else "a" + +if isinstance(x, int): + reveal_type(x) # revealed: Literal[1] + +if isinstance(x, str): + reveal_type(x) # revealed: Literal["a"] + if isinstance(x, int): + reveal_type(x) # revealed: Never + +if isinstance(x, (int, object)): + reveal_type(x) # revealed: Literal[1] | Literal["a"] +``` + +## `classinfo` is a tuple of types + +Note: `isinstance(x, (int, str))` should not be confused with +`isinstance(x, tuple[(int, str)])`. The former is equivalent to +`isinstance(x, int | str)`: + +```py +def bool_instance() -> bool: + return True + +flag, flag1, flag2 = bool_instance(), bool_instance(), bool_instance() + +x = 1 if flag else "a" + +if isinstance(x, (int, str)): + reveal_type(x) # revealed: Literal[1] | Literal["a"] +else: + reveal_type(x) # revealed: Never + +if isinstance(x, (int, bytes)): + reveal_type(x) # revealed: Literal[1] + +if isinstance(x, (bytes, str)): + reveal_type(x) # revealed: Literal["a"] + +# No narrowing should occur if a larger type is also +# one of the possibilities: +if isinstance(x, (int, object)): + reveal_type(x) # revealed: Literal[1] | Literal["a"] +else: + reveal_type(x) # revealed: Never + +y = 1 if flag1 else "a" if flag2 else b"b" +if isinstance(y, (int, str)): + reveal_type(y) # revealed: Literal[1] | Literal["a"] + +if isinstance(y, (int, bytes)): + reveal_type(y) # revealed: Literal[1] | Literal[b"b"] + +if isinstance(y, (str, bytes)): + reveal_type(y) # revealed: Literal["a"] | Literal[b"b"] +``` + +## `classinfo` is a nested tuple of types + +```py +def bool_instance() -> bool: + return True + +flag = bool_instance() + +x = 1 if flag else "a" + +if isinstance(x, (bool, (bytes, int))): + reveal_type(x) # revealed: Literal[1] +else: + reveal_type(x) # revealed: Literal["a"] +``` + +## Class types + +```py +class A: ... +class B: ... +class C: ... + +def get_object() -> object: ... + +x = get_object() + +if isinstance(x, A): + reveal_type(x) # revealed: A + if isinstance(x, B): + reveal_type(x) # revealed: A & B + else: + reveal_type(x) # revealed: A & ~B + +if isinstance(x, (A, B)): + reveal_type(x) # revealed: A | B +elif isinstance(x, (A, C)): + reveal_type(x) # revealed: C & ~A & ~B +else: + # TODO: Should be simplified to ~A & ~B & ~C + reveal_type(x) # revealed: object & ~A & ~B & ~C +``` + +## No narrowing for instances of `builtins.type` + +```py +def bool_instance() -> bool: + return True + +flag = bool_instance() + +t = type("t", (), {}) + +# This isn't testing what we want it to test if we infer anything more precise here: +reveal_type(t) # revealed: type +x = 1 if flag else "foo" + +if isinstance(x, t): + reveal_type(x) # revealed: Literal[1] | Literal["foo"] +``` + +## Do not use custom `isinstance` for narrowing + +```py +def bool_instance() -> bool: + return True + +flag = bool_instance() + +def isinstance(x, t): + return True + +x = 1 if flag else "a" +if isinstance(x, int): + reveal_type(x) # revealed: Literal[1] | Literal["a"] +``` + +## Do support narrowing if `isinstance` is aliased + +```py +def bool_instance() -> bool: + return True + +flag = bool_instance() + +isinstance_alias = isinstance + +x = 1 if flag else "a" +if isinstance_alias(x, int): + reveal_type(x) # revealed: Literal[1] +``` + +## Do support narrowing if `isinstance` is imported + +```py +from builtins import isinstance as imported_isinstance + +def bool_instance() -> bool: + return True + +flag = bool_instance() +x = 1 if flag else "a" +if imported_isinstance(x, int): + reveal_type(x) # revealed: Literal[1] +``` + +## Do not narrow if second argument is not a type + +```py +def bool_instance() -> bool: + return True + +flag = bool_instance() +x = 1 if flag else "a" + +# TODO: this should cause us to emit a diagnostic during +# type checking +if isinstance(x, "a"): + reveal_type(x) # revealed: Literal[1] | Literal["a"] + +# TODO: this should cause us to emit a diagnostic during +# type checking +if isinstance(x, "int"): + reveal_type(x) # revealed: Literal[1] | Literal["a"] +``` + +## Do not narrow if there are keyword arguments + +```py +def bool_instance() -> bool: + return True + +flag = bool_instance() +x = 1 if flag else "a" + +# TODO: this should cause us to emit a diagnostic +# (`isinstance` has no `foo` parameter) +if isinstance(x, int, foo="bar"): + reveal_type(x) # revealed: Literal[1] | Literal["a"] +``` diff --git a/crates/red_knot_python_semantic/resources/mdtest/narrow/match.md b/crates/red_knot_python_semantic/resources/mdtest/narrow/match.md index 0c8ea0e363cbe..2144f91f9eff7 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/narrow/match.md +++ b/crates/red_knot_python_semantic/resources/mdtest/narrow/match.md @@ -3,6 +3,11 @@ ## Single `match` pattern ```py +def bool_instance() -> bool: + return True + +flag = bool_instance() + x = None if flag else 1 reveal_type(x) # revealed: None | Literal[1] diff --git a/crates/red_knot_python_semantic/resources/mdtest/scopes/builtin.md b/crates/red_knot_python_semantic/resources/mdtest/scopes/builtin.md index 88698d3cb9339..7be6c7531b31c 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/scopes/builtin.md +++ b/crates/red_knot_python_semantic/resources/mdtest/scopes/builtin.md @@ -9,11 +9,9 @@ with the conditionally-defined type: def returns_bool() -> bool: return True - if returns_bool(): copyright = 1 - def f(): reveal_type(copyright) # revealed: Literal[copyright] | Literal[1] ``` @@ -26,11 +24,9 @@ Same is true if the name is annotated: def returns_bool() -> bool: return True - if returns_bool(): copyright: int = 1 - def f(): reveal_type(copyright) # revealed: Literal[copyright] | int ``` diff --git a/crates/red_knot_python_semantic/resources/mdtest/shadowing/class.md b/crates/red_knot_python_semantic/resources/mdtest/shadowing/class.md index ef50b25a66296..212c97baf9978 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/shadowing/class.md +++ b/crates/red_knot_python_semantic/resources/mdtest/shadowing/class.md @@ -5,7 +5,6 @@ ```py class C: ... - C = 1 # error: "Implicit shadowing of class `C`; annotate to make it explicit if this is intentional" ``` @@ -16,6 +15,5 @@ No diagnostic is raised in the case of explicit shadowing: ```py class C: ... - C: int = 1 ``` diff --git a/crates/red_knot_python_semantic/resources/mdtest/shadowing/function.md b/crates/red_knot_python_semantic/resources/mdtest/shadowing/function.md index bd283babd3424..b3e0c81ccc84a 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/shadowing/function.md +++ b/crates/red_knot_python_semantic/resources/mdtest/shadowing/function.md @@ -14,7 +14,6 @@ def f(x: str): ```py path=a.py def f(): ... - f = 1 # error: "Implicit shadowing of function `f`; annotate to make it explicit if this is intentional" ``` @@ -23,6 +22,5 @@ f = 1 # error: "Implicit shadowing of function `f`; annotate to make it explici ```py path=a.py def f(): ... - f: int = 1 ``` diff --git a/crates/red_knot_python_semantic/resources/mdtest/shadowing/variable_declaration.md b/crates/red_knot_python_semantic/resources/mdtest/shadowing/variable_declaration.md index ebce13ccedade..44a93668c9801 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/shadowing/variable_declaration.md +++ b/crates/red_knot_python_semantic/resources/mdtest/shadowing/variable_declaration.md @@ -3,6 +3,11 @@ ## Shadow after incompatible declarations is OK ```py +def bool_instance() -> bool: + return True + +flag = bool_instance() + if flag: x: str else: diff --git a/crates/red_knot_python_semantic/resources/mdtest/stubs/class.md b/crates/red_knot_python_semantic/resources/mdtest/stubs/class.md index f962c2d03727f..c679e51c0c96a 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/stubs/class.md +++ b/crates/red_knot_python_semantic/resources/mdtest/stubs/class.md @@ -7,6 +7,5 @@ In type stubs, classes can reference themselves in their base class definitions. ```py path=a.pyi class C(C): ... - reveal_type(C) # revealed: Literal[C] ``` diff --git a/crates/red_knot_python_semantic/resources/mdtest/subscript/bytes.md b/crates/red_knot_python_semantic/resources/mdtest/subscript/bytes.md index 0e71cb814b531..15330e4006b9d 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/subscript/bytes.md +++ b/crates/red_knot_python_semantic/resources/mdtest/subscript/bytes.md @@ -3,8 +3,33 @@ ## Simple ```py -reveal_type(b"red" b"knot") # revealed: Literal[b"redknot"] -reveal_type(b"hello") # revealed: Literal[b"hello"] -reveal_type(b"world" + b"!") # revealed: Literal[b"world!"] -reveal_type(b"\xff\x00") # revealed: Literal[b"\xff\x00"] +b = b"\x00abc\xff" + +reveal_type(b[0]) # revealed: Literal[b"\x00"] +reveal_type(b[1]) # revealed: Literal[b"a"] +reveal_type(b[4]) # revealed: Literal[b"\xff"] + +reveal_type(b[-1]) # revealed: Literal[b"\xff"] +reveal_type(b[-2]) # revealed: Literal[b"c"] +reveal_type(b[-5]) # revealed: Literal[b"\x00"] + +reveal_type(b[False]) # revealed: Literal[b"\x00"] +reveal_type(b[True]) # revealed: Literal[b"a"] + +x = b[5] # error: [index-out-of-bounds] "Index 5 is out of bounds for bytes literal `Literal[b"\x00abc\xff"]` with length 5" +reveal_type(x) # revealed: Unknown + +y = b[-6] # error: [index-out-of-bounds] "Index -6 is out of bounds for bytes literal `Literal[b"\x00abc\xff"]` with length 5" +reveal_type(y) # revealed: Unknown +``` + +## Function return + +```py +def int_instance() -> int: + return 42 + +a = b"abcde"[int_instance()] +# TODO: Support overloads... Should be `bytes` +reveal_type(a) # revealed: @Todo ``` diff --git a/crates/red_knot_python_semantic/resources/mdtest/subscript/class.md b/crates/red_knot_python_semantic/resources/mdtest/subscript/class.md index 2423749e69eda..82d8beff9ef82 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/subscript/class.md +++ b/crates/red_knot_python_semantic/resources/mdtest/subscript/class.md @@ -5,7 +5,6 @@ ```py class NotSubscriptable: ... - a = NotSubscriptable[0] # error: "Cannot subscript object of type `Literal[NotSubscriptable]` with no `__class_getitem__` method" ``` @@ -16,7 +15,6 @@ class Identity: def __class_getitem__(cls, item: int) -> str: return item - reveal_type(Identity[0]) # revealed: str ``` @@ -25,19 +23,16 @@ reveal_type(Identity[0]) # revealed: str ```py flag = True - class UnionClassGetItem: if flag: def __class_getitem__(cls, item: int) -> str: return item - else: def __class_getitem__(cls, item: int) -> int: return item - reveal_type(UnionClassGetItem[0]) # revealed: str | int ``` @@ -46,17 +41,14 @@ reveal_type(UnionClassGetItem[0]) # revealed: str | int ```py flag = True - class A: def __class_getitem__(cls, item: int) -> str: return item - class B: def __class_getitem__(cls, item: int) -> int: return item - x = A if flag else B reveal_type(x) # revealed: Literal[A, B] @@ -69,16 +61,13 @@ reveal_type(x[0]) # revealed: str | int flag = True if flag: - class Spam: def __class_getitem__(self, x: int) -> str: return "foo" else: - class Spam: ... - # error: [call-non-callable] "Method `__class_getitem__` of type `Literal[__class_getitem__] | Unbound` is not callable on object of type `Literal[Spam, Spam]`" # revealed: str | Unknown reveal_type(Spam[42]) @@ -90,7 +79,6 @@ reveal_type(Spam[42]) flag = True if flag: - class Eggs: def __class_getitem__(self, x: int) -> str: return "foo" diff --git a/crates/red_knot_python_semantic/resources/mdtest/subscript/instance.md b/crates/red_knot_python_semantic/resources/mdtest/subscript/instance.md index 73ce30ec5ca55..a7d170a29b490 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/subscript/instance.md +++ b/crates/red_knot_python_semantic/resources/mdtest/subscript/instance.md @@ -5,7 +5,6 @@ ```py class NotSubscriptable: ... - a = NotSubscriptable()[0] # error: "Cannot subscript object of type `NotSubscriptable` with no `__getitem__` method" ``` @@ -15,7 +14,6 @@ a = NotSubscriptable()[0] # error: "Cannot subscript object of type `NotSubscri class NotSubscriptable: __getitem__ = None - a = NotSubscriptable()[0] # error: "Method `__getitem__` of type `None` is not callable on object of type `NotSubscriptable`" ``` @@ -26,7 +24,6 @@ class Identity: def __getitem__(self, index: int) -> int: return index - reveal_type(Identity()[0]) # revealed: int ``` @@ -35,18 +32,15 @@ reveal_type(Identity()[0]) # revealed: int ```py flag = True - class Identity: if flag: def __getitem__(self, index: int) -> int: return index - else: def __getitem__(self, index: int) -> str: return str(index) - reveal_type(Identity()[0]) # revealed: int | str ``` diff --git a/crates/red_knot_python_semantic/resources/mdtest/subscript/string.md b/crates/red_knot_python_semantic/resources/mdtest/subscript/string.md index 0e1c21389abdc..6987a95a70b18 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/subscript/string.md +++ b/crates/red_knot_python_semantic/resources/mdtest/subscript/string.md @@ -10,6 +10,9 @@ reveal_type(s[1]) # revealed: Literal["b"] reveal_type(s[-1]) # revealed: Literal["e"] reveal_type(s[-2]) # revealed: Literal["d"] +reveal_type(s[False]) # revealed: Literal["a"] +reveal_type(s[True]) # revealed: Literal["b"] + a = s[8] # error: [index-out-of-bounds] "Index 8 is out of bounds for string `Literal["abcde"]` with length 5" reveal_type(a) # revealed: Unknown @@ -20,11 +23,10 @@ reveal_type(b) # revealed: Unknown ## Function return ```py -def add(x: int, y: int) -> int: - return x + y - +def int_instance() -> int: + return 42 -a = "abcde"[add(0, 1)] +a = "abcde"[int_instance()] # TODO: Support overloads... Should be `str` reveal_type(a) # revealed: @Todo ``` diff --git a/crates/red_knot_python_semantic/resources/mdtest/unary/instance.md b/crates/red_knot_python_semantic/resources/mdtest/unary/instance.md new file mode 100644 index 0000000000000..c07bf7b56c34b --- /dev/null +++ b/crates/red_knot_python_semantic/resources/mdtest/unary/instance.md @@ -0,0 +1,29 @@ +# Unary Operations + +```py +class Number: + def __init__(self, value: int): + self.value = 1 + + def __pos__(self) -> int: + return +self.value + + def __neg__(self) -> int: + return -self.value + + def __invert__(self) -> Literal[True]: + return True + +a = Number() + +reveal_type(+a) # revealed: int +reveal_type(-a) # revealed: int +reveal_type(~a) # revealed: @Todo + +class NoDunder: ... + +b = NoDunder() ++b # error: [unsupported-operator] "Unary operator `+` is unsupported for type `NoDunder`" +-b # error: [unsupported-operator] "Unary operator `-` is unsupported for type `NoDunder`" +~b # error: [unsupported-operator] "Unary operator `~` is unsupported for type `NoDunder`" +``` diff --git a/crates/red_knot_python_semantic/resources/mdtest/unary/not.md b/crates/red_knot_python_semantic/resources/mdtest/unary/not.md index b85cb2f31a8e5..bb9f6ccac735d 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/unary/not.md +++ b/crates/red_knot_python_semantic/resources/mdtest/unary/not.md @@ -12,11 +12,9 @@ reveal_type(not not None) # revealed: Literal[False] ```py from typing import reveal_type - def f(): return 1 - reveal_type(not f) # revealed: Literal[False] # TODO Unknown should not be part of the type of typing.reveal_type # reveal_type(not reveal_type) revealed: Literal[False] @@ -28,7 +26,6 @@ reveal_type(not f) # revealed: Literal[False] import b import warnings - reveal_type(not b) # revealed: Literal[False] reveal_type(not warnings) # revealed: Literal[False] ``` @@ -40,6 +37,11 @@ y = 1 ## Union ```py +def bool_instance() -> bool: + return True + +flag = bool_instance() + if flag: p = 1 q = 3.3 diff --git a/crates/red_knot_python_semantic/resources/mdtest/unpacking.md b/crates/red_knot_python_semantic/resources/mdtest/unpacking.md index be4ef400b3b7a..e1ee64721161f 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/unpacking.md +++ b/crates/red_knot_python_semantic/resources/mdtest/unpacking.md @@ -155,12 +155,10 @@ class Iterator: def __next__(self) -> int: return 42 - class Iterable: def __iter__(self) -> Iterator: return Iterator() - (a, b) = Iterable() reveal_type(a) # revealed: int reveal_type(b) # revealed: int @@ -173,12 +171,10 @@ class Iterator: def __next__(self) -> int: return 42 - class Iterable: def __iter__(self) -> Iterator: return Iterator() - (a, (b, c), d) = (1, Iterable(), 2) reveal_type(a) # revealed: Literal[1] reveal_type(b) # revealed: int diff --git a/crates/red_knot_python_semantic/src/ast_node_ref.rs b/crates/red_knot_python_semantic/src/ast_node_ref.rs index 6ea0267c0b280..367748b5ad285 100644 --- a/crates/red_knot_python_semantic/src/ast_node_ref.rs +++ b/crates/red_knot_python_semantic/src/ast_node_ref.rs @@ -132,7 +132,7 @@ mod tests { #[test] fn inequality() { let parsed_raw = parse_unchecked_source("1 + 2", PySourceType::Python); - let parsed = ParsedModule::new(parsed_raw.clone()); + let parsed = ParsedModule::new(parsed_raw); let stmt = &parsed.syntax().body[0]; let node = unsafe { AstNodeRef::new(parsed.clone(), stmt) }; @@ -150,7 +150,7 @@ mod tests { #[allow(unsafe_code)] fn debug() { let parsed_raw = parse_unchecked_source("1 + 2", PySourceType::Python); - let parsed = ParsedModule::new(parsed_raw.clone()); + let parsed = ParsedModule::new(parsed_raw); let stmt = &parsed.syntax().body[0]; diff --git a/crates/red_knot_python_semantic/src/lib.rs b/crates/red_knot_python_semantic/src/lib.rs index 2c1b059b29d3d..f48f4900089f0 100644 --- a/crates/red_knot_python_semantic/src/lib.rs +++ b/crates/red_knot_python_semantic/src/lib.rs @@ -21,5 +21,6 @@ mod semantic_model; pub(crate) mod site_packages; mod stdlib; pub mod types; +mod util; type FxOrderSet = ordermap::set::OrderSet>; diff --git a/crates/red_knot_python_semantic/src/module_resolver/resolver.rs b/crates/red_knot_python_semantic/src/module_resolver/resolver.rs index a51bee306508d..808dbab0fafdd 100644 --- a/crates/red_knot_python_semantic/src/module_resolver/resolver.rs +++ b/crates/red_knot_python_semantic/src/module_resolver/resolver.rs @@ -1294,7 +1294,7 @@ mod tests { search_paths: SearchPathSettings { extra_paths: vec![], src_root: src.clone(), - custom_typeshed: Some(custom_typeshed.clone()), + custom_typeshed: Some(custom_typeshed), site_packages: SitePackages::Known(vec![site_packages]), }, }, @@ -1445,7 +1445,7 @@ mod tests { assert_function_query_was_not_run( &db, resolve_module_query, - ModuleNameIngredient::new(&db, functools_module_name.clone()), + ModuleNameIngredient::new(&db, functools_module_name), &events, ); assert_eq!(functools_module.search_path(), &stdlib); diff --git a/crates/red_knot_python_semantic/src/semantic_index/builder.rs b/crates/red_knot_python_semantic/src/semantic_index/builder.rs index 61e810ffb38e4..523682fcd3f1f 100644 --- a/crates/red_knot_python_semantic/src/semantic_index/builder.rs +++ b/crates/red_knot_python_semantic/src/semantic_index/builder.rs @@ -27,7 +27,7 @@ use crate::semantic_index::use_def::{FlowSnapshot, UseDefMapBuilder}; use crate::semantic_index::SemanticIndex; use crate::Db; -use super::constraint::{Constraint, PatternConstraint}; +use super::constraint::{Constraint, ConstraintNode, PatternConstraint}; use super::definition::{ AssignmentKind, DefinitionCategory, ExceptHandlerDefinitionNodeRef, MatchPatternDefinitionNodeRef, WithItemDefinitionNodeRef, @@ -243,12 +243,23 @@ impl<'db> SemanticIndexBuilder<'db> { definition } - fn add_expression_constraint(&mut self, constraint_node: &ast::Expr) -> Expression<'db> { + fn add_expression_constraint(&mut self, constraint_node: &ast::Expr) -> Constraint<'db> { let expression = self.add_standalone_expression(constraint_node); - self.current_use_def_map_mut() - .record_constraint(Constraint::Expression(expression)); + let constraint = Constraint { + node: ConstraintNode::Expression(expression), + is_positive: true, + }; + self.current_use_def_map_mut().record_constraint(constraint); - expression + constraint + } + + fn add_negated_constraint(&mut self, constraint: Constraint<'db>) { + self.current_use_def_map_mut() + .record_constraint(Constraint { + node: constraint.node, + is_positive: false, + }); } fn push_assignment(&mut self, assignment: CurrentAssignment<'db>) { @@ -285,7 +296,10 @@ impl<'db> SemanticIndexBuilder<'db> { countme::Count::default(), ); self.current_use_def_map_mut() - .record_constraint(Constraint::Pattern(pattern_constraint)); + .record_constraint(Constraint { + node: ConstraintNode::Pattern(pattern_constraint), + is_positive: true, + }); pattern_constraint } @@ -639,7 +653,8 @@ where ast::Stmt::If(node) => { self.visit_expr(&node.test); let pre_if = self.flow_snapshot(); - self.add_expression_constraint(&node.test); + let constraint = self.add_expression_constraint(&node.test); + let mut constraints = vec![constraint]; self.visit_body(&node.body); let mut post_clauses: Vec = vec![]; for clause in &node.elif_else_clauses { @@ -649,7 +664,14 @@ where // we can only take an elif/else branch if none of the previous ones were // taken, so the block entry state is always `pre_if` self.flow_restore(pre_if.clone()); - self.visit_elif_else_clause(clause); + for constraint in &constraints { + self.add_negated_constraint(*constraint); + } + if let Some(elif_test) = &clause.test { + self.visit_expr(elif_test); + constraints.push(self.add_expression_constraint(elif_test)); + } + self.visit_body(&clause.body); } for post_clause_state in post_clauses { self.flow_merge(post_clause_state); @@ -1012,7 +1034,7 @@ where // Add symbols and definitions for the parameters to the lambda scope. if let Some(parameters) = &lambda.parameters { - for parameter in &**parameters { + for parameter in parameters { self.declare_parameter(parameter); } } diff --git a/crates/red_knot_python_semantic/src/semantic_index/constraint.rs b/crates/red_knot_python_semantic/src/semantic_index/constraint.rs index 9659d5f82f903..44b542f0e90ac 100644 --- a/crates/red_knot_python_semantic/src/semantic_index/constraint.rs +++ b/crates/red_knot_python_semantic/src/semantic_index/constraint.rs @@ -7,7 +7,13 @@ use crate::semantic_index::expression::Expression; use crate::semantic_index::symbol::{FileScopeId, ScopeId}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum Constraint<'db> { +pub(crate) struct Constraint<'db> { + pub(crate) node: ConstraintNode<'db>, + pub(crate) is_positive: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ConstraintNode<'db> { Expression(Expression<'db>), Pattern(PatternConstraint<'db>), } diff --git a/crates/red_knot_python_semantic/src/semantic_index/definition.rs b/crates/red_knot_python_semantic/src/semantic_index/definition.rs index 20a6647b3c7a7..4da4c4e6a71d6 100644 --- a/crates/red_knot_python_semantic/src/semantic_index/definition.rs +++ b/crates/red_knot_python_semantic/src/semantic_index/definition.rs @@ -47,7 +47,13 @@ impl<'db> Definition<'db> { self.kind(db).category().is_binding() } - /// Return true if this is a symbol was defined in the `typing` or `typing_extensions` modules + pub(crate) fn is_builtin_definition(self, db: &'db dyn Db) -> bool { + file_to_module(db, self.file(db)).is_some_and(|module| { + module.search_path().is_standard_library() && matches!(&**module.name(), "builtins") + }) + } + + /// Return true if this symbol was defined in the `typing` or `typing_extensions` modules pub(crate) fn is_typing_definition(self, db: &'db dyn Db) -> bool { file_to_module(db, self.file(db)).is_some_and(|module| { module.search_path().is_standard_library() @@ -290,7 +296,7 @@ impl DefinitionNodeRef<'_> { handler, is_star, }) => DefinitionKind::ExceptHandler(ExceptHandlerDefinitionKind { - handler: AstNodeRef::new(parsed.clone(), handler), + handler: AstNodeRef::new(parsed, handler), is_star, }), } diff --git a/crates/red_knot_python_semantic/src/semantic_model.rs b/crates/red_knot_python_semantic/src/semantic_model.rs index f4a66ef199c00..9cdbec789c50a 100644 --- a/crates/red_knot_python_semantic/src/semantic_model.rs +++ b/crates/red_knot_python_semantic/src/semantic_model.rs @@ -175,7 +175,6 @@ mod tests { use crate::db::tests::TestDb; use crate::program::{Program, SearchPathSettings}; use crate::python_version::PythonVersion; - use crate::types::Type; use crate::{HasTy, ProgramSettings, SemanticModel}; fn setup_db<'a>(files: impl IntoIterator) -> anyhow::Result { @@ -205,7 +204,7 @@ mod tests { let model = SemanticModel::new(&db, foo); let ty = function.ty(&model); - assert!(matches!(ty, Type::Function(_))); + assert!(ty.is_function_literal()); Ok(()) } @@ -222,7 +221,7 @@ mod tests { let model = SemanticModel::new(&db, foo); let ty = class.ty(&model); - assert!(matches!(ty, Type::Class(_))); + assert!(ty.is_class_literal()); Ok(()) } @@ -243,7 +242,7 @@ mod tests { let model = SemanticModel::new(&db, bar); let ty = alias.ty(&model); - assert!(matches!(ty, Type::Class(_))); + assert!(ty.is_class_literal()); Ok(()) } diff --git a/crates/red_knot_python_semantic/src/types.rs b/crates/red_knot_python_semantic/src/types.rs index 8f5412ef4373f..c932492f21f35 100644 --- a/crates/red_knot_python_semantic/src/types.rs +++ b/crates/red_knot_python_semantic/src/types.rs @@ -148,18 +148,18 @@ fn bindings_ty<'db>( binding, constraints, }| { - let mut constraint_tys = - constraints.filter_map(|constraint| narrowing_constraint(db, constraint, binding)); + let mut constraint_tys = constraints + .filter_map(|constraint| narrowing_constraint(db, constraint, binding)) + .peekable(); + let binding_ty = binding_ty(db, binding); - if let Some(first_constraint_ty) = constraint_tys.next() { - let mut builder = IntersectionBuilder::new(db); - builder = builder - .add_positive(binding_ty) - .add_positive(first_constraint_ty); - for constraint_ty in constraint_tys { - builder = builder.add_positive(constraint_ty); - } - builder.build() + if constraint_tys.peek().is_some() { + constraint_tys + .fold( + IntersectionBuilder::new(db).add_positive(binding_ty), + IntersectionBuilder::add_positive, + ) + .build() } else { binding_ty } @@ -211,7 +211,13 @@ fn declarations_ty<'db>( let declared_ty = if let Some(second) = all_types.next() { let mut builder = UnionBuilder::new(db).add(first); for other in [second].into_iter().chain(all_types) { - if !first.is_equivalent_to(db, other) { + // Make sure not to emit spurious errors relating to `Type::Todo`, + // since we only infer this type due to a limitation in our current model. + // + // `Unknown` is different here, since we might infer `Unknown` + // for one of these due to a variable being defined in one possible + // control-flow branch but not another one. + if !first.is_equivalent_to(db, other) && !first.is_todo() && !other.is_todo() { conflicting.push(other); } builder = builder.add(other); @@ -255,11 +261,11 @@ pub enum Type<'db> { /// `Todo` would change the output type. Todo, /// A specific function object - Function(FunctionType<'db>), + FunctionLiteral(FunctionType<'db>), /// A specific module object - Module(File), + ModuleLiteral(File), /// A specific class object - Class(ClassType<'db>), + ClassLiteral(ClassType<'db>), /// The set of Python objects with the given class in their __class__'s method resolution order Instance(ClassType<'db>), /// The set of objects in any of the types in the union @@ -292,28 +298,43 @@ impl<'db> Type<'db> { matches!(self, Type::Never) } - pub const fn into_class_type(self) -> Option> { + pub const fn is_todo(&self) -> bool { + matches!(self, Type::Todo) + } + + pub const fn into_class_literal_type(self) -> Option> { match self { - Type::Class(class_type) => Some(class_type), + Type::ClassLiteral(class_type) => Some(class_type), _ => None, } } - pub fn expect_class(self) -> ClassType<'db> { - self.into_class_type() - .expect("Expected a Type::Class variant") + #[track_caller] + pub fn expect_class_literal(self) -> ClassType<'db> { + self.into_class_literal_type() + .expect("Expected a Type::ClassLiteral variant") + } + + pub const fn is_class_literal(&self) -> bool { + matches!(self, Type::ClassLiteral(..)) } - pub const fn into_module_type(self) -> Option { + pub const fn into_module_literal_type(self) -> Option { match self { - Type::Module(file) => Some(file), + Type::ModuleLiteral(file) => Some(file), _ => None, } } - pub fn expect_module(self) -> File { - self.into_module_type() - .expect("Expected a Type::Module variant") + #[track_caller] + pub fn expect_module_literal(self) -> File { + self.into_module_literal_type() + .expect("Expected a Type::ModuleLiteral variant") + } + + #[must_use] + pub fn negate(&self, db: &'db dyn Db) -> Type<'db> { + IntersectionBuilder::new(db).add_negative(*self).build() } pub const fn into_union_type(self) -> Option> { @@ -323,11 +344,16 @@ impl<'db> Type<'db> { } } + #[track_caller] pub fn expect_union(self) -> UnionType<'db> { self.into_union_type() .expect("Expected a Type::Union variant") } + pub const fn is_union(&self) -> bool { + matches!(self, Type::Union(..)) + } + pub const fn into_intersection_type(self) -> Option> { match self { Type::Intersection(intersection_type) => Some(intersection_type), @@ -335,21 +361,27 @@ impl<'db> Type<'db> { } } + #[track_caller] pub fn expect_intersection(self) -> IntersectionType<'db> { self.into_intersection_type() .expect("Expected a Type::Intersection variant") } - pub const fn into_function_type(self) -> Option> { + pub const fn into_function_literal_type(self) -> Option> { match self { - Type::Function(function_type) => Some(function_type), + Type::FunctionLiteral(function_type) => Some(function_type), _ => None, } } - pub fn expect_function(self) -> FunctionType<'db> { - self.into_function_type() - .expect("Expected a Type::Function variant") + #[track_caller] + pub fn expect_function_literal(self) -> FunctionType<'db> { + self.into_function_literal_type() + .expect("Expected a Type::FunctionLiteral variant") + } + + pub const fn is_function_literal(&self) -> bool { + matches!(self, Type::FunctionLiteral(..)) } pub const fn into_int_literal_type(self) -> Option { @@ -359,11 +391,20 @@ impl<'db> Type<'db> { } } + #[track_caller] pub fn expect_int_literal(self) -> i64 { self.into_int_literal_type() .expect("Expected a Type::IntLiteral variant") } + pub const fn is_boolean_literal(&self) -> bool { + matches!(self, Type::BooleanLiteral(..)) + } + + pub const fn is_literal_string(&self) -> bool { + matches!(self, Type::LiteralString) + } + pub fn may_be_unbound(&self, db: &'db dyn Db) -> bool { match self { Type::Unbound => true, @@ -387,18 +428,8 @@ impl<'db> Type<'db> { pub fn is_stdlib_symbol(&self, db: &'db dyn Db, module_name: &str, name: &str) -> bool { match self { - Type::Class(class) => class.is_stdlib_symbol(db, module_name, name), - Type::Function(function) => function.is_stdlib_symbol(db, module_name, name), - _ => false, - } - } - - /// Return true if the type is a class or a union of classes. - pub fn is_class(&self, db: &'db dyn Db) -> bool { - match self { - Type::Union(union) => union.elements(db).iter().all(|ty| ty.is_class(db)), - Type::Class(_) => true, - // / TODO include type[X], once we add that type + Type::ClassLiteral(class) => class.is_stdlib_symbol(db, module_name, name), + Type::FunctionLiteral(function) => function.is_stdlib_symbol(db, module_name, name), _ => false, } } @@ -415,6 +446,8 @@ impl<'db> Type<'db> { (_, Type::Unknown | Type::Any | Type::Todo) => false, (Type::Never, _) => true, (_, Type::Never) => false, + (_, Type::Instance(class)) if class.is_known(db, KnownClass::Object) => true, + (Type::Instance(class), _) if class.is_known(db, KnownClass::Object) => false, (Type::BooleanLiteral(_), Type::Instance(class)) if class.is_known(db, KnownClass::Bool) => { @@ -434,12 +467,22 @@ impl<'db> Type<'db> { { true } + (Type::ClassLiteral(..), Type::Instance(class)) + if class.is_known(db, KnownClass::Type) => + { + true + } + (Type::Union(union), ty) => union + .elements(db) + .iter() + .all(|&elem_ty| elem_ty.is_subtype_of(db, ty)), (ty, Type::Union(union)) => union .elements(db) .iter() .any(|&elem_ty| ty.is_subtype_of(db, elem_ty)), - (_, Type::Instance(class)) if class.is_known(db, KnownClass::Object) => true, - (Type::Instance(class), _) if class.is_known(db, KnownClass::Object) => false, + (Type::Instance(self_class), Type::Instance(target_class)) => { + self_class.is_subclass_of(db, target_class) + } // TODO _ => false, } @@ -510,17 +553,17 @@ impl<'db> Type<'db> { | Type::IntLiteral(..) | Type::StringLiteral(..) | Type::BytesLiteral(..) - | Type::Function(..) - | Type::Module(..) - | Type::Class(..)), + | Type::FunctionLiteral(..) + | Type::ModuleLiteral(..) + | Type::ClassLiteral(..)), right @ (Type::None | Type::BooleanLiteral(..) | Type::IntLiteral(..) | Type::StringLiteral(..) | Type::BytesLiteral(..) - | Type::Function(..) - | Type::Module(..) - | Type::Class(..)), + | Type::FunctionLiteral(..) + | Type::ModuleLiteral(..) + | Type::ClassLiteral(..)), ) => left != right, (Type::None, Type::Instance(class_type)) | (Type::Instance(class_type), Type::None) => { @@ -570,12 +613,12 @@ impl<'db> Type<'db> { (Type::BytesLiteral(..), _) | (_, Type::BytesLiteral(..)) => true, ( - Type::Function(..) | Type::Module(..) | Type::Class(..), + Type::FunctionLiteral(..) | Type::ModuleLiteral(..) | Type::ClassLiteral(..), Type::Instance(class_type), ) | ( Type::Instance(class_type), - Type::Function(..) | Type::Module(..) | Type::Class(..), + Type::FunctionLiteral(..) | Type::ModuleLiteral(..) | Type::ClassLiteral(..), ) => !class_type.is_known(db, KnownClass::Object), (Type::Instance(..), Type::Instance(..)) => { @@ -594,7 +637,7 @@ impl<'db> Type<'db> { tuple .elements(db) .iter() - .zip(other_tuple.elements(db).iter()) + .zip(other_tuple.elements(db)) .any(|(e1, e2)| e1.is_disjoint_from(db, *e2)) } else { true @@ -636,7 +679,7 @@ impl<'db> Type<'db> { // are both of type Literal[345], for example. false } - Type::None | Type::BooleanLiteral(_) | Type::Function(..) | Type::Class(..) | Type::Module(..) => true, + Type::None | Type::BooleanLiteral(_) | Type::FunctionLiteral(..) | Type::ClassLiteral(..) | Type::ModuleLiteral(..) => true, Type::Tuple(..) => { // The empty tuple is a singleton on CPython and PyPy, but not on other Python // implementations such as GraalPy. Its *use* as a singleton is discouraged and @@ -664,6 +707,55 @@ impl<'db> Type<'db> { } } + /// Return true if this type is non-empty and all inhabitants of this type compare equal. + pub(crate) fn is_single_valued(self, db: &'db dyn Db) -> bool { + match self { + Type::None + | Type::FunctionLiteral(..) + | Type::ModuleLiteral(..) + | Type::ClassLiteral(..) + | Type::IntLiteral(..) + | Type::BooleanLiteral(..) + | Type::StringLiteral(..) + | Type::BytesLiteral(..) => true, + + Type::Tuple(tuple) => tuple + .elements(db) + .iter() + .all(|elem| elem.is_single_valued(db)), + + Type::Instance(class_type) => match class_type.known(db) { + Some(KnownClass::NoneType) => true, + Some( + KnownClass::Bool + | KnownClass::Object + | KnownClass::Bytes + | KnownClass::Type + | KnownClass::Int + | KnownClass::Float + | KnownClass::Str + | KnownClass::List + | KnownClass::Tuple + | KnownClass::Set + | KnownClass::Dict + | KnownClass::GenericAlias + | KnownClass::ModuleType + | KnownClass::FunctionType, + ) => false, + None => false, + }, + + Type::Any + | Type::Never + | Type::Unknown + | Type::Unbound + | Type::Todo + | Type::Union(..) + | Type::Intersection(..) + | Type::LiteralString => false, + } + } + /// Resolve a member access of a type. /// /// For example, if `foo` is `Type::Instance()`, @@ -691,12 +783,12 @@ impl<'db> Type<'db> { // TODO: attribute lookup on None type Type::Todo } - Type::Function(_) => { + Type::FunctionLiteral(_) => { // TODO: attribute lookup on function type Type::Todo } - Type::Module(file) => global_symbol_ty(db, *file, name), - Type::Class(class) => class.class_member(db, name), + Type::ModuleLiteral(file) => global_symbol_ty(db, *file, name), + Type::ClassLiteral(class) => class.class_member(db, name), Type::Instance(_) => { // TODO MRO? get_own_instance_member, get_instance_member Type::Todo @@ -744,9 +836,9 @@ impl<'db> Type<'db> { Truthiness::Ambiguous } Type::None => Truthiness::AlwaysFalse, - Type::Function(_) => Truthiness::AlwaysTrue, - Type::Module(_) => Truthiness::AlwaysTrue, - Type::Class(_) => { + Type::FunctionLiteral(_) => Truthiness::AlwaysTrue, + Type::ModuleLiteral(_) => Truthiness::AlwaysTrue, + Type::ClassLiteral(_) => { // TODO: lookup `__bool__` and `__len__` methods on the class's metaclass // More info in https://docs.python.org/3/library/stdtypes.html#truth-value-testing Truthiness::Ambiguous @@ -791,16 +883,19 @@ impl<'db> Type<'db> { fn call(self, db: &'db dyn Db, arg_types: &[Type<'db>]) -> CallOutcome<'db> { match self { // TODO validate typed call arguments vs callable signature - Type::Function(function_type) => match function_type.known(db) { - None => CallOutcome::callable(function_type.return_type(db)), - Some(KnownFunction::RevealType) => CallOutcome::revealed( - function_type.return_type(db), - *arg_types.first().unwrap_or(&Type::Unknown), - ), - }, + Type::FunctionLiteral(function_type) => { + if function_type.is_known(db, KnownFunction::RevealType) { + CallOutcome::revealed( + function_type.return_type(db), + *arg_types.first().unwrap_or(&Type::Unknown), + ) + } else { + CallOutcome::callable(function_type.return_type(db)) + } + } // TODO annotated return type on `__new__` or metaclass `__call__` - Type::Class(class) => { + Type::ClassLiteral(class) => { CallOutcome::callable(match class.known(db) { // If the class is the builtin-bool class (for example `bool(1)`), we try to // return the specific truthiness value of the input arg, `Literal[True]` for @@ -860,7 +955,7 @@ impl<'db> Type<'db> { fn iterate(self, db: &'db dyn Db) -> IterationOutcome<'db> { if let Type::Tuple(tuple_type) = self { return IterationOutcome::Iterable { - element_ty: UnionType::from_elements(db, &**tuple_type.elements(db)), + element_ty: UnionType::from_elements(db, tuple_type.elements(db)), }; } @@ -920,7 +1015,7 @@ impl<'db> Type<'db> { Type::Unknown => Type::Unknown, Type::Unbound => Type::Unknown, Type::Never => Type::Never, - Type::Class(class) => Type::Instance(*class), + Type::ClassLiteral(class) => Type::Instance(*class), Type::Union(union) => union.map(db, |element| element.to_instance(db)), // TODO: we can probably do better here: --Alex Type::Intersection(_) => Type::Todo, @@ -928,9 +1023,9 @@ impl<'db> Type<'db> { // since they already indicate that the object is an instance of some kind: Type::BooleanLiteral(_) | Type::BytesLiteral(_) - | Type::Function(_) + | Type::FunctionLiteral(_) | Type::Instance(_) - | Type::Module(_) + | Type::ModuleLiteral(_) | Type::IntLiteral(_) | Type::StringLiteral(_) | Type::Tuple(_) @@ -946,17 +1041,17 @@ impl<'db> Type<'db> { match self { Type::Unbound => Type::Unbound, Type::Never => Type::Never, - Type::Instance(class) => Type::Class(*class), + Type::Instance(class) => Type::ClassLiteral(*class), Type::Union(union) => union.map(db, |ty| ty.to_meta_type(db)), Type::BooleanLiteral(_) => KnownClass::Bool.to_class(db), Type::BytesLiteral(_) => KnownClass::Bytes.to_class(db), Type::IntLiteral(_) => KnownClass::Int.to_class(db), - Type::Function(_) => KnownClass::FunctionType.to_class(db), - Type::Module(_) => KnownClass::ModuleType.to_class(db), + Type::FunctionLiteral(_) => KnownClass::FunctionType.to_class(db), + Type::ModuleLiteral(_) => KnownClass::ModuleType.to_class(db), Type::Tuple(_) => KnownClass::Tuple.to_class(db), Type::None => KnownClass::NoneType.to_class(db), // TODO not accurate if there's a custom metaclass... - Type::Class(_) => KnownClass::Type.to_class(db), + Type::ClassLiteral(_) => KnownClass::Type.to_class(db), // TODO can we do better here? `type[LiteralString]`? Type::StringLiteral(_) | Type::LiteralString => KnownClass::Str.to_class(db), // TODO: `type[Any]`? @@ -1307,7 +1402,7 @@ impl<'db> CallOutcome<'db> { let mut not_callable = vec![]; let mut union_builder = UnionBuilder::new(db); let mut revealed = false; - for outcome in &**outcomes { + for outcome in outcomes { let return_ty = match outcome { Self::NotCallable { not_callable_ty } => { not_callable.push(*not_callable_ty); @@ -1518,6 +1613,10 @@ impl<'db> FunctionType<'db> { }) .unwrap_or(Type::Unknown) } + + pub fn is_known(self, db: &'db dyn Db, known_function: KnownFunction) -> bool { + self.known(db) == Some(known_function) + } } /// Non-exhaustive enumeration of known functions (e.g. `builtins.reveal_type`, ...) that might @@ -1526,6 +1625,8 @@ impl<'db> FunctionType<'db> { pub enum KnownFunction { /// `builtins.reveal_type`, `typing.reveal_type` or `typing_extensions.reveal_type` RevealType, + /// `builtins.isinstance` + IsInstance, } #[salsa::interned] @@ -1582,6 +1683,18 @@ impl<'db> ClassType<'db> { }) } + pub fn is_subclass_of(self, db: &'db dyn Db, other: ClassType) -> bool { + // TODO: we need to iterate over the *MRO* here, not the bases + (other == self) + || self.bases(db).any(|base| match base { + Type::ClassLiteral(base_class) => base_class == other, + // `is_subclass_of` is checking the subtype relation, in which gradual types do not + // participate, so we should not return `True` if we find `Any/Unknown` in the + // bases. + _ => false, + }) + } + /// Returns the class member of this class named `name`. /// /// The member resolves to a member of the class itself or any of its bases. @@ -1741,7 +1854,7 @@ mod tests { None, Any, IntLiteral(i64), - BoolLiteral(bool), + BooleanLiteral(bool), StringLiteral(&'static str), LiteralString, BytesLiteral(&'static str), @@ -1760,7 +1873,7 @@ mod tests { Ty::Any => Type::Any, Ty::IntLiteral(n) => Type::IntLiteral(n), Ty::StringLiteral(s) => Type::StringLiteral(StringLiteralType::new(db, s)), - Ty::BoolLiteral(b) => Type::BooleanLiteral(b), + Ty::BooleanLiteral(b) => Type::BooleanLiteral(b), Ty::LiteralString => Type::LiteralString, Ty::BytesLiteral(s) => Type::BytesLiteral(BytesLiteralType::new(db, s.as_bytes())), Ty::BuiltinInstance(s) => builtins_symbol_ty(db, s).to_instance(db), @@ -1816,13 +1929,24 @@ mod tests { #[test_case(Ty::BuiltinInstance("str"), Ty::BuiltinInstance("object"))] #[test_case(Ty::BuiltinInstance("int"), Ty::BuiltinInstance("object"))] + #[test_case(Ty::BuiltinInstance("bool"), Ty::BuiltinInstance("object"))] + #[test_case(Ty::BuiltinInstance("bool"), Ty::BuiltinInstance("int"))] #[test_case(Ty::Never, Ty::IntLiteral(1))] #[test_case(Ty::IntLiteral(1), Ty::BuiltinInstance("int"))] + #[test_case(Ty::IntLiteral(1), Ty::BuiltinInstance("object"))] + #[test_case(Ty::BooleanLiteral(true), Ty::BuiltinInstance("bool"))] + #[test_case(Ty::BooleanLiteral(true), Ty::BuiltinInstance("object"))] #[test_case(Ty::StringLiteral("foo"), Ty::BuiltinInstance("str"))] + #[test_case(Ty::StringLiteral("foo"), Ty::BuiltinInstance("object"))] #[test_case(Ty::StringLiteral("foo"), Ty::LiteralString)] #[test_case(Ty::LiteralString, Ty::BuiltinInstance("str"))] + #[test_case(Ty::LiteralString, Ty::BuiltinInstance("object"))] #[test_case(Ty::BytesLiteral("foo"), Ty::BuiltinInstance("bytes"))] + #[test_case(Ty::BytesLiteral("foo"), Ty::BuiltinInstance("object"))] #[test_case(Ty::IntLiteral(1), Ty::Union(vec![Ty::BuiltinInstance("int"), Ty::BuiltinInstance("str")]))] + #[test_case(Ty::Union(vec![Ty::BuiltinInstance("str"), Ty::BuiltinInstance("int")]), Ty::BuiltinInstance("object"))] + #[test_case(Ty::Union(vec![Ty::IntLiteral(1), Ty::IntLiteral(2)]), Ty::Union(vec![Ty::IntLiteral(1), Ty::IntLiteral(2), Ty::IntLiteral(3)]))] + #[test_case(Ty::BuiltinInstance("TypeError"), Ty::BuiltinInstance("Exception"))] fn is_subtype_of(from: Ty, to: Ty) { let db = setup_db(); assert!(from.into_type(&db).is_subtype_of(&db, to.into_type(&db))); @@ -1835,6 +1959,8 @@ mod tests { #[test_case(Ty::IntLiteral(1), Ty::Any)] #[test_case(Ty::IntLiteral(1), Ty::Union(vec![Ty::Unknown, Ty::BuiltinInstance("str")]))] #[test_case(Ty::IntLiteral(1), Ty::BuiltinInstance("str"))] + #[test_case(Ty::Union(vec![Ty::IntLiteral(1), Ty::IntLiteral(2)]), Ty::IntLiteral(1))] + #[test_case(Ty::Union(vec![Ty::IntLiteral(1), Ty::IntLiteral(2)]), Ty::Union(vec![Ty::IntLiteral(1), Ty::IntLiteral(3)]))] #[test_case(Ty::BuiltinInstance("int"), Ty::BuiltinInstance("str"))] #[test_case(Ty::BuiltinInstance("int"), Ty::IntLiteral(1))] fn is_not_subtype_of(from: Ty, to: Ty) { @@ -1842,6 +1968,32 @@ mod tests { assert!(!from.into_type(&db).is_subtype_of(&db, to.into_type(&db))); } + #[test] + fn is_subtype_of_class_literals() { + let mut db = setup_db(); + db.write_dedented( + "/src/module.py", + " + class A: ... + class B: ... + U = A if flag else B + ", + ) + .unwrap(); + let module = ruff_db::files::system_path_to_file(&db, "/src/module.py").unwrap(); + + let type_a = super::global_symbol_ty(&db, module, "A"); + let type_u = super::global_symbol_ty(&db, module, "U"); + + assert!(type_a.is_class_literal()); + assert!(type_a.is_subtype_of(&db, Ty::BuiltinInstance("type").into_type(&db))); + assert!(type_a.is_subtype_of(&db, Ty::BuiltinInstance("object").into_type(&db))); + + assert!(type_u.is_union()); + assert!(type_u.is_subtype_of(&db, Ty::BuiltinInstance("type").into_type(&db))); + assert!(type_u.is_subtype_of(&db, Ty::BuiltinInstance("object").into_type(&db))); + } + #[test_case( Ty::Union(vec![Ty::IntLiteral(1), Ty::IntLiteral(2)]), Ty::Union(vec![Ty::IntLiteral(1), Ty::IntLiteral(2)]) @@ -1855,17 +2007,17 @@ mod tests { #[test_case(Ty::Never, Ty::Never)] #[test_case(Ty::Never, Ty::None)] #[test_case(Ty::Never, Ty::BuiltinInstance("int"))] - #[test_case(Ty::None, Ty::BoolLiteral(true))] + #[test_case(Ty::None, Ty::BooleanLiteral(true))] #[test_case(Ty::None, Ty::IntLiteral(1))] #[test_case(Ty::None, Ty::StringLiteral("test"))] #[test_case(Ty::None, Ty::BytesLiteral("test"))] #[test_case(Ty::None, Ty::LiteralString)] #[test_case(Ty::None, Ty::BuiltinInstance("int"))] #[test_case(Ty::None, Ty::Tuple(vec![Ty::None]))] - #[test_case(Ty::BoolLiteral(true), Ty::BoolLiteral(false))] - #[test_case(Ty::BoolLiteral(true), Ty::Tuple(vec![Ty::None]))] - #[test_case(Ty::BoolLiteral(true), Ty::IntLiteral(1))] - #[test_case(Ty::BoolLiteral(false), Ty::IntLiteral(0))] + #[test_case(Ty::BooleanLiteral(true), Ty::BooleanLiteral(false))] + #[test_case(Ty::BooleanLiteral(true), Ty::Tuple(vec![Ty::None]))] + #[test_case(Ty::BooleanLiteral(true), Ty::IntLiteral(1))] + #[test_case(Ty::BooleanLiteral(false), Ty::IntLiteral(0))] #[test_case(Ty::IntLiteral(1), Ty::IntLiteral(2))] #[test_case(Ty::IntLiteral(1), Ty::Tuple(vec![Ty::None]))] #[test_case(Ty::StringLiteral("a"), Ty::StringLiteral("b"))] @@ -1894,10 +2046,10 @@ mod tests { #[test_case(Ty::None, Ty::BuiltinInstance("object"))] #[test_case(Ty::BuiltinInstance("int"), Ty::BuiltinInstance("int"))] #[test_case(Ty::BuiltinInstance("str"), Ty::LiteralString)] - #[test_case(Ty::BoolLiteral(true), Ty::BoolLiteral(true))] - #[test_case(Ty::BoolLiteral(false), Ty::BoolLiteral(false))] - #[test_case(Ty::BoolLiteral(true), Ty::BuiltinInstance("bool"))] - #[test_case(Ty::BoolLiteral(true), Ty::BuiltinInstance("int"))] + #[test_case(Ty::BooleanLiteral(true), Ty::BooleanLiteral(true))] + #[test_case(Ty::BooleanLiteral(false), Ty::BooleanLiteral(false))] + #[test_case(Ty::BooleanLiteral(true), Ty::BuiltinInstance("bool"))] + #[test_case(Ty::BooleanLiteral(true), Ty::BuiltinInstance("int"))] #[test_case(Ty::IntLiteral(1), Ty::IntLiteral(1))] #[test_case(Ty::StringLiteral("a"), Ty::StringLiteral("a"))] #[test_case(Ty::StringLiteral("a"), Ty::LiteralString)] @@ -1924,37 +2076,62 @@ mod tests { " class A: ... class B: ... - x = A if flag else B + U = A if flag else B ", ) .unwrap(); let module = ruff_db::files::system_path_to_file(&db, "/src/module.py").unwrap(); let type_a = super::global_symbol_ty(&db, module, "A"); - let type_x = super::global_symbol_ty(&db, module, "x"); + let type_u = super::global_symbol_ty(&db, module, "U"); - assert!(matches!(type_a, Type::Class(_))); - assert!(matches!(type_x, Type::Union(_))); + assert!(type_a.is_class_literal()); + assert!(type_u.is_union()); - assert!(!type_a.is_disjoint_from(&db, type_x)); + assert!(!type_a.is_disjoint_from(&db, type_u)); } #[test_case(Ty::None)] - #[test_case(Ty::BoolLiteral(true))] - #[test_case(Ty::BoolLiteral(false))] + #[test_case(Ty::BooleanLiteral(true))] + #[test_case(Ty::BooleanLiteral(false))] fn is_singleton(from: Ty) { let db = setup_db(); assert!(from.into_type(&db).is_singleton()); } + #[test_case(Ty::None)] + #[test_case(Ty::BooleanLiteral(true))] + #[test_case(Ty::IntLiteral(1))] + #[test_case(Ty::StringLiteral("abc"))] + #[test_case(Ty::BytesLiteral("abc"))] + #[test_case(Ty::Tuple(vec![]))] + #[test_case(Ty::Tuple(vec![Ty::BooleanLiteral(true), Ty::IntLiteral(1)]))] + fn is_single_valued(from: Ty) { + let db = setup_db(); + + assert!(from.into_type(&db).is_single_valued(&db)); + } + + #[test_case(Ty::Never)] + #[test_case(Ty::Any)] + #[test_case(Ty::Union(vec![Ty::IntLiteral(1), Ty::IntLiteral(2)]))] + #[test_case(Ty::Tuple(vec![Ty::None, Ty::BuiltinInstance("int")]))] + #[test_case(Ty::BuiltinInstance("str"))] + #[test_case(Ty::LiteralString)] + fn is_not_single_valued(from: Ty) { + let db = setup_db(); + + assert!(!from.into_type(&db).is_single_valued(&db)); + } + #[test_case(Ty::Never)] #[test_case(Ty::IntLiteral(345))] #[test_case(Ty::BuiltinInstance("str"))] #[test_case(Ty::Union(vec![Ty::IntLiteral(1), Ty::IntLiteral(2)]))] #[test_case(Ty::Tuple(vec![]))] #[test_case(Ty::Tuple(vec![Ty::None]))] - #[test_case(Ty::Tuple(vec![Ty::None, Ty::BoolLiteral(true)]))] + #[test_case(Ty::Tuple(vec![Ty::None, Ty::BooleanLiteral(true)]))] fn is_not_singleton(from: Ty) { let db = setup_db(); @@ -1990,8 +2167,8 @@ mod tests { } #[test_case(Ty::IntLiteral(1), Ty::StringLiteral("1"))] - #[test_case(Ty::BoolLiteral(true), Ty::StringLiteral("True"))] - #[test_case(Ty::BoolLiteral(false), Ty::StringLiteral("False"))] + #[test_case(Ty::BooleanLiteral(true), Ty::StringLiteral("True"))] + #[test_case(Ty::BooleanLiteral(false), Ty::StringLiteral("False"))] #[test_case(Ty::StringLiteral("ab'cd"), Ty::StringLiteral("ab'cd"))] // no quotes #[test_case(Ty::LiteralString, Ty::LiteralString)] #[test_case(Ty::BuiltinInstance("int"), Ty::BuiltinInstance("str"))] @@ -2002,8 +2179,8 @@ mod tests { } #[test_case(Ty::IntLiteral(1), Ty::StringLiteral("1"))] - #[test_case(Ty::BoolLiteral(true), Ty::StringLiteral("True"))] - #[test_case(Ty::BoolLiteral(false), Ty::StringLiteral("False"))] + #[test_case(Ty::BooleanLiteral(true), Ty::StringLiteral("True"))] + #[test_case(Ty::BooleanLiteral(false), Ty::StringLiteral("False"))] #[test_case(Ty::StringLiteral("ab'cd"), Ty::StringLiteral("'ab\\'cd'"))] // single quotes #[test_case(Ty::LiteralString, Ty::LiteralString)] #[test_case(Ty::BuiltinInstance("int"), Ty::BuiltinInstance("str"))] diff --git a/crates/red_knot_python_semantic/src/types/builder.rs b/crates/red_knot_python_semantic/src/types/builder.rs index 4cf17ba4f6ddc..0a6e77283163a 100644 --- a/crates/red_knot_python_semantic/src/types/builder.rs +++ b/crates/red_knot_python_semantic/src/types/builder.rs @@ -173,14 +173,10 @@ impl<'db> IntersectionBuilder<'db> { pub(crate) fn add_negative(mut self, ty: Type<'db>) -> Self { // See comments above in `add_positive`; this is just the negated version. if let Type::Union(union) = ty { - union - .elements(self.db) - .iter() - .map(|elem| self.clone().add_negative(*elem)) - .fold(IntersectionBuilder::empty(self.db), |mut builder, sub| { - builder.intersections.extend(sub.intersections); - builder - }) + for elem in union.elements(self.db) { + self = self.add_negative(*elem); + } + self } else { for inner in &mut self.intersections { inner.add_negative(self.db, ty); @@ -231,7 +227,7 @@ impl<'db> InnerIntersectionBuilder<'db> { if let Some(&Type::BooleanLiteral(value)) = self .negative .iter() - .find(|element| matches!(element, Type::BooleanLiteral(..))) + .find(|element| element.is_boolean_literal()) { *self = Self::new(); self.positive.insert(Type::BooleanLiteral(!value)); @@ -253,6 +249,7 @@ impl<'db> InnerIntersectionBuilder<'db> { // A & B = Never if A and B are disjoint if new_positive.is_disjoint_from(db, *existing_positive) { *self = Self::new(); + self.positive.insert(Type::Never); return; } } @@ -265,6 +262,7 @@ impl<'db> InnerIntersectionBuilder<'db> { // S & ~T = Never if S <: T if new_positive.is_subtype_of(db, *existing_negative) { *self = Self::new(); + self.positive.insert(Type::Never); return; } // A & ~B = A if A and B are disjoint @@ -328,6 +326,7 @@ impl<'db> InnerIntersectionBuilder<'db> { // S & ~T = Never if S <: T if existing_positive.is_subtype_of(db, new_negative) { *self = Self::new(); + self.positive.insert(Type::Never); return; } // A & ~B = A if A and B are disjoint @@ -351,7 +350,7 @@ impl<'db> InnerIntersectionBuilder<'db> { fn build(mut self, db: &'db dyn Db) -> Type<'db> { self.simplify_unbound(); match (self.positive.len(), self.negative.len()) { - (0, 0) => Type::Never, + (0, 0) => KnownClass::Object.to_instance(db), (1, 0) => self.positive[0], _ => { self.positive.shrink_to_fit(); @@ -523,6 +522,15 @@ mod tests { assert_eq!(intersection.neg_vec(&db), &[t0]); } + #[test] + fn build_intersection_empty_intersection_equals_object() { + let db = setup_db(); + + let ty = IntersectionBuilder::new(&db).build(); + + assert_eq!(ty, KnownClass::Object.to_instance(&db)); + } + #[test] fn build_intersection_flatten_positive() { let db = setup_db(); @@ -655,6 +663,27 @@ mod tests { assert_eq!(ty, Type::IntLiteral(1)); } + #[test] + fn build_negative_union_de_morgan() { + let db = setup_db(); + + let union = UnionBuilder::new(&db) + .add(Type::IntLiteral(1)) + .add(Type::IntLiteral(2)) + .build(); + assert_eq!(union.display(&db).to_string(), "Literal[1, 2]"); + + let ty = IntersectionBuilder::new(&db).add_negative(union).build(); + + let expected = IntersectionBuilder::new(&db) + .add_negative(Type::IntLiteral(1)) + .add_negative(Type::IntLiteral(2)) + .build(); + + assert_eq!(ty.display(&db).to_string(), "~Literal[1] & ~Literal[2]"); + assert_eq!(ty, expected); + } + #[test] fn build_intersection_simplify_positive_type_and_positive_subtype() { let db = setup_db(); @@ -852,7 +881,7 @@ mod tests { let db = setup_db(); let t_bool = KnownClass::Bool.to_instance(&db); - let t_bool_literal = Type::BooleanLiteral(bool_value); + let t_boolean_literal = Type::BooleanLiteral(bool_value); // We add t_object in various orders (in first or second position) in // the tests below to ensure that the boolean simplification eliminates @@ -862,26 +891,26 @@ mod tests { let ty = IntersectionBuilder::new(&db) .add_positive(t_object) .add_positive(t_bool) - .add_negative(t_bool_literal) + .add_negative(t_boolean_literal) .build(); assert_eq!(ty, Type::BooleanLiteral(!bool_value)); let ty = IntersectionBuilder::new(&db) .add_positive(t_bool) .add_positive(t_object) - .add_negative(t_bool_literal) + .add_negative(t_boolean_literal) .build(); assert_eq!(ty, Type::BooleanLiteral(!bool_value)); let ty = IntersectionBuilder::new(&db) .add_positive(t_object) - .add_negative(t_bool_literal) + .add_negative(t_boolean_literal) .add_positive(t_bool) .build(); assert_eq!(ty, Type::BooleanLiteral(!bool_value)); let ty = IntersectionBuilder::new(&db) - .add_negative(t_bool_literal) + .add_negative(t_boolean_literal) .add_positive(t_object) .add_positive(t_bool) .build(); diff --git a/crates/red_knot_python_semantic/src/types/display.rs b/crates/red_knot_python_semantic/src/types/display.rs index b362bd982f26b..395b290ae024c 100644 --- a/crates/red_knot_python_semantic/src/types/display.rs +++ b/crates/red_knot_python_semantic/src/types/display.rs @@ -34,8 +34,8 @@ impl Display for DisplayType<'_> { | Type::BooleanLiteral(_) | Type::StringLiteral(_) | Type::BytesLiteral(_) - | Type::Class(_) - | Type::Function(_) + | Type::ClassLiteral(_) + | Type::FunctionLiteral(_) ) { write!(f, "Literal[{representation}]") } else { @@ -69,13 +69,13 @@ impl Display for DisplayRepresentation<'_> { // `[Type::Todo]`'s display should be explicit that is not a valid display of // any other type Type::Todo => f.write_str("@Todo"), - Type::Module(file) => { + Type::ModuleLiteral(file) => { write!(f, "", file.path(self.db)) } // TODO functions and classes should display using a fully qualified name - Type::Class(class) => f.write_str(class.name(self.db)), + Type::ClassLiteral(class) => f.write_str(class.name(self.db)), Type::Instance(class) => f.write_str(class.name(self.db)), - Type::Function(function) => f.write_str(function.name(self.db)), + Type::FunctionLiteral(function) => f.write_str(function.name(self.db)), Type::Union(union) => union.display(self.db).fmt(f), Type::Intersection(intersection) => intersection.display(self.db).fmt(f), Type::IntLiteral(n) => n.fmt(f), @@ -119,13 +119,13 @@ impl Display for DisplayUnionType<'_> { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { let elements = self.ty.elements(self.db); - // Group literal types by kind. - let mut grouped_literals = FxHashMap::default(); + // Group condensed-display types by kind. + let mut grouped_condensed_kinds = FxHashMap::default(); for element in elements { - if let Ok(literal_kind) = LiteralTypeKind::try_from(*element) { - grouped_literals - .entry(literal_kind) + if let Ok(kind) = CondensedDisplayTypeKind::try_from(*element) { + grouped_condensed_kinds + .entry(kind) .or_insert_with(Vec::new) .push(*element); } @@ -134,15 +134,15 @@ impl Display for DisplayUnionType<'_> { let mut join = f.join(" | "); for element in elements { - if let Ok(literal_kind) = LiteralTypeKind::try_from(*element) { - let Some(mut literals) = grouped_literals.remove(&literal_kind) else { + if let Ok(kind) = CondensedDisplayTypeKind::try_from(*element) { + let Some(mut condensed_kind) = grouped_condensed_kinds.remove(&kind) else { continue; }; - if literal_kind == LiteralTypeKind::IntLiteral { - literals.sort_unstable_by_key(|ty| ty.expect_int_literal()); + if kind == CondensedDisplayTypeKind::Int { + condensed_kind.sort_unstable_by_key(|ty| ty.expect_int_literal()); } join.entry(&DisplayLiteralGroup { - literals, + literals: condensed_kind, db: self.db, }); } else { @@ -152,7 +152,7 @@ impl Display for DisplayUnionType<'_> { join.finish()?; - debug_assert!(grouped_literals.is_empty()); + debug_assert!(grouped_condensed_kinds.is_empty()); Ok(()) } @@ -179,25 +179,31 @@ impl Display for DisplayLiteralGroup<'_> { } } +/// Enumeration of literal types that are displayed in a "condensed way" inside `Literal` slices. +/// +/// For example, `Literal[1] | Literal[2]` is displayed as `"Literal[1, 2]"`. +/// Not all `Literal` types are displayed using `Literal` slices +/// (e.g. it would be inappropriate to display `LiteralString` +/// as `Literal[LiteralString]`). #[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] -enum LiteralTypeKind { +enum CondensedDisplayTypeKind { Class, Function, - IntLiteral, - StringLiteral, - BytesLiteral, + Int, + String, + Bytes, } -impl TryFrom> for LiteralTypeKind { +impl TryFrom> for CondensedDisplayTypeKind { type Error = (); fn try_from(value: Type<'_>) -> Result { match value { - Type::Class(_) => Ok(Self::Class), - Type::Function(_) => Ok(Self::Function), - Type::IntLiteral(_) => Ok(Self::IntLiteral), - Type::StringLiteral(_) => Ok(Self::StringLiteral), - Type::BytesLiteral(_) => Ok(Self::BytesLiteral), + Type::ClassLiteral(_) => Ok(Self::Class), + Type::FunctionLiteral(_) => Ok(Self::Function), + Type::IntLiteral(_) => Ok(Self::Int), + Type::StringLiteral(_) => Ok(Self::String), + Type::BytesLiteral(_) => Ok(Self::Bytes), _ => Err(()), } } diff --git a/crates/red_knot_python_semantic/src/types/infer.rs b/crates/red_knot_python_semantic/src/types/infer.rs index 27655b944ecf8..f3c6b106914bc 100644 --- a/crates/red_knot_python_semantic/src/types/infer.rs +++ b/crates/red_knot_python_semantic/src/types/infer.rs @@ -55,6 +55,7 @@ use crate::types::{ typing_extensions_symbol_ty, BytesLiteralType, ClassType, FunctionType, KnownFunction, StringLiteralType, Truthiness, TupleType, Type, TypeArrayDisplay, UnionType, }; +use crate::util::subscript::PythonSubscript; use crate::Db; use super::{KnownClass, UnionBuilder}; @@ -205,6 +206,7 @@ impl<'db> TypeInference<'db> { } } + #[track_caller] pub(crate) fn expression_ty(&self, expression: ScopedExpressionId) -> Type<'db> { self.expressions[&expression] } @@ -213,10 +215,12 @@ impl<'db> TypeInference<'db> { self.expressions.get(&expression).copied() } + #[track_caller] pub(crate) fn binding_ty(&self, definition: Definition<'db>) -> Type<'db> { self.bindings[&definition] } + #[track_caller] pub(crate) fn declaration_ty(&self, definition: Definition<'db>) -> Type<'db> { self.declarations[&definition] } @@ -316,9 +320,7 @@ impl<'db> TypeInferenceBuilder<'db> { db, index, region, - file, - types: TypeInference::empty(scope), } } @@ -510,12 +512,12 @@ impl<'db> TypeInferenceBuilder<'db> { assigned_ty: Type<'db>, ) { match declared_ty { - Type::Class(class) => { + Type::ClassLiteral(class) => { self.add_diagnostic(node, "invalid-assignment", format_args!( "Implicit shadowing of class `{}`; annotate to make it explicit if this is intentional", class.name(self.db))); } - Type::Function(function) => { + Type::FunctionLiteral(function) => { self.add_diagnostic(node, "invalid-assignment", format_args!( "Implicit shadowing of function `{}`; annotate to make it explicit if this is intentional", function.name(self.db))); @@ -539,10 +541,11 @@ impl<'db> TypeInferenceBuilder<'db> { /// Expects the resolved type of the left side of the binary expression. fn check_division_by_zero(&mut self, expr: &ast::ExprBinOp, left: Type<'db>) { match left { - Type::IntLiteral(_) => {} + Type::BooleanLiteral(_) | Type::IntLiteral(_) => {} Type::Instance(cls) - if cls.is_known(self.db, KnownClass::Float) - || cls.is_known(self.db, KnownClass::Int) => {} + if [KnownClass::Float, KnownClass::Int, KnownClass::Bool] + .iter() + .any(|&k| cls.is_known(self.db, k)) => {} _ => return, }; @@ -775,9 +778,12 @@ impl<'db> TypeInferenceBuilder<'db> { "reveal_type" if definition.is_typing_definition(self.db) => { Some(KnownFunction::RevealType) } + "isinstance" if definition.is_builtin_definition(self.db) => { + Some(KnownFunction::IsInstance) + } _ => None, }; - let function_ty = Type::Function(FunctionType::new( + let function_ty = Type::FunctionLiteral(FunctionType::new( self.db, name.id.clone(), function_kind, @@ -886,7 +892,7 @@ impl<'db> TypeInferenceBuilder<'db> { .as_ref() .and_then(|module| KnownClass::maybe_from_module(module, name.as_str())); - let class_ty = Type::Class(ClassType::new( + let class_ty = Type::ClassLiteral(ClassType::new( self.db, name.id.clone(), definition, @@ -1055,13 +1061,13 @@ impl<'db> TypeInferenceBuilder<'db> { // anything else is invalid and should lead to a diagnostic being reported --Alex match node_ty { Type::Any | Type::Unknown => node_ty, - Type::Class(class_ty) => Type::Instance(class_ty), + Type::ClassLiteral(class_ty) => Type::Instance(class_ty), Type::Tuple(tuple) => UnionType::from_elements( self.db, - tuple - .elements(self.db) - .iter() - .map(|ty| ty.into_class_type().map_or(Type::Todo, Type::Instance)), + tuple.elements(self.db).iter().map(|ty| { + ty.into_class_literal_type() + .map_or(Type::Todo, Type::Instance) + }), ), _ => Type::Todo, } @@ -1310,7 +1316,7 @@ impl<'db> TypeInferenceBuilder<'db> { } } _ => { - let value_ty = if matches!(value_ty, Type::LiteralString) { + let value_ty = if value_ty.is_literal_string() { Type::LiteralString } else { value_ty @@ -1459,8 +1465,9 @@ impl<'db> TypeInferenceBuilder<'db> { } /// Emit a diagnostic declaring that an index is out of bounds for a tuple. - pub(super) fn tuple_index_out_of_bounds_diagnostic( + pub(super) fn index_out_of_bounds_diagnostic( &mut self, + kind: &'static str, node: AnyNodeRef, tuple_ty: Type<'db>, length: usize, @@ -1470,30 +1477,12 @@ impl<'db> TypeInferenceBuilder<'db> { node, "index-out-of-bounds", format_args!( - "Index {index} is out of bounds for tuple of type `{}` with length {length}", + "Index {index} is out of bounds for {kind} `{}` with length {length}", tuple_ty.display(self.db) ), ); } - /// Emit a diagnostic declaring that an index is out of bounds for a string. - pub(super) fn string_index_out_of_bounds_diagnostic( - &mut self, - node: AnyNodeRef, - string_ty: Type<'db>, - length: usize, - index: i64, - ) { - self.add_diagnostic( - node, - "index-out-of-bounds", - format_args!( - "Index {index} is out of bounds for string `{}` with length {length}", - string_ty.display(self.db) - ), - ); - } - /// Emit a diagnostic declaring that a type does not support subscripting. pub(super) fn non_subscriptable_diagnostic( &mut self, @@ -1772,7 +1761,7 @@ impl<'db> TypeInferenceBuilder<'db> { } fn module_ty_from_name(&self, module_name: &ModuleName) -> Option> { - resolve_module(self.db, module_name).map(|module| Type::Module(module.file())) + resolve_module(self.db, module_name).map(|module| Type::ModuleLiteral(module.file())) } fn infer_decorator(&mut self, decorator: &ast::Decorator) -> Type<'db> { @@ -2426,8 +2415,23 @@ impl<'db> TypeInferenceBuilder<'db> { } else { None }; + let ty = bindings_ty(self.db, definitions, unbound_ty); - bindings_ty(self.db, definitions, unbound_ty) + if ty.is_unbound() { + self.add_diagnostic( + name.into(), + "unresolved-reference", + format_args!("Name `{id}` used when not defined"), + ); + } else if ty.may_be_unbound(self.db) { + self.add_diagnostic( + name.into(), + "possibly-unresolved-reference", + format_args!("Name `{id}` used when possibly not defined"), + ); + } + + ty } ExprContext::Store | ExprContext::Del => Type::None, ExprContext::Invalid => Type::Unknown, @@ -2459,7 +2463,9 @@ impl<'db> TypeInferenceBuilder<'db> { operand, } = unary; - match (op, self.infer_expression(operand)) { + let operand_type = self.infer_expression(operand); + + match (op, operand_type) { (UnaryOp::UAdd, Type::IntLiteral(value)) => Type::IntLiteral(value), (UnaryOp::USub, Type::IntLiteral(value)) => Type::IntLiteral(-value), (UnaryOp::Invert, Type::IntLiteral(value)) => Type::IntLiteral(!value), @@ -2469,7 +2475,35 @@ impl<'db> TypeInferenceBuilder<'db> { (UnaryOp::Invert, Type::BooleanLiteral(bool)) => Type::IntLiteral(!i64::from(bool)), (UnaryOp::Not, ty) => ty.bool(self.db).negate().into_type(self.db), + (_, Type::Any) => Type::Any, + (_, Type::Unknown) => Type::Unknown, + (op @ (UnaryOp::UAdd | UnaryOp::USub | UnaryOp::Invert), Type::Instance(class)) => { + let unary_dunder_method = match op { + UnaryOp::Invert => "__invert__", + UnaryOp::UAdd => "__pos__", + UnaryOp::USub => "__neg__", + UnaryOp::Not => { + unreachable!("Not operator is handled in its own case"); + } + }; + let class_member = class.class_member(self.db, unary_dunder_method); + let call = class_member.call(self.db, &[operand_type]); + match call.return_ty_result(self.db, AnyNodeRef::ExprUnaryOp(unary), self) { + Ok(t) => t, + Err(e) => { + self.add_diagnostic( + unary.into(), + "unsupported-operator", + format_args!( + "Unary operator `{op}` is unsupported for type `{}`", + operand_type.display(self.db), + ), + ); + e.return_ty() + } + } + } _ => Type::Todo, // TODO other unary op types } } @@ -2491,77 +2525,115 @@ impl<'db> TypeInferenceBuilder<'db> { (op, right_ty), ( ast::Operator::Div | ast::Operator::FloorDiv | ast::Operator::Mod, - Type::IntLiteral(0), + Type::IntLiteral(0) | Type::BooleanLiteral(false) ) ) { self.check_division_by_zero(binary, left_ty); } + self.infer_binary_expression_type(left_ty, right_ty, *op) + .unwrap_or_else(|| { + self.add_diagnostic( + binary.into(), + "unsupported-operator", + format_args!( + "Operator `{op}` is unsupported between objects of type `{}` and `{}`", + left_ty.display(self.db), + right_ty.display(self.db) + ), + ); + Type::Unknown + }) + } + + fn infer_binary_expression_type( + &mut self, + left_ty: Type<'db>, + right_ty: Type<'db>, + op: ast::Operator, + ) -> Option> { match (left_ty, right_ty, op) { // When interacting with Todo, Any and Unknown should propagate (as if we fix this // `Todo` in the future, the result would then become Any or Unknown, respectively.) - (Type::Any, _, _) | (_, Type::Any, _) => Type::Any, - (Type::Unknown, _, _) | (_, Type::Unknown, _) => Type::Unknown, + (Type::Any, _, _) | (_, Type::Any, _) => Some(Type::Any), + (Type::Unknown, _, _) | (_, Type::Unknown, _) => Some(Type::Unknown), - (Type::IntLiteral(n), Type::IntLiteral(m), ast::Operator::Add) => n - .checked_add(m) - .map(Type::IntLiteral) - .unwrap_or_else(|| KnownClass::Int.to_instance(self.db)), + (Type::IntLiteral(n), Type::IntLiteral(m), ast::Operator::Add) => Some( + n.checked_add(m) + .map(Type::IntLiteral) + .unwrap_or_else(|| KnownClass::Int.to_instance(self.db)), + ), - (Type::IntLiteral(n), Type::IntLiteral(m), ast::Operator::Sub) => n - .checked_sub(m) - .map(Type::IntLiteral) - .unwrap_or_else(|| KnownClass::Int.to_instance(self.db)), + (Type::IntLiteral(n), Type::IntLiteral(m), ast::Operator::Sub) => Some( + n.checked_sub(m) + .map(Type::IntLiteral) + .unwrap_or_else(|| KnownClass::Int.to_instance(self.db)), + ), - (Type::IntLiteral(n), Type::IntLiteral(m), ast::Operator::Mult) => n - .checked_mul(m) - .map(Type::IntLiteral) - .unwrap_or_else(|| KnownClass::Int.to_instance(self.db)), + (Type::IntLiteral(n), Type::IntLiteral(m), ast::Operator::Mult) => Some( + n.checked_mul(m) + .map(Type::IntLiteral) + .unwrap_or_else(|| KnownClass::Int.to_instance(self.db)), + ), (Type::IntLiteral(_), Type::IntLiteral(_), ast::Operator::Div) => { - KnownClass::Float.to_instance(self.db) + Some(KnownClass::Float.to_instance(self.db)) } - (Type::IntLiteral(n), Type::IntLiteral(m), ast::Operator::FloorDiv) => n - .checked_div(m) - .map(Type::IntLiteral) - .unwrap_or_else(|| KnownClass::Int.to_instance(self.db)), + (Type::IntLiteral(n), Type::IntLiteral(m), ast::Operator::FloorDiv) => Some( + n.checked_div(m) + .map(Type::IntLiteral) + .unwrap_or_else(|| KnownClass::Int.to_instance(self.db)), + ), - (Type::IntLiteral(n), Type::IntLiteral(m), ast::Operator::Mod) => n - .checked_rem(m) - .map(Type::IntLiteral) - .unwrap_or_else(|| KnownClass::Int.to_instance(self.db)), + (Type::IntLiteral(n), Type::IntLiteral(m), ast::Operator::Mod) => Some( + n.checked_rem(m) + .map(Type::IntLiteral) + .unwrap_or_else(|| KnownClass::Int.to_instance(self.db)), + ), + + (Type::IntLiteral(n), Type::IntLiteral(m), ast::Operator::Pow) => { + let m = u32::try_from(m); + Some(match m { + Ok(m) => n + .checked_pow(m) + .map(Type::IntLiteral) + .unwrap_or_else(|| KnownClass::Int.to_instance(self.db)), + Err(_) => KnownClass::Int.to_instance(self.db), + }) + } (Type::BytesLiteral(lhs), Type::BytesLiteral(rhs), ast::Operator::Add) => { - Type::BytesLiteral(BytesLiteralType::new( + Some(Type::BytesLiteral(BytesLiteralType::new( self.db, [lhs.value(self.db).as_ref(), rhs.value(self.db).as_ref()] .concat() .into_boxed_slice(), - )) + ))) } (Type::StringLiteral(lhs), Type::StringLiteral(rhs), ast::Operator::Add) => { let lhs_value = lhs.value(self.db).to_string(); let rhs_value = rhs.value(self.db).as_ref(); - if lhs_value.len() + rhs_value.len() <= Self::MAX_STRING_LITERAL_SIZE { + let ty = if lhs_value.len() + rhs_value.len() <= Self::MAX_STRING_LITERAL_SIZE { Type::StringLiteral(StringLiteralType::new(self.db, { (lhs_value + rhs_value).into_boxed_str() })) } else { Type::LiteralString - } + }; + Some(ty) } ( Type::StringLiteral(_) | Type::LiteralString, Type::StringLiteral(_) | Type::LiteralString, ast::Operator::Add, - ) => Type::LiteralString, + ) => Some(Type::LiteralString), (Type::StringLiteral(s), Type::IntLiteral(n), ast::Operator::Mult) | (Type::IntLiteral(n), Type::StringLiteral(s), ast::Operator::Mult) => { - if n < 1 { + let ty = if n < 1 { Type::StringLiteral(StringLiteralType::new(self.db, "")) } else if let Ok(n) = usize::try_from(n) { if n.checked_mul(s.value(self.db).len()) @@ -2577,19 +2649,110 @@ impl<'db> TypeInferenceBuilder<'db> { } } else { Type::LiteralString - } + }; + Some(ty) } (Type::LiteralString, Type::IntLiteral(n), ast::Operator::Mult) | (Type::IntLiteral(n), Type::LiteralString, ast::Operator::Mult) => { - if n < 1 { + let ty = if n < 1 { Type::StringLiteral(StringLiteralType::new(self.db, "")) } else { Type::LiteralString + }; + Some(ty) + } + + (Type::Instance(_), Type::IntLiteral(_), op) => { + self.infer_binary_expression_type(left_ty, KnownClass::Int.to_instance(self.db), op) + } + + (Type::IntLiteral(_), Type::Instance(_), op) => self.infer_binary_expression_type( + KnownClass::Int.to_instance(self.db), + right_ty, + op, + ), + + (Type::Instance(_), Type::Tuple(_), op) => self.infer_binary_expression_type( + left_ty, + KnownClass::Tuple.to_instance(self.db), + op, + ), + + (Type::Tuple(_), Type::Instance(_), op) => self.infer_binary_expression_type( + KnownClass::Tuple.to_instance(self.db), + right_ty, + op, + ), + + (Type::Instance(_), Type::StringLiteral(_) | Type::LiteralString, op) => { + self.infer_binary_expression_type(left_ty, KnownClass::Str.to_instance(self.db), op) + } + + (Type::StringLiteral(_) | Type::LiteralString, Type::Instance(_), op) => self + .infer_binary_expression_type(KnownClass::Str.to_instance(self.db), right_ty, op), + + (Type::Instance(_), Type::BytesLiteral(_), op) => self.infer_binary_expression_type( + left_ty, + KnownClass::Bytes.to_instance(self.db), + op, + ), + + (Type::BytesLiteral(_), Type::Instance(_), op) => self.infer_binary_expression_type( + KnownClass::Bytes.to_instance(self.db), + right_ty, + op, + ), + + (Type::Instance(left_class), Type::Instance(right_class), op) => { + if left_class != right_class && right_class.is_subclass_of(self.db, left_class) { + let reflected_dunder = op.reflected_dunder(); + let rhs_reflected = right_class.class_member(self.db, reflected_dunder); + if !rhs_reflected.is_unbound() + && rhs_reflected != left_class.class_member(self.db, reflected_dunder) + { + return rhs_reflected + .call(self.db, &[right_ty, left_ty]) + .return_ty(self.db) + .or_else(|| { + left_class + .class_member(self.db, op.dunder()) + .call(self.db, &[left_ty, right_ty]) + .return_ty(self.db) + }); + } } + left_class + .class_member(self.db, op.dunder()) + .call(self.db, &[left_ty, right_ty]) + .return_ty(self.db) + .or_else(|| { + if left_class == right_class { + None + } else { + right_class + .class_member(self.db, op.reflected_dunder()) + .call(self.db, &[right_ty, left_ty]) + .return_ty(self.db) + } + }) } - _ => Type::Todo, // TODO + ( + Type::BooleanLiteral(b1), + Type::BooleanLiteral(b2), + ruff_python_ast::Operator::BitOr, + ) => Some(Type::BooleanLiteral(b1 | b2)), + + (Type::BooleanLiteral(bool_value), right, op) => self.infer_binary_expression_type( + Type::IntLiteral(i64::from(bool_value)), + right, + op, + ), + (left, Type::BooleanLiteral(bool_value), op) => { + self.infer_binary_expression_type(left, Type::IntLiteral(i64::from(bool_value)), op) + } + _ => Some(Type::Todo), // TODO } } @@ -2649,7 +2812,7 @@ impl<'db> TypeInferenceBuilder<'db> { } = compare; self.infer_expression(left); - for right in comparators.as_ref() { + for right in comparators { self.infer_expression(right); } @@ -2663,27 +2826,37 @@ impl<'db> TypeInferenceBuilder<'db> { Self::infer_chained_boolean_types( self.db, ast::BoolOp::And, - std::iter::once(left.as_ref()) - .chain(comparators.as_ref().iter()) + std::iter::once(&**left) + .chain(comparators) .tuple_windows::<(_, _)>() - .zip(ops.iter()) + .zip(ops) .map(|((left, right), op)| { let left_ty = self.expression_ty(left); let right_ty = self.expression_ty(right); self.infer_binary_type_comparison(left_ty, *op, right_ty) - .unwrap_or_else(|| { + .unwrap_or_else(|error| { // Handle unsupported operators (diagnostic, `bool`/`Unknown` outcome) self.add_diagnostic( AnyNodeRef::ExprCompare(compare), "operator-unsupported", format_args!( - "Operator `{}` is not supported for types `{}` and `{}`", - op, - left_ty.display(self.db), - right_ty.display(self.db) + "Operator `{}` is not supported for types `{}` and `{}`{}", + error.op, + error.left_ty.display(self.db), + error.right_ty.display(self.db), + if (left_ty, right_ty) == (error.left_ty, error.right_ty) { + String::new() + } else { + format!( + ", in comparing `{}` with `{}`", + left_ty.display(self.db), + right_ty.display(self.db) + ) + } ), ); + match op { // `in, not in, is, is not` always return bool instances ast::CmpOp::In @@ -2710,7 +2883,7 @@ impl<'db> TypeInferenceBuilder<'db> { left: Type<'db>, op: ast::CmpOp, right: Type<'db>, - ) -> Option> { + ) -> Result, CompareUnsupportedError<'db>> { // Note: identity (is, is not) for equal builtin types is unreliable and not part of the // language spec. // - `[ast::CompOp::Is]`: return `false` if unequal, `bool` if equal @@ -2721,39 +2894,43 @@ impl<'db> TypeInferenceBuilder<'db> { for element in union.elements(self.db) { builder = builder.add(self.infer_binary_type_comparison(*element, op, other)?); } - Some(builder.build()) + Ok(builder.build()) } (other, Type::Union(union)) => { let mut builder = UnionBuilder::new(self.db); for element in union.elements(self.db) { builder = builder.add(self.infer_binary_type_comparison(other, op, *element)?); } - Some(builder.build()) + Ok(builder.build()) } (Type::IntLiteral(n), Type::IntLiteral(m)) => match op { - ast::CmpOp::Eq => Some(Type::BooleanLiteral(n == m)), - ast::CmpOp::NotEq => Some(Type::BooleanLiteral(n != m)), - ast::CmpOp::Lt => Some(Type::BooleanLiteral(n < m)), - ast::CmpOp::LtE => Some(Type::BooleanLiteral(n <= m)), - ast::CmpOp::Gt => Some(Type::BooleanLiteral(n > m)), - ast::CmpOp::GtE => Some(Type::BooleanLiteral(n >= m)), + ast::CmpOp::Eq => Ok(Type::BooleanLiteral(n == m)), + ast::CmpOp::NotEq => Ok(Type::BooleanLiteral(n != m)), + ast::CmpOp::Lt => Ok(Type::BooleanLiteral(n < m)), + ast::CmpOp::LtE => Ok(Type::BooleanLiteral(n <= m)), + ast::CmpOp::Gt => Ok(Type::BooleanLiteral(n > m)), + ast::CmpOp::GtE => Ok(Type::BooleanLiteral(n >= m)), ast::CmpOp::Is => { if n == m { - Some(KnownClass::Bool.to_instance(self.db)) + Ok(KnownClass::Bool.to_instance(self.db)) } else { - Some(Type::BooleanLiteral(false)) + Ok(Type::BooleanLiteral(false)) } } ast::CmpOp::IsNot => { if n == m { - Some(KnownClass::Bool.to_instance(self.db)) + Ok(KnownClass::Bool.to_instance(self.db)) } else { - Some(Type::BooleanLiteral(true)) + Ok(Type::BooleanLiteral(true)) } } // Undefined for (int, int) - ast::CmpOp::In | ast::CmpOp::NotIn => None, + ast::CmpOp::In | ast::CmpOp::NotIn => Err(CompareUnsupportedError { + op, + left_ty: left, + right_ty: right, + }), }, (Type::IntLiteral(_), Type::Instance(_)) => { self.infer_binary_type_comparison(KnownClass::Int.to_instance(self.db), op, right) @@ -2784,26 +2961,26 @@ impl<'db> TypeInferenceBuilder<'db> { let s1 = salsa_s1.value(self.db); let s2 = salsa_s2.value(self.db); match op { - ast::CmpOp::Eq => Some(Type::BooleanLiteral(s1 == s2)), - ast::CmpOp::NotEq => Some(Type::BooleanLiteral(s1 != s2)), - ast::CmpOp::Lt => Some(Type::BooleanLiteral(s1 < s2)), - ast::CmpOp::LtE => Some(Type::BooleanLiteral(s1 <= s2)), - ast::CmpOp::Gt => Some(Type::BooleanLiteral(s1 > s2)), - ast::CmpOp::GtE => Some(Type::BooleanLiteral(s1 >= s2)), - ast::CmpOp::In => Some(Type::BooleanLiteral(s2.contains(s1.as_ref()))), - ast::CmpOp::NotIn => Some(Type::BooleanLiteral(!s2.contains(s1.as_ref()))), + ast::CmpOp::Eq => Ok(Type::BooleanLiteral(s1 == s2)), + ast::CmpOp::NotEq => Ok(Type::BooleanLiteral(s1 != s2)), + ast::CmpOp::Lt => Ok(Type::BooleanLiteral(s1 < s2)), + ast::CmpOp::LtE => Ok(Type::BooleanLiteral(s1 <= s2)), + ast::CmpOp::Gt => Ok(Type::BooleanLiteral(s1 > s2)), + ast::CmpOp::GtE => Ok(Type::BooleanLiteral(s1 >= s2)), + ast::CmpOp::In => Ok(Type::BooleanLiteral(s2.contains(s1.as_ref()))), + ast::CmpOp::NotIn => Ok(Type::BooleanLiteral(!s2.contains(s1.as_ref()))), ast::CmpOp::Is => { if s1 == s2 { - Some(KnownClass::Bool.to_instance(self.db)) + Ok(KnownClass::Bool.to_instance(self.db)) } else { - Some(Type::BooleanLiteral(false)) + Ok(Type::BooleanLiteral(false)) } } ast::CmpOp::IsNot => { if s1 == s2 { - Some(KnownClass::Bool.to_instance(self.db)) + Ok(KnownClass::Bool.to_instance(self.db)) } else { - Some(Type::BooleanLiteral(true)) + Ok(Type::BooleanLiteral(true)) } } } @@ -2826,30 +3003,30 @@ impl<'db> TypeInferenceBuilder<'db> { let b1 = &**salsa_b1.value(self.db); let b2 = &**salsa_b2.value(self.db); match op { - ast::CmpOp::Eq => Some(Type::BooleanLiteral(b1 == b2)), - ast::CmpOp::NotEq => Some(Type::BooleanLiteral(b1 != b2)), - ast::CmpOp::Lt => Some(Type::BooleanLiteral(b1 < b2)), - ast::CmpOp::LtE => Some(Type::BooleanLiteral(b1 <= b2)), - ast::CmpOp::Gt => Some(Type::BooleanLiteral(b1 > b2)), - ast::CmpOp::GtE => Some(Type::BooleanLiteral(b1 >= b2)), + ast::CmpOp::Eq => Ok(Type::BooleanLiteral(b1 == b2)), + ast::CmpOp::NotEq => Ok(Type::BooleanLiteral(b1 != b2)), + ast::CmpOp::Lt => Ok(Type::BooleanLiteral(b1 < b2)), + ast::CmpOp::LtE => Ok(Type::BooleanLiteral(b1 <= b2)), + ast::CmpOp::Gt => Ok(Type::BooleanLiteral(b1 > b2)), + ast::CmpOp::GtE => Ok(Type::BooleanLiteral(b1 >= b2)), ast::CmpOp::In => { - Some(Type::BooleanLiteral(memchr::memmem::find(b2, b1).is_some())) + Ok(Type::BooleanLiteral(memchr::memmem::find(b2, b1).is_some())) } ast::CmpOp::NotIn => { - Some(Type::BooleanLiteral(memchr::memmem::find(b2, b1).is_none())) + Ok(Type::BooleanLiteral(memchr::memmem::find(b2, b1).is_none())) } ast::CmpOp::Is => { if b1 == b2 { - Some(KnownClass::Bool.to_instance(self.db)) + Ok(KnownClass::Bool.to_instance(self.db)) } else { - Some(Type::BooleanLiteral(false)) + Ok(Type::BooleanLiteral(false)) } } ast::CmpOp::IsNot => { if b1 == b2 { - Some(KnownClass::Bool.to_instance(self.db)) + Ok(KnownClass::Bool.to_instance(self.db)) } else { - Some(Type::BooleanLiteral(true)) + Ok(Type::BooleanLiteral(true)) } } } @@ -2862,8 +3039,8 @@ impl<'db> TypeInferenceBuilder<'db> { } (Type::Tuple(lhs), Type::Tuple(rhs)) => { // Note: This only works on heterogeneous tuple types. - let lhs_elements = lhs.elements(self.db).as_ref(); - let rhs_elements = rhs.elements(self.db).as_ref(); + let lhs_elements = lhs.elements(self.db); + let rhs_elements = rhs.elements(self.db); let mut lexicographic_type_comparison = |op| self.infer_lexicographic_type_comparison(lhs_elements, op, rhs_elements); @@ -2887,7 +3064,7 @@ impl<'db> TypeInferenceBuilder<'db> { ).expect("infer_binary_type_comparison should never return None for `CmpOp::Eq`"); match eq_result { - Type::Todo => return Some(Type::Todo), + Type::Todo => return Ok(Type::Todo), ty => match ty.bool(self.db) { Truthiness::AlwaysTrue => eq_count += 1, Truthiness::AlwaysFalse => not_eq_count += 1, @@ -2897,11 +3074,11 @@ impl<'db> TypeInferenceBuilder<'db> { } if eq_count >= 1 { - Some(Type::BooleanLiteral(op.is_in())) + Ok(Type::BooleanLiteral(op.is_in())) } else if not_eq_count == rhs_elements.len() { - Some(Type::BooleanLiteral(op.is_not_in())) + Ok(Type::BooleanLiteral(op.is_not_in())) } else { - Some(KnownClass::Bool.to_instance(self.db)) + Ok(KnownClass::Bool.to_instance(self.db)) } } ast::CmpOp::Is | ast::CmpOp::IsNot => { @@ -2912,7 +3089,7 @@ impl<'db> TypeInferenceBuilder<'db> { "infer_binary_type_comparison should never return None for `CmpOp::Eq`", ); - Some(match eq_result { + Ok(match eq_result { Type::Todo => Type::Todo, ty => match ty.bool(self.db) { Truthiness::AlwaysFalse => Type::BooleanLiteral(op.is_is_not()), @@ -2925,16 +3102,19 @@ impl<'db> TypeInferenceBuilder<'db> { // Lookup the rich comparison `__dunder__` methods on instances (Type::Instance(left_class_ty), Type::Instance(right_class_ty)) => match op { - ast::CmpOp::Lt => { - perform_rich_comparison(self.db, left_class_ty, right_class_ty, "__lt__") - } + ast::CmpOp::Lt => perform_rich_comparison( + self.db, + left_class_ty, + right_class_ty, + RichCompareOperator::Lt, + ), // TODO: implement mapping from `ast::CmpOp` to rich comparison methods - _ => Some(Type::Todo), + _ => Ok(Type::Todo), }, // TODO: handle more types _ => match op { - ast::CmpOp::Is | ast::CmpOp::IsNot => Some(KnownClass::Bool.to_instance(self.db)), - _ => Some(Type::Todo), + ast::CmpOp::Is | ast::CmpOp::IsNot => Ok(KnownClass::Bool.to_instance(self.db)), + _ => Ok(Type::Todo), }, } } @@ -2942,14 +3122,14 @@ impl<'db> TypeInferenceBuilder<'db> { /// Performs lexicographic comparison between two slices of types. /// /// For lexicographic comparison, elements from both slices are compared pairwise using - /// `infer_binary_type_comparison`. If a conclusive result cannot be determined as a `BoolLiteral`, + /// `infer_binary_type_comparison`. If a conclusive result cannot be determined as a `BooleanLiteral`, /// it returns `bool`. Returns `None` if the comparison is not supported. fn infer_lexicographic_type_comparison( &mut self, left: &[Type<'db>], op: RichCompareOperator, right: &[Type<'db>], - ) -> Option> { + ) -> Result, CompareUnsupportedError<'db>> { // Compare paired elements from left and right slices for (l_ty, r_ty) in left.iter().copied().zip(right.iter().copied()) { let eq_result = self @@ -2958,7 +3138,7 @@ impl<'db> TypeInferenceBuilder<'db> { match eq_result { // If propagation is required, return the result as is - Type::Todo => return Some(Type::Todo), + Type::Todo => return Ok(Type::Todo), ty => match ty.bool(self.db) { // Types are equal, continue to the next pair Truthiness::AlwaysTrue => continue, @@ -2968,7 +3148,7 @@ impl<'db> TypeInferenceBuilder<'db> { } // If the intermediate result is ambiguous, we cannot determine the final result as BooleanLiteral. // In this case, we simply return a bool instance. - Truthiness::Ambiguous => return Some(KnownClass::Bool.to_instance(self.db)), + Truthiness::Ambiguous => return Ok(KnownClass::Bool.to_instance(self.db)), }, } } @@ -2978,7 +3158,7 @@ impl<'db> TypeInferenceBuilder<'db> { // We return a comparison of the slice lengths based on the operator. let (left_len, right_len) = (left.len(), right.len()); - Some(Type::BooleanLiteral(match op { + Ok(Type::BooleanLiteral(match op { RichCompareOperator::Eq => left_len == right_len, RichCompareOperator::Ne => left_len != right_len, RichCompareOperator::Lt => left_len < right_len, @@ -3009,30 +3189,15 @@ impl<'db> TypeInferenceBuilder<'db> { ) -> Type<'db> { match (value_ty, slice_ty) { // Ex) Given `("a", "b", "c", "d")[1]`, return `"b"` - (Type::Tuple(tuple_ty), Type::IntLiteral(int)) if int >= 0 => { + (Type::Tuple(tuple_ty), Type::IntLiteral(int)) => { let elements = tuple_ty.elements(self.db); - usize::try_from(int) - .ok() - .and_then(|index| elements.get(index).copied()) + elements + .iter() + .python_subscript(int) + .copied() .unwrap_or_else(|| { - self.tuple_index_out_of_bounds_diagnostic( - value_node.into(), - value_ty, - elements.len(), - int, - ); - Type::Unknown - }) - } - // Ex) Given `("a", "b", "c", "d")[-1]`, return `"c"` - (Type::Tuple(tuple_ty), Type::IntLiteral(int)) if int < 0 => { - let elements = tuple_ty.elements(self.db); - int.checked_neg() - .and_then(|int| usize::try_from(int).ok()) - .and_then(|index| elements.len().checked_sub(index)) - .and_then(|index| elements.get(index).copied()) - .unwrap_or_else(|| { - self.tuple_index_out_of_bounds_diagnostic( + self.index_out_of_bounds_diagnostic( + "tuple", value_node.into(), value_ty, elements.len(), @@ -3048,11 +3213,11 @@ impl<'db> TypeInferenceBuilder<'db> { Type::IntLiteral(i64::from(bool)), ), // Ex) Given `"value"[1]`, return `"a"` - (Type::StringLiteral(literal_ty), Type::IntLiteral(int)) if int >= 0 => { + (Type::StringLiteral(literal_ty), Type::IntLiteral(int)) => { let literal_value = literal_ty.value(self.db); - usize::try_from(int) - .ok() - .and_then(|index| literal_value.chars().nth(index)) + literal_value + .chars() + .python_subscript(int) .map(|ch| { Type::StringLiteral(StringLiteralType::new( self.db, @@ -3060,7 +3225,8 @@ impl<'db> TypeInferenceBuilder<'db> { )) }) .unwrap_or_else(|| { - self.string_index_out_of_bounds_diagnostic( + self.index_out_of_bounds_diagnostic( + "string", value_node.into(), value_ty, literal_value.chars().count(), @@ -3069,31 +3235,28 @@ impl<'db> TypeInferenceBuilder<'db> { Type::Unknown }) } - // Ex) Given `"value"[-1]`, return `"e"` - (Type::StringLiteral(literal_ty), Type::IntLiteral(int)) if int < 0 => { + // Ex) Given `b"value"[1]`, return `b"a"` + (Type::BytesLiteral(literal_ty), Type::IntLiteral(int)) => { let literal_value = literal_ty.value(self.db); - int.checked_neg() - .and_then(|int| usize::try_from(int).ok()) - .and_then(|index| index.checked_sub(1)) - .and_then(|index| literal_value.chars().rev().nth(index)) - .map(|ch| { - Type::StringLiteral(StringLiteralType::new( - self.db, - ch.to_string().into_boxed_str(), - )) + literal_value + .iter() + .python_subscript(int) + .map(|byte| { + Type::BytesLiteral(BytesLiteralType::new(self.db, [*byte].as_slice())) }) .unwrap_or_else(|| { - self.string_index_out_of_bounds_diagnostic( + self.index_out_of_bounds_diagnostic( + "bytes literal", value_node.into(), value_ty, - literal_value.chars().count(), + literal_value.len(), int, ); Type::Unknown }) } // Ex) Given `"value"[True]`, return `"a"` - (Type::StringLiteral(_), Type::BooleanLiteral(bool)) => self + (Type::StringLiteral(_) | Type::BytesLiteral(_), Type::BooleanLiteral(bool)) => self .infer_subscript_expression_types( value_node, value_ty, @@ -3134,7 +3297,7 @@ impl<'db> TypeInferenceBuilder<'db> { // even if the target version is Python 3.8 or lower, // despite the fact that there will be no corresponding `__class_getitem__` // method in these `sys.version_info` branches. - if value_ty.is_class(self.db) { + if value_ty.is_subtype_of(self.db, KnownClass::Type.to_instance(self.db)) { let dunder_class_getitem_method = value_ty.member(self.db, "__class_getitem__"); if !dunder_class_getitem_method.is_unbound() { return dunder_class_getitem_method @@ -3154,7 +3317,7 @@ impl<'db> TypeInferenceBuilder<'db> { }); } - if matches!(value_ty, Type::Class(class) if class.is_known(self.db, KnownClass::Type)) + if matches!(value_ty, Type::ClassLiteral(class) if class.is_known(self.db, KnownClass::Type)) { return KnownClass::GenericAlias.to_instance(self.db); } @@ -3321,6 +3484,17 @@ impl<'db> TypeInferenceBuilder<'db> { Type::Todo } + // TODO PEP-604 unions + ast::Expr::BinOp(binary) => { + self.infer_binary_expression(binary); + match binary.op { + // PEP-604 unions are okay + ast::Operator::BitOr => Type::Todo, + // anything else is an invalid annotation: + _ => Type::Unknown, + } + } + // Forms which are invalid in the context of annotation expressions: we infer their // nested expressions as normal expressions, but the type of the top-level expression is // always `Type::Unknown` in these cases. @@ -3332,10 +3506,6 @@ impl<'db> TypeInferenceBuilder<'db> { self.infer_named_expression(named); Type::Unknown } - ast::Expr::BinOp(binary) => { - self.infer_binary_expression(binary); - Type::Unknown - } ast::Expr::UnaryOp(unary) => { self.infer_unary_expression(unary); Type::Unknown @@ -3452,6 +3622,26 @@ impl From for ast::CmpOp { } } +impl RichCompareOperator { + const fn dunder_name(self) -> &'static str { + match self { + RichCompareOperator::Eq => "__eq__", + RichCompareOperator::Ne => "__ne__", + RichCompareOperator::Lt => "__lt__", + RichCompareOperator::Le => "__le__", + RichCompareOperator::Gt => "__gt__", + RichCompareOperator::Ge => "__ge__", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct CompareUnsupportedError<'db> { + op: ast::CmpOp, + left_ty: Type<'db>, + right_ty: Type<'db>, +} + fn format_import_from_module(level: u32, module: Option<&str>) -> String { format!( "{}{}", @@ -3532,8 +3722,8 @@ fn perform_rich_comparison<'db>( db: &'db dyn Db, left: ClassType<'db>, right: ClassType<'db>, - dunder_name: &str, -) -> Option> { + op: RichCompareOperator, +) -> Result, CompareUnsupportedError<'db>> { // The following resource has details about the rich comparison algorithm: // https://snarky.ca/unravelling-rich-comparison-operators/ // @@ -3541,17 +3731,26 @@ fn perform_rich_comparison<'db>( // l.h.s. // TODO: `object.__ne__` will call `__eq__` if `__ne__` is not defined - let dunder = left.class_member(db, dunder_name); + let dunder = left.class_member(db, op.dunder_name()); if !dunder.is_unbound() { // TODO: this currently gives the return type even if the arg types are invalid // (e.g. int.__lt__ with string instance should be None, currently bool) return dunder .call(db, &[Type::Instance(left), Type::Instance(right)]) - .return_ty(db); + .return_ty(db) + .ok_or_else(|| CompareUnsupportedError { + op: op.into(), + left_ty: Type::Instance(left), + right_ty: Type::Instance(right), + }); } // TODO: reflected dunder -- (==, ==), (!=, !=), (<, >), (>, <), (<=, >=), (>=, <=) - None + Err(CompareUnsupportedError { + op: op.into(), + left_ty: Type::Instance(left), + right_ty: Type::Instance(right), + }) } #[cfg(test)] @@ -3622,6 +3821,7 @@ mod tests { Ok(db) } + #[track_caller] fn assert_public_ty(db: &TestDb, file_name: &str, symbol_name: &str, expected: &str) { let file = system_path_to_file(db, file_name).expect("file to exist"); @@ -3633,6 +3833,7 @@ mod tests { ); } + #[track_caller] fn assert_scope_ty( db: &TestDb, file_name: &str, @@ -3658,6 +3859,7 @@ mod tests { assert_eq!(ty.display(db).to_string(), expected); } + #[track_caller] fn assert_diagnostic_messages(diagnostics: &TypeCheckDiagnostics, expected: &[&str]) { let messages: Vec<&str> = diagnostics .iter() @@ -3666,6 +3868,7 @@ mod tests { assert_eq!(&messages, expected); } + #[track_caller] fn assert_file_diagnostics(db: &TestDb, filename: &str, expected: &[&str]) { let file = system_path_to_file(db, filename).unwrap(); let diagnostics = check_types(db, file); @@ -3701,7 +3904,7 @@ mod tests { let mod_file = system_path_to_file(&db, "src/mod.py").expect("file to exist"); let ty = global_symbol_ty(&db, mod_file, "Sub"); - let class = ty.expect_class(); + let class = ty.expect_class_literal(); let base_names: Vec<_> = class .bases(&db) @@ -3727,9 +3930,9 @@ mod tests { let mod_file = system_path_to_file(&db, "src/mod.py").unwrap(); let ty = global_symbol_ty(&db, mod_file, "C"); - let class_id = ty.expect_class(); + let class_id = ty.expect_class_literal(); let member_ty = class_id.class_member(&db, &Name::new_static("f")); - let func = member_ty.expect_function(); + let func = member_ty.expect_function_literal(); assert_eq!(func.name(&db), "f"); Ok(()) @@ -3907,7 +4110,7 @@ mod tests { db.write_file("src/a.py", "def example() -> int: return 42")?; let mod_file = system_path_to_file(&db, "src/a.py").unwrap(); - let function = global_symbol_ty(&db, mod_file, "example").expect_function(); + let function = global_symbol_ty(&db, mod_file, "example").expect_function_literal(); let returns = function.return_type(&db); assert_eq!(returns.display(&db).to_string(), "int"); @@ -3936,14 +4139,14 @@ mod tests { let a = system_path_to_file(&db, "src/a.py").expect("file to exist"); let c_ty = global_symbol_ty(&db, a, "C"); - let c_class = c_ty.expect_class(); + let c_class = c_ty.expect_class_literal(); let mut c_bases = c_class.bases(&db); let b_ty = c_bases.next().unwrap(); - let b_class = b_ty.expect_class(); + let b_class = b_ty.expect_class_literal(); assert_eq!(b_class.name(&db), "B"); let mut b_bases = b_class.bases(&db); let a_ty = b_bases.next().unwrap(); - let a_class = a_ty.expect_class(); + let a_class = a_ty.expect_class_literal(); assert_eq!(a_class.name(&db), "A"); Ok(()) @@ -4093,7 +4296,7 @@ mod tests { let ty = global_symbol_ty(&db, file, "C"); let base = ty - .expect_class() + .expect_class_literal() .bases(&db) .next() .expect("there should be at least one base"); @@ -4253,7 +4456,7 @@ mod tests { from typing_extensions import reveal_type try: - x + print except as e: reveal_type(e) ", @@ -4390,7 +4593,10 @@ mod tests { assert_file_diagnostics( &db, "src/a.py", - &["Object of type `Unbound` is not iterable"], + &[ + "Name `x` used when not defined", + "Object of type `Unbound` is not iterable", + ], ); Ok(()) @@ -4525,7 +4731,7 @@ mod tests { assert_scope_ty(&db, "src/a.py", &["foo", ""], "z", "Unbound"); // (There is a diagnostic for invalid syntax that's emitted, but it's not listed by `assert_file_diagnostics`) - assert_file_diagnostics(&db, "src/a.py", &[]); + assert_file_diagnostics(&db, "src/a.py", &["Name `z` used when not defined"]); Ok(()) } diff --git a/crates/red_knot_python_semantic/src/types/narrow.rs b/crates/red_knot_python_semantic/src/types/narrow.rs index 16577e3ddf48e..58a314523a9ab 100644 --- a/crates/red_knot_python_semantic/src/types/narrow.rs +++ b/crates/red_knot_python_semantic/src/types/narrow.rs @@ -1,11 +1,14 @@ use crate::semantic_index::ast_ids::HasScopedAstId; -use crate::semantic_index::constraint::{Constraint, PatternConstraint}; +use crate::semantic_index::constraint::{Constraint, ConstraintNode, PatternConstraint}; use crate::semantic_index::definition::Definition; use crate::semantic_index::expression::Expression; use crate::semantic_index::symbol::{ScopeId, ScopedSymbolId, SymbolTable}; use crate::semantic_index::symbol_table; -use crate::types::{infer_expression_types, IntersectionBuilder, Type}; +use crate::types::{ + infer_expression_types, IntersectionBuilder, KnownFunction, Type, UnionBuilder, +}; use crate::Db; +use itertools::Itertools; use ruff_python_ast as ast; use rustc_hash::FxHashMap; use std::sync::Arc; @@ -31,13 +34,19 @@ pub(crate) fn narrowing_constraint<'db>( constraint: Constraint<'db>, definition: Definition<'db>, ) -> Option> { - match constraint { - Constraint::Expression(expression) => { - all_narrowing_constraints_for_expression(db, expression) - .get(&definition.symbol(db)) - .copied() + match constraint.node { + ConstraintNode::Expression(expression) => { + if constraint.is_positive { + all_narrowing_constraints_for_expression(db, expression) + .get(&definition.symbol(db)) + .copied() + } else { + all_negative_narrowing_constraints_for_expression(db, expression) + .get(&definition.symbol(db)) + .copied() + } } - Constraint::Pattern(pattern) => all_narrowing_constraints_for_pattern(db, pattern) + ConstraintNode::Pattern(pattern) => all_narrowing_constraints_for_pattern(db, pattern) .get(&definition.symbol(db)) .copied(), } @@ -48,7 +57,7 @@ fn all_narrowing_constraints_for_pattern<'db>( db: &'db dyn Db, pattern: PatternConstraint<'db>, ) -> NarrowingConstraints<'db> { - NarrowingConstraintsBuilder::new(db, Constraint::Pattern(pattern)).finish() + NarrowingConstraintsBuilder::new(db, ConstraintNode::Pattern(pattern), true).finish() } #[salsa::tracked(return_ref)] @@ -56,41 +65,79 @@ fn all_narrowing_constraints_for_expression<'db>( db: &'db dyn Db, expression: Expression<'db>, ) -> NarrowingConstraints<'db> { - NarrowingConstraintsBuilder::new(db, Constraint::Expression(expression)).finish() + NarrowingConstraintsBuilder::new(db, ConstraintNode::Expression(expression), true).finish() +} + +#[salsa::tracked(return_ref)] +fn all_negative_narrowing_constraints_for_expression<'db>( + db: &'db dyn Db, + expression: Expression<'db>, +) -> NarrowingConstraints<'db> { + NarrowingConstraintsBuilder::new(db, ConstraintNode::Expression(expression), false).finish() +} + +/// Generate a constraint from the *type* of the second argument of an `isinstance` call. +/// +/// Example: for `isinstance(…, str)`, we would infer `Type::ClassLiteral(str)` from the +/// second argument, but we need to generate a `Type::Instance(str)` constraint that can +/// be used to narrow down the type of the first argument. +fn generate_isinstance_constraint<'db>( + db: &'db dyn Db, + classinfo: &Type<'db>, +) -> Option> { + match classinfo { + Type::ClassLiteral(class) => Some(Type::Instance(*class)), + Type::Tuple(tuple) => { + let mut builder = UnionBuilder::new(db); + for element in tuple.elements(db) { + builder = builder.add(generate_isinstance_constraint(db, element)?); + } + Some(builder.build()) + } + _ => None, + } } type NarrowingConstraints<'db> = FxHashMap>; struct NarrowingConstraintsBuilder<'db> { db: &'db dyn Db, - constraint: Constraint<'db>, + constraint: ConstraintNode<'db>, + is_positive: bool, constraints: NarrowingConstraints<'db>, } impl<'db> NarrowingConstraintsBuilder<'db> { - fn new(db: &'db dyn Db, constraint: Constraint<'db>) -> Self { + fn new(db: &'db dyn Db, constraint: ConstraintNode<'db>, is_positive: bool) -> Self { Self { db, constraint, + is_positive, constraints: NarrowingConstraints::default(), } } fn finish(mut self) -> NarrowingConstraints<'db> { match self.constraint { - Constraint::Expression(expression) => self.evaluate_expression_constraint(expression), - Constraint::Pattern(pattern) => self.evaluate_pattern_constraint(pattern), + ConstraintNode::Expression(expression) => { + self.evaluate_expression_constraint(expression, self.is_positive); + } + ConstraintNode::Pattern(pattern) => self.evaluate_pattern_constraint(pattern), } - self.constraints.shrink_to_fit(); self.constraints } - fn evaluate_expression_constraint(&mut self, expression: Expression<'db>) { - if let ast::Expr::Compare(expr_compare) = expression.node_ref(self.db).node() { - self.add_expr_compare(expr_compare, expression); + fn evaluate_expression_constraint(&mut self, expression: Expression<'db>, is_positive: bool) { + match expression.node_ref(self.db).node() { + ast::Expr::Compare(expr_compare) => { + self.add_expr_compare(expr_compare, expression, is_positive); + } + ast::Expr::Call(expr_call) => { + self.add_expr_call(expr_call, expression, is_positive); + } + _ => {} // TODO other test expression kinds } - // TODO other test expression kinds } fn evaluate_pattern_constraint(&mut self, pattern: PatternConstraint<'db>) { @@ -130,36 +177,57 @@ impl<'db> NarrowingConstraintsBuilder<'db> { fn scope(&self) -> ScopeId<'db> { match self.constraint { - Constraint::Expression(expression) => expression.scope(self.db), - Constraint::Pattern(pattern) => pattern.scope(self.db), + ConstraintNode::Expression(expression) => expression.scope(self.db), + ConstraintNode::Pattern(pattern) => pattern.scope(self.db), } } - fn add_expr_compare(&mut self, expr_compare: &ast::ExprCompare, expression: Expression<'db>) { + fn add_expr_compare( + &mut self, + expr_compare: &ast::ExprCompare, + expression: Expression<'db>, + is_positive: bool, + ) { let ast::ExprCompare { range: _, left, ops, comparators, } = expr_compare; + if !left.is_name_expr() && comparators.iter().all(|c| !c.is_name_expr()) { + // If none of the comparators are name expressions, + // we have no symbol to narrow down the type of. + return; + } + if !is_positive && comparators.len() > 1 { + // We can't negate a constraint made by a multi-comparator expression, since we can't + // know which comparison part is the one being negated. + // For example, the negation of `x is 1 is y is 2`, would be `(x is not 1) or (y is not 1) or (y is not 2)` + // and that requires cross-symbol constraints, which we don't support yet. + return; + } + let scope = self.scope(); + let inference = infer_expression_types(self.db, expression); - if let ast::Expr::Name(ast::ExprName { - range: _, - id, - ctx: _, - }) = left.as_ref() - { - // SAFETY: we should always have a symbol for every Name node. - let symbol = self.symbols().symbol_id_by_name(id).unwrap(); - let scope = self.scope(); - let inference = infer_expression_types(self.db, expression); - for (op, comparator) in std::iter::zip(&**ops, &**comparators) { - let comp_ty = inference.expression_ty(comparator.scoped_ast_id(self.db, scope)); - match op { + let comparator_tuples = std::iter::once(&**left) + .chain(comparators) + .tuple_windows::<(&ruff_python_ast::Expr, &ruff_python_ast::Expr)>(); + for (op, (left, right)) in std::iter::zip(&**ops, comparator_tuples) { + if let ast::Expr::Name(ast::ExprName { + range: _, + id, + ctx: _, + }) = left + { + // SAFETY: we should always have a symbol for every Name node. + let symbol = self.symbols().symbol_id_by_name(id).unwrap(); + let rhs_ty = inference.expression_ty(right.scoped_ast_id(self.db, scope)); + + match if is_positive { *op } else { op.negate() } { ast::CmpOp::IsNot => { - if comp_ty.is_singleton() { + if rhs_ty.is_singleton() { let ty = IntersectionBuilder::new(self.db) - .add_negative(comp_ty) + .add_negative(rhs_ty) .build(); self.constraints.insert(symbol, ty); } else { @@ -167,7 +235,15 @@ impl<'db> NarrowingConstraintsBuilder<'db> { } } ast::CmpOp::Is => { - self.constraints.insert(symbol, comp_ty); + self.constraints.insert(symbol, rhs_ty); + } + ast::CmpOp::NotEq => { + if rhs_ty.is_single_valued(self.db) { + let ty = IntersectionBuilder::new(self.db) + .add_negative(rhs_ty) + .build(); + self.constraints.insert(symbol, ty); + } } _ => { // TODO other comparison types @@ -177,6 +253,42 @@ impl<'db> NarrowingConstraintsBuilder<'db> { } } + fn add_expr_call( + &mut self, + expr_call: &ast::ExprCall, + expression: Expression<'db>, + is_positive: bool, + ) { + let scope = self.scope(); + let inference = infer_expression_types(self.db, expression); + + if let Some(func_type) = inference + .expression_ty(expr_call.func.scoped_ast_id(self.db, scope)) + .into_function_literal_type() + { + if func_type.is_known(self.db, KnownFunction::IsInstance) + && expr_call.arguments.keywords.is_empty() + { + if let [ast::Expr::Name(ast::ExprName { id, .. }), rhs] = &*expr_call.arguments.args + { + let symbol = self.symbols().symbol_id_by_name(id).unwrap(); + + let rhs_type = inference.expression_ty(rhs.scoped_ast_id(self.db, scope)); + + // TODO: add support for PEP 604 union types on the right hand side: + // isinstance(x, str | (int | float)) + if let Some(mut constraint) = generate_isinstance_constraint(self.db, &rhs_type) + { + if !is_positive { + constraint = constraint.negate(self.db); + } + self.constraints.insert(symbol, constraint); + } + } + } + } + } + fn add_match_pattern_singleton( &mut self, subject: &ast::Expr, diff --git a/crates/red_knot_python_semantic/src/util/mod.rs b/crates/red_knot_python_semantic/src/util/mod.rs new file mode 100644 index 0000000000000..0f80fb9c6ef09 --- /dev/null +++ b/crates/red_knot_python_semantic/src/util/mod.rs @@ -0,0 +1 @@ +pub(crate) mod subscript; diff --git a/crates/red_knot_python_semantic/src/util/subscript.rs b/crates/red_knot_python_semantic/src/util/subscript.rs new file mode 100644 index 0000000000000..a88276e4b4254 --- /dev/null +++ b/crates/red_knot_python_semantic/src/util/subscript.rs @@ -0,0 +1,83 @@ +pub(crate) trait PythonSubscript { + type Item; + + fn python_subscript(&mut self, index: i64) -> Option; +} + +impl> PythonSubscript for T { + type Item = I; + + fn python_subscript(&mut self, index: i64) -> Option { + if index >= 0 { + self.nth(usize::try_from(index).ok()?) + } else { + let nth_rev = usize::try_from(index.checked_neg()?).ok()?.checked_sub(1)?; + self.rev().nth(nth_rev) + } + } +} + +#[cfg(test)] +#[allow(clippy::redundant_clone)] +mod tests { + use super::PythonSubscript; + + #[test] + fn python_subscript_basic() { + let iter = 'a'..='e'; + + assert_eq!(iter.clone().python_subscript(0), Some('a')); + assert_eq!(iter.clone().python_subscript(1), Some('b')); + assert_eq!(iter.clone().python_subscript(4), Some('e')); + assert_eq!(iter.clone().python_subscript(5), None); + + assert_eq!(iter.clone().python_subscript(-1), Some('e')); + assert_eq!(iter.clone().python_subscript(-2), Some('d')); + assert_eq!(iter.clone().python_subscript(-5), Some('a')); + assert_eq!(iter.clone().python_subscript(-6), None); + } + + #[test] + fn python_subscript_empty() { + let iter = 'a'..'a'; + + assert_eq!(iter.clone().python_subscript(0), None); + assert_eq!(iter.clone().python_subscript(1), None); + assert_eq!(iter.clone().python_subscript(-1), None); + } + + #[test] + fn python_subscript_single_element() { + let iter = 'a'..='a'; + + assert_eq!(iter.clone().python_subscript(0), Some('a')); + assert_eq!(iter.clone().python_subscript(1), None); + assert_eq!(iter.clone().python_subscript(-1), Some('a')); + assert_eq!(iter.clone().python_subscript(-2), None); + } + + #[test] + fn python_subscript_uses_full_index_range() { + let iter = 0..=u64::MAX; + + assert_eq!(iter.clone().python_subscript(0), Some(0)); + assert_eq!(iter.clone().python_subscript(1), Some(1)); + assert_eq!( + iter.clone().python_subscript(i64::MAX), + Some(i64::MAX as u64) + ); + + assert_eq!(iter.clone().python_subscript(-1), Some(u64::MAX)); + assert_eq!(iter.clone().python_subscript(-2), Some(u64::MAX - 1)); + + // i64::MIN is not representable as a positive number, so it is not + // a valid index: + assert_eq!(iter.clone().python_subscript(i64::MIN), None); + + // but i64::MIN +1 is: + assert_eq!( + iter.clone().python_subscript(i64::MIN + 1), + Some(2u64.pow(63) + 1) + ); + } +} diff --git a/crates/red_knot_python_semantic/tests/mdtest.rs b/crates/red_knot_python_semantic/tests/mdtest.rs index 1215995700564..74e07e65455a5 100644 --- a/crates/red_knot_python_semantic/tests/mdtest.rs +++ b/crates/red_knot_python_semantic/tests/mdtest.rs @@ -1,14 +1,24 @@ +use dir_test::{dir_test, Fixture}; use red_knot_test::run; -use std::path::PathBuf; +use std::path::Path; /// See `crates/red_knot_test/README.md` for documentation on these tests. -#[rstest::rstest] -fn mdtest(#[files("resources/mdtest/**/*.md")] path: PathBuf) { - let crate_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("resources") - .join("mdtest") +#[dir_test( + dir: "$CARGO_MANIFEST_DIR/resources/mdtest", + glob: "**/*.md" +)] +#[allow(clippy::needless_pass_by_value)] +fn mdtest(fixture: Fixture<&str>) { + let path = fixture.path(); + + let crate_dir = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("resources/mdtest") .canonicalize() .unwrap(); - let title = path.strip_prefix(crate_dir).unwrap(); - run(&path, title.as_os_str().to_str().unwrap()); + + let relative_path = path + .strip_prefix(crate_dir.to_str().unwrap()) + .unwrap_or(path); + + run(Path::new(path), relative_path); } diff --git a/crates/red_knot_server/src/server/api/notifications/did_open_notebook.rs b/crates/red_knot_server/src/server/api/notifications/did_open_notebook.rs index acd1c3ea308c2..edbff7e022b4d 100644 --- a/crates/red_knot_server/src/server/api/notifications/did_open_notebook.rs +++ b/crates/red_knot_server/src/server/api/notifications/did_open_notebook.rs @@ -37,7 +37,7 @@ impl SyncNotificationHandler for DidOpenNotebookHandler { params.cell_text_documents, ) .with_failure_code(ErrorCode::InternalError)?; - session.open_notebook_document(params.notebook_document.uri.clone(), notebook); + session.open_notebook_document(params.notebook_document.uri, notebook); match path { AnySystemPath::System(path) => { diff --git a/crates/red_knot_test/Cargo.toml b/crates/red_knot_test/Cargo.toml index 5fcc0b9143edb..68c7f13e96e23 100644 --- a/crates/red_knot_test/Cargo.toml +++ b/crates/red_knot_test/Cargo.toml @@ -21,7 +21,7 @@ ruff_text_size = { workspace = true } anyhow = { workspace = true } colored = { workspace = true } -once_cell = { workspace = true } +memchr = { workspace = true } regex = { workspace = true } rustc-hash = { workspace = true } salsa = { workspace = true } diff --git a/crates/red_knot_test/README.md b/crates/red_knot_test/README.md index 3c35862b4d488..e53b9ca4acb77 100644 --- a/crates/red_knot_test/README.md +++ b/crates/red_knot_test/README.md @@ -34,14 +34,14 @@ syntax, it's just how this README embeds an example mdtest Markdown document.) See actual example mdtest suites in [`crates/red_knot_python_semantic/resources/mdtest`](https://github.com/astral-sh/ruff/tree/main/crates/red_knot_python_semantic/resources/mdtest). -> ℹ️ Note: If you use `rstest` to generate a separate test for all Markdown files in a certain directory, +> ℹ️ 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/red_knot_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/red_knot_python_semantic/build.rs`](https://github.com/astral-sh/ruff/tree/main/crates/red_knot_python_semantic/build.rs) > for an example of how to do this. > -> This is because `rstest` generates its tests at build time rather than at runtime. +> 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. diff --git a/crates/red_knot_test/src/assertion.rs b/crates/red_knot_test/src/assertion.rs index 160214e36b652..2355b19f2f748 100644 --- a/crates/red_knot_test/src/assertion.rs +++ b/crates/red_knot_test/src/assertion.rs @@ -35,7 +35,6 @@ //! ``` use crate::db::Db; -use once_cell::sync::Lazy; use regex::Regex; use ruff_db::files::File; use ruff_db::parsed::parsed_module; @@ -45,6 +44,7 @@ use ruff_source_file::{LineIndex, Locator, OneIndexed}; use ruff_text_size::{Ranged, TextRange}; use smallvec::SmallVec; use std::ops::Deref; +use std::sync::LazyLock; /// Diagnostic assertion comments in a single embedded file. #[derive(Debug)] @@ -239,10 +239,10 @@ impl<'a> Deref for LineAssertions<'a> { } } -static TYPE_RE: Lazy = - Lazy::new(|| Regex::new(r"^#\s*revealed:\s*(?.+?)\s*$").unwrap()); +static TYPE_RE: LazyLock = + LazyLock::new(|| Regex::new(r"^#\s*revealed:\s*(?.+?)\s*$").unwrap()); -static ERROR_RE: Lazy = Lazy::new(|| { +static ERROR_RE: LazyLock = LazyLock::new(|| { Regex::new( r#"^#\s*error:(\s*(?\d+))?(\s*\[(?.+?)\])?(\s*"(?.+?)")?\s*$"#, ) diff --git a/crates/red_knot_test/src/db.rs b/crates/red_knot_test/src/db.rs index 5787942d7f73e..3cbd2eccb7efd 100644 --- a/crates/red_knot_test/src/db.rs +++ b/crates/red_knot_test/src/db.rs @@ -2,13 +2,13 @@ use red_knot_python_semantic::{ Db as SemanticDb, Program, ProgramSettings, PythonVersion, SearchPathSettings, }; use ruff_db::files::{File, Files}; -use ruff_db::system::SystemPathBuf; -use ruff_db::system::{DbWithTestSystem, System, TestSystem}; +use ruff_db::system::{DbWithTestSystem, System, SystemPath, SystemPathBuf, TestSystem}; use ruff_db::vendored::VendoredFileSystem; use ruff_db::{Db as SourceDb, Upcast}; #[salsa::db] pub(crate) struct Db { + workspace_root: SystemPathBuf, storage: salsa::Storage, files: Files, system: TestSystem, @@ -18,6 +18,7 @@ pub(crate) struct Db { impl Db { pub(crate) fn setup(workspace_root: SystemPathBuf) -> Self { let db = Self { + workspace_root, storage: salsa::Storage::default(), system: TestSystem::default(), vendored: red_knot_vendored::file_system().clone(), @@ -25,20 +26,24 @@ impl Db { }; db.memory_file_system() - .create_directory_all(&workspace_root) + .create_directory_all(&db.workspace_root) .unwrap(); Program::from_settings( &db, &ProgramSettings { target_version: PythonVersion::default(), - search_paths: SearchPathSettings::new(workspace_root), + search_paths: SearchPathSettings::new(db.workspace_root.clone()), }, ) .expect("Invalid search path settings"); db } + + pub(crate) fn workspace_root(&self) -> &SystemPath { + &self.workspace_root + } } impl DbWithTestSystem for Db { diff --git a/crates/red_knot_test/src/lib.rs b/crates/red_knot_test/src/lib.rs index 688b906ac09d0..cd49a98041f74 100644 --- a/crates/red_knot_test/src/lib.rs +++ b/crates/red_knot_test/src/lib.rs @@ -1,13 +1,12 @@ use colored::Colorize; use parser as test_parser; use red_knot_python_semantic::types::check_types; -use ruff_db::files::system_path_to_file; +use ruff_db::files::{system_path_to_file, File, Files}; use ruff_db::parsed::parsed_module; use ruff_db::system::{DbWithTestSystem, SystemPathBuf}; -use std::collections::BTreeMap; -use std::path::PathBuf; - -type Failures = BTreeMap; +use ruff_source_file::LineIndex; +use ruff_text_size::TextSize; +use std::path::Path; mod assertion; mod db; @@ -19,7 +18,7 @@ mod parser; /// /// Panic on test failure, and print failure details. #[allow(clippy::print_stdout)] -pub fn run(path: &PathBuf, title: &str) { +pub fn run(path: &Path, title: &str) { let source = std::fs::read_to_string(path).unwrap(); let suite = match test_parser::parse(title, &source) { Ok(suite) => suite, @@ -28,68 +27,106 @@ pub fn run(path: &PathBuf, title: &str) { } }; + let mut db = db::Db::setup(SystemPathBuf::from("/src")); + let mut any_failures = false; for test in suite.tests() { - if let Err(failures) = run_test(&test) { + // Remove all files so that the db is in a "fresh" state. + db.memory_file_system().remove_all(); + Files::sync_all(&mut db); + + if let Err(failures) = run_test(&mut db, &test) { any_failures = true; println!("\n{}\n", test.name().bold().underline()); - for (path, by_line) in failures { - println!("{}", path.as_str().bold()); - for (line_number, failures) in by_line.iter() { + let md_index = LineIndex::from_source_text(&source); + + for test_failures in failures { + let backtick_line = md_index.line_index(test_failures.backtick_offset); + + for (relative_line_number, failures) in test_failures.by_line.iter() { for failure in failures { - let line_info = format!("line {line_number}:").cyan(); + let absolute_line_number = + backtick_line.checked_add(relative_line_number).unwrap(); + let line_info = format!("{title}:{absolute_line_number}").cyan(); println!(" {line_info} {failure}"); } } - println!(); } } } - println!("{}\n", "-".repeat(50)); + println!("\n{}\n", "-".repeat(50)); assert!(!any_failures, "Some tests failed."); } -fn run_test(test: &parser::MarkdownTest) -> Result<(), Failures> { - let workspace_root = SystemPathBuf::from("/src"); - let mut db = db::Db::setup(workspace_root.clone()); +fn run_test(db: &mut db::Db, test: &parser::MarkdownTest) -> Result<(), Failures> { + let workspace_root = db.workspace_root().to_path_buf(); - let mut system_paths = vec![]; + let test_files: Vec<_> = test + .files() + .map(|embedded| { + assert!( + matches!(embedded.lang, "py" | "pyi"), + "Non-Python files not supported yet." + ); + let full_path = workspace_root.join(embedded.path); + db.write_file(&full_path, embedded.code).unwrap(); + let file = system_path_to_file(db, full_path).unwrap(); - for file in test.files() { - assert!( - matches!(file.lang, "py" | "pyi"), - "Non-Python files not supported yet." - ); - let full_path = workspace_root.join(file.path); - db.write_file(&full_path, file.code).unwrap(); - system_paths.push(full_path); - } + TestFile { + file, + backtick_offset: embedded.md_offset, + } + }) + .collect(); - let mut failures = BTreeMap::default(); + let failures: Failures = test_files + .into_iter() + .filter_map(|test_file| { + let parsed = parsed_module(db, test_file.file); - for path in system_paths { - let file = system_path_to_file(&db, path.clone()).unwrap(); - let parsed = parsed_module(&db, file); + // TODO allow testing against code with syntax errors + assert!( + parsed.errors().is_empty(), + "Python syntax errors in {}, {}: {:?}", + test.name(), + test_file.file.path(db), + parsed.errors() + ); - // TODO allow testing against code with syntax errors - assert!( - parsed.errors().is_empty(), - "Python syntax errors in {}, {:?}: {:?}", - test.name(), - path, - parsed.errors() - ); + match matcher::match_file(db, test_file.file, check_types(db, test_file.file)) { + Ok(()) => None, + Err(line_failures) => Some(FileFailures { + backtick_offset: test_file.backtick_offset, + by_line: line_failures, + }), + } + }) + .collect(); - matcher::match_file(&db, file, check_types(&db, file)).unwrap_or_else(|line_failures| { - failures.insert(path, line_failures); - }); - } if failures.is_empty() { Ok(()) } else { Err(failures) } } + +type Failures = Vec; + +/// The failures for a single file in a test by line number. +struct FileFailures { + /// The offset of the backticks that starts the code block in the Markdown file + backtick_offset: TextSize, + /// The failures by lines in the code block. + by_line: matcher::FailuresByLine, +} + +/// File in a test. +struct TestFile { + file: File, + + // Offset of the backticks that starts the code block in the Markdown file + backtick_offset: TextSize, +} diff --git a/crates/red_knot_test/src/parser.rs b/crates/red_knot_test/src/parser.rs index 9d760f9036231..8b1445020d86f 100644 --- a/crates/red_knot_test/src/parser.rs +++ b/crates/red_knot_test/src/parser.rs @@ -1,8 +1,13 @@ -use once_cell::sync::Lazy; -use regex::{Captures, Regex}; -use ruff_index::{newtype_index, IndexVec}; +use std::sync::LazyLock; + +use memchr::memchr2; +use regex::{Captures, Match, Regex}; use rustc_hash::{FxHashMap, FxHashSet}; +use ruff_index::{newtype_index, IndexVec}; +use ruff_python_trivia::Cursor; +use ruff_text_size::{TextLen, TextSize}; + /// Parse the Markdown `source` as a test suite with given `title`. pub(crate) fn parse<'s>(title: &'s str, source: &'s str) -> anyhow::Result> { let parser = Parser::new(title, source); @@ -131,17 +136,20 @@ pub(crate) struct EmbeddedFile<'s> { pub(crate) path: &'s str, pub(crate) lang: &'s str, pub(crate) code: &'s str, + + /// The offset of the backticks beginning the code block within the markdown file + pub(crate) md_offset: TextSize, } -/// Matches an arbitrary amount of whitespace (including newlines), followed by a sequence of `#` -/// characters, followed by a title heading, followed by a newline. -static HEADER_RE: Lazy = - Lazy::new(|| Regex::new(r"^(\s*\n)*(?#+)\s+(?.+)\s*\n").unwrap()); +/// Matches a sequence of `#` characters, followed by a title heading, followed by a newline. +static HEADER_RE: LazyLock<Regex> = + LazyLock::new(|| Regex::new(r"^(?<level>#+)\s+(?<title>.+)\s*\n").unwrap()); /// Matches a code block fenced by triple backticks, possibly with language and `key=val` /// configuration items following the opening backticks (in the "tag string" of the code block). -static CODE_RE: Lazy<Regex> = Lazy::new(|| { - Regex::new(r"^```(?<lang>\w+)(?<config>( +\S+)*)\s*\n(?<code>(.|\n)*?)\n?```\s*\n").unwrap() +static CODE_RE: LazyLock<Regex> = LazyLock::new(|| { + Regex::new(r"^```(?<lang>(?-u:\w)+)?(?<config>(?: +\S+)*)\s*\n(?<code>(?:.|\n)*?)\n?```\s*\n") + .unwrap() }); #[derive(Debug)] @@ -184,7 +192,9 @@ struct Parser<'s> { files: IndexVec<EmbeddedFileId, EmbeddedFile<'s>>, /// The unparsed remainder of the Markdown source. - unparsed: &'s str, + cursor: Cursor<'s>, + + source_len: TextSize, /// Stack of ancestor sections. stack: SectionStack, @@ -204,7 +214,8 @@ impl<'s> Parser<'s> { Self { sections, files: IndexVec::default(), - unparsed: source, + cursor: Cursor::new(source), + source_len: source.text_len(), stack: SectionStack::new(root_section_id), current_section_files: None, } @@ -226,33 +237,58 @@ impl<'s> Parser<'s> { } fn parse_impl(&mut self) -> anyhow::Result<()> { - while !self.unparsed.is_empty() { - if let Some(captures) = self.scan(&HEADER_RE) { - self.parse_header(&captures)?; - } else if let Some(captures) = self.scan(&CODE_RE) { - self.parse_code_block(&captures)?; - } else { - // ignore other Markdown syntax (paragraphs, etc) used as comments in the test - if let Some(next_newline) = self.unparsed.find('\n') { - (_, self.unparsed) = self.unparsed.split_at(next_newline + 1); - } else { - break; + while let Some(position) = memchr2(b'`', b'#', self.cursor.as_bytes()) { + self.cursor.skip_bytes(position.saturating_sub(1)); + + // code blocks and headers must start on a new line. + if position == 0 || self.cursor.eat_char('\n') { + match self.cursor.first() { + '#' => { + if let Some(find) = HEADER_RE.find(self.cursor.as_str()) { + self.parse_header(find.as_str())?; + self.cursor.skip_bytes(find.len()); + continue; + } + } + '`' => { + if let Some(captures) = CODE_RE.captures(self.cursor.as_str()) { + self.parse_code_block(&captures)?; + self.cursor.skip_bytes(captures.get(0).unwrap().len()); + continue; + } + } + _ => unreachable!(), } } + + // Skip to the end of the line + if let Some(position) = memchr::memchr(b'\n', self.cursor.as_bytes()) { + self.cursor.skip_bytes(position); + } else { + break; + } } Ok(()) } - fn parse_header(&mut self, captures: &Captures<'s>) -> anyhow::Result<()> { - let header_level = captures["level"].len(); + fn parse_header(&mut self, header: &'s str) -> anyhow::Result<()> { + let mut trimmed = header.trim(); + + let mut header_level = 0usize; + while let Some(rest) = trimmed.strip_prefix('#') { + header_level += 1; + trimmed = rest; + } + + let title = trimmed.trim_start(); + self.pop_sections_to_level(header_level); let parent = self.stack.parent(); let section = Section { - // HEADER_RE can't match without a match for group 'title'. - title: captures.name("title").unwrap().into(), + title, level: header_level.try_into()?, parent_id: Some(parent), }; @@ -300,9 +336,15 @@ impl<'s> Parser<'s> { self.files.push(EmbeddedFile { path, section: parent, + lang: captures + .name("lang") + .as_ref() + .map(Match::as_str) + .unwrap_or_default(), // CODE_RE can't match without matches for 'lang' and 'code'. - lang: captures.name("lang").unwrap().into(), code: captures.name("code").unwrap().into(), + + md_offset: self.offset(), }); if let Some(current_files) = &mut self.current_section_files { @@ -336,15 +378,9 @@ impl<'s> Parser<'s> { } } - /// Get capture groups and advance cursor past match if unparsed text matches `pattern`. - fn scan(&mut self, pattern: &Regex) -> Option<Captures<'s>> { - if let Some(captures) = pattern.captures(self.unparsed) { - let (_, unparsed) = self.unparsed.split_at(captures.get(0).unwrap().end()); - self.unparsed = unparsed; - Some(captures) - } else { - None - } + /// Retrieves the current offset of the cursor within the source code. + fn offset(&self) -> TextSize { + self.source_len - self.cursor.text_len() } } @@ -428,6 +464,57 @@ mod tests { assert_eq!(file.code, "y = 2"); } + #[test] + fn multiple_file_tests() { + let source = dedent( + " + # One + + ```py path=main.py + from foo import y + ``` + + ```py path=foo.py + y = 2 + ``` + + # Two + + ```py + y = 2 + ``` + ", + ); + let mf = super::parse("file.md", &source).unwrap(); + + let [test1, test2] = &mf.tests().collect::<Vec<_>>()[..] else { + panic!("expected two tests"); + }; + + assert_eq!(test1.name(), "file.md - One"); + assert_eq!(test2.name(), "file.md - Two"); + + let [main, foo] = test1.files().collect::<Vec<_>>()[..] else { + panic!("expected two files"); + }; + + assert_eq!(main.path, "main.py"); + assert_eq!(main.lang, "py"); + assert_eq!(main.code, "from foo import y"); + + assert_eq!(foo.path, "foo.py"); + assert_eq!(foo.lang, "py"); + assert_eq!(foo.code, "y = 2"); + + let [file] = test2.files().collect::<Vec<_>>()[..] else { + panic!("expected one file"); + }; + + assert_eq!(file.path, "test.py"); + assert_eq!(file.lang, "py"); + assert_eq!(file.code, "y = 2"); + } + #[test] fn custom_file_path() { let source = dedent( @@ -473,6 +560,49 @@ mod tests { assert_eq!(file.code, "x = 1\ny = 2"); } + #[test] + fn empty_file() { + let source = dedent( + " + ```py + ``` + ", + ); + + let mf = super::parse("file.md", &source).unwrap(); + + let [test] = &mf.tests().collect::<Vec<_>>()[..] else { + panic!("expected one test"); + }; + let [file] = test.files().collect::<Vec<_>>()[..] else { + panic!("expected one file"); + }; + + assert_eq!(file.code, ""); + } + + #[test] + fn no_lang() { + let source = dedent( + " + ``` + x = 10 + ``` + ", + ); + + let mf = super::parse("file.md", &source).unwrap(); + + let [test] = &mf.tests().collect::<Vec<_>>()[..] else { + panic!("expected one test"); + }; + let [file] = test.files().collect::<Vec<_>>()[..] else { + panic!("expected one file"); + }; + + assert_eq!(file.code, "x = 10"); + } + #[test] fn no_header_inside_test() { let source = dedent( diff --git a/crates/red_knot_vendored/Cargo.toml b/crates/red_knot_vendored/Cargo.toml index 72c7dd542078e..b740bef3f4db5 100644 --- a/crates/red_knot_vendored/Cargo.toml +++ b/crates/red_knot_vendored/Cargo.toml @@ -12,7 +12,6 @@ license = { workspace = true } [dependencies] ruff_db = { workspace = true } -once_cell = { workspace = true } zip = { workspace = true } [build-dependencies] diff --git a/crates/red_knot_vendored/build.rs b/crates/red_knot_vendored/build.rs index 2ae39a2f9eb14..08ad3418bff8c 100644 --- a/crates/red_knot_vendored/build.rs +++ b/crates/red_knot_vendored/build.rs @@ -69,7 +69,7 @@ fn zip_dir(directory_path: &str, writer: File) -> ZipResult<File> { } fn main() { - println!("cargo:rerun-if-changed={TYPESHED_SOURCE_DIR}"); + println!("cargo::rerun-if-changed={TYPESHED_SOURCE_DIR}"); assert!( Path::new(TYPESHED_SOURCE_DIR).is_dir(), "Where is typeshed?" diff --git a/crates/red_knot_vendored/src/lib.rs b/crates/red_knot_vendored/src/lib.rs index de384cd570b97..d1a5816ec2383 100644 --- a/crates/red_knot_vendored/src/lib.rs +++ b/crates/red_knot_vendored/src/lib.rs @@ -1,14 +1,13 @@ -use once_cell::sync::Lazy; - use ruff_db::vendored::VendoredFileSystem; +use std::sync::LazyLock; // The file path here is hardcoded in this crate's `build.rs` script. // Luckily this crate will fail to build if this file isn't available at build time. static TYPESHED_ZIP_BYTES: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/zipped_typeshed.zip")); pub fn file_system() -> &'static VendoredFileSystem { - static VENDORED_TYPESHED_STUBS: Lazy<VendoredFileSystem> = - Lazy::new(|| VendoredFileSystem::new_static(TYPESHED_ZIP_BYTES).unwrap()); + static VENDORED_TYPESHED_STUBS: LazyLock<VendoredFileSystem> = + LazyLock::new(|| VendoredFileSystem::new_static(TYPESHED_ZIP_BYTES).unwrap()); &VENDORED_TYPESHED_STUBS } diff --git a/crates/red_knot_wasm/src/lib.rs b/crates/red_knot_wasm/src/lib.rs index 0379ad3fdda56..2d70b3d68419e 100644 --- a/crates/red_knot_wasm/src/lib.rs +++ b/crates/red_knot_wasm/src/lib.rs @@ -110,14 +110,14 @@ impl Workspace { pub fn check_file(&self, file_id: &FileHandle) -> Result<Vec<String>, Error> { let result = self.db.check_file(file_id.file).map_err(into_error)?; - Ok(result.clone()) + Ok(result) } /// Checks all open files pub fn check(&self) -> Result<Vec<String>, Error> { let result = self.db.check().map_err(into_error)?; - Ok(result.clone()) + Ok(result) } /// Returns the parsed AST for `path` diff --git a/crates/red_knot_workspace/src/lib.rs b/crates/red_knot_workspace/src/lib.rs index f0b3f62a9802f..495a1301157fb 100644 --- a/crates/red_knot_workspace/src/lib.rs +++ b/crates/red_knot_workspace/src/lib.rs @@ -1,4 +1,3 @@ pub mod db; -pub mod lint; pub mod watch; pub mod workspace; diff --git a/crates/red_knot_workspace/src/lint.rs b/crates/red_knot_workspace/src/lint.rs deleted file mode 100644 index 090e1fd5b609e..0000000000000 --- a/crates/red_knot_workspace/src/lint.rs +++ /dev/null @@ -1,318 +0,0 @@ -use std::cell::RefCell; -use std::time::Duration; - -use tracing::debug_span; - -use red_knot_python_semantic::types::Type; -use red_knot_python_semantic::{HasTy, ModuleName, SemanticModel}; -use ruff_db::files::File; -use ruff_db::parsed::{parsed_module, ParsedModule}; -use ruff_db::source::{source_text, SourceText}; -use ruff_python_ast as ast; -use ruff_python_ast::visitor::{walk_expr, walk_stmt, Visitor}; -use ruff_text_size::{Ranged, TextSize}; - -use crate::db::Db; - -/// Workaround query to test for if the computation should be cancelled. -/// Ideally, push for Salsa to expose an API for testing if cancellation was requested. -#[salsa::tracked] -#[allow(unused_variables)] -pub(crate) fn unwind_if_cancelled(db: &dyn Db) {} - -#[salsa::tracked(return_ref)] -pub(crate) fn lint_syntax(db: &dyn Db, file_id: File) -> Vec<String> { - #[allow(clippy::print_stdout)] - if std::env::var("RED_KNOT_SLOW_LINT").is_ok() { - for i in 0..10 { - unwind_if_cancelled(db); - - println!("RED_KNOT_SLOW_LINT is set, sleeping for {i}/10 seconds"); - std::thread::sleep(Duration::from_secs(1)); - } - } - - let mut diagnostics = Vec::new(); - - let source = source_text(db.upcast(), file_id); - lint_lines(&source, &mut diagnostics); - - let parsed = parsed_module(db.upcast(), file_id); - - if parsed.errors().is_empty() { - let ast = parsed.syntax(); - - let mut visitor = SyntaxLintVisitor { - diagnostics, - source: &source, - }; - visitor.visit_body(&ast.body); - diagnostics = visitor.diagnostics; - } - - diagnostics -} - -fn lint_lines(source: &str, diagnostics: &mut Vec<String>) { - for (line_number, line) in source.lines().enumerate() { - if line.len() < 88 { - continue; - } - - let char_count = line.chars().count(); - if char_count > 88 { - diagnostics.push(format!( - "Line {} is too long ({} characters)", - line_number + 1, - char_count - )); - } - } -} - -#[allow(unreachable_pub)] -#[salsa::tracked(return_ref)] -pub fn lint_semantic(db: &dyn Db, file_id: File) -> Vec<String> { - let _span = debug_span!("lint_semantic", file=%file_id.path(db)).entered(); - - let source = source_text(db.upcast(), file_id); - let parsed = parsed_module(db.upcast(), file_id); - let semantic = SemanticModel::new(db.upcast(), file_id); - - if !parsed.is_valid() { - return vec![]; - } - - let context = SemanticLintContext { - source, - parsed, - semantic, - diagnostics: RefCell::new(Vec::new()), - }; - - SemanticVisitor { context: &context }.visit_body(parsed.suite()); - - context.diagnostics.take() -} - -fn format_diagnostic(context: &SemanticLintContext, message: &str, start: TextSize) -> String { - let source_location = context - .semantic - .line_index() - .source_location(start, context.source_text()); - format!( - "{}:{}:{}: {}", - context.semantic.file_path(), - source_location.row, - source_location.column, - message, - ) -} - -fn lint_maybe_undefined(context: &SemanticLintContext, name: &ast::ExprName) { - if !matches!(name.ctx, ast::ExprContext::Load) { - return; - } - let semantic = &context.semantic; - let ty = name.ty(semantic); - if ty.is_unbound() { - context.push_diagnostic(format_diagnostic( - context, - &format!("Name `{}` used when not defined", &name.id), - name.start(), - )); - } else if ty.may_be_unbound(semantic.db()) { - context.push_diagnostic(format_diagnostic( - context, - &format!("Name `{}` used when possibly not defined", &name.id), - name.start(), - )); - } -} - -fn lint_bad_override(context: &SemanticLintContext, class: &ast::StmtClassDef) { - let semantic = &context.semantic; - - // TODO we should have a special marker on the real typing module (from typeshed) so if you - // have your own "typing" module in your project, we don't consider it THE typing module (and - // same for other stdlib modules that our lint rules care about) - let Some(typing) = semantic.resolve_module(&ModuleName::new("typing").unwrap()) else { - return; - }; - - let override_ty = semantic.global_symbol_ty(&typing, "override"); - - let Type::Class(class_ty) = class.ty(semantic) else { - return; - }; - - for function in class - .body - .iter() - .filter_map(|stmt| stmt.as_function_def_stmt()) - { - let Type::Function(ty) = function.ty(semantic) else { - return; - }; - - // TODO this shouldn't make direct use of the Db; see comment on SemanticModel::db - let db = semantic.db(); - - if ty.has_decorator(db, override_ty) { - let method_name = ty.name(db); - if class_ty - .inherited_class_member(db, method_name) - .is_unbound() - { - // TODO should have a qualname() method to support nested classes - context.push_diagnostic( - format!( - "Method {}.{} is decorated with `typing.override` but does not override any base class method", - class_ty.name(db), - method_name, - )); - } - } - } -} - -pub(crate) struct SemanticLintContext<'a> { - source: SourceText, - parsed: &'a ParsedModule, - semantic: SemanticModel<'a>, - diagnostics: RefCell<Vec<String>>, -} - -impl<'db> SemanticLintContext<'db> { - #[allow(unused)] - pub(crate) fn source_text(&self) -> &str { - self.source.as_str() - } - - #[allow(unused)] - pub(crate) fn ast(&self) -> &'db ast::ModModule { - self.parsed.syntax() - } - - pub(crate) fn push_diagnostic(&self, diagnostic: String) { - self.diagnostics.borrow_mut().push(diagnostic); - } - - #[allow(unused)] - pub(crate) fn extend_diagnostics(&mut self, diagnostics: impl IntoIterator<Item = String>) { - self.diagnostics.get_mut().extend(diagnostics); - } -} - -#[derive(Debug)] -struct SyntaxLintVisitor<'a> { - diagnostics: Vec<String>, - source: &'a str, -} - -impl Visitor<'_> for SyntaxLintVisitor<'_> { - fn visit_string_literal(&mut self, string_literal: &'_ ast::StringLiteral) { - // A very naive implementation of use double quotes - let text = &self.source[string_literal.range]; - - if text.starts_with('\'') { - self.diagnostics - .push("Use double quotes for strings".to_string()); - } - } -} - -struct SemanticVisitor<'a> { - context: &'a SemanticLintContext<'a>, -} - -impl Visitor<'_> for SemanticVisitor<'_> { - fn visit_stmt(&mut self, stmt: &ast::Stmt) { - if let ast::Stmt::ClassDef(class) = stmt { - lint_bad_override(self.context, class); - } - - walk_stmt(self, stmt); - } - - fn visit_expr(&mut self, expr: &ast::Expr) { - match expr { - ast::Expr::Name(name) if matches!(name.ctx, ast::ExprContext::Load) => { - lint_maybe_undefined(self.context, name); - } - _ => {} - } - - walk_expr(self, expr); - } -} - -#[cfg(test)] -mod tests { - use red_knot_python_semantic::{Program, ProgramSettings, PythonVersion, SearchPathSettings}; - use ruff_db::files::system_path_to_file; - use ruff_db::system::{DbWithTestSystem, SystemPathBuf}; - - use crate::db::tests::TestDb; - - use super::lint_semantic; - - fn setup_db() -> TestDb { - setup_db_with_root(SystemPathBuf::from("/src")) - } - - fn setup_db_with_root(src_root: SystemPathBuf) -> TestDb { - let db = TestDb::new(); - - db.memory_file_system() - .create_directory_all(&src_root) - .unwrap(); - - Program::from_settings( - &db, - &ProgramSettings { - target_version: PythonVersion::default(), - search_paths: SearchPathSettings::new(src_root), - }, - ) - .expect("Valid program settings"); - - db - } - - #[test] - fn undefined_variable() { - let mut db = setup_db(); - - db.write_dedented( - "/src/a.py", - " - x = int - if flag: - y = x - y - ", - ) - .unwrap(); - - let file = system_path_to_file(&db, "/src/a.py").expect("file to exist"); - let messages = lint_semantic(&db, file); - - assert_ne!(messages, &[] as &[String], "expected some diagnostics"); - - assert_eq!( - *messages, - if cfg!(windows) { - vec![ - "\\src\\a.py:3:4: Name `flag` used when not defined", - "\\src\\a.py:5:1: Name `y` used when possibly not defined", - ] - } else { - vec![ - "/src/a.py:3:4: Name `flag` used when not defined", - "/src/a.py:5:1: Name `y` used when possibly not defined", - ] - } - ); - } -} diff --git a/crates/red_knot_workspace/src/workspace.rs b/crates/red_knot_workspace/src/workspace.rs index a35aabc3adf0a..52e5f57a7a9c4 100644 --- a/crates/red_knot_workspace/src/workspace.rs +++ b/crates/red_knot_workspace/src/workspace.rs @@ -15,12 +15,9 @@ use ruff_db::{ use ruff_python_ast::{name::Name, PySourceType}; use ruff_text_size::Ranged; +use crate::db::Db; use crate::db::RootDatabase; use crate::workspace::files::{Index, Indexed, IndexedIter, PackageFiles}; -use crate::{ - db::Db, - lint::{lint_semantic, lint_syntax}, -}; mod files; mod metadata; @@ -423,8 +420,6 @@ pub(super) fn check_file(db: &dyn Db, file: File) -> Vec<String> { )); } - diagnostics.extend_from_slice(lint_syntax(db, file)); - diagnostics.extend_from_slice(lint_semantic(db, file)); diagnostics } @@ -540,17 +535,17 @@ impl Iterator for WorkspaceFilesIter<'_> { #[cfg(test)] mod tests { + use red_knot_python_semantic::types::check_types; use ruff_db::files::system_path_to_file; use ruff_db::source::source_text; use ruff_db::system::{DbWithTestSystem, SystemPath}; use ruff_db::testing::assert_function_query_was_not_run; use crate::db::tests::TestDb; - use crate::lint::lint_syntax; use crate::workspace::check_file; #[test] - fn check_file_skips_linting_when_file_cant_be_read() -> ruff_db::system::Result<()> { + fn check_file_skips_type_checking_when_file_cant_be_read() -> ruff_db::system::Result<()> { let mut db = TestDb::new(); let path = SystemPath::new("test.py"); @@ -568,7 +563,7 @@ mod tests { ); let events = db.take_salsa_events(); - assert_function_query_was_not_run(&db, lint_syntax, file, &events); + assert_function_query_was_not_run(&db, check_types, file, &events); // The user now creates a new file with an empty text. The source text // content returned by `source_text` remains unchanged, but the diagnostics should get updated. diff --git a/crates/ruff/Cargo.toml b/crates/ruff/Cargo.toml index 40dcddbf8a2be..baf65d724044e 100644 --- a/crates/ruff/Cargo.toml +++ b/crates/ruff/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff" -version = "0.7.0" +version = "0.7.1" publish = true authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff/build.rs b/crates/ruff/build.rs index c50a033d6df2b..3615ee555d68b 100644 --- a/crates/ruff/build.rs +++ b/crates/ruff/build.rs @@ -11,7 +11,7 @@ fn main() { #[allow(clippy::disallowed_methods)] let target = std::env::var("TARGET").unwrap(); - println!("cargo:rustc-env=RUST_HOST_TARGET={target}"); + println!("cargo::rustc-env=RUST_HOST_TARGET={target}"); } fn commit_info(workspace_root: &Path) { @@ -23,7 +23,7 @@ fn commit_info(workspace_root: &Path) { let git_head_path = git_dir.join("HEAD"); println!( - "cargo:rerun-if-changed={}", + "cargo::rerun-if-changed={}", git_head_path.as_path().display() ); @@ -39,7 +39,7 @@ fn commit_info(workspace_root: &Path) { if let Some(git_ref) = git_ref_parts.next() { let git_ref_path = git_dir.join(git_ref); println!( - "cargo:rerun-if-changed={}", + "cargo::rerun-if-changed={}", git_ref_path.as_path().display() ); } @@ -59,21 +59,21 @@ fn commit_info(workspace_root: &Path) { let stdout = String::from_utf8(output.stdout).unwrap(); let mut parts = stdout.split_whitespace(); let mut next = || parts.next().unwrap(); - println!("cargo:rustc-env=RUFF_COMMIT_HASH={}", next()); - println!("cargo:rustc-env=RUFF_COMMIT_SHORT_HASH={}", next()); - println!("cargo:rustc-env=RUFF_COMMIT_DATE={}", next()); + println!("cargo::rustc-env=RUFF_COMMIT_HASH={}", next()); + println!("cargo::rustc-env=RUFF_COMMIT_SHORT_HASH={}", next()); + println!("cargo::rustc-env=RUFF_COMMIT_DATE={}", next()); // Describe can fail for some commits // https://git-scm.com/docs/pretty-formats#Documentation/pretty-formats.txt-emdescribeoptionsem if let Some(describe) = parts.next() { let mut describe_parts = describe.split('-'); println!( - "cargo:rustc-env=RUFF_LAST_TAG={}", + "cargo::rustc-env=RUFF_LAST_TAG={}", describe_parts.next().unwrap() ); // If this is the tagged commit, this component will be missing println!( - "cargo:rustc-env=RUFF_LAST_TAG_DISTANCE={}", + "cargo::rustc-env=RUFF_LAST_TAG_DISTANCE={}", describe_parts.next().unwrap_or("0") ); } diff --git a/crates/ruff/src/args.rs b/crates/ruff/src/args.rs index 65f7e0aa0b01c..5a3d2f2874106 100644 --- a/crates/ruff/src/args.rs +++ b/crates/ruff/src/args.rs @@ -92,7 +92,7 @@ pub struct Args { #[allow(clippy::large_enum_variant)] #[derive(Debug, clap::Subcommand)] pub enum Command { - /// Run Ruff on the given files or directories (default). + /// Run Ruff on the given files or directories. Check(CheckCommand), /// Explain a rule (or all rules). #[command(group = clap::ArgGroup::new("selector").multiple(false).required(true))] diff --git a/crates/ruff_benchmark/Cargo.toml b/crates/ruff_benchmark/Cargo.toml index 05328759cd722..c6c8510145d69 100644 --- a/crates/ruff_benchmark/Cargo.toml +++ b/crates/ruff_benchmark/Cargo.toml @@ -39,7 +39,6 @@ harness = false [dependencies] codspeed-criterion-compat = { workspace = true, default-features = false, optional = true } criterion = { workspace = true, default-features = false } -once_cell = { workspace = true } rayon = { workspace = true } rustc-hash = { workspace = true } serde = { workspace = true } diff --git a/crates/ruff_benchmark/benches/red_knot.rs b/crates/ruff_benchmark/benches/red_knot.rs index 1a002f190b8ce..657b278079dab 100644 --- a/crates/ruff_benchmark/benches/red_knot.rs +++ b/crates/ruff_benchmark/benches/red_knot.rs @@ -28,30 +28,18 @@ static EXPECTED_DIAGNOSTICS: &[&str] = &[ // We don't support `*` imports yet: "/src/tomllib/_parser.py:7:29: Module `collections.abc` has no member `Iterable`", // We don't support terminal statements in control flow yet: - "/src/tomllib/_parser.py:353:5: Method `__getitem__` of type `Unbound | @Todo` is not callable on object of type `Unbound | @Todo`", - "/src/tomllib/_parser.py:455:9: Method `__getitem__` of type `Unbound | @Todo` is not callable on object of type `Unbound | @Todo`", - // True positives! - "Line 69 is too long (89 characters)", - "Use double quotes for strings", - "Use double quotes for strings", - "Use double quotes for strings", - "Use double quotes for strings", - "Use double quotes for strings", - "Use double quotes for strings", - "Use double quotes for strings", - // We don't support terminal statements in control flow yet: "/src/tomllib/_parser.py:66:18: Name `s` used when possibly not defined", "/src/tomllib/_parser.py:98:12: Name `char` used when possibly not defined", "/src/tomllib/_parser.py:101:12: Name `char` used when possibly not defined", "/src/tomllib/_parser.py:104:14: Name `char` used when possibly not defined", - "/src/tomllib/_parser.py:104:14: Name `char` used when possibly not defined", - "/src/tomllib/_parser.py:115:14: Name `char` used when possibly not defined", "/src/tomllib/_parser.py:115:14: Name `char` used when possibly not defined", "/src/tomllib/_parser.py:126:12: Name `char` used when possibly not defined", "/src/tomllib/_parser.py:348:20: Name `nest` used when possibly not defined", "/src/tomllib/_parser.py:353:5: Name `nest` used when possibly not defined", + "/src/tomllib/_parser.py:353:5: Method `__getitem__` of type `Unbound | @Todo` is not callable on object of type `Unbound | @Todo`", "/src/tomllib/_parser.py:453:24: Name `nest` used when possibly not defined", "/src/tomllib/_parser.py:455:9: Name `nest` used when possibly not defined", + "/src/tomllib/_parser.py:455:9: Method `__getitem__` of type `Unbound | @Todo` is not callable on object of type `Unbound | @Todo`", "/src/tomllib/_parser.py:482:16: Name `char` used when possibly not defined", "/src/tomllib/_parser.py:566:12: Name `char` used when possibly not defined", "/src/tomllib/_parser.py:573:12: Name `char` used when possibly not defined", diff --git a/crates/ruff_benchmark/src/lib.rs b/crates/ruff_benchmark/src/lib.rs index 5fcf31bf7e586..cb6236c29a1e5 100644 --- a/crates/ruff_benchmark/src/lib.rs +++ b/crates/ruff_benchmark/src/lib.rs @@ -82,7 +82,7 @@ impl TestFile { } } -static TARGET_DIR: once_cell::sync::Lazy<PathBuf> = once_cell::sync::Lazy::new(|| { +static TARGET_DIR: std::sync::LazyLock<PathBuf> = std::sync::LazyLock::new(|| { cargo_target_directory().unwrap_or_else(|| PathBuf::from("target")) }); diff --git a/crates/ruff_db/src/system/memory_fs.rs b/crates/ruff_db/src/system/memory_fs.rs index 44e7fe8a5fa59..5b584de114697 100644 --- a/crates/ruff_db/src/system/memory_fs.rs +++ b/crates/ruff_db/src/system/memory_fs.rs @@ -351,6 +351,17 @@ impl MemoryFileSystem { Ok(ReadDirectory::new(collected)) } + + /// Removes all files and directories except the current working directory. + pub fn remove_all(&self) { + self.inner.virtual_files.write().unwrap().clear(); + + self.inner + .by_path + .write() + .unwrap() + .retain(|key, _| key == self.inner.cwd.as_utf8_path()); + } } impl Default for MemoryFileSystem { diff --git a/crates/ruff_dev/src/format_dev.rs b/crates/ruff_dev/src/format_dev.rs index 2ef361a46d69b..f9be6b0bf591a 100644 --- a/crates/ruff_dev/src/format_dev.rs +++ b/crates/ruff_dev/src/format_dev.rs @@ -251,8 +251,7 @@ pub(crate) fn main(args: &Args) -> anyhow::Result<ExitCode> { } info!( parent: None, - "Done: {} stability errors, {} files, similarity index {:.5}), files with differences: {} took {:.2}s, {} input files contained syntax errors ", - error_count, + "Done: {error_count} stability/syntax errors, {} files, similarity index {:.5}), files with differences: {} took {:.2}s, {} input files contained syntax errors ", result.file_count, result.statistics.similarity_index(), result.statistics.files_with_differences, @@ -796,7 +795,7 @@ impl CheckFileError { | CheckFileError::PrintError(_) | CheckFileError::Panic { .. } => false, #[cfg(not(debug_assertions))] - CheckFileError::Slow(_) => false, + CheckFileError::Slow(_) => true, } } } diff --git a/crates/ruff_formatter/src/builders.rs b/crates/ruff_formatter/src/builders.rs index 33ea49724eb24..21ab988b5e38e 100644 --- a/crates/ruff_formatter/src/builders.rs +++ b/crates/ruff_formatter/src/builders.rs @@ -1454,7 +1454,7 @@ impl<Context> std::fmt::Debug for Group<'_, Context> { /// layout doesn't exceed the line width too, in which case it falls back to the flat layout. /// /// This IR is identical to the following [`best_fitting`] layout but is implemented as custom IR for -/// best performance. +/// better performance. /// /// ```rust /// # use ruff_formatter::prelude::*; diff --git a/crates/ruff_formatter/src/format_extensions.rs b/crates/ruff_formatter/src/format_extensions.rs index 33ca7b58e1daa..63ac361ee5799 100644 --- a/crates/ruff_formatter/src/format_extensions.rs +++ b/crates/ruff_formatter/src/format_extensions.rs @@ -139,7 +139,7 @@ where /// # Ok(()) /// # } /// ``` - pub fn inspect(&mut self, f: &mut Formatter<Context>) -> FormatResult<&[FormatElement]> { + pub fn inspect(&self, f: &mut Formatter<Context>) -> FormatResult<&[FormatElement]> { let result = self.memory.get_or_init(|| f.intern(&self.inner)); match result.as_ref() { diff --git a/crates/ruff_graph/Cargo.toml b/crates/ruff_graph/Cargo.toml index 9bb0a67d9ca9d..645c9cd2e4255 100644 --- a/crates/ruff_graph/Cargo.toml +++ b/crates/ruff_graph/Cargo.toml @@ -20,7 +20,6 @@ ruff_python_parser = { workspace = true } anyhow = { workspace = true } clap = { workspace = true, optional = true } -once_cell = { workspace = true } salsa = { workspace = true } schemars = { workspace = true, optional = true } serde = { workspace = true, optional = true } diff --git a/crates/ruff_graph/src/db.rs b/crates/ruff_graph/src/db.rs index a84168763131f..39f74081bc83d 100644 --- a/crates/ruff_graph/src/db.rs +++ b/crates/ruff_graph/src/db.rs @@ -7,12 +7,11 @@ use ruff_db::system::{OsSystem, System, SystemPathBuf}; use ruff_db::vendored::{VendoredFileSystem, VendoredFileSystemBuilder}; use ruff_db::{Db as SourceDb, Upcast}; -static EMPTY_VENDORED: once_cell::sync::Lazy<VendoredFileSystem> = - once_cell::sync::Lazy::new(|| { - let mut builder = VendoredFileSystemBuilder::new(CompressionMethod::Stored); - builder.add_file("stdlib/VERSIONS", "\n").unwrap(); - builder.finish().unwrap() - }); +static EMPTY_VENDORED: std::sync::LazyLock<VendoredFileSystem> = std::sync::LazyLock::new(|| { + let mut builder = VendoredFileSystemBuilder::new(CompressionMethod::Stored); + builder.add_file("stdlib/VERSIONS", "\n").unwrap(); + builder.finish().unwrap() +}); #[salsa::db] #[derive(Default)] diff --git a/crates/ruff_linter/Cargo.toml b/crates/ruff_linter/Cargo.toml index eded5f03f457d..770f89e2bafbe 100644 --- a/crates/ruff_linter/Cargo.toml +++ b/crates/ruff_linter/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_linter" -version = "0.7.0" +version = "0.7.1" publish = false authors = { workspace = true } edition = { workspace = true } @@ -46,13 +46,12 @@ libcst = { workspace = true } log = { workspace = true } memchr = { workspace = true } natord = { workspace = true } -once_cell = { workspace = true } path-absolutize = { workspace = true, features = [ - "once_cell_cache", - "use_unix_paths_on_wasm", + "once_cell_cache", + "use_unix_paths_on_wasm", ] } pathdiff = { workspace = true } -pep440_rs = { workspace = true, features = ["serde"] } +pep440_rs = { workspace = true } pyproject-toml = { workspace = true } quick-junit = { workspace = true } regex = { workspace = true } diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_type_checking/quote.py b/crates/ruff_linter/resources/test/fixtures/flake8_type_checking/quote.py index d13e8cd970f8c..547cd30117672 100644 --- a/crates/ruff_linter/resources/test/fixtures/flake8_type_checking/quote.py +++ b/crates/ruff_linter/resources/test/fixtures/flake8_type_checking/quote.py @@ -12,13 +12,6 @@ def baz() -> DataFrame[int]: ... -def f(): - from pandas import DataFrame - - def baz() -> DataFrame["int"]: - ... - - def f(): import pandas as pd diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_type_checking/quote2.py b/crates/ruff_linter/resources/test/fixtures/flake8_type_checking/quote2.py new file mode 100644 index 0000000000000..4433416da5087 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/flake8_type_checking/quote2.py @@ -0,0 +1,87 @@ +def f(): + from django.contrib.auth.models import AbstractBaseUser + + def test_remove_inner_quotes_double(self, user: AbstractBaseUser["int"]): + pass + + +def f(): + from django.contrib.auth.models import AbstractBaseUser + + def test_remove_inner_quotes_single(self, user: AbstractBaseUser['int']): + pass + + +def f(): + from django.contrib.auth.models import AbstractBaseUser + + def test_remove_inner_quotes_mixed(self, user: AbstractBaseUser['int', "str"]): + pass + + +def f(): + from typing import Annotated, Literal + + from django.contrib.auth.models import AbstractBaseUser + + def test_literal_annotation_args_contain_quotes(self, type1: AbstractBaseUser[Literal["user", "admin"], Annotated["int", "1", 2]]): + pass + + +def f(): + from typing import Literal + + from django.contrib.auth.models import AbstractBaseUser + + def test_union_contain_inner_quotes(self, type1: AbstractBaseUser["int" | Literal["int"]]): + pass + + +def f(): + from typing import Literal + + from django.contrib.auth.models import AbstractBaseUser + + def test_inner_literal_mixed_quotes(user: AbstractBaseUser[Literal['user', "admin"]]): + pass + + +def f(): + from typing import Literal + + from django.contrib.auth.models import AbstractBaseUser + + def test_inner_literal_single_quote(user: AbstractBaseUser[Literal['int'], str]): + pass + + +def f(): + from typing import Literal + + from django.contrib.auth.models import AbstractBaseUser + + def test_mixed_quotes_literal(user: AbstractBaseUser[Literal['user'], "int"]): + pass + + +def f(): + from typing import Annotated, Literal + + from django.contrib.auth.models import AbstractBaseUser + + def test_annotated_literal_mixed_quotes(user: AbstractBaseUser[Annotated[str, "max_length=20", Literal['user', "admin"]]]): + pass + + +def f(): + from typing import Literal, Union + + def test_union_literal_no_removal(arg: Union[Literal["true", "false"], None]): + pass + + +def f(): + from typing import Literal, Optional + + def test_optional_literal_no_removal(arg: Optional[Literal["red", "blue"]]): + pass diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_type_checking/quote3.py b/crates/ruff_linter/resources/test/fixtures/flake8_type_checking/quote3.py new file mode 100644 index 0000000000000..b360230b4e6e1 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/flake8_type_checking/quote3.py @@ -0,0 +1,44 @@ +def f(): + from typing import Literal, Union + + from django.contrib.auth.models import AbstractBaseUser + + def test_union_literal_mixed_quotes(user: AbstractBaseUser[Union[Literal['active', "inactive"], str]]): + pass + + +def f(): + from typing import Callable, Literal + + from django.contrib.auth.models import AbstractBaseUser + + def test_callable_literal_mixed_quotes(callable_fn: AbstractBaseUser[Callable[["int", Literal['admin', "user"]], 'bool']]): + pass + + +def f(): + from typing import Annotated, Callable, Literal + + from django.contrib.auth.models import AbstractBaseUser + + def test_callable_annotated_literal(callable_fn: AbstractBaseUser[Callable[[int, Annotated[str, Literal['active', "inactive"]]], bool]]): + pass + + +def f(): + from typing import literal + + from django.contrib.auth import models + + def test_attribute(arg: models.AbstractBaseUser["int"]): + pass + + +def f(): + from typing import Literal + + from django.contrib.auth import models + + def test_attribute_typing_literal(arg: models.AbstractBaseUser[Literal["admin"]]): + pass + diff --git a/crates/ruff_linter/resources/test/fixtures/pycodestyle/E22.py b/crates/ruff_linter/resources/test/fixtures/pycodestyle/E22.py index 556984df20b45..d20bf4278111b 100644 --- a/crates/ruff_linter/resources/test/fixtures/pycodestyle/E22.py +++ b/crates/ruff_linter/resources/test/fixtures/pycodestyle/E22.py @@ -180,3 +180,6 @@ def squares(n): if i == -1: pass #: + +if a == 1: + print(a) diff --git a/crates/ruff_linter/resources/test/fixtures/pylint/iteration_over_set.py b/crates/ruff_linter/resources/test/fixtures/pylint/iteration_over_set.py index 38a20dcc1ed55..f8d32e59a572b 100644 --- a/crates/ruff_linter/resources/test/fixtures/pylint/iteration_over_set.py +++ b/crates/ruff_linter/resources/test/fixtures/pylint/iteration_over_set.py @@ -50,3 +50,13 @@ for item in {*numbers_set, 4, 5, 6}: # set unpacking is fine print(f"I like {item}.") + +for item in {1, 2, 3, 4, 5, 6, 2 // 1}: # operations in set literals are fine + print(f"I like {item}.") + +for item in {1, 2, 3, 4, 5, 6, int("7")}: # calls in set literals are fine + print(f"I like {item}.") + +for item in {1, 2, 2}: # duplicate literals will be ignored + # B033 catches this + print(f"I like {item}.") diff --git a/crates/ruff_linter/src/checkers/ast/mod.rs b/crates/ruff_linter/src/checkers/ast/mod.rs index 3d4ab46a6d9af..821d74eb4d0b7 100644 --- a/crates/ruff_linter/src/checkers/ast/mod.rs +++ b/crates/ruff_linter/src/checkers/ast/mod.rs @@ -732,7 +732,7 @@ impl<'a> Visitor<'a> for Checker<'a> { // The default values of the parameters needs to be evaluated in the enclosing // scope. - for parameter in &**parameters { + for parameter in parameters { if let Some(expr) = parameter.default() { self.visit_expr(expr); } @@ -744,7 +744,7 @@ impl<'a> Visitor<'a> for Checker<'a> { self.visit_type_params(type_params); } - for parameter in &**parameters { + for parameter in parameters { if let Some(expr) = parameter.annotation() { if singledispatch && !parameter.is_variadic() { self.visit_runtime_required_annotation(expr); diff --git a/crates/ruff_linter/src/linter.rs b/crates/ruff_linter/src/linter.rs index ca7dc608f746c..6958b298cf3a3 100644 --- a/crates/ruff_linter/src/linter.rs +++ b/crates/ruff_linter/src/linter.rs @@ -1,4 +1,5 @@ use std::borrow::Cow; +use std::cell::LazyCell; use std::ops::Deref; use std::path::Path; @@ -440,7 +441,7 @@ fn diagnostics_to_messages( locator: &Locator, directives: &Directives, ) -> Vec<Message> { - let file = once_cell::unsync::Lazy::new(|| { + let file = LazyCell::new(|| { let mut builder = SourceFileBuilder::new(path.to_string_lossy().as_ref(), locator.contents()); diff --git a/crates/ruff_linter/src/logging.rs b/crates/ruff_linter/src/logging.rs index ce89402231ffc..4d88c637bcc43 100644 --- a/crates/ruff_linter/src/logging.rs +++ b/crates/ruff_linter/src/logging.rs @@ -1,12 +1,11 @@ use std::fmt::{Display, Formatter, Write}; use std::path::{Path, PathBuf}; -use std::sync::Mutex; +use std::sync::{LazyLock, Mutex}; use anyhow::Result; use colored::Colorize; use fern; use log::Level; -use once_cell::sync::Lazy; use ruff_python_parser::{ParseError, ParseErrorType}; use rustc_hash::FxHashSet; @@ -16,7 +15,7 @@ use crate::fs; use crate::source_kind::SourceKind; use ruff_notebook::Notebook; -pub static IDENTIFIERS: Lazy<Mutex<Vec<&'static str>>> = Lazy::new(Mutex::default); +pub static IDENTIFIERS: LazyLock<Mutex<Vec<&'static str>>> = LazyLock::new(Mutex::default); /// Warn a user once, with uniqueness determined by the given ID. #[macro_export] @@ -35,7 +34,7 @@ macro_rules! warn_user_once_by_id { }; } -pub static MESSAGES: Lazy<Mutex<FxHashSet<String>>> = Lazy::new(Mutex::default); +pub static MESSAGES: LazyLock<Mutex<FxHashSet<String>>> = LazyLock::new(Mutex::default); /// Warn a user once, if warnings are enabled, with uniqueness determined by the content of the /// message. diff --git a/crates/ruff_linter/src/rule_redirects.rs b/crates/ruff_linter/src/rule_redirects.rs index ab5ae7c7a7008..ccbd4ca571627 100644 --- a/crates/ruff_linter/src/rule_redirects.rs +++ b/crates/ruff_linter/src/rule_redirects.rs @@ -1,6 +1,5 @@ use std::collections::HashMap; - -use once_cell::sync::Lazy; +use std::sync::LazyLock; /// Returns the redirect target for the given code. pub(crate) fn get_redirect_target(code: &str) -> Option<&'static str> { @@ -13,7 +12,7 @@ pub(crate) fn get_redirect(code: &str) -> Option<(&'static str, &'static str)> { REDIRECTS.get_key_value(code).map(|(k, v)| (*k, *v)) } -static REDIRECTS: Lazy<HashMap<&'static str, &'static str>> = Lazy::new(|| { +static REDIRECTS: LazyLock<HashMap<&'static str, &'static str>> = LazyLock::new(|| { HashMap::from_iter([ // The following are here because we don't yet have the many-to-one mapping enabled. ("SIM111", "SIM110"), diff --git a/crates/ruff_linter/src/rules/eradicate/detection.rs b/crates/ruff_linter/src/rules/eradicate/detection.rs index f10efcaf5b212..c3ba54a199350 100644 --- a/crates/ruff_linter/src/rules/eradicate/detection.rs +++ b/crates/ruff_linter/src/rules/eradicate/detection.rs @@ -1,29 +1,28 @@ /// See: [eradicate.py](https://github.com/myint/eradicate/blob/98f199940979c94447a461d50d27862b118b282d/eradicate.py) use aho_corasick::AhoCorasick; use itertools::Itertools; -use once_cell::sync::Lazy; use regex::{Regex, RegexSet}; - use ruff_python_parser::parse_module; use ruff_python_trivia::{SimpleTokenKind, SimpleTokenizer}; use ruff_text_size::TextSize; +use std::sync::LazyLock; -static CODE_INDICATORS: Lazy<AhoCorasick> = Lazy::new(|| { +static CODE_INDICATORS: LazyLock<AhoCorasick> = LazyLock::new(|| { AhoCorasick::new([ "(", ")", "[", "]", "{", "}", ":", "=", "%", "return", "break", "continue", "import", ]) .unwrap() }); -static ALLOWLIST_REGEX: Lazy<Regex> = Lazy::new(|| { +static ALLOWLIST_REGEX: LazyLock<Regex> = LazyLock::new(|| { Regex::new( r"^(?i)(?:pylint|pyright|noqa|nosec|region|endregion|type:\s*ignore|fmt:\s*(on|off)|isort:\s*(on|off|skip|skip_file|split|dont-add-imports(:\s*\[.*?])?)|mypy:|SPDX-License-Identifier:|(?:en)?coding[:=][ \t]*([-_.a-zA-Z0-9]+))", ).unwrap() }); -static HASH_NUMBER: Lazy<Regex> = Lazy::new(|| Regex::new(r"#\d").unwrap()); +static HASH_NUMBER: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"#\d").unwrap()); -static POSITIVE_CASES: Lazy<RegexSet> = Lazy::new(|| { +static POSITIVE_CASES: LazyLock<RegexSet> = LazyLock::new(|| { RegexSet::new([ // Keywords r"^(?:elif\s+.*\s*:.*|else\s*:.*|try\s*:.*|finally\s*:.*|except.*:.*|case\s+.*\s*:.*)$", diff --git a/crates/ruff_linter/src/rules/eradicate/rules/commented_out_code.rs b/crates/ruff_linter/src/rules/eradicate/rules/commented_out_code.rs index b87dd9b8bfa82..53d87dcf94af7 100644 --- a/crates/ruff_linter/src/rules/eradicate/rules/commented_out_code.rs +++ b/crates/ruff_linter/src/rules/eradicate/rules/commented_out_code.rs @@ -88,7 +88,7 @@ where let line_end = locator.full_line_end(script_start.end()); let rest = locator.after(line_end); let mut end_offset = None; - let mut lines = UniversalNewlineIterator::with_offset(rest, line_end).peekable(); + let mut lines = UniversalNewlineIterator::with_offset(rest, line_end); while let Some(line) = lines.next() { let Some(content) = script_line_content(&line) else { diff --git a/crates/ruff_linter/src/rules/fastapi/rules/fastapi_unused_path_parameter.rs b/crates/ruff_linter/src/rules/fastapi/rules/fastapi_unused_path_parameter.rs index edd7c93e0c830..ec6acffebd29d 100644 --- a/crates/ruff_linter/src/rules/fastapi/rules/fastapi_unused_path_parameter.rs +++ b/crates/ruff_linter/src/rules/fastapi/rules/fastapi_unused_path_parameter.rs @@ -141,7 +141,7 @@ pub(crate) fn fastapi_unused_path_parameter( .parameters .args .iter() - .chain(function_def.parameters.kwonlyargs.iter()) + .chain(&function_def.parameters.kwonlyargs) .map(|ParameterWithDefault { parameter, .. }| { parameter_alias(parameter, checker.semantic()) .unwrap_or_else(|| parameter.name.as_str()) diff --git a/crates/ruff_linter/src/rules/flake8_bandit/helpers.rs b/crates/ruff_linter/src/rules/flake8_bandit/helpers.rs index 9f287804ff68d..a79d707f8aa13 100644 --- a/crates/ruff_linter/src/rules/flake8_bandit/helpers.rs +++ b/crates/ruff_linter/src/rules/flake8_bandit/helpers.rs @@ -1,10 +1,10 @@ -use once_cell::sync::Lazy; use regex::Regex; use ruff_python_ast::{self as ast, Expr}; +use std::sync::LazyLock; use ruff_python_semantic::SemanticModel; -static PASSWORD_CANDIDATE_REGEX: Lazy<Regex> = Lazy::new(|| { +static PASSWORD_CANDIDATE_REGEX: LazyLock<Regex> = LazyLock::new(|| { Regex::new(r"(^|_)(?i)(pas+wo?r?d|pass(phrase)?|pwd|token|secrete?)($|_)").unwrap() }); diff --git a/crates/ruff_linter/src/rules/flake8_bandit/rules/hardcoded_sql_expression.rs b/crates/ruff_linter/src/rules/flake8_bandit/rules/hardcoded_sql_expression.rs index 670c7d406fcf1..e68d0dcce5f07 100644 --- a/crates/ruff_linter/src/rules/flake8_bandit/rules/hardcoded_sql_expression.rs +++ b/crates/ruff_linter/src/rules/flake8_bandit/rules/hardcoded_sql_expression.rs @@ -1,5 +1,7 @@ -use once_cell::sync::Lazy; +use std::sync::LazyLock; + use regex::Regex; + use ruff_diagnostics::{Diagnostic, Violation}; use ruff_macros::{derive_message_formats, violation}; use ruff_python_ast::str::raw_contents; @@ -9,7 +11,7 @@ use ruff_text_size::Ranged; use crate::checkers::ast::Checker; -static SQL_REGEX: Lazy<Regex> = Lazy::new(|| { +static SQL_REGEX: LazyLock<Regex> = LazyLock::new(|| { Regex::new(r"(?i)\b(select\s+.*\s+from\s|delete\s+from\s|(insert|replace)\s+.*\s+values\s|update\s+.*\s+set\s)") .unwrap() }); diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/zip_without_explicit_strict.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/zip_without_explicit_strict.rs index 5c3bfb1e4129c..162af2b907305 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/rules/zip_without_explicit_strict.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/zip_without_explicit_strict.rs @@ -116,7 +116,7 @@ fn is_infinite_iterator(arg: &Expr, semantic: &SemanticModel) -> bool { } // Ex) `iterools.repeat(1, times=None)` - for keyword in &**keywords { + for keyword in keywords { if keyword.arg.as_ref().is_some_and(|name| name == "times") { if keyword.value.is_none_literal_expr() { return true; diff --git a/crates/ruff_linter/src/rules/flake8_copyright/settings.rs b/crates/ruff_linter/src/rules/flake8_copyright/settings.rs index 03551b0a0a93f..8eb50bc0a39b0 100644 --- a/crates/ruff_linter/src/rules/flake8_copyright/settings.rs +++ b/crates/ruff_linter/src/rules/flake8_copyright/settings.rs @@ -1,12 +1,14 @@ //! Settings for the `flake8-copyright` plugin. -use once_cell::sync::Lazy; -use regex::Regex; use std::fmt::{Display, Formatter}; +use std::sync::LazyLock; + +use regex::Regex; -use crate::display_settings; use ruff_macros::CacheKey; +use crate::display_settings; + #[derive(Debug, Clone, CacheKey)] pub struct Settings { pub notice_rgx: Regex, @@ -14,8 +16,8 @@ pub struct Settings { pub min_file_size: usize, } -pub static COPYRIGHT: Lazy<Regex> = - Lazy::new(|| Regex::new(r"(?i)Copyright\s+((?:\(C\)|©)\s+)?\d{4}((-|,\s)\d{4})*").unwrap()); +pub static COPYRIGHT: LazyLock<Regex> = + LazyLock::new(|| Regex::new(r"(?i)Copyright\s+((?:\(C\)|©)\s+)?\d{4}((-|,\s)\d{4})*").unwrap()); impl Default for Settings { fn default() -> Self { diff --git a/crates/ruff_linter/src/rules/flake8_logging_format/rules/logging_call.rs b/crates/ruff_linter/src/rules/flake8_logging_format/rules/logging_call.rs index 03e25673bd3c3..fede8ba3681d8 100644 --- a/crates/ruff_linter/src/rules/flake8_logging_format/rules/logging_call.rs +++ b/crates/ruff_linter/src/rules/flake8_logging_format/rules/logging_call.rs @@ -110,7 +110,7 @@ fn check_log_record_attr_clash(checker: &mut Checker, extra: &Keyword) { .. }) => { if checker.semantic().match_builtin_expr(func, "dict") { - for keyword in &**keywords { + for keyword in keywords { if let Some(attr) = &keyword.arg { if is_reserved_attr(attr) { checker.diagnostics.push(Diagnostic::new( diff --git a/crates/ruff_linter/src/rules/flake8_pyi/rules/custom_type_var_return_type.rs b/crates/ruff_linter/src/rules/flake8_pyi/rules/custom_type_var_return_type.rs index 426d81543da6e..34eb68943f693 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/rules/custom_type_var_return_type.rs +++ b/crates/ruff_linter/src/rules/flake8_pyi/rules/custom_type_var_return_type.rs @@ -78,7 +78,7 @@ pub(crate) fn custom_type_var_return_type( let Some(self_or_cls_annotation) = args .posonlyargs .iter() - .chain(args.args.iter()) + .chain(&args.args) .next() .and_then(|parameter_with_default| parameter_with_default.parameter.annotation.as_ref()) else { diff --git a/crates/ruff_linter/src/rules/flake8_pyi/rules/exit_annotations.rs b/crates/ruff_linter/src/rules/flake8_pyi/rules/exit_annotations.rs index 71e2d162a93bf..15a92bae415b4 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/rules/exit_annotations.rs +++ b/crates/ruff_linter/src/rules/flake8_pyi/rules/exit_annotations.rs @@ -140,7 +140,7 @@ pub(crate) fn bad_exit_annotation(checker: &mut Checker, function: &StmtFunction let non_self_positional_args: SmallVec<[&ParameterWithDefault; 3]> = parameters .posonlyargs .iter() - .chain(parameters.args.iter()) + .chain(¶meters.args) .skip(1) .collect(); diff --git a/crates/ruff_linter/src/rules/flake8_pyi/rules/type_comment_in_stub.rs b/crates/ruff_linter/src/rules/flake8_pyi/rules/type_comment_in_stub.rs index ddb8acd333631..719e4b6212f0b 100644 --- a/crates/ruff_linter/src/rules/flake8_pyi/rules/type_comment_in_stub.rs +++ b/crates/ruff_linter/src/rules/flake8_pyi/rules/type_comment_in_stub.rs @@ -1,7 +1,7 @@ -use once_cell::sync::Lazy; use regex::Regex; use ruff_python_trivia::CommentRanges; use ruff_source_file::Locator; +use std::sync::LazyLock; use ruff_diagnostics::{Diagnostic, Violation}; use ruff_macros::{derive_message_formats, violation}; @@ -49,8 +49,8 @@ pub(crate) fn type_comment_in_stub( } } -static TYPE_COMMENT_REGEX: Lazy<Regex> = - Lazy::new(|| Regex::new(r"^#\s*type:\s*([^#]+)(\s*#.*?)?$").unwrap()); +static TYPE_COMMENT_REGEX: LazyLock<Regex> = + LazyLock::new(|| Regex::new(r"^#\s*type:\s*([^#]+)(\s*#.*?)?$").unwrap()); -static TYPE_IGNORE_REGEX: Lazy<Regex> = - Lazy::new(|| Regex::new(r"^#\s*type:\s*ignore([^#]+)?(\s*#.*?)?$").unwrap()); +static TYPE_IGNORE_REGEX: LazyLock<Regex> = + LazyLock::new(|| Regex::new(r"^#\s*type:\s*ignore([^#]+)?(\s*#.*?)?$").unwrap()); diff --git a/crates/ruff_linter/src/rules/flake8_pytest_style/rules/unittest_assert.rs b/crates/ruff_linter/src/rules/flake8_pytest_style/rules/unittest_assert.rs index b4e4d5fafe462..fce0ea72420b0 100644 --- a/crates/ruff_linter/src/rules/flake8_pytest_style/rules/unittest_assert.rs +++ b/crates/ruff_linter/src/rules/flake8_pytest_style/rules/unittest_assert.rs @@ -252,7 +252,7 @@ impl UnittestAssert { FxHashMap::with_capacity_and_hasher(args.len() + keywords.len(), FxBuildHasher); // Process positional arguments. - for (arg_name, value) in arg_spec.iter().zip(args.iter()) { + for (arg_name, value) in arg_spec.iter().zip(args) { args_map.insert(arg_name, value); } diff --git a/crates/ruff_linter/src/rules/flake8_simplify/rules/if_with_same_arms.rs b/crates/ruff_linter/src/rules/flake8_simplify/rules/if_with_same_arms.rs index 7c4cd90b12f53..699544081361c 100644 --- a/crates/ruff_linter/src/rules/flake8_simplify/rules/if_with_same_arms.rs +++ b/crates/ruff_linter/src/rules/flake8_simplify/rules/if_with_same_arms.rs @@ -65,7 +65,7 @@ pub(crate) fn if_with_same_arms(checker: &mut Checker, stmt_if: &ast::StmtIf) { if !current_branch .body .iter() - .zip(following_branch.body.iter()) + .zip(following_branch.body) .all(|(stmt1, stmt2)| ComparableStmt::from(stmt1) == ComparableStmt::from(stmt2)) { continue; diff --git a/crates/ruff_linter/src/rules/flake8_simplify/rules/needless_bool.rs b/crates/ruff_linter/src/rules/flake8_simplify/rules/needless_bool.rs index 569e53bf2b3d2..8f7a4745516e1 100644 --- a/crates/ruff_linter/src/rules/flake8_simplify/rules/needless_bool.rs +++ b/crates/ruff_linter/src/rules/flake8_simplify/rules/needless_bool.rs @@ -144,7 +144,7 @@ pub(crate) fn needless_bool(checker: &mut Checker, stmt: &Stmt) { .semantic() .current_statement_parent() .and_then(|parent| traversal::suite(stmt, parent)) - .and_then(|suite| traversal::next_sibling(stmt, suite)) + .and_then(|suite| suite.next_sibling()) else { return; }; diff --git a/crates/ruff_linter/src/rules/flake8_simplify/rules/reimplemented_builtin.rs b/crates/ruff_linter/src/rules/flake8_simplify/rules/reimplemented_builtin.rs index 6bcdb9c1fc232..5b81ed48df7f2 100644 --- a/crates/ruff_linter/src/rules/flake8_simplify/rules/reimplemented_builtin.rs +++ b/crates/ruff_linter/src/rules/flake8_simplify/rules/reimplemented_builtin.rs @@ -72,8 +72,7 @@ pub(crate) fn convert_for_loop_to_any_all(checker: &mut Checker, stmt: &Stmt) { // - `for` loop followed by `return True` or `return False`. let Some(terminal) = match_else_return(stmt).or_else(|| { let parent = checker.semantic().current_statement_parent()?; - let suite = traversal::suite(stmt, parent)?; - let sibling = traversal::next_sibling(stmt, suite)?; + let sibling = traversal::suite(stmt, parent)?.next_sibling()?; match_sibling_return(stmt, sibling) }) else { return; diff --git a/crates/ruff_linter/src/rules/flake8_todos/rules/todos.rs b/crates/ruff_linter/src/rules/flake8_todos/rules/todos.rs index 35f1d6039fcf9..491d94a2f217c 100644 --- a/crates/ruff_linter/src/rules/flake8_todos/rules/todos.rs +++ b/crates/ruff_linter/src/rules/flake8_todos/rules/todos.rs @@ -1,8 +1,8 @@ -use once_cell::sync::Lazy; use regex::RegexSet; use ruff_python_trivia::CommentRanges; use ruff_source_file::Locator; use ruff_text_size::{TextLen, TextRange, TextSize}; +use std::sync::LazyLock; use ruff_diagnostics::{AlwaysFixableViolation, Diagnostic, Edit, Fix, Violation}; use ruff_macros::{derive_message_formats, violation}; @@ -222,7 +222,7 @@ impl Violation for MissingSpaceAfterTodoColon { } } -static ISSUE_LINK_REGEX_SET: Lazy<RegexSet> = Lazy::new(|| { +static ISSUE_LINK_REGEX_SET: LazyLock<RegexSet> = LazyLock::new(|| { RegexSet::new([ r"^#\s*(http|https)://.*", // issue link r"^#\s*\d+$", // issue code - like "003" diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/helpers.rs b/crates/ruff_linter/src/rules/flake8_type_checking/helpers.rs index 09973b7ff3637..346665411081a 100644 --- a/crates/ruff_linter/src/rules/flake8_type_checking/helpers.rs +++ b/crates/ruff_linter/src/rules/flake8_type_checking/helpers.rs @@ -1,4 +1,6 @@ use anyhow::Result; +use ast::visitor::source_order; +use ruff_python_ast::visitor::source_order::SourceOrderVisitor; use std::cmp::Reverse; use ruff_diagnostics::Edit; @@ -9,7 +11,6 @@ use ruff_python_codegen::{Generator, Stylist}; use ruff_python_semantic::{ analyze, Binding, BindingKind, Modules, NodeId, ResolvedReference, ScopeKind, SemanticModel, }; -use ruff_source_file::Locator; use ruff_text_size::Ranged; use crate::rules::flake8_type_checking::settings::Settings; @@ -223,9 +224,7 @@ pub(crate) fn is_singledispatch_implementation( pub(crate) fn quote_annotation( node_id: NodeId, semantic: &SemanticModel, - locator: &Locator, stylist: &Stylist, - generator: Generator, ) -> Result<Edit> { let expr = semantic.expression(node_id).expect("Expression not found"); if let Some(parent_id) = semantic.parent_expression_id(node_id) { @@ -235,7 +234,7 @@ pub(crate) fn quote_annotation( // If we're quoting the value of a subscript, we need to quote the entire // expression. For example, when quoting `DataFrame` in `DataFrame[int]`, we // should generate `"DataFrame[int]"`. - return quote_annotation(parent_id, semantic, locator, stylist, generator); + return quote_annotation(parent_id, semantic, stylist); } } Some(Expr::Attribute(parent)) => { @@ -243,7 +242,7 @@ pub(crate) fn quote_annotation( // If we're quoting the value of an attribute, we need to quote the entire // expression. For example, when quoting `DataFrame` in `pd.DataFrame`, we // should generate `"pd.DataFrame"`. - return quote_annotation(parent_id, semantic, locator, stylist, generator); + return quote_annotation(parent_id, semantic, stylist); } } Some(Expr::Call(parent)) => { @@ -251,7 +250,7 @@ pub(crate) fn quote_annotation( // If we're quoting the function of a call, we need to quote the entire // expression. For example, when quoting `DataFrame` in `DataFrame()`, we // should generate `"DataFrame()"`. - return quote_annotation(parent_id, semantic, locator, stylist, generator); + return quote_annotation(parent_id, semantic, stylist); } } Some(Expr::BinOp(parent)) => { @@ -259,27 +258,17 @@ pub(crate) fn quote_annotation( // If we're quoting the left or right side of a binary operation, we need to // quote the entire expression. For example, when quoting `DataFrame` in // `DataFrame | Series`, we should generate `"DataFrame | Series"`. - return quote_annotation(parent_id, semantic, locator, stylist, generator); + return quote_annotation(parent_id, semantic, stylist); } } _ => {} } } - // If the annotation already contains a quote, avoid attempting to re-quote it. For example: - // ```python - // from typing import Literal - // - // Set[Literal["Foo"]] - // ``` - let text = locator.slice(expr); - if text.contains('\'') || text.contains('"') { - return Err(anyhow::anyhow!("Annotation already contains a quote")); - } - - // Quote the entire expression. let quote = stylist.quote(); - let annotation = generator.expr(expr); + let mut quote_annotator = QuoteAnnotator::new(semantic, stylist); + quote_annotator.visit_expr(expr); + let annotation = quote_annotator.into_annotation(); Ok(Edit::range_replacement( format!("{quote}{annotation}{quote}"), @@ -306,3 +295,111 @@ pub(crate) fn filter_contained(edits: Vec<Edit>) -> Vec<Edit> { } filtered } + +#[derive(Copy, PartialEq, Clone)] +enum QuoteAnnotatorState { + Literal, + AnnotatedFirst, + AnnotatedRest, + Other, +} + +pub(crate) struct QuoteAnnotator<'a> { + stylist: &'a Stylist<'a>, + semantic: &'a SemanticModel<'a>, + state: Vec<QuoteAnnotatorState>, + annotation: String, +} + +impl<'a> QuoteAnnotator<'a> { + fn new(semantic: &'a SemanticModel<'a>, stylist: &'a Stylist<'a>) -> Self { + Self { + stylist, + semantic, + state: Vec::new(), + annotation: String::new(), + } + } + + fn into_annotation(self) -> String { + self.annotation + } +} + +impl<'a> source_order::SourceOrderVisitor<'a> for QuoteAnnotator<'a> { + fn visit_expr(&mut self, expr: &'a Expr) { + let generator = Generator::from(self.stylist); + + match expr { + Expr::Subscript(ast::ExprSubscript { value, slice, .. }) => { + let value_expr = generator.expr(value); + self.annotation.push_str(&value_expr); + self.annotation.push('['); + + if let Some(qualified_name) = self.semantic.resolve_qualified_name(value) { + if self + .semantic + .match_typing_qualified_name(&qualified_name, "Literal") + { + self.state.push(QuoteAnnotatorState::Literal); + } else if self + .semantic + .match_typing_qualified_name(&qualified_name, "Annotated") + { + self.state.push(QuoteAnnotatorState::AnnotatedFirst); + } else { + self.state.push(QuoteAnnotatorState::Other); + } + } + + self.visit_expr(slice); + self.state.pop(); + self.annotation.push(']'); + } + Expr::Tuple(ast::ExprTuple { elts, .. }) => { + let Some((first, remaining)) = elts.split_first() else { + return; + }; + self.visit_expr(first); + if let Some(last) = self.state.last_mut() { + if *last == QuoteAnnotatorState::AnnotatedFirst { + *last = QuoteAnnotatorState::AnnotatedRest; + } + } + for expr in remaining { + self.annotation.push_str(", "); + self.visit_expr(expr); + } + self.state.pop(); + } + Expr::BinOp(ast::ExprBinOp { + left, op, right, .. + }) => { + debug_assert_eq!(*op, ast::Operator::BitOr); + self.visit_expr(left); + self.annotation.push_str(" | "); + self.visit_expr(right); + } + _ => { + let source = match self.state.last().copied() { + Some(QuoteAnnotatorState::Literal | QuoteAnnotatorState::AnnotatedRest) => { + let mut source = generator.expr(expr); + source = source.replace( + self.stylist.quote().as_char(), + &self.stylist.quote().opposite().as_char().to_string(), + ); + source + } + None + | Some(QuoteAnnotatorState::AnnotatedFirst | QuoteAnnotatorState::Other) => { + let mut source = generator.expr(expr); + source = source.replace(self.stylist.quote().as_char(), ""); + source = source.replace(self.stylist.quote().opposite().as_char(), ""); + source + } + }; + self.annotation.push_str(&source); + } + } + } +} diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/mod.rs b/crates/ruff_linter/src/rules/flake8_type_checking/mod.rs index 30fc9c5aa7d8b..f8abd210b3bfd 100644 --- a/crates/ruff_linter/src/rules/flake8_type_checking/mod.rs +++ b/crates/ruff_linter/src/rules/flake8_type_checking/mod.rs @@ -60,6 +60,10 @@ mod tests { #[test_case(Rule::RuntimeImportInTypeCheckingBlock, Path::new("quote.py"))] #[test_case(Rule::TypingOnlyThirdPartyImport, Path::new("quote.py"))] + #[test_case(Rule::RuntimeImportInTypeCheckingBlock, Path::new("quote2.py"))] + #[test_case(Rule::TypingOnlyThirdPartyImport, Path::new("quote2.py"))] + #[test_case(Rule::RuntimeImportInTypeCheckingBlock, Path::new("quote3.py"))] + #[test_case(Rule::TypingOnlyThirdPartyImport, Path::new("quote3.py"))] fn quote(rule_code: Rule, path: &Path) -> Result<()> { let snapshot = format!("quote_{}_{}", rule_code.as_ref(), path.to_string_lossy()); let diagnostics = test_path( diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/rules/runtime_import_in_type_checking_block.rs b/crates/ruff_linter/src/rules/flake8_type_checking/rules/runtime_import_in_type_checking_block.rs index d9d7b45e2e5c6..fda7243224fd5 100644 --- a/crates/ruff_linter/src/rules/flake8_type_checking/rules/runtime_import_in_type_checking_block.rs +++ b/crates/ruff_linter/src/rules/flake8_type_checking/rules/runtime_import_in_type_checking_block.rs @@ -270,9 +270,7 @@ fn quote_imports(checker: &Checker, node_id: NodeId, imports: &[ImportBinding]) Some(quote_annotation( reference.expression_id()?, checker.semantic(), - checker.locator(), checker.stylist(), - checker.generator(), )) } else { None diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/rules/typing_only_runtime_import.rs b/crates/ruff_linter/src/rules/flake8_type_checking/rules/typing_only_runtime_import.rs index 32bdb7046f7a3..c51b515d03c6a 100644 --- a/crates/ruff_linter/src/rules/flake8_type_checking/rules/typing_only_runtime_import.rs +++ b/crates/ruff_linter/src/rules/flake8_type_checking/rules/typing_only_runtime_import.rs @@ -505,9 +505,7 @@ fn fix_imports(checker: &Checker, node_id: NodeId, imports: &[ImportBinding]) -> Some(quote_annotation( reference.expression_id()?, checker.semantic(), - checker.locator(), checker.stylist(), - checker.generator(), )) } else { None diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__quote_runtime-import-in-type-checking-block_quote.py.snap b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__quote_runtime-import-in-type-checking-block_quote.py.snap index ae71c56c8195a..de91616ce86fa 100644 --- a/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__quote_runtime-import-in-type-checking-block_quote.py.snap +++ b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__quote_runtime-import-in-type-checking-block_quote.py.snap @@ -1,24 +1,22 @@ --- source: crates/ruff_linter/src/rules/flake8_type_checking/mod.rs --- -quote.py:64:28: TCH004 [*] Quote references to `pandas.DataFrame`. Import is in a type-checking block. +quote.py:57:28: TCH004 [*] Quote references to `pandas.DataFrame`. Import is in a type-checking block. | -63 | if TYPE_CHECKING: -64 | from pandas import DataFrame +56 | if TYPE_CHECKING: +57 | from pandas import DataFrame | ^^^^^^^^^ TCH004 -65 | -66 | def func(value: DataFrame): +58 | +59 | def func(value: DataFrame): | = help: Quote references ℹ Unsafe fix -63 63 | if TYPE_CHECKING: -64 64 | from pandas import DataFrame -65 65 | -66 |- def func(value: DataFrame): - 66 |+ def func(value: "DataFrame"): -67 67 | ... -68 68 | -69 69 | - - +56 56 | if TYPE_CHECKING: +57 57 | from pandas import DataFrame +58 58 | +59 |- def func(value: DataFrame): + 59 |+ def func(value: "DataFrame"): +60 60 | ... +61 61 | +62 62 | diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__quote_runtime-import-in-type-checking-block_quote2.py.snap b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__quote_runtime-import-in-type-checking-block_quote2.py.snap new file mode 100644 index 0000000000000..6c5ead27428ce --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__quote_runtime-import-in-type-checking-block_quote2.py.snap @@ -0,0 +1,4 @@ +--- +source: crates/ruff_linter/src/rules/flake8_type_checking/mod.rs +--- + diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__quote_runtime-import-in-type-checking-block_quote3.py.snap b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__quote_runtime-import-in-type-checking-block_quote3.py.snap new file mode 100644 index 0000000000000..6c5ead27428ce --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__quote_runtime-import-in-type-checking-block_quote3.py.snap @@ -0,0 +1,4 @@ +--- +source: crates/ruff_linter/src/rules/flake8_type_checking/mod.rs +--- + diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__quote_runtime-import-in-type-checking-block_quote_annotations.py.snap b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__quote_runtime-import-in-type-checking-block_quote_annotations.py.snap new file mode 100644 index 0000000000000..6c5ead27428ce --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__quote_runtime-import-in-type-checking-block_quote_annotations.py.snap @@ -0,0 +1,4 @@ +--- +source: crates/ruff_linter/src/rules/flake8_type_checking/mod.rs +--- + diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__quote_typing-only-third-party-import_quote.py.snap b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__quote_typing-only-third-party-import_quote.py.snap index d9bb6a9824812..cc72734ecf5e9 100644 --- a/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__quote_typing-only-third-party-import_quote.py.snap +++ b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__quote_typing-only-third-party-import_quote.py.snap @@ -55,23 +55,43 @@ quote.py:9:24: TCH002 [*] Move third-party import `pandas.DataFrame` into a type 13 16 | 14 17 | -quote.py:16:24: TCH002 Move third-party import `pandas.DataFrame` into a type-checking block +quote.py:16:22: TCH002 [*] Move third-party import `pandas` into a type-checking block | 15 | def f(): -16 | from pandas import DataFrame - | ^^^^^^^^^ TCH002 +16 | import pandas as pd + | ^^ TCH002 17 | -18 | def baz() -> DataFrame["int"]: +18 | def baz() -> pd.DataFrame: | = help: Move into type-checking block +ℹ Unsafe fix + 1 |+from typing import TYPE_CHECKING + 2 |+ + 3 |+if TYPE_CHECKING: + 4 |+ import pandas as pd +1 5 | def f(): +2 6 | from pandas import DataFrame +3 7 | +-------------------------------------------------------------------------------- +13 17 | +14 18 | +15 19 | def f(): +16 |- import pandas as pd +17 20 | +18 |- def baz() -> pd.DataFrame: + 21 |+ def baz() -> "pd.DataFrame": +19 22 | ... +20 23 | +21 24 | + quote.py:23:22: TCH002 [*] Move third-party import `pandas` into a type-checking block | 22 | def f(): 23 | import pandas as pd | ^^ TCH002 24 | -25 | def baz() -> pd.DataFrame: +25 | def baz() -> pd.DataFrame.Extra: | = help: Move into type-checking block @@ -89,8 +109,8 @@ quote.py:23:22: TCH002 [*] Move third-party import `pandas` into a type-checking 22 26 | def f(): 23 |- import pandas as pd 24 27 | -25 |- def baz() -> pd.DataFrame: - 28 |+ def baz() -> "pd.DataFrame": +25 |- def baz() -> pd.DataFrame.Extra: + 28 |+ def baz() -> "pd.DataFrame.Extra": 26 29 | ... 27 30 | 28 31 | @@ -101,7 +121,7 @@ quote.py:30:22: TCH002 [*] Move third-party import `pandas` into a type-checking 30 | import pandas as pd | ^^ TCH002 31 | -32 | def baz() -> pd.DataFrame.Extra: +32 | def baz() -> pd.DataFrame | int: | = help: Move into type-checking block @@ -119,19 +139,19 @@ quote.py:30:22: TCH002 [*] Move third-party import `pandas` into a type-checking 29 33 | def f(): 30 |- import pandas as pd 31 34 | -32 |- def baz() -> pd.DataFrame.Extra: - 35 |+ def baz() -> "pd.DataFrame.Extra": +32 |- def baz() -> pd.DataFrame | int: + 35 |+ def baz() -> "pd.DataFrame | int": 33 36 | ... 34 37 | 35 38 | -quote.py:37:22: TCH002 [*] Move third-party import `pandas` into a type-checking block +quote.py:38:24: TCH002 [*] Move third-party import `pandas.DataFrame` into a type-checking block | -36 | def f(): -37 | import pandas as pd - | ^^ TCH002 -38 | -39 | def baz() -> pd.DataFrame | int: +37 | def f(): +38 | from pandas import DataFrame + | ^^^^^^^^^ TCH002 +39 | +40 | def baz() -> DataFrame(): | = help: Move into type-checking block @@ -139,29 +159,30 @@ quote.py:37:22: TCH002 [*] Move third-party import `pandas` into a type-checking 1 |+from typing import TYPE_CHECKING 2 |+ 3 |+if TYPE_CHECKING: - 4 |+ import pandas as pd + 4 |+ from pandas import DataFrame 1 5 | def f(): 2 6 | from pandas import DataFrame 3 7 | -------------------------------------------------------------------------------- -34 38 | 35 39 | -36 40 | def f(): -37 |- import pandas as pd -38 41 | -39 |- def baz() -> pd.DataFrame | int: - 42 |+ def baz() -> "pd.DataFrame | int": -40 43 | ... -41 44 | +36 40 | +37 41 | def f(): +38 |- from pandas import DataFrame +39 42 | +40 |- def baz() -> DataFrame(): + 43 |+ def baz() -> "DataFrame()": +41 44 | ... 42 45 | +43 46 | -quote.py:45:24: TCH002 [*] Move third-party import `pandas.DataFrame` into a type-checking block +quote.py:47:24: TCH002 [*] Move third-party import `pandas.DataFrame` into a type-checking block | -44 | def f(): -45 | from pandas import DataFrame - | ^^^^^^^^^ TCH002 +45 | from typing import Literal 46 | -47 | def baz() -> DataFrame(): +47 | from pandas import DataFrame + | ^^^^^^^^^ TCH002 +48 | +49 | def baz() -> DataFrame[Literal["int"]]: | = help: Move into type-checking block @@ -174,35 +195,84 @@ quote.py:45:24: TCH002 [*] Move third-party import `pandas.DataFrame` into a typ 2 6 | from pandas import DataFrame 3 7 | -------------------------------------------------------------------------------- -42 46 | -43 47 | 44 48 | def f(): -45 |- from pandas import DataFrame -46 49 | -47 |- def baz() -> DataFrame(): - 50 |+ def baz() -> "DataFrame()": -48 51 | ... -49 52 | -50 53 | +45 49 | from typing import Literal +46 50 | +47 |- from pandas import DataFrame +48 51 | +49 |- def baz() -> DataFrame[Literal["int"]]: + 52 |+ def baz() -> "DataFrame[Literal['int']]": +50 53 | ... +51 54 | +52 55 | -quote.py:54:24: TCH002 Move third-party import `pandas.DataFrame` into a type-checking block +quote.py:64:24: TCH002 [*] Move third-party import `pandas.DataFrame` into a type-checking block | -52 | from typing import Literal -53 | -54 | from pandas import DataFrame +63 | def f(): +64 | from pandas import DataFrame, Series | ^^^^^^^^^ TCH002 -55 | -56 | def baz() -> DataFrame[Literal["int"]]: +65 | +66 | def baz() -> DataFrame | Series: | = help: Move into type-checking block +ℹ Unsafe fix + 1 |+from typing import TYPE_CHECKING + 2 |+ + 3 |+if TYPE_CHECKING: + 4 |+ from pandas import DataFrame, Series +1 5 | def f(): +2 6 | from pandas import DataFrame +3 7 | +-------------------------------------------------------------------------------- +61 65 | +62 66 | +63 67 | def f(): +64 |- from pandas import DataFrame, Series +65 68 | +66 |- def baz() -> DataFrame | Series: + 69 |+ def baz() -> "DataFrame | Series": +67 70 | ... +68 71 | +69 72 | + +quote.py:64:35: TCH002 [*] Move third-party import `pandas.Series` into a type-checking block + | +63 | def f(): +64 | from pandas import DataFrame, Series + | ^^^^^^ TCH002 +65 | +66 | def baz() -> DataFrame | Series: + | + = help: Move into type-checking block + +ℹ Unsafe fix + 1 |+from typing import TYPE_CHECKING + 2 |+ + 3 |+if TYPE_CHECKING: + 4 |+ from pandas import DataFrame, Series +1 5 | def f(): +2 6 | from pandas import DataFrame +3 7 | +-------------------------------------------------------------------------------- +61 65 | +62 66 | +63 67 | def f(): +64 |- from pandas import DataFrame, Series +65 68 | +66 |- def baz() -> DataFrame | Series: + 69 |+ def baz() -> "DataFrame | Series": +67 70 | ... +68 71 | +69 72 | + quote.py:71:24: TCH002 [*] Move third-party import `pandas.DataFrame` into a type-checking block | 70 | def f(): 71 | from pandas import DataFrame, Series | ^^^^^^^^^ TCH002 72 | -73 | def baz() -> DataFrame | Series: +73 | def baz() -> ( | = help: Move into type-checking block @@ -220,11 +290,24 @@ quote.py:71:24: TCH002 [*] Move third-party import `pandas.DataFrame` into a typ 70 74 | def f(): 71 |- from pandas import DataFrame, Series 72 75 | -73 |- def baz() -> DataFrame | Series: - 76 |+ def baz() -> "DataFrame | Series": -74 77 | ... -75 78 | -76 79 | +73 76 | def baz() -> ( +74 |- DataFrame | +75 |- Series + 77 |+ "DataFrame | Series" +76 78 | ): +77 79 | ... +78 80 | +79 81 | class C: +80 |- x: DataFrame[ +81 |- int +82 |- ] = 1 + 82 |+ x: "DataFrame[int]" = 1 +83 83 | +84 |- def func() -> DataFrame[[DataFrame[_P, _R]], DataFrame[_P, _R]]: + 84 |+ def func() -> "DataFrame[[DataFrame[_P, _R]], DataFrame[_P, _R]]": +85 85 | ... +86 86 | +87 87 | quote.py:71:35: TCH002 [*] Move third-party import `pandas.Series` into a type-checking block | @@ -232,7 +315,7 @@ quote.py:71:35: TCH002 [*] Move third-party import `pandas.Series` into a type-c 71 | from pandas import DataFrame, Series | ^^^^^^ TCH002 72 | -73 | def baz() -> DataFrame | Series: +73 | def baz() -> ( | = help: Move into type-checking block @@ -250,19 +333,32 @@ quote.py:71:35: TCH002 [*] Move third-party import `pandas.Series` into a type-c 70 74 | def f(): 71 |- from pandas import DataFrame, Series 72 75 | -73 |- def baz() -> DataFrame | Series: - 76 |+ def baz() -> "DataFrame | Series": -74 77 | ... -75 78 | -76 79 | +73 76 | def baz() -> ( +74 |- DataFrame | +75 |- Series + 77 |+ "DataFrame | Series" +76 78 | ): +77 79 | ... +78 80 | +79 81 | class C: +80 |- x: DataFrame[ +81 |- int +82 |- ] = 1 + 82 |+ x: "DataFrame[int]" = 1 +83 83 | +84 |- def func() -> DataFrame[[DataFrame[_P, _R]], DataFrame[_P, _R]]: + 84 |+ def func() -> "DataFrame[[DataFrame[_P, _R]], DataFrame[_P, _R]]": +85 85 | ... +86 86 | +87 87 | -quote.py:78:24: TCH002 [*] Move third-party import `pandas.DataFrame` into a type-checking block +quote.py:89:24: TCH002 [*] Move third-party import `pandas.DataFrame` into a type-checking block | -77 | def f(): -78 | from pandas import DataFrame, Series +88 | def f(): +89 | from pandas import DataFrame, Series | ^^^^^^^^^ TCH002 -79 | -80 | def baz() -> ( +90 | +91 | def func(self) -> DataFrame | list[Series]: | = help: Move into type-checking block @@ -275,37 +371,22 @@ quote.py:78:24: TCH002 [*] Move third-party import `pandas.DataFrame` into a typ 2 6 | from pandas import DataFrame 3 7 | -------------------------------------------------------------------------------- -75 79 | -76 80 | -77 81 | def f(): -78 |- from pandas import DataFrame, Series -79 82 | -80 83 | def baz() -> ( -81 |- DataFrame | -82 |- Series - 84 |+ "DataFrame | Series" -83 85 | ): -84 86 | ... -85 87 | -86 88 | class C: -87 |- x: DataFrame[ -88 |- int -89 |- ] = 1 - 89 |+ x: "DataFrame[int]" = 1 -90 90 | -91 |- def func() -> DataFrame[[DataFrame[_P, _R]], DataFrame[_P, _R]]: - 91 |+ def func() -> "DataFrame[[DataFrame[_P, _R]], DataFrame[_P, _R]]": -92 92 | ... -93 93 | -94 94 | +86 90 | +87 91 | +88 92 | def f(): +89 |- from pandas import DataFrame, Series +90 93 | +91 |- def func(self) -> DataFrame | list[Series]: + 94 |+ def func(self) -> "DataFrame | list[Series]": +92 95 | pass -quote.py:78:35: TCH002 [*] Move third-party import `pandas.Series` into a type-checking block +quote.py:89:35: TCH002 [*] Move third-party import `pandas.Series` into a type-checking block | -77 | def f(): -78 | from pandas import DataFrame, Series +88 | def f(): +89 | from pandas import DataFrame, Series | ^^^^^^ TCH002 -79 | -80 | def baz() -> ( +90 | +91 | def func(self) -> DataFrame | list[Series]: | = help: Move into type-checking block @@ -318,82 +399,11 @@ quote.py:78:35: TCH002 [*] Move third-party import `pandas.Series` into a type-c 2 6 | from pandas import DataFrame 3 7 | -------------------------------------------------------------------------------- -75 79 | -76 80 | -77 81 | def f(): -78 |- from pandas import DataFrame, Series -79 82 | -80 83 | def baz() -> ( -81 |- DataFrame | -82 |- Series - 84 |+ "DataFrame | Series" -83 85 | ): -84 86 | ... -85 87 | -86 88 | class C: -87 |- x: DataFrame[ -88 |- int -89 |- ] = 1 - 89 |+ x: "DataFrame[int]" = 1 -90 90 | -91 |- def func() -> DataFrame[[DataFrame[_P, _R]], DataFrame[_P, _R]]: - 91 |+ def func() -> "DataFrame[[DataFrame[_P, _R]], DataFrame[_P, _R]]": -92 92 | ... -93 93 | -94 94 | - -quote.py:96:24: TCH002 [*] Move third-party import `pandas.DataFrame` into a type-checking block - | -95 | def f(): -96 | from pandas import DataFrame, Series - | ^^^^^^^^^ TCH002 -97 | -98 | def func(self) -> DataFrame | list[Series]: - | - = help: Move into type-checking block - -ℹ Unsafe fix - 1 |+from typing import TYPE_CHECKING - 2 |+ - 3 |+if TYPE_CHECKING: - 4 |+ from pandas import DataFrame, Series -1 5 | def f(): -2 6 | from pandas import DataFrame -3 7 | --------------------------------------------------------------------------------- -93 97 | -94 98 | -95 99 | def f(): -96 |- from pandas import DataFrame, Series -97 100 | -98 |- def func(self) -> DataFrame | list[Series]: - 101 |+ def func(self) -> "DataFrame | list[Series]": -99 102 | pass - -quote.py:96:35: TCH002 [*] Move third-party import `pandas.Series` into a type-checking block - | -95 | def f(): -96 | from pandas import DataFrame, Series - | ^^^^^^ TCH002 -97 | -98 | def func(self) -> DataFrame | list[Series]: - | - = help: Move into type-checking block - -ℹ Unsafe fix - 1 |+from typing import TYPE_CHECKING - 2 |+ - 3 |+if TYPE_CHECKING: - 4 |+ from pandas import DataFrame, Series -1 5 | def f(): -2 6 | from pandas import DataFrame -3 7 | --------------------------------------------------------------------------------- -93 97 | -94 98 | -95 99 | def f(): -96 |- from pandas import DataFrame, Series -97 100 | -98 |- def func(self) -> DataFrame | list[Series]: - 101 |+ def func(self) -> "DataFrame | list[Series]": -99 102 | pass +86 90 | +87 91 | +88 92 | def f(): +89 |- from pandas import DataFrame, Series +90 93 | +91 |- def func(self) -> DataFrame | list[Series]: + 94 |+ def func(self) -> "DataFrame | list[Series]": +92 95 | pass diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__quote_typing-only-third-party-import_quote2.py.snap b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__quote_typing-only-third-party-import_quote2.py.snap new file mode 100644 index 0000000000000..dc87bebfccda0 --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__quote_typing-only-third-party-import_quote2.py.snap @@ -0,0 +1,272 @@ +--- +source: crates/ruff_linter/src/rules/flake8_type_checking/mod.rs +--- +quote2.py:2:44: TCH002 [*] Move third-party import `django.contrib.auth.models.AbstractBaseUser` into a type-checking block + | +1 | def f(): +2 | from django.contrib.auth.models import AbstractBaseUser + | ^^^^^^^^^^^^^^^^ TCH002 +3 | +4 | def test_remove_inner_quotes_double(self, user: AbstractBaseUser["int"]): + | + = help: Move into type-checking block + +ℹ Unsafe fix +1 |-def f(): + 1 |+from typing import TYPE_CHECKING + 2 |+ + 3 |+if TYPE_CHECKING: +2 4 | from django.contrib.auth.models import AbstractBaseUser + 5 |+def f(): +3 6 | +4 |- def test_remove_inner_quotes_double(self, user: AbstractBaseUser["int"]): + 7 |+ def test_remove_inner_quotes_double(self, user: "AbstractBaseUser[int]"): +5 8 | pass +6 9 | +7 10 | + +quote2.py:9:44: TCH002 [*] Move third-party import `django.contrib.auth.models.AbstractBaseUser` into a type-checking block + | + 8 | def f(): + 9 | from django.contrib.auth.models import AbstractBaseUser + | ^^^^^^^^^^^^^^^^ TCH002 +10 | +11 | def test_remove_inner_quotes_single(self, user: AbstractBaseUser['int']): + | + = help: Move into type-checking block + +ℹ Unsafe fix + 1 |+from typing import TYPE_CHECKING + 2 |+ + 3 |+if TYPE_CHECKING: + 4 |+ from django.contrib.auth.models import AbstractBaseUser +1 5 | def f(): +2 6 | from django.contrib.auth.models import AbstractBaseUser +3 7 | +-------------------------------------------------------------------------------- +6 10 | +7 11 | +8 12 | def f(): +9 |- from django.contrib.auth.models import AbstractBaseUser +10 13 | +11 |- def test_remove_inner_quotes_single(self, user: AbstractBaseUser['int']): + 14 |+ def test_remove_inner_quotes_single(self, user: "AbstractBaseUser[int]"): +12 15 | pass +13 16 | +14 17 | + +quote2.py:16:44: TCH002 [*] Move third-party import `django.contrib.auth.models.AbstractBaseUser` into a type-checking block + | +15 | def f(): +16 | from django.contrib.auth.models import AbstractBaseUser + | ^^^^^^^^^^^^^^^^ TCH002 +17 | +18 | def test_remove_inner_quotes_mixed(self, user: AbstractBaseUser['int', "str"]): + | + = help: Move into type-checking block + +ℹ Unsafe fix + 1 |+from typing import TYPE_CHECKING + 2 |+ + 3 |+if TYPE_CHECKING: + 4 |+ from django.contrib.auth.models import AbstractBaseUser +1 5 | def f(): +2 6 | from django.contrib.auth.models import AbstractBaseUser +3 7 | +-------------------------------------------------------------------------------- +13 17 | +14 18 | +15 19 | def f(): +16 |- from django.contrib.auth.models import AbstractBaseUser +17 20 | +18 |- def test_remove_inner_quotes_mixed(self, user: AbstractBaseUser['int', "str"]): + 21 |+ def test_remove_inner_quotes_mixed(self, user: "AbstractBaseUser[int, str]"): +19 22 | pass +20 23 | +21 24 | + +quote2.py:25:44: TCH002 [*] Move third-party import `django.contrib.auth.models.AbstractBaseUser` into a type-checking block + | +23 | from typing import Annotated, Literal +24 | +25 | from django.contrib.auth.models import AbstractBaseUser + | ^^^^^^^^^^^^^^^^ TCH002 +26 | +27 | def test_literal_annotation_args_contain_quotes(self, type1: AbstractBaseUser[Literal["user", "admin"], Annotated["int", "1", 2]]): + | + = help: Move into type-checking block + +ℹ Unsafe fix + 1 |+from typing import TYPE_CHECKING + 2 |+ + 3 |+if TYPE_CHECKING: + 4 |+ from django.contrib.auth.models import AbstractBaseUser +1 5 | def f(): +2 6 | from django.contrib.auth.models import AbstractBaseUser +3 7 | +-------------------------------------------------------------------------------- +22 26 | def f(): +23 27 | from typing import Annotated, Literal +24 28 | +25 |- from django.contrib.auth.models import AbstractBaseUser +26 29 | +27 |- def test_literal_annotation_args_contain_quotes(self, type1: AbstractBaseUser[Literal["user", "admin"], Annotated["int", "1", 2]]): + 30 |+ def test_literal_annotation_args_contain_quotes(self, type1: "AbstractBaseUser[Literal['user', 'admin'], Annotated[int, '1', 2]]"): +28 31 | pass +29 32 | +30 33 | + +quote2.py:34:44: TCH002 [*] Move third-party import `django.contrib.auth.models.AbstractBaseUser` into a type-checking block + | +32 | from typing import Literal +33 | +34 | from django.contrib.auth.models import AbstractBaseUser + | ^^^^^^^^^^^^^^^^ TCH002 +35 | +36 | def test_union_contain_inner_quotes(self, type1: AbstractBaseUser["int" | Literal["int"]]): + | + = help: Move into type-checking block + +ℹ Unsafe fix + 1 |+from typing import TYPE_CHECKING + 2 |+ + 3 |+if TYPE_CHECKING: + 4 |+ from django.contrib.auth.models import AbstractBaseUser +1 5 | def f(): +2 6 | from django.contrib.auth.models import AbstractBaseUser +3 7 | +-------------------------------------------------------------------------------- +31 35 | def f(): +32 36 | from typing import Literal +33 37 | +34 |- from django.contrib.auth.models import AbstractBaseUser +35 38 | +36 |- def test_union_contain_inner_quotes(self, type1: AbstractBaseUser["int" | Literal["int"]]): + 39 |+ def test_union_contain_inner_quotes(self, type1: "AbstractBaseUser[int | Literal['int']]"): +37 40 | pass +38 41 | +39 42 | + +quote2.py:43:44: TCH002 [*] Move third-party import `django.contrib.auth.models.AbstractBaseUser` into a type-checking block + | +41 | from typing import Literal +42 | +43 | from django.contrib.auth.models import AbstractBaseUser + | ^^^^^^^^^^^^^^^^ TCH002 +44 | +45 | def test_inner_literal_mixed_quotes(user: AbstractBaseUser[Literal['user', "admin"]]): + | + = help: Move into type-checking block + +ℹ Unsafe fix + 1 |+from typing import TYPE_CHECKING + 2 |+ + 3 |+if TYPE_CHECKING: + 4 |+ from django.contrib.auth.models import AbstractBaseUser +1 5 | def f(): +2 6 | from django.contrib.auth.models import AbstractBaseUser +3 7 | +-------------------------------------------------------------------------------- +40 44 | def f(): +41 45 | from typing import Literal +42 46 | +43 |- from django.contrib.auth.models import AbstractBaseUser +44 47 | +45 |- def test_inner_literal_mixed_quotes(user: AbstractBaseUser[Literal['user', "admin"]]): + 48 |+ def test_inner_literal_mixed_quotes(user: "AbstractBaseUser[Literal['user', 'admin']]"): +46 49 | pass +47 50 | +48 51 | + +quote2.py:52:44: TCH002 [*] Move third-party import `django.contrib.auth.models.AbstractBaseUser` into a type-checking block + | +50 | from typing import Literal +51 | +52 | from django.contrib.auth.models import AbstractBaseUser + | ^^^^^^^^^^^^^^^^ TCH002 +53 | +54 | def test_inner_literal_single_quote(user: AbstractBaseUser[Literal['int'], str]): + | + = help: Move into type-checking block + +ℹ Unsafe fix + 1 |+from typing import TYPE_CHECKING + 2 |+ + 3 |+if TYPE_CHECKING: + 4 |+ from django.contrib.auth.models import AbstractBaseUser +1 5 | def f(): +2 6 | from django.contrib.auth.models import AbstractBaseUser +3 7 | +-------------------------------------------------------------------------------- +49 53 | def f(): +50 54 | from typing import Literal +51 55 | +52 |- from django.contrib.auth.models import AbstractBaseUser +53 56 | +54 |- def test_inner_literal_single_quote(user: AbstractBaseUser[Literal['int'], str]): + 57 |+ def test_inner_literal_single_quote(user: "AbstractBaseUser[Literal['int'], str]"): +55 58 | pass +56 59 | +57 60 | + +quote2.py:61:44: TCH002 [*] Move third-party import `django.contrib.auth.models.AbstractBaseUser` into a type-checking block + | +59 | from typing import Literal +60 | +61 | from django.contrib.auth.models import AbstractBaseUser + | ^^^^^^^^^^^^^^^^ TCH002 +62 | +63 | def test_mixed_quotes_literal(user: AbstractBaseUser[Literal['user'], "int"]): + | + = help: Move into type-checking block + +ℹ Unsafe fix + 1 |+from typing import TYPE_CHECKING + 2 |+ + 3 |+if TYPE_CHECKING: + 4 |+ from django.contrib.auth.models import AbstractBaseUser +1 5 | def f(): +2 6 | from django.contrib.auth.models import AbstractBaseUser +3 7 | +-------------------------------------------------------------------------------- +58 62 | def f(): +59 63 | from typing import Literal +60 64 | +61 |- from django.contrib.auth.models import AbstractBaseUser +62 65 | +63 |- def test_mixed_quotes_literal(user: AbstractBaseUser[Literal['user'], "int"]): + 66 |+ def test_mixed_quotes_literal(user: "AbstractBaseUser[Literal['user'], int]"): +64 67 | pass +65 68 | +66 69 | + +quote2.py:70:44: TCH002 [*] Move third-party import `django.contrib.auth.models.AbstractBaseUser` into a type-checking block + | +68 | from typing import Annotated, Literal +69 | +70 | from django.contrib.auth.models import AbstractBaseUser + | ^^^^^^^^^^^^^^^^ TCH002 +71 | +72 | def test_annotated_literal_mixed_quotes(user: AbstractBaseUser[Annotated[str, "max_length=20", Literal['user', "admin"]]]): + | + = help: Move into type-checking block + +ℹ Unsafe fix + 1 |+from typing import TYPE_CHECKING + 2 |+ + 3 |+if TYPE_CHECKING: + 4 |+ from django.contrib.auth.models import AbstractBaseUser +1 5 | def f(): +2 6 | from django.contrib.auth.models import AbstractBaseUser +3 7 | +-------------------------------------------------------------------------------- +67 71 | def f(): +68 72 | from typing import Annotated, Literal +69 73 | +70 |- from django.contrib.auth.models import AbstractBaseUser +71 74 | +72 |- def test_annotated_literal_mixed_quotes(user: AbstractBaseUser[Annotated[str, "max_length=20", Literal['user', "admin"]]]): + 75 |+ def test_annotated_literal_mixed_quotes(user: "AbstractBaseUser[Annotated[str, 'max_length=20', Literal['user', 'admin']]]"): +73 76 | pass +74 77 | +75 78 | diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__quote_typing-only-third-party-import_quote3.py.snap b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__quote_typing-only-third-party-import_quote3.py.snap new file mode 100644 index 0000000000000..579cc74973b14 --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__quote_typing-only-third-party-import_quote3.py.snap @@ -0,0 +1,152 @@ +--- +source: crates/ruff_linter/src/rules/flake8_type_checking/mod.rs +--- +quote3.py:4:44: TCH002 [*] Move third-party import `django.contrib.auth.models.AbstractBaseUser` into a type-checking block + | +2 | from typing import Literal, Union +3 | +4 | from django.contrib.auth.models import AbstractBaseUser + | ^^^^^^^^^^^^^^^^ TCH002 +5 | +6 | def test_union_literal_mixed_quotes(user: AbstractBaseUser[Union[Literal['active', "inactive"], str]]): + | + = help: Move into type-checking block + +ℹ Unsafe fix + 1 |+from typing import TYPE_CHECKING + 2 |+ + 3 |+if TYPE_CHECKING: + 4 |+ from django.contrib.auth.models import AbstractBaseUser +1 5 | def f(): +2 6 | from typing import Literal, Union +3 7 | +4 |- from django.contrib.auth.models import AbstractBaseUser +5 8 | +6 |- def test_union_literal_mixed_quotes(user: AbstractBaseUser[Union[Literal['active', "inactive"], str]]): + 9 |+ def test_union_literal_mixed_quotes(user: 'AbstractBaseUser[Union[Literal["active", "inactive"], str]]'): +7 10 | pass +8 11 | +9 12 | + +quote3.py:13:44: TCH002 [*] Move third-party import `django.contrib.auth.models.AbstractBaseUser` into a type-checking block + | +11 | from typing import Callable, Literal +12 | +13 | from django.contrib.auth.models import AbstractBaseUser + | ^^^^^^^^^^^^^^^^ TCH002 +14 | +15 | def test_callable_literal_mixed_quotes(callable_fn: AbstractBaseUser[Callable[["int", Literal['admin', "user"]], 'bool']]): + | + = help: Move into type-checking block + +ℹ Unsafe fix + 1 |+from typing import TYPE_CHECKING + 2 |+ + 3 |+if TYPE_CHECKING: + 4 |+ from django.contrib.auth.models import AbstractBaseUser +1 5 | def f(): +2 6 | from typing import Literal, Union +3 7 | +-------------------------------------------------------------------------------- +10 14 | def f(): +11 15 | from typing import Callable, Literal +12 16 | +13 |- from django.contrib.auth.models import AbstractBaseUser +14 17 | +15 |- def test_callable_literal_mixed_quotes(callable_fn: AbstractBaseUser[Callable[["int", Literal['admin', "user"]], 'bool']]): + 18 |+ def test_callable_literal_mixed_quotes(callable_fn: 'AbstractBaseUser[Callable[[int, Literal[admin, user]], bool]]'): +16 19 | pass +17 20 | +18 21 | + +quote3.py:22:44: TCH002 [*] Move third-party import `django.contrib.auth.models.AbstractBaseUser` into a type-checking block + | +20 | from typing import Annotated, Callable, Literal +21 | +22 | from django.contrib.auth.models import AbstractBaseUser + | ^^^^^^^^^^^^^^^^ TCH002 +23 | +24 | def test_callable_annotated_literal(callable_fn: AbstractBaseUser[Callable[[int, Annotated[str, Literal['active', "inactive"]]], bool]]): + | + = help: Move into type-checking block + +ℹ Unsafe fix + 1 |+from typing import TYPE_CHECKING + 2 |+ + 3 |+if TYPE_CHECKING: + 4 |+ from django.contrib.auth.models import AbstractBaseUser +1 5 | def f(): +2 6 | from typing import Literal, Union +3 7 | +-------------------------------------------------------------------------------- +19 23 | def f(): +20 24 | from typing import Annotated, Callable, Literal +21 25 | +22 |- from django.contrib.auth.models import AbstractBaseUser +23 26 | +24 |- def test_callable_annotated_literal(callable_fn: AbstractBaseUser[Callable[[int, Annotated[str, Literal['active', "inactive"]]], bool]]): + 27 |+ def test_callable_annotated_literal(callable_fn: 'AbstractBaseUser[Callable[[int, Annotated[str, Literal[active, inactive]]], bool]]'): +25 28 | pass +26 29 | +27 30 | + +quote3.py:31:37: TCH002 [*] Move third-party import `django.contrib.auth.models` into a type-checking block + | +29 | from typing import literal +30 | +31 | from django.contrib.auth import models + | ^^^^^^ TCH002 +32 | +33 | def test_attribute(arg: models.AbstractBaseUser["int"]): + | + = help: Move into type-checking block + +ℹ Unsafe fix + 1 |+from typing import TYPE_CHECKING + 2 |+ + 3 |+if TYPE_CHECKING: + 4 |+ from django.contrib.auth import models +1 5 | def f(): +2 6 | from typing import Literal, Union +3 7 | +-------------------------------------------------------------------------------- +28 32 | def f(): +29 33 | from typing import literal +30 34 | +31 |- from django.contrib.auth import models +32 35 | +33 |- def test_attribute(arg: models.AbstractBaseUser["int"]): + 36 |+ def test_attribute(arg: 'models.AbstractBaseUser[int]'): +34 37 | pass +35 38 | +36 39 | + +quote3.py:40:37: TCH002 [*] Move third-party import `django.contrib.auth.models` into a type-checking block + | +38 | from typing import Literal +39 | +40 | from django.contrib.auth import models + | ^^^^^^ TCH002 +41 | +42 | def test_attribute_typing_literal(arg: models.AbstractBaseUser[Literal["admin"]]): + | + = help: Move into type-checking block + +ℹ Unsafe fix + 1 |+from typing import TYPE_CHECKING + 2 |+ + 3 |+if TYPE_CHECKING: + 4 |+ from django.contrib.auth import models +1 5 | def f(): +2 6 | from typing import Literal, Union +3 7 | +-------------------------------------------------------------------------------- +37 41 | def f(): +38 42 | from typing import Literal +39 43 | +40 |- from django.contrib.auth import models +41 44 | +42 |- def test_attribute_typing_literal(arg: models.AbstractBaseUser[Literal["admin"]]): + 45 |+ def test_attribute_typing_literal(arg: 'models.AbstractBaseUser[Literal["admin"]]'): +43 46 | pass +44 47 | diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__quote_typing-only-third-party-import_quote_annotations.py.snap b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__quote_typing-only-third-party-import_quote_annotations.py.snap new file mode 100644 index 0000000000000..a8907800043a6 --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__quote_typing-only-third-party-import_quote_annotations.py.snap @@ -0,0 +1,425 @@ +--- +source: crates/ruff_linter/src/rules/flake8_type_checking/mod.rs +--- +quote_annotations.py:2:44: TCH002 [*] Move third-party import `django.contrib.auth.models.AbstractBaseUser` into a type-checking block + | +1 | def f(): +2 | from django.contrib.auth.models import AbstractBaseUser + | ^^^^^^^^^^^^^^^^ TCH002 +3 | +4 | def test_remove_inner_quotes_double(self, user: AbstractBaseUser["int"], view: "type[CondorBaseViewSet]"): + | + = help: Move into type-checking block + +ℹ Unsafe fix +1 |-def f(): + 1 |+from typing import TYPE_CHECKING + 2 |+ + 3 |+if TYPE_CHECKING: +2 4 | from django.contrib.auth.models import AbstractBaseUser + 5 |+def f(): +3 6 | +4 |- def test_remove_inner_quotes_double(self, user: AbstractBaseUser["int"], view: "type[CondorBaseViewSet]"): + 7 |+ def test_remove_inner_quotes_double(self, user: "AbstractBaseUser[int]", view: "type[CondorBaseViewSet]"): +5 8 | pass +6 9 | +7 10 | + +quote_annotations.py:9:44: TCH002 [*] Move third-party import `django.contrib.auth.models.AbstractBaseUser` into a type-checking block + | + 8 | def f(): + 9 | from django.contrib.auth.models import AbstractBaseUser + | ^^^^^^^^^^^^^^^^ TCH002 +10 | +11 | def test_remove_inner_quotes_single(self, user: AbstractBaseUser['int']): + | + = help: Move into type-checking block + +ℹ Unsafe fix + 1 |+from typing import TYPE_CHECKING + 2 |+ + 3 |+if TYPE_CHECKING: + 4 |+ from django.contrib.auth.models import AbstractBaseUser +1 5 | def f(): +2 6 | from django.contrib.auth.models import AbstractBaseUser +3 7 | +-------------------------------------------------------------------------------- +6 10 | +7 11 | +8 12 | def f(): +9 |- from django.contrib.auth.models import AbstractBaseUser +10 13 | +11 |- def test_remove_inner_quotes_single(self, user: AbstractBaseUser['int']): + 14 |+ def test_remove_inner_quotes_single(self, user: "AbstractBaseUser[int]"): +12 15 | pass +13 16 | +14 17 | + +quote_annotations.py:16:44: TCH002 [*] Move third-party import `django.contrib.auth.models.AbstractBaseUser` into a type-checking block + | +15 | def f(): +16 | from django.contrib.auth.models import AbstractBaseUser + | ^^^^^^^^^^^^^^^^ TCH002 +17 | +18 | def test_remove_inner_quotes_mixed(self, user: AbstractBaseUser['int', "str"]): + | + = help: Move into type-checking block + +ℹ Unsafe fix + 1 |+from typing import TYPE_CHECKING + 2 |+ + 3 |+if TYPE_CHECKING: + 4 |+ from django.contrib.auth.models import AbstractBaseUser +1 5 | def f(): +2 6 | from django.contrib.auth.models import AbstractBaseUser +3 7 | +-------------------------------------------------------------------------------- +13 17 | +14 18 | +15 19 | def f(): +16 |- from django.contrib.auth.models import AbstractBaseUser +17 20 | +18 |- def test_remove_inner_quotes_mixed(self, user: AbstractBaseUser['int', "str"]): + 21 |+ def test_remove_inner_quotes_mixed(self, user: "AbstractBaseUser[int, str]"): +19 22 | pass +20 23 | +21 24 | + +quote_annotations.py:25:44: TCH002 [*] Move third-party import `django.contrib.auth.models.AbstractBaseUser` into a type-checking block + | +23 | from typing import Annotated, Literal +24 | +25 | from django.contrib.auth.models import AbstractBaseUser + | ^^^^^^^^^^^^^^^^ TCH002 +26 | +27 | def test_literal_annotation_args_contain_quotes(self, type1: AbstractBaseUser[Literal["user", "admin"], Annotated["int", "1", 2]]): + | + = help: Move into type-checking block + +ℹ Unsafe fix + 1 |+from typing import TYPE_CHECKING + 2 |+ + 3 |+if TYPE_CHECKING: + 4 |+ from django.contrib.auth.models import AbstractBaseUser +1 5 | def f(): +2 6 | from django.contrib.auth.models import AbstractBaseUser +3 7 | +-------------------------------------------------------------------------------- +22 26 | def f(): +23 27 | from typing import Annotated, Literal +24 28 | +25 |- from django.contrib.auth.models import AbstractBaseUser +26 29 | +27 |- def test_literal_annotation_args_contain_quotes(self, type1: AbstractBaseUser[Literal["user", "admin"], Annotated["int", "1", 2]]): + 30 |+ def test_literal_annotation_args_contain_quotes(self, type1: "AbstractBaseUser[Literal['user', 'admin'], Annotated[int, '1', 2]]"): +28 31 | pass +29 32 | +30 33 | + +quote_annotations.py:34:44: TCH002 [*] Move third-party import `django.contrib.auth.models.AbstractBaseUser` into a type-checking block + | +32 | from typing import Literal +33 | +34 | from django.contrib.auth.models import AbstractBaseUser + | ^^^^^^^^^^^^^^^^ TCH002 +35 | +36 | def test_union_contain_inner_quotes(self, type1: AbstractBaseUser["int" | Literal["int"]]): + | + = help: Move into type-checking block + +ℹ Unsafe fix + 1 |+from typing import TYPE_CHECKING + 2 |+ + 3 |+if TYPE_CHECKING: + 4 |+ from django.contrib.auth.models import AbstractBaseUser +1 5 | def f(): +2 6 | from django.contrib.auth.models import AbstractBaseUser +3 7 | +-------------------------------------------------------------------------------- +31 35 | def f(): +32 36 | from typing import Literal +33 37 | +34 |- from django.contrib.auth.models import AbstractBaseUser +35 38 | +36 |- def test_union_contain_inner_quotes(self, type1: AbstractBaseUser["int" | Literal["int"]]): + 39 |+ def test_union_contain_inner_quotes(self, type1: "AbstractBaseUser[int | Literal['int']]"): +37 40 | pass +38 41 | +39 42 | + +quote_annotations.py:43:44: TCH002 [*] Move third-party import `django.contrib.auth.models.AbstractBaseUser` into a type-checking block + | +41 | from typing import Literal +42 | +43 | from django.contrib.auth.models import AbstractBaseUser + | ^^^^^^^^^^^^^^^^ TCH002 +44 | +45 | def test_inner_literal_mixed_quotes(user: AbstractBaseUser[Literal['user', "admin"]]): + | + = help: Move into type-checking block + +ℹ Unsafe fix + 1 |+from typing import TYPE_CHECKING + 2 |+ + 3 |+if TYPE_CHECKING: + 4 |+ from django.contrib.auth.models import AbstractBaseUser +1 5 | def f(): +2 6 | from django.contrib.auth.models import AbstractBaseUser +3 7 | +-------------------------------------------------------------------------------- +40 44 | def f(): +41 45 | from typing import Literal +42 46 | +43 |- from django.contrib.auth.models import AbstractBaseUser +44 47 | +45 |- def test_inner_literal_mixed_quotes(user: AbstractBaseUser[Literal['user', "admin"]]): + 48 |+ def test_inner_literal_mixed_quotes(user: "AbstractBaseUser[Literal['user', 'admin']]"): +46 49 | pass +47 50 | +48 51 | + +quote_annotations.py:52:44: TCH002 [*] Move third-party import `django.contrib.auth.models.AbstractBaseUser` into a type-checking block + | +50 | from typing import Literal +51 | +52 | from django.contrib.auth.models import AbstractBaseUser + | ^^^^^^^^^^^^^^^^ TCH002 +53 | +54 | def test_inner_literal_single_quote(user: AbstractBaseUser[Literal['int'], str]): + | + = help: Move into type-checking block + +ℹ Unsafe fix + 1 |+from typing import TYPE_CHECKING + 2 |+ + 3 |+if TYPE_CHECKING: + 4 |+ from django.contrib.auth.models import AbstractBaseUser +1 5 | def f(): +2 6 | from django.contrib.auth.models import AbstractBaseUser +3 7 | +-------------------------------------------------------------------------------- +49 53 | def f(): +50 54 | from typing import Literal +51 55 | +52 |- from django.contrib.auth.models import AbstractBaseUser +53 56 | +54 |- def test_inner_literal_single_quote(user: AbstractBaseUser[Literal['int'], str]): + 57 |+ def test_inner_literal_single_quote(user: "AbstractBaseUser[Literal['int'], str]"): +55 58 | pass +56 59 | +57 60 | + +quote_annotations.py:61:44: TCH002 [*] Move third-party import `django.contrib.auth.models.AbstractBaseUser` into a type-checking block + | +59 | from typing import Literal +60 | +61 | from django.contrib.auth.models import AbstractBaseUser + | ^^^^^^^^^^^^^^^^ TCH002 +62 | +63 | def test_mixed_quotes_literal(user: AbstractBaseUser[Literal['user'], "int"]): + | + = help: Move into type-checking block + +ℹ Unsafe fix + 1 |+from typing import TYPE_CHECKING + 2 |+ + 3 |+if TYPE_CHECKING: + 4 |+ from django.contrib.auth.models import AbstractBaseUser +1 5 | def f(): +2 6 | from django.contrib.auth.models import AbstractBaseUser +3 7 | +-------------------------------------------------------------------------------- +58 62 | def f(): +59 63 | from typing import Literal +60 64 | +61 |- from django.contrib.auth.models import AbstractBaseUser +62 65 | +63 |- def test_mixed_quotes_literal(user: AbstractBaseUser[Literal['user'], "int"]): + 66 |+ def test_mixed_quotes_literal(user: "AbstractBaseUser[Literal['user'], int]"): +64 67 | pass +65 68 | +66 69 | + +quote_annotations.py:70:44: TCH002 [*] Move third-party import `django.contrib.auth.models.AbstractBaseUser` into a type-checking block + | +68 | from typing import Annotated, Literal +69 | +70 | from django.contrib.auth.models import AbstractBaseUser + | ^^^^^^^^^^^^^^^^ TCH002 +71 | +72 | def test_annotated_literal_mixed_quotes(user: AbstractBaseUser[Annotated[str, "max_length=20", Literal['user', "admin"]]]): + | + = help: Move into type-checking block + +ℹ Unsafe fix + 1 |+from typing import TYPE_CHECKING + 2 |+ + 3 |+if TYPE_CHECKING: + 4 |+ from django.contrib.auth.models import AbstractBaseUser +1 5 | def f(): +2 6 | from django.contrib.auth.models import AbstractBaseUser +3 7 | +-------------------------------------------------------------------------------- +67 71 | def f(): +68 72 | from typing import Annotated, Literal +69 73 | +70 |- from django.contrib.auth.models import AbstractBaseUser +71 74 | +72 |- def test_annotated_literal_mixed_quotes(user: AbstractBaseUser[Annotated[str, "max_length=20", Literal['user', "admin"]]]): + 75 |+ def test_annotated_literal_mixed_quotes(user: "AbstractBaseUser[Annotated[str, 'max_length=20', Literal['user', 'admin']]]"): +73 76 | pass +74 77 | +75 78 | + +quote_annotations.py:79:44: TCH002 [*] Move third-party import `django.contrib.auth.models.AbstractBaseUser` into a type-checking block + | +77 | from typing import Literal, Union +78 | +79 | from django.contrib.auth.models import AbstractBaseUser + | ^^^^^^^^^^^^^^^^ TCH002 +80 | +81 | def test_union_literal_mixed_quotes(user: AbstractBaseUser[Union[Literal['active', "inactive"], str]]): + | + = help: Move into type-checking block + +ℹ Unsafe fix + 1 |+from typing import TYPE_CHECKING + 2 |+ + 3 |+if TYPE_CHECKING: + 4 |+ from django.contrib.auth.models import AbstractBaseUser +1 5 | def f(): +2 6 | from django.contrib.auth.models import AbstractBaseUser +3 7 | +-------------------------------------------------------------------------------- +76 80 | def f(): +77 81 | from typing import Literal, Union +78 82 | +79 |- from django.contrib.auth.models import AbstractBaseUser +80 83 | +81 |- def test_union_literal_mixed_quotes(user: AbstractBaseUser[Union[Literal['active', "inactive"], str]]): + 84 |+ def test_union_literal_mixed_quotes(user: "AbstractBaseUser[Union[Literal['active', 'inactive'], str]]"): +82 85 | pass +83 86 | +84 87 | + +quote_annotations.py:88:44: TCH002 [*] Move third-party import `django.contrib.auth.models.AbstractBaseUser` into a type-checking block + | +86 | from typing import Callable, Literal +87 | +88 | from django.contrib.auth.models import AbstractBaseUser + | ^^^^^^^^^^^^^^^^ TCH002 +89 | +90 | def test_callable_literal_mixed_quotes(callable_fn: AbstractBaseUser[Callable[["int", Literal['admin', "user"]], 'bool']]): + | + = help: Move into type-checking block + +ℹ Unsafe fix + 1 |+from typing import TYPE_CHECKING + 2 |+ + 3 |+if TYPE_CHECKING: + 4 |+ from django.contrib.auth.models import AbstractBaseUser +1 5 | def f(): +2 6 | from django.contrib.auth.models import AbstractBaseUser +3 7 | +-------------------------------------------------------------------------------- +85 89 | def f(): +86 90 | from typing import Callable, Literal +87 91 | +88 |- from django.contrib.auth.models import AbstractBaseUser +89 92 | +90 |- def test_callable_literal_mixed_quotes(callable_fn: AbstractBaseUser[Callable[["int", Literal['admin', "user"]], 'bool']]): + 93 |+ def test_callable_literal_mixed_quotes(callable_fn: "AbstractBaseUser[Callable[[int, Literal[admin, user]], bool]]"): +91 94 | pass +92 95 | +93 96 | + +quote_annotations.py:97:44: TCH002 [*] Move third-party import `django.contrib.auth.models.AbstractBaseUser` into a type-checking block + | +95 | from typing import Annotated, Callable, Literal +96 | +97 | from django.contrib.auth.models import AbstractBaseUser + | ^^^^^^^^^^^^^^^^ TCH002 +98 | +99 | def test_callable_annotated_literal(callable_fn: AbstractBaseUser[Callable[[int, Annotated[str, Literal['active', "inactive"]]], bool]]): + | + = help: Move into type-checking block + +ℹ Unsafe fix + 1 |+from typing import TYPE_CHECKING + 2 |+ + 3 |+if TYPE_CHECKING: + 4 |+ from django.contrib.auth.models import AbstractBaseUser +1 5 | def f(): +2 6 | from django.contrib.auth.models import AbstractBaseUser +3 7 | +-------------------------------------------------------------------------------- +94 98 | def f(): +95 99 | from typing import Annotated, Callable, Literal +96 100 | +97 |- from django.contrib.auth.models import AbstractBaseUser +98 101 | +99 |- def test_callable_annotated_literal(callable_fn: AbstractBaseUser[Callable[[int, Annotated[str, Literal['active', "inactive"]]], bool]]): + 102 |+ def test_callable_annotated_literal(callable_fn: "AbstractBaseUser[Callable[[int, Annotated[str, Literal[active, inactive]]], bool]]"): +100 103 | pass +101 104 | +102 105 | + +quote_annotations.py:120:37: TCH002 [*] Move third-party import `django.contrib.auth.models` into a type-checking block + | +118 | from typing import literal +119 | +120 | from django.contrib.auth import models + | ^^^^^^ TCH002 +121 | +122 | def test_attribute(arg: models.AbstractBaseUser["int"]): + | + = help: Move into type-checking block + +ℹ Unsafe fix + 1 |+from typing import TYPE_CHECKING + 2 |+ + 3 |+if TYPE_CHECKING: + 4 |+ from django.contrib.auth import models +1 5 | def f(): +2 6 | from django.contrib.auth.models import AbstractBaseUser +3 7 | +-------------------------------------------------------------------------------- +117 121 | def f(): +118 122 | from typing import literal +119 123 | +120 |- from django.contrib.auth import models +121 124 | +122 |- def test_attribute(arg: models.AbstractBaseUser["int"]): + 125 |+ def test_attribute(arg: "models.AbstractBaseUser[int]"): +123 126 | pass +124 127 | +125 128 | + +quote_annotations.py:129:37: TCH002 [*] Move third-party import `django.contrib.auth.models` into a type-checking block + | +127 | from typing import Literal +128 | +129 | from django.contrib.auth import models + | ^^^^^^ TCH002 +130 | +131 | def test_attribute_typing_literal(arg: models.AbstractBaseUser[Literal["admin"]]): + | + = help: Move into type-checking block + +ℹ Unsafe fix + 1 |+from typing import TYPE_CHECKING + 2 |+ + 3 |+if TYPE_CHECKING: + 4 |+ from django.contrib.auth import models +1 5 | def f(): +2 6 | from django.contrib.auth.models import AbstractBaseUser +3 7 | +-------------------------------------------------------------------------------- +126 130 | def f(): +127 131 | from typing import Literal +128 132 | +129 |- from django.contrib.auth import models +130 133 | +131 |- def test_attribute_typing_literal(arg: models.AbstractBaseUser[Literal["admin"]]): + 134 |+ def test_attribute_typing_literal(arg: "models.AbstractBaseUser[Literal['admin']]"): +132 135 | pass diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__runtime-import-in-type-checking-block_quote.py.snap b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__runtime-import-in-type-checking-block_quote.py.snap index c09777853b97a..cc6b8d74f43b5 100644 --- a/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__runtime-import-in-type-checking-block_quote.py.snap +++ b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__runtime-import-in-type-checking-block_quote.py.snap @@ -1,13 +1,13 @@ --- source: crates/ruff_linter/src/rules/flake8_type_checking/mod.rs --- -quote.py:64:28: TCH004 [*] Move import `pandas.DataFrame` out of type-checking block. Import is used for more than type hinting. +quote.py:57:28: TCH004 [*] Move import `pandas.DataFrame` out of type-checking block. Import is used for more than type hinting. | -63 | if TYPE_CHECKING: -64 | from pandas import DataFrame +56 | if TYPE_CHECKING: +57 | from pandas import DataFrame | ^^^^^^^^^ TCH004 -65 | -66 | def func(value: DataFrame): +58 | +59 | def func(value: DataFrame): | = help: Move out of type-checking block @@ -17,13 +17,11 @@ quote.py:64:28: TCH004 [*] Move import `pandas.DataFrame` out of type-checking b 2 3 | from pandas import DataFrame 3 4 | -------------------------------------------------------------------------------- -61 62 | from typing import TYPE_CHECKING -62 63 | -63 64 | if TYPE_CHECKING: -64 |- from pandas import DataFrame - 65 |+ pass -65 66 | -66 67 | def func(value: DataFrame): -67 68 | ... - - +54 55 | from typing import TYPE_CHECKING +55 56 | +56 57 | if TYPE_CHECKING: +57 |- from pandas import DataFrame + 58 |+ pass +58 59 | +59 60 | def func(value: DataFrame): +60 61 | ... diff --git a/crates/ruff_linter/src/rules/pycodestyle/rules/literal_comparisons.rs b/crates/ruff_linter/src/rules/pycodestyle/rules/literal_comparisons.rs index c5117544f0427..0a59cdb866299 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/rules/literal_comparisons.rs +++ b/crates/ruff_linter/src/rules/pycodestyle/rules/literal_comparisons.rs @@ -248,12 +248,7 @@ pub(crate) fn literal_comparisons(checker: &mut Checker, compare: &ast::ExprComp } // Check each comparator in order. - for (index, (op, next)) in compare - .ops - .iter() - .zip(compare.comparators.iter()) - .enumerate() - { + for (index, (op, next)) in compare.ops.iter().zip(&compare.comparators).enumerate() { if helpers::is_constant_non_singleton(comparator) { comparator = next; continue; diff --git a/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/space_around_operator.rs b/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/space_around_operator.rs index 0fc9d93ef3a44..a707d2fb4b22b 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/space_around_operator.rs +++ b/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/space_around_operator.rs @@ -302,6 +302,7 @@ const fn is_operator_token(token: TokenKind) -> bool { | TokenKind::Equal | TokenKind::Percent | TokenKind::NotEqual + | TokenKind::EqEqual | TokenKind::LessEqual | TokenKind::GreaterEqual | TokenKind::CircumFlex diff --git a/crates/ruff_linter/src/rules/pycodestyle/rules/type_comparison.rs b/crates/ruff_linter/src/rules/pycodestyle/rules/type_comparison.rs index 3adaef03ab543..7e57b9cc8f93d 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/rules/type_comparison.rs +++ b/crates/ruff_linter/src/rules/pycodestyle/rules/type_comparison.rs @@ -62,10 +62,10 @@ impl Violation for TypeComparison { /// E721 pub(crate) fn type_comparison(checker: &mut Checker, compare: &ast::ExprCompare) { - for (left, right) in std::iter::once(compare.left.as_ref()) - .chain(compare.comparators.iter()) + for (left, right) in std::iter::once(&*compare.left) + .chain(&compare.comparators) .tuple_windows() - .zip(compare.ops.iter()) + .zip(&compare.ops) .filter(|(_, op)| matches!(op, CmpOp::Eq | CmpOp::NotEq)) .map(|((left, right), _)| (left, right)) { diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E221_E22.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E221_E22.py.snap index ec75229e10a6b..7df31ee2f5c3b 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E221_E22.py.snap +++ b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E221_E22.py.snap @@ -168,4 +168,20 @@ E22.py:19:14: E221 [*] Multiple spaces before operator 21 21 | x = 1 22 22 | y = 2 +E22.py:184:5: E221 [*] Multiple spaces before operator + | +182 | #: +183 | +184 | if a == 1: + | ^^ E221 +185 | print(a) + | + = help: Replace with single space +ℹ Safe fix +181 181 | pass +182 182 | #: +183 183 | +184 |-if a == 1: + 184 |+if a == 1: +185 185 | print(a) diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E222_E22.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E222_E22.py.snap index 52ec6b7ed6831..71fad6ac8ac73 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E222_E22.py.snap +++ b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E222_E22.py.snap @@ -105,4 +105,20 @@ E22.py:36:7: E222 [*] Multiple spaces after operator 38 38 | #: 39 39 | +E22.py:184:9: E222 [*] Multiple spaces after operator + | +182 | #: +183 | +184 | if a == 1: + | ^^ E222 +185 | print(a) + | + = help: Replace with single space +ℹ Safe fix +181 181 | pass +182 182 | #: +183 183 | +184 |-if a == 1: + 184 |+if a == 1: +185 185 | print(a) diff --git a/crates/ruff_linter/src/rules/pydocstyle/rules/blank_before_after_class.rs b/crates/ruff_linter/src/rules/pydocstyle/rules/blank_before_after_class.rs index 465ba4964b0fd..299d6f44b4c6e 100644 --- a/crates/ruff_linter/src/rules/pydocstyle/rules/blank_before_after_class.rs +++ b/crates/ruff_linter/src/rules/pydocstyle/rules/blank_before_after_class.rs @@ -65,8 +65,8 @@ impl AlwaysFixableViolation for OneBlankLineBeforeClass { /// docstring from its methods. /// /// This rule may not apply to all projects; its applicability is a matter of -/// convention. By default, this rule is enabled when using the `google` -/// convention, and disabled when using the `numpy` and `pep257` conventions. +/// convention. By default, this rule is enabled when using the `numpy` and `pep257` +/// conventions, and disabled when using the `google` convention. /// /// ## Example /// ```python diff --git a/crates/ruff_linter/src/rules/pydocstyle/rules/blank_before_after_function.rs b/crates/ruff_linter/src/rules/pydocstyle/rules/blank_before_after_function.rs index 25e335e80eb62..11027c562dee3 100644 --- a/crates/ruff_linter/src/rules/pydocstyle/rules/blank_before_after_function.rs +++ b/crates/ruff_linter/src/rules/pydocstyle/rules/blank_before_after_function.rs @@ -1,5 +1,5 @@ -use once_cell::sync::Lazy; use regex::Regex; +use std::sync::LazyLock; use ruff_diagnostics::{AlwaysFixableViolation, Diagnostic, Edit, Fix}; use ruff_macros::{derive_message_formats, violation}; @@ -98,8 +98,8 @@ impl AlwaysFixableViolation for NoBlankLineAfterFunction { } } -static INNER_FUNCTION_OR_CLASS_REGEX: Lazy<Regex> = - Lazy::new(|| Regex::new(r"^\s+(?:(?:class|def|async def)\s|@)").unwrap()); +static INNER_FUNCTION_OR_CLASS_REGEX: LazyLock<Regex> = + LazyLock::new(|| Regex::new(r"^\s+(?:(?:class|def|async def)\s|@)").unwrap()); /// D201, D202 pub(crate) fn blank_before_after_function(checker: &mut Checker, docstring: &Docstring) { diff --git a/crates/ruff_linter/src/rules/pydocstyle/rules/non_imperative_mood.rs b/crates/ruff_linter/src/rules/pydocstyle/rules/non_imperative_mood.rs index 2391aeb11a131..7be580cd1b496 100644 --- a/crates/ruff_linter/src/rules/pydocstyle/rules/non_imperative_mood.rs +++ b/crates/ruff_linter/src/rules/pydocstyle/rules/non_imperative_mood.rs @@ -1,5 +1,6 @@ +use std::sync::LazyLock; + use imperative::Mood; -use once_cell::sync::Lazy; use ruff_diagnostics::{Diagnostic, Violation}; use ruff_macros::{derive_message_formats, violation}; @@ -12,7 +13,7 @@ use crate::docstrings::Docstring; use crate::rules::pydocstyle::helpers::normalize_word; use crate::rules::pydocstyle::settings::Settings; -static MOOD: Lazy<Mood> = Lazy::new(Mood::new); +static MOOD: LazyLock<Mood> = LazyLock::new(Mood::new); /// ## What it does /// Checks for docstring first lines that are not in an imperative mood. diff --git a/crates/ruff_linter/src/rules/pydocstyle/rules/sections.rs b/crates/ruff_linter/src/rules/pydocstyle/rules/sections.rs index 10346ec19482a..647e53a24ee61 100644 --- a/crates/ruff_linter/src/rules/pydocstyle/rules/sections.rs +++ b/crates/ruff_linter/src/rules/pydocstyle/rules/sections.rs @@ -1,7 +1,7 @@ use itertools::Itertools; -use once_cell::sync::Lazy; use regex::Regex; use rustc_hash::FxHashSet; +use std::sync::LazyLock; use ruff_diagnostics::{AlwaysFixableViolation, Violation}; use ruff_diagnostics::{Diagnostic, Edit, Fix}; @@ -1846,11 +1846,11 @@ fn missing_args(checker: &mut Checker, docstring: &Docstring, docstrings_args: & } // See: `GOOGLE_ARGS_REGEX` in `pydocstyle/checker.py`. -static GOOGLE_ARGS_REGEX: Lazy<Regex> = - Lazy::new(|| Regex::new(r"^\s*(\*?\*?\w+)\s*(\(.*?\))?\s*:(\r\n|\n)?\s*.+").unwrap()); +static GOOGLE_ARGS_REGEX: LazyLock<Regex> = + LazyLock::new(|| Regex::new(r"^\s*(\*?\*?\w+)\s*(\(.*?\))?\s*:(\r\n|\n)?\s*.+").unwrap()); fn args_section(context: &SectionContext) -> FxHashSet<String> { - let mut following_lines = context.following_lines().peekable(); + let mut following_lines = context.following_lines(); let Some(first_line) = following_lines.next() else { return FxHashSet::default(); }; diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/strings.rs b/crates/ruff_linter/src/rules/pyflakes/rules/strings.rs index 4a296de44e845..acbfd5ef14849 100644 --- a/crates/ruff_linter/src/rules/pyflakes/rules/strings.rs +++ b/crates/ruff_linter/src/rules/pyflakes/rules/strings.rs @@ -870,7 +870,7 @@ pub(crate) fn string_dot_format_missing_argument( let missing: Vec<String> = summary .autos .iter() - .chain(summary.indices.iter()) + .chain(&summary.indices) .filter(|&&i| i >= args.len()) .map(ToString::to_string) .chain( diff --git a/crates/ruff_linter/src/rules/pygrep_hooks/rules/blanket_type_ignore.rs b/crates/ruff_linter/src/rules/pygrep_hooks/rules/blanket_type_ignore.rs index 1fd73eb5c661e..662d76709968f 100644 --- a/crates/ruff_linter/src/rules/pygrep_hooks/rules/blanket_type_ignore.rs +++ b/crates/ruff_linter/src/rules/pygrep_hooks/rules/blanket_type_ignore.rs @@ -1,7 +1,7 @@ use anyhow::{anyhow, Result}; use memchr::memchr_iter; -use once_cell::sync::Lazy; use regex::Regex; +use std::sync::LazyLock; use ruff_diagnostics::{Diagnostic, Violation}; use ruff_macros::{derive_message_formats, violation}; @@ -102,8 +102,8 @@ pub(crate) fn blanket_type_ignore( // Match, e.g., `[attr-defined]` or `[attr-defined, misc]`. // See: https://github.com/python/mypy/blob/b43e0d34247a6d1b3b9d9094d184bbfcb9808bb9/mypy/fastparse.py#L327 -static TYPE_IGNORE_TAG_PATTERN: Lazy<Regex> = - Lazy::new(|| Regex::new(r"^\s*\[(?P<codes>[^]#]*)]\s*(#.*)?$").unwrap()); +static TYPE_IGNORE_TAG_PATTERN: LazyLock<Regex> = + LazyLock::new(|| Regex::new(r"^\s*\[(?P<codes>[^]#]*)]\s*(#.*)?$").unwrap()); /// Parse the optional `[...]` tag in a `# type: ignore[...]` comment. /// diff --git a/crates/ruff_linter/src/rules/pylint/rules/compare_to_empty_string.rs b/crates/ruff_linter/src/rules/pylint/rules/compare_to_empty_string.rs index 3b0166bfd9885..db7d246ec6166 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/compare_to_empty_string.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/compare_to_empty_string.rs @@ -76,7 +76,7 @@ pub(crate) fn compare_to_empty_string( let mut first = true; for ((lhs, rhs), op) in std::iter::once(left) - .chain(comparators.iter()) + .chain(comparators) .tuple_windows::<(&Expr, &Expr)>() .zip(ops) { diff --git a/crates/ruff_linter/src/rules/pylint/rules/comparison_of_constant.rs b/crates/ruff_linter/src/rules/pylint/rules/comparison_of_constant.rs index df46b79883a7d..f55906a324988 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/comparison_of_constant.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/comparison_of_constant.rs @@ -57,7 +57,7 @@ pub(crate) fn comparison_of_constant( comparators: &[Expr], ) { for ((left, right), op) in std::iter::once(left) - .chain(comparators.iter()) + .chain(comparators) .tuple_windows() .zip(ops) { diff --git a/crates/ruff_linter/src/rules/pylint/rules/comparison_with_itself.rs b/crates/ruff_linter/src/rules/pylint/rules/comparison_with_itself.rs index 27533f35e2eab..2984fb6d9beb7 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/comparison_with_itself.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/comparison_with_itself.rs @@ -55,7 +55,7 @@ pub(crate) fn comparison_with_itself( comparators: &[Expr], ) { for ((left, right), op) in std::iter::once(left) - .chain(comparators.iter()) + .chain(comparators) .tuple_windows() .zip(ops) { diff --git a/crates/ruff_linter/src/rules/pylint/rules/duplicate_bases.rs b/crates/ruff_linter/src/rules/pylint/rules/duplicate_bases.rs index 5ec9859cf2358..506f53b9d5209 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/duplicate_bases.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/duplicate_bases.rs @@ -64,7 +64,7 @@ pub(crate) fn duplicate_bases(checker: &mut Checker, name: &str, arguments: Opti let bases = &arguments.args; let mut seen: FxHashSet<&str> = FxHashSet::with_capacity_and_hasher(bases.len(), FxBuildHasher); - for base in &**bases { + for base in bases { if let Expr::Name(ast::ExprName { id, .. }) = base { if !seen.insert(id) { let mut diagnostic = Diagnostic::new( diff --git a/crates/ruff_linter/src/rules/pylint/rules/iteration_over_set.rs b/crates/ruff_linter/src/rules/pylint/rules/iteration_over_set.rs index 82023c5ad8320..0abc2d89b0e5a 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/iteration_over_set.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/iteration_over_set.rs @@ -1,12 +1,14 @@ use ruff_diagnostics::{AlwaysFixableViolation, Diagnostic, Edit, Fix}; use ruff_macros::{derive_message_formats, violation}; -use ruff_python_ast::Expr; +use ruff_python_ast::{comparable::ComparableExpr, Expr}; use ruff_text_size::Ranged; +use rustc_hash::{FxBuildHasher, FxHashSet}; use crate::checkers::ast::Checker; /// ## What it does -/// Checks for iterations over `set` literals. +/// Checks for iteration over a `set` literal where each element in the set is +/// itself a literal value. /// /// ## Why is this bad? /// Iterating over a `set` is less efficient than iterating over a sequence @@ -46,10 +48,20 @@ pub(crate) fn iteration_over_set(checker: &mut Checker, expr: &Expr) { return; }; - if set.iter().any(Expr::is_starred_expr) { + if set.iter().any(|value| !value.is_literal_expr()) { return; } + let mut seen_values = FxHashSet::with_capacity_and_hasher(set.len(), FxBuildHasher); + for value in set { + let comparable_value = ComparableExpr::from(value); + if !seen_values.insert(comparable_value) { + // if the set contains a duplicate literal value, early exit. + // rule `B033` can catch that. + return; + } + } + let mut diagnostic = Diagnostic::new(IterationOverSet, expr.range()); let tuple = if let [elt] = set.elts.as_slice() { diff --git a/crates/ruff_linter/src/rules/pylint/rules/magic_value_comparison.rs b/crates/ruff_linter/src/rules/pylint/rules/magic_value_comparison.rs index 5e2463df4def1..2ae14884f7f84 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/magic_value_comparison.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/magic_value_comparison.rs @@ -97,10 +97,7 @@ fn is_magic_value(literal_expr: LiteralExpressionRef, allowed_types: &[ConstantT /// PLR2004 pub(crate) fn magic_value_comparison(checker: &mut Checker, left: &Expr, comparators: &[Expr]) { - for (left, right) in std::iter::once(left) - .chain(comparators.iter()) - .tuple_windows() - { + for (left, right) in std::iter::once(left).chain(comparators).tuple_windows() { // If both of the comparators are literals, skip rule for the whole expression. // R0133: comparison-of-constants if as_literal(left).is_some() && as_literal(right).is_some() { @@ -108,7 +105,7 @@ pub(crate) fn magic_value_comparison(checker: &mut Checker, left: &Expr, compara } } - for comparison_expr in std::iter::once(left).chain(comparators.iter()) { + for comparison_expr in std::iter::once(left).chain(comparators) { if let Some(value) = as_literal(comparison_expr) { if is_magic_value(value, &checker.settings.pylint.allow_magic_value_types) { checker.diagnostics.push(Diagnostic::new( diff --git a/crates/ruff_linter/src/rules/pylint/rules/nan_comparison.rs b/crates/ruff_linter/src/rules/pylint/rules/nan_comparison.rs index fdc3347e2de64..4329a69f906e8 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/nan_comparison.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/nan_comparison.rs @@ -49,7 +49,7 @@ impl Violation for NanComparison { /// PLW0177 pub(crate) fn nan_comparison(checker: &mut Checker, left: &Expr, comparators: &[Expr]) { - for expr in std::iter::once(left).chain(comparators.iter()) { + for expr in std::iter::once(left).chain(comparators) { if let Some(qualified_name) = checker.semantic().resolve_qualified_name(expr) { match qualified_name.segments() { ["numpy", "nan" | "NAN" | "NaN"] => { diff --git a/crates/ruff_linter/src/rules/pylint/rules/repeated_equality_comparison.rs b/crates/ruff_linter/src/rules/pylint/rules/repeated_equality_comparison.rs index adb9544c3b05e..56f2343343033 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/repeated_equality_comparison.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/repeated_equality_comparison.rs @@ -108,7 +108,7 @@ pub(crate) fn repeated_equality_comparison(checker: &mut Checker, bool_op: &ast: // Break into sequences of consecutive comparisons. let mut sequences: Vec<(Vec<usize>, Vec<&Expr>)> = Vec::new(); let mut last = None; - for (index, comparator) in indices.iter().zip(comparators.iter()) { + for (index, comparator) in indices.iter().zip(comparators) { if last.is_some_and(|last| last + 1 == *index) { let (indices, comparators) = sequences.last_mut().unwrap(); indices.push(*index); diff --git a/crates/ruff_linter/src/rules/pylint/rules/unnecessary_lambda.rs b/crates/ruff_linter/src/rules/pylint/rules/unnecessary_lambda.rs index 3a89da451ec9f..e42b176829bde 100644 --- a/crates/ruff_linter/src/rules/pylint/rules/unnecessary_lambda.rs +++ b/crates/ruff_linter/src/rules/pylint/rules/unnecessary_lambda.rs @@ -179,7 +179,7 @@ pub(crate) fn unnecessary_lambda(checker: &mut Checker, lambda: &ExprLambda) { if call_posargs.len() != lambda_posargs.len() { return; } - for (param, arg) in lambda_posargs.iter().zip(call_posargs.iter()) { + for (param, arg) in lambda_posargs.iter().zip(call_posargs) { let Expr::Name(ast::ExprName { id, .. }) = arg else { return; }; diff --git a/crates/ruff_linter/src/rules/pyupgrade/helpers.rs b/crates/ruff_linter/src/rules/pyupgrade/helpers.rs index 07196a9127332..84108102078f4 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/helpers.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/helpers.rs @@ -1,8 +1,9 @@ -use once_cell::sync::Lazy; use regex::{Captures, Regex}; use std::borrow::Cow; +use std::sync::LazyLock; -static CURLY_BRACES: Lazy<Regex> = Lazy::new(|| Regex::new(r"(\\N\{[^}]+})|([{}])").unwrap()); +static CURLY_BRACES: LazyLock<Regex> = + LazyLock::new(|| Regex::new(r"(\\N\{[^}]+})|([{}])").unwrap()); pub(super) fn curly_escape(text: &str) -> Cow<'_, str> { // Match all curly braces. This will include named unicode escapes (like @@ -20,7 +21,8 @@ pub(super) fn curly_escape(text: &str) -> Cow<'_, str> { }) } -static DOUBLE_CURLY_BRACES: Lazy<Regex> = Lazy::new(|| Regex::new(r"((\{\{)|(\}\}))").unwrap()); +static DOUBLE_CURLY_BRACES: LazyLock<Regex> = + LazyLock::new(|| Regex::new(r"((\{\{)|(\}\}))").unwrap()); pub(super) fn curly_unescape(text: &str) -> Cow<'_, str> { // Match all double curly braces and replace with a single diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/deprecated_unittest_alias.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/deprecated_unittest_alias.rs index 6205df6ec0fc8..431ef71b90fac 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/deprecated_unittest_alias.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/deprecated_unittest_alias.rs @@ -1,6 +1,6 @@ -use once_cell::sync::Lazy; use ruff_python_ast::{self as ast, Expr}; use rustc_hash::FxHashMap; +use std::sync::LazyLock; use ruff_diagnostics::{AlwaysFixableViolation, Diagnostic, Edit, Fix}; use ruff_macros::{derive_message_formats, violation}; @@ -57,7 +57,7 @@ impl AlwaysFixableViolation for DeprecatedUnittestAlias { } } -static DEPRECATED_ALIASES: Lazy<FxHashMap<&'static str, &'static str>> = Lazy::new(|| { +static DEPRECATED_ALIASES: LazyLock<FxHashMap<&'static str, &'static str>> = LazyLock::new(|| { FxHashMap::from_iter([ ("assertAlmostEquals", "assertAlmostEqual"), ("assertEquals", "assertEqual"), diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/format_literals.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/format_literals.rs index ab6753f0c8ad3..dd1f4e943d6f4 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/format_literals.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/format_literals.rs @@ -1,7 +1,7 @@ use anyhow::{anyhow, Result}; use libcst_native::{Arg, Expression}; -use once_cell::sync::Lazy; use regex::Regex; +use std::sync::LazyLock; use ruff_diagnostics::{Diagnostic, Edit, Fix, FixAvailability, Violation}; use ruff_macros::{derive_message_formats, violation}; @@ -127,8 +127,8 @@ fn is_sequential(indices: &[usize]) -> bool { // An opening curly brace, followed by any integer, followed by any text, // followed by a closing brace. -static FORMAT_SPECIFIER: Lazy<Regex> = - Lazy::new(|| Regex::new(r"\{(?P<int>\d+)(?P<fmt>.*?)}").unwrap()); +static FORMAT_SPECIFIER: LazyLock<Regex> = + LazyLock::new(|| Regex::new(r"\{(?P<int>\d+)(?P<fmt>.*?)}").unwrap()); /// Remove the explicit positional indices from a format string. fn remove_specifiers<'a>(value: &mut Expression<'a>, arena: &'a typed_arena::Arena<String>) { diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/unnecessary_coding_comment.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/unnecessary_coding_comment.rs index 68b0ee777f391..f48c05a474264 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/unnecessary_coding_comment.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/unnecessary_coding_comment.rs @@ -1,5 +1,5 @@ -use once_cell::sync::Lazy; use regex::Regex; +use std::sync::LazyLock; use ruff_diagnostics::{AlwaysFixableViolation, Diagnostic, Edit, Fix}; use ruff_macros::{derive_message_formats, violation}; @@ -42,8 +42,8 @@ impl AlwaysFixableViolation for UTF8EncodingDeclaration { } // Regex from PEP263. -static CODING_COMMENT_REGEX: Lazy<Regex> = - Lazy::new(|| Regex::new(r"^[ \t\f]*#.*?coding[:=][ \t]*utf-?8").unwrap()); +static CODING_COMMENT_REGEX: LazyLock<Regex> = + LazyLock::new(|| Regex::new(r"^[ \t\f]*#.*?coding[:=][ \t]*utf-?8").unwrap()); /// UP009 pub(crate) fn unnecessary_coding_comment( diff --git a/crates/ruff_linter/src/rules/refurb/rules/repeated_append.rs b/crates/ruff_linter/src/rules/refurb/rules/repeated_append.rs index 57e5867172f85..1489932e9f4ed 100644 --- a/crates/ruff_linter/src/rules/refurb/rules/repeated_append.rs +++ b/crates/ruff_linter/src/rules/refurb/rules/repeated_append.rs @@ -3,6 +3,7 @@ use rustc_hash::FxHashMap; use ast::traversal; use ruff_diagnostics::{Diagnostic, Edit, Fix, FixAvailability, Violation}; use ruff_macros::{derive_message_formats, violation}; +use ruff_python_ast::traversal::EnclosingSuite; use ruff_python_ast::{self as ast, Expr, Stmt}; use ruff_python_codegen::Generator; use ruff_python_semantic::analyze::typing::is_list; @@ -179,10 +180,10 @@ fn match_consecutive_appends<'a>( // In order to match consecutive statements, we need to go to the tree ancestor of the // given statement, find its position there, and match all 'appends' from there. - let siblings: &[Stmt] = if semantic.at_top_level() { + let suite = if semantic.at_top_level() { // If the statement is at the top level, we should go to the parent module. // Module is available in the definitions list. - semantic.definitions.python_ast()? + EnclosingSuite::new(semantic.definitions.python_ast()?, stmt)? } else { // Otherwise, go to the parent, and take its body as a sequence of siblings. semantic @@ -190,11 +191,12 @@ fn match_consecutive_appends<'a>( .and_then(|parent| traversal::suite(stmt, parent))? }; - let stmt_index = siblings.iter().position(|sibling| sibling == stmt)?; - // We shouldn't repeat the same work for many 'appends' that go in a row. Let's check // that this statement is at the beginning of such a group. - if stmt_index != 0 && match_append(semantic, &siblings[stmt_index - 1]).is_some() { + if suite + .previous_sibling() + .is_some_and(|previous_stmt| match_append(semantic, previous_stmt).is_some()) + { return None; } @@ -202,9 +204,9 @@ fn match_consecutive_appends<'a>( Some( std::iter::once(append) .chain( - siblings + suite + .next_siblings() .iter() - .skip(stmt_index + 1) .map_while(|sibling| match_append(semantic, sibling)), ) .collect(), diff --git a/crates/ruff_linter/src/rules/ruff/rules/collection_literal_concatenation.rs b/crates/ruff_linter/src/rules/ruff/rules/collection_literal_concatenation.rs index 4dce615771ed6..1a69095473d1b 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/collection_literal_concatenation.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/collection_literal_concatenation.rs @@ -143,10 +143,10 @@ fn concatenate_expressions(expr: &Expr) -> Option<(Expr, Type)> { } // If the splat element is itself a list/tuple, insert them in the other list/tuple. Expr::List(ast::ExprList { elts, .. }) if matches!(type_, Type::List) => { - other_elements.iter().chain(elts.iter()).cloned().collect() + other_elements.iter().chain(elts).cloned().collect() } Expr::Tuple(ast::ExprTuple { elts, .. }) if matches!(type_, Type::Tuple) => { - other_elements.iter().chain(elts.iter()).cloned().collect() + other_elements.iter().chain(elts).cloned().collect() } _ => return None, }; diff --git a/crates/ruff_linter/src/rules/ruff/rules/missing_fstring_syntax.rs b/crates/ruff_linter/src/rules/ruff/rules/missing_fstring_syntax.rs index fbc652084d7d5..7ecfe19b1346d 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/missing_fstring_syntax.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/missing_fstring_syntax.rs @@ -189,12 +189,12 @@ fn should_be_fstring( .filter_map(ast::Expr::as_call_expr) { let ast::Arguments { keywords, args, .. } = &expr.arguments; - for keyword in &**keywords { + for keyword in keywords { if let Some(ident) = keyword.arg.as_ref() { arg_names.insert(&ident.id); } } - for arg in &**args { + for arg in args { if let ast::Expr::Name(ast::ExprName { id, .. }) = arg { arg_names.insert(id); } diff --git a/crates/ruff_linter/src/rules/ruff/rules/test_rules.rs b/crates/ruff_linter/src/rules/ruff/rules/test_rules.rs index fb2a5da22781f..a3f2548738662 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/test_rules.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/test_rules.rs @@ -122,10 +122,7 @@ impl TestRule for StableTestRuleSafeFix { } else { Some( Diagnostic::new(StableTestRuleSafeFix, ruff_text_size::TextRange::default()) - .with_fix(Fix::safe_edit(Edit::insertion( - comment.to_string(), - TextSize::new(0), - ))), + .with_fix(Fix::safe_edit(Edit::insertion(comment, TextSize::new(0)))), ) } } @@ -169,10 +166,7 @@ impl TestRule for StableTestRuleUnsafeFix { StableTestRuleUnsafeFix, ruff_text_size::TextRange::default(), ) - .with_fix(Fix::unsafe_edit(Edit::insertion( - comment.to_string(), - TextSize::new(0), - ))), + .with_fix(Fix::unsafe_edit(Edit::insertion(comment, TextSize::new(0)))), ) } } @@ -217,7 +211,7 @@ impl TestRule for StableTestRuleDisplayOnlyFix { ruff_text_size::TextRange::default(), ) .with_fix(Fix::display_only_edit(Edit::insertion( - comment.to_string(), + comment, TextSize::new(0), ))), ) diff --git a/crates/ruff_linter/src/rules/tryceratops/rules/verbose_raise.rs b/crates/ruff_linter/src/rules/tryceratops/rules/verbose_raise.rs index 4d975889d1df6..132459592c6bb 100644 --- a/crates/ruff_linter/src/rules/tryceratops/rules/verbose_raise.rs +++ b/crates/ruff_linter/src/rules/tryceratops/rules/verbose_raise.rs @@ -100,7 +100,7 @@ impl<'a> StatementVisitor<'a> for RaiseStatementVisitor<'a> { Stmt::Try(ast::StmtTry { body, finalbody, .. }) => { - for stmt in body.iter().chain(finalbody.iter()) { + for stmt in body.iter().chain(finalbody) { walk_stmt(self, stmt); } } diff --git a/crates/ruff_linter/src/settings/mod.rs b/crates/ruff_linter/src/settings/mod.rs index 06e6239bf0c0f..2bc98bc930a8f 100644 --- a/crates/ruff_linter/src/settings/mod.rs +++ b/crates/ruff_linter/src/settings/mod.rs @@ -2,13 +2,12 @@ //! command-line options. Structure is optimized for internal usage, as opposed //! to external visibility or parsing. -use std::fmt::{Display, Formatter}; -use std::path::{Path, PathBuf}; - -use once_cell::sync::Lazy; use path_absolutize::path_dedot; use regex::Regex; use rustc_hash::FxHashSet; +use std::fmt::{Display, Formatter}; +use std::path::{Path, PathBuf}; +use std::sync::LazyLock; use crate::codes::RuleCodePrefix; use ruff_macros::CacheKey; @@ -355,8 +354,8 @@ pub const DEFAULT_SELECTORS: &[RuleSelector] = &[ pub const TASK_TAGS: &[&str] = &["TODO", "FIXME", "XXX"]; -pub static DUMMY_VARIABLE_RGX: Lazy<Regex> = - Lazy::new(|| Regex::new("^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$").unwrap()); +pub static DUMMY_VARIABLE_RGX: LazyLock<Regex> = + LazyLock::new(|| Regex::new("^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$").unwrap()); impl LinterSettings { pub fn for_rule(rule_code: Rule) -> Self { diff --git a/crates/ruff_linter/src/source_kind.rs b/crates/ruff_linter/src/source_kind.rs index 6c70b58dc80fd..f067fe49dbb4c 100644 --- a/crates/ruff_linter/src/source_kind.rs +++ b/crates/ruff_linter/src/source_kind.rs @@ -135,9 +135,8 @@ impl std::fmt::Display for SourceKindDiff<'_> { } DiffKind::IpyNotebook(original, modified) => { // Cell indices are 1-based. - for ((idx, src_cell), dst_cell) in (1u32..) - .zip(original.cells().iter()) - .zip(modified.cells().iter()) + for ((idx, src_cell), dst_cell) in + (1u32..).zip(original.cells()).zip(modified.cells()) { let (Cell::Code(src_cell), Cell::Code(dst_cell)) = (src_cell, dst_cell) else { continue; diff --git a/crates/ruff_macros/src/config.rs b/crates/ruff_macros/src/config.rs index 6300f08c7c18e..0df56a2256426 100644 --- a/crates/ruff_macros/src/config.rs +++ b/crates/ruff_macros/src/config.rs @@ -60,15 +60,13 @@ pub(crate) fn derive_impl(input: DeriveInput) -> syn::Result<TokenStream> { } } - let docs: Vec<&Attribute> = struct_attributes + let docs = struct_attributes .iter() - .filter(|attr| attr.path().is_ident("doc")) - .collect(); + .filter(|attr| attr.path().is_ident("doc")); // Convert the list of `doc` attributes into a single string. let doc = dedent( &docs - .into_iter() .map(parse_doc) .collect::<syn::Result<Vec<_>>>()? .join("\n"), diff --git a/crates/ruff_notebook/Cargo.toml b/crates/ruff_notebook/Cargo.toml index 6de68f30415dd..c8273399edfaa 100644 --- a/crates/ruff_notebook/Cargo.toml +++ b/crates/ruff_notebook/Cargo.toml @@ -20,7 +20,6 @@ ruff_text_size = { workspace = true } anyhow = { workspace = true } itertools = { workspace = true } -once_cell = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } serde_with = { workspace = true, default-features = false, features = ["macros"] } diff --git a/crates/ruff_notebook/src/notebook.rs b/crates/ruff_notebook/src/notebook.rs index 9098227f7c2d4..3ec9108e0373d 100644 --- a/crates/ruff_notebook/src/notebook.rs +++ b/crates/ruff_notebook/src/notebook.rs @@ -1,15 +1,14 @@ +use itertools::Itertools; +use rand::{Rng, SeedableRng}; +use serde::Serialize; +use serde_json::error::Category; use std::cmp::Ordering; use std::collections::HashSet; use std::fs::File; use std::io::{BufReader, Cursor, Read, Seek, SeekFrom, Write}; use std::path::Path; +use std::sync::OnceLock; use std::{io, iter}; - -use itertools::Itertools; -use once_cell::sync::OnceCell; -use rand::{Rng, SeedableRng}; -use serde::Serialize; -use serde_json::error::Category; use thiserror::Error; use ruff_diagnostics::{SourceMap, SourceMarker}; @@ -63,7 +62,7 @@ pub struct Notebook { source_code: String, /// The index of the notebook. This is used to map between the concatenated /// source code and the original notebook. - index: OnceCell<NotebookIndex>, + index: OnceLock<NotebookIndex>, /// The raw notebook i.e., the deserialized version of JSON string. raw: RawNotebook, /// The offsets of each cell in the concatenated source code. This includes @@ -194,7 +193,7 @@ impl Notebook { Ok(Self { raw: raw_notebook, - index: OnceCell::new(), + index: OnceLock::new(), // The additional newline at the end is to maintain consistency for // all cells. These newlines will be removed before updating the // source code with the transformed content. Refer `update_cell_content`. diff --git a/crates/ruff_python_ast/Cargo.toml b/crates/ruff_python_ast/Cargo.toml index 401f56975cba9..497782a4eef59 100644 --- a/crates/ruff_python_ast/Cargo.toml +++ b/crates/ruff_python_ast/Cargo.toml @@ -23,7 +23,6 @@ aho-corasick = { workspace = true } bitflags = { workspace = true } is-macro = { workspace = true } itertools = { workspace = true } -once_cell = { workspace = true } rustc-hash = { workspace = true } schemars = { workspace = true, optional = true } serde = { workspace = true, optional = true } diff --git a/crates/ruff_python_ast/src/expression.rs b/crates/ruff_python_ast/src/expression.rs index a4a9c044ba08c..469f12b003af4 100644 --- a/crates/ruff_python_ast/src/expression.rs +++ b/crates/ruff_python_ast/src/expression.rs @@ -506,7 +506,7 @@ pub enum StringLikePart<'a> { FString(&'a ast::FString), } -impl StringLikePart<'_> { +impl<'a> StringLikePart<'a> { /// Returns the [`AnyStringFlags`] for the current string-like part. pub fn flags(&self) -> AnyStringFlags { match self { @@ -524,6 +524,21 @@ impl StringLikePart<'_> { self.end() - kind.closer_len(), ) } + + pub const fn is_string_literal(self) -> bool { + matches!(self, Self::String(_)) + } + + pub const fn as_string_literal(self) -> Option<&'a ast::StringLiteral> { + match self { + StringLikePart::String(value) => Some(value), + _ => None, + } + } + + pub const fn is_fstring(self) -> bool { + matches!(self, Self::FString(_)) + } } impl<'a> From<&'a ast::StringLiteral> for StringLikePart<'a> { @@ -567,6 +582,7 @@ impl Ranged for StringLikePart<'_> { /// An iterator over all the [`StringLikePart`] of a string-like expression. /// /// This is created by the [`StringLike::parts`] method. +#[derive(Clone)] pub enum StringLikePartIter<'a> { String(std::slice::Iter<'a, ast::StringLiteral>), Bytes(std::slice::Iter<'a, ast::BytesLiteral>), @@ -603,5 +619,25 @@ impl<'a> Iterator for StringLikePartIter<'a> { } } +impl DoubleEndedIterator for StringLikePartIter<'_> { + fn next_back(&mut self) -> Option<Self::Item> { + let part = match self { + StringLikePartIter::String(inner) => StringLikePart::String(inner.next_back()?), + StringLikePartIter::Bytes(inner) => StringLikePart::Bytes(inner.next_back()?), + StringLikePartIter::FString(inner) => { + let part = inner.next_back()?; + match part { + ast::FStringPart::Literal(string_literal) => { + StringLikePart::String(string_literal) + } + ast::FStringPart::FString(f_string) => StringLikePart::FString(f_string), + } + } + }; + + Some(part) + } +} + impl FusedIterator for StringLikePartIter<'_> {} impl ExactSizeIterator for StringLikePartIter<'_> {} diff --git a/crates/ruff_python_ast/src/node.rs b/crates/ruff_python_ast/src/node.rs index ca3bbf27f3382..0eb967e0c440a 100644 --- a/crates/ruff_python_ast/src/node.rs +++ b/crates/ruff_python_ast/src/node.rs @@ -2731,7 +2731,7 @@ impl AstNode for ast::ExprCompare { visitor.visit_expr(left); - for (op, comparator) in ops.iter().zip(&**comparators) { + for (op, comparator) in ops.iter().zip(comparators) { visitor.visit_cmp_op(op); visitor.visit_expr(comparator); } diff --git a/crates/ruff_python_ast/src/nodes.rs b/crates/ruff_python_ast/src/nodes.rs index c8d508d2734ba..a35868d6a0371 100644 --- a/crates/ruff_python_ast/src/nodes.rs +++ b/crates/ruff_python_ast/src/nodes.rs @@ -1296,7 +1296,7 @@ impl FStringValue { /// Returns an iterator over all the [`FStringPart`]s contained in this value /// that allows modification. - pub(crate) fn iter_mut(&mut self) -> IterMut<FStringPart> { + pub fn iter_mut(&mut self) -> IterMut<FStringPart> { self.as_mut_slice().iter_mut() } @@ -1749,7 +1749,7 @@ impl StringLiteralValue { /// Returns an iterator over all the [`StringLiteral`] parts contained in this value /// that allows modification. - pub(crate) fn iter_mut(&mut self) -> IterMut<StringLiteral> { + pub fn iter_mut(&mut self) -> IterMut<StringLiteral> { self.as_mut_slice().iter_mut() } @@ -2048,7 +2048,7 @@ impl PartialEq for ConcatenatedStringLiteral { // The `zip` here is safe because we have checked the length of both parts. self.strings .iter() - .zip(other.strings.iter()) + .zip(&other.strings) .all(|(s1, s2)| s1 == s2) } } @@ -2138,7 +2138,7 @@ impl BytesLiteralValue { /// Returns an iterator over all the [`BytesLiteral`] parts contained in this value /// that allows modification. - pub(crate) fn iter_mut(&mut self) -> IterMut<BytesLiteral> { + pub fn iter_mut(&mut self) -> IterMut<BytesLiteral> { self.as_mut_slice().iter_mut() } @@ -2971,6 +2971,42 @@ impl Operator { Operator::FloorDiv => "//", } } + + pub const fn dunder(self) -> &'static str { + match self { + Operator::Add => "__add__", + Operator::Sub => "__sub__", + Operator::Mult => "__mul__", + Operator::MatMult => "__matmul__", + Operator::Div => "__truediv__", + Operator::Mod => "__mod__", + Operator::Pow => "__pow__", + Operator::LShift => "__lshift__", + Operator::RShift => "__rshift__", + Operator::BitOr => "__or__", + Operator::BitXor => "__xor__", + Operator::BitAnd => "__and__", + Operator::FloorDiv => "__floordiv__", + } + } + + pub const fn reflected_dunder(self) -> &'static str { + match self { + Operator::Add => "__radd__", + Operator::Sub => "__rsub__", + Operator::Mult => "__rmul__", + Operator::MatMult => "__rmatmul__", + Operator::Div => "__rtruediv__", + Operator::Mod => "__rmod__", + Operator::Pow => "__rpow__", + Operator::LShift => "__rlshift__", + Operator::RShift => "__rrshift__", + Operator::BitOr => "__ror__", + Operator::BitXor => "__rxor__", + Operator::BitAnd => "__rand__", + Operator::FloorDiv => "__rfloordiv__", + } + } } impl fmt::Display for Operator { @@ -3035,6 +3071,22 @@ impl CmpOp { CmpOp::NotIn => "not in", } } + + #[must_use] + pub const fn negate(&self) -> Self { + match self { + CmpOp::Eq => CmpOp::NotEq, + CmpOp::NotEq => CmpOp::Eq, + CmpOp::Lt => CmpOp::GtE, + CmpOp::LtE => CmpOp::Gt, + CmpOp::Gt => CmpOp::LtE, + CmpOp::GtE => CmpOp::Lt, + CmpOp::Is => CmpOp::IsNot, + CmpOp::IsNot => CmpOp::Is, + CmpOp::In => CmpOp::NotIn, + CmpOp::NotIn => CmpOp::In, + } + } } impl fmt::Display for CmpOp { @@ -3624,6 +3676,14 @@ impl<'a> IntoIterator for &'a Parameters { } } +impl<'a> IntoIterator for &'a Box<Parameters> { + type IntoIter = ParametersIterator<'a>; + type Item = AnyParameterRef<'a>; + fn into_iter(self) -> Self::IntoIter { + (&**self).into_iter() + } +} + /// An alternative type of AST `arg`. This is used for each function argument that might have a default value. /// Used by `Arguments` original type. /// diff --git a/crates/ruff_python_ast/src/str.rs b/crates/ruff_python_ast/src/str.rs index 220df07857f70..13a08d2ccc793 100644 --- a/crates/ruff_python_ast/src/str.rs +++ b/crates/ruff_python_ast/src/str.rs @@ -1,7 +1,6 @@ -use std::fmt; - use aho_corasick::{AhoCorasick, AhoCorasickKind, Anchored, Input, MatchKind, StartKind}; -use once_cell::sync::Lazy; +use std::fmt; +use std::sync::LazyLock; use ruff_text_size::{TextLen, TextRange}; @@ -205,7 +204,7 @@ pub fn raw_contents_range(contents: &str) -> Option<TextRange> { } /// An [`AhoCorasick`] matcher for string and byte literal prefixes. -static PREFIX_MATCHER: Lazy<AhoCorasick> = Lazy::new(|| { +static PREFIX_MATCHER: LazyLock<AhoCorasick> = LazyLock::new(|| { AhoCorasick::builder() .start_kind(StartKind::Anchored) .match_kind(MatchKind::LeftmostLongest) diff --git a/crates/ruff_python_ast/src/traversal.rs b/crates/ruff_python_ast/src/traversal.rs index 1e050cfa94b1e..1803042e7775d 100644 --- a/crates/ruff_python_ast/src/traversal.rs +++ b/crates/ruff_python_ast/src/traversal.rs @@ -1,81 +1,81 @@ //! Utilities for manually traversing a Python AST. -use crate::{self as ast, ExceptHandler, Stmt, Suite}; +use crate::{self as ast, AnyNodeRef, ExceptHandler, Stmt}; -/// Given a [`Stmt`] and its parent, return the [`Suite`] that contains the [`Stmt`]. -pub fn suite<'a>(stmt: &'a Stmt, parent: &'a Stmt) -> Option<&'a Suite> { +/// Given a [`Stmt`] and its parent, return the [`ast::Suite`] that contains the [`Stmt`]. +pub fn suite<'a>(stmt: &'a Stmt, parent: &'a Stmt) -> Option<EnclosingSuite<'a>> { // TODO: refactor this to work without a parent, ie when `stmt` is at the top level match parent { - Stmt::FunctionDef(ast::StmtFunctionDef { body, .. }) => Some(body), - Stmt::ClassDef(ast::StmtClassDef { body, .. }) => Some(body), - Stmt::For(ast::StmtFor { body, orelse, .. }) => { - if body.contains(stmt) { - Some(body) - } else if orelse.contains(stmt) { - Some(orelse) - } else { - None - } - } - Stmt::While(ast::StmtWhile { body, orelse, .. }) => { - if body.contains(stmt) { - Some(body) - } else if orelse.contains(stmt) { - Some(orelse) - } else { - None - } - } + Stmt::FunctionDef(ast::StmtFunctionDef { body, .. }) => EnclosingSuite::new(body, stmt), + Stmt::ClassDef(ast::StmtClassDef { body, .. }) => EnclosingSuite::new(body, stmt), + Stmt::For(ast::StmtFor { body, orelse, .. }) => [body, orelse] + .iter() + .find_map(|suite| EnclosingSuite::new(suite, stmt)), + Stmt::While(ast::StmtWhile { body, orelse, .. }) => [body, orelse] + .iter() + .find_map(|suite| EnclosingSuite::new(suite, stmt)), Stmt::If(ast::StmtIf { body, elif_else_clauses, .. - }) => { - if body.contains(stmt) { - Some(body) - } else { - elif_else_clauses - .iter() - .map(|elif_else_clause| &elif_else_clause.body) - .find(|body| body.contains(stmt)) - } - } - Stmt::With(ast::StmtWith { body, .. }) => Some(body), + }) => [body] + .into_iter() + .chain(elif_else_clauses.iter().map(|clause| &clause.body)) + .find_map(|suite| EnclosingSuite::new(suite, stmt)), + Stmt::With(ast::StmtWith { body, .. }) => EnclosingSuite::new(body, stmt), Stmt::Match(ast::StmtMatch { cases, .. }) => cases .iter() .map(|case| &case.body) - .find(|body| body.contains(stmt)), + .find_map(|body| EnclosingSuite::new(body, stmt)), Stmt::Try(ast::StmtTry { body, handlers, orelse, finalbody, .. - }) => { - if body.contains(stmt) { - Some(body) - } else if orelse.contains(stmt) { - Some(orelse) - } else if finalbody.contains(stmt) { - Some(finalbody) - } else { + }) => [body, orelse, finalbody] + .into_iter() + .chain( handlers .iter() .filter_map(ExceptHandler::as_except_handler) - .map(|handler| &handler.body) - .find(|body| body.contains(stmt)) - } - } + .map(|handler| &handler.body), + ) + .find_map(|suite| EnclosingSuite::new(suite, stmt)), _ => None, } } -/// Given a [`Stmt`] and its containing [`Suite`], return the next [`Stmt`] in the [`Suite`]. -pub fn next_sibling<'a>(stmt: &'a Stmt, suite: &'a Suite) -> Option<&'a Stmt> { - let mut iter = suite.iter(); - while let Some(sibling) = iter.next() { - if sibling == stmt { - return iter.next(); - } +pub struct EnclosingSuite<'a> { + suite: &'a [Stmt], + position: usize, +} + +impl<'a> EnclosingSuite<'a> { + pub fn new(suite: &'a [Stmt], stmt: &'a Stmt) -> Option<Self> { + let position = suite + .iter() + .position(|sibling| AnyNodeRef::ptr_eq(sibling.into(), stmt.into()))?; + + Some(EnclosingSuite { suite, position }) + } + + pub fn next_sibling(&self) -> Option<&'a Stmt> { + self.suite.get(self.position + 1) + } + + pub fn next_siblings(&self) -> &'a [Stmt] { + self.suite.get(self.position + 1..).unwrap_or_default() + } + + pub fn previous_sibling(&self) -> Option<&'a Stmt> { + self.suite.get(self.position.checked_sub(1)?) + } +} + +impl std::ops::Deref for EnclosingSuite<'_> { + type Target = [Stmt]; + + fn deref(&self) -> &Self::Target { + self.suite } - None } diff --git a/crates/ruff_python_ast/src/visitor.rs b/crates/ruff_python_ast/src/visitor.rs index 687a4bcf14019..0f16af535461c 100644 --- a/crates/ruff_python_ast/src/visitor.rs +++ b/crates/ruff_python_ast/src/visitor.rs @@ -459,10 +459,10 @@ pub fn walk_expr<'a, V: Visitor<'a> + ?Sized>(visitor: &mut V, expr: &'a Expr) { range: _, }) => { visitor.visit_expr(left); - for cmp_op in &**ops { + for cmp_op in ops { visitor.visit_cmp_op(cmp_op); } - for expr in &**comparators { + for expr in comparators { visitor.visit_expr(expr); } } diff --git a/crates/ruff_python_codegen/Cargo.toml b/crates/ruff_python_codegen/Cargo.toml index cf273027bb79d..35dcbc30116d9 100644 --- a/crates/ruff_python_codegen/Cargo.toml +++ b/crates/ruff_python_codegen/Cargo.toml @@ -20,7 +20,6 @@ ruff_python_parser = { workspace = true } ruff_source_file = { workspace = true } ruff_text_size = { workspace = true } -once_cell = { workspace = true } [lints] workspace = true diff --git a/crates/ruff_python_codegen/src/generator.rs b/crates/ruff_python_codegen/src/generator.rs index 89d4702f8dd25..68b4ff606425d 100644 --- a/crates/ruff_python_codegen/src/generator.rs +++ b/crates/ruff_python_codegen/src/generator.rs @@ -1024,7 +1024,7 @@ impl<'a> Generator<'a> { group_if!(precedence::CMP, { let new_lvl = precedence::CMP + 1; self.unparse_expr(left, new_lvl); - for (op, cmp) in ops.iter().zip(&**comparators) { + for (op, cmp) in ops.iter().zip(comparators) { let op = match op { CmpOp::Eq => " == ", CmpOp::NotEq => " != ", diff --git a/crates/ruff_python_codegen/src/stylist.rs b/crates/ruff_python_codegen/src/stylist.rs index ea37492c2f0dd..b78417add2971 100644 --- a/crates/ruff_python_codegen/src/stylist.rs +++ b/crates/ruff_python_codegen/src/stylist.rs @@ -1,9 +1,8 @@ //! Detect code style from Python source code. +use std::cell::OnceCell; use std::ops::Deref; -use once_cell::unsync::OnceCell; - use ruff_python_ast::str::Quote; use ruff_python_parser::{Token, TokenKind, Tokens}; use ruff_source_file::{find_newline, LineEnding, Locator}; diff --git a/crates/ruff_python_formatter/Cargo.toml b/crates/ruff_python_formatter/Cargo.toml index befb78e31f4ff..3341f94027c70 100644 --- a/crates/ruff_python_formatter/Cargo.toml +++ b/crates/ruff_python_formatter/Cargo.toml @@ -28,7 +28,6 @@ clap = { workspace = true } countme = { workspace = true } itertools = { workspace = true } memchr = { workspace = true } -once_cell = { workspace = true } regex = { workspace = true } rustc-hash = { workspace = true } serde = { workspace = true, optional = true } diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/fstring.py b/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/fstring.py index 69f65c20c573a..3c895ee85aeb4 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/fstring.py +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/fstring.py @@ -94,6 +94,8 @@ "bbbbbbbbbbbbbbbbbbbbbbbbbbbbb" } ccccccccccccccc" x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa { # comment 9 "bbbbbbbbbbbbbbbbbbbbbbbbbbbbb" = } ccccccccccccccc" +x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa { # comment 9 + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbb' = } ccccccccccccccc" # Multiple larger expressions which exceeds the line length limit. Here, we need to decide # whether to split at the first or second expression. This should work similarly to the @@ -144,18 +146,37 @@ {'aaaaaaaaaaaaaaaaaaaaaaaaa', 'bbbbbbbbbbbbbbbbbbbbbbbbbbb', 'cccccccccccccccccccccccccc'} }" +############################################################################################# # Quotes +############################################################################################# f"foo 'bar' {x}" f"foo \"bar\" {x}" f'foo "bar" {x}' f'foo \'bar\' {x}' f"foo {"bar"}" -f"foo {'\'bar\''}" + +f"single quoted '{x}' double quoted \"{x}\"" # Same number of quotes => use preferred quote style +f"single quote ' {x} double quoted \"{x}\"" # More double quotes => use single quotes +f"single quoted '{x}' double quote \" {x}" # More single quotes => use double quotes + +fr"single quotes ' {x}" # Keep double because `'` can't be escaped +fr'double quotes " {x}' # Keep single because `"` can't be escaped +fr'flip quotes {x}' # Use preferred quotes, because raw string contains now quotes. # Here, the formatter will remove the escapes which is correct because they aren't allowed # pre 3.12. This means we can assume that the f-string is used in the context of 3.12. +f"foo {'\'bar\''}" f"foo {'\"bar\"'}" +# Quotes inside the expressions have no impact on the quote selection of the outer string. +# Required so that the following two examples result in the same formatting. +f'foo {10 + len("bar")}' +f"foo {10 + len('bar')}" + +# Pre 312, preserve the outer quotes if the f-string contains quotes in the debug expression +f'foo {10 + len("bar")=}' +f'''foo {10 + len('''bar''')=}''' +f'''foo {10 + len('bar')=}''' # Fine to change the quotes because it uses triple quotes # Triple-quoted strings # It's ok to use the same quote char for the inner string if it's single-quoted. @@ -164,6 +185,16 @@ # But if the inner string is also triple-quoted then we should preserve the existing quotes. f"""test {'''inner'''}""" +# It's not okay to change the quote style if the inner string is triple quoted and contains a quote. +f'{"""other " """}' +f'{"""other " """ + "more"}' +f'{b"""other " """}' +f'{f"""other " """}' + +# Not valid Pre 3.12 +f"""test {f'inner {'''inner inner'''}'}""" +f"""test {f'''inner {"""inner inner"""}'''}""" + # Magic trailing comma # # The expression formatting will result in breaking it across multiple lines with a @@ -312,6 +343,6 @@ # Implicit concatenated f-string containing quotes _ = ( 'This string should change its quotes to double quotes' - f'This string uses double quotes in an expression {"woah"}' + f'This string uses double quotes in an expression {"it's a quote"}' f'This f-string does not use any quotes.' ) diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/fstring_py312.py b/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/fstring_py312.py index 8ce24fa363574..bd1d755f2d5fb 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/fstring_py312.py +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/fstring_py312.py @@ -4,3 +4,14 @@ # Quotes reuse f"{'a'}" + +# 312+, it's okay to change the outer quotes even when there's a debug expression using the same quotes +f'foo {10 + len("bar")=}' +f'''foo {10 + len("""bar""")=}''' + +# 312+, it's okay to change the quotes here without creating an invalid f-string +f'{"""other " """}' +f'{"""other " """ + "more"}' +f'{b"""other " """}' +f'{f"""other " """}' + diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/join_implicit_concatenated_string.options.json b/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/join_implicit_concatenated_string.options.json new file mode 100644 index 0000000000000..8925dd0a8280f --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/join_implicit_concatenated_string.options.json @@ -0,0 +1,5 @@ +[ + { + "preview": "enabled" + } +] diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/join_implicit_concatenated_string.py b/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/join_implicit_concatenated_string.py new file mode 100644 index 0000000000000..6dccb377d898c --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/join_implicit_concatenated_string.py @@ -0,0 +1,321 @@ +"aaaaaaaaa" "bbbbbbbbbbbbbbbbbbbb" # Join + +( + "aaaaaaaaaaa" "bbbbbbbbbbbbbbbb" +) # join + + +( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +) # too long to join + + +"different '" 'quote "are fine"' # join + +# More single quotes +"one single'" "two 'single'" ' two "double"' + +# More double quotes +'one double"' 'two "double"' " two 'single'" + +# Equal number of single and double quotes +'two "double"' " two 'single'" + +f"{'Hy \"User\"'}" 'more' + +b"aaaaaaaaa" b"bbbbbbbbbbbbbbbbbbbb" # Join + +( + b"aaaaaaaaaaa" b"bbbbbbbbbbbbbbbb" +) # join + + +( + b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" b"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +) # too long to join + + +# Skip joining if there is a trailing comment +( + "fffffffffffff" + "bbbbbbbbbbbbb" # comment + "cccccccccccccc" +) + +# Skip joining if there is a leading comment +( + "fffffffffffff" + # comment + "bbbbbbbbbbbbb" + "cccccccccccccc" +) + + +############################################################################## +# F-strings +############################################################################## + +# Escape `{` and `}` when marging an f-string with a string +"a {not_a_variable}" f"b {10}" "c" + +# Join, and break expressions +f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa{ +expression +}bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" f"cccccccccccccccccccc {20999}" "more" + +# Join, but don't break the expressions +f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa{expression}bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" f"cccccccccccccccccccc {20999}" "more" + +f"test{ +expression +}flat" f"can be { +joined +} together" + +aaaaaaaaaaa = f"test{ +expression +}flat" f"cean beeeeeeee { +joined +} eeeeeeeeeeeeeeeeeeeeeeeeeeeee" # inline + + +f"single quoted '{x}'" f'double quoted "{x}"' # Same number of quotes => use preferred quote style +f"single quote ' {x}" f'double quoted "{x}"' # More double quotes => use single quotes +f"single quoted '{x}'" f'double quote " {x}"' # More single quotes => use double quotes + +# Different triple quoted strings +f"{'''test'''}" f'{"""other"""}' + +# Now with inner quotes +f"{'''test ' '''}" f'{"""other " """}' +f"{some_where_nested('''test ' ''')}" f'{"""other " """ + "more"}' +f"{b'''test ' '''}" f'{b"""other " """}' +f"{f'''test ' '''}" f'{f"""other " """}' + +# debug expressions containing quotes +f"{10 + len('bar')=}" f"{10 + len('bar')=}" +f"{10 + len('bar')=}" f'no debug{10}' f"{10 + len('bar')=}" + +# We can't savely merge this pre Python 3.12 without altering the debug expression. +f"{10 + len('bar')=}" f'{10 + len("bar")=}' + + +############################################################################## +# Don't join raw strings +############################################################################## + +r"a" "normal" +R"a" "normal" + +f"test" fr"test" +f"test" fR"test" + + +############################################################################## +# Don't join triple quoted strings +############################################################################## + +"single" """triple""" + +"single" f""""single""" + +b"single" b"""triple""" + + +############################################################################## +# Join strings in with statements +############################################################################## + +# Fits +with "aa" "bbb" "cccccccccccccccccccccccccccccccccccccccccccccc": + pass + +# Parenthesize single-line +with "aa" "bbb" "ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc": + pass + +# Multiline +with "aa" "bbb" "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc": + pass + +with f"aaaaaaa{expression}bbbb" f"ccc {20999}" "more": + pass + + +############################################################################## +# For loops +############################################################################## + +# Flat +for a in "aaaaaaaaa" "bbbbbbbbb" "ccccccccc" "dddddddddd": + pass + +# Parenthesize single-line +for a in "aaaaaaaaa" "bbbbbbbbb" "ccccccccc" "dddddddddd" "eeeeeeeeeeeeeee" "fffffffffffff" "ggggggggggggggg" "hh": + pass + +# Multiline +for a in "aaaaaaaaa" "bbbbbbbbb" "ccccccccc" "dddddddddd" "eeeeeeeeeeeeeee" "fffffffffffff" "ggggggggggggggg" "hhhh": + pass + +############################################################################## +# Assert statement +############################################################################## + +# Fits +assert "aaaaaaaaa" "bbbbbbbbbbbb", "cccccccccccccccc" "dddddddddddddddd" + +# Wrap right +assert "aaaaaaaaa" "bbbbbbbbbbbb", "cccccccccccccccc" "dddddddddddddddd" "eeeeeeeeeeeee" "fffffffffff" + +# Right multiline +assert "aaaaaaaaa" "bbbbbbbbbbbb", "cccccccccccccccc" "dddddddddddddddd" "eeeeeeeeeeeee" "fffffffffffffff" "ggggggggggggg" "hhhhhhhhhhh" + +# Wrap left +assert "aaaaaaaaa" "bbbbbbbbbbbb" "cccccccccccccccc" "dddddddddddddddd" "eeeeeeeeeeeee" "fffffffffffffff", "ggggggggggggg" "hhhhhhhhhhh" + +# Left multiline +assert "aaaaaaaaa" "bbbbbbbbbbbb" "cccccccccccccccc" "dddddddddddddddd" "eeeeeeeeeeeee" "fffffffffffffff" "ggggggggggggg", "hhhhhhhhhhh" + +# wrap both +assert "aaaaaaaaa" "bbbbbbbbbbbb" "cccccccccccccccc" "dddddddddddddddd" "eeeeeeeeeeeee" "fffffffffffffff", "ggggggggggggg" "hhhhhhhhhhh" "iiiiiiiiiiiiiiiiii" "jjjjjjjjjjjjj" "kkkkkkkkkkkkkkkkk" "llllllllllll" + +# both multiline +assert "aaaaaaaaa" "bbbbbbbbbbbb" "cccccccccccccccc" "dddddddddddddddd" "eeeeeeeeeeeee" "fffffffffffffff" "ggggggggggggg", "hhhhhhhhhhh" "iiiiiiiiiiiiiiiiii" "jjjjjjjjjjjjj" "kkkkkkkkkkkkkkkkk" "llllllllllll" "mmmmmmmmmmmmmm" + + +############################################################################## +# In clause headers (can_omit_optional_parentheses) +############################################################################## +# Use can_omit_optional_parentheses layout to avoid an instability where the formatter +# picks the can_omit_optional_parentheses layout when the strings are joined. +if ( + f"implicit" + "concatenated" + "string" + f"implicit" + "concaddddddddddded" + "ring" + * len([aaaaaa, bbbbbbbbbbbbbbbb, cccccccccccccccccc, ddddddddddddddddddddddddddd]) +): + pass + +# Keep parenthesizing multiline - implicit concatenated strings +if ( + f"implicit" + """concatenate + d""" + "string" + f"implicit" + "concaddddddddddded" + "ring" + * len([aaaaaa, bbbbbbbbbbbbbbbb, cccccccccccccccccc, ddddddddddddddddddddddddddd]) +): + pass + +if ( + [ + aaaaaa, + bbbbbbbbbbbbbbbb, + cccccccccccccccccc, + ddddddddddddddddddddddddddd, + ] + + "implicitconcat" + "enatedstriiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiing" +): + pass + + +# In match statements +match x: + case "implicitconcat" "enatedstring" | [ + aaaaaa, + bbbbbbbbbbbbbbbb, + cccccccccccccccccc, + ddddddddddddddddddddddddddd, + ]: + pass + + case [ + aaaaaa, + bbbbbbbbbbbbbbbb, + cccccccccccccccccc, + ddddddddddddddddddddddddddd, + ] | "implicitconcat" "enatedstring" : + pass + + case "implicitconcat" "enatedstriiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiing" | [ + aaaaaa, + bbbbbbbbbbbbbbbb, + cccccccccccccccccc, + ddddddddddddddddddddddddddd, + ]: + pass + + +############################################################################## +# In docstring positions +############################################################################## + +def short_docstring(): + "Implicit" "concatenated" "docstring" + +def long_docstring(): + "Loooooooooooooooooooooong" "doooooooooooooooooooocstriiiiiiiiiiiiiiiiiiiiiiiiiiiiiiing" "exceding the line width" "but it should be concatenated anyways because it is single line" + +def docstring_with_leading_whitespace(): + " This is a " "implicit" "concatenated" "docstring" + +def docstring_with_trailing_whitespace(): + "This is a " "implicit" "concatenated" "docstring " + +def docstring_with_leading_empty_parts(): + " " " " "" "This is a " "implicit" "concatenated" "docstring" + +def docstring_with_trailing_empty_parts(): + "This is a " "implicit" "concatenated" "docstring" "" " " " " + +def all_empty(): + " " " " " " + +def byte_string_in_docstring_position(): + b" don't trim the" b"bytes literal " + +def f_string_in_docstring_position(): + f" don't trim the" "f-string literal " + +def single_quoted(): + ' content\ ' ' ' + return + +def implicit_with_comment(): + ( + "a" + # leading + "the comment above" + ) + +############################################################################## +# Regressions +############################################################################## + +LEEEEEEEEEEEEEEEEEEEEEEFT = RRRRRRRRIIIIIIIIIIIIGGGGGHHHT | { + "entityNameeeeeeeeeeeeeeeeee", # comment must be long enough to + "some long implicit concatenated string" "that should join" +} + +# Ensure that flipping between Multiline and BestFit layout results in stable formatting +# when using IfBreaksParenthesized layout. +assert False, "Implicit concatenated string" "uses {} layout on {} format".format( + "Multiline", "first" +) + +assert False, await "Implicit concatenated string" "uses {} layout on {} format".format( + "Multiline", "first" +) + +assert False, "Implicit concatenated stringuses {} layout on {} format"[ + aaaaaaaaa, bbbbbb +] + +assert False, +"Implicit concatenated string" "uses {} layout on {} format".format( + "Multiline", "first" +) diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/join_implicit_concatenated_string_assignment.options.json b/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/join_implicit_concatenated_string_assignment.options.json new file mode 100644 index 0000000000000..8925dd0a8280f --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/join_implicit_concatenated_string_assignment.options.json @@ -0,0 +1,5 @@ +[ + { + "preview": "enabled" + } +] diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/join_implicit_concatenated_string_assignment.py b/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/join_implicit_concatenated_string_assignment.py new file mode 100644 index 0000000000000..2cd23efdc254d --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/join_implicit_concatenated_string_assignment.py @@ -0,0 +1,293 @@ +## Implicit concatenated strings with a trailing comment but a non splittable target. + +# Don't join the string because the joined string with the inlined comment exceeds the line length limit. +____aaa = ( + "aaaaaaaaaaaaaaaaaaaaa" "aaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbvvvvvvvvvvvvvvv" +) # c + +# This is the same string as above and should lead to the same formatting. The only difference is that we start +# with an unparenthesized string. +____aaa = "aaaaaaaaaaaaaaaaaaaaa" "aaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbvvvvvvvvvvvvvvv" # c + +# Again the same string as above but this time as non-implicit concatenated string. +# It's okay if the formatting differs because it's an explicit choice to use implicit concatenation. +____aaa = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbvvvvvvvvvvvvvvv" # c + +# Join the string because it's exactly in the line length limit when the comment is inlined. +____aaa = ( + "aaaaaaaaaaaaaaaaaaa" "aaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbvvvvvvvvvvv" +) # c + +# This is the same string as above and should lead to the same formatting. The only difference is that we start +# with an unparenthesized string. +____aaa = "aaaaaaaaaaaaaaaaaaa" "aaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbvvvvvvvvvvv" # c + +# Again the same string as above but as a non-implicit concatenated string. It should result in the same formatting +# (for consistency). +____aaa = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbvvvvvvvvvvv" # c + +# It should collapse the parentheses if the joined string and the comment fit on the same line. +# This is required for stability. +____aaa = ( + "aaaaaaaaaaaaaaaaaaaaa" "aaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbvv" # c +) + + +############################################################# +# Assignments where the target or annotations are splittable +############################################################# + + +# The target splits because of a magic trailing comma +# The string is joined and not parenthesized because it just fits into the line length (including comment). +a[ + aaaaaaa, + b, +] = "ccccccccccccccccccccccccccccc" "cccccccccccccccccccccccccccccccccccccccccc" # comment + +# Same but starting with a joined string. They should both result in the same formatting. +[ + aaaaaaa, + b, +] = "ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" # comment + +# The target splits because of the magic trailing comma +# The string is **not** joined because it with the inlined comment exceeds the line length limit. +a[ + aaaaaaa, + b, +] = "ccccccccccccccccccccccccccccc" "ccccccccccccccccccccccccccccccccccccccccccc" # comment + + +# The target should be flat +# The string should be joined because it fits into the line length +a[ + aaaaaaa, + b +] = ( + "ccccccccccccccccccccccccccccccccccc" "cccccccccccccccccccccccc" # comment +) + +# Same but starting with a joined string. They should both result in the same formatting. +a[ + aaaaaaa, + b +] = "ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" # comment + +# The target should be flat +# The string gets parenthesized because it, with the inlined comment, exceeds the line length limit. +a[ + aaaaaaa, + b +] = "ccccccccccccccccccccccccccccc" "ccccccccccccccccccccccccccccccccccccccccccc" # comment + + +# Split an overlong target, but join the string if it fits +a[ + aaaaaaa, + b +].bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb = ( + "ccccccccccccccccccccccccccccccccccccccccc" "cccccccccccccccccccccccccccccc" # comment +) + +# Split both if necessary and keep multiline +a[ + aaaaaaa, + b +].bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb = ( + "ccccccccccccccccccccccccccccccccccccccccc" "ccccccccccccccccccccccccccccccc" # comment +) + +######################################################### +# Leading or trailing own line comments: +# Preserve the parentheses +######################################################## +a[ + aaaaaaa, + b +] = ( + # test + "ccccccccccccccccccccccccccccc" "ccccccccccccccccccccccccccccccccccccccccccc" +) + +a[ + aaaaaaa, + b +] = ( + "ccccccccccccccccccccccccccccc" "ccccccccccccccccccccccccccccccccccccccccccc" + # test +) + +a[ + aaaaaaa, + b +] = ( + "ccccccccccccccccccccccccccccccccccccccccc" "ccccccccccccccccccccccccccccccccccccccccccc" + # test +) + + +############################################################# +# Type alias statements +############################################################# + +# First break the right, join the string +type A[str, int, number] = "Literal[string, int] | None | " "CustomType" "| OtherCustomTypeExcee" # comment + +# Keep multiline if overlong +type A[str, int, number] = "Literal[string, int] | None | " "CustomTypeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" # comment + +# Break the left if it is over-long, join the string +type Aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa[stringgggggggggg, inttttttttttttttttttttttt, number] = "Literal[string, int] | None | " "CustomType" # comment + +# Break both if necessary and keep multiline +type Aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa[stringgggggggggg, inttttttttttttttttttttttt, number] = "Literal[string, int] | None | " "CustomTypeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" # comment + + +############################################################# +# F-Strings +############################################################# + +# Flatten and join the f-string +aaaaaaaaaaa = f"test{ +expression}flat" f"cean beeeeeeee {joined} eeeeeeeeeeeeeeeee" # inline + +# Parenthesize the value and join it, inline the comment +aaaaaaaaaaa = f"test{ +expression}flat" f"cean beeeeeeee {joined} eeeeeeeeeeeeeeeeeeeeeeeeeee" # inline + +# Parenthesize the f-string and keep it multiline because it doesn't fit on a single line including the comment +aaaaaaaaaaa = f"test{ +expression +}flat" f"cean beeeeeeee { +joined +} eeeeeeeeeeeeeeeeeeeeeeeeeeeee" # inline + + +# The target splits because of a magic trailing comma +# The string is joined and not parenthesized because it just fits into the line length (including comment). +a[ + aaaaaaa, + b, +] = f"ccccc{ +expression}ccccccccccc" f"cccccccccccccccccccccccccccccccccccccccccc" # comment + + +# Same but starting with a joined string. They should both result in the same formatting. +[ + aaaaaaa, + b, +] = f"ccccc{ +expression}ccccccccccccccccccccccccccccccccccccccccccccccccccccc" # comment + +# The target splits because of the magic trailing comma +# The string is **not** joined because it with the inlined comment exceeds the line length limit. +a[ + aaaaaaa, + b, +] = f"ccccc{ +expression}cccccccccccccccccccc" f"cccccccccccccccccccccccccccccccccccccccccc" # comment + + +# The target should be flat +# The string should be joined because it fits into the line length +a[ + aaaaaaa, + b +] = ( + f"ccccc{ + expression}ccccccccccc" "cccccccccccccccccccccccc" # comment +) + +# Same but starting with a joined string. They should both result in the same formatting. +a[ + aaaaaaa, + b +] = f"ccccc{ +expression}ccccccccccccccccccccccccccccccccccc" # comment + +# The target should be flat +# The string gets parenthesized because it, with the inlined comment, exceeds the line length limit. +a[ + aaaaaaa, + b +] = f"ccccc{ +expression}ccccccccccc" "ccccccccccccccccccccccccccccccccccccccccccc" # comment + + +# Split an overlong target, but join the string if it fits +a[ + aaaaaaa, + b +].bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb = ( + f"ccccc{ + expression}ccccccccccc" "cccccccccccccccccccccccccccccc" # comment +) + +# Split both if necessary and keep multiline +a[ + aaaaaaa, + b +].bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb = ( + f"ccccc{ + expression}cccccccccccccccccccccccccccccccc" "ccccccccccccccccccccccccccccccc" # comment +) + +# Don't inline f-strings that contain expressions that are guaranteed to split, e.b. because of a magic trailing comma +aaaaaaaaaaaaaaaaaa = f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{ +[a,] +}" "moreeeeeeeeeeeeeeeeeeee" "test" # comment + +aaaaaaaaaaaaaaaaaa = ( + f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{ +[a,] +}" "moreeeeeeeeeeeeeeeeeeee" "test" # comment +) + +aaaaa[aaaaaaaaaaa] = f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{ +[a,] +}" "moreeeeeeeeeeeeeeeeeeee" "test" # comment + +aaaaa[aaaaaaaaaaa] = (f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{ +[a,] +}" "moreeeeeeeeeeeeeeeeeeee" "test" # comment +) + +# Don't inline f-strings that contain commented expressions +aaaaaaaaaaaaaaaaaa = ( + f"testeeeeeeeeeeeeeeeeeeeeeeeee{[ + a # comment + ]}" "moreeeeeeeeeeeeeeeeeetest" # comment +) + +aaaaa[aaaaaaaaaaa] = ( + f"testeeeeeeeeeeeeeeeeeeeeeeeee{[ + a # comment + ]}" "moreeeeeeeeeeeeeeeeeetest" # comment +) + +# Don't inline f-strings with multiline debug expressions: +aaaaaaaaaaaaaaaaaa = ( + f"testeeeeeeeeeeeeeeeeeeeeeeeee{ + a=}" "moreeeeeeeeeeeeeeeeeetest" # comment +) + +aaaaaaaaaaaaaaaaaa = ( + f"testeeeeeeeeeeeeeeeeeeeeeeeee{a + + b=}" "moreeeeeeeeeeeeeeeeeetest" # comment +) + +aaaaaaaaaaaaaaaaaa = ( + f"testeeeeeeeeeeeeeeeeeeeeeeeee{a + =}" "moreeeeeeeeeeeeeeeeeetest" # comment +) + +aaaaa[aaaaaaaaaaa] = ( + f"testeeeeeeeeeeeeeeeeeeeeeeeee{ + a=}" "moreeeeeeeeeeeeeeeeeetest" # comment +) + +aaaaa[aaaaaaaaaaa] = ( + f"testeeeeeeeeeeeeeeeeeeeeeeeee{a + =}" "moreeeeeeeeeeeeeeeeeetest" # comment +) diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/join_implicit_concatenated_string_preserve.options.json b/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/join_implicit_concatenated_string_preserve.options.json new file mode 100644 index 0000000000000..b7d2d58057d95 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/join_implicit_concatenated_string_preserve.options.json @@ -0,0 +1,11 @@ +[ + { + "quote_style": "preserve", + "preview": "enabled" + }, + { + "quote_style": "preserve", + "preview": "enabled", + "target_version": "py312" + } +] diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/join_implicit_concatenated_string_preserve.py b/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/join_implicit_concatenated_string_preserve.py new file mode 100644 index 0000000000000..2492be47039e7 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/join_implicit_concatenated_string_preserve.py @@ -0,0 +1,13 @@ +a = "different '" 'quote "are fine"' # join + +# More single quotes +"one single'" "two 'single'" ' two "double"' + +# More double quotes +'one double"' 'two "double"' " two 'single'" + +# Equal number of single and double quotes +'two "double"' " two 'single'" + +# Already invalid Pre Python 312 +f"{'Hy "User"'}" f'{"Hy 'User'"}' diff --git a/crates/ruff_python_formatter/src/comments/mod.rs b/crates/ruff_python_formatter/src/comments/mod.rs index 34808e7867a73..c2db89159bafa 100644 --- a/crates/ruff_python_formatter/src/comments/mod.rs +++ b/crates/ruff_python_formatter/src/comments/mod.rs @@ -431,6 +431,41 @@ impl<'a> Comments<'a> { pub(crate) fn debug(&'a self, source_code: SourceCode<'a>) -> DebugComments<'a> { DebugComments::new(&self.data.comments, source_code) } + + /// Returns true if the node itself or any of its descendants have comments. + pub(crate) fn contains_comments(&self, node: AnyNodeRef) -> bool { + use ruff_python_ast::visitor::source_order::{SourceOrderVisitor, TraversalSignal}; + + struct Visitor<'a> { + comments: &'a Comments<'a>, + has_comment: bool, + } + + impl<'a> SourceOrderVisitor<'a> for Visitor<'a> { + fn enter_node(&mut self, node: AnyNodeRef<'a>) -> TraversalSignal { + if self.has_comment { + TraversalSignal::Skip + } else if self.comments.has(node) { + self.has_comment = true; + TraversalSignal::Skip + } else { + TraversalSignal::Traverse + } + } + } + + if self.has(node) { + return true; + } + + let mut visitor = Visitor { + comments: self, + has_comment: false, + }; + node.visit_preorder(&mut visitor); + + visitor.has_comment + } } pub(crate) type LeadingDanglingTrailingComments<'a> = LeadingDanglingTrailing<'a, SourceComment>; diff --git a/crates/ruff_python_formatter/src/context.rs b/crates/ruff_python_formatter/src/context.rs index 32169ccf7dc92..b449b95eca692 100644 --- a/crates/ruff_python_formatter/src/context.rs +++ b/crates/ruff_python_formatter/src/context.rs @@ -8,7 +8,6 @@ use ruff_source_file::Locator; use std::fmt::{Debug, Formatter}; use std::ops::{Deref, DerefMut}; -#[derive(Clone)] pub struct PyFormatContext<'a> { options: PyFormatOptions, contents: &'a str, @@ -52,7 +51,6 @@ impl<'a> PyFormatContext<'a> { self.contents } - #[allow(unused)] pub(crate) fn locator(&self) -> Locator<'a> { Locator::new(self.contents) } diff --git a/crates/ruff_python_formatter/src/expression/binary_like.rs b/crates/ruff_python_formatter/src/expression/binary_like.rs index 46b5e15bc5e2f..be6f3ec22d212 100644 --- a/crates/ruff_python_formatter/src/expression/binary_like.rs +++ b/crates/ruff_python_formatter/src/expression/binary_like.rs @@ -20,7 +20,7 @@ use crate::expression::parentheses::{ }; use crate::expression::OperatorPrecedence; use crate::prelude::*; -use crate::string::FormatImplicitConcatenatedString; +use crate::string::implicit::FormatImplicitConcatenatedString; #[derive(Copy, Clone, Debug)] pub(super) enum BinaryLike<'a> { diff --git a/crates/ruff_python_formatter/src/expression/expr_bytes_literal.rs b/crates/ruff_python_formatter/src/expression/expr_bytes_literal.rs index 7b6837b655080..92d2af2d1f952 100644 --- a/crates/ruff_python_formatter/src/expression/expr_bytes_literal.rs +++ b/crates/ruff_python_formatter/src/expression/expr_bytes_literal.rs @@ -5,7 +5,8 @@ use crate::expression::parentheses::{ in_parentheses_only_group, NeedsParentheses, OptionalParentheses, }; use crate::prelude::*; -use crate::string::{FormatImplicitConcatenatedString, StringLikeExtensions}; +use crate::string::implicit::FormatImplicitConcatenatedStringFlat; +use crate::string::{implicit::FormatImplicitConcatenatedString, StringLikeExtensions}; #[derive(Default)] pub struct FormatExprBytesLiteral; @@ -14,9 +15,19 @@ impl FormatNodeRule<ExprBytesLiteral> for FormatExprBytesLiteral { fn fmt_fields(&self, item: &ExprBytesLiteral, f: &mut PyFormatter) -> FormatResult<()> { let ExprBytesLiteral { value, .. } = item; - match value.as_slice() { - [bytes_literal] => bytes_literal.format().fmt(f), - _ => in_parentheses_only_group(&FormatImplicitConcatenatedString::new(item)).fmt(f), + if let [bytes_literal] = value.as_slice() { + bytes_literal.format().fmt(f) + } else { + // Always join byte literals that aren't parenthesized and thus, always on a single line. + if !f.context().node_level().is_parenthesized() { + if let Some(format_flat) = + FormatImplicitConcatenatedStringFlat::new(item.into(), f.context()) + { + return format_flat.fmt(f); + } + } + + in_parentheses_only_group(&FormatImplicitConcatenatedString::new(item)).fmt(f) } } } diff --git a/crates/ruff_python_formatter/src/expression/expr_f_string.rs b/crates/ruff_python_formatter/src/expression/expr_f_string.rs index e88638d7c26fe..a4b0325d7976a 100644 --- a/crates/ruff_python_formatter/src/expression/expr_f_string.rs +++ b/crates/ruff_python_formatter/src/expression/expr_f_string.rs @@ -7,7 +7,8 @@ use crate::expression::parentheses::{ }; use crate::other::f_string_part::FormatFStringPart; use crate::prelude::*; -use crate::string::{FormatImplicitConcatenatedString, Quoting, StringLikeExtensions}; +use crate::string::implicit::FormatImplicitConcatenatedStringFlat; +use crate::string::{implicit::FormatImplicitConcatenatedString, Quoting, StringLikeExtensions}; #[derive(Default)] pub struct FormatExprFString; @@ -16,13 +17,23 @@ impl FormatNodeRule<ExprFString> for FormatExprFString { fn fmt_fields(&self, item: &ExprFString, f: &mut PyFormatter) -> FormatResult<()> { let ExprFString { value, .. } = item; - match value.as_slice() { - [f_string_part] => FormatFStringPart::new( + if let [f_string_part] = value.as_slice() { + FormatFStringPart::new( f_string_part, f_string_quoting(item, &f.context().locator()), ) - .fmt(f), - _ => in_parentheses_only_group(&FormatImplicitConcatenatedString::new(item)).fmt(f), + .fmt(f) + } else { + // Always join fstrings that aren't parenthesized and thus, are always on a single line. + if !f.context().node_level().is_parenthesized() { + if let Some(format_flat) = + FormatImplicitConcatenatedStringFlat::new(item.into(), f.context()) + { + return format_flat.fmt(f); + } + } + + in_parentheses_only_group(&FormatImplicitConcatenatedString::new(item)).fmt(f) } } } @@ -35,6 +46,7 @@ impl NeedsParentheses for ExprFString { ) -> OptionalParentheses { if self.value.is_implicit_concatenated() { OptionalParentheses::Multiline + } // TODO(dhruvmanila): Ideally what we want here is a new variant which // is something like: // - If the expression fits by just adding the parentheses, then add them and @@ -53,7 +65,7 @@ impl NeedsParentheses for ExprFString { // ``` // This isn't decided yet, refer to the relevant discussion: // https://github.com/astral-sh/ruff/discussions/9785 - } else if StringLike::FString(self).is_multiline(context.source()) { + else if StringLike::FString(self).is_multiline(context.source()) { OptionalParentheses::Never } else { OptionalParentheses::BestFit diff --git a/crates/ruff_python_formatter/src/expression/expr_string_literal.rs b/crates/ruff_python_formatter/src/expression/expr_string_literal.rs index 2ee661f76db60..9813958de52cf 100644 --- a/crates/ruff_python_formatter/src/expression/expr_string_literal.rs +++ b/crates/ruff_python_formatter/src/expression/expr_string_literal.rs @@ -6,7 +6,8 @@ use crate::expression::parentheses::{ }; use crate::other::string_literal::StringLiteralKind; use crate::prelude::*; -use crate::string::{FormatImplicitConcatenatedString, StringLikeExtensions}; +use crate::string::implicit::FormatImplicitConcatenatedStringFlat; +use crate::string::{implicit::FormatImplicitConcatenatedString, StringLikeExtensions}; #[derive(Default)] pub struct FormatExprStringLiteral { @@ -26,16 +27,20 @@ impl FormatNodeRule<ExprStringLiteral> for FormatExprStringLiteral { fn fmt_fields(&self, item: &ExprStringLiteral, f: &mut PyFormatter) -> FormatResult<()> { let ExprStringLiteral { value, .. } = item; - match value.as_slice() { - [string_literal] => string_literal.format().with_options(self.kind).fmt(f), - _ => { - // This is just a sanity check because [`DocstringStmt::try_from_statement`] - // ensures that the docstring is a *single* string literal. - assert!(!self.kind.is_docstring()); - - in_parentheses_only_group(&FormatImplicitConcatenatedString::new(item)) + if let [string_literal] = value.as_slice() { + string_literal.format().with_options(self.kind).fmt(f) + } else { + // Always join strings that aren't parenthesized and thus, always on a single line. + if !f.context().node_level().is_parenthesized() { + if let Some(mut format_flat) = + FormatImplicitConcatenatedStringFlat::new(item.into(), f.context()) + { + format_flat.set_docstring(self.kind.is_docstring()); + return format_flat.fmt(f); + } } - .fmt(f), + + in_parentheses_only_group(&FormatImplicitConcatenatedString::new(item)).fmt(f) } } } diff --git a/crates/ruff_python_formatter/src/expression/mod.rs b/crates/ruff_python_formatter/src/expression/mod.rs index 1cc060ec11423..d908ee2ae1ffc 100644 --- a/crates/ruff_python_formatter/src/expression/mod.rs +++ b/crates/ruff_python_formatter/src/expression/mod.rs @@ -21,7 +21,7 @@ use crate::expression::parentheses::{ use crate::prelude::*; use crate::preview::{ is_empty_parameters_no_unnecessary_parentheses_around_return_value_enabled, - is_hug_parens_with_braces_and_square_brackets_enabled, + is_f_string_formatting_enabled, is_hug_parens_with_braces_and_square_brackets_enabled, }; mod binary_like; @@ -405,38 +405,39 @@ impl Format<PyFormatContext<'_>> for MaybeParenthesizeExpression<'_> { needs_parentheses => needs_parentheses, }; + let unparenthesized = expression.format().with_options(Parentheses::Never); + match needs_parentheses { OptionalParentheses::Multiline => match parenthesize { - Parenthesize::IfBreaksParenthesized | Parenthesize::IfBreaksParenthesizedNested if !is_empty_parameters_no_unnecessary_parentheses_around_return_value_enabled(f.context()) => { - parenthesize_if_expands(&expression.format().with_options(Parentheses::Never)) - .fmt(f) - } - Parenthesize::IfRequired => { - expression.format().with_options(Parentheses::Never).fmt(f) + parenthesize_if_expands(&unparenthesized).fmt(f) } + Parenthesize::IfRequired => unparenthesized.fmt(f), + Parenthesize::Optional | Parenthesize::IfBreaks | Parenthesize::IfBreaksParenthesized | Parenthesize::IfBreaksParenthesizedNested => { if can_omit_optional_parentheses(expression, f.context()) { - optional_parentheses(&expression.format().with_options(Parentheses::Never)) - .fmt(f) + optional_parentheses(&unparenthesized).fmt(f) } else { - parenthesize_if_expands( - &expression.format().with_options(Parentheses::Never), - ) - .fmt(f) + parenthesize_if_expands(&unparenthesized).fmt(f) } } }, OptionalParentheses::BestFit => match parenthesize { + Parenthesize::IfBreaksParenthesized | Parenthesize::IfBreaksParenthesizedNested if !is_empty_parameters_no_unnecessary_parentheses_around_return_value_enabled(f.context()) => + parenthesize_if_expands(&unparenthesized).fmt(f), + Parenthesize::IfBreaksParenthesized | Parenthesize::IfBreaksParenthesizedNested => { - parenthesize_if_expands(&expression.format().with_options(Parentheses::Never)) - .fmt(f) + // Can-omit layout is relevant for `"abcd".call`. We don't want to add unnecessary + // parentheses in this case. + if can_omit_optional_parentheses(expression, f.context()) { + optional_parentheses(&unparenthesized).fmt(f) + } else { + parenthesize_if_expands(&unparenthesized).fmt(f) + } } - Parenthesize::Optional | Parenthesize::IfRequired => { - expression.format().with_options(Parentheses::Never).fmt(f) - } + Parenthesize::Optional | Parenthesize::IfRequired => unparenthesized.fmt(f), Parenthesize::IfBreaks => { if node_comments.has_trailing() { @@ -446,7 +447,7 @@ impl Format<PyFormatContext<'_>> for MaybeParenthesizeExpression<'_> { let group_id = f.group_id("optional_parentheses"); let f = &mut WithNodeLevel::new(NodeLevel::Expression(Some(group_id)), f); - best_fit_parenthesize(&expression.format().with_options(Parentheses::Never)) + best_fit_parenthesize(&unparenthesized) .with_group_id(Some(group_id)) .fmt(f) } @@ -454,13 +455,13 @@ impl Format<PyFormatContext<'_>> for MaybeParenthesizeExpression<'_> { }, OptionalParentheses::Never => match parenthesize { Parenthesize::IfBreaksParenthesized | Parenthesize::IfBreaksParenthesizedNested if !is_empty_parameters_no_unnecessary_parentheses_around_return_value_enabled(f.context()) => { - parenthesize_if_expands(&expression.format().with_options(Parentheses::Never)) + parenthesize_if_expands(&unparenthesized) .with_indent(!is_expression_huggable(expression, f.context())) .fmt(f) } Parenthesize::Optional | Parenthesize::IfBreaks | Parenthesize::IfRequired | Parenthesize::IfBreaksParenthesized | Parenthesize::IfBreaksParenthesizedNested => { - expression.format().with_options(Parentheses::Never).fmt(f) + unparenthesized.fmt(f) } }, @@ -768,15 +769,26 @@ impl<'input> CanOmitOptionalParenthesesVisitor<'input> { Expr::StringLiteral(ast::ExprStringLiteral { value, .. }) if value.is_implicit_concatenated() => { - self.update_max_precedence(OperatorPrecedence::String); + if !is_f_string_formatting_enabled(self.context) { + self.update_max_precedence(OperatorPrecedence::String); + } + + return; } Expr::BytesLiteral(ast::ExprBytesLiteral { value, .. }) if value.is_implicit_concatenated() => { - self.update_max_precedence(OperatorPrecedence::String); + if !is_f_string_formatting_enabled(self.context) { + self.update_max_precedence(OperatorPrecedence::String); + } + + return; } Expr::FString(ast::ExprFString { value, .. }) if value.is_implicit_concatenated() => { - self.update_max_precedence(OperatorPrecedence::String); + if !is_f_string_formatting_enabled(self.context) { + self.update_max_precedence(OperatorPrecedence::String); + } + return; } diff --git a/crates/ruff_python_formatter/src/other/f_string.rs b/crates/ruff_python_formatter/src/other/f_string.rs index 9202ea94aab20..a8e792643243d 100644 --- a/crates/ruff_python_formatter/src/other/f_string.rs +++ b/crates/ruff_python_formatter/src/other/f_string.rs @@ -1,12 +1,10 @@ -use crate::prelude::*; -use crate::preview::{ - is_f_string_formatting_enabled, is_f_string_implicit_concatenated_string_literal_quotes_enabled, -}; -use crate::string::{Quoting, StringNormalizer, StringQuotes}; use ruff_formatter::write; use ruff_python_ast::{AnyStringFlags, FString, StringFlags}; use ruff_source_file::Locator; -use ruff_text_size::Ranged; + +use crate::prelude::*; +use crate::preview::is_f_string_formatting_enabled; +use crate::string::{Quoting, StringNormalizer, StringQuotes}; use super::f_string_element::FormatFStringElement; @@ -33,12 +31,11 @@ impl Format<PyFormatContext<'_>> for FormatFString<'_> { // If the preview style is enabled, make the decision on what quotes to use locally for each // f-string instead of globally for the entire f-string expression. - let quoting = - if is_f_string_implicit_concatenated_string_literal_quotes_enabled(f.context()) { - f_string_quoting(self.value, &locator) - } else { - self.quoting - }; + let quoting = if is_f_string_formatting_enabled(f.context()) { + Quoting::CanChange + } else { + self.quoting + }; let normalizer = StringNormalizer::from_context(f.context()).with_quoting(quoting); @@ -76,14 +73,9 @@ impl Format<PyFormatContext<'_>> for FormatFString<'_> { let quotes = StringQuotes::from(string_kind); write!(f, [string_kind.prefix(), quotes])?; - f.join() - .entries( - self.value - .elements - .iter() - .map(|element| FormatFStringElement::new(element, context)), - ) - .finish()?; + for element in &self.value.elements { + FormatFStringElement::new(element, context).fmt(f)?; + } // Ending quote quotes.fmt(f) @@ -92,17 +84,21 @@ impl Format<PyFormatContext<'_>> for FormatFString<'_> { #[derive(Clone, Copy, Debug)] pub(crate) struct FStringContext { - flags: AnyStringFlags, + /// The string flags of the enclosing f-string part. + enclosing_flags: AnyStringFlags, layout: FStringLayout, } impl FStringContext { - const fn new(flags: AnyStringFlags, layout: FStringLayout) -> Self { - Self { flags, layout } + pub(crate) const fn new(flags: AnyStringFlags, layout: FStringLayout) -> Self { + Self { + enclosing_flags: flags, + layout, + } } pub(crate) fn flags(self) -> AnyStringFlags { - self.flags + self.enclosing_flags } pub(crate) const fn layout(self) -> FStringLayout { @@ -121,7 +117,7 @@ pub(crate) enum FStringLayout { } impl FStringLayout { - fn from_f_string(f_string: &FString, locator: &Locator) -> Self { + pub(crate) fn from_f_string(f_string: &FString, locator: &Locator) -> Self { // Heuristic: Allow breaking the f-string expressions across multiple lines // only if there already is at least one multiline expression. This puts the // control in the hands of the user to decide if they want to break the @@ -149,20 +145,3 @@ impl FStringLayout { matches!(self, FStringLayout::Multiline) } } - -fn f_string_quoting(f_string: &FString, locator: &Locator) -> Quoting { - let triple_quoted = f_string.flags.is_triple_quoted(); - - if f_string.elements.expressions().any(|expression| { - let string_content = locator.slice(expression.range()); - if triple_quoted { - string_content.contains(r#"""""#) || string_content.contains("'''") - } else { - string_content.contains(['"', '\'']) - } - }) { - Quoting::Preserve - } else { - Quoting::CanChange - } -} diff --git a/crates/ruff_python_formatter/src/other/f_string_element.rs b/crates/ruff_python_formatter/src/other/f_string_element.rs index 12e653e755860..77daaca0d25a4 100644 --- a/crates/ruff_python_formatter/src/other/f_string_element.rs +++ b/crates/ruff_python_formatter/src/other/f_string_element.rs @@ -2,8 +2,8 @@ use std::borrow::Cow; use ruff_formatter::{format_args, write, Buffer, RemoveSoftLinesBuffer}; use ruff_python_ast::{ - ConversionFlag, Expr, FStringElement, FStringExpressionElement, FStringLiteralElement, - StringFlags, + AnyStringFlags, ConversionFlag, Expr, FStringElement, FStringExpressionElement, + FStringLiteralElement, StringFlags, }; use ruff_text_size::Ranged; @@ -33,7 +33,7 @@ impl Format<PyFormatContext<'_>> for FormatFStringElement<'_> { fn fmt(&self, f: &mut PyFormatter) -> FormatResult<()> { match self.element { FStringElement::Literal(string_literal) => { - FormatFStringLiteralElement::new(string_literal, self.context).fmt(f) + FormatFStringLiteralElement::new(string_literal, self.context.flags()).fmt(f) } FStringElement::Expression(expression) => { FormatFStringExpressionElement::new(expression, self.context).fmt(f) @@ -45,19 +45,24 @@ impl Format<PyFormatContext<'_>> for FormatFStringElement<'_> { /// Formats an f-string literal element. pub(crate) struct FormatFStringLiteralElement<'a> { element: &'a FStringLiteralElement, - context: FStringContext, + /// Flags of the enclosing F-string part + fstring_flags: AnyStringFlags, } impl<'a> FormatFStringLiteralElement<'a> { - pub(crate) fn new(element: &'a FStringLiteralElement, context: FStringContext) -> Self { - Self { element, context } + pub(crate) fn new(element: &'a FStringLiteralElement, fstring_flags: AnyStringFlags) -> Self { + Self { + element, + fstring_flags, + } } } impl Format<PyFormatContext<'_>> for FormatFStringLiteralElement<'_> { fn fmt(&self, f: &mut PyFormatter) -> FormatResult<()> { let literal_content = f.context().locator().slice(self.element.range()); - let normalized = normalize_string(literal_content, 0, self.context.flags(), true); + let normalized = + normalize_string(literal_content, 0, self.fstring_flags, false, false, true); match &normalized { Cow::Borrowed(_) => source_text_slice(self.element.range()).fmt(f), Cow::Owned(normalized) => text(normalized).fmt(f), @@ -231,11 +236,9 @@ impl Format<PyFormatContext<'_>> for FormatFStringExpressionElement<'_> { if let Some(format_spec) = format_spec.as_deref() { token(":").fmt(f)?; - f.join() - .entries(format_spec.elements.iter().map(|element| { - FormatFStringElement::new(element, self.context.f_string()) - })) - .finish()?; + for element in &format_spec.elements { + FormatFStringElement::new(element, self.context.f_string()).fmt(f)?; + } // These trailing comments can only occur if the format specifier is // present. For example, diff --git a/crates/ruff_python_formatter/src/pattern/mod.rs b/crates/ruff_python_formatter/src/pattern/mod.rs index d564a6f97025a..d2ba4801dd26e 100644 --- a/crates/ruff_python_formatter/src/pattern/mod.rs +++ b/crates/ruff_python_formatter/src/pattern/mod.rs @@ -14,6 +14,7 @@ use crate::expression::parentheses::{ optional_parentheses, parenthesized, NeedsParentheses, OptionalParentheses, Parentheses, }; use crate::prelude::*; +use crate::preview::is_join_implicit_concatenated_string_enabled; pub(crate) mod pattern_arguments; pub(crate) mod pattern_keyword; @@ -226,7 +227,7 @@ pub(crate) fn can_pattern_omit_optional_parentheses( pattern: &Pattern, context: &PyFormatContext, ) -> bool { - let mut visitor = CanOmitOptionalParenthesesVisitor::default(); + let mut visitor = CanOmitOptionalParenthesesVisitor::new(context); visitor.visit_pattern(pattern, context); if !visitor.any_parenthesized_expressions { @@ -271,16 +272,32 @@ pub(crate) fn can_pattern_omit_optional_parentheses( } } -#[derive(Debug, Default)] +#[derive(Debug)] struct CanOmitOptionalParenthesesVisitor<'input> { max_precedence: OperatorPrecedence, max_precedence_count: usize, any_parenthesized_expressions: bool, + join_implicit_concatenated_strings: bool, last: Option<&'input Pattern>, first: First<'input>, } impl<'a> CanOmitOptionalParenthesesVisitor<'a> { + fn new(context: &PyFormatContext) -> Self { + Self { + max_precedence: OperatorPrecedence::default(), + max_precedence_count: 0, + any_parenthesized_expressions: false, + // TODO: Derive default for `CanOmitOptionalParenthesesVisitor` when removing the `join_implicit_concatenated_strings` + // preview style. + join_implicit_concatenated_strings: is_join_implicit_concatenated_string_enabled( + context, + ), + last: None, + first: First::default(), + } + } + fn visit_pattern(&mut self, pattern: &'a Pattern, context: &PyFormatContext) { match pattern { Pattern::MatchSequence(_) | Pattern::MatchMapping(_) => { @@ -289,18 +306,24 @@ impl<'a> CanOmitOptionalParenthesesVisitor<'a> { Pattern::MatchValue(value) => match &*value.value { Expr::StringLiteral(string) => { - self.update_max_precedence(OperatorPrecedence::String, string.value.len()); + if !self.join_implicit_concatenated_strings { + self.update_max_precedence(OperatorPrecedence::String, string.value.len()); + } } Expr::BytesLiteral(bytes) => { - self.update_max_precedence(OperatorPrecedence::String, bytes.value.len()); + if !self.join_implicit_concatenated_strings { + self.update_max_precedence(OperatorPrecedence::String, bytes.value.len()); + } } // F-strings are allowed according to python's grammar but fail with a syntax error at runtime. // That's why we need to support them for formatting. Expr::FString(string) => { - self.update_max_precedence( - OperatorPrecedence::String, - string.value.as_slice().len(), - ); + if !self.join_implicit_concatenated_strings { + self.update_max_precedence( + OperatorPrecedence::String, + string.value.as_slice().len(), + ); + } } Expr::NumberLiteral(_) | Expr::Attribute(_) | Expr::UnaryOp(_) => { diff --git a/crates/ruff_python_formatter/src/pattern/pattern_arguments.rs b/crates/ruff_python_formatter/src/pattern/pattern_arguments.rs index 6add2788c3238..3bbfa4ceceb57 100644 --- a/crates/ruff_python_formatter/src/pattern/pattern_arguments.rs +++ b/crates/ruff_python_formatter/src/pattern/pattern_arguments.rs @@ -48,7 +48,7 @@ impl FormatNodeRule<PatternArguments> for FormatPatternArguments { pattern.format().with_options(Parentheses::Preserve), ) })) - .nodes(item.keywords.iter()); + .nodes(&item.keywords); } } diff --git a/crates/ruff_python_formatter/src/preview.rs b/crates/ruff_python_formatter/src/preview.rs index 92b86f3ccfd7b..55d5d60dcd9fb 100644 --- a/crates/ruff_python_formatter/src/preview.rs +++ b/crates/ruff_python_formatter/src/preview.rs @@ -15,11 +15,15 @@ pub(crate) const fn is_hug_parens_with_braces_and_square_brackets_enabled( } /// Returns `true` if the [`f-string formatting`](https://github.com/astral-sh/ruff/issues/7594) preview style is enabled. +/// WARNING: This preview style depends on `is_f_string_implicit_concatenated_string_literal_quotes_enabled`. +/// TODO: Remove `Quoting` when promoting this preview style and convert `FormatStringPart` etc. regular `FormatWithRule` implementations. +/// TODO: Remove `format_f_string` from `normalize_string` when promoting this preview style. pub(crate) fn is_f_string_formatting_enabled(context: &PyFormatContext) -> bool { context.is_preview() } /// See [#13539](https://github.com/astral-sh/ruff/pull/13539) +/// Remove `Quoting` when stabalizing this preview style. pub(crate) fn is_f_string_implicit_concatenated_string_literal_quotes_enabled( context: &PyFormatContext, ) -> bool { @@ -60,3 +64,11 @@ pub(crate) fn is_docstring_code_block_in_docstring_indent_enabled( ) -> bool { context.is_preview() } + +/// Returns `true` if implicitly concatenated strings should be joined if they all fit on a single line. +/// See [#9457](https://github.com/astral-sh/ruff/issues/9457) +/// WARNING: This preview style depends on `is_empty_parameters_no_unnecessary_parentheses_around_return_value_enabled` +/// because it relies on the new semantic of `IfBreaksParenthesized`. +pub(crate) fn is_join_implicit_concatenated_string_enabled(context: &PyFormatContext) -> bool { + context.is_preview() +} diff --git a/crates/ruff_python_formatter/src/range.rs b/crates/ruff_python_formatter/src/range.rs index 3541f46e4cf08..33f7d2a1d2fe7 100644 --- a/crates/ruff_python_formatter/src/range.rs +++ b/crates/ruff_python_formatter/src/range.rs @@ -211,9 +211,9 @@ impl<'ast> SourceOrderVisitor<'ast> for FindEnclosingNode<'_, 'ast> { // Don't pick potential docstrings as the closest enclosing node because `suite.rs` than fails to identify them as // docstrings and docstring formatting won't kick in. // Format the enclosing node instead and slice the formatted docstring from the result. - let is_maybe_docstring = node.as_stmt_expr().is_some_and(|stmt| { - DocstringStmt::is_docstring_statement(stmt, self.context.options().source_type()) - }); + let is_maybe_docstring = node + .as_stmt_expr() + .is_some_and(|stmt| DocstringStmt::is_docstring_statement(stmt, self.context)); if is_maybe_docstring { return TraversalSignal::Skip; diff --git a/crates/ruff_python_formatter/src/statement/stmt_assert.rs b/crates/ruff_python_formatter/src/statement/stmt_assert.rs index 91a85fe04925b..2e7e99240beba 100644 --- a/crates/ruff_python_formatter/src/statement/stmt_assert.rs +++ b/crates/ruff_python_formatter/src/statement/stmt_assert.rs @@ -6,6 +6,7 @@ use crate::comments::SourceComment; use crate::expression::maybe_parenthesize_expression; use crate::expression::parentheses::Parenthesize; +use crate::preview::is_join_implicit_concatenated_string_enabled; use crate::{has_skip_comment, prelude::*}; #[derive(Default)] @@ -29,12 +30,18 @@ impl FormatNodeRule<StmtAssert> for FormatStmtAssert { )?; if let Some(msg) = msg { + let parenthesize = if is_join_implicit_concatenated_string_enabled(f.context()) { + Parenthesize::IfBreaksParenthesized + } else { + Parenthesize::IfBreaks + }; + write!( f, [ token(","), space(), - maybe_parenthesize_expression(msg, item, Parenthesize::IfBreaks), + maybe_parenthesize_expression(msg, item, parenthesize), ] )?; } diff --git a/crates/ruff_python_formatter/src/statement/stmt_assign.rs b/crates/ruff_python_formatter/src/statement/stmt_assign.rs index 3e4da62aedd84..f568d2a9756cb 100644 --- a/crates/ruff_python_formatter/src/statement/stmt_assign.rs +++ b/crates/ruff_python_formatter/src/statement/stmt_assign.rs @@ -1,6 +1,6 @@ -use ruff_formatter::{format_args, write, FormatError}; +use ruff_formatter::{format_args, write, FormatError, RemoveSoftLinesBuffer}; use ruff_python_ast::{ - AnyNodeRef, Expr, ExprAttribute, ExprCall, Operator, StmtAssign, TypeParams, + AnyNodeRef, Expr, ExprAttribute, ExprCall, Operator, StmtAssign, StringLike, TypeParams, }; use crate::builders::parenthesize_if_expands; @@ -16,7 +16,11 @@ use crate::expression::{ can_omit_optional_parentheses, has_own_parentheses, has_parentheses, maybe_parenthesize_expression, }; +use crate::preview::is_join_implicit_concatenated_string_enabled; use crate::statement::trailing_semicolon; +use crate::string::implicit::{ + FormatImplicitConcatenatedStringExpanded, FormatImplicitConcatenatedStringFlat, +}; use crate::{has_skip_comment, prelude::*}; #[derive(Default)] @@ -281,8 +285,11 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> { match self { FormatStatementsLastExpression::LeftToRight { value, statement } => { let can_inline_comment = should_inline_comments(value, *statement, f.context()); + let format_implicit_flat = StringLike::try_from(*value).ok().and_then(|string| { + FormatImplicitConcatenatedStringFlat::new(string, f.context()) + }); - if !can_inline_comment { + if !can_inline_comment && format_implicit_flat.is_none() { return maybe_parenthesize_expression( value, *statement, @@ -301,28 +308,149 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> { ) { let group_id = f.group_id("optional_parentheses"); - let f = &mut WithNodeLevel::new(NodeLevel::Expression(Some(group_id)), f); - - best_fit_parenthesize(&format_with(|f| { + // Special case for implicit concatenated strings in assignment value positions. + // The special handling is necessary to prevent an instability where an assignment has + // a trailing own line comment and the implicit concatenated string fits on the line, + // but only if the comment doesn't get inlined. + // + // ```python + // ____aaa = ( + // "aaaaaaaaaaaaaaaaaaaaa" "aaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbvvvvvvvvvvvvvvv" + // ) # c + // ``` + // + // Without the special handling, this would get formatted to: + // ```python + // ____aaa = ( + // "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbvvvvvvvvvvvvvvv" + // ) # c + // ``` + // + // However, this now gets reformatted again because Ruff now takes the `BestFit` layout for the string + // because the value is no longer an implicit concatenated string. + // ```python + // ____aaa = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbvvvvvvvvvvvvvvv" # c + // ``` + // + // The special handling here ensures that the implicit concatenated string only gets + // joined **if** it fits with the trailing comment inlined. Otherwise, keep the multiline + // formatting. + if let Some(flat) = format_implicit_flat { inline_comments.mark_formatted(); - - value.format().with_options(Parentheses::Never).fmt(f)?; - - if !inline_comments.is_empty() { - // If the expressions exceeds the line width, format the comments in the parentheses - if_group_breaks(&inline_comments).fmt(f)?; + let string = flat.string(); + + let flat = format_with(|f| { + if string.is_fstring() { + let mut buffer = RemoveSoftLinesBuffer::new(&mut *f); + + write!(buffer, [flat]) + } else { + flat.fmt(f) + } + }) + .memoized(); + + // F-String containing an expression with a magic trailing comma, a comment, or a + // multiline debug expression should never be joined. Use the default layout. + // ```python + // aaaa = f"abcd{[ + // 1, + // 2, + // ]}" "more" + // ``` + if string.is_fstring() && flat.inspect(f)?.will_break() { + inline_comments.mark_unformatted(); + + return write!( + f, + [maybe_parenthesize_expression( + value, + *statement, + Parenthesize::IfBreaks, + )] + ); } - Ok(()) - })) - .with_group_id(Some(group_id)) - .fmt(f)?; - - if !inline_comments.is_empty() { - // If the line fits into the line width, format the comments after the parenthesized expression - if_group_fits_on_line(&inline_comments) + let expanded = format_with(|f| { + let f = + &mut WithNodeLevel::new(NodeLevel::Expression(Some(group_id)), f); + + write!(f, [FormatImplicitConcatenatedStringExpanded::new(string)]) + }); + + // Join the implicit concatenated string if it fits on a single line + // ```python + // a = "testmorelong" # comment + // ``` + let single_line = format_with(|f| write!(f, [flat, inline_comments])); + + // Parenthesize the string but join the implicit concatenated string and inline the comment. + // ```python + // a = ( + // "testmorelong" # comment + // ) + // ``` + let joined_parenthesized = format_with(|f| { + group(&format_args![ + token("("), + soft_block_indent(&format_args![flat, inline_comments]), + token(")"), + ]) + .with_group_id(Some(group_id)) + .should_expand(true) + .fmt(f) + }); + + // Keep the implicit concatenated string multiline and don't inline the comment. + // ```python + // a = ( + // "test" + // "more" + // "long" + // ) # comment + // ``` + let implicit_expanded = format_with(|f| { + group(&format_args![ + token("("), + block_indent(&expanded), + token(")"), + inline_comments, + ]) .with_group_id(Some(group_id)) + .should_expand(true) + .fmt(f) + }); + + // We can't use `optional_parentheses` here because the `inline_comments` contains + // a `expand_parent` which results in an instability because the next format + // collapses the parentheses. + // We can't use `parenthesize_if_expands` because it defaults to + // the *flat* layout when the expanded layout doesn't fit. + best_fitting![single_line, joined_parenthesized, implicit_expanded] + .with_mode(BestFittingMode::AllLines) .fmt(f)?; + } else { + best_fit_parenthesize(&format_once(|f| { + inline_comments.mark_formatted(); + + value.format().with_options(Parentheses::Never).fmt(f)?; + + if !inline_comments.is_empty() { + // If the expressions exceeds the line width, format the comments in the parentheses + if_group_breaks(&inline_comments).fmt(f)?; + } + + Ok(()) + })) + .with_group_id(Some(group_id)) + .fmt(f)?; + + if !inline_comments.is_empty() { + // If the line fits into the line width, format the comments after the parenthesized expression + if_group_fits_on_line(&inline_comments) + .with_group_id(Some(group_id)) + .fmt(f)?; + } } Ok(()) @@ -339,10 +467,14 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> { statement, } => { let should_inline_comments = should_inline_comments(value, *statement, f.context()); + let format_implicit_flat = StringLike::try_from(*value).ok().and_then(|string| { + FormatImplicitConcatenatedStringFlat::new(string, f.context()) + }); // Use the normal `maybe_parenthesize_layout` for splittable `value`s. if !should_inline_comments && !should_non_inlineable_use_best_fit(value, *statement, f.context()) + && format_implicit_flat.is_none() { return write!( f, @@ -364,7 +496,7 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> { let expression_comments = comments.leading_dangling_trailing(*value); // Don't inline comments for attribute and call expressions for black compatibility - let inline_comments = if should_inline_comments { + let inline_comments = if should_inline_comments || format_implicit_flat.is_some() { OptionalParenthesesInlinedComments::new( &expression_comments, *statement, @@ -396,13 +528,14 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> { // Prevent inline comments to be formatted as part of the expression. inline_comments.mark_formatted(); - let mut last_target = before_operator.memoized(); + let last_target = before_operator.memoized(); + let last_target_breaks = last_target.inspect(f)?.will_break(); // Don't parenthesize the `value` if it is known that the target will break. // This is mainly a performance optimisation that avoids unnecessary memoization // and using the costly `BestFitting` layout if it is already known that only the last variant // can ever fit because the left breaks. - if last_target.inspect(f)?.will_break() { + if format_implicit_flat.is_none() && last_target_breaks { return write!( f, [ @@ -416,13 +549,29 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> { ); } - let format_value = value.format().with_options(Parentheses::Never).memoized(); + let format_value = format_with(|f| { + if let Some(format_implicit_flat) = format_implicit_flat.as_ref() { + if format_implicit_flat.string().is_fstring() { + // Remove any soft line breaks emitted by the f-string formatting. + // This is important when formatting f-strings as part of an assignment right side + // because `best_fit_parenthesize` will otherwise still try to break inner + // groups if wrapped in a `group(..).should_expand(true)` + let mut buffer = RemoveSoftLinesBuffer::new(&mut *f); + write!(buffer, [format_implicit_flat]) + } else { + format_implicit_flat.fmt(f) + } + } else { + value.format().with_options(Parentheses::Never).fmt(f) + } + }) + .memoized(); // Tries to fit the `left` and the `value` on a single line: // ```python // a = b = c // ``` - let format_flat = format_with(|f| { + let single_line = format_with(|f| { write!( f, [ @@ -443,19 +592,21 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> { // c // ) // ``` - let format_parenthesize_value = format_with(|f| { - write!( - f, - [ - last_target, - space(), - operator, - space(), - token("("), - block_indent(&format_args![format_value, inline_comments]), - token(")") - ] - ) + let flat_target_parenthesize_value = format_with(|f| { + write!(f, [last_target, space(), operator, space(), token("("),])?; + + if is_join_implicit_concatenated_string_enabled(f.context()) { + group(&soft_block_indent(&format_args![ + format_value, + inline_comments + ])) + .should_expand(true) + .fmt(f)?; + } else { + block_indent(&format_args![format_value, inline_comments]).fmt(f)?; + } + + token(")").fmt(f) }); // Fall back to parenthesizing (or splitting) the last target part if we can't make the value @@ -466,17 +617,16 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> { // "bbbbb" // ] = c // ``` - let format_split_left = format_with(|f| { + let split_target_flat_value = format_with(|f| { + if is_join_implicit_concatenated_string_enabled(f.context()) { + group(&last_target).should_expand(true).fmt(f)?; + } else { + last_target.fmt(f)?; + } + write!( f, - [ - last_target, - space(), - operator, - space(), - format_value, - inline_comments - ] + [space(), operator, space(), format_value, inline_comments] ) }); @@ -486,7 +636,7 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> { // For attribute chains that contain any parenthesized value: Try expanding the parenthesized value first. if value.is_call_expr() || value.is_subscript_expr() || value.is_attribute_expr() { best_fitting![ - format_flat, + single_line, // Avoid parenthesizing the call expression if the `(` fit on the line format_args![ last_target, @@ -495,12 +645,165 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> { space(), group(&format_value).should_expand(true), ], - format_parenthesize_value, - format_split_left + flat_target_parenthesize_value, + split_target_flat_value ] .fmt(f) + } else if let Some(format_implicit_flat) = &format_implicit_flat { + // F-String containing an expression with a magic trailing comma, a comment, or a + // multiline debug expression should never be joined. Use the default layout. + // + // ```python + // aaaa = f"abcd{[ + // 1, + // 2, + // ]}" "more" + // ``` + if format_implicit_flat.string().is_fstring() + && format_value.inspect(f)?.will_break() + { + inline_comments.mark_unformatted(); + + return write!( + f, + [ + before_operator, + space(), + operator, + space(), + maybe_parenthesize_expression( + value, + *statement, + Parenthesize::IfBreaks + ) + ] + ); + } + + let group_id = f.group_id("optional_parentheses"); + let format_expanded = format_with(|f| { + let f = &mut WithNodeLevel::new(NodeLevel::Expression(Some(group_id)), f); + + FormatImplicitConcatenatedStringExpanded::new( + StringLike::try_from(*value).unwrap(), + ) + .fmt(f) + }) + .memoized(); + + // Keep the target flat, parenthesize the value, and keep it multiline. + // + // ```python + // Literal[ "a", "b"] = ( + // "looooooooooooooooooooooooooooooong" + // "string" + // ) # comment + // ``` + let flat_target_value_parenthesized_multiline = format_with(|f| { + write!( + f, + [ + last_target, + space(), + operator, + space(), + token("("), + group(&soft_block_indent(&format_expanded)) + .with_group_id(Some(group_id)) + .should_expand(true), + token(")"), + inline_comments + ] + ) + }); + + // Expand the parent and parenthesize the joined string with the inlined comment. + // + // ```python + // Literal[ + // "a", + // "b", + // ] = ( + // "not that long string" # comment + // ) + // ``` + let split_target_value_parenthesized_flat = format_with(|f| { + write!( + f, + [ + group(&last_target).should_expand(true), + space(), + operator, + space(), + token("("), + group(&soft_block_indent(&format_args![ + format_value, + inline_comments + ])) + .should_expand(true), + token(")") + ] + ) + }); + + // The most expanded variant: Expand both the target and the string. + // + // ```python + // Literal[ + // "a", + // "b", + // ] = ( + // "looooooooooooooooooooooooooooooong" + // "string" + // ) # comment + // ``` + let split_target_value_parenthesized_multiline = format_with(|f| { + write!( + f, + [ + group(&last_target).should_expand(true), + space(), + operator, + space(), + token("("), + group(&soft_block_indent(&format_expanded)) + .with_group_id(Some(group_id)) + .should_expand(true), + token(")"), + inline_comments + ] + ) + }); + + // This is only a perf optimisation. No point in trying all the "flat-target" + // variants if we know that the last target must break. + if last_target_breaks { + best_fitting![ + split_target_flat_value, + split_target_value_parenthesized_flat, + split_target_value_parenthesized_multiline, + ] + .with_mode(BestFittingMode::AllLines) + .fmt(f) + } else { + best_fitting![ + single_line, + flat_target_parenthesize_value, + flat_target_value_parenthesized_multiline, + split_target_flat_value, + split_target_value_parenthesized_flat, + split_target_value_parenthesized_multiline, + ] + .with_mode(BestFittingMode::AllLines) + .fmt(f) + } } else { - best_fitting![format_flat, format_parenthesize_value, format_split_left].fmt(f) + best_fitting![ + single_line, + flat_target_parenthesize_value, + split_target_flat_value + ] + .fmt(f) } } } @@ -556,6 +859,12 @@ impl<'a> OptionalParenthesesInlinedComments<'a> { comment.mark_formatted(); } } + + fn mark_unformatted(&self) { + for comment in self.expression { + comment.mark_unformatted(); + } + } } impl Format<PyFormatContext<'_>> for OptionalParenthesesInlinedComments<'_> { diff --git a/crates/ruff_python_formatter/src/statement/suite.rs b/crates/ruff_python_formatter/src/statement/suite.rs index c483f917e2395..7b275677ef288 100644 --- a/crates/ruff_python_formatter/src/statement/suite.rs +++ b/crates/ruff_python_formatter/src/statement/suite.rs @@ -138,7 +138,7 @@ impl FormatRule<Suite, PyFormatContext<'_>> for FormatSuite { SuiteKind::Function | SuiteKind::Class | SuiteKind::TopLevel => { if let Some(docstring) = - DocstringStmt::try_from_statement(first, self.kind, source_type) + DocstringStmt::try_from_statement(first, self.kind, f.context()) { SuiteChildStatement::Docstring(docstring) } else { @@ -179,7 +179,7 @@ impl FormatRule<Suite, PyFormatContext<'_>> for FormatSuite { // Insert a newline after a module level docstring, but treat // it as a docstring otherwise. See: https://github.com/psf/black/pull/3932. self.kind == SuiteKind::TopLevel - && DocstringStmt::try_from_statement(first.statement(), self.kind, source_type) + && DocstringStmt::try_from_statement(first.statement(), self.kind, f.context()) .is_some() }; @@ -785,37 +785,23 @@ impl<'a> DocstringStmt<'a> { fn try_from_statement( stmt: &'a Stmt, suite_kind: SuiteKind, - source_type: PySourceType, + context: &PyFormatContext, ) -> Option<DocstringStmt<'a>> { // Notebooks don't have a concept of modules, therefore, don't recognise the first string as the module docstring. - if source_type.is_ipynb() && suite_kind == SuiteKind::TopLevel { + if context.options().source_type().is_ipynb() && suite_kind == SuiteKind::TopLevel { return None; } - let Stmt::Expr(ast::StmtExpr { value, .. }) = stmt else { - return None; - }; - - match value.as_ref() { - Expr::StringLiteral(ast::ExprStringLiteral { value, .. }) - if !value.is_implicit_concatenated() => - { - Some(DocstringStmt { - docstring: stmt, - suite_kind, - }) - } - _ => None, - } + Self::is_docstring_statement(stmt.as_expr_stmt()?, context).then_some(DocstringStmt { + docstring: stmt, + suite_kind, + }) } - pub(crate) fn is_docstring_statement(stmt: &StmtExpr, source_type: PySourceType) -> bool { - if source_type.is_ipynb() { - return false; - } - + pub(crate) fn is_docstring_statement(stmt: &StmtExpr, context: &PyFormatContext) -> bool { if let Expr::StringLiteral(ast::ExprStringLiteral { value, .. }) = stmt.value.as_ref() { !value.is_implicit_concatenated() + || !value.iter().any(|literal| context.comments().has(literal)) } else { false } diff --git a/crates/ruff_python_formatter/src/string/docstring.rs b/crates/ruff_python_formatter/src/string/docstring.rs index 5c177a0142acd..3279d40106438 100644 --- a/crates/ruff_python_formatter/src/string/docstring.rs +++ b/crates/ruff_python_formatter/src/string/docstring.rs @@ -2,15 +2,15 @@ // "reStructuredText." #![allow(clippy::doc_markdown)] +use itertools::Itertools; use std::cmp::Ordering; +use std::sync::LazyLock; use std::{borrow::Cow, collections::VecDeque}; -use itertools::Itertools; - +use regex::Regex; use ruff_formatter::printer::SourceMapGeneration; -use ruff_python_ast::{str::Quote, StringFlags}; +use ruff_python_ast::{str::Quote, AnyStringFlags, StringFlags}; use ruff_python_trivia::CommentRanges; -use {once_cell::sync::Lazy, regex::Regex}; use { ruff_formatter::{write, FormatOptions, IndentStyle, LineWidth, Printed}, ruff_python_trivia::{is_python_whitespace, PythonWhitespace}, @@ -19,7 +19,10 @@ use { }; use super::NormalizedString; -use crate::preview::is_docstring_code_block_in_docstring_indent_enabled; +use crate::preview::{ + is_docstring_code_block_in_docstring_indent_enabled, + is_join_implicit_concatenated_string_enabled, +}; use crate::string::StringQuotes; use crate::{prelude::*, DocstringCodeLineWidth, FormatModuleError}; @@ -167,7 +170,7 @@ pub(crate) fn format(normalized: &NormalizedString, f: &mut PyFormatter) -> Form if docstring[first.len()..].trim().is_empty() { // For `"""\n"""` or other whitespace between the quotes, black keeps a single whitespace, // but `""""""` doesn't get one inserted. - if needs_chaperone_space(normalized, trim_end) + if needs_chaperone_space(normalized.flags(), trim_end, f.context()) || (trim_end.is_empty() && !docstring.is_empty()) { space().fmt(f)?; @@ -207,7 +210,7 @@ pub(crate) fn format(normalized: &NormalizedString, f: &mut PyFormatter) -> Form let trim_end = docstring .as_ref() .trim_end_matches(|c: char| c.is_whitespace() && c != '\n'); - if needs_chaperone_space(normalized, trim_end) { + if needs_chaperone_space(normalized.flags(), trim_end, f.context()) { space().fmt(f)?; } @@ -1075,7 +1078,7 @@ impl<'src> CodeExampleRst<'src> { // [directives]: https://docutils.sourceforge.io/docs/ref/rst/restructuredtext.html#directives // [Pygments lexer names]: https://pygments.org/docs/lexers/ // [code-block]: https://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html#directive-code-block - static DIRECTIVE_START: Lazy<Regex> = Lazy::new(|| { + static DIRECTIVE_START: LazyLock<Regex> = LazyLock::new(|| { Regex::new( r"(?m)^\s*\.\. \s*(?i:code-block|sourcecode)::\s*(?i:python|py|python3|py3)$", ) @@ -1320,7 +1323,7 @@ impl<'src> CodeExampleMarkdown<'src> { /// /// [fenced code block]: https://spec.commonmark.org/0.30/#fenced-code-blocks fn new(original: InputDocstringLine<'src>) -> Option<CodeExampleMarkdown<'src>> { - static FENCE_START: Lazy<Regex> = Lazy::new(|| { + static FENCE_START: LazyLock<Regex> = LazyLock::new(|| { Regex::new( r"(?xm) ^ @@ -1604,9 +1607,18 @@ fn docstring_format_source( /// If the last line of the docstring is `content" """` or `content\ """`, we need a chaperone space /// that avoids `content""""` and `content\"""`. This does only applies to un-escaped backslashes, /// so `content\\ """` doesn't need a space while `content\\\ """` does. -fn needs_chaperone_space(normalized: &NormalizedString, trim_end: &str) -> bool { - trim_end.ends_with(normalized.flags().quote_style().as_char()) - || trim_end.chars().rev().take_while(|c| *c == '\\').count() % 2 == 1 +pub(super) fn needs_chaperone_space( + flags: AnyStringFlags, + trim_end: &str, + context: &PyFormatContext, +) -> bool { + if trim_end.chars().rev().take_while(|c| *c == '\\').count() % 2 == 1 { + true + } else if is_join_implicit_concatenated_string_enabled(context) { + flags.is_triple_quoted() && trim_end.ends_with(flags.quote_style().as_char()) + } else { + trim_end.ends_with(flags.quote_style().as_char()) + } } #[derive(Copy, Clone, Debug)] diff --git a/crates/ruff_python_formatter/src/string/implicit.rs b/crates/ruff_python_formatter/src/string/implicit.rs new file mode 100644 index 0000000000000..0ef4aae5afab6 --- /dev/null +++ b/crates/ruff_python_formatter/src/string/implicit.rs @@ -0,0 +1,407 @@ +use std::borrow::Cow; + +use ruff_formatter::{format_args, write, FormatContext}; +use ruff_python_ast::str::Quote; +use ruff_python_ast::str_prefix::{ + AnyStringPrefix, ByteStringPrefix, FStringPrefix, StringLiteralPrefix, +}; +use ruff_python_ast::{AnyStringFlags, FStringElement, StringFlags, StringLike, StringLikePart}; +use ruff_text_size::{Ranged, TextRange}; + +use crate::comments::{leading_comments, trailing_comments}; +use crate::expression::parentheses::in_parentheses_only_soft_line_break_or_space; +use crate::other::f_string::{FStringContext, FStringLayout, FormatFString}; +use crate::other::f_string_element::FormatFStringExpressionElement; +use crate::other::string_literal::StringLiteralKind; +use crate::prelude::*; +use crate::preview::{ + is_f_string_formatting_enabled, is_join_implicit_concatenated_string_enabled, +}; +use crate::string::docstring::needs_chaperone_space; +use crate::string::normalize::{ + is_fstring_with_quoted_debug_expression, + is_fstring_with_triple_quoted_literal_expression_containing_quotes, QuoteMetadata, +}; +use crate::string::{normalize_string, StringLikeExtensions, StringNormalizer, StringQuotes}; + +/// Formats any implicitly concatenated string. This could be any valid combination +/// of string, bytes or f-string literals. +pub(crate) struct FormatImplicitConcatenatedString<'a> { + string: StringLike<'a>, +} + +impl<'a> FormatImplicitConcatenatedString<'a> { + pub(crate) fn new(string: impl Into<StringLike<'a>>) -> Self { + Self { + string: string.into(), + } + } +} + +impl Format<PyFormatContext<'_>> for FormatImplicitConcatenatedString<'_> { + fn fmt(&self, f: &mut PyFormatter) -> FormatResult<()> { + let expanded = FormatImplicitConcatenatedStringExpanded::new(self.string); + + // If the string can be joined, try joining the implicit concatenated string into a single string + // if it fits on the line. Otherwise, parenthesize the string parts and format each part on its + // own line. + if let Some(flat) = FormatImplicitConcatenatedStringFlat::new(self.string, f.context()) { + write!( + f, + [if_group_fits_on_line(&flat), if_group_breaks(&expanded)] + ) + } else { + expanded.fmt(f) + } + } +} + +/// Formats an implicit concatenated string where parts are separated by a space or line break. +pub(crate) struct FormatImplicitConcatenatedStringExpanded<'a> { + string: StringLike<'a>, +} + +impl<'a> FormatImplicitConcatenatedStringExpanded<'a> { + pub(crate) fn new(string: StringLike<'a>) -> Self { + assert!(string.is_implicit_concatenated()); + + Self { string } + } +} + +impl Format<PyFormatContext<'_>> for FormatImplicitConcatenatedStringExpanded<'_> { + fn fmt(&self, f: &mut Formatter<PyFormatContext<'_>>) -> FormatResult<()> { + let comments = f.context().comments().clone(); + let quoting = self.string.quoting(&f.context().locator()); + + let join_implicit_concatenated_string_enabled = + is_join_implicit_concatenated_string_enabled(f.context()); + let mut joiner = f.join_with(in_parentheses_only_soft_line_break_or_space()); + + for part in self.string.parts() { + let format_part = format_with(|f: &mut PyFormatter| match part { + StringLikePart::String(part) => { + let kind = if self.string.is_fstring() { + #[allow(deprecated)] + StringLiteralKind::InImplicitlyConcatenatedFString(quoting) + } else { + StringLiteralKind::String + }; + + part.format().with_options(kind).fmt(f) + } + StringLikePart::Bytes(bytes_literal) => bytes_literal.format().fmt(f), + StringLikePart::FString(part) => FormatFString::new(part, quoting).fmt(f), + }); + + let part_comments = comments.leading_dangling_trailing(&part); + joiner.entry(&format_args![ + (!join_implicit_concatenated_string_enabled).then_some(line_suffix_boundary()), + leading_comments(part_comments.leading), + format_part, + trailing_comments(part_comments.trailing) + ]); + } + + joiner.finish() + } +} + +/// Formats an implicit concatenated string where parts are joined into a single string if possible. +pub(crate) struct FormatImplicitConcatenatedStringFlat<'a> { + string: StringLike<'a>, + flags: AnyStringFlags, + docstring: bool, +} + +impl<'a> FormatImplicitConcatenatedStringFlat<'a> { + /// Creates a new formatter. Returns `None` if the string can't be merged into a single string. + pub(crate) fn new(string: StringLike<'a>, context: &PyFormatContext) -> Option<Self> { + fn merge_flags(string: StringLike, context: &PyFormatContext) -> Option<AnyStringFlags> { + if !is_join_implicit_concatenated_string_enabled(context) { + return None; + } + + // Multiline strings can never fit on a single line. + if !string.is_fstring() && string.is_multiline(context.source()) { + return None; + } + + let first_part = string.parts().next()?; + + // The string is either a regular string, f-string, or bytes string. + let normalizer = StringNormalizer::from_context(context); + + // Some if a part requires preserving its quotes. + let mut preserve_quotes_requirement: Option<Quote> = None; + + // Early exit if it's known that this string can't be joined + for part in string.parts() { + // Similar to Black, don't collapse triple quoted and raw strings. + // We could technically join strings that are raw-strings and use the same quotes but lets not do this for now. + // Joining triple quoted strings is more complicated because an + // implicit concatenated string could become a docstring (if it's the first string in a block). + // That means the joined string formatting would have to call into + // the docstring formatting or otherwise guarantee that the output + // won't change on a second run. + if part.flags().is_triple_quoted() || part.flags().is_raw_string() { + return None; + } + + // For now, preserve comments documenting a specific part over possibly + // collapsing onto a single line. Collapsing could result in pragma comments + // now covering more code. + if context.comments().leading_trailing(&part).next().is_some() { + return None; + } + + if let StringLikePart::FString(fstring) = part { + if fstring.elements.iter().any(|element| match element { + // Same as for other literals. Multiline literals can't fit on a single line. + FStringElement::Literal(literal) => context + .locator() + .slice(literal.range()) + .contains(['\n', '\r']), + FStringElement::Expression(expression) => { + if is_f_string_formatting_enabled(context) { + // Expressions containing comments can't be joined. + context.comments().contains_comments(expression.into()) + } else { + // Multiline f-string expressions can't be joined if the f-string formatting is disabled because + // the string gets inserted in verbatim preserving the newlines. + context.locator().slice(expression).contains(['\n', '\r']) + } + } + }) { + return None; + } + + // Avoid invalid syntax for pre Python 312: + // * When joining parts that have debug expressions with quotes: `f"{10 + len('bar')=}" f'{10 + len("bar")=}' + // * When joining parts that contain triple quoted strings with quotes: `f"{'''test ' '''}" f'{"""other " """}'` + if !context.options().target_version().supports_pep_701() { + if is_fstring_with_quoted_debug_expression(fstring, context) + || is_fstring_with_triple_quoted_literal_expression_containing_quotes( + fstring, context, + ) + { + if preserve_quotes_requirement + .is_some_and(|quote| quote != part.flags().quote_style()) + { + return None; + } + preserve_quotes_requirement = Some(part.flags().quote_style()); + } + } + } + } + + // The string is either a regular string, f-string, or bytes string. + let mut merged_quotes: Option<QuoteMetadata> = None; + + // Only preserve the string type but disregard the `u` and `r` prefixes. + // * It's not necessary to preserve the `r` prefix because Ruff doesn't support joining raw strings (we shouldn't get here). + // * It's not necessary to preserve the `u` prefix because Ruff discards the `u` prefix (it's meaningless in Python 3+) + let prefix = match string { + StringLike::String(_) => AnyStringPrefix::Regular(StringLiteralPrefix::Empty), + StringLike::Bytes(_) => AnyStringPrefix::Bytes(ByteStringPrefix::Regular), + StringLike::FString(_) => AnyStringPrefix::Format(FStringPrefix::Regular), + }; + + // Only determining the preferred quote for the first string is sufficient + // because we don't support joining triple quoted strings with non triple quoted strings. + let quote = if let Ok(preferred_quote) = + Quote::try_from(normalizer.preferred_quote_style(first_part)) + { + for part in string.parts() { + let part_quote_metadata = + QuoteMetadata::from_part(part, context, preferred_quote); + + if let Some(merged) = merged_quotes.as_mut() { + *merged = part_quote_metadata.merge(merged)?; + } else { + merged_quotes = Some(part_quote_metadata); + } + } + + merged_quotes?.choose(preferred_quote) + } else { + // Use the quotes of the first part if the quotes should be preserved. + first_part.flags().quote_style() + }; + + Some(AnyStringFlags::new(prefix, quote, false)) + } + + if !string.is_implicit_concatenated() { + return None; + } + + Some(Self { + flags: merge_flags(string, context)?, + string, + docstring: false, + }) + } + + pub(crate) fn set_docstring(&mut self, is_docstring: bool) { + self.docstring = is_docstring; + } + + pub(crate) fn string(&self) -> StringLike<'a> { + self.string + } +} + +impl Format<PyFormatContext<'_>> for FormatImplicitConcatenatedStringFlat<'_> { + fn fmt(&self, f: &mut Formatter<PyFormatContext<'_>>) -> FormatResult<()> { + // Merges all string parts into a single string. + let quotes = StringQuotes::from(self.flags); + + write!(f, [self.flags.prefix(), quotes])?; + + let mut parts = self.string.parts().peekable(); + + // Trim implicit concatenated strings in docstring positions. + // Skip over any trailing parts that are all whitespace. + // Leading parts are handled as part of the formatting loop below. + if self.docstring { + for part in self.string.parts().rev() { + assert!(part.is_string_literal()); + + if f.context() + .locator() + .slice(part.content_range()) + .trim() + .is_empty() + { + // Don't format the part. + parts.next_back(); + } else { + break; + } + } + } + + let mut first_non_empty = self.docstring; + + while let Some(part) = parts.next() { + match part { + StringLikePart::String(_) | StringLikePart::Bytes(_) => { + FormatLiteralContent { + range: part.content_range(), + flags: self.flags, + is_fstring: false, + trim_start: first_non_empty && self.docstring, + trim_end: self.docstring && parts.peek().is_none(), + } + .fmt(f)?; + + if first_non_empty { + first_non_empty = f + .context() + .locator() + .slice(part.content_range()) + .trim_start() + .is_empty(); + } + } + + StringLikePart::FString(f_string) => { + if is_f_string_formatting_enabled(f.context()) { + for element in &f_string.elements { + match element { + FStringElement::Literal(literal) => { + FormatLiteralContent { + range: literal.range(), + flags: self.flags, + is_fstring: true, + trim_end: false, + trim_start: false, + } + .fmt(f)?; + } + // Formatting the expression here and in the expanded version is safe **only** + // because we assert that the f-string never contains any comments. + FStringElement::Expression(expression) => { + let context = FStringContext::new( + self.flags, + FStringLayout::from_f_string( + f_string, + &f.context().locator(), + ), + ); + + FormatFStringExpressionElement::new(expression, context) + .fmt(f)?; + } + } + } + } else { + FormatLiteralContent { + range: part.content_range(), + flags: self.flags, + is_fstring: true, + trim_end: false, + trim_start: false, + } + .fmt(f)?; + } + } + } + } + + quotes.fmt(f) + } +} + +struct FormatLiteralContent { + range: TextRange, + flags: AnyStringFlags, + is_fstring: bool, + trim_start: bool, + trim_end: bool, +} + +impl Format<PyFormatContext<'_>> for FormatLiteralContent { + fn fmt(&self, f: &mut PyFormatter) -> FormatResult<()> { + let content = f.context().locator().slice(self.range); + let mut normalized = normalize_string( + content, + 0, + self.flags, + self.flags.is_f_string() && !self.is_fstring, + // TODO: Remove the argument from `normalize_string` when promoting the `is_f_string_formatting_enabled` preview style. + self.flags.is_f_string() && !is_f_string_formatting_enabled(f.context()), + is_f_string_formatting_enabled(f.context()), + ); + + // Trim the start and end of the string if it's the first or last part of a docstring. + // This is rare, so don't bother with optimizing to use `Cow`. + if self.trim_start { + let trimmed = normalized.trim_start(); + if trimmed.len() < normalized.len() { + normalized = trimmed.to_string().into(); + } + } + + if self.trim_end { + let trimmed = normalized.trim_end(); + if trimmed.len() < normalized.len() { + normalized = trimmed.to_string().into(); + } + } + + if !normalized.is_empty() { + match &normalized { + Cow::Borrowed(_) => source_text_slice(self.range).fmt(f)?, + Cow::Owned(normalized) => text(normalized).fmt(f)?, + } + + if self.trim_end && needs_chaperone_space(self.flags, &normalized, f.context()) { + space().fmt(f)?; + } + } + Ok(()) + } +} diff --git a/crates/ruff_python_formatter/src/string/mod.rs b/crates/ruff_python_formatter/src/string/mod.rs index 3eaf87121f459..dba9adc555bee 100644 --- a/crates/ruff_python_formatter/src/string/mod.rs +++ b/crates/ruff_python_formatter/src/string/mod.rs @@ -1,25 +1,21 @@ use memchr::memchr2; pub(crate) use normalize::{normalize_string, NormalizedString, StringNormalizer}; -use ruff_formatter::format_args; use ruff_python_ast::str::Quote; use ruff_python_ast::{ self as ast, str_prefix::{AnyStringPrefix, StringLiteralPrefix}, - AnyStringFlags, StringFlags, StringLike, StringLikePart, + AnyStringFlags, StringFlags, }; use ruff_source_file::Locator; use ruff_text_size::Ranged; -use crate::comments::{leading_comments, trailing_comments}; use crate::expression::expr_f_string::f_string_quoting; -use crate::expression::parentheses::in_parentheses_only_soft_line_break_or_space; -use crate::other::f_string::FormatFString; -use crate::other::string_literal::StringLiteralKind; use crate::prelude::*; use crate::QuoteStyle; pub(crate) mod docstring; +pub(crate) mod implicit; mod normalize; #[derive(Copy, Clone, Debug, Default)] @@ -29,57 +25,6 @@ pub(crate) enum Quoting { Preserve, } -/// Formats any implicitly concatenated string. This could be any valid combination -/// of string, bytes or f-string literals. -pub(crate) struct FormatImplicitConcatenatedString<'a> { - string: StringLike<'a>, -} - -impl<'a> FormatImplicitConcatenatedString<'a> { - pub(crate) fn new(string: impl Into<StringLike<'a>>) -> Self { - Self { - string: string.into(), - } - } -} - -impl Format<PyFormatContext<'_>> for FormatImplicitConcatenatedString<'_> { - fn fmt(&self, f: &mut PyFormatter) -> FormatResult<()> { - let comments = f.context().comments().clone(); - let quoting = self.string.quoting(&f.context().locator()); - - let mut joiner = f.join_with(in_parentheses_only_soft_line_break_or_space()); - - for part in self.string.parts() { - let part_comments = comments.leading_dangling_trailing(&part); - - let format_part = format_with(|f: &mut PyFormatter| match part { - StringLikePart::String(part) => { - let kind = if self.string.is_fstring() { - #[allow(deprecated)] - StringLiteralKind::InImplicitlyConcatenatedFString(quoting) - } else { - StringLiteralKind::String - }; - - part.format().with_options(kind).fmt(f) - } - StringLikePart::Bytes(bytes_literal) => bytes_literal.format().fmt(f), - StringLikePart::FString(part) => FormatFString::new(part, quoting).fmt(f), - }); - - joiner.entry(&format_args![ - line_suffix_boundary(), - leading_comments(part_comments.leading), - format_part, - trailing_comments(part_comments.trailing) - ]); - } - - joiner.finish() - } -} - impl Format<PyFormatContext<'_>> for AnyStringPrefix { fn fmt(&self, f: &mut PyFormatter) -> FormatResult<()> { // Remove the unicode prefix `u` if any because it is meaningless in Python 3+. @@ -159,12 +104,10 @@ impl StringLikeExtensions for ast::StringLike<'_> { fn is_multiline(&self, source: &str) -> bool { match self { - Self::String(_) | Self::Bytes(_) => { - self.parts() - .next() - .is_some_and(|part| part.flags().is_triple_quoted()) + Self::String(_) | Self::Bytes(_) => self.parts().any(|part| { + part.flags().is_triple_quoted() && memchr2(b'\n', b'\r', source[self.range()].as_bytes()).is_some() - } + }), Self::FString(fstring) => { memchr2(b'\n', b'\r', source[fstring.range].as_bytes()).is_some() } diff --git a/crates/ruff_python_formatter/src/string/normalize.rs b/crates/ruff_python_formatter/src/string/normalize.rs index 6836ad80828ae..3defefb75fa67 100644 --- a/crates/ruff_python_formatter/src/string/normalize.rs +++ b/crates/ruff_python_formatter/src/string/normalize.rs @@ -3,7 +3,10 @@ use std::cmp::Ordering; use std::iter::FusedIterator; use ruff_formatter::FormatContext; -use ruff_python_ast::{str::Quote, AnyStringFlags, StringFlags, StringLikePart}; +use ruff_python_ast::visitor::source_order::SourceOrderVisitor; +use ruff_python_ast::{ + str::Quote, AnyStringFlags, BytesLiteral, FString, StringFlags, StringLikePart, StringLiteral, +}; use ruff_text_size::{Ranged, TextRange}; use crate::context::FStringState; @@ -14,7 +17,7 @@ use crate::QuoteStyle; pub(crate) struct StringNormalizer<'a, 'src> { quoting: Quoting, - preferred_quote_style: QuoteStyle, + preferred_quote_style: Option<QuoteStyle>, context: &'a PyFormatContext<'src>, } @@ -22,13 +25,13 @@ impl<'a, 'src> StringNormalizer<'a, 'src> { pub(crate) fn from_context(context: &'a PyFormatContext<'src>) -> Self { Self { quoting: Quoting::default(), - preferred_quote_style: context.options().quote_style(), + preferred_quote_style: None, context, } } pub(crate) fn with_preferred_quote_style(mut self, quote_style: QuoteStyle) -> Self { - self.preferred_quote_style = quote_style; + self.preferred_quote_style = Some(quote_style); self } @@ -37,48 +40,59 @@ impl<'a, 'src> StringNormalizer<'a, 'src> { self } - fn quoting(&self, string: StringLikePart) -> Quoting { - if let FStringState::InsideExpressionElement(context) = self.context.f_string_state() { - // If we're inside an f-string, we need to make sure to preserve the - // existing quotes unless we're inside a triple-quoted f-string and - // the inner string itself isn't triple-quoted. For example: - // - // ```python - // f"""outer {"inner"}""" # Valid - // f"""outer {"""inner"""}""" # Invalid - // ``` - // - // Or, if the target version supports PEP 701. - // - // The reason to preserve the quotes is based on the assumption that - // the original f-string is valid in terms of quoting, and we don't - // want to change that to make it invalid. - if (context.f_string().flags().is_triple_quoted() && !string.flags().is_triple_quoted()) - || self.context.options().target_version().supports_pep_701() - { - self.quoting - } else { - Quoting::Preserve - } - } else { - self.quoting - } - } + /// Determines the preferred quote style for `string`. + /// The formatter should use the preferred quote style unless + /// it can't because the string contains the preferred quotes OR + /// it leads to more escaping. + /// + /// Note: If you add more cases here where we return `QuoteStyle::Preserve`, + /// make sure to also add them to [`FormatImplicitConcatenatedStringFlat::new`]. + pub(super) fn preferred_quote_style(&self, string: StringLikePart) -> QuoteStyle { + match self.quoting { + Quoting::Preserve => QuoteStyle::Preserve, + Quoting::CanChange => { + let preferred_quote_style = self + .preferred_quote_style + .unwrap_or(self.context.options().quote_style()); - /// Computes the strings preferred quotes. - pub(crate) fn choose_quotes(&self, string: StringLikePart) -> QuoteSelection { - let raw_content = self.context.locator().slice(string.content_range()); - let first_quote_or_normalized_char_offset = raw_content - .bytes() - .position(|b| matches!(b, b'\\' | b'"' | b'\'' | b'\r' | b'{')); - let string_flags = string.flags(); + if preferred_quote_style.is_preserve() { + return QuoteStyle::Preserve; + } + + // There are two cases where it's necessary to preserve the quotes + // if the target version is pre 3.12 and the part is an f-string. + if !self.context.options().target_version().supports_pep_701() { + if let StringLikePart::FString(fstring) = string { + // An f-string expression contains a debug text with a quote character + // because the formatter will emit the debug expression **exactly** the same as in the source text. + if is_fstring_with_quoted_debug_expression(fstring, self.context) { + return QuoteStyle::Preserve; + } + + // An f-string expression that contains a triple quoted string literal expression + // that contains a quote. + if is_fstring_with_triple_quoted_literal_expression_containing_quotes( + fstring, + self.context, + ) { + return QuoteStyle::Preserve; + } + } + } + + // For f-strings prefer alternating the quotes unless The outer string is triple quoted and the inner isn't. + if let FStringState::InsideExpressionElement(parent_context) = + self.context.f_string_state() + { + let parent_flags = parent_context.f_string().flags(); + + if !parent_flags.is_triple_quoted() || string.flags().is_triple_quoted() { + return QuoteStyle::from(parent_flags.quote_style().opposite()); + } + } - let new_kind = match self.quoting(string) { - Quoting::Preserve => string_flags, - Quoting::CanChange => { // Per PEP 8, always prefer double quotes for triple-quoted strings. - // Except when using quote-style-preserve. - let preferred_style = if string_flags.is_triple_quoted() { + if string.flags().is_triple_quoted() { // ... unless we're formatting a code snippet inside a docstring, // then we specifically want to invert our quote style to avoid // writing out invalid Python. @@ -126,39 +140,52 @@ impl<'a, 'src> StringNormalizer<'a, 'src> { // if it doesn't have perfect alignment with PEP8. if let Some(quote) = self.context.docstring() { QuoteStyle::from(quote.opposite()) - } else if self.preferred_quote_style.is_preserve() { - QuoteStyle::Preserve } else { QuoteStyle::Double } } else { - self.preferred_quote_style - }; + preferred_quote_style + } + } + } + } - if let Ok(preferred_quote) = Quote::try_from(preferred_style) { - if let Some(first_quote_or_normalized_char_offset) = - first_quote_or_normalized_char_offset - { - if string_flags.is_raw_string() { - choose_quotes_for_raw_string( - &raw_content[first_quote_or_normalized_char_offset..], - string_flags, - preferred_quote, - ) - } else { - choose_quotes_impl( - &raw_content[first_quote_or_normalized_char_offset..], - string_flags, - preferred_quote, - ) - } - } else { - string_flags.with_quote_style(preferred_quote) - } + /// Computes the strings preferred quotes. + pub(crate) fn choose_quotes(&self, string: StringLikePart) -> QuoteSelection { + let raw_content = self.context.locator().slice(string.content_range()); + let first_quote_or_normalized_char_offset = raw_content + .bytes() + .position(|b| matches!(b, b'\\' | b'"' | b'\'' | b'\r' | b'{')); + let string_flags = string.flags(); + let preferred_style = self.preferred_quote_style(string); + + let new_kind = match ( + Quote::try_from(preferred_style), + first_quote_or_normalized_char_offset, + ) { + // The string contains no quotes so it's safe to use the preferred quote style + (Ok(preferred_quote), None) => string_flags.with_quote_style(preferred_quote), + + // The preferred quote style is single or double quotes, and the string contains a quote or + // another character that may require escaping + (Ok(preferred_quote), Some(first_quote_or_normalized_char_offset)) => { + let metadata = if string.is_fstring() { + QuoteMetadata::from_part(string, self.context, preferred_quote) } else { - string_flags - } + QuoteMetadata::from_str( + &raw_content[first_quote_or_normalized_char_offset..], + string.flags(), + preferred_quote, + ) + }; + + let quote = metadata.choose(preferred_quote); + + string_flags.with_quote_style(quote) } + + // The preferred quote style is to preserve the quotes, so let's do that. + (Err(()), _) => string_flags, }; QuoteSelection { @@ -181,6 +208,8 @@ impl<'a, 'src> StringNormalizer<'a, 'src> { quote_selection.flags, // TODO: Remove the `b'{'` in `choose_quotes` when promoting the // `format_fstring` preview style + false, + false, is_f_string_formatting_enabled(self.context), ) } else { @@ -209,119 +238,201 @@ impl QuoteSelection { } } -#[derive(Debug)] -pub(crate) struct NormalizedString<'a> { - /// Holds data about the quotes and prefix of the string - flags: AnyStringFlags, - - /// The range of the string's content in the source (minus prefix and quotes). - content_range: TextRange, +#[derive(Clone, Debug)] +pub(crate) struct QuoteMetadata { + kind: QuoteMetadataKind, - /// The normalized text - text: Cow<'a, str>, + /// The quote style in the source. + source_style: Quote, } -impl<'a> NormalizedString<'a> { - pub(crate) fn text(&self) -> &Cow<'a, str> { - &self.text - } +/// Tracks information about the used quotes in a string which is used +/// to choose the quotes for a part. +impl QuoteMetadata { + pub(crate) fn from_part( + part: StringLikePart, + context: &PyFormatContext, + preferred_quote: Quote, + ) -> Self { + match part { + StringLikePart::String(_) | StringLikePart::Bytes(_) => { + let text = context.locator().slice(part.content_range()); + + Self::from_str(text, part.flags(), preferred_quote) + } + StringLikePart::FString(fstring) => { + if is_f_string_formatting_enabled(context) { + // For f-strings, only consider the quotes inside string-literals but ignore + // quotes inside expressions. This allows both the outer and the nested literals + // to make the optimal local-choice to reduce the total number of quotes necessary. + // This doesn't require any pre 312 special handling because an expression + // can never contain the outer quote character, not even escaped: + // ```python + // f"{'escaping a quote like this \" is a syntax error pre 312'}" + // ``` + let mut literals = fstring.elements.literals(); + + let Some(first) = literals.next() else { + return QuoteMetadata::from_str("", part.flags(), preferred_quote); + }; + + let mut metadata = QuoteMetadata::from_str( + context.locator().slice(first.range()), + fstring.flags.into(), + preferred_quote, + ); + + for literal in literals { + metadata = metadata + .merge(&QuoteMetadata::from_str( + context.locator().slice(literal.range()), + fstring.flags.into(), + preferred_quote, + )) + .expect("Merge to succeed because all parts have the same flags"); + } - pub(crate) fn flags(&self) -> AnyStringFlags { - self.flags + metadata + } else { + let text = context.locator().slice(part.content_range()); + + Self::from_str(text, part.flags(), preferred_quote) + } + } + } } -} -impl Ranged for NormalizedString<'_> { - fn range(&self) -> TextRange { - self.content_range + pub(crate) fn from_str(text: &str, flags: AnyStringFlags, preferred_quote: Quote) -> Self { + let kind = if flags.is_raw_string() { + QuoteMetadataKind::raw(text, preferred_quote, flags.is_triple_quoted()) + } else if flags.is_triple_quoted() { + QuoteMetadataKind::triple_quoted(text, preferred_quote) + } else { + QuoteMetadataKind::regular(text) + }; + + Self { + kind, + source_style: flags.quote_style(), + } } -} -impl Format<PyFormatContext<'_>> for NormalizedString<'_> { - fn fmt(&self, f: &mut Formatter<PyFormatContext<'_>>) -> FormatResult<()> { - let quotes = StringQuotes::from(self.flags); - ruff_formatter::write!(f, [self.flags.prefix(), quotes])?; - match &self.text { - Cow::Borrowed(_) => { - source_text_slice(self.range()).fmt(f)?; + pub(super) fn choose(&self, preferred_quote: Quote) -> Quote { + match self.kind { + QuoteMetadataKind::Raw { contains_preferred } => { + if contains_preferred { + self.source_style + } else { + preferred_quote + } } - Cow::Owned(normalized) => { - text(normalized).fmt(f)?; + QuoteMetadataKind::Triple { contains_preferred } => { + if contains_preferred { + self.source_style + } else { + preferred_quote + } } + QuoteMetadataKind::Regular { + single_quotes, + double_quotes, + } => match single_quotes.cmp(&double_quotes) { + Ordering::Less => Quote::Single, + Ordering::Equal => preferred_quote, + Ordering::Greater => Quote::Double, + }, } - quotes.fmt(f) + } + + /// Merges the quotes metadata of different literals. + /// + /// ## Raw and triple quoted strings + /// Merging raw and triple quoted strings is only correct if all literals are from the same part. + /// E.g. it's okay to merge triple and raw strings from a single `FString` part's literals + /// but it isn't safe to merge raw and triple quoted strings from different parts of an implicit + /// concatenated string. Where safe means, it may lead to incorrect results. + pub(super) fn merge(self, other: &QuoteMetadata) -> Option<QuoteMetadata> { + let kind = match (self.kind, other.kind) { + ( + QuoteMetadataKind::Regular { + single_quotes: self_single, + double_quotes: self_double, + }, + QuoteMetadataKind::Regular { + single_quotes: other_single, + double_quotes: other_double, + }, + ) => QuoteMetadataKind::Regular { + single_quotes: self_single + other_single, + double_quotes: self_double + other_double, + }, + + // Can't merge quotes from raw strings (even when both strings are raw) + ( + QuoteMetadataKind::Raw { + contains_preferred: self_contains_preferred, + }, + QuoteMetadataKind::Raw { + contains_preferred: other_contains_preferred, + }, + ) => QuoteMetadataKind::Raw { + contains_preferred: self_contains_preferred || other_contains_preferred, + }, + + ( + QuoteMetadataKind::Triple { + contains_preferred: self_contains_preferred, + }, + QuoteMetadataKind::Triple { + contains_preferred: other_contains_preferred, + }, + ) => QuoteMetadataKind::Triple { + contains_preferred: self_contains_preferred || other_contains_preferred, + }, + + (_, _) => return None, + }; + + Some(Self { + kind, + source_style: self.source_style, + }) } } -/// Choose the appropriate quote style for a raw string. -/// -/// The preferred quote style is chosen unless the string contains unescaped quotes of the -/// preferred style. For example, `r"foo"` is chosen over `r'foo'` if the preferred quote -/// style is double quotes. -fn choose_quotes_for_raw_string( - input: &str, - flags: AnyStringFlags, - preferred_quote: Quote, -) -> AnyStringFlags { - let preferred_quote_char = preferred_quote.as_char(); - let mut chars = input.chars().peekable(); - let contains_unescaped_configured_quotes = loop { - match chars.next() { - Some('\\') => { - // Ignore escaped characters - chars.next(); - } - // `"` or `'` - Some(c) if c == preferred_quote_char => { - if !flags.is_triple_quoted() { - break true; - } +#[derive(Copy, Clone, Debug)] +enum QuoteMetadataKind { + /// A raw string. + /// + /// For raw strings it's only possible to change the quotes if the preferred quote style + /// isn't used inside the string. + Raw { contains_preferred: bool }, - match chars.peek() { - // We can't turn `r'''\""'''` into `r"""\"""""`, this would confuse the parser - // about where the closing triple quotes start - None => break true, - Some(next) if *next == preferred_quote_char => { - // `""` or `''` - chars.next(); + /// Regular (non raw) triple quoted string. + /// + /// For triple quoted strings it's only possible to change the quotes if no + /// triple of the preferred quotes is used inside the string. + Triple { contains_preferred: bool }, - // We can't turn `r'''""'''` into `r""""""""`, nor can we have - // `"""` or `'''` respectively inside the string - if chars.peek().is_none() || chars.peek() == Some(&preferred_quote_char) { - break true; - } - } - _ => {} - } - } - Some(_) => continue, - None => break false, - } - }; - if contains_unescaped_configured_quotes { - flags - } else { - flags.with_quote_style(preferred_quote) - } + /// A single quoted string that uses either double or single quotes. + /// + /// For regular strings it's desired to pick the quote style that requires the least escaping. + /// E.g. pick single quotes for `'A "dog"'` because using single quotes would require escaping + /// the two `"`. + Regular { + single_quotes: u32, + double_quotes: u32, + }, } -/// Choose the appropriate quote style for a string. -/// -/// For single quoted strings, the preferred quote style is used, unless the alternative quote style -/// would require fewer escapes. -/// -/// For triple quoted strings, the preferred quote style is always used, unless the string contains -/// a triplet of the quote character (e.g., if double quotes are preferred, double quotes will be -/// used unless the string contains `"""`). -fn choose_quotes_impl( - input: &str, - flags: AnyStringFlags, - preferred_quote: Quote, -) -> AnyStringFlags { - let quote = if flags.is_triple_quoted() { +impl QuoteMetadataKind { + /// For triple quoted strings, the preferred quote style can't be used if the string contains + /// a tripled of the quote character (e.g., if double quotes are preferred, double quotes will be + /// used unless the string contains `"""`). + fn triple_quoted(content: &str, preferred_quote: Quote) -> Self { // True if the string contains a triple quote sequence of the configured quote style. let mut uses_triple_quotes = false; - let mut chars = input.chars().peekable(); + let mut chars = content.chars().peekable(); while let Some(c) = chars.next() { let preferred_quote_char = preferred_quote.as_char(); @@ -369,18 +480,18 @@ fn choose_quotes_impl( } } - if uses_triple_quotes { - // String contains a triple quote sequence of the configured quote style. - // Keep the existing quote style. - flags.quote_style() - } else { - preferred_quote + Self::Triple { + contains_preferred: uses_triple_quotes, } - } else { + } + + /// For single quoted strings, the preferred quote style is used, unless the alternative quote style + /// would require fewer escapes. + fn regular(text: &str) -> Self { let mut single_quotes = 0u32; let mut double_quotes = 0u32; - for c in input.chars() { + for c in text.chars() { match c { '\'' => { single_quotes += 1; @@ -394,25 +505,107 @@ fn choose_quotes_impl( } } - match single_quotes.cmp(&double_quotes) { - Ordering::Less => Quote::Single, - Ordering::Equal => preferred_quote, - Ordering::Greater => Quote::Double, + Self::Regular { + single_quotes, + double_quotes, } - }; + } - flags.with_quote_style(quote) + /// Computes if a raw string uses the preferred quote. If it does, then it's not possible + /// to change the quote style because it would require escaping which isn't possible in raw strings. + fn raw(text: &str, preferred: Quote, triple_quoted: bool) -> Self { + let mut chars = text.chars().peekable(); + let preferred_quote_char = preferred.as_char(); + + let contains_unescaped_configured_quotes = loop { + match chars.next() { + Some('\\') => { + // Ignore escaped characters + chars.next(); + } + // `"` or `'` + Some(c) if c == preferred_quote_char => { + if !triple_quoted { + break true; + } + + match chars.peek() { + // We can't turn `r'''\""'''` into `r"""\"""""`, this would confuse the parser + // about where the closing triple quotes start + None => break true, + Some(next) if *next == preferred_quote_char => { + // `""` or `''` + chars.next(); + + // We can't turn `r'''""'''` into `r""""""""`, nor can we have + // `"""` or `'''` respectively inside the string + if chars.peek().is_none() || chars.peek() == Some(&preferred_quote_char) + { + break true; + } + } + _ => {} + } + } + Some(_) => continue, + None => break false, + } + }; + + Self::Raw { + contains_preferred: contains_unescaped_configured_quotes, + } + } +} + +#[derive(Debug)] +pub(crate) struct NormalizedString<'a> { + /// Holds data about the quotes and prefix of the string + flags: AnyStringFlags, + + /// The range of the string's content in the source (minus prefix and quotes). + content_range: TextRange, + + /// The normalized text + text: Cow<'a, str>, +} + +impl<'a> NormalizedString<'a> { + pub(crate) fn text(&self) -> &Cow<'a, str> { + &self.text + } + + pub(crate) fn flags(&self) -> AnyStringFlags { + self.flags + } +} + +impl Ranged for NormalizedString<'_> { + fn range(&self) -> TextRange { + self.content_range + } +} + +impl Format<PyFormatContext<'_>> for NormalizedString<'_> { + fn fmt(&self, f: &mut Formatter<PyFormatContext<'_>>) -> FormatResult<()> { + let quotes = StringQuotes::from(self.flags); + ruff_formatter::write!(f, [self.flags.prefix(), quotes])?; + match &self.text { + Cow::Borrowed(_) => source_text_slice(self.range()).fmt(f)?, + Cow::Owned(normalized) => text(normalized).fmt(f)?, + } + + quotes.fmt(f) + } } -/// Adds the necessary quote escapes and removes unnecessary escape sequences when quoting `input` -/// with the provided [`StringQuotes`] style. -/// -/// Returns the normalized string and whether it contains new lines. pub(crate) fn normalize_string( input: &str, start_offset: usize, - flags: AnyStringFlags, - format_fstring: bool, + new_flags: AnyStringFlags, + escape_braces: bool, + flip_nested_fstring_quotes: bool, + format_f_string: bool, ) -> Cow<str> { // The normalized string if `input` is not yet normalized. // `output` must remain empty if `input` is already normalized. @@ -421,29 +614,39 @@ pub(crate) fn normalize_string( // If `last_index` is `0` at the end, then the input is already normalized and can be returned as is. let mut last_index = 0; - let quote = flags.quote_style(); + let quote = new_flags.quote_style(); let preferred_quote = quote.as_char(); let opposite_quote = quote.opposite().as_char(); let mut chars = CharIndicesWithOffset::new(input, start_offset).peekable(); - let is_raw = flags.is_raw_string(); - let is_fstring = !format_fstring && flags.is_f_string(); + let is_raw = new_flags.is_raw_string(); + + let is_fstring = !format_f_string && new_flags.is_f_string(); let mut formatted_value_nesting = 0u32; while let Some((index, c)) = chars.next() { - if is_fstring && matches!(c, '{' | '}') { - if chars.peek().copied().is_some_and(|(_, next)| next == c) { - // Skip over the second character of the double braces - chars.next(); - } else if c == '{' { - formatted_value_nesting += 1; - } else { - // Safe to assume that `c == '}'` here because of the matched pattern above - formatted_value_nesting = formatted_value_nesting.saturating_sub(1); + if matches!(c, '{' | '}') { + if escape_braces { + // Escape `{` and `}` when converting a regular string literal to an f-string literal. + output.push_str(&input[last_index..=index]); + output.push(c); + last_index = index + c.len_utf8(); + continue; + } else if is_fstring { + if chars.peek().copied().is_some_and(|(_, next)| next == c) { + // Skip over the second character of the double braces + chars.next(); + } else if c == '{' { + formatted_value_nesting += 1; + } else { + // Safe to assume that `c == '}'` here because of the matched pattern above + formatted_value_nesting = formatted_value_nesting.saturating_sub(1); + } + continue; } - continue; } + if c == '\r' { output.push_str(&input[last_index..index]); @@ -466,8 +669,10 @@ pub(crate) fn normalize_string( } else { // Length of the `\` plus the length of the escape sequence character (`u` | `U` | `x`) let escape_start_len = '\\'.len_utf8() + next.len_utf8(); - if let Some(normalised) = UnicodeEscape::new(next, !flags.is_byte_string()) - .and_then(|escape| escape.normalize(&input[index + escape_start_len..])) + if let Some(normalised) = + UnicodeEscape::new(next, !new_flags.is_byte_string()).and_then( + |escape| escape.normalize(&input[index + escape_start_len..]), + ) { let escape_start_offset = index + escape_start_len; if let Cow::Owned(normalised) = &normalised { @@ -485,7 +690,7 @@ pub(crate) fn normalize_string( } } - if !flags.is_triple_quoted() { + if !new_flags.is_triple_quoted() { #[allow(clippy::if_same_then_else)] if next == opposite_quote && formatted_value_nesting == 0 { // Remove the escape by ending before the backslash and starting again with the quote @@ -498,7 +703,7 @@ pub(crate) fn normalize_string( } } } - } else if !flags.is_triple_quoted() + } else if !new_flags.is_triple_quoted() && c == preferred_quote && formatted_value_nesting == 0 { @@ -507,18 +712,24 @@ pub(crate) fn normalize_string( output.push('\\'); output.push(c); last_index = index + preferred_quote.len_utf8(); + } else if c == preferred_quote + && flip_nested_fstring_quotes + && formatted_value_nesting > 0 + { + // Flip the quotes + output.push_str(&input[last_index..index]); + output.push(opposite_quote); + last_index = index + preferred_quote.len_utf8(); } } } - let normalized = if last_index == 0 { + if last_index == 0 { Cow::Borrowed(input) } else { output.push_str(&input[last_index..]); Cow::Owned(output) - }; - - normalized + } } #[derive(Clone, Debug)] @@ -667,17 +878,142 @@ impl UnicodeEscape { } } +/// Returns `true` if `string` is an f-string part that contains a debug expression that uses quotes +/// and the format target is pre Python 312 +/// We can't join f-strings where: +/// +/// ```python +/// f"{10 + len('bar')=}" +/// f'{10 + len("bar")=}' +/// f""""{10 + len('''bar''')=}""" +/// ``` +pub(super) fn is_fstring_with_quoted_debug_expression( + fstring: &FString, + context: &PyFormatContext, +) -> bool { + if fstring.elements.expressions().any(|expression| { + if expression.debug_text.is_some() { + let content = context.locator().slice(expression.range()); + match fstring.flags.quote_style() { + Quote::Single => { + if fstring.flags.is_triple_quoted() { + content.contains(r#"""""#) + } else { + content.contains('"') + } + } + Quote::Double => { + if fstring.flags.is_triple_quoted() { + content.contains("'''") + } else { + content.contains('\'') + } + } + } + } else { + false + } + }) { + return true; + } + + false +} + +/// Tests if the `fstring` contains any triple quoted string, byte, or f-string literal that +/// contains a quote character opposite to its own quote character. +/// +/// ```python +/// f'{"""other " """}' +/// ``` +/// +/// We can't flip the quote of the outer f-string because it would result in invalid syntax: +/// ```python +/// f"{'''other " '''}' +/// ``` +pub(super) fn is_fstring_with_triple_quoted_literal_expression_containing_quotes( + fstring: &FString, + context: &PyFormatContext, +) -> bool { + struct Visitor<'a> { + context: &'a PyFormatContext<'a>, + found: bool, + } + + impl Visitor<'_> { + fn visit_string_like_part(&mut self, part: StringLikePart) { + if !part.flags().is_triple_quoted() || self.found { + return; + } + + let contains_quotes = match part { + StringLikePart::String(_) | StringLikePart::Bytes(_) => { + self.contains_quote(part.content_range(), part.flags()) + } + StringLikePart::FString(fstring) => { + let mut contains_quotes = false; + for literal in fstring.elements.literals() { + if self.contains_quote(literal.range(), fstring.flags.into()) { + contains_quotes = true; + break; + } + } + + contains_quotes + } + }; + + if contains_quotes { + self.found = true; + } + } + + fn contains_quote(&self, range: TextRange, flags: AnyStringFlags) -> bool { + self.context + .locator() + .slice(range) + .contains(flags.quote_style().as_char()) + } + } + + impl SourceOrderVisitor<'_> for Visitor<'_> { + fn visit_f_string(&mut self, f_string: &FString) { + self.visit_string_like_part(StringLikePart::FString(f_string)); + } + + fn visit_string_literal(&mut self, string_literal: &StringLiteral) { + self.visit_string_like_part(StringLikePart::String(string_literal)); + } + + fn visit_bytes_literal(&mut self, bytes_literal: &BytesLiteral) { + self.visit_string_like_part(StringLikePart::Bytes(bytes_literal)); + } + } + + let mut visitor = Visitor { + context, + found: false, + }; + + ruff_python_ast::visitor::source_order::walk_f_string(&mut visitor, fstring); + + visitor.found +} + #[cfg(test)] mod tests { use std::borrow::Cow; + use ruff_python_ast::str_prefix::FStringPrefix; use ruff_python_ast::{ str::Quote, str_prefix::{AnyStringPrefix, ByteStringPrefix}, AnyStringFlags, }; - use super::{normalize_string, UnicodeEscape}; + use crate::string::normalize_string; + + use super::UnicodeEscape; #[test] fn normalize_32_escape() { @@ -701,9 +1037,35 @@ mod tests { Quote::Double, false, ), + false, + false, true, ); assert_eq!(r"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a", &normalized); } + + #[test] + fn normalize_nested_fstring() { + let input = + r#"With single quote: ' {my_dict['foo']} With double quote: " {my_dict["bar"]}"#; + + let normalized = normalize_string( + input, + 0, + AnyStringFlags::new( + AnyStringPrefix::Format(FStringPrefix::Regular), + Quote::Double, + false, + ), + false, + true, + false, + ); + + assert_eq!( + "With single quote: ' {my_dict['foo']} With double quote: \\\" {my_dict['bar']}", + &normalized + ); + } } diff --git a/crates/ruff_python_formatter/tests/normalizer.rs b/crates/ruff_python_formatter/tests/normalizer.rs index b2cfc4dd6b08c..b83a8cee0c35b 100644 --- a/crates/ruff_python_formatter/tests/normalizer.rs +++ b/crates/ruff_python_formatter/tests/normalizer.rs @@ -1,12 +1,16 @@ +use std::sync::LazyLock; use { itertools::Either::{Left, Right}, - once_cell::sync::Lazy, regex::Regex, }; use ruff_python_ast::visitor::transformer; use ruff_python_ast::visitor::transformer::Transformer; -use ruff_python_ast::{self as ast, Expr, Stmt}; +use ruff_python_ast::{ + self as ast, BytesLiteralFlags, Expr, FStringElement, FStringFlags, FStringLiteralElement, + FStringPart, Stmt, StringFlags, StringLiteralFlags, +}; +use ruff_text_size::{Ranged, TextRange}; /// A struct to normalize AST nodes for the purpose of comparing formatted representations for /// semantic equivalence. @@ -59,8 +63,137 @@ impl Transformer for Normalizer { transformer::walk_stmt(self, stmt); } + fn visit_expr(&self, expr: &mut Expr) { + // Ruff supports joining implicitly concatenated strings. The code below implements this + // at an AST level by joining the string literals in the AST if they can be joined (it doesn't mean that + // they'll be joined in the formatted output but they could). + // Comparable expression handles some of this by comparing the concatenated string + // but not joining here doesn't play nicely with other string normalizations done in the + // Normalizer. + match expr { + Expr::StringLiteral(string) => { + if string.value.is_implicit_concatenated() { + let can_join = string.value.iter().all(|literal| { + !literal.flags.is_triple_quoted() && !literal.flags.prefix().is_raw() + }); + + if can_join { + string.value = ast::StringLiteralValue::single(ast::StringLiteral { + value: string.value.to_str().to_string().into_boxed_str(), + range: string.range, + flags: StringLiteralFlags::default(), + }); + } + } + } + + Expr::BytesLiteral(bytes) => { + if bytes.value.is_implicit_concatenated() { + let can_join = bytes.value.iter().all(|literal| { + !literal.flags.is_triple_quoted() && !literal.flags.prefix().is_raw() + }); + + if can_join { + bytes.value = ast::BytesLiteralValue::single(ast::BytesLiteral { + value: bytes.value.bytes().collect(), + range: bytes.range, + flags: BytesLiteralFlags::default(), + }); + } + } + } + + Expr::FString(fstring) => { + if fstring.value.is_implicit_concatenated() { + let can_join = fstring.value.iter().all(|part| match part { + FStringPart::Literal(literal) => { + !literal.flags.is_triple_quoted() && !literal.flags.prefix().is_raw() + } + FStringPart::FString(string) => { + !string.flags.is_triple_quoted() && !string.flags.prefix().is_raw() + } + }); + + if can_join { + #[derive(Default)] + struct Collector { + elements: Vec<FStringElement>, + } + + impl Collector { + // The logic for concatenating adjacent string literals + // occurs here, implicitly: when we encounter a sequence + // of string literals, the first gets pushed to the + // `elements` vector, while subsequent strings + // are concatenated onto this top string. + fn push_literal(&mut self, literal: &str, range: TextRange) { + if let Some(FStringElement::Literal(existing_literal)) = + self.elements.last_mut() + { + let value = std::mem::take(&mut existing_literal.value); + let mut value = value.into_string(); + value.push_str(literal); + existing_literal.value = value.into_boxed_str(); + existing_literal.range = + TextRange::new(existing_literal.start(), range.end()); + } else { + self.elements.push(FStringElement::Literal( + FStringLiteralElement { + range, + value: literal.into(), + }, + )); + } + } + + fn push_expression( + &mut self, + expression: ast::FStringExpressionElement, + ) { + self.elements.push(FStringElement::Expression(expression)); + } + } + + let mut collector = Collector::default(); + + for part in &fstring.value { + match part { + ast::FStringPart::Literal(string_literal) => { + collector + .push_literal(&string_literal.value, string_literal.range); + } + ast::FStringPart::FString(fstring) => { + for element in &fstring.elements { + match element { + ast::FStringElement::Literal(literal) => { + collector + .push_literal(&literal.value, literal.range); + } + ast::FStringElement::Expression(expression) => { + collector.push_expression(expression.clone()); + } + } + } + } + } + } + + fstring.value = ast::FStringValue::single(ast::FString { + elements: collector.elements.into(), + range: fstring.range, + flags: FStringFlags::default(), + }); + } + } + } + + _ => {} + } + transformer::walk_expr(self, expr); + } + fn visit_string_literal(&self, string_literal: &mut ast::StringLiteral) { - static STRIP_DOC_TESTS: Lazy<Regex> = Lazy::new(|| { + static STRIP_DOC_TESTS: LazyLock<Regex> = LazyLock::new(|| { Regex::new( r"(?mx) ( @@ -75,14 +208,14 @@ impl Transformer for Normalizer { ) .unwrap() }); - static STRIP_RST_BLOCKS: Lazy<Regex> = Lazy::new(|| { + static STRIP_RST_BLOCKS: LazyLock<Regex> = LazyLock::new(|| { // This is kind of unfortunate, but it's pretty tricky (likely // impossible) to detect a reStructuredText block with a simple // regex. So we just look for the start of a block and remove // everything after it. Talk about a hammer. Regex::new(r"::(?s:.*)").unwrap() }); - static STRIP_MARKDOWN_BLOCKS: Lazy<Regex> = Lazy::new(|| { + static STRIP_MARKDOWN_BLOCKS: LazyLock<Regex> = LazyLock::new(|| { // This covers more than valid Markdown blocks, but that's OK. Regex::new(r"(```|~~~)\p{any}*(```|~~~|$)").unwrap() }); diff --git a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__composition_no_trailing_comma.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__composition_no_trailing_comma.py.snap index 4888f9a617d8c..6a9570acf64ef 100644 --- a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__composition_no_trailing_comma.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__composition_no_trailing_comma.py.snap @@ -610,5 +610,3 @@ class C: } ) ``` - - diff --git a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_long_strings.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_long_strings.py.snap index eeda12f088cf7..fa0173187df9c 100644 --- a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_long_strings.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_long_strings.py.snap @@ -667,21 +667,21 @@ log.info(f"""Skipping: {'a' == 'b'} {desc['ms_name']} {money=} {dte=} {pos_share "Arg #2", "Arg #3", "Arg #4", -@@ -315,80 +232,72 @@ - +@@ -316,79 +233,75 @@ triple_quote_string = """This is a really really really long triple quote string assignment and it should not be touched.""" --assert some_type_of_boolean_expression, ( + assert some_type_of_boolean_expression, ( - "Followed by a really really really long string that is used to provide context to" - " the AssertionError exception." --) -+assert some_type_of_boolean_expression, "Followed by a really really really long string that is used to provide context to the AssertionError exception." ++ "Followed by a really really really long string that is used to provide context to the AssertionError exception." + ) --assert some_type_of_boolean_expression, ( + assert some_type_of_boolean_expression, ( - "Followed by a really really really long string that is used to provide context to" - " the AssertionError exception, which uses dynamic string {}.".format("formatting") -+assert some_type_of_boolean_expression, "Followed by a really really really long string that is used to provide context to the AssertionError exception, which uses dynamic string {}.".format( -+ "formatting" ++ "Followed by a really really really long string that is used to provide context to the AssertionError exception, which uses dynamic string {}.".format( ++ "formatting" ++ ) ) assert some_type_of_boolean_expression, ( @@ -772,7 +772,7 @@ log.info(f"""Skipping: {'a' == 'b'} {desc['ms_name']} {money=} {dte=} {pos_share x, y, z, -@@ -397,7 +306,7 @@ +@@ -397,7 +310,7 @@ func_with_bad_parens( x, y, @@ -781,7 +781,7 @@ log.info(f"""Skipping: {'a' == 'b'} {desc['ms_name']} {money=} {dte=} {pos_share z, ) -@@ -408,50 +317,27 @@ +@@ -408,50 +321,27 @@ + CONCATENATED + "using the '+' operator." ) @@ -813,11 +813,10 @@ log.info(f"""Skipping: {'a' == 'b'} {desc['ms_name']} {money=} {dte=} {pos_share +backslashes = "This is a really long string with \"embedded\" double quotes and 'single' quotes that also handles checking for an even number of backslashes \\\\" +backslashes = "This is a really 'long' string with \"embedded double quotes\" and 'single' quotes that also handles checking for an odd number of backslashes \\\", like this...\\\\\\" --short_string = "Hi there." -+short_string = "Hi" " there." + short_string = "Hi there." -func_call(short_string="Hi there.") -+func_call(short_string=("Hi" " there.")) ++func_call(short_string=("Hi there.")) raw_strings = r"Don't" " get" r" merged" " unless they are all raw." @@ -841,7 +840,7 @@ log.info(f"""Skipping: {'a' == 'b'} {desc['ms_name']} {money=} {dte=} {pos_share long_unmergable_string_with_pragma = ( "This is a really long string that can't be merged because it has a likely pragma at the end" # type: ignore -@@ -468,51 +354,24 @@ +@@ -468,51 +358,24 @@ " of it." ) @@ -902,7 +901,7 @@ log.info(f"""Skipping: {'a' == 'b'} {desc['ms_name']} {money=} {dte=} {pos_share ) dict_with_lambda_values = { -@@ -524,65 +383,58 @@ +@@ -524,65 +387,58 @@ # Complex string concatenations with a method call in the middle. code = ( @@ -941,7 +940,7 @@ log.info(f"""Skipping: {'a' == 'b'} {desc['ms_name']} {money=} {dte=} {pos_share log.info( - "Skipping:" - f" {desc['db_id']} {foo('bar',x=123)} {'foo' != 'bar'} {(x := 'abc=')} {pos_share=} {desc['status']} {desc['exposure_max']}" -+ f'Skipping: {desc["db_id"]} {foo("bar", x=123)} {"foo" != "bar"} {(x := "abc=")} {pos_share=} {desc["status"]} {desc["exposure_max"]}' ++ f"Skipping: {desc['db_id']} {foo('bar', x=123)} {'foo' != 'bar'} {(x := 'abc=')} {pos_share=} {desc['status']} {desc['exposure_max']}" ) log.info( @@ -965,7 +964,7 @@ log.info(f"""Skipping: {'a' == 'b'} {desc['ms_name']} {money=} {dte=} {pos_share log.info( - "Skipping:" - f" {'a' == 'b' == 'c' == 'd'} {desc['ms_name']} {money=} {dte=} {pos_share=} {desc['status']} {desc['exposure_max']}" -+ f'Skipping: {"a" == "b" == "c" == "d"} {desc["ms_name"]} {money=} {dte=} {pos_share=} {desc["status"]} {desc["exposure_max"]}' ++ f"Skipping: {'a' == 'b' == 'c' == 'd'} {desc['ms_name']} {money=} {dte=} {pos_share=} {desc['status']} {desc['exposure_max']}" ) log.info( @@ -986,7 +985,7 @@ log.info(f"""Skipping: {'a' == 'b'} {desc['ms_name']} {money=} {dte=} {pos_share ) log.info( -@@ -590,5 +442,5 @@ +@@ -590,5 +446,5 @@ ) log.info( @@ -1232,10 +1231,14 @@ pragma_comment_string2 = "Lines which end with an inline pragma comment of the f triple_quote_string = """This is a really really really long triple quote string assignment and it should not be touched.""" -assert some_type_of_boolean_expression, "Followed by a really really really long string that is used to provide context to the AssertionError exception." +assert some_type_of_boolean_expression, ( + "Followed by a really really really long string that is used to provide context to the AssertionError exception." +) -assert some_type_of_boolean_expression, "Followed by a really really really long string that is used to provide context to the AssertionError exception, which uses dynamic string {}.".format( - "formatting" +assert some_type_of_boolean_expression, ( + "Followed by a really really really long string that is used to provide context to the AssertionError exception, which uses dynamic string {}.".format( + "formatting" + ) ) assert some_type_of_boolean_expression, ( @@ -1326,9 +1329,9 @@ backslashes = "This is a really long string with \"embedded\" double quotes and backslashes = "This is a really long string with \"embedded\" double quotes and 'single' quotes that also handles checking for an even number of backslashes \\\\" backslashes = "This is a really 'long' string with \"embedded double quotes\" and 'single' quotes that also handles checking for an odd number of backslashes \\\", like this...\\\\\\" -short_string = "Hi" " there." +short_string = "Hi there." -func_call(short_string=("Hi" " there.")) +func_call(short_string=("Hi there.")) raw_strings = r"Don't" " get" r" merged" " unless they are all raw." @@ -1406,7 +1409,7 @@ log.info( ) log.info( - f'Skipping: {desc["db_id"]} {foo("bar", x=123)} {"foo" != "bar"} {(x := "abc=")} {pos_share=} {desc["status"]} {desc["exposure_max"]}' + f"Skipping: {desc['db_id']} {foo('bar', x=123)} {'foo' != 'bar'} {(x := 'abc=')} {pos_share=} {desc['status']} {desc['exposure_max']}" ) log.info( @@ -1422,7 +1425,7 @@ log.info( ) log.info( - f'Skipping: {"a" == "b" == "c" == "d"} {desc["ms_name"]} {money=} {dte=} {pos_share=} {desc["status"]} {desc["exposure_max"]}' + f"Skipping: {'a' == 'b' == 'c' == 'd'} {desc['ms_name']} {money=} {dte=} {pos_share=} {desc['status']} {desc['exposure_max']}" ) log.info( diff --git a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_long_strings__regression.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_long_strings__regression.py.snap index 54b2c0f438b71..fc3857c2b45b2 100644 --- a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_long_strings__regression.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_long_strings__regression.py.snap @@ -614,26 +614,20 @@ s = f'Lorem Ipsum is simply dummy text of the printing and typesetting industry: ), varX, varY, -@@ -70,9 +69,10 @@ - def foo(xxxx): +@@ -71,8 +70,9 @@ for xxx_xxxx, _xxx_xxx, _xxx_xxxxx, xxx_xxxx in xxxx: for xxx in xxx_xxxx: -- assert ("x" in xxx) or (xxx in xxx_xxx_xxxxx), ( + assert ("x" in xxx) or (xxx in xxx_xxx_xxxxx), ( - "{0} xxxxxxx xx {1}, xxx {1} xx xxx xx xxxx xx xxx xxxx: xxx xxxx {2}" - .format(xxx_xxxx, xxx, xxxxxx.xxxxxxx(xxx_xxx_xxxxx)) -+ assert ( -+ ("x" in xxx) or (xxx in xxx_xxx_xxxxx) -+ ), "{0} xxxxxxx xx {1}, xxx {1} xx xxx xx xxxx xx xxx xxxx: xxx xxxx {2}".format( -+ xxx_xxxx, xxx, xxxxxx.xxxxxxx(xxx_xxx_xxxxx) ++ "{0} xxxxxxx xx {1}, xxx {1} xx xxx xx xxxx xx xxx xxxx: xxx xxxx {2}".format( ++ xxx_xxxx, xxx, xxxxxx.xxxxxxx(xxx_xxx_xxxxx) ++ ) ) -@@ -80,10 +80,11 @@ - def disappearing_comment(): - return ( - ( # xx -x xxxxxxx xx xxx xxxxxxx. -- "{{xxx_xxxxxxxxxx_xxxxxxxx}} xxx xxxx {} {{xxxx}} >&2".format( -+ "{{xxx_xxxxxxxxxx_xxxxxxxx}} xxx xxxx" " {} {{xxxx}} >&2".format( +@@ -83,7 +83,8 @@ + "{{xxx_xxxxxxxxxx_xxxxxxxx}} xxx xxxx {} {{xxxx}} >&2".format( "{xxxx} {xxxxxx}" if xxxxx.xx_xxxxxxxxxx - else ( # Disappearing Comment @@ -689,7 +683,7 @@ s = f'Lorem Ipsum is simply dummy text of the printing and typesetting industry: + ( + "xxxxxxxxxx xxxx xx xxxxxx(%x) xx %x xxxx xx xxx %x.xx" + % (len(self) + 1, xxxx.xxxxxxxxxx, xxxx.xxxxxxxxxx) -+ ) + ) + + ( + " %.3f (%s) to %.3f (%s).\n" + % ( @@ -698,7 +692,7 @@ s = f'Lorem Ipsum is simply dummy text of the printing and typesetting industry: + x, + xxxx.xxxxxxxxxxxxxx(xx), + ) - ) ++ ) ) @@ -783,7 +777,7 @@ s = f'Lorem Ipsum is simply dummy text of the printing and typesetting industry: ) -@@ -232,39 +248,24 @@ +@@ -232,36 +248,21 @@ some_dictionary = { "xxxxx006": [ @@ -827,12 +821,8 @@ s = f'Lorem Ipsum is simply dummy text of the printing and typesetting industry: + ) # xxxx xxxxxxxxxx xxxx xx xxxx xx xxx xxxxxxxx xxxxxx xxxxx. --some_tuple = ("some string", "some string which should be joined") -+some_tuple = ("some string", "some string" " which should be joined") - - some_commented_string = ( # This comment stays at the top. - "This string is long but not so long that it needs hahahah toooooo be so greatttt" -@@ -279,37 +280,26 @@ + some_tuple = ("some string", "some string which should be joined") +@@ -279,34 +280,21 @@ ) lpar_and_rpar_have_comments = func_call( # LPAR Comment @@ -852,33 +842,28 @@ s = f'Lorem Ipsum is simply dummy text of the printing and typesetting industry: - f" {'' if ID is None else ID} | perl -nE 'print if /^{field}:/'" -) +cmd_fstring = f"sudo -E deluge-console info --detailed --sort-reverse=time_added {'' if ID is None else ID} | perl -nE 'print if /^{field}:/'" -+ -+cmd_fstring = f"sudo -E deluge-console info --detailed --sort-reverse=time_added {'{{}}' if ID is None else ID} | perl -nE 'print if /^{field}:/'" -cmd_fstring = ( - "sudo -E deluge-console info --detailed --sort-reverse=time_added" - f" {'{{}}' if ID is None else ID} | perl -nE 'print if /^{field}:/'" -) -+cmd_fstring = f"sudo -E deluge-console info --detailed --sort-reverse=time_added {{'' if ID is None else ID}} | perl -nE 'print if /^{field}:/'" ++cmd_fstring = f"sudo -E deluge-console info --detailed --sort-reverse=time_added {'{{}}' if ID is None else ID} | perl -nE 'print if /^{field}:/'" -cmd_fstring = ( - "sudo -E deluge-console info --detailed --sort-reverse=time_added {'' if ID is" - f" None else ID}} | perl -nE 'print if /^{field}:/'" -) -+fstring = f"This string really doesn't need to be an {{{{fstring}}}}, but this one most certainly, absolutely {does}." ++cmd_fstring = f"sudo -E deluge-console info --detailed --sort-reverse=time_added {{'' if ID is None else ID}} | perl -nE 'print if /^{field}:/'" - fstring = ( +-fstring = ( - "This string really doesn't need to be an {{fstring}}, but this one most" - f" certainly, absolutely {does}." -+ f"We have to remember to escape {braces}." " Like {these}." f" But not {this}." - ) +-) ++fstring = f"This string really doesn't need to be an {{{{fstring}}}}, but this one most certainly, absolutely {does}." --fstring = f"We have to remember to escape {braces}. Like {{these}}. But not {this}." -- + fstring = f"We have to remember to escape {braces}. Like {{these}}. But not {this}." - class A: - class B: -@@ -364,10 +354,7 @@ +@@ -364,10 +352,7 @@ def foo(): if not hasattr(module, name): raise ValueError( @@ -890,7 +875,7 @@ s = f'Lorem Ipsum is simply dummy text of the printing and typesetting industry: % (name, module_name, get_docs_version()) ) -@@ -382,23 +369,19 @@ +@@ -382,23 +367,19 @@ class Step(StepBase): def who(self): @@ -921,7 +906,7 @@ s = f'Lorem Ipsum is simply dummy text of the printing and typesetting industry: # xxxxx xxxxxxxxxxxx xxxx xxx (xxxxxxxxxxxxxxxx) xx x xxxxxxxxx xx xxxxxx. "(x.bbbbbbbbbbbb.xxx != " '"xxx:xxx:xxx::cccccccccccc:xxxxxxx-xxxx/xxxxxxxxxxx/xxxxxxxxxxxxxxxxx") && ' -@@ -409,8 +392,8 @@ +@@ -409,8 +390,8 @@ if __name__ == "__main__": for i in range(4, 8): cmd = ( @@ -932,7 +917,7 @@ s = f'Lorem Ipsum is simply dummy text of the printing and typesetting industry: ) -@@ -432,9 +415,7 @@ +@@ -432,9 +413,7 @@ assert xxxxxxx_xxxx in [ x.xxxxx.xxxxxx.xxxxx.xxxxxx, x.xxxxx.xxxxxx.xxxxx.xxxx, @@ -943,7 +928,7 @@ s = f'Lorem Ipsum is simply dummy text of the printing and typesetting industry: value.__dict__[key] = ( -@@ -449,8 +430,7 @@ +@@ -449,8 +428,7 @@ RE_TWO_BACKSLASHES = { "asdf_hjkl_jkl": re.compile( @@ -953,23 +938,23 @@ s = f'Lorem Ipsum is simply dummy text of the printing and typesetting industry: ), } -@@ -462,13 +442,9 @@ +@@ -462,13 +440,9 @@ # We do NOT split on f-string expressions. print( - "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam." - f" {[f'{i}' for i in range(10)]}" -+ f"Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam. {[f'{i}' for i in range(10)]}" - ) +-) -x = ( - "This is a long string which contains an f-expr that should not split" - f" {{{[i for i in range(5)]}}}." --) ++ f"Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam. {[f'{i}' for i in range(10)]}" + ) +x = f"This is a long string which contains an f-expr that should not split {{{[i for i in range(5)]}}}." # The parens should NOT be removed in this case. ( -@@ -478,8 +454,8 @@ +@@ -478,8 +452,8 @@ # The parens should NOT be removed in this case. ( @@ -980,7 +965,7 @@ s = f'Lorem Ipsum is simply dummy text of the printing and typesetting industry: ) # The parens should NOT be removed in this case. -@@ -513,93 +489,83 @@ +@@ -513,93 +487,83 @@ temp_msg = ( @@ -1110,7 +1095,7 @@ s = f'Lorem Ipsum is simply dummy text of the printing and typesetting industry: "6. Click on Create Credential at the top." '7. At the top click the link for "API key".' "8. No application restrictions are needed. Click Create at the bottom." -@@ -608,60 +574,45 @@ +@@ -608,7 +572,7 @@ # It shouldn't matter if the string prefixes are capitalized. temp_msg = ( @@ -1119,11 +1104,7 @@ s = f'Lorem Ipsum is simply dummy text of the printing and typesetting industry: f"{balance: <{bal_len + 5}} " f"<<{author.display_name}>>\n" ) - --fstring = f"We have to remember to escape {braces}. Like {{these}}. But not {this}." -+fstring = ( -+ f"We have to remember to escape {braces}." " Like {these}." f" But not {this}." -+) +@@ -617,51 +581,34 @@ welcome_to_programming = R"hello," R" world!" @@ -1189,22 +1170,15 @@ s = f'Lorem Ipsum is simply dummy text of the printing and typesetting industry: ) # Regression test for https://github.com/psf/black/issues/3455. -@@ -672,9 +623,11 @@ - } - +@@ -674,7 +621,4 @@ # Regression test for https://github.com/psf/black/issues/3506. --s = f"With single quote: ' {my_dict['foo']} With double quote: \" {my_dict['bar']}" -- - s = ( + s = f"With single quote: ' {my_dict['foo']} With double quote: \" {my_dict['bar']}" + +-s = ( - "Lorem Ipsum is simply dummy text of the printing and typesetting" - f" industry:'{my_dict['foo']}'" -+ "With single quote: ' " -+ f" {my_dict['foo']}" -+ ' With double quote: " ' -+ f' {my_dict["bar"]}' - ) -+ -+s = f'Lorem Ipsum is simply dummy text of the printing and typesetting industry:\'{my_dict["foo"]}\'' +-) ++s = f"Lorem Ipsum is simply dummy text of the printing and typesetting industry:'{my_dict['foo']}'" ``` ## Ruff Output @@ -1281,10 +1255,10 @@ class A: def foo(xxxx): for xxx_xxxx, _xxx_xxx, _xxx_xxxxx, xxx_xxxx in xxxx: for xxx in xxx_xxxx: - assert ( - ("x" in xxx) or (xxx in xxx_xxx_xxxxx) - ), "{0} xxxxxxx xx {1}, xxx {1} xx xxx xx xxxx xx xxx xxxx: xxx xxxx {2}".format( - xxx_xxxx, xxx, xxxxxx.xxxxxxx(xxx_xxx_xxxxx) + assert ("x" in xxx) or (xxx in xxx_xxx_xxxxx), ( + "{0} xxxxxxx xx {1}, xxx {1} xx xxx xx xxxx xx xxx xxxx: xxx xxxx {2}".format( + xxx_xxxx, xxx, xxxxxx.xxxxxxx(xxx_xxx_xxxxx) + ) ) @@ -1292,7 +1266,7 @@ class A: def disappearing_comment(): return ( ( # xx -x xxxxxxx xx xxx xxxxxxx. - "{{xxx_xxxxxxxxxx_xxxxxxxx}} xxx xxxx" " {} {{xxxx}} >&2".format( + "{{xxx_xxxxxxxxxx_xxxxxxxx}} xxx xxxx {} {{xxxx}} >&2".format( "{xxxx} {xxxxxx}" if xxxxx.xx_xxxxxxxxxx # Disappearing Comment @@ -1477,7 +1451,7 @@ def foo(): ) # xxxx xxxxxxxxxx xxxx xx xxxx xx xxx xxxxxxxx xxxxxx xxxxx. -some_tuple = ("some string", "some string" " which should be joined") +some_tuple = ("some string", "some string which should be joined") some_commented_string = ( # This comment stays at the top. "This string is long but not so long that it needs hahahah toooooo be so greatttt" @@ -1508,9 +1482,7 @@ cmd_fstring = f"sudo -E deluge-console info --detailed --sort-reverse=time_added fstring = f"This string really doesn't need to be an {{{{fstring}}}}, but this one most certainly, absolutely {does}." -fstring = ( - f"We have to remember to escape {braces}." " Like {these}." f" But not {this}." -) +fstring = f"We have to remember to escape {braces}. Like {{these}}. But not {this}." class A: @@ -1791,9 +1763,7 @@ temp_msg = ( f"<<{author.display_name}>>\n" ) -fstring = ( - f"We have to remember to escape {braces}." " Like {these}." f" But not {this}." -) +fstring = f"We have to remember to escape {braces}. Like {{these}}. But not {this}." welcome_to_programming = R"hello," R" world!" @@ -1835,14 +1805,9 @@ a_dict = { } # Regression test for https://github.com/psf/black/issues/3506. -s = ( - "With single quote: ' " - f" {my_dict['foo']}" - ' With double quote: " ' - f' {my_dict["bar"]}' -) +s = f"With single quote: ' {my_dict['foo']} With double quote: \" {my_dict['bar']}" -s = f'Lorem Ipsum is simply dummy text of the printing and typesetting industry:\'{my_dict["foo"]}\'' +s = f"Lorem Ipsum is simply dummy text of the printing and typesetting industry:'{my_dict['foo']}'" ``` ## Black Output diff --git a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_long_strings__type_annotations.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_long_strings__type_annotations.py.snap index 8b8220f9c47cb..da32d342f0cd2 100644 --- a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_long_strings__type_annotations.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_long_strings__type_annotations.py.snap @@ -45,8 +45,9 @@ def func( def func( - argument: "int |" "str", -+ argument: ("int |" "str"), - ) -> Set["int |" " str"]: +-) -> Set["int |" " str"]: ++ argument: ("int |str"), ++) -> Set["int | str"]: pass ``` @@ -76,8 +77,8 @@ def func( def func( - argument: ("int |" "str"), -) -> Set["int |" " str"]: + argument: ("int |str"), +) -> Set["int | str"]: pass ``` @@ -111,5 +112,3 @@ def func( ) -> Set["int |" " str"]: pass ``` - - diff --git a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_multiline_strings.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_multiline_strings.py.snap index 25ed182111884..68b0202991a58 100644 --- a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_multiline_strings.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_multiline_strings.py.snap @@ -366,22 +366,13 @@ actual: {some_var}""" [ """cow moos""", -@@ -198,7 +239,7 @@ - `--global-option` is reserved to flags like `--verbose` or `--quiet`. - """ - --this_will_become_one_line = "abc" -+this_will_become_one_line = "a" "b" "c" - - this_will_stay_on_three_lines = ( - "a" # comment @@ -206,7 +247,9 @@ "c" ) -this_will_also_become_one_line = "abc" # comment +this_will_also_become_one_line = ( # comment -+ "a" "b" "c" ++ "abc" +) assert some_var == expected_result, """ @@ -632,7 +623,7 @@ Please use `--build-option` instead, `--global-option` is reserved to flags like `--verbose` or `--quiet`. """ -this_will_become_one_line = "a" "b" "c" +this_will_become_one_line = "abc" this_will_stay_on_three_lines = ( "a" # comment @@ -641,7 +632,7 @@ this_will_stay_on_three_lines = ( ) this_will_also_become_one_line = ( # comment - "a" "b" "c" + "abc" ) assert some_var == expected_result, """ diff --git a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__trailing_commas_in_leading_parts.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__trailing_commas_in_leading_parts.py.snap index fd4942632e8a3..27cdd32b80c32 100644 --- a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__trailing_commas_in_leading_parts.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__trailing_commas_in_leading_parts.py.snap @@ -175,5 +175,3 @@ assert xxxxxxxxx.xxxxxxxxx.xxxxxxxxx( "xxx {xxxxxxxxx} xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" ) ``` - - diff --git a/crates/ruff_python_formatter/tests/snapshots/format.snap b/crates/ruff_python_formatter/tests/snapshots/format.snap new file mode 100644 index 0000000000000..6f55c55302108 --- /dev/null +++ b/crates/ruff_python_formatter/tests/snapshots/format.snap @@ -0,0 +1,50 @@ +--- +source: crates/ruff_python_formatter/tests/fixtures.rs +input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/join_implicit_concatenated_string_preserve.py +--- +## Input +```python +"diffent '" 'quote "are fine"' # join + +# More single quotes +"one single'" "two 'single'" ' two "double"' + +# More double quotes +'one double"' 'two "double"' " two 'single'" + +# Equal number of single and double quotes +'two "double"' " two 'single'" + +f"{'Hy \"User\"'}" +``` + +## Outputs +### Output 1 +``` +indent-style = space +line-width = 88 +indent-width = 4 +quote-style = Preserve +line-ending = LineFeed +magic-trailing-comma = Respect +docstring-code = Disabled +docstring-code-line-width = "dynamic" +preview = Enabled +target_version = Py38 +source_type = Python +``` + +```python +"diffent 'quote \"are fine\"" # join + +# More single quotes +"one single'two 'single' two \"double\"" + +# More double quotes +'one double"two "double" two \'single\'' + +# Equal number of single and double quotes +'two "double" two \'single\'' + +f"{'Hy "User"'}" +``` diff --git a/crates/ruff_python_formatter/tests/snapshots/format@expression__binary_implicit_string.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@expression__binary_implicit_string.py.snap index 5f3c84a8dfa69..585f222d40096 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@expression__binary_implicit_string.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@expression__binary_implicit_string.py.snap @@ -406,4 +406,19 @@ class EC2REPATH: ``` - +## Preview changes +```diff +--- Stable ++++ Preview +@@ -197,8 +197,8 @@ + "dddddddddddddddddddddddddd" % aaaaaaaaaaaa + x + ) + +-"a" "b" "c" + "d" "e" + "f" "g" + "h" "i" "j" ++"abc" + "de" + "fg" + "hij" + + + class EC2REPATH: +- f.write("Pathway name" + "\t" "Database Identifier" + "\t" "Source database" + "\n") ++ f.write("Pathway name" + "\tDatabase Identifier" + "\tSource database" + "\n") +``` diff --git a/crates/ruff_python_formatter/tests/snapshots/format@expression__bytes.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@expression__bytes.py.snap index 7f98068782493..852c7b92282a9 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@expression__bytes.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@expression__bytes.py.snap @@ -285,6 +285,33 @@ b"Unicode Escape sequence don't apply to bytes: \N{0x} \u{ABCD} \U{ABCDEFGH}" ``` +#### Preview changes +```diff +--- Stable ++++ Preview +@@ -63,9 +63,9 @@ + + # String continuation + +-b"Let's" b"start" b"with" b"a" b"simple" b"example" ++b"Let'sstartwithasimpleexample" + +-b"Let's" b"start" b"with" b"a" b"simple" b"example" b"now repeat after me:" b"I am confident" b"I am confident" b"I am confident" b"I am confident" b"I am confident" ++b"Let'sstartwithasimpleexamplenow repeat after me:I am confidentI am confidentI am confidentI am confidentI am confident" + + ( + b"Let's" +@@ -132,6 +132,6 @@ + ] + + # Parenthesized string continuation with messed up indentation +-{"key": ([], b"a" b"b" b"c")} ++{"key": ([], b"abc")} + + b"Unicode Escape sequence don't apply to bytes: \N{0x} \u{ABCD} \U{ABCDEFGH}" +``` + + ### Output 2 ``` indent-style = space @@ -441,4 +468,28 @@ b"Unicode Escape sequence don't apply to bytes: \N{0x} \u{ABCD} \U{ABCDEFGH}" ``` - +#### Preview changes +```diff +--- Stable ++++ Preview +@@ -63,9 +63,9 @@ + + # String continuation + +-b"Let's" b'start' b'with' b'a' b'simple' b'example' ++b"Let'sstartwithasimpleexample" + +-b"Let's" b'start' b'with' b'a' b'simple' b'example' b'now repeat after me:' b'I am confident' b'I am confident' b'I am confident' b'I am confident' b'I am confident' ++b"Let'sstartwithasimpleexamplenow repeat after me:I am confidentI am confidentI am confidentI am confidentI am confident" + + ( + b"Let's" +@@ -132,6 +132,6 @@ + ] + + # Parenthesized string continuation with messed up indentation +-{'key': ([], b'a' b'b' b'c')} ++{'key': ([], b'abc')} + + b"Unicode Escape sequence don't apply to bytes: \N{0x} \u{ABCD} \U{ABCDEFGH}" +``` diff --git a/crates/ruff_python_formatter/tests/snapshots/format@expression__fstring.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@expression__fstring.py.snap index 5faebb836e37d..af2a324aa36a0 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@expression__fstring.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@expression__fstring.py.snap @@ -100,6 +100,8 @@ x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa { # comment 8 "bbbbbbbbbbbbbbbbbbbbbbbbbbbbb" } ccccccccccccccc" x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa { # comment 9 "bbbbbbbbbbbbbbbbbbbbbbbbbbbbb" = } ccccccccccccccc" +x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa { # comment 9 + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbb' = } ccccccccccccccc" # Multiple larger expressions which exceeds the line length limit. Here, we need to decide # whether to split at the first or second expression. This should work similarly to the @@ -150,18 +152,37 @@ xxxxxxx = f"{ {'aaaaaaaaaaaaaaaaaaaaaaaaa', 'bbbbbbbbbbbbbbbbbbbbbbbbbbb', 'cccccccccccccccccccccccccc'} }" +############################################################################################# # Quotes +############################################################################################# f"foo 'bar' {x}" f"foo \"bar\" {x}" f'foo "bar" {x}' f'foo \'bar\' {x}' f"foo {"bar"}" -f"foo {'\'bar\''}" + +f"single quoted '{x}' double quoted \"{x}\"" # Same number of quotes => use preferred quote style +f"single quote ' {x} double quoted \"{x}\"" # More double quotes => use single quotes +f"single quoted '{x}' double quote \" {x}" # More single quotes => use double quotes + +fr"single quotes ' {x}" # Keep double because `'` can't be escaped +fr'double quotes " {x}' # Keep single because `"` can't be escaped +fr'flip quotes {x}' # Use preferred quotes, because raw string contains now quotes. # Here, the formatter will remove the escapes which is correct because they aren't allowed # pre 3.12. This means we can assume that the f-string is used in the context of 3.12. +f"foo {'\'bar\''}" f"foo {'\"bar\"'}" +# Quotes inside the expressions have no impact on the quote selection of the outer string. +# Required so that the following two examples result in the same formatting. +f'foo {10 + len("bar")}' +f"foo {10 + len('bar')}" + +# Pre 312, preserve the outer quotes if the f-string contains quotes in the debug expression +f'foo {10 + len("bar")=}' +f'''foo {10 + len('''bar''')=}''' +f'''foo {10 + len('bar')=}''' # Fine to change the quotes because it uses triple quotes # Triple-quoted strings # It's ok to use the same quote char for the inner string if it's single-quoted. @@ -170,6 +191,16 @@ f"""test {"inner"}""" # But if the inner string is also triple-quoted then we should preserve the existing quotes. f"""test {'''inner'''}""" +# It's not okay to change the quote style if the inner string is triple quoted and contains a quote. +f'{"""other " """}' +f'{"""other " """ + "more"}' +f'{b"""other " """}' +f'{f"""other " """}' + +# Not valid Pre 3.12 +f"""test {f'inner {'''inner inner'''}'}""" +f"""test {f'''inner {"""inner inner"""}'''}""" + # Magic trailing comma # # The expression formatting will result in breaking it across multiple lines with a @@ -318,7 +349,7 @@ hello { # Implicit concatenated f-string containing quotes _ = ( 'This string should change its quotes to double quotes' - f'This string uses double quotes in an expression {"woah"}' + f'This string uses double quotes in an expression {"it's a quote"}' f'This f-string does not use any quotes.' ) ``` @@ -340,7 +371,7 @@ source_type = Python ``` ```python -(f"{one}" f"{two}") +(f"{one}{two}") rf"Not-so-tricky \"quote" @@ -380,7 +411,7 @@ result_f = ( ) ( - f"{1}" f"{2}" # comment 3 + f"{1}{2}" # comment 3 ) ( @@ -429,13 +460,15 @@ aaaaaaaaaaa = ( # This should never add the optional parentheses because even after adding them, the # f-string exceeds the line length limit. -x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa {"bbbbbbbbbbbbbbbbbbbbbbbbbbbbb"} ccccccccccccccc" +x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa {'bbbbbbbbbbbbbbbbbbbbbbbbbbbbb'} ccccccccccccccc" x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa { "bbbbbbbbbbbbbbbbbbbbbbbbbbbbb" = } ccccccccccccccc" x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa { # comment 8 - "bbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbb' } ccccccccccccccc" x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa { # comment 9 "bbbbbbbbbbbbbbbbbbbbbbbbbbbbb" = } ccccccccccccccc" +x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa { # comment 9 + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbb' = } ccccccccccccccc" # Multiple larger expressions which exceeds the line length limit. Here, we need to decide # whether to split at the first or second expression. This should work similarly to the @@ -496,18 +529,37 @@ xxxxxxx = f"{ } }" +############################################################################################# # Quotes +############################################################################################# f"foo 'bar' {x}" f'foo "bar" {x}' f'foo "bar" {x}' f"foo 'bar' {x}" -f"foo {"bar"}" -f"foo {'\'bar\''}" +f"foo {'bar'}" + +f"single quoted '{x}' double quoted \"{x}\"" # Same number of quotes => use preferred quote style +f'single quote \' {x} double quoted "{x}"' # More double quotes => use single quotes +f"single quoted '{x}' double quote \" {x}" # More single quotes => use double quotes + +rf"single quotes ' {x}" # Keep double because `'` can't be escaped +rf'double quotes " {x}' # Keep single because `"` can't be escaped +rf"flip quotes {x}" # Use preferred quotes, because raw string contains now quotes. # Here, the formatter will remove the escapes which is correct because they aren't allowed # pre 3.12. This means we can assume that the f-string is used in the context of 3.12. +f"foo {"'bar'"}" f"foo {'"bar"'}" +# Quotes inside the expressions have no impact on the quote selection of the outer string. +# Required so that the following two examples result in the same formatting. +f"foo {10 + len('bar')}" +f"foo {10 + len('bar')}" + +# Pre 312, preserve the outer quotes if the f-string contains quotes in the debug expression +f'foo {10 + len("bar")=}' +f"""foo {10 + len('''bar''')=}""" +f"""foo {10 + len('bar')=}""" # Fine to change the quotes because it uses triple quotes # Triple-quoted strings # It's ok to use the same quote char for the inner string if it's single-quoted. @@ -516,6 +568,16 @@ f"""test {"inner"}""" # But if the inner string is also triple-quoted then we should preserve the existing quotes. f"""test {'''inner'''}""" +# It's not okay to change the quote style if the inner string is triple quoted and contains a quote. +f'{"""other " """}' +f'{"""other " """ + "more"}' +f'{b"""other " """}' +f'{f"""other " """}' + +# Not valid Pre 3.12 +f"""test {f"inner {'''inner inner'''}"}""" +f"""test {f'''inner {"""inner inner"""}'''}""" + # Magic trailing comma # # The expression formatting will result in breaking it across multiple lines with a @@ -662,7 +724,7 @@ hello { # Implicit concatenated f-string containing quotes _ = ( "This string should change its quotes to double quotes" - f'This string uses double quotes in an expression {"woah"}' + f"This string uses double quotes in an expression {"it's a quote"}" f"This f-string does not use any quotes." ) ``` @@ -778,6 +840,8 @@ x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa { # comment 8 "bbbbbbbbbbbbbbbbbbbbbbbbbbbbb" } ccccccccccccccc" x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa { # comment 9 "bbbbbbbbbbbbbbbbbbbbbbbbbbbbb" = } ccccccccccccccc" +x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa { # comment 9 + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbb' = } ccccccccccccccc" # Multiple larger expressions which exceeds the line length limit. Here, we need to decide # whether to split at the first or second expression. This should work similarly to the @@ -828,18 +892,37 @@ xxxxxxx = f"{ {'aaaaaaaaaaaaaaaaaaaaaaaaa', 'bbbbbbbbbbbbbbbbbbbbbbbbbbb', 'cccccccccccccccccccccccccc'} }" +############################################################################################# # Quotes +############################################################################################# f"foo 'bar' {x}" f'foo "bar" {x}' f'foo "bar" {x}' f"foo 'bar' {x}" f"foo {"bar"}" -f"foo {'\'bar\''}" + +f"single quoted '{x}' double quoted \"{x}\"" # Same number of quotes => use preferred quote style +f'single quote \' {x} double quoted "{x}"' # More double quotes => use single quotes +f"single quoted '{x}' double quote \" {x}" # More single quotes => use double quotes + +rf"single quotes ' {x}" # Keep double because `'` can't be escaped +rf'double quotes " {x}' # Keep single because `"` can't be escaped +rf"flip quotes {x}" # Use preferred quotes, because raw string contains now quotes. # Here, the formatter will remove the escapes which is correct because they aren't allowed # pre 3.12. This means we can assume that the f-string is used in the context of 3.12. +f"foo {'\'bar\''}" f"foo {'\"bar\"'}" +# Quotes inside the expressions have no impact on the quote selection of the outer string. +# Required so that the following two examples result in the same formatting. +f'foo {10 + len("bar")}' +f"foo {10 + len('bar')}" + +# Pre 312, preserve the outer quotes if the f-string contains quotes in the debug expression +f'foo {10 + len("bar")=}' +f'''foo {10 + len('''bar''')=}''' +f"""foo {10 + len('bar')=}""" # Fine to change the quotes because it uses triple quotes # Triple-quoted strings # It's ok to use the same quote char for the inner string if it's single-quoted. @@ -848,6 +931,16 @@ f"""test {"inner"}""" # But if the inner string is also triple-quoted then we should preserve the existing quotes. f"""test {'''inner'''}""" +# It's not okay to change the quote style if the inner string is triple quoted and contains a quote. +f'{"""other " """}' +f'{"""other " """ + "more"}' +f'{b"""other " """}' +f'{f"""other " """}' + +# Not valid Pre 3.12 +f"""test {f'inner {'''inner inner'''}'}""" +f"""test {f'''inner {"""inner inner"""}'''}""" + # Magic trailing comma # # The expression formatting will result in breaking it across multiple lines with a @@ -994,7 +1087,7 @@ hello { # Implicit concatenated f-string containing quotes _ = ( 'This string should change its quotes to double quotes' - f'This string uses double quotes in an expression {"woah"}' + f'This string uses double quotes in an expression {"it's a quote"}' f'This f-string does not use any quotes.' ) ``` @@ -1004,6 +1097,12 @@ _ = ( ```diff --- Stable +++ Preview +@@ -1,4 +1,4 @@ +-(f"{one}" f"{two}") ++(f"{one}{two}") + + + rf"Not-so-tricky \"quote" @@ -6,13 +6,13 @@ # Regression test for fstrings dropping comments result_f = ( @@ -1022,7 +1121,16 @@ _ = ( " f()\n" # XXX: The following line changes depending on whether the tests # are run through the interactive interpreter or with -m -@@ -67,64 +67,72 @@ +@@ -38,7 +38,7 @@ + ) + + ( +- f"{1}" f"{2}" # comment 3 ++ f"{1}{2}" # comment 3 + ) + + ( +@@ -67,29 +67,31 @@ x = f"{a}" x = f"{ a = }" @@ -1052,16 +1160,17 @@ _ = ( # This should never add the optional parentheses because even after adding them, the # f-string exceeds the line length limit. -x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa { "bbbbbbbbbbbbbbbbbbbbbbbbbbbbb" } ccccccccccccccc" -+x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa {"bbbbbbbbbbbbbbbbbbbbbbbbbbbbb"} ccccccccccccccc" ++x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa {'bbbbbbbbbbbbbbbbbbbbbbbbbbbbb'} ccccccccccccccc" x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa { "bbbbbbbbbbbbbbbbbbbbbbbbbbbbb" = } ccccccccccccccc" -x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa { # comment 8 - "bbbbbbbbbbbbbbbbbbbbbbbbbbbbb" } ccccccccccccccc" +x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa { # comment 8 -+ "bbbbbbbbbbbbbbbbbbbbbbbbbbbbb" ++ 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbb' +} ccccccccccccccc" x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa { # comment 9 "bbbbbbbbbbbbbbbbbbbbbbbbbbbbb" = } ccccccccccccccc" - + x = f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa { # comment 9 +@@ -98,35 +100,41 @@ # Multiple larger expressions which exceeds the line length limit. Here, we need to decide # whether to split at the first or second expression. This should work similarly to the # assignment statement formatting where we split from right to left in preview mode. @@ -1119,7 +1228,7 @@ _ = ( x = f"{ # comment 13 {'x': 1, 'y': 2} = }" # But, if there's a format specifier or a conversion flag then we don't need to add -@@ -139,7 +147,11 @@ +@@ -141,7 +149,11 @@ }" # And, split the expression itself because it exceeds the line length. xxxxxxx = f"{ @@ -1131,14 +1240,36 @@ _ = ( + } }" - # Quotes -@@ -152,13 +164,13 @@ + ############################################################################################# +@@ -151,7 +163,7 @@ + f'foo "bar" {x}' + f'foo "bar" {x}' + f"foo 'bar' {x}" +-f"foo {"bar"}" ++f"foo {'bar'}" + + f"single quoted '{x}' double quoted \"{x}\"" # Same number of quotes => use preferred quote style + f'single quote \' {x} double quoted "{x}"' # More double quotes => use single quotes +@@ -163,23 +175,23 @@ # Here, the formatter will remove the escapes which is correct because they aren't allowed # pre 3.12. This means we can assume that the f-string is used in the context of 3.12. +-f"foo {'\'bar\''}" -f"foo {'\"bar\"'}" ++f"foo {"'bar'"}" +f"foo {'"bar"'}" + # Quotes inside the expressions have no impact on the quote selection of the outer string. + # Required so that the following two examples result in the same formatting. +-f'foo {10 + len("bar")}' + f"foo {10 + len('bar')}" ++f"foo {10 + len('bar')}" + + # Pre 312, preserve the outer quotes if the f-string contains quotes in the debug expression + f'foo {10 + len("bar")=}' +-f'''foo {10 + len('''bar''')=}''' ++f"""foo {10 + len('''bar''')=}""" + f"""foo {10 + len('bar')=}""" # Fine to change the quotes because it uses triple quotes # Triple-quoted strings # It's ok to use the same quote char for the inner string if it's single-quoted. @@ -1148,7 +1279,16 @@ _ = ( # But if the inner string is also triple-quoted then we should preserve the existing quotes. f"""test {'''inner'''}""" -@@ -171,63 +183,66 @@ +@@ -190,7 +202,7 @@ + f'{f"""other " """}' + + # Not valid Pre 3.12 +-f"""test {f'inner {'''inner inner'''}'}""" ++f"""test {f"inner {'''inner inner'''}"}""" + f"""test {f'''inner {"""inner inner"""}'''}""" + + # Magic trailing comma +@@ -202,63 +214,66 @@ f"aaaaaaa {['aaaaaaaaaaaaaaa', 'bbbbbbbbbbbbb', 'ccccccccccccccccc', 'ddddddddddddddd', 'eeeeeeeeeeeeee']} aaaaaaa" # And, if the trailing comma is already present, we still need to remove it. @@ -1243,7 +1383,7 @@ _ = ( # comment} cccccccccc""" # Conversion flags -@@ -235,24 +250,21 @@ +@@ -266,24 +281,21 @@ # This is not a valid Python code because of the additional whitespace between the `!` # and conversion type. But, our parser isn't strict about this. This should probably be # removed once we have a strict parser. @@ -1275,7 +1415,7 @@ _ = ( x = f""" { # comment 22 -@@ -261,19 +273,19 @@ +@@ -292,19 +304,19 @@ # Here, the debug expression is in a nested f-string so we should start preserving # whitespaces from that point onwards. This means we should format the outer f-string. @@ -1303,7 +1443,7 @@ _ = ( # comment 27 # comment 28 } woah {x}" -@@ -287,27 +299,27 @@ +@@ -318,27 +330,27 @@ if indent2: foo = f"""hello world hello { @@ -1343,9 +1483,10 @@ _ = ( # Implicit concatenated f-string containing quotes _ = ( - 'This string should change its quotes to double quotes' -+ "This string should change its quotes to double quotes" - f'This string uses double quotes in an expression {"woah"}' +- f'This string uses double quotes in an expression {"it's a quote"}' - f'This f-string does not use any quotes.' ++ "This string should change its quotes to double quotes" ++ f"This string uses double quotes in an expression {"it's a quote"}" + f"This f-string does not use any quotes." ) ``` diff --git a/crates/ruff_python_formatter/tests/snapshots/format@expression__fstring_py312.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@expression__fstring_py312.py.snap index fd22501c96a68..75b2a13bdc59e 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@expression__fstring_py312.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@expression__fstring_py312.py.snap @@ -10,6 +10,17 @@ input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/expression # Quotes reuse f"{'a'}" + +# 312+, it's okay to change the outer quotes even when there's a debug expression using the same quotes +f'foo {10 + len("bar")=}' +f'''foo {10 + len("""bar""")=}''' + +# 312+, it's okay to change the quotes here without creating an invalid f-string +f'{"""other " """}' +f'{"""other " """ + "more"}' +f'{b"""other " """}' +f'{f"""other " """}' + ``` ## Outputs @@ -35,6 +46,16 @@ source_type = Python # Quotes reuse f"{'a'}" + +# 312+, it's okay to change the outer quotes even when there's a debug expression using the same quotes +f'foo {10 + len("bar")=}' +f'''foo {10 + len("""bar""")=}''' + +# 312+, it's okay to change the quotes here without creating an invalid f-string +f'{"""other " """}' +f'{"""other " """ + "more"}' +f'{b"""other " """}' +f'{f"""other " """}' ``` @@ -42,10 +63,22 @@ f"{'a'}" ```diff --- Stable +++ Preview -@@ -3,4 +3,4 @@ - # version isn't set. +@@ -6,11 +6,11 @@ + f"{'a'}" + + # 312+, it's okay to change the outer quotes even when there's a debug expression using the same quotes +-f'foo {10 + len("bar")=}' +-f'''foo {10 + len("""bar""")=}''' ++f"foo {10 + len("bar")=}" ++f"""foo {10 + len("""bar""")=}""" - # Quotes reuse --f"{'a'}" -+f"{"a"}" + # 312+, it's okay to change the quotes here without creating an invalid f-string +-f'{"""other " """}' +-f'{"""other " """ + "more"}' +-f'{b"""other " """}' +-f'{f"""other " """}' ++f"{'''other " '''}" ++f"{'''other " ''' + 'more'}" ++f"{b'''other " '''}" ++f"{f'''other " '''}" ``` diff --git a/crates/ruff_python_formatter/tests/snapshots/format@expression__join_implicit_concatenated_string.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@expression__join_implicit_concatenated_string.py.snap new file mode 100644 index 0000000000000..bfab700e2e3f2 --- /dev/null +++ b/crates/ruff_python_formatter/tests/snapshots/format@expression__join_implicit_concatenated_string.py.snap @@ -0,0 +1,735 @@ +--- +source: crates/ruff_python_formatter/tests/fixtures.rs +input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/join_implicit_concatenated_string.py +--- +## Input +```python +"aaaaaaaaa" "bbbbbbbbbbbbbbbbbbbb" # Join + +( + "aaaaaaaaaaa" "bbbbbbbbbbbbbbbb" +) # join + + +( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +) # too long to join + + +"different '" 'quote "are fine"' # join + +# More single quotes +"one single'" "two 'single'" ' two "double"' + +# More double quotes +'one double"' 'two "double"' " two 'single'" + +# Equal number of single and double quotes +'two "double"' " two 'single'" + +f"{'Hy \"User\"'}" 'more' + +b"aaaaaaaaa" b"bbbbbbbbbbbbbbbbbbbb" # Join + +( + b"aaaaaaaaaaa" b"bbbbbbbbbbbbbbbb" +) # join + + +( + b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" b"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +) # too long to join + + +# Skip joining if there is a trailing comment +( + "fffffffffffff" + "bbbbbbbbbbbbb" # comment + "cccccccccccccc" +) + +# Skip joining if there is a leading comment +( + "fffffffffffff" + # comment + "bbbbbbbbbbbbb" + "cccccccccccccc" +) + + +############################################################################## +# F-strings +############################################################################## + +# Escape `{` and `}` when marging an f-string with a string +"a {not_a_variable}" f"b {10}" "c" + +# Join, and break expressions +f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa{ +expression +}bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" f"cccccccccccccccccccc {20999}" "more" + +# Join, but don't break the expressions +f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa{expression}bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" f"cccccccccccccccccccc {20999}" "more" + +f"test{ +expression +}flat" f"can be { +joined +} together" + +aaaaaaaaaaa = f"test{ +expression +}flat" f"cean beeeeeeee { +joined +} eeeeeeeeeeeeeeeeeeeeeeeeeeeee" # inline + + +f"single quoted '{x}'" f'double quoted "{x}"' # Same number of quotes => use preferred quote style +f"single quote ' {x}" f'double quoted "{x}"' # More double quotes => use single quotes +f"single quoted '{x}'" f'double quote " {x}"' # More single quotes => use double quotes + +# Different triple quoted strings +f"{'''test'''}" f'{"""other"""}' + +# Now with inner quotes +f"{'''test ' '''}" f'{"""other " """}' +f"{some_where_nested('''test ' ''')}" f'{"""other " """ + "more"}' +f"{b'''test ' '''}" f'{b"""other " """}' +f"{f'''test ' '''}" f'{f"""other " """}' + +# debug expressions containing quotes +f"{10 + len('bar')=}" f"{10 + len('bar')=}" +f"{10 + len('bar')=}" f'no debug{10}' f"{10 + len('bar')=}" + +# We can't savely merge this pre Python 3.12 without altering the debug expression. +f"{10 + len('bar')=}" f'{10 + len("bar")=}' + + +############################################################################## +# Don't join raw strings +############################################################################## + +r"a" "normal" +R"a" "normal" + +f"test" fr"test" +f"test" fR"test" + + +############################################################################## +# Don't join triple quoted strings +############################################################################## + +"single" """triple""" + +"single" f""""single""" + +b"single" b"""triple""" + + +############################################################################## +# Join strings in with statements +############################################################################## + +# Fits +with "aa" "bbb" "cccccccccccccccccccccccccccccccccccccccccccccc": + pass + +# Parenthesize single-line +with "aa" "bbb" "ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc": + pass + +# Multiline +with "aa" "bbb" "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc": + pass + +with f"aaaaaaa{expression}bbbb" f"ccc {20999}" "more": + pass + + +############################################################################## +# For loops +############################################################################## + +# Flat +for a in "aaaaaaaaa" "bbbbbbbbb" "ccccccccc" "dddddddddd": + pass + +# Parenthesize single-line +for a in "aaaaaaaaa" "bbbbbbbbb" "ccccccccc" "dddddddddd" "eeeeeeeeeeeeeee" "fffffffffffff" "ggggggggggggggg" "hh": + pass + +# Multiline +for a in "aaaaaaaaa" "bbbbbbbbb" "ccccccccc" "dddddddddd" "eeeeeeeeeeeeeee" "fffffffffffff" "ggggggggggggggg" "hhhh": + pass + +############################################################################## +# Assert statement +############################################################################## + +# Fits +assert "aaaaaaaaa" "bbbbbbbbbbbb", "cccccccccccccccc" "dddddddddddddddd" + +# Wrap right +assert "aaaaaaaaa" "bbbbbbbbbbbb", "cccccccccccccccc" "dddddddddddddddd" "eeeeeeeeeeeee" "fffffffffff" + +# Right multiline +assert "aaaaaaaaa" "bbbbbbbbbbbb", "cccccccccccccccc" "dddddddddddddddd" "eeeeeeeeeeeee" "fffffffffffffff" "ggggggggggggg" "hhhhhhhhhhh" + +# Wrap left +assert "aaaaaaaaa" "bbbbbbbbbbbb" "cccccccccccccccc" "dddddddddddddddd" "eeeeeeeeeeeee" "fffffffffffffff", "ggggggggggggg" "hhhhhhhhhhh" + +# Left multiline +assert "aaaaaaaaa" "bbbbbbbbbbbb" "cccccccccccccccc" "dddddddddddddddd" "eeeeeeeeeeeee" "fffffffffffffff" "ggggggggggggg", "hhhhhhhhhhh" + +# wrap both +assert "aaaaaaaaa" "bbbbbbbbbbbb" "cccccccccccccccc" "dddddddddddddddd" "eeeeeeeeeeeee" "fffffffffffffff", "ggggggggggggg" "hhhhhhhhhhh" "iiiiiiiiiiiiiiiiii" "jjjjjjjjjjjjj" "kkkkkkkkkkkkkkkkk" "llllllllllll" + +# both multiline +assert "aaaaaaaaa" "bbbbbbbbbbbb" "cccccccccccccccc" "dddddddddddddddd" "eeeeeeeeeeeee" "fffffffffffffff" "ggggggggggggg", "hhhhhhhhhhh" "iiiiiiiiiiiiiiiiii" "jjjjjjjjjjjjj" "kkkkkkkkkkkkkkkkk" "llllllllllll" "mmmmmmmmmmmmmm" + + +############################################################################## +# In clause headers (can_omit_optional_parentheses) +############################################################################## +# Use can_omit_optional_parentheses layout to avoid an instability where the formatter +# picks the can_omit_optional_parentheses layout when the strings are joined. +if ( + f"implicit" + "concatenated" + "string" + f"implicit" + "concaddddddddddded" + "ring" + * len([aaaaaa, bbbbbbbbbbbbbbbb, cccccccccccccccccc, ddddddddddddddddddddddddddd]) +): + pass + +# Keep parenthesizing multiline - implicit concatenated strings +if ( + f"implicit" + """concatenate + d""" + "string" + f"implicit" + "concaddddddddddded" + "ring" + * len([aaaaaa, bbbbbbbbbbbbbbbb, cccccccccccccccccc, ddddddddddddddddddddddddddd]) +): + pass + +if ( + [ + aaaaaa, + bbbbbbbbbbbbbbbb, + cccccccccccccccccc, + ddddddddddddddddddddddddddd, + ] + + "implicitconcat" + "enatedstriiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiing" +): + pass + + +# In match statements +match x: + case "implicitconcat" "enatedstring" | [ + aaaaaa, + bbbbbbbbbbbbbbbb, + cccccccccccccccccc, + ddddddddddddddddddddddddddd, + ]: + pass + + case [ + aaaaaa, + bbbbbbbbbbbbbbbb, + cccccccccccccccccc, + ddddddddddddddddddddddddddd, + ] | "implicitconcat" "enatedstring" : + pass + + case "implicitconcat" "enatedstriiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiing" | [ + aaaaaa, + bbbbbbbbbbbbbbbb, + cccccccccccccccccc, + ddddddddddddddddddddddddddd, + ]: + pass + + +############################################################################## +# In docstring positions +############################################################################## + +def short_docstring(): + "Implicit" "concatenated" "docstring" + +def long_docstring(): + "Loooooooooooooooooooooong" "doooooooooooooooooooocstriiiiiiiiiiiiiiiiiiiiiiiiiiiiiiing" "exceding the line width" "but it should be concatenated anyways because it is single line" + +def docstring_with_leading_whitespace(): + " This is a " "implicit" "concatenated" "docstring" + +def docstring_with_trailing_whitespace(): + "This is a " "implicit" "concatenated" "docstring " + +def docstring_with_leading_empty_parts(): + " " " " "" "This is a " "implicit" "concatenated" "docstring" + +def docstring_with_trailing_empty_parts(): + "This is a " "implicit" "concatenated" "docstring" "" " " " " + +def all_empty(): + " " " " " " + +def byte_string_in_docstring_position(): + b" don't trim the" b"bytes literal " + +def f_string_in_docstring_position(): + f" don't trim the" "f-string literal " + +def single_quoted(): + ' content\ ' ' ' + return + +def implicit_with_comment(): + ( + "a" + # leading + "the comment above" + ) + +############################################################################## +# Regressions +############################################################################## + +LEEEEEEEEEEEEEEEEEEEEEEFT = RRRRRRRRIIIIIIIIIIIIGGGGGHHHT | { + "entityNameeeeeeeeeeeeeeeeee", # comment must be long enough to + "some long implicit concatenated string" "that should join" +} + +# Ensure that flipping between Multiline and BestFit layout results in stable formatting +# when using IfBreaksParenthesized layout. +assert False, "Implicit concatenated string" "uses {} layout on {} format".format( + "Multiline", "first" +) + +assert False, await "Implicit concatenated string" "uses {} layout on {} format".format( + "Multiline", "first" +) + +assert False, "Implicit concatenated stringuses {} layout on {} format"[ + aaaaaaaaa, bbbbbb +] + +assert False, +"Implicit concatenated string" "uses {} layout on {} format".format( + "Multiline", "first" +) +``` + +## Outputs +### Output 1 +``` +indent-style = space +line-width = 88 +indent-width = 4 +quote-style = Double +line-ending = LineFeed +magic-trailing-comma = Respect +docstring-code = Disabled +docstring-code-line-width = "dynamic" +preview = Enabled +target_version = Py38 +source_type = Python +``` + +```python +"aaaaaaaaabbbbbbbbbbbbbbbbbbbb" # Join + +("aaaaaaaaaaabbbbbbbbbbbbbbbb") # join + + +( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +) # too long to join + + +'different \'quote "are fine"' # join + +# More single quotes +"one single'two 'single' two \"double\"" + +# More double quotes +'one double"two "double" two \'single\'' + +# Equal number of single and double quotes +"two \"double\" two 'single'" + +f"{'Hy "User"'}more" + +b"aaaaaaaaabbbbbbbbbbbbbbbbbbbb" # Join + +(b"aaaaaaaaaaabbbbbbbbbbbbbbbb") # join + + +( + b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + b"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +) # too long to join + + +# Skip joining if there is a trailing comment +( + "fffffffffffff" + "bbbbbbbbbbbbb" # comment + "cccccccccccccc" +) + +# Skip joining if there is a leading comment +( + "fffffffffffff" + # comment + "bbbbbbbbbbbbb" + "cccccccccccccc" +) + + +############################################################################## +# F-strings +############################################################################## + +# Escape `{` and `}` when marging an f-string with a string +f"a {{not_a_variable}}b {10}c" + +# Join, and break expressions +f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa{ + expression +}bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbcccccccccccccccccccc {20999}more" + +# Join, but don't break the expressions +f"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa{expression}bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbcccccccccccccccccccc {20999}more" + +f"test{expression}flatcan be {joined} together" + +aaaaaaaaaaa = ( + f"test{expression}flat" + f"cean beeeeeeee {joined} eeeeeeeeeeeeeeeeeeeeeeeeeeeee" +) # inline + + +f"single quoted '{x}'double quoted \"{x}\"" # Same number of quotes => use preferred quote style +f'single quote \' {x}double quoted "{x}"' # More double quotes => use single quotes +f"single quoted '{x}'double quote \" {x}\"" # More single quotes => use double quotes + +# Different triple quoted strings +f"{'''test'''}{'''other'''}" + +# Now with inner quotes +f"{'''test ' '''}" f'{"""other " """}' +f"{some_where_nested('''test ' ''')}" f'{"""other " """ + "more"}' +f"{b'''test ' '''}" f'{b"""other " """}' +f"{f'''test ' '''}" f'{f"""other " """}' + +# debug expressions containing quotes +f"{10 + len('bar')=}{10 + len('bar')=}" +f"{10 + len('bar')=}no debug{10}{10 + len('bar')=}" + +# We can't savely merge this pre Python 3.12 without altering the debug expression. +f"{10 + len('bar')=}" f'{10 + len("bar")=}' + + +############################################################################## +# Don't join raw strings +############################################################################## + +r"a" "normal" +R"a" "normal" + +f"test" rf"test" +f"test" Rf"test" + + +############################################################################## +# Don't join triple quoted strings +############################################################################## + +"single" """triple""" + +"single" f""""single""" + +b"single" b"""triple""" + + +############################################################################## +# Join strings in with statements +############################################################################## + +# Fits +with "aabbbcccccccccccccccccccccccccccccccccccccccccccccc": + pass + +# Parenthesize single-line +with ( + "aabbbccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" +): + pass + +# Multiline +with ( + "aa" + "bbb" + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" +): + pass + +with f"aaaaaaa{expression}bbbbccc {20999}more": + pass + + +############################################################################## +# For loops +############################################################################## + +# Flat +for a in "aaaaaaaaabbbbbbbbbcccccccccdddddddddd": + pass + +# Parenthesize single-line +for a in ( + "aaaaaaaaabbbbbbbbbcccccccccddddddddddeeeeeeeeeeeeeeefffffffffffffggggggggggggggghh" +): + pass + +# Multiline +for a in ( + "aaaaaaaaa" + "bbbbbbbbb" + "ccccccccc" + "dddddddddd" + "eeeeeeeeeeeeeee" + "fffffffffffff" + "ggggggggggggggg" + "hhhh" +): + pass + +############################################################################## +# Assert statement +############################################################################## + +# Fits +assert "aaaaaaaaabbbbbbbbbbbb", "ccccccccccccccccdddddddddddddddd" + +# Wrap right +assert "aaaaaaaaabbbbbbbbbbbb", ( + "ccccccccccccccccddddddddddddddddeeeeeeeeeeeeefffffffffff" +) + +# Right multiline +assert "aaaaaaaaabbbbbbbbbbbb", ( + "cccccccccccccccc" + "dddddddddddddddd" + "eeeeeeeeeeeee" + "fffffffffffffff" + "ggggggggggggg" + "hhhhhhhhhhh" +) + +# Wrap left +assert ( + "aaaaaaaaabbbbbbbbbbbbccccccccccccccccddddddddddddddddeeeeeeeeeeeeefffffffffffffff" +), "ggggggggggggghhhhhhhhhhh" + +# Left multiline +assert ( + "aaaaaaaaa" + "bbbbbbbbbbbb" + "cccccccccccccccc" + "dddddddddddddddd" + "eeeeeeeeeeeee" + "fffffffffffffff" + "ggggggggggggg" +), "hhhhhhhhhhh" + +# wrap both +assert ( + "aaaaaaaaabbbbbbbbbbbbccccccccccccccccddddddddddddddddeeeeeeeeeeeeefffffffffffffff" +), ( + "ggggggggggggg" + "hhhhhhhhhhh" + "iiiiiiiiiiiiiiiiii" + "jjjjjjjjjjjjj" + "kkkkkkkkkkkkkkkkk" + "llllllllllll" +) + +# both multiline +assert ( + "aaaaaaaaa" + "bbbbbbbbbbbb" + "cccccccccccccccc" + "dddddddddddddddd" + "eeeeeeeeeeeee" + "fffffffffffffff" + "ggggggggggggg" +), ( + "hhhhhhhhhhh" + "iiiiiiiiiiiiiiiiii" + "jjjjjjjjjjjjj" + "kkkkkkkkkkkkkkkkk" + "llllllllllll" + "mmmmmmmmmmmmmm" +) + + +############################################################################## +# In clause headers (can_omit_optional_parentheses) +############################################################################## +# Use can_omit_optional_parentheses layout to avoid an instability where the formatter +# picks the can_omit_optional_parentheses layout when the strings are joined. +if f"implicitconcatenatedstring" + f"implicitconcadddddddddddedring" * len([ + aaaaaa, + bbbbbbbbbbbbbbbb, + cccccccccccccccccc, + ddddddddddddddddddddddddddd, +]): + pass + +# Keep parenthesizing multiline - implicit concatenated strings +if ( + f"implicit" + """concatenate + d""" + "string" + f"implicit" + "concaddddddddddded" + "ring" + * len([aaaaaa, bbbbbbbbbbbbbbbb, cccccccccccccccccc, ddddddddddddddddddddddddddd]) +): + pass + +if ( + [ + aaaaaa, + bbbbbbbbbbbbbbbb, + cccccccccccccccccc, + ddddddddddddddddddddddddddd, + ] + + "implicitconcat" + "enatedstriiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiing" +): + pass + + +# In match statements +match x: + case "implicitconcatenatedstring" | [ + aaaaaa, + bbbbbbbbbbbbbbbb, + cccccccccccccccccc, + ddddddddddddddddddddddddddd, + ]: + pass + + case [ + aaaaaa, + bbbbbbbbbbbbbbbb, + cccccccccccccccccc, + ddddddddddddddddddddddddddd, + ] | "implicitconcatenatedstring": + pass + + case ( + "implicitconcat" + "enatedstriiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiing" + | [ + aaaaaa, + bbbbbbbbbbbbbbbb, + cccccccccccccccccc, + ddddddddddddddddddddddddddd, + ] + ): + pass + + +############################################################################## +# In docstring positions +############################################################################## + + +def short_docstring(): + "Implicitconcatenateddocstring" + + +def long_docstring(): + "Loooooooooooooooooooooongdoooooooooooooooooooocstriiiiiiiiiiiiiiiiiiiiiiiiiiiiiiingexceding the line widthbut it should be concatenated anyways because it is single line" + + +def docstring_with_leading_whitespace(): + "This is a implicitconcatenateddocstring" + + +def docstring_with_trailing_whitespace(): + "This is a implicitconcatenateddocstring" + + +def docstring_with_leading_empty_parts(): + "This is a implicitconcatenateddocstring" + + +def docstring_with_trailing_empty_parts(): + "This is a implicitconcatenateddocstring" + + +def all_empty(): + "" + + +def byte_string_in_docstring_position(): + b" don't trim thebytes literal " + + +def f_string_in_docstring_position(): + f" don't trim thef-string literal " + + +def single_quoted(): + "content\ " + return + + +def implicit_with_comment(): + ( + "a" + # leading + "the comment above" + ) + + +############################################################################## +# Regressions +############################################################################## + +LEEEEEEEEEEEEEEEEEEEEEEFT = RRRRRRRRIIIIIIIIIIIIGGGGGHHHT | { + "entityNameeeeeeeeeeeeeeeeee", # comment must be long enough to + "some long implicit concatenated stringthat should join", +} + +# Ensure that flipping between Multiline and BestFit layout results in stable formatting +# when using IfBreaksParenthesized layout. +assert False, "Implicit concatenated stringuses {} layout on {} format".format( + "Multiline", "first" +) + +assert False, await "Implicit concatenated stringuses {} layout on {} format".format( + "Multiline", "first" +) + +assert False, "Implicit concatenated stringuses {} layout on {} format"[ + aaaaaaaaa, bbbbbb +] + +assert False, +"Implicit concatenated stringuses {} layout on {} format".format( + "Multiline", "first" +) +``` diff --git a/crates/ruff_python_formatter/tests/snapshots/format@expression__join_implicit_concatenated_string_assignment.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@expression__join_implicit_concatenated_string_assignment.py.snap new file mode 100644 index 0000000000000..046264fe539e3 --- /dev/null +++ b/crates/ruff_python_formatter/tests/snapshots/format@expression__join_implicit_concatenated_string_assignment.py.snap @@ -0,0 +1,637 @@ +--- +source: crates/ruff_python_formatter/tests/fixtures.rs +input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/join_implicit_concatenated_string_assignment.py +--- +## Input +```python +## Implicit concatenated strings with a trailing comment but a non splittable target. + +# Don't join the string because the joined string with the inlined comment exceeds the line length limit. +____aaa = ( + "aaaaaaaaaaaaaaaaaaaaa" "aaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbvvvvvvvvvvvvvvv" +) # c + +# This is the same string as above and should lead to the same formatting. The only difference is that we start +# with an unparenthesized string. +____aaa = "aaaaaaaaaaaaaaaaaaaaa" "aaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbvvvvvvvvvvvvvvv" # c + +# Again the same string as above but this time as non-implicit concatenated string. +# It's okay if the formatting differs because it's an explicit choice to use implicit concatenation. +____aaa = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbvvvvvvvvvvvvvvv" # c + +# Join the string because it's exactly in the line length limit when the comment is inlined. +____aaa = ( + "aaaaaaaaaaaaaaaaaaa" "aaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbvvvvvvvvvvv" +) # c + +# This is the same string as above and should lead to the same formatting. The only difference is that we start +# with an unparenthesized string. +____aaa = "aaaaaaaaaaaaaaaaaaa" "aaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbvvvvvvvvvvv" # c + +# Again the same string as above but as a non-implicit concatenated string. It should result in the same formatting +# (for consistency). +____aaa = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbvvvvvvvvvvv" # c + +# It should collapse the parentheses if the joined string and the comment fit on the same line. +# This is required for stability. +____aaa = ( + "aaaaaaaaaaaaaaaaaaaaa" "aaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbvv" # c +) + + +############################################################# +# Assignments where the target or annotations are splittable +############################################################# + + +# The target splits because of a magic trailing comma +# The string is joined and not parenthesized because it just fits into the line length (including comment). +a[ + aaaaaaa, + b, +] = "ccccccccccccccccccccccccccccc" "cccccccccccccccccccccccccccccccccccccccccc" # comment + +# Same but starting with a joined string. They should both result in the same formatting. +[ + aaaaaaa, + b, +] = "ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" # comment + +# The target splits because of the magic trailing comma +# The string is **not** joined because it with the inlined comment exceeds the line length limit. +a[ + aaaaaaa, + b, +] = "ccccccccccccccccccccccccccccc" "ccccccccccccccccccccccccccccccccccccccccccc" # comment + + +# The target should be flat +# The string should be joined because it fits into the line length +a[ + aaaaaaa, + b +] = ( + "ccccccccccccccccccccccccccccccccccc" "cccccccccccccccccccccccc" # comment +) + +# Same but starting with a joined string. They should both result in the same formatting. +a[ + aaaaaaa, + b +] = "ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" # comment + +# The target should be flat +# The string gets parenthesized because it, with the inlined comment, exceeds the line length limit. +a[ + aaaaaaa, + b +] = "ccccccccccccccccccccccccccccc" "ccccccccccccccccccccccccccccccccccccccccccc" # comment + + +# Split an overlong target, but join the string if it fits +a[ + aaaaaaa, + b +].bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb = ( + "ccccccccccccccccccccccccccccccccccccccccc" "cccccccccccccccccccccccccccccc" # comment +) + +# Split both if necessary and keep multiline +a[ + aaaaaaa, + b +].bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb = ( + "ccccccccccccccccccccccccccccccccccccccccc" "ccccccccccccccccccccccccccccccc" # comment +) + +######################################################### +# Leading or trailing own line comments: +# Preserve the parentheses +######################################################## +a[ + aaaaaaa, + b +] = ( + # test + "ccccccccccccccccccccccccccccc" "ccccccccccccccccccccccccccccccccccccccccccc" +) + +a[ + aaaaaaa, + b +] = ( + "ccccccccccccccccccccccccccccc" "ccccccccccccccccccccccccccccccccccccccccccc" + # test +) + +a[ + aaaaaaa, + b +] = ( + "ccccccccccccccccccccccccccccccccccccccccc" "ccccccccccccccccccccccccccccccccccccccccccc" + # test +) + + +############################################################# +# Type alias statements +############################################################# + +# First break the right, join the string +type A[str, int, number] = "Literal[string, int] | None | " "CustomType" "| OtherCustomTypeExcee" # comment + +# Keep multiline if overlong +type A[str, int, number] = "Literal[string, int] | None | " "CustomTypeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" # comment + +# Break the left if it is over-long, join the string +type Aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa[stringgggggggggg, inttttttttttttttttttttttt, number] = "Literal[string, int] | None | " "CustomType" # comment + +# Break both if necessary and keep multiline +type Aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa[stringgggggggggg, inttttttttttttttttttttttt, number] = "Literal[string, int] | None | " "CustomTypeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" # comment + + +############################################################# +# F-Strings +############################################################# + +# Flatten and join the f-string +aaaaaaaaaaa = f"test{ +expression}flat" f"cean beeeeeeee {joined} eeeeeeeeeeeeeeeee" # inline + +# Parenthesize the value and join it, inline the comment +aaaaaaaaaaa = f"test{ +expression}flat" f"cean beeeeeeee {joined} eeeeeeeeeeeeeeeeeeeeeeeeeee" # inline + +# Parenthesize the f-string and keep it multiline because it doesn't fit on a single line including the comment +aaaaaaaaaaa = f"test{ +expression +}flat" f"cean beeeeeeee { +joined +} eeeeeeeeeeeeeeeeeeeeeeeeeeeee" # inline + + +# The target splits because of a magic trailing comma +# The string is joined and not parenthesized because it just fits into the line length (including comment). +a[ + aaaaaaa, + b, +] = f"ccccc{ +expression}ccccccccccc" f"cccccccccccccccccccccccccccccccccccccccccc" # comment + + +# Same but starting with a joined string. They should both result in the same formatting. +[ + aaaaaaa, + b, +] = f"ccccc{ +expression}ccccccccccccccccccccccccccccccccccccccccccccccccccccc" # comment + +# The target splits because of the magic trailing comma +# The string is **not** joined because it with the inlined comment exceeds the line length limit. +a[ + aaaaaaa, + b, +] = f"ccccc{ +expression}cccccccccccccccccccc" f"cccccccccccccccccccccccccccccccccccccccccc" # comment + + +# The target should be flat +# The string should be joined because it fits into the line length +a[ + aaaaaaa, + b +] = ( + f"ccccc{ + expression}ccccccccccc" "cccccccccccccccccccccccc" # comment +) + +# Same but starting with a joined string. They should both result in the same formatting. +a[ + aaaaaaa, + b +] = f"ccccc{ +expression}ccccccccccccccccccccccccccccccccccc" # comment + +# The target should be flat +# The string gets parenthesized because it, with the inlined comment, exceeds the line length limit. +a[ + aaaaaaa, + b +] = f"ccccc{ +expression}ccccccccccc" "ccccccccccccccccccccccccccccccccccccccccccc" # comment + + +# Split an overlong target, but join the string if it fits +a[ + aaaaaaa, + b +].bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb = ( + f"ccccc{ + expression}ccccccccccc" "cccccccccccccccccccccccccccccc" # comment +) + +# Split both if necessary and keep multiline +a[ + aaaaaaa, + b +].bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb = ( + f"ccccc{ + expression}cccccccccccccccccccccccccccccccc" "ccccccccccccccccccccccccccccccc" # comment +) + +# Don't inline f-strings that contain expressions that are guaranteed to split, e.b. because of a magic trailing comma +aaaaaaaaaaaaaaaaaa = f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{ +[a,] +}" "moreeeeeeeeeeeeeeeeeeee" "test" # comment + +aaaaaaaaaaaaaaaaaa = ( + f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{ +[a,] +}" "moreeeeeeeeeeeeeeeeeeee" "test" # comment +) + +aaaaa[aaaaaaaaaaa] = f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{ +[a,] +}" "moreeeeeeeeeeeeeeeeeeee" "test" # comment + +aaaaa[aaaaaaaaaaa] = (f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{ +[a,] +}" "moreeeeeeeeeeeeeeeeeeee" "test" # comment +) + +# Don't inline f-strings that contain commented expressions +aaaaaaaaaaaaaaaaaa = ( + f"testeeeeeeeeeeeeeeeeeeeeeeeee{[ + a # comment + ]}" "moreeeeeeeeeeeeeeeeeetest" # comment +) + +aaaaa[aaaaaaaaaaa] = ( + f"testeeeeeeeeeeeeeeeeeeeeeeeee{[ + a # comment + ]}" "moreeeeeeeeeeeeeeeeeetest" # comment +) + +# Don't inline f-strings with multiline debug expressions: +aaaaaaaaaaaaaaaaaa = ( + f"testeeeeeeeeeeeeeeeeeeeeeeeee{ + a=}" "moreeeeeeeeeeeeeeeeeetest" # comment +) + +aaaaaaaaaaaaaaaaaa = ( + f"testeeeeeeeeeeeeeeeeeeeeeeeee{a + + b=}" "moreeeeeeeeeeeeeeeeeetest" # comment +) + +aaaaaaaaaaaaaaaaaa = ( + f"testeeeeeeeeeeeeeeeeeeeeeeeee{a + =}" "moreeeeeeeeeeeeeeeeeetest" # comment +) + +aaaaa[aaaaaaaaaaa] = ( + f"testeeeeeeeeeeeeeeeeeeeeeeeee{ + a=}" "moreeeeeeeeeeeeeeeeeetest" # comment +) + +aaaaa[aaaaaaaaaaa] = ( + f"testeeeeeeeeeeeeeeeeeeeeeeeee{a + =}" "moreeeeeeeeeeeeeeeeeetest" # comment +) +``` + +## Outputs +### Output 1 +``` +indent-style = space +line-width = 88 +indent-width = 4 +quote-style = Double +line-ending = LineFeed +magic-trailing-comma = Respect +docstring-code = Disabled +docstring-code-line-width = "dynamic" +preview = Enabled +target_version = Py38 +source_type = Python +``` + +```python +## Implicit concatenated strings with a trailing comment but a non splittable target. + +# Don't join the string because the joined string with the inlined comment exceeds the line length limit. +____aaa = ( + "aaaaaaaaaaaaaaaaaaaaa" + "aaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbvvvvvvvvvvvvvvv" +) # c + +# This is the same string as above and should lead to the same formatting. The only difference is that we start +# with an unparenthesized string. +____aaa = ( + "aaaaaaaaaaaaaaaaaaaaa" + "aaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbvvvvvvvvvvvvvvv" +) # c + +# Again the same string as above but this time as non-implicit concatenated string. +# It's okay if the formatting differs because it's an explicit choice to use implicit concatenation. +____aaa = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbvvvvvvvvvvvvvvv" # c + +# Join the string because it's exactly in the line length limit when the comment is inlined. +____aaa = ( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbvvvvvvvvvvv" # c +) + +# This is the same string as above and should lead to the same formatting. The only difference is that we start +# with an unparenthesized string. +____aaa = ( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbvvvvvvvvvvv" # c +) + +# Again the same string as above but as a non-implicit concatenated string. It should result in the same formatting +# (for consistency). +____aaa = ( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbvvvvvvvvvvv" # c +) + +# It should collapse the parentheses if the joined string and the comment fit on the same line. +# This is required for stability. +____aaa = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbvv" # c + + +############################################################# +# Assignments where the target or annotations are splittable +############################################################# + + +# The target splits because of a magic trailing comma +# The string is joined and not parenthesized because it just fits into the line length (including comment). +a[ + aaaaaaa, + b, +] = "ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" # comment + +# Same but starting with a joined string. They should both result in the same formatting. +[ + aaaaaaa, + b, +] = "ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" # comment + +# The target splits because of the magic trailing comma +# The string is **not** joined because it with the inlined comment exceeds the line length limit. +a[ + aaaaaaa, + b, +] = ( + "ccccccccccccccccccccccccccccc" + "ccccccccccccccccccccccccccccccccccccccccccc" +) # comment + + +# The target should be flat +# The string should be joined because it fits into the line length +a[aaaaaaa, b] = "ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" # comment + +# Same but starting with a joined string. They should both result in the same formatting. +a[aaaaaaa, b] = "ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" # comment + +# The target should be flat +# The string gets parenthesized because it, with the inlined comment, exceeds the line length limit. +a[aaaaaaa, b] = ( + "ccccccccccccccccccccccccccccc" + "ccccccccccccccccccccccccccccccccccccccccccc" +) # comment + + +# Split an overlong target, but join the string if it fits +a[ + aaaaaaa, b +].bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb = ( + "ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" # comment +) + +# Split both if necessary and keep multiline +a[ + aaaaaaa, b +].bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb = ( + "ccccccccccccccccccccccccccccccccccccccccc" + "ccccccccccccccccccccccccccccccc" +) # comment + +######################################################### +# Leading or trailing own line comments: +# Preserve the parentheses +######################################################## +a[aaaaaaa, b] = ( + # test + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" +) + +a[aaaaaaa, b] = ( + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + # test +) + +a[aaaaaaa, b] = ( + "ccccccccccccccccccccccccccccccccccccccccc" + "ccccccccccccccccccccccccccccccccccccccccccc" + # test +) + + +############################################################# +# Type alias statements +############################################################# + +# First break the right, join the string +type A[str, int, number] = ( + "Literal[string, int] | None | CustomType| OtherCustomTypeExcee" # comment +) + +# Keep multiline if overlong +type A[str, int, number] = ( + "Literal[string, int] | None | " + "CustomTypeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" +) # comment + +# Break the left if it is over-long, join the string +type Aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa[ + stringgggggggggg, + inttttttttttttttttttttttt, + number, +] = "Literal[string, int] | None | CustomType" # comment + +# Break both if necessary and keep multiline +type Aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa[ + stringgggggggggg, + inttttttttttttttttttttttt, + number, +] = ( + "Literal[string, int] | None | " + "CustomTypeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" +) # comment + + +############################################################# +# F-Strings +############################################################# + +# Flatten and join the f-string +aaaaaaaaaaa = f"test{expression}flatcean beeeeeeee {joined} eeeeeeeeeeeeeeeee" # inline + +# Parenthesize the value and join it, inline the comment +aaaaaaaaaaa = ( + f"test{expression}flatcean beeeeeeee {joined} eeeeeeeeeeeeeeeeeeeeeeeeeee" # inline +) + +# Parenthesize the f-string and keep it multiline because it doesn't fit on a single line including the comment +aaaaaaaaaaa = ( + f"test{expression}flat" + f"cean beeeeeeee {joined} eeeeeeeeeeeeeeeeeeeeeeeeeeeee" +) # inline + + +# The target splits because of a magic trailing comma +# The string is joined and not parenthesized because it just fits into the line length (including comment). +a[ + aaaaaaa, + b, +] = f"ccccc{expression}ccccccccccccccccccccccccccccccccccccccccccccccccccccc" # comment + + +# Same but starting with a joined string. They should both result in the same formatting. +[ + aaaaaaa, + b, +] = f"ccccc{expression}ccccccccccccccccccccccccccccccccccccccccccccccccccccc" # comment + +# The target splits because of the magic trailing comma +# The string is **not** joined because it with the inlined comment exceeds the line length limit. +a[ + aaaaaaa, + b, +] = ( + f"ccccc{expression}cccccccccccccccccccc" + f"cccccccccccccccccccccccccccccccccccccccccc" +) # comment + + +# The target should be flat +# The string should be joined because it fits into the line length +a[aaaaaaa, b] = f"ccccc{expression}ccccccccccccccccccccccccccccccccccc" # comment + +# Same but starting with a joined string. They should both result in the same formatting. +a[aaaaaaa, b] = f"ccccc{expression}ccccccccccccccccccccccccccccccccccc" # comment + +# The target should be flat +# The string gets parenthesized because it, with the inlined comment, exceeds the line length limit. +a[aaaaaaa, b] = ( + f"ccccc{expression}ccccccccccc" + "ccccccccccccccccccccccccccccccccccccccccccc" +) # comment + + +# Split an overlong target, but join the string if it fits +a[ + aaaaaaa, b +].bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb = ( + f"ccccc{expression}ccccccccccccccccccccccccccccccccccccccccc" # comment +) + +# Split both if necessary and keep multiline +a[ + aaaaaaa, b +].bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb = ( + f"ccccc{expression}cccccccccccccccccccccccccccccccc" + "ccccccccccccccccccccccccccccccc" +) # comment + +# Don't inline f-strings that contain expressions that are guaranteed to split, e.b. because of a magic trailing comma +aaaaaaaaaaaaaaaaaa = ( + f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{ + [ + a, + ] + }" + "moreeeeeeeeeeeeeeeeeeee" + "test" +) # comment + +aaaaaaaaaaaaaaaaaa = ( + f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{ + [ + a, + ] + }" + "moreeeeeeeeeeeeeeeeeeee" + "test" # comment +) + +aaaaa[aaaaaaaaaaa] = ( + f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{ + [ + a, + ] + }" + "moreeeeeeeeeeeeeeeeeeee" + "test" +) # comment + +aaaaa[aaaaaaaaaaa] = ( + f"testeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee{ + [ + a, + ] + }" + "moreeeeeeeeeeeeeeeeeeee" + "test" # comment +) + +# Don't inline f-strings that contain commented expressions +aaaaaaaaaaaaaaaaaa = ( + f"testeeeeeeeeeeeeeeeeeeeeeeeee{ + [ + a # comment + ] + }" + "moreeeeeeeeeeeeeeeeeetest" # comment +) + +aaaaa[aaaaaaaaaaa] = ( + f"testeeeeeeeeeeeeeeeeeeeeeeeee{ + [ + a # comment + ] + }" + "moreeeeeeeeeeeeeeeeeetest" # comment +) + +# Don't inline f-strings with multiline debug expressions: +aaaaaaaaaaaaaaaaaa = ( + f"testeeeeeeeeeeeeeeeeeeeeeeeee{ + a=}" + "moreeeeeeeeeeeeeeeeeetest" # comment +) + +aaaaaaaaaaaaaaaaaa = ( + f"testeeeeeeeeeeeeeeeeeeeeeeeee{a + + b=}" + "moreeeeeeeeeeeeeeeeeetest" # comment +) + +aaaaaaaaaaaaaaaaaa = ( + f"testeeeeeeeeeeeeeeeeeeeeeeeee{a + =}" + "moreeeeeeeeeeeeeeeeeetest" # comment +) + +aaaaa[aaaaaaaaaaa] = ( + f"testeeeeeeeeeeeeeeeeeeeeeeeee{ + a=}" + "moreeeeeeeeeeeeeeeeeetest" # comment +) + +aaaaa[aaaaaaaaaaa] = ( + f"testeeeeeeeeeeeeeeeeeeeeeeeee{a + =}" + "moreeeeeeeeeeeeeeeeeetest" # comment +) +``` diff --git a/crates/ruff_python_formatter/tests/snapshots/format@expression__join_implicit_concatenated_string_preserve.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@expression__join_implicit_concatenated_string_preserve.py.snap new file mode 100644 index 0000000000000..72e46d20bf8af --- /dev/null +++ b/crates/ruff_python_formatter/tests/snapshots/format@expression__join_implicit_concatenated_string_preserve.py.snap @@ -0,0 +1,84 @@ +--- +source: crates/ruff_python_formatter/tests/fixtures.rs +input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/join_implicit_concatenated_string_preserve.py +--- +## Input +```python +a = "different '" 'quote "are fine"' # join + +# More single quotes +"one single'" "two 'single'" ' two "double"' + +# More double quotes +'one double"' 'two "double"' " two 'single'" + +# Equal number of single and double quotes +'two "double"' " two 'single'" + +# Already invalid Pre Python 312 +f"{'Hy "User"'}" f'{"Hy 'User'"}' +``` + +## Outputs +### Output 1 +``` +indent-style = space +line-width = 88 +indent-width = 4 +quote-style = Preserve +line-ending = LineFeed +magic-trailing-comma = Respect +docstring-code = Disabled +docstring-code-line-width = "dynamic" +preview = Enabled +target_version = Py38 +source_type = Python +``` + +```python +a = "different 'quote \"are fine\"" # join + +# More single quotes +"one single'two 'single' two \"double\"" + +# More double quotes +'one double"two "double" two \'single\'' + +# Equal number of single and double quotes +'two "double" two \'single\'' + +# Already invalid Pre Python 312 +f"{'Hy "User"'}{"Hy 'User'"}" +``` + + +### Output 2 +``` +indent-style = space +line-width = 88 +indent-width = 4 +quote-style = Preserve +line-ending = LineFeed +magic-trailing-comma = Respect +docstring-code = Disabled +docstring-code-line-width = "dynamic" +preview = Enabled +target_version = Py312 +source_type = Python +``` + +```python +a = "different 'quote \"are fine\"" # join + +# More single quotes +"one single'two 'single' two \"double\"" + +# More double quotes +'one double"two "double" two \'single\'' + +# Equal number of single and double quotes +'two "double" two \'single\'' + +# Already invalid Pre Python 312 +f"{'Hy "User"'}{"Hy 'User'"}" +``` diff --git a/crates/ruff_python_formatter/tests/snapshots/format@expression__string.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@expression__string.py.snap index 96a35c577ad56..3303732eb9434 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@expression__string.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@expression__string.py.snap @@ -331,6 +331,34 @@ a = """\\\x1f""" ``` +#### Preview changes +```diff +--- Stable ++++ Preview +@@ -70,9 +70,9 @@ + + # String continuation + +-"Let's" "start" "with" "a" "simple" "example" ++"Let'sstartwithasimpleexample" + +-"Let's" "start" "with" "a" "simple" "example" "now repeat after me:" "I am confident" "I am confident" "I am confident" "I am confident" "I am confident" ++"Let'sstartwithasimpleexamplenow repeat after me:I am confidentI am confidentI am confidentI am confidentI am confident" + + ( + "Let's" +@@ -139,7 +139,7 @@ + ] + + # Parenthesized string continuation with messed up indentation +-{"key": ([], "a" "b" "c")} ++{"key": ([], "abc")} + + + # Regression test for https://github.com/astral-sh/ruff/issues/5893 +``` + + ### Output 2 ``` indent-style = space @@ -515,4 +543,29 @@ a = """\\\x1f""" ``` - +#### Preview changes +```diff +--- Stable ++++ Preview +@@ -70,9 +70,9 @@ + + # String continuation + +-"Let's" 'start' 'with' 'a' 'simple' 'example' ++"Let'sstartwithasimpleexample" + +-"Let's" 'start' 'with' 'a' 'simple' 'example' 'now repeat after me:' 'I am confident' 'I am confident' 'I am confident' 'I am confident' 'I am confident' ++"Let'sstartwithasimpleexamplenow repeat after me:I am confidentI am confidentI am confidentI am confidentI am confident" + + ( + "Let's" +@@ -139,7 +139,7 @@ + ] + + # Parenthesized string continuation with messed up indentation +-{'key': ([], 'a' 'b' 'c')} ++{'key': ([], 'abc')} + + + # Regression test for https://github.com/astral-sh/ruff/issues/5893 +``` diff --git a/crates/ruff_python_formatter/tests/snapshots/format@expression__yield.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@expression__yield.py.snap index 0d591cc737f80..0649b92081271 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@expression__yield.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@expression__yield.py.snap @@ -279,4 +279,37 @@ print((yield x)) ``` - +## Preview changes +```diff +--- Stable ++++ Preview +@@ -78,7 +78,7 @@ + ) + ) + +-yield "Cache key will cause errors if used with memcached: %r " "(longer than %s)" % ( ++yield "Cache key will cause errors if used with memcached: %r (longer than %s)" % ( + key, + MEMCACHE_MAX_KEY_LENGTH, + ) +@@ -96,8 +96,7 @@ + "Django to create, modify, and delete the table" + ) + yield ( +- "# Feel free to rename the models, but don't rename db_table values or " +- "field names." ++ "# Feel free to rename the models, but don't rename db_table values or field names." + ) + + yield ( +@@ -109,8 +108,7 @@ + "Django to create, modify, and delete the table" + ) + yield ( +- "# Feel free to rename the models, but don't rename db_table values or " +- "field names." ++ "# Feel free to rename the models, but don't rename db_table values or field names." + ) + + # Regression test for: https://github.com/astral-sh/ruff/issues/7420 +``` diff --git a/crates/ruff_python_formatter/tests/snapshots/format@parentheses__opening_parentheses_comment_empty.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@parentheses__opening_parentheses_comment_empty.py.snap index 126da8e6e3800..7969cd7b7d29c 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@parentheses__opening_parentheses_comment_empty.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@parentheses__opening_parentheses_comment_empty.py.snap @@ -188,6 +188,3 @@ f3 = { # f3 { # f 4 } ``` - - - diff --git a/crates/ruff_python_formatter/tests/snapshots/format@pattern__pattern_maybe_parenthesize.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@pattern__pattern_maybe_parenthesize.py.snap index 29004a1548d04..89c82dd396ddd 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@pattern__pattern_maybe_parenthesize.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@pattern__pattern_maybe_parenthesize.py.snap @@ -831,7 +831,51 @@ match x: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb, ccccccccccccccccccccccccccccccccc, ): -@@ -246,63 +238,48 @@ +@@ -220,89 +212,80 @@ + + ## Always use parentheses for implicitly concatenated strings + match x: +- case ( +- "implicit" "concatenated" "string" +- | [aaaaaa, bbbbbbbbbbbbbbbb, cccccccccccccccccc, ddddddddddddddddddddddddddd] +- ): ++ case "implicitconcatenatedstring" | [ ++ aaaaaa, ++ bbbbbbbbbbbbbbbb, ++ cccccccccccccccccc, ++ ddddddddddddddddddddddddddd, ++ ]: + pass + + + match x: +- case ( +- b"implicit" b"concatenated" b"string" +- | [aaaaaa, bbbbbbbbbbbbbbbb, cccccccccccccccccc, ddddddddddddddddddddddddddd] +- ): ++ case b"implicitconcatenatedstring" | [ ++ aaaaaa, ++ bbbbbbbbbbbbbbbb, ++ cccccccccccccccccc, ++ ddddddddddddddddddddddddddd, ++ ]: + pass + + + match x: +- case ( +- f"implicit" "concatenated" "string" +- | [aaaaaa, bbbbbbbbbbbbbbbb, cccccccccccccccccc, ddddddddddddddddddddddddddd] +- ): ++ case f"implicitconcatenatedstring" | [ ++ aaaaaa, ++ bbbbbbbbbbbbbbbb, ++ cccccccccccccccccc, ++ ddddddddddddddddddddddddddd, ++ ]: + pass + + ## Complex number expressions and unary expressions match x: diff --git a/crates/ruff_python_formatter/tests/snapshots/format@quote_style.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@quote_style.py.snap index d00f653417462..68e97d27f0bc7 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@quote_style.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@quote_style.py.snap @@ -131,6 +131,28 @@ def docstring_single(): ``` +#### Preview changes +```diff +--- Stable ++++ Preview +@@ -33,10 +33,10 @@ + rb"""br single triple""" + rb"""br double triple""" + +-'single1' 'single2' +-'single1' 'double2' +-'double1' 'single2' +-'double1' 'double2' ++'single1single2' ++'single1double2' ++'double1single2' ++'double1double2' + + + def docstring_single_triple(): +``` + + ### Output 2 ``` indent-style = space @@ -205,6 +227,28 @@ def docstring_single(): ``` +#### Preview changes +```diff +--- Stable ++++ Preview +@@ -33,10 +33,10 @@ + rb"""br single triple""" + rb"""br double triple""" + +-"single1" "single2" +-"single1" "double2" +-"double1" "single2" +-"double1" "double2" ++"single1single2" ++"single1double2" ++"double1single2" ++"double1double2" + + + def docstring_single_triple(): +``` + + ### Output 3 ``` indent-style = space @@ -279,4 +323,23 @@ def docstring_single(): ``` - +#### Preview changes +```diff +--- Stable ++++ Preview +@@ -33,10 +33,10 @@ + rb'''br single triple''' + rb"""br double triple""" + +-'single1' 'single2' +-'single1' "double2" +-"double1" 'single2' +-"double1" "double2" ++'single1single2' ++'single1double2' ++"double1single2" ++"double1double2" + + + def docstring_single_triple(): +``` diff --git a/crates/ruff_python_formatter/tests/snapshots/format@statement__assert.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@statement__assert.py.snap index 0193312898a7f..2bc958df25c17 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@statement__assert.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@statement__assert.py.snap @@ -362,4 +362,154 @@ assert package.files == [ ``` - +## Preview changes +```diff +--- Stable ++++ Preview +@@ -30,50 +30,47 @@ + + + def test(): +- assert ( +- { +- key1: value1, +- key2: value2, +- key3: value3, +- key4: value4, +- key5: value5, +- key6: value6, +- key7: value7, +- key8: value8, +- key9: value9, +- } +- == expected +- ), "Not what we expected and the message is too long to fit ineeeeee one line" ++ assert { ++ key1: value1, ++ key2: value2, ++ key3: value3, ++ key4: value4, ++ key5: value5, ++ key6: value6, ++ key7: value7, ++ key8: value8, ++ key9: value9, ++ } == expected, ( ++ "Not what we expected and the message is too long to fit ineeeeee one line" ++ ) + +- assert ( +- { +- key1: value1, +- key2: value2, +- key3: value3, +- key4: value4, +- key5: value5, +- key6: value6, +- key7: value7, +- key8: value8, +- key9: value9, +- } +- == expected +- ), "Not what we expected and the message is too long to fit in one lineeeee" ++ assert { ++ key1: value1, ++ key2: value2, ++ key3: value3, ++ key4: value4, ++ key5: value5, ++ key6: value6, ++ key7: value7, ++ key8: value8, ++ key9: value9, ++ } == expected, ( ++ "Not what we expected and the message is too long to fit in one lineeeee" ++ ) + +- assert ( +- { +- key1: value1, +- key2: value2, +- key3: value3, +- key4: value4, +- key5: value5, +- key6: value6, +- key7: value7, +- key8: value8, +- key9: value9, +- } +- == expected +- ), "Not what we expected and the message is too long to fit in one lineeeeeeeeeeeee" ++ assert { ++ key1: value1, ++ key2: value2, ++ key3: value3, ++ key4: value4, ++ key5: value5, ++ key6: value6, ++ key7: value7, ++ key8: value8, ++ key9: value9, ++ } == expected, ( ++ "Not what we expected and the message is too long to fit in one lineeeeeeeeeeeee" ++ ) + + assert ( + { +@@ -103,7 +100,9 @@ + key9: value9, + } + == expectedeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee +- ), "Not what we expected and the message is too long to fit in one lineeeeeeeeeeeeeeeee" ++ ), ( ++ "Not what we expected and the message is too long to fit in one lineeeeeeeeeeeeeeeee" ++ ) + + assert expected == { + key1: value1, +@@ -117,20 +116,19 @@ + key9: value9, + }, "Not what we expected and the message is too long to fit ineeeeee one line" + +- assert ( +- expected +- == { +- key1: value1, +- key2: value2, +- key3: value3, +- key4: value4, +- key5: value5, +- key6: value6, +- key7: value7, +- key8: value8, +- key9: value9, +- } +- ), "Not what we expected and the message is too long to fit in one lineeeeeeeeeeeeeeeeeeee" ++ assert expected == { ++ key1: value1, ++ key2: value2, ++ key3: value3, ++ key4: value4, ++ key5: value5, ++ key6: value6, ++ key7: value7, ++ key8: value8, ++ key9: value9, ++ }, ( ++ "Not what we expected and the message is too long to fit in one lineeeeeeeeeeeeeeeeeeee" ++ ) + + assert ( + expectedeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee +@@ -160,7 +158,9 @@ + key8: value8, + key9: value9, + } +- ), "Not what we expected and the message is too long to fit in one lineeeeeeeeeeeeeee" ++ ), ( ++ "Not what we expected and the message is too long to fit in one lineeeeeeeeeeeeeee" ++ ) + + + # Test for https://github.com/astral-sh/ruff/issues/7246 +``` diff --git a/crates/ruff_python_formatter/tests/snapshots/format@statement__return_type_no_parameters.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@statement__return_type_no_parameters.py.snap index 2032db2308701..01cc4a19ebf42 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@statement__return_type_no_parameters.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@statement__return_type_no_parameters.py.snap @@ -397,6 +397,21 @@ def f() -> ( pass +@@ -80,12 +78,12 @@ + ######################################################################################### + + +-def test_implicit_concatenated_string_return_type() -> "str" "bbbbbbbbbbbbbbbb": ++def test_implicit_concatenated_string_return_type() -> "strbbbbbbbbbbbbbbbb": + pass + + + def test_overlong_implicit_concatenated_string_return_type() -> ( +- "liiiiiiiiiiiisssssst[str]" "bbbbbbbbbbbbbbbb" ++ "liiiiiiiiiiiisssssst[str]bbbbbbbbbbbbbbbb" + ): + pass + @@ -108,9 +106,9 @@ # 1. Black tries to keep the list flat by parenthesizing the list as shown below even when the `list` identifier # fits on the header line. IMO, this adds unnecessary parentheses that can be avoided diff --git a/crates/ruff_python_formatter/tests/snapshots/format@statement__return_type_parameters.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@statement__return_type_parameters.py.snap index ca5a99fc920b6..20d605b884317 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@statement__return_type_parameters.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@statement__return_type_parameters.py.snap @@ -412,3 +412,26 @@ def test_return_multiline_string_binary_expression_return_type_annotation( ]: pass ``` + + +## Preview changes +```diff +--- Stable ++++ Preview +@@ -82,13 +82,13 @@ + ######################################################################################### + + +-def test_implicit_concatenated_string_return_type(a) -> "str" "bbbbbbbbbbbbbbbb": ++def test_implicit_concatenated_string_return_type(a) -> "strbbbbbbbbbbbbbbbb": + pass + + + def test_overlong_implicit_concatenated_string_return_type( + a, +-) -> "liiiiiiiiiiiisssssst[str]" "bbbbbbbbbbbbbbbb": ++) -> "liiiiiiiiiiiisssssst[str]bbbbbbbbbbbbbbbb": + pass + + +``` diff --git a/crates/ruff_python_semantic/src/analyze/typing.rs b/crates/ruff_python_semantic/src/analyze/typing.rs index 073b6334a6515..d915bfa8b6901 100644 --- a/crates/ruff_python_semantic/src/analyze/typing.rs +++ b/crates/ruff_python_semantic/src/analyze/typing.rs @@ -487,7 +487,7 @@ fn check_type<T: TypeChecker>(binding: &Binding, semantic: &SemanticModel) -> bo // The type checker might know how to infer the type based on `init_expr`. Some(Stmt::Assign(ast::StmtAssign { targets, value, .. })) => targets .iter() - .find_map(|target| match_value(binding, target, value.as_ref())) + .find_map(|target| match_value(binding, target, value)) .is_some_and(|value| T::match_initializer(value, semantic)), // ```python @@ -496,7 +496,7 @@ fn check_type<T: TypeChecker>(binding: &Binding, semantic: &SemanticModel) -> bo // // In this situation, we check only the annotation. Some(Stmt::AnnAssign(ast::StmtAnnAssign { annotation, .. })) => { - T::match_annotation(annotation.as_ref(), semantic) + T::match_annotation(annotation, semantic) } _ => false, @@ -512,7 +512,7 @@ fn check_type<T: TypeChecker>(binding: &Binding, semantic: &SemanticModel) -> bo .expressions(source) .find_map(|expr| expr.as_named_expr()) .and_then(|ast::ExprNamed { target, value, .. }| { - match_value(binding, target.as_ref(), value.as_ref()) + match_value(binding, target, value) }) .is_some_and(|value| T::match_initializer(value, semantic)) }) @@ -543,13 +543,13 @@ fn check_type<T: TypeChecker>(binding: &Binding, semantic: &SemanticModel) -> bo // // We trust the annotation and see if the type checker matches the annotation. Some(Stmt::FunctionDef(ast::StmtFunctionDef { parameters, .. })) => { - let Some(parameter) = find_parameter(parameters.as_ref(), binding) else { + let Some(parameter) = find_parameter(parameters, binding) else { return false; }; let Some(ref annotation) = parameter.parameter.annotation else { return false; }; - T::match_annotation(annotation.as_ref(), semantic) + T::match_annotation(annotation, semantic) } _ => false, @@ -562,7 +562,7 @@ fn check_type<T: TypeChecker>(binding: &Binding, semantic: &SemanticModel) -> bo // // It's a typed declaration, type annotation is the only source of information. Some(Stmt::AnnAssign(ast::StmtAnnAssign { annotation, .. })) => { - T::match_annotation(annotation.as_ref(), semantic) + T::match_annotation(annotation, semantic) } _ => false, }, @@ -726,7 +726,7 @@ impl TypeChecker for IoBaseChecker { // Ex) `open("file.txt")` semantic - .resolve_qualified_name(func.as_ref()) + .resolve_qualified_name(func) .is_some_and(|qualified_name| { matches!( qualified_name.segments(), @@ -899,7 +899,7 @@ pub fn find_binding_value<'a>(binding: &Binding, semantic: &'a SemanticModel) -> .expressions(parent_id) .find_map(|expr| expr.as_named_expr()); if let Some(ast::ExprNamed { target, value, .. }) = parent { - return match_value(binding, target.as_ref(), value.as_ref()); + return match_value(binding, target, value); } } // Ex) `x = 1` @@ -907,14 +907,14 @@ pub fn find_binding_value<'a>(binding: &Binding, semantic: &'a SemanticModel) -> Some(Stmt::Assign(ast::StmtAssign { value, targets, .. })) => { return targets .iter() - .find_map(|target| match_value(binding, target, value.as_ref())) + .find_map(|target| match_value(binding, target, value)) } Some(Stmt::AnnAssign(ast::StmtAnnAssign { value: Some(value), target, .. })) => { - return match_value(binding, target, value.as_ref()); + return match_value(binding, target, value); } _ => {} }, @@ -958,7 +958,7 @@ fn match_value<'a>(binding: &Binding, target: &Expr, value: &'a Expr) -> Option< /// Given a target and value, find the value that's assigned to the given symbol. fn match_target<'a>(binding: &Binding, targets: &[Expr], values: &'a [Expr]) -> Option<&'a Expr> { - for (target, value) in targets.iter().zip(values.iter()) { + for (target, value) in targets.iter().zip(values) { match target { Expr::Tuple(ast::ExprTuple { elts: target_elts, .. diff --git a/crates/ruff_python_semantic/src/model.rs b/crates/ruff_python_semantic/src/model.rs index 959ab5226d3c9..7c7967e80e36c 100644 --- a/crates/ruff_python_semantic/src/model.rs +++ b/crates/ruff_python_semantic/src/model.rs @@ -795,7 +795,7 @@ impl<'a> SemanticModel<'a> { let resolved: QualifiedName = qualified_name .segments() .iter() - .chain(tail.iter()) + .chain(tail) .copied() .collect(); Some(resolved) @@ -809,7 +809,7 @@ impl<'a> SemanticModel<'a> { .segments() .iter() .take(1) - .chain(tail.iter()) + .chain(tail) .copied() .collect(), ) @@ -832,7 +832,7 @@ impl<'a> SemanticModel<'a> { qualified_name .segments() .iter() - .chain(tail.iter()) + .chain(tail) .copied() .collect() }; diff --git a/crates/ruff_python_trivia/src/cursor.rs b/crates/ruff_python_trivia/src/cursor.rs index 5c2e218ff5609..c06d83156549d 100644 --- a/crates/ruff_python_trivia/src/cursor.rs +++ b/crates/ruff_python_trivia/src/cursor.rs @@ -26,6 +26,16 @@ impl<'a> Cursor<'a> { self.chars.clone() } + /// Returns the remaining input as byte slice. + pub fn as_bytes(&self) -> &'a [u8] { + self.as_str().as_bytes() + } + + /// Returns the remaining input as string slice. + pub fn as_str(&self) -> &'a str { + self.chars.as_str() + } + /// Peeks the next character from the input stream without consuming it. /// Returns [`EOF_CHAR`] if the file is at the end of the file. pub fn first(&self) -> char { @@ -110,4 +120,13 @@ impl<'a> Cursor<'a> { self.bump_back(); } } + + /// Skips the next `count` bytes. + /// + /// ## Panics + /// - If `count` is larger than the remaining bytes in the input stream. + /// - If `count` indexes into a multi-byte character. + pub fn skip_bytes(&mut self, count: usize) { + self.chars = self.chars.as_str()[count..].chars(); + } } diff --git a/crates/ruff_python_trivia/src/tokenizer.rs b/crates/ruff_python_trivia/src/tokenizer.rs index b480214acbb74..9b42665235f5b 100644 --- a/crates/ruff_python_trivia/src/tokenizer.rs +++ b/crates/ruff_python_trivia/src/tokenizer.rs @@ -30,7 +30,10 @@ pub fn find_only_token_in_range( let token = tokens.next().expect("Expected a token"); debug_assert_eq!(token.kind(), token_kind); let mut tokens = tokens.skip_while(|token| token.kind == SimpleTokenKind::LParen); - debug_assert_eq!(tokens.next(), None); + #[allow(clippy::debug_assert_with_mut_call)] + { + debug_assert_eq!(tokens.next(), None); + } token } diff --git a/crates/ruff_server/tests/notebook.rs b/crates/ruff_server/tests/notebook.rs index 52a63b19193e4..d9e3f690b12f7 100644 --- a/crates/ruff_server/tests/notebook.rs +++ b/crates/ruff_server/tests/notebook.rs @@ -298,7 +298,7 @@ fn super_resolution_overview() { .unwrap(); } - let snapshot = session.take_snapshot(file_url.clone()).unwrap(); + let snapshot = session.take_snapshot(file_url).unwrap(); insta::assert_snapshot!( "changed_notebook", diff --git a/crates/ruff_source_file/Cargo.toml b/crates/ruff_source_file/Cargo.toml index b57c2eb638bc9..0f82f37169ee8 100644 --- a/crates/ruff_source_file/Cargo.toml +++ b/crates/ruff_source_file/Cargo.toml @@ -16,7 +16,6 @@ license = { workspace = true } ruff_text_size = { workspace = true } memchr = { workspace = true } -once_cell = { workspace = true } serde = { workspace = true, optional = true } [dev-dependencies] diff --git a/crates/ruff_source_file/src/lib.rs b/crates/ruff_source_file/src/lib.rs index 078c50cdc21e8..a0de92b1c14a0 100644 --- a/crates/ruff_source_file/src/lib.rs +++ b/crates/ruff_source_file/src/lib.rs @@ -1,6 +1,6 @@ use std::cmp::Ordering; use std::fmt::{Debug, Display, Formatter}; -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; @@ -134,9 +134,9 @@ impl SourceFileBuilder { /// Consumes `self` and returns the [`SourceFile`]. pub fn finish(self) -> SourceFile { let index = if let Some(index) = self.index { - once_cell::sync::OnceCell::with_value(index) + OnceLock::from(index) } else { - once_cell::sync::OnceCell::new() + OnceLock::new() }; SourceFile { @@ -218,7 +218,7 @@ impl Ord for SourceFile { struct SourceFileInner { name: Box<str>, code: Box<str>, - line_index: once_cell::sync::OnceCell<LineIndex>, + line_index: OnceLock<LineIndex>, } impl PartialEq for SourceFileInner { diff --git a/crates/ruff_source_file/src/line_index.rs b/crates/ruff_source_file/src/line_index.rs index a66d6b2f99e34..e9d211ca5c1ff 100644 --- a/crates/ruff_source_file/src/line_index.rs +++ b/crates/ruff_source_file/src/line_index.rs @@ -394,6 +394,18 @@ impl OneIndexed { None => Self::MIN, } } + + /// Checked addition. Returns `None` if overflow occurred. + #[must_use] + pub fn checked_add(self, rhs: Self) -> Option<Self> { + self.0.checked_add(rhs.0.get()).map(Self) + } + + /// Checked subtraction. Returns `None` if overflow occurred. + #[must_use] + pub fn checked_sub(self, rhs: Self) -> Option<Self> { + self.0.get().checked_sub(rhs.get()).and_then(Self::new) + } } impl fmt::Display for OneIndexed { diff --git a/crates/ruff_source_file/src/locator.rs b/crates/ruff_source_file/src/locator.rs index 30bd59d830fe4..f133377a9e195 100644 --- a/crates/ruff_source_file/src/locator.rs +++ b/crates/ruff_source_file/src/locator.rs @@ -1,10 +1,9 @@ //! Struct used to efficiently slice source code at (row, column) Locations. -use std::ops::Add; - use memchr::{memchr2, memrchr2}; -use once_cell::unsync::OnceCell; use ruff_text_size::{Ranged, TextLen, TextRange, TextSize}; +use std::cell::OnceCell; +use std::ops::Add; use crate::newlines::find_newline; use crate::{LineIndex, OneIndexed, SourceCode, SourceLocation}; @@ -23,10 +22,10 @@ impl<'a> Locator<'a> { } } - pub const fn with_index(contents: &'a str, index: LineIndex) -> Self { + pub fn with_index(contents: &'a str, index: LineIndex) -> Self { Self { contents, - index: OnceCell::with_value(index), + index: OnceCell::from(index), } } diff --git a/crates/ruff_wasm/Cargo.toml b/crates/ruff_wasm/Cargo.toml index 065a997681025..d203947df3efd 100644 --- a/crates/ruff_wasm/Cargo.toml +++ b/crates/ruff_wasm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ruff_wasm" -version = "0.7.0" +version = "0.7.1" publish = false authors = { workspace = true } edition = { workspace = true } diff --git a/crates/ruff_workspace/Cargo.toml b/crates/ruff_workspace/Cargo.toml index 81b5f87596747..df81cd034764a 100644 --- a/crates/ruff_workspace/Cargo.toml +++ b/crates/ruff_workspace/Cargo.toml @@ -34,7 +34,7 @@ glob = { workspace = true } globset = { workspace = true } path-absolutize = { workspace = true } path-slash = { workspace = true } -pep440_rs = { workspace = true, features = ["serde"] } +pep440_rs = { workspace = true } regex = { workspace = true } rustc-hash = { workspace = true } schemars = { workspace = true, optional = true } diff --git a/crates/ruff_workspace/src/configuration.rs b/crates/ruff_workspace/src/configuration.rs index 5d95fac9d01f2..40aebcdc4f425 100644 --- a/crates/ruff_workspace/src/configuration.rs +++ b/crates/ruff_workspace/src/configuration.rs @@ -803,7 +803,7 @@ impl LintConfiguration { .select .iter() .flatten() - .chain(selection.extend_select.iter()) + .chain(&selection.extend_select) .filter(|s| s.specificity() == spec) { for rule in selector.rules(&preview) { @@ -830,7 +830,7 @@ impl LintConfiguration { .fixable .iter() .flatten() - .chain(selection.extend_fixable.iter()) + .chain(&selection.extend_fixable) .filter(|s| s.specificity() == spec) { for rule in selector.all_rules() { @@ -2029,11 +2029,11 @@ mod tests { assert_override( vec![ RuleSelection { - select: Some(vec![d417.clone()]), + select: Some(vec![d417]), ..RuleSelection::default() }, RuleSelection { - extend_select: vec![d41.clone()], + extend_select: vec![d41], ..RuleSelection::default() }, ], diff --git a/crates/ruff_workspace/src/resolver.rs b/crates/ruff_workspace/src/resolver.rs index 04750c8509362..ae9f46f1a97b8 100644 --- a/crates/ruff_workspace/src/resolver.rs +++ b/crates/ruff_workspace/src/resolver.rs @@ -219,7 +219,7 @@ impl<'a> Resolver<'a> { /// Return an iterator over the resolved [`Settings`] in this [`Resolver`]. pub fn settings(&self) -> impl Iterator<Item = &Settings> { - std::iter::once(&self.pyproject_config.settings).chain(self.settings.iter()) + std::iter::once(&self.pyproject_config.settings).chain(&self.settings) } } diff --git a/docs/configuration.md b/docs/configuration.md index 3d8a288a236de..20da0743dfa64 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -515,7 +515,7 @@ Ruff: An extremely fast Python linter and code formatter. Usage: ruff [OPTIONS] <COMMAND> Commands: - check Run Ruff on the given files or directories (default) + check Run Ruff on the given files or directories rule Explain a rule (or all rules) config List or describe the available configuration options linter List all supported upstream linters @@ -557,7 +557,7 @@ Or `ruff help check` for more on the linting command: <!-- Begin auto-generated check help. --> ```text -Run Ruff on the given files or directories (default) +Run Ruff on the given files or directories Usage: ruff check [OPTIONS] [FILES]... diff --git a/docs/integrations.md b/docs/integrations.md index 973fba10364e2..e8479db3b90ab 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -71,6 +71,34 @@ For example, to run `ruff check --select B ./src` using Ruff version `0.0.259`: src: "./src" ``` +## GitLab CI/CD + +You can add the following configuration to `.gitlab-ci.yml` to run a `ruff format` in parallel with a `ruff check` compatible with GitLab's codequality report. + +```yaml +.base_ruff: + stage: build + interruptible: true + image: + name: ghcr.io/astral-sh/ruff:0.7.1-alpine + before_script: + - cd $CI_PROJECT_DIR + - ruff --version + +Ruff Check: + extends: .base_ruff + script: + - ruff check --output-format=gitlab > code-quality-report.json + artifacts: + reports: + codequality: $CI_PROJECT_DIR/code-quality-report.json + +Ruff Format: + extends: .base_ruff + script: + - ruff format --diff +``` + ## pre-commit Ruff can be used as a [pre-commit](https://pre-commit.com) hook via [`ruff-pre-commit`](https://github.com/astral-sh/ruff-pre-commit): @@ -78,7 +106,7 @@ Ruff can be used as a [pre-commit](https://pre-commit.com) hook via [`ruff-pre-c ```yaml - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.7.0 + rev: v0.7.1 hooks: # Run the linter. - id: ruff @@ -91,7 +119,7 @@ To enable lint fixes, add the `--fix` argument to the lint hook: ```yaml - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.7.0 + rev: v0.7.1 hooks: # Run the linter. - id: ruff @@ -105,7 +133,7 @@ To run the hooks over Jupyter Notebooks too, add `jupyter` to the list of allowe ```yaml - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.7.0 + rev: v0.7.1 hooks: # Run the linter. - id: ruff @@ -131,3 +159,29 @@ When running without `--fix`, Ruff's formatter hook can be placed before or afte [mdformat](https://mdformat.readthedocs.io/en/stable/users/plugins.html#code-formatter-plugins) is capable of formatting code blocks within Markdown. The [`mdformat-ruff`](https://github.com/Freed-Wu/mdformat-ruff) plugin enables mdformat to format Python code blocks with Ruff. + + +## Docker + +Ruff provides a distroless Docker image including the `ruff` binary. The following tags are published: + +- `ruff:latest` +- `ruff:{major}.{minor}.{patch}`, e.g., `ruff:0.6.6` +- `ruff:{major}.{minor}`, e.g., `ruff:0.6` (the latest patch version) + +In addition, ruff publishes the following images: + +<!-- prettier-ignore --> +- Based on `alpine:3.20`: + - `ruff:alpine` + - `ruff:alpine3.20` +- Based on `debian:bookworm-slim`: + - `ruff:debian-slim` + - `ruff:bookworm-slim` +- Based on `buildpack-deps:bookworm`: + - `ruff:debian` + - `ruff:bookworm` + +As with the distroless image, each image is published with ruff version tags as +`ruff:{major}.{minor}.{patch}-{base}` and `ruff:{major}.{minor}-{base}`, e.g., `ruff:0.6.6-alpine`. + diff --git a/docs/requirements-insiders.txt b/docs/requirements-insiders.txt index 64a16d5d511cf..2db2df8b51f71 100644 --- a/docs/requirements-insiders.txt +++ b/docs/requirements-insiders.txt @@ -1,9 +1,9 @@ PyYAML==6.0.2 -ruff==0.6.9 +ruff==0.7.0 mkdocs==1.6.1 mkdocs-material @ git+ssh://git@github.com/astral-sh/mkdocs-material-insiders.git@38c0b8187325c3bab386b666daf3518ac036f2f4 mkdocs-redirects==1.2.1 -mdformat==0.7.17 +mdformat==0.7.18 mdformat-mkdocs==3.0.0 mdformat-admon==2.0.6 # Using a commit from pygments main branch so we get diff --git a/docs/requirements.txt b/docs/requirements.txt index d2abb0f4175e4..8e3a27df14609 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,9 +1,9 @@ PyYAML==6.0.2 -ruff==0.6.9 +ruff==0.7.0 mkdocs==1.6.1 mkdocs-material==9.1.18 mkdocs-redirects==1.2.1 -mdformat==0.7.17 +mdformat==0.7.18 mdformat-mkdocs==3.0.0 mdformat-admon==2.0.6 # Using a commit from pygments main branch so we get diff --git a/playground/api/package-lock.json b/playground/api/package-lock.json index 5c05c407e426c..b82288712e16f 100644 --- a/playground/api/package-lock.json +++ b/playground/api/package-lock.json @@ -16,7 +16,7 @@ "@cloudflare/workers-types": "^4.20230801.0", "miniflare": "^3.20230801.1", "typescript": "^5.1.6", - "wrangler": "3.80.0" + "wrangler": "3.81.0" } }, "node_modules/@cloudflare/kv-asset-handler": { @@ -33,9 +33,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20240925.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20240925.0.tgz", - "integrity": "sha512-KdLnSXuzB65CbqZPm+qYzk+zkQ1tUNPaaRGYVd/jPYAxwwtfTUQdQ+ahDPwVVs2tmQELKy7ZjQjf2apqSWUfjw==", + "version": "1.20241011.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20241011.1.tgz", + "integrity": "sha512-gZ2PrMCQ4WdDCB+V6vsB2U2SyYcmgaGMEa3GGjcUfC79L/8so3Vp/bO0eCoLmvttRs39wascZ+JiWL0HpcZUgA==", "cpu": [ "x64" ], @@ -50,9 +50,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20240925.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20240925.0.tgz", - "integrity": "sha512-MiQ6uUmCXjsXgWNV+Ock2tp2/tYqNJGzjuaH6jFioeRF+//mz7Tv7J7EczOL4zq+TH8QFOh0/PUsLyazIWVGng==", + "version": "1.20241011.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20241011.1.tgz", + "integrity": "sha512-c26TYtS0e3WZ09nL/a8YaEqveCsTlgDm12ehPMNua9u68sh1KzETMl2G45O934m8UrI3Rhpv2TTecO0S5b9exA==", "cpu": [ "arm64" ], @@ -67,9 +67,9 @@ } }, "node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20240925.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20240925.0.tgz", - "integrity": "sha512-Rjix8jsJMfsInmq3Hm3fmiRQ+rwzuWRPV1pg/OWhMSfNP7Qp2RCU+RGkhgeR9Z5eNAje0Sn2BMrFq4RvF9/yRA==", + "version": "1.20241011.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20241011.1.tgz", + "integrity": "sha512-pl4xvHNXnm3cYh5GwHadOTQRWt4Ih/gzCOb6RW4n78oNQQydFvpwqYAjbYk32y485feLhdTKXut/MgZAyWnKyQ==", "cpu": [ "x64" ], @@ -84,9 +84,9 @@ } }, "node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20240925.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20240925.0.tgz", - "integrity": "sha512-VYIPeMHQRtbwQoIjUwS/zULlywPxyDvo46XkTpIW5MScEChfqHvAYviQ7TzYGx6Q+gmZmN+DUB2KOMx+MEpCxA==", + "version": "1.20241011.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20241011.1.tgz", + "integrity": "sha512-I4HAF2Qe8xgIjAdE53viT2fDdHXkrb3Be0L3eWeeP5SEkOtQ4cHLqsOV7yhUWOJpHiI1XCDcf+wdfn0PB/EngQ==", "cpu": [ "arm64" ], @@ -101,9 +101,9 @@ } }, "node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20240925.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20240925.0.tgz", - "integrity": "sha512-C8peGvaU5R51bIySi1VbyfRgwNSSRknqoFSnSbSBI3uTN3THTB3UnmRKy7GXJDmyjgXuT9Pcs1IgaWNubLtNtw==", + "version": "1.20241011.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20241011.1.tgz", + "integrity": "sha512-oVr1Cb7NkDpukd7v68FdxOH8vaHRSzHkX9uE/IttHd2yPK6mwOS220nIxK9UMcx5CwZmrgphRwtZwSYVk/lREQ==", "cpu": [ "x64" ], @@ -118,9 +118,9 @@ } }, "node_modules/@cloudflare/workers-shared": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/@cloudflare/workers-shared/-/workers-shared-0.5.4.tgz", - "integrity": "sha512-PNL/0TjKRdUHa1kwgVdqUNJVZ9ez4kacsi8omz+gv859EvJmsVuGiMAClY2YfJnC9LVKhKCcjqmFgKNXG9/IXA==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-shared/-/workers-shared-0.6.0.tgz", + "integrity": "sha512-rfUCvb3hx4AsvdUZsxgk9lmgEnQehqV3jdtXLP/Xr0+P56n11T/0nXNMzmn7Nnv+IJFOV6X9NmFhuMz4sBPw7w==", "dev": true, "license": "MIT OR Apache-2.0", "dependencies": { @@ -132,9 +132,9 @@ } }, "node_modules/@cloudflare/workers-types": { - "version": "4.20241004.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20241004.0.tgz", - "integrity": "sha512-3LrPvtecs4umknOF1bTPNLHUG/ZjeSE6PYBQ/tbO7lwaVhjZTaTugiaCny2byrZupBlVNuubQVktcAgMfw0C1A==", + "version": "4.20241018.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20241018.0.tgz", + "integrity": "sha512-gGWumpaaqnjN/G6SI7CZQ13G4SpIUV+EM8QqvuN2D3cGDF6mKIm+ov7H4x7V/c+AdY90v0FCc8B5nmEEt90MvQ==", "dev": true, "license": "MIT OR Apache-2.0" }, @@ -1087,9 +1087,9 @@ } }, "node_modules/miniflare": { - "version": "3.20240925.0", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-3.20240925.0.tgz", - "integrity": "sha512-2LmQbKHf0n6ertUKhT+Iltixi53giqDH7P71+wCir3OnGyXIODqYwOECx1mSDNhYThpxM2dav8UdPn6SQiMoXw==", + "version": "3.20241011.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-3.20241011.0.tgz", + "integrity": "sha512-Mb3U9+QvKgIUl9LgHwBxEz8WajMRYqO5mMHRtO8yHjNCLGh24I6Ts9z13zRAYGPDd1xBQ1o983fHT9S+tn6r+A==", "dev": true, "license": "MIT", "dependencies": { @@ -1101,7 +1101,7 @@ "glob-to-regexp": "^0.4.1", "stoppable": "^1.1.0", "undici": "^5.28.4", - "workerd": "1.20240925.0", + "workerd": "1.20241011.1", "ws": "^8.17.1", "youch": "^3.2.2", "zod": "^3.22.3" @@ -1467,9 +1467,9 @@ "dev": true }, "node_modules/typescript": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.2.tgz", - "integrity": "sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==", + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -1502,9 +1502,9 @@ }, "node_modules/unenv": { "name": "unenv-nightly", - "version": "2.0.0-20240919-125358-9a64854", - "resolved": "https://registry.npmjs.org/unenv-nightly/-/unenv-nightly-2.0.0-20240919-125358-9a64854.tgz", - "integrity": "sha512-XjsgUTrTHR7iw+k/SRTNjh6EQgwpC9voygnoCJo5kh4hKqsSDHUW84MhL9EsHTNfLctvVBHaSw8e2k3R2fKXsQ==", + "version": "2.0.0-20241009-125958-e8ea22f", + "resolved": "https://registry.npmjs.org/unenv-nightly/-/unenv-nightly-2.0.0-20241009-125958-e8ea22f.tgz", + "integrity": "sha512-hRxmKz1iSVRmuFx/vBdPsx7rX4o7Cas9vdjDNeUeWpQTK2LzU3Xy3Jz0zbo7MJX0bpqo/LEFCA+GPwsbl6zKEQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1553,9 +1553,9 @@ } }, "node_modules/workerd": { - "version": "1.20240925.0", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20240925.0.tgz", - "integrity": "sha512-/Jj6+yLwfieZGEt3Kx4+5MoufuC3g/8iFaIh4MPBNGJOGYmdSKXvgCqz09m2+tVCYnysRfbq2zcbVxJRBfOCqQ==", + "version": "1.20241011.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20241011.1.tgz", + "integrity": "sha512-ORobT1XDkE+p+36yk6Szyw68bWuGSmuwIlDnAeUOfnYunb/Txt0jg7ydzfwr4UIsof7AH5F1nqZms5PWLu05yw==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -1566,36 +1566,36 @@ "node": ">=16" }, "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20240925.0", - "@cloudflare/workerd-darwin-arm64": "1.20240925.0", - "@cloudflare/workerd-linux-64": "1.20240925.0", - "@cloudflare/workerd-linux-arm64": "1.20240925.0", - "@cloudflare/workerd-windows-64": "1.20240925.0" + "@cloudflare/workerd-darwin-64": "1.20241011.1", + "@cloudflare/workerd-darwin-arm64": "1.20241011.1", + "@cloudflare/workerd-linux-64": "1.20241011.1", + "@cloudflare/workerd-linux-arm64": "1.20241011.1", + "@cloudflare/workerd-windows-64": "1.20241011.1" } }, "node_modules/wrangler": { - "version": "3.80.0", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-3.80.0.tgz", - "integrity": "sha512-c9aN7Buf7XgTPpQkw1d0VjNRI4qg3m35PTg70Tg4mOeHwHGjsd74PAsP1G8BjpdqDjfWtsua7tj1g4M3/5dYNQ==", + "version": "3.81.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-3.81.0.tgz", + "integrity": "sha512-sa5dhLJAMmYtl/dJWDJ92sdnKj0VUC0DYBfGqbhd5xn7CDdn1oGhICDXtx2E6BNhQ1L+4d9oAcP/oQvOs5gKLA==", "dev": true, "license": "MIT OR Apache-2.0", "dependencies": { "@cloudflare/kv-asset-handler": "0.3.4", - "@cloudflare/workers-shared": "0.5.4", + "@cloudflare/workers-shared": "0.6.0", "@esbuild-plugins/node-globals-polyfill": "^0.2.3", "@esbuild-plugins/node-modules-polyfill": "^0.2.2", "blake3-wasm": "^2.1.5", "chokidar": "^3.5.3", "esbuild": "0.17.19", - "miniflare": "3.20240925.0", + "miniflare": "3.20241011.0", "nanoid": "^3.3.3", "path-to-regexp": "^6.3.0", "resolve": "^1.22.8", "resolve.exports": "^2.0.2", "selfsigned": "^2.0.1", "source-map": "^0.6.1", - "unenv": "npm:unenv-nightly@2.0.0-20240919-125358-9a64854", - "workerd": "1.20240925.0", + "unenv": "npm:unenv-nightly@2.0.0-20241009-125958-e8ea22f", + "workerd": "1.20241011.1", "xxhash-wasm": "^1.0.1" }, "bin": { @@ -1609,7 +1609,7 @@ "fsevents": "~2.3.2" }, "peerDependencies": { - "@cloudflare/workers-types": "^4.20240925.0" + "@cloudflare/workers-types": "^4.20241011.0" }, "peerDependenciesMeta": { "@cloudflare/workers-types": { diff --git a/playground/api/package.json b/playground/api/package.json index 0da4ca8f4d241..9dba3234a8852 100644 --- a/playground/api/package.json +++ b/playground/api/package.json @@ -5,7 +5,7 @@ "@cloudflare/workers-types": "^4.20230801.0", "miniflare": "^3.20230801.1", "typescript": "^5.1.6", - "wrangler": "3.80.0" + "wrangler": "3.81.0" }, "private": true, "scripts": { diff --git a/playground/package-lock.json b/playground/package-lock.json index cdc39990528d8..e3e266a473516 100644 --- a/playground/package-lock.json +++ b/playground/package-lock.json @@ -28,7 +28,7 @@ "eslint-config-prettier": "^9.0.0", "eslint-plugin-import": "^2.29.1", "eslint-plugin-react": "^7.31.11", - "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-hooks": "^5.0.0", "postcss": "^8.4.20", "prettier": "^3.0.0", "tailwindcss": "^3.2.4", @@ -1140,26 +1140,27 @@ } }, "node_modules/@types/react-dom": { - "version": "18.3.0", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz", - "integrity": "sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==", "dev": true, + "license": "MIT", "dependencies": { "@types/react": "*" } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.8.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.8.0.tgz", - "integrity": "sha512-wORFWjU30B2WJ/aXBfOm1LX9v9nyt9D3jsSOxC3cCaTQGCW5k4jNpmjFv3U7p/7s4yvdjHzwtv2Sd2dOyhjS0A==", + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.10.0.tgz", + "integrity": "sha512-phuB3hoP7FFKbRXxjl+DRlQDuJqhpOnm5MmtROXyWi3uS/Xg2ZXqiQfcG2BJHiN4QKyzdOJi3NEn/qTnjUlkmQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.8.0", - "@typescript-eslint/type-utils": "8.8.0", - "@typescript-eslint/utils": "8.8.0", - "@typescript-eslint/visitor-keys": "8.8.0", + "@typescript-eslint/scope-manager": "8.10.0", + "@typescript-eslint/type-utils": "8.10.0", + "@typescript-eslint/utils": "8.10.0", + "@typescript-eslint/visitor-keys": "8.10.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -1183,16 +1184,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.8.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.8.0.tgz", - "integrity": "sha512-uEFUsgR+tl8GmzmLjRqz+VrDv4eoaMqMXW7ruXfgThaAShO9JTciKpEsB+TvnfFfbg5IpujgMXVV36gOJRLtZg==", + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.10.0.tgz", + "integrity": "sha512-E24l90SxuJhytWJ0pTQydFT46Nk0Z+bsLKo/L8rtQSL93rQ6byd1V/QbDpHUTdLPOMsBCcYXZweADNCfOCmOAg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/scope-manager": "8.8.0", - "@typescript-eslint/types": "8.8.0", - "@typescript-eslint/typescript-estree": "8.8.0", - "@typescript-eslint/visitor-keys": "8.8.0", + "@typescript-eslint/scope-manager": "8.10.0", + "@typescript-eslint/types": "8.10.0", + "@typescript-eslint/typescript-estree": "8.10.0", + "@typescript-eslint/visitor-keys": "8.10.0", "debug": "^4.3.4" }, "engines": { @@ -1212,14 +1213,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.8.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.8.0.tgz", - "integrity": "sha512-EL8eaGC6gx3jDd8GwEFEV091210U97J0jeEHrAYvIYosmEGet4wJ+g0SYmLu+oRiAwbSA5AVrt6DxLHfdd+bUg==", + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.10.0.tgz", + "integrity": "sha512-AgCaEjhfql9MDKjMUxWvH7HjLeBqMCBfIaBbzzIcBbQPZE7CPh1m6FF+L75NUMJFMLYhCywJXIDEMa3//1A0dw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.8.0", - "@typescript-eslint/visitor-keys": "8.8.0" + "@typescript-eslint/types": "8.10.0", + "@typescript-eslint/visitor-keys": "8.10.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1230,14 +1231,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.8.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.8.0.tgz", - "integrity": "sha512-IKwJSS7bCqyCeG4NVGxnOP6lLT9Okc3Zj8hLO96bpMkJab+10HIfJbMouLrlpyOr3yrQ1cA413YPFiGd1mW9/Q==", + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.10.0.tgz", + "integrity": "sha512-PCpUOpyQSpxBn230yIcK+LeCQaXuxrgCm2Zk1S+PTIRJsEfU6nJ0TtwyH8pIwPK/vJoA+7TZtzyAJSGBz+s/dg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.8.0", - "@typescript-eslint/utils": "8.8.0", + "@typescript-eslint/typescript-estree": "8.10.0", + "@typescript-eslint/utils": "8.10.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -1255,9 +1256,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.8.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.8.0.tgz", - "integrity": "sha512-QJwc50hRCgBd/k12sTykOJbESe1RrzmX6COk8Y525C9l7oweZ+1lw9JiU56im7Amm8swlz00DRIlxMYLizr2Vw==", + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.10.0.tgz", + "integrity": "sha512-k/E48uzsfJCRRbGLapdZgrX52csmWJ2rcowwPvOZ8lwPUv3xW6CcFeJAXgx4uJm+Ge4+a4tFOkdYvSpxhRhg1w==", "dev": true, "license": "MIT", "engines": { @@ -1269,14 +1270,14 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.8.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.8.0.tgz", - "integrity": "sha512-ZaMJwc/0ckLz5DaAZ+pNLmHv8AMVGtfWxZe/x2JVEkD5LnmhWiQMMcYT7IY7gkdJuzJ9P14fRy28lUrlDSWYdw==", + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.10.0.tgz", + "integrity": "sha512-3OE0nlcOHaMvQ8Xu5gAfME3/tWVDpb/HxtpUZ1WeOAksZ/h/gwrBzCklaGzwZT97/lBbbxJ16dMA98JMEngW4w==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/types": "8.8.0", - "@typescript-eslint/visitor-keys": "8.8.0", + "@typescript-eslint/types": "8.10.0", + "@typescript-eslint/visitor-keys": "8.10.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -1324,16 +1325,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.8.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.8.0.tgz", - "integrity": "sha512-QE2MgfOTem00qrlPgyByaCHay9yb1+9BjnMFnSFkUKQfu7adBXDTnCAivURnuPPAG/qiB+kzKkZKmKfaMT0zVg==", + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.10.0.tgz", + "integrity": "sha512-Oq4uZ7JFr9d1ZunE/QKy5egcDRXT/FrS2z/nlxzPua2VHFtmMvFNDvpq1m/hq0ra+T52aUezfcjGRIB7vNJF9w==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.8.0", - "@typescript-eslint/types": "8.8.0", - "@typescript-eslint/typescript-estree": "8.8.0" + "@typescript-eslint/scope-manager": "8.10.0", + "@typescript-eslint/types": "8.10.0", + "@typescript-eslint/typescript-estree": "8.10.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1347,13 +1348,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.8.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.8.0.tgz", - "integrity": "sha512-8mq51Lx6Hpmd7HnA2fcHQo3YgfX1qbccxQOgZcb4tvasu//zXRaA1j5ZRFeCw/VRAdFi4mRM9DnZw0Nu0Q2d1g==", + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.10.0.tgz", + "integrity": "sha512-k8nekgqwr7FadWk548Lfph6V3r9OVqjzAIVskE7orMZR23cGJjAOVazsZSJW+ElyjfTM4wx/1g88Mi70DDtG9A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.8.0", + "@typescript-eslint/types": "8.10.0", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -2525,15 +2526,16 @@ } }, "node_modules/eslint-plugin-react-hooks": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz", - "integrity": "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.0.0.tgz", + "integrity": "sha512-hIOwI+5hYGpJEc4uPRmz2ulCjAGD/N13Lukkh8cLV0i2IRk/bdZDYjgLVHj+U9Z704kLIdIO6iueGvxNur0sgw==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "node_modules/eslint-plugin-react/node_modules/doctrine": { @@ -4779,9 +4781,9 @@ } }, "node_modules/tailwindcss": { - "version": "3.4.13", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.13.tgz", - "integrity": "sha512-KqjHOJKogOUt5Bs752ykCeiwvi0fKVkr5oqsFNt/8px/tA8scFPIlkygsf6jXrfCqGHz7VflA6+yytWuM+XhFw==", + "version": "3.4.14", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.14.tgz", + "integrity": "sha512-IcSvOcTRcUtQQ7ILQL5quRDg7Xs93PdJEk1ZLbhhvJc7uj/OAhYOnruEiwnGgBvUtaUAJ8/mhSw1o8L2jCiENA==", "dev": true, "license": "MIT", "dependencies": { @@ -4983,9 +4985,9 @@ } }, "node_modules/typescript": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.2.tgz", - "integrity": "sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==", + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -5058,9 +5060,9 @@ "dev": true }, "node_modules/vite": { - "version": "5.4.8", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.8.tgz", - "integrity": "sha512-FqrItQ4DT1NC4zCUqMB4c4AZORMKIa0m8/URVCZ77OZ/QSNeJ54bU1vrFADbDsuwfIPcgknRkmqakQcgnL4GiQ==", + "version": "5.4.9", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.9.tgz", + "integrity": "sha512-20OVpJHh0PAM0oSOELa5GaZNWeDjcAvQjGXy2Uyr+Tp+/D2/Hdz6NLgpJLsarPTA2QJ6v8mX2P1ZfbsSKvdMkg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/playground/package.json b/playground/package.json index 67e30cfc1f9b5..380fb08e244c9 100644 --- a/playground/package.json +++ b/playground/package.json @@ -35,7 +35,7 @@ "eslint-config-prettier": "^9.0.0", "eslint-plugin-import": "^2.29.1", "eslint-plugin-react": "^7.31.11", - "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-hooks": "^5.0.0", "postcss": "^8.4.20", "prettier": "^3.0.0", "tailwindcss": "^3.2.4", diff --git a/playground/src/Editor/Diagnostics.tsx b/playground/src/Editor/Diagnostics.tsx index c5c5652f40f67..850de4725528b 100644 --- a/playground/src/Editor/Diagnostics.tsx +++ b/playground/src/Editor/Diagnostics.tsx @@ -67,10 +67,10 @@ function Items({ return ( <ul className="space-y-0.5 flex-grow overflow-y-scroll"> - {diagnostics.map((diagnostic) => { + {diagnostics.map((diagnostic, index) => { return ( <li - key={`${diagnostic.location.row}:${diagnostic.location.column}-${diagnostic.code}`} + key={`${diagnostic.location.row}:${diagnostic.location.column}-${diagnostic.code ?? index}`} > <button onClick={() => diff --git a/pyproject.toml b/pyproject.toml index 90cd6fe2fd23a..0f6810dd80852 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "ruff" -version = "0.7.0" +version = "0.7.1" description = "An extremely fast Python linter and code formatter, written in Rust." authors = [{ name = "Astral Software Inc.", email = "hey@astral.sh" }] readme = "README.md" diff --git a/python/ruff-ecosystem/pyproject.toml b/python/ruff-ecosystem/pyproject.toml index 1ed27e7017cde..73fd29c0651fe 100644 --- a/python/ruff-ecosystem/pyproject.toml +++ b/python/ruff-ecosystem/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" name = "ruff-ecosystem" version = "0.0.0" requires-python = ">=3.11" -dependencies = ["unidiff==0.7.5", "tomli_w==1.0.0", "tomli==2.0.2"] +dependencies = ["unidiff==0.7.5", "tomli_w==1.1.0", "tomli==2.0.2"] [project.scripts] ruff-ecosystem = "ruff_ecosystem.cli:entrypoint" diff --git a/scripts/benchmarks/pyproject.toml b/scripts/benchmarks/pyproject.toml index e796fec03b750..1edebef90a6f1 100644 --- a/scripts/benchmarks/pyproject.toml +++ b/scripts/benchmarks/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "scripts" -version = "0.7.0" +version = "0.7.1" description = "" authors = ["Charles Marsh <charlie.r.marsh@gmail.com>"] diff --git a/scripts/knot_benchmark/src/benchmark/cases.py b/scripts/knot_benchmark/src/benchmark/cases.py index 6268a7c1db208..3f97a1bfd7434 100644 --- a/scripts/knot_benchmark/src/benchmark/cases.py +++ b/scripts/knot_benchmark/src/benchmark/cases.py @@ -154,12 +154,12 @@ def __init__(self, path: Path): self.path = path @property - def name(self): + def name(self) -> str: """The name of the virtual environment directory.""" return self.path.name @property - def python(self): + def python(self) -> Path: """Returns the path to the python executable""" return self.script("python") @@ -193,7 +193,7 @@ def create(parent: Path) -> Venv: root = parent / "venv" return Venv(root) - def install(self, dependencies: list[str]): + def install(self, dependencies: list[str]) -> None: """Installs the dependencies required to type check the project.""" logging.debug(f"Installing dependencies: {', '.join(dependencies)}") @@ -202,7 +202,7 @@ def install(self, dependencies: list[str]): "pip", "install", "--python", - self.python, + self.python.as_posix(), "--quiet", # We pass `--exclude-newer` to ensure that type-checking of one of # our projects isn't unexpectedly broken by a change in the diff --git a/scripts/knot_benchmark/src/benchmark/projects.py b/scripts/knot_benchmark/src/benchmark/projects.py index 749ed4a622a9b..534cb89587c87 100644 --- a/scripts/knot_benchmark/src/benchmark/projects.py +++ b/scripts/knot_benchmark/src/benchmark/projects.py @@ -1,5 +1,4 @@ import logging -import os import subprocess import typing from pathlib import Path @@ -30,9 +29,9 @@ class Project(typing.NamedTuple): mypy_arguments: list[str] | None = None """The arguments passed to mypy. Overrides `include` if set.""" - def clone(self, checkout_dir: Path): + def clone(self, checkout_dir: Path) -> None: # Skip cloning if the project has already been cloned (the script doesn't yet support updating) - if os.path.exists(os.path.join(checkout_dir, ".git")): + if (checkout_dir / ".git").exists(): return logging.debug(f"Cloning {self.repository} to {checkout_dir}") diff --git a/scripts/knot_benchmark/src/benchmark/run.py b/scripts/knot_benchmark/src/benchmark/run.py index 0fa5932a0a4fc..eab1449651733 100644 --- a/scripts/knot_benchmark/src/benchmark/run.py +++ b/scripts/knot_benchmark/src/benchmark/run.py @@ -16,7 +16,7 @@ from benchmark.cases import Tool -def main(): +def main() -> None: """Run the benchmark.""" parser = argparse.ArgumentParser( description="Benchmark knot against other packaging tools." @@ -69,7 +69,7 @@ def main(): ) parser.add_argument( "--knot-path", - type=str, + type=Path, help="Path(s) to the red_knot binary to benchmark.", action="append", ) @@ -116,8 +116,8 @@ def main(): ] for project in projects: - with tempfile.TemporaryDirectory() as cwd: - cwd = Path(cwd) + with tempfile.TemporaryDirectory() as tempdir: + cwd = Path(tempdir) project.clone(cwd) venv = Venv.create(cwd)