diff --git a/.flake8 b/.flake8 new file mode 100644 index 000000000..4f16d6847 --- /dev/null +++ b/.flake8 @@ -0,0 +1,4 @@ +[flake8] + +# Match the maximum line length in Swift files. +max-line-length = 120 diff --git a/.github/workflows/publish_release.yml b/.github/workflows/publish_release.yml new file mode 100644 index 000000000..a360c3f97 --- /dev/null +++ b/.github/workflows/publish_release.yml @@ -0,0 +1,152 @@ +name: Publish Release + +on: + workflow_dispatch: + inputs: + prerelease: + type: boolean + description: "Prerelease" + # Whether to create a prerelease or proper release + default: true + required: true + swift_format_version: + type: string + default: 601.0.0 + description: "swift-format version" + # The version of swift-format to tag. If this is a prerelease, `-prerelease-` is added to this version. + required: true + swift_syntax_tag: + type: string + default: 601.0.0 + description: "swift-syntax version" + # The swift-syntax version to depend on. If this is a prerelease, the latest swift-syntax prerelease tag for this version is used. + required: true + +jobs: + check_triggering_actor: + name: Check user is allowed to create release + # Only a single user should be allowed to create releases to avoid two people triggering the creation of a release + # at the same time. If the release manager changes between users, update this condition. + runs-on: ubuntu-latest + steps: + - run: | + if [[ "${{ github.triggering_actor }}" != "ahoppen" ]]; then + echo "${{ github.triggering_actor }} is not allowed to create a release" + exit 1 + fi + create_release_commits: + name: Create release commits + runs-on: ubuntu-latest + outputs: + swift_format_version: ${{ steps.swift_format_version.outputs.swift_format_version }} + release_commit_patch: ${{ steps.create_release_commits.outputs.release_commit_patch }} + steps: + - name: Determine swift-syntax tag to depend on + id: swift_syntax_tag + shell: bash + run: | + if [[ "${{ github.event.inputs.prerelease }}" == "false" ]]; then + SWIFT_SYNTAX_TAG="${{ github.event.inputs.swift_syntax_tag }}" + else + git clone https://github.com/swiftlang/swift-syntax.git + cd swift-syntax + SWIFT_SYNTAX_TAG="$(git tag | grep ${{ github.event.inputs.swift_syntax_tag }}-prerelease | sort -r | head -1)" + fi + + echo "Using swift-syntax tag: $SWIFT_SYNTAX_TAG" + echo "swift_syntax_tag=$SWIFT_SYNTAX_TAG" >> "$GITHUB_OUTPUT" + - name: Determine swift-format prerelease version + id: swift_format_version + run: | + if [[ "${{ github.event.inputs.prerelease }}" == "false" ]]; then + SWIFT_FORMAT_VERSION="${{ github.event.inputs.swift_format_version }}" + else + SWIFT_FORMAT_VERSION="${{ github.event.inputs.swift_format_version }}-prerelease-$(date +'%Y-%m-%d')" + fi + echo "Using swift-format version: $SWIFT_FORMAT_VERSION" + echo "swift_format_version=$SWIFT_FORMAT_VERSION" >> "$GITHUB_OUTPUT" + - name: Checkout repository + uses: actions/checkout@v4 + - name: Create release commits + id: create_release_commits + run: | + # Without this, we can't perform git operations in GitHub actions. + git config --global --add safe.directory "$(realpath .)" + git config --local user.name 'swift-ci' + git config --local user.email 'swift-ci@users.noreply.github.com' + + BASE_COMMIT=$(git rev-parse HEAD) + + sed -E -i "s#branch: \"(main|release/[0-9]+\.[0-9]+)\"#from: \"${{ steps.swift_syntax_tag.outputs.swift_syntax_tag }}\"#" Package.swift + git add Package.swift + git commit -m "Change swift-syntax dependency to ${{ steps.swift_syntax_tag.outputs.swift_syntax_tag }}" + + sed -E -i "s#print\(\".*\"\)#print\(\"${{ steps.swift_format_version.outputs.swift_format_version }}\"\)#" Sources/swift-format/PrintVersion.swift + git add Sources/swift-format/PrintVersion.swift + git commit -m "Change version to ${{ steps.swift_format_version.outputs.swift_format_version }}" + + { + echo 'release_commit_patch<> "$GITHUB_OUTPUT" + test: + name: Test in ${{ matrix.release && 'Release' || 'Debug' }} configuration + uses: swiftlang/github-workflows/.github/workflows/swift_package_test.yml@main + needs: create_release_commits + strategy: + fail-fast: false + matrix: + release: [true, false] + with: + linux_pre_build_command: | + git config --global --add safe.directory "$(realpath .)" + git config --local user.name 'swift-ci' + git config --local user.email 'swift-ci@users.noreply.github.com' + git am << EOF + ${{ needs.create_release_commits.outputs.release_commit_patch }} + EOF + windows_pre_build_command: | + git config --local user.name "swift-ci" + git config --local user.email "swift-ci@users.noreply.github.com" + echo @" + ${{ needs.create_release_commits.outputs.release_commit_patch }} + "@ > $env:TEMP\patch.diff + # For some reason `git am` fails in Powershell with the following error. Executing it in cmd works... + # fatal: empty ident name (for <>) not allowed + cmd /c "type $env:TEMP\patch.diff | git am || (exit /b 1)" + # We require that releases of swift-format build without warnings + linux_build_command: swift test -Xswiftc -warnings-as-errors ${{ matrix.release && '-c release' || '' }} + windows_build_command: swift test -Xswiftc -warnings-as-errors ${{ matrix.release && '-c release' || '' }} + create_tag: + name: Create Tag + runs-on: ubuntu-latest + needs: [check_triggering_actor, test, create_release_commits] + permissions: + contents: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Apply release commits + run: | + git config --global --add safe.directory "$(realpath .)" + git config --local user.name 'swift-ci' + git config --local user.email 'swift-ci@users.noreply.github.com' + git am << EOF + ${{ needs.create_release_commits.outputs.release_commit_patch }} + EOF + - name: Tag release + run: | + git tag "${{ needs.create_release_commits.outputs.swift_format_version }}" + git push origin "${{ needs.create_release_commits.outputs.swift_format_version }}" + - name: Create release + env: + GH_TOKEN: ${{ github.token }} + run: | + if [[ "${{ github.event.inputs.prerelease }}" != "true" ]]; then + # Only create a release automatically for prereleases. For real releases, release notes should be crafted by hand. + exit + fi + gh release create "${{ needs.create_release_commits.outputs.swift_format_version }}" \ + --title "${{ needs.create_release_commits.outputs.swift_format_version }}" \ + --prerelease diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml new file mode 100644 index 000000000..87e2293aa --- /dev/null +++ b/.github/workflows/pull_request.yml @@ -0,0 +1,17 @@ +name: Pull request + +on: + pull_request: + types: [opened, reopened, synchronize] + +jobs: + tests: + name: Test + uses: swiftlang/github-workflows/.github/workflows/swift_package_test.yml@main + soundness: + name: Soundness + uses: swiftlang/github-workflows/.github/workflows/soundness.yml@main + with: + license_header_check_enabled: false + license_header_check_project_name: "Swift.org" + api_breakage_check_allowlist_path: "api-breakages.txt" diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml new file mode 100644 index 000000000..f7fd11fa5 --- /dev/null +++ b/.pre-commit-hooks.yaml @@ -0,0 +1,6 @@ +- id: swift-format + name: swift-format + entry: swift-format format --in-place --recursive --parallel + language: swift + types: [swift] + require_serial: true diff --git a/.spi.yml b/.spi.yml new file mode 100644 index 000000000..bde5eb41c --- /dev/null +++ b/.spi.yml @@ -0,0 +1,11 @@ +# This is manifest file for the Swift Package Index for it to +# auto-generate and host DocC documentation. +# For reference see https://swiftpackageindex.com/swiftpackageindex/spimanifest/documentation/spimanifest/commonusecases#Host-DocC-documentation-in-the-Swift-Package-Index. + +version: 1 +builder: + configs: + - documentation_targets: + # First item in the list is the "landing" (default) target + - SwiftFormat + custom_documentation_parameters: [--experimental-skip-synthesized-symbols] diff --git a/.swift-format b/.swift-format new file mode 100644 index 000000000..41a022f26 --- /dev/null +++ b/.swift-format @@ -0,0 +1,18 @@ +{ + "version": 1, + "lineLength": 120, + "indentation": { + "spaces": 2 + }, + "lineBreakBeforeEachArgument": true, + "indentConditionalCompilationBlocks": false, + "prioritizeKeepingFunctionOutputTogether": true, + "rules": { + "AlwaysUseLowerCamelCase": false, + "AmbiguousTrailingClosureOverload": false, + "NoBlockComments": false, + "OrderedImports": true, + "UseLetInEveryBoundCaseVariable": false, + "UseSynthesizedInitializer": false + } +} diff --git a/.swiftci/5_10_ubuntu2204 b/.swiftci/5_10_ubuntu2204 new file mode 100644 index 000000000..bc063162a --- /dev/null +++ b/.swiftci/5_10_ubuntu2204 @@ -0,0 +1,5 @@ +LinuxSwiftPackageJob { + swift_version_tag = "5.10-jammy" + repo = "swift-format" + branch = "main" +} diff --git a/.swiftci/5_7_ubuntu2204 b/.swiftci/5_7_ubuntu2204 new file mode 100644 index 000000000..7b7f96233 --- /dev/null +++ b/.swiftci/5_7_ubuntu2204 @@ -0,0 +1,5 @@ +LinuxSwiftPackageJob { + swift_version_tag = "5.7-jammy" + repo = "swift-format" + branch = "main" +} diff --git a/.swiftci/5_8_ubuntu2204 b/.swiftci/5_8_ubuntu2204 new file mode 100644 index 000000000..26077ad9c --- /dev/null +++ b/.swiftci/5_8_ubuntu2204 @@ -0,0 +1,5 @@ +LinuxSwiftPackageJob { + swift_version_tag = "5.8-jammy" + repo = "swift-format" + branch = "main" +} diff --git a/.swiftci/5_9_ubuntu2204 b/.swiftci/5_9_ubuntu2204 new file mode 100644 index 000000000..53e0ebc31 --- /dev/null +++ b/.swiftci/5_9_ubuntu2204 @@ -0,0 +1,5 @@ +LinuxSwiftPackageJob { + swift_version_tag = "5.9-jammy" + repo = "swift-format" + branch = "main" +} diff --git a/.swiftci/nightly_6_0_macos b/.swiftci/nightly_6_0_macos new file mode 100644 index 000000000..e3c0187c3 --- /dev/null +++ b/.swiftci/nightly_6_0_macos @@ -0,0 +1,5 @@ +macOSSwiftPackageJob { + swift_version = "6.0" + repo = "swift-format" + branch = "main" +} diff --git a/.swiftci/nightly_6_0_ubuntu2204 b/.swiftci/nightly_6_0_ubuntu2204 new file mode 100644 index 000000000..936388e31 --- /dev/null +++ b/.swiftci/nightly_6_0_ubuntu2204 @@ -0,0 +1,5 @@ +LinuxSwiftPackageJob { + nightly_docker_tag = "nightly-6.0-jammy" + repo = "swift-format" + branch = "main" +} diff --git a/.swiftci/nightly_main_macos b/.swiftci/nightly_main_macos new file mode 100644 index 000000000..72bb76b23 --- /dev/null +++ b/.swiftci/nightly_main_macos @@ -0,0 +1,5 @@ +macOSSwiftPackageJob { + swift_version = "main" + repo = "swift-format" + branch = "main" +} diff --git a/.swiftci/nightly_main_ubuntu2204 b/.swiftci/nightly_main_ubuntu2204 new file mode 100644 index 000000000..f0337280a --- /dev/null +++ b/.swiftci/nightly_main_ubuntu2204 @@ -0,0 +1,5 @@ +LinuxSwiftPackageJob { + nightly_docker_tag = "nightly-jammy" + repo = "swift-format" + branch = "main" +} diff --git a/.swiftci/nightly_main_windows b/.swiftci/nightly_main_windows new file mode 100644 index 000000000..e3bbb519b --- /dev/null +++ b/.swiftci/nightly_main_windows @@ -0,0 +1,7 @@ +WindowsSwiftPackageWithDockerImageJob { + docker_image = "swiftlang/swift:nightly-windowsservercore-1809" + repo = "swift-format" + branch = "main" + sub_dir = "swift-format" + label = "windows-server-2019" +} diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 000000000..28bd4e530 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,92 @@ +#[[ +This source file is part of the swift-format open source project + +Copyright (c) 2024 Apple Inc. and the swift-format project authors +Licensed under Apache License v2.0 with Runtime Library Exception + +See https://swift.org/LICENSE.txt for license information +#]] + +cmake_minimum_required(VERSION 3.19.0) + +if(POLICY CMP0077) + cmake_policy(SET CMP0077 NEW) +endif() +if(POLICY CMP0091) + cmake_policy(SET CMP0091 NEW) +endif() + +project(SwiftFormat + LANGUAGES C Swift) + +set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib) +set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib) +set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin) + +set(CMAKE_Swift_MODULE_DIRECTORY ${CMAKE_BINARY_DIR}/swift) +set(CMAKE_Swift_COMPILE_OPTIONS_MSVC_RUNTIME_LIBRARY MultiThreadedDLL) + +list(APPEND CMAKE_MODULE_PATH ${PROJECT_SOURCE_DIR}/cmake/modules) + +include(FetchContent) +include(GNUInstallDirs) +include(SwiftSupport) + +find_package(Foundation CONFIG) + +set(_SF_VENDOR_DEPENDENCIES) + +set(BUILD_EXAMPLES NO) +set(BUILD_TESTING NO) + +find_package(ArgumentParser CONFIG) +if(NOT ArgumentParser_FOUND) + FetchContent_Declare(ArgumentParser + GIT_REPOSITORY https://github.com/apple/swift-argument-parser + GIT_TAG 1.2.3) + list(APPEND _SF_VENDOR_DEPENDENCIES ArgumentParser) +endif() + +find_package(cmark-gfm CONFIG) +if(NOT cmark-gfm_FOUND) + FetchContent_Declare(cmark-gfm + GIT_REPOSITORY https://github.com/apple/swift-cmark + GIT_TAG gfm) + list(APPEND _SF_VENDOR_DEPENDENCIES cmark-gfm) +endif() + +find_package(SwiftMarkdown CONFIG) +if(NOT SwiftMarkdown_FOUND) + # TODO(compnerd) we need a latest version for now as we need the CMake support + # which is untagged. + FetchContent_Declare(Markdown + GIT_REPOSITORY https://github.com/apple/swift-markdown + GIT_TAG main) + list(APPEND _SF_VENDOR_DEPENDENCIES Markdown) +endif() + +find_package(SwiftSyntax CONFIG) +if(NOT SwiftSyntax_FOUND) + FetchContent_Declare(Syntax + GIT_REPOSITORY https://github.com/swiftlang/swift-syntax + GIT_TAG main) + list(APPEND _SF_VENDOR_DEPENDENCIES Syntax) +endif() + +if(_SF_VENDOR_DEPENDENCIES) + FetchContent_MakeAvailable(${_SF_VENDOR_DEPENDENCIES}) + + if(NOT TARGET SwiftMarkdown::Markdown) + add_library(SwiftMarkdown::Markdown ALIAS Markdown) + endif() + + if(NOT TARGET SwiftSyntax::SwiftSyntax) + add_library(SwiftSyntax::SwiftSyntax ALIAS SwiftSyntax) + add_library(SwiftSyntax::SwiftSyntaxBuilder ALIAS SwiftSyntaxBuilder) + add_library(SwiftSyntax::SwiftOperators ALIAS SwiftOperators) + add_library(SwiftSyntax::SwiftParser ALIAS SwiftParser) + add_library(SwiftSyntax::SwiftParserDiagnostics ALIAS SwiftParserDiagnostics) + endif() +endif() + +add_subdirectory(Sources) diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 000000000..28d802dc0 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,14 @@ +# +# This source file is part of the Swift.org open source project +# +# Copyright (c) 2024 Apple Inc. and the Swift project authors +# Licensed under Apache License v2.0 with Runtime Library Exception +# +# See https://swift.org/LICENSE.txt for license information +# See https://swift.org/CONTRIBUTORS.txt for Swift project authors +# + +* @ahoppen @allevato @bnbarham @shawnhyam + +.github/ @ahoppen @bnbarham @shahmishal +.swiftci/ @ahoppen @bnbarham @shahmishal diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md deleted file mode 100644 index 2b0a60355..000000000 --- a/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,55 +0,0 @@ -# Code of Conduct -To be a truly great community, Swift.org needs to welcome developers from all walks of life, -with different backgrounds, and with a wide range of experience. A diverse and friendly -community will have more great ideas, more unique perspectives, and produce more great -code. We will work diligently to make the Swift community welcoming to everyone. - -To give clarity of what is expected of our members, Swift.org has adopted the code of conduct -defined by [contributor-covenant.org](https://www.contributor-covenant.org). This document is used across many open source -communities, and we think it articulates our values well. The full text is copied below: - -### Contributor Code of Conduct v1.3 -As contributors and maintainers of this project, and in the interest of fostering an open and -welcoming community, we pledge to respect all people who contribute through reporting -issues, posting feature requests, updating documentation, submitting pull requests or patches, -and other activities. - -We are committed to making participation in this project a harassment-free experience for -everyone, regardless of level of experience, gender, gender identity and expression, sexual -orientation, disability, personal appearance, body size, race, ethnicity, age, religion, or -nationality. - -Examples of unacceptable behavior by participants include: -- The use of sexualized language or imagery -- Personal attacks -- Trolling or insulting/derogatory comments -- Public or private harassment -- Publishing other’s private information, such as physical or electronic addresses, without explicit permission -- Other unethical or unprofessional conduct - -Project maintainers have the right and responsibility to remove, edit, or reject comments, -commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of -Conduct, or to ban temporarily or permanently any contributor for other behaviors that they -deem inappropriate, threatening, offensive, or harmful. - -By adopting this Code of Conduct, project maintainers commit themselves to fairly and -consistently applying these principles to every aspect of managing this project. Project -maintainers who do not follow or enforce the Code of Conduct may be permanently removed -from the project team. - -This code of conduct applies both within project spaces and in public spaces when an -individual is representing the project or its community. - -Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by -contacting a project maintainer at [conduct@swift.org](mailto:conduct@swift.org). All complaints will be reviewed and -investigated and will result in a response that is deemed necessary and appropriate to the -circumstances. Maintainers are obligated to maintain confidentiality with regard to the reporter -of an incident. - -*This policy is adapted from the Contributor Code of Conduct [version 1.3.0](http://contributor-covenant.org/version/1/3/0/).* - -### Reporting -A working group of community members is committed to promptly addressing any [reported -issues](mailto:conduct@swift.org). Working group members are volunteers appointed by the project lead, with a -preference for individuals with varied backgrounds and perspectives. Membership is expected -to change regularly, and may grow or shrink. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 9f01e1f3b..000000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,11 +0,0 @@ -By submitting a pull request, you represent that you have the right to license -your contribution to Apple and the community, and agree by submitting the patch -that your contributions are licensed under the [Swift -license](https://swift.org/LICENSE.txt). - ---- - -Before submitting the pull request, please make sure you have [tested your -changes](https://github.com/apple/swift/blob/main/docs/ContinuousIntegration.md) -and that they follow the Swift project [guidelines for contributing -code](https://swift.org/contributing/#contributing-code). diff --git a/Documentation/Configuration.md b/Documentation/Configuration.md index 6f1092103..f84fb1c60 100644 --- a/Documentation/Configuration.md +++ b/Documentation/Configuration.md @@ -8,79 +8,255 @@ used as a command line tool or as an API. A `swift-format` configuration file is a JSON file with the following top-level keys and values: -* `version` _(number)_: The version of the configuration file. For now, this - should always be `1`. - -* `lineLength` _(number)_: The maximum allowed length of a line, in - characters. - -* `indentation` _(object)_: The kind and amount of whitespace that should be - added when indenting one level. The object value of this property should - have **exactly one of the following properties:** - - * `spaces` _(number)_: One level of indentation is the given number of - spaces. - * `tabs` _(number)_: One level of indentation is the given number of - tabs. - -* `tabWidth` _(number)_: The number of spaces that should be considered - equivalent to one tab character. This is used during line length - calculations when tabs are used for indentation. - -* `maximumBlankLines` _(number)_: The maximum number of consecutive blank - lines that are allowed to be present in a source file. Any number larger - than this will be collapsed down to the maximum. - -* `respectsExistingLineBreaks` _(boolean)_: Indicates whether or not existing - line breaks in the source code should be honored (if they are valid - according to the style guidelines being enforced). If this settings is - `false`, then the formatter will be more "opinionated" by only inserting - line breaks where absolutely necessary and removing any others, effectively - canonicalizing the output. - -* `lineBreakBeforeControlFlowKeywords` _(boolean)_: Determines the - line-breaking behavior for control flow keywords that follow a closing - brace, like `else` and `catch`. If true, a line break will be added before - the keyword, forcing it onto its own line. If false (the default), the - keyword will be placed after the closing brace (separated by a space). - -* `lineBreakBeforeEachArgument` _(boolean)_: Determines the line-breaking - behavior for generic arguments and function arguments when a declaration is - wrapped onto multiple lines. If true, a line break will be added before each - argument, forcing the entire argument list to be laid out vertically. - If false (the default), arguments will be laid out horizontally first, with - line breaks only being fired when the line length would be exceeded. - -* `lineBreakBeforeEachGenericRequirement` _(boolean)_: Determines the - line-breaking behavior for generic requirements when the requirements list - is wrapped onto multiple lines. If true, a line break will be added before each - requirement, forcing the entire requirements list to be laid out vertically. If false - (the default), requirements will be laid out horizontally first, with line breaks - only being fired when the line length would be exceeded. - -* `prioritizeKeepingFunctionOutputTogether` _(boolean)_: Determines if - function-like declaration outputs should be prioritized to be together with the - function signature right (closing) parenthesis. If false (the default), function - output (i.e. throws, return type) is not prioritized to be together with the - signature's right parenthesis, and when the line length would be exceeded, - a line break will be fired after the function signature first, indenting the - declaration output one additional level. If true, A line break will be fired - further up in the function's declaration (e.g. generic parameters, - parameters) before breaking on the function's output. - -* `indentConditionalCompilationBlocks` _(boolean)_: Determines if - conditional compilation blocks are indented. If this setting is `false` the body - of `#if`, `#elseif`, and `#else` is not indented. Defaults to `true`. - -* `lineBreakAroundMultilineExpressionChainComponents` _(boolean)_: Determines whether - line breaks should be forced before and after multiline components of dot-chained - expressions, such as function calls and subscripts chained together through member - access (i.e. "." expressions). When any component is multiline and this option is - true, a line break is forced before the "." of the component and after the component's - closing delimiter (i.e. right paren, right bracket, right brace, etc.). - -* `spacesAroundRangeFormationOperators` _(boolean)_: Determines whether whitespace should be forced - before and after the range formation operators `...` and `..<`. +### `version` +**type:** number + +**description:** The version of the configuration file. For now, this should always be `1`. + +**default:** `1` + +--- + +### `lineLength` +**type:** number + +**description:** The maximum allowed length of a line, in characters. + +**default:** `100` + +--- + +### `indentation` +**type:** object + +**description:** The kind and amount of whitespace that should be added when indenting one level. The object value of this property should have exactly one of the following properties: + +- `spaces` _(number)_: One level of indentation is the given number of spaces. +- `tabs` _(number)_: One level of indentation is the given number of tabs. + +**default:** `{ "spaces": 2 }` + +--- + +### `tabWidth` +**type:** number + +**description:** The number of spaces that should be considered equivalent to one tab character. This is used during line length calculations when tabs are used for indentation. + +**default:** `8` + +--- + +### `maximumBlankLines` +**type:** number + +**description:** The maximum number of consecutive blank lines that are allowed to be present in a source file. Any number larger than this will be collapsed down to the maximum. + +**default:** `1` + +--- + +### `spacesBeforeEndOfLineComments` +**type:** number + +**description:** The number of spaces between the last token on a non-empty line and a line comment starting with `//`. + +**default:** `2` + +--- + +### `respectsExistingLineBreaks` +**type:** boolean + +**description:** Indicates whether or not existing line breaks in the source code should be honored (if they are valid according to the style guidelines being enforced). If this settings is `false`, then the formatter will be more "opinionated" by only inserting line breaks where absolutely necessary and removing any others, effectively canonicalizing the output. + +**default:** `true` + +--- + +### `lineBreakBeforeControlFlowKeywords` +**type:** boolean + +**description:** Determines the line-breaking behavior for control flow keywords that follow a closing brace, like `else` and `catch`. If true, a line break will be added before the keyword, forcing it onto its own line. If `false`, the keyword will be placed after the closing brace (separated by a space). + +**default:** `false` + +--- + +### `lineBreakBeforeEachArgument` +**type:** boolean + +**description:** Determines the line-breaking behavior for generic arguments and function arguments when a declaration is wrapped onto multiple lines. If true, a line break will be added before each argument, forcing the entire argument list to be laid out vertically. If `false`, arguments will be laid out horizontally first, with line breaks only being fired when the line length would be exceeded. + +**default:** `false` + +--- + +### `lineBreakBeforeEachGenericRequirement` +**type:** boolean + +**description:** Determines the line-breaking behavior for generic requirements when the requirements list is wrapped onto multiple lines. If true, a line break will be added before each requirement, forcing the entire requirements list to be laid out vertically. If `false`, requirements will be laid out horizontally first, with line breaks only being fired when the line length would be exceeded. + +**default:** `false` + +--- + +### `lineBreakBetweenDeclarationAttributes` +**type:** boolean + +**description:** Determines the line-breaking behavior for adjacent attributes on declarations. If true, a line break will be added between each attribute, forcing the attribute list to be laid out vertically. If `false`, attributes will be laid out horizontally first, with line breaks only being fired when the line length would be exceeded. + +**default:** `false` + +--- + +### `prioritizeKeepingFunctionOutputTogether` +**type:** boolean + +**description:** Determines if function-like declaration outputs should be prioritized to be together with thefunction signature right (closing) parenthesis. If `false`, function output (i.e. throws, return type) is not prioritized to be together with the signature's right parenthesis, and when the line length would be exceeded,a line break will be fired after the function signature first, indenting the declaration output one additional level. If true, A line break will be fired further up in the function's declaration (e.g. generic parameters, parameters) before breaking on the function's output. + +**default:** `false` + +--- + +### `indentConditionalCompilationBlocks` +**type:** boolean + +**description:** Determines if conditional compilation blocks are indented. If this setting is `false` the body of `#if`, `#elseif`, and `#else` is not indented. + +**default:** `true` + +--- + +### `lineBreakAroundMultilineExpressionChainComponents` +**type:** boolean + +**description:** Determines whether line breaks should be forced before and after multiline components of dot-chained expressions, such as function calls and subscripts chained together through member access (i.e. "." expressions). When any component is multiline and this option is true, a line break is forced before the "." of the component and after the component's closing delimiter (i.e. right paren, right bracket, right brace, etc.). + +**default:** `false` + +--- + +## `fileScopedDeclarationPrivacy` +**type:** object + +**description:** Declarations at file scope with effective private access should be consistently declared as either `fileprivate` or `private`, determined by configuration. + +- `accessLevel` _(string)_: The formal access level to use when encountering a file-scoped declaration with effective private access. Allowed values are `private` and `fileprivate`. + +**default:** `{ "accessLevel" : "private" }` + +--- + +### `indentSwitchCaseLabels` +**type:** boolean + +**description:** Determines if `case` statements should be indented compared to the containing `switch` block. + +When `false`, the correct form is: +```swift +switch someValue { +case someCase: + someStatement +... +} +``` +When `true`, the correct form is: +```swift +switch someValue { + case someCase: + someStatement + ... +} +``` + +**default:** `false` + +--- + +### `spacesAroundRangeFormationOperators` +**type:** boolean + +**description:** Determines whether whitespace should be forced before and after the range formation operators `...` and `..<`. + +**default:** `false` + +--- + +### `noAssignmentInExpressions` +**type:** object + +**description:** Assignment expressions must be their own statements. Assignment should not be used in an expression context that expects a `Void` value. For example, assigning a variable within a `return` statement existing a `Void` function is prohibited. + +- `allowedFunctions` _(strings array)_: A list of function names where assignments are allowed to be embedded in expressions that are passed as parameters to that function. + +**default:** `{ "allowedFunctions" : ["XCTAssertNoThrow"] }` + +--- + +### `multiElementCollectionTrailingCommas` +**type:** boolean + +**description:** Determines whether multi-element collection literals should have trailing commas. + +**default:** `true` + +--- + +### `reflowMultilineStringLiterals` +**type:** `string` + +**description:** Determines how multiline string literals should reflow when formatted. + +- `never`: Never reflow multiline string literals. +- `onlyLinesOverLength`: Reflow lines in string literal that exceed the maximum line length. +For example with a line length of 10: +```swift +""" +an escape\ + line break +a hard line break +""" +``` +will be formatted as: +```swift +""" +an esacpe\ + line break +a hard \ +line break +""" +``` +- `always`: Always reflow multiline string literals, this will ignore existing escaped newlines in the literal and reflow each line. Hard linebreaks are still respected. +For example, with a line length of 10: +```swift +""" +one \ +word \ +a line. +this is too long. +""" +``` +will be formatted as: +```swift +""" +one word \ +a line. +this is \ +too long. +""" +``` + +**default:** `"never"` + +--- + +### `indentBlankLines` +**type:** boolean + +**description:** Determines whether blank lines should be modified to match the current indentation. When this setting is true, blank lines will be modified whitespace. If `false`, all whitespace in blank lines will be completely removed. + +**default:** `false` > TODO: Add support for enabling/disabling specific syntax transformations in > the pipeline. @@ -103,6 +279,18 @@ An example `.swift-format` configuration file is shown below. } ``` +## Linter and Formatter Rules Configuration + +In the `rules` block of `.swift-format`, you can specify which rules to apply +when linting and formatting your project. Read the +[rules documentation](RuleDocumentation.md) to see the list of all +supported linter and formatter rules, and their overview. + +You can also run this command to see the list of rules in the default +`swift-format` configuration: + + $ swift-format dump-configuration + ## API Configuration The `SwiftConfiguration` module contains a `Configuration` type that is diff --git a/Documentation/Development.md b/Documentation/Development.md index 8773f051f..f9c67fce8 100644 --- a/Documentation/Development.md +++ b/Documentation/Development.md @@ -4,27 +4,19 @@ Since Swift does not yet have a runtime reflection system, we use code generation to keep the linting/formatting pipeline up-to-date. If you add or -remove any rules from the `SwiftFormatRules` module, or if you add or remove +remove any rules from the `SwiftFormat` module, or if you add or remove any `visit` methods from an existing rule in that module, you must run the -`generate-pipeline` tool update the pipeline and configuration sources. +`generate-swift-format` tool update the pipeline and configuration sources. The easiest way to do this is to run the following command in your terminal: ```shell -swift run generate-pipeline +swift run generate-swift-format ``` -If successful, this tool will update -`Sources/SwiftFormatConfiguration/RuleRegistry+Generated.swift` and -`Sources/SwiftFormat/Pipelines+Generated.swift`. - -Likewise, you should keep the Linux XCTest manifests updated if you add or -remove any tests from `swift-format` by running the following command in your -terminal: - -```shell -swift test --generate-linuxmain -``` +If successful, this tool will update the files `Pipelines+Generated.swift`, +`RuleNameCache+Generated.swift`, and `RuleRegistry+Generated.swift` in +the `Sources/SwiftFormat/Core` directory. ## Command Line Options for Debugging diff --git a/Documentation/PrettyPrinter.md b/Documentation/PrettyPrinter.md index bfc81aacc..085740bb4 100644 --- a/Documentation/PrettyPrinter.md +++ b/Documentation/PrettyPrinter.md @@ -31,7 +31,7 @@ The available cases are: `syntax`, `break`, `spaces`, `open`, `close`, `newlines`, `comment`, and `verbatim`. The behavior of each of them is described below with pseudocode examples. -See: [`Token.swift`](../Sources/SwiftFormatPrettyPrint/Token.swift) +See: [`Token.swift`](../Sources/SwiftFormat/PrettyPrint/Token.swift) #### Syntax @@ -263,7 +263,7 @@ if someCondition { ### Token Generation Token generation begins with the abstract syntax tree (AST) of the Swift source -file, provided by the [SwiftSyntax](https://github.com/apple/swift-syntax) +file, provided by the [SwiftSyntax](https://github.com/swiftlang/swift-syntax) library. We have overloaded a `visit` method for each of the different kinds of syntax nodes. Most of these nodes are higher-level, and are composed of other nodes. For example, `FunctionDeclSyntax` contains @@ -326,7 +326,7 @@ beginning of a source file). When we have visited all nodes in the AST, the array of printing tokens is then passed on to the *scan* phase of the pretty-printer. -See: [`TokenStreamCreator.swift`](../Sources/SwiftFormatPrettyPrint/TokenStreamCreator.swift) +See: [`TokenStreamCreator.swift`](../Sources/SwiftFormat/PrettyPrint/TokenStreamCreator.swift) ## Scan @@ -346,7 +346,7 @@ After having iterated over the entire list of tokens and calculated their lengths, we then loop over the tokens and call `print` for each token with its corresponding length. -See: [`PrettyPrint.swift:prettyPrint()`](../Sources/SwiftFormatPrettyPrint/PrettyPrint.swift) +See: [`PrettyPrint.swift:prettyPrint()`](../Sources/SwiftFormat/PrettyPrint/PrettyPrint.swift) ### Syntax Tokens @@ -421,7 +421,7 @@ The logic for the `print` function is fairly complex and varies depending on the kind of token or break being printed. Rather than explain it here, we recommend viewing its documented source directly. -See: [`PrettyPrint.swift:printToken(...)`](../Sources/SwiftFormatPrettyPrint/PrettyPrint.swift) +See: [`PrettyPrint.swift:printToken(...)`](../Sources/SwiftFormat/PrettyPrint/PrettyPrint.swift) ## Differences from Oppen's Algorithm @@ -490,6 +490,6 @@ sense to place this label on the containing group. Oppen's algorithm prints the indentation whitespace when `break` tokens are encountered. If we have extra blank lines in between source code, this can -result in hanging or trailing whitespace. Waiting to print the indentation +result in hanging or trailing whitespace. Waiting to print the indentation whitespace until encountering a `syntax`, `comment, or `verbatim` tokens prevents this. diff --git a/Documentation/RuleDocumentation.md b/Documentation/RuleDocumentation.md new file mode 100644 index 000000000..f0d7e6b2a --- /dev/null +++ b/Documentation/RuleDocumentation.md @@ -0,0 +1,578 @@ + + +# `swift-format` Lint and Format Rules + +Use the rules below in the `rules` block of your `.swift-format` +configuration file, as described in +[Configuration](Configuration.md). All of these rules can be +applied in the linter, but only some of them can format your source code +automatically. + +Here's the list of available rules: + +- [AllPublicDeclarationsHaveDocumentation](#AllPublicDeclarationsHaveDocumentation) +- [AlwaysUseLiteralForEmptyCollectionInit](#AlwaysUseLiteralForEmptyCollectionInit) +- [AlwaysUseLowerCamelCase](#AlwaysUseLowerCamelCase) +- [AmbiguousTrailingClosureOverload](#AmbiguousTrailingClosureOverload) +- [AvoidRetroactiveConformances](#AvoidRetroactiveConformances) +- [BeginDocumentationCommentWithOneLineSummary](#BeginDocumentationCommentWithOneLineSummary) +- [DoNotUseSemicolons](#DoNotUseSemicolons) +- [DontRepeatTypeInStaticProperties](#DontRepeatTypeInStaticProperties) +- [FileScopedDeclarationPrivacy](#FileScopedDeclarationPrivacy) +- [FullyIndirectEnum](#FullyIndirectEnum) +- [GroupNumericLiterals](#GroupNumericLiterals) +- [IdentifiersMustBeASCII](#IdentifiersMustBeASCII) +- [NeverForceUnwrap](#NeverForceUnwrap) +- [NeverUseForceTry](#NeverUseForceTry) +- [NeverUseImplicitlyUnwrappedOptionals](#NeverUseImplicitlyUnwrappedOptionals) +- [NoAccessLevelOnExtensionDeclaration](#NoAccessLevelOnExtensionDeclaration) +- [NoAssignmentInExpressions](#NoAssignmentInExpressions) +- [NoBlockComments](#NoBlockComments) +- [NoCasesWithOnlyFallthrough](#NoCasesWithOnlyFallthrough) +- [NoEmptyLinesOpeningClosingBraces](#NoEmptyLinesOpeningClosingBraces) +- [NoEmptyTrailingClosureParentheses](#NoEmptyTrailingClosureParentheses) +- [NoLabelsInCasePatterns](#NoLabelsInCasePatterns) +- [NoLeadingUnderscores](#NoLeadingUnderscores) +- [NoParensAroundConditions](#NoParensAroundConditions) +- [NoPlaygroundLiterals](#NoPlaygroundLiterals) +- [NoVoidReturnOnFunctionSignature](#NoVoidReturnOnFunctionSignature) +- [OmitExplicitReturns](#OmitExplicitReturns) +- [OneCasePerLine](#OneCasePerLine) +- [OneVariableDeclarationPerLine](#OneVariableDeclarationPerLine) +- [OnlyOneTrailingClosureArgument](#OnlyOneTrailingClosureArgument) +- [OrderedImports](#OrderedImports) +- [ReplaceForEachWithForLoop](#ReplaceForEachWithForLoop) +- [ReturnVoidInsteadOfEmptyTuple](#ReturnVoidInsteadOfEmptyTuple) +- [TypeNamesShouldBeCapitalized](#TypeNamesShouldBeCapitalized) +- [UseEarlyExits](#UseEarlyExits) +- [UseExplicitNilCheckInConditions](#UseExplicitNilCheckInConditions) +- [UseLetInEveryBoundCaseVariable](#UseLetInEveryBoundCaseVariable) +- [UseShorthandTypeNames](#UseShorthandTypeNames) +- [UseSingleLinePropertyGetter](#UseSingleLinePropertyGetter) +- [UseSynthesizedInitializer](#UseSynthesizedInitializer) +- [UseTripleSlashForDocumentationComments](#UseTripleSlashForDocumentationComments) +- [UseWhereClausesInForLoops](#UseWhereClausesInForLoops) +- [ValidateDocumentationComments](#ValidateDocumentationComments) + +### AllPublicDeclarationsHaveDocumentation + +All public or open declarations must have a top-level documentation comment. + +Lint: If a public declaration is missing a documentation comment, a lint error is raised. + +`AllPublicDeclarationsHaveDocumentation` is a linter-only rule. + +### AlwaysUseLiteralForEmptyCollectionInit + +Never use `[]()` syntax. In call sites that should be replaced with `[]`, +for initializations use explicit type combined with empty array literal `let _: [] = []` +Static properties of a type that return that type should not include a reference to their type. + +Lint: Non-literal empty array initialization will yield a lint error. +Format: All invalid use sites would be related with empty literal (with or without explicit type annotation). + +`AlwaysUseLiteralForEmptyCollectionInit` rule can format your code automatically. + +### AlwaysUseLowerCamelCase + +All values should be written in lower camel-case (`lowerCamelCase`). +Underscores (except at the beginning of an identifier) are disallowed. + +This rule does not apply to test code, defined as code which: + * Contains the line `import XCTest` + * The function is marked with `@Test` attribute + +Lint: If an identifier contains underscores or begins with a capital letter, a lint error is + raised. + +`AlwaysUseLowerCamelCase` is a linter-only rule. + +### AmbiguousTrailingClosureOverload + +Overloads with only a closure argument should not be disambiguated by parameter labels. + +Lint: If two overloaded functions with one closure parameter appear in the same scope, a lint + error is raised. + +`AmbiguousTrailingClosureOverload` is a linter-only rule. + +### AvoidRetroactiveConformances + +`@retroactive` conformances are forbidden. + +Lint: Using `@retroactive` results in a lint error. + +`AvoidRetroactiveConformances` is a linter-only rule. + +### BeginDocumentationCommentWithOneLineSummary + +All documentation comments must begin with a one-line summary of the declaration. + +Lint: If a comment does not begin with a single-line summary, a lint error is raised. + +`BeginDocumentationCommentWithOneLineSummary` is a linter-only rule. + +### DoNotUseSemicolons + +Semicolons should not be present in Swift code. + +Lint: If a semicolon appears anywhere, a lint error is raised. + +Format: All semicolons will be replaced with line breaks. + +`DoNotUseSemicolons` rule can format your code automatically. + +### DontRepeatTypeInStaticProperties + +Static properties of a type that return that type should not include a reference to their type. + +"Reference to their type" means that the property name includes part, or all, of the type. If +the type contains a namespace (i.e. `UIColor`) the namespace is ignored; +`public class var redColor: UIColor` would trigger this rule. + +Lint: Static properties of a type that return that type will yield a lint error. + +`DontRepeatTypeInStaticProperties` is a linter-only rule. + +### FileScopedDeclarationPrivacy + +Declarations at file scope with effective private access should be consistently declared as +either `fileprivate` or `private`, determined by configuration. + +Lint: If a file-scoped declaration has formal access opposite to the desired access level in the + formatter's configuration, a lint error is raised. + +Format: File-scoped declarations that have formal access opposite to the desired access level in + the formatter's configuration will have their access level changed. + +`FileScopedDeclarationPrivacy` rule can format your code automatically. + +### FullyIndirectEnum + +If all cases of an enum are `indirect`, the entire enum should be marked `indirect`. + +Lint: If every case of an enum is `indirect`, but the enum itself is not, a lint error is + raised. + +Format: Enums where all cases are `indirect` will be rewritten such that the enum is marked + `indirect`, and each case is not. + +`FullyIndirectEnum` rule can format your code automatically. + +### GroupNumericLiterals + +Numeric literals should be grouped with `_`s to delimit common separators. + +Specifically, decimal numeric literals should be grouped every 3 numbers, hexadecimal every 4, +and binary every 8. + +Lint: If a numeric literal is too long and should be grouped, a lint error is raised. + +Format: All numeric literals that should be grouped will have `_`s inserted where appropriate. + +TODO: Minimum numeric literal length bounds and numeric groupings have been selected arbitrarily; +these could be reevaluated. +TODO: Handle floating point literals. + +`GroupNumericLiterals` rule can format your code automatically. + +### IdentifiersMustBeASCII + +All identifiers must be ASCII. + +Lint: If an identifier contains non-ASCII characters, a lint error is raised. + +`IdentifiersMustBeASCII` is a linter-only rule. + +### NeverForceUnwrap + +Force-unwraps are strongly discouraged and must be documented. + +This rule does not apply to test code, defined as code which: + * Contains the line `import XCTest` + * The function is marked with `@Test` attribute + +Lint: If a force unwrap is used, a lint warning is raised. + +`NeverForceUnwrap` is a linter-only rule. + +### NeverUseForceTry + +Force-try (`try!`) is forbidden. + +This rule does not apply to test code, defined as code which: + * Contains the line `import XCTest` + * The function is marked with `@Test` attribute + +Lint: Using `try!` results in a lint error. + +TODO: Create exception for NSRegularExpression + +`NeverUseForceTry` is a linter-only rule. + +### NeverUseImplicitlyUnwrappedOptionals + +Implicitly unwrapped optionals (e.g. `var s: String!`) are forbidden. + +Certain properties (e.g. `@IBOutlet`) tied to the UI lifecycle are ignored. + +This rule does not apply to test code, defined as code which: + * Contains the line `import XCTest` + * The function is marked with `@Test` attribute + +TODO: Create exceptions for other UI elements (ex: viewDidLoad) + +Lint: Declaring a property with an implicitly unwrapped type yields a lint error. + +`NeverUseImplicitlyUnwrappedOptionals` is a linter-only rule. + +### NoAccessLevelOnExtensionDeclaration + +Specifying an access level for an extension declaration is forbidden. + +Lint: Specifying an access level for an extension declaration yields a lint error. + +Format: The access level is removed from the extension declaration and is added to each + declaration in the extension; declarations with redundant access levels (e.g. + `internal`, as that is the default access level) have the explicit access level removed. + +`NoAccessLevelOnExtensionDeclaration` rule can format your code automatically. + +### NoAssignmentInExpressions + +Assignment expressions must be their own statements. + +Assignment should not be used in an expression context that expects a `Void` value. For example, +assigning a variable within a `return` statement existing a `Void` function is prohibited. + +Lint: If an assignment expression is found in a position other than a standalone statement, a + lint finding is emitted. + +Format: A `return` statement containing an assignment expression is expanded into two separate + statements. + +`NoAssignmentInExpressions` rule can format your code automatically. + +### NoBlockComments + +Block comments should be avoided in favor of line comments. + +Lint: If a block comment appears, a lint error is raised. + +`NoBlockComments` is a linter-only rule. + +### NoCasesWithOnlyFallthrough + +Cases that contain only the `fallthrough` statement are forbidden. + +Lint: Cases containing only the `fallthrough` statement yield a lint error. + +Format: The fallthrough `case` is added as a prefix to the next case unless the next case is + `default`; in that case, the fallthrough `case` is deleted. + +`NoCasesWithOnlyFallthrough` rule can format your code automatically. + +### NoEmptyLinesOpeningClosingBraces + +Empty lines are forbidden after opening braces and before closing braces. + +Lint: Empty lines after opening braces and before closing braces yield a lint error. + +Format: Empty lines after opening braces and before closing braces will be removed. + +`NoEmptyLinesOpeningClosingBraces` rule can format your code automatically. + +### NoEmptyTrailingClosureParentheses + +Function calls with no arguments and a trailing closure should not have empty parentheses. + +Lint: If a function call with a trailing closure has an empty argument list with parentheses, + a lint error is raised. + +Format: Empty parentheses in function calls with trailing closures will be removed. + +`NoEmptyTrailingClosureParentheses` rule can format your code automatically. + +### NoLabelsInCasePatterns + +Redundant labels are forbidden in case patterns. + +In practice, *all* case pattern labels should be redundant. + +Lint: Using a label in a case statement yields a lint error unless the label does not match the + binding identifier. + +Format: Redundant labels in case patterns are removed. + +`NoLabelsInCasePatterns` rule can format your code automatically. + +### NoLeadingUnderscores + +Identifiers in declarations and patterns should not have leading underscores. + +This is intended to avoid certain antipatterns; `self.member = member` should be preferred to +`member = _member` and the leading underscore should not be used to signal access level. + +This rule intentionally checks only the parameter variable names of a function declaration, not +the parameter labels. It also only checks identifiers at the declaration site, not at usage +sites. + +Lint: Declaring an identifier with a leading underscore yields a lint error. + +`NoLeadingUnderscores` is a linter-only rule. + +### NoParensAroundConditions + +Enforces rules around parentheses in conditions or matched expressions. + +Parentheses are not used around any condition of an `if`, `guard`, or `while` statement, or +around the matched expression in a `switch` statement. + +Lint: If a top-most expression in a `switch`, `if`, `guard`, or `while` statement is surrounded + by parentheses, and it does not include a function call with a trailing closure, a lint + error is raised. + +Format: Parentheses around such expressions are removed, if they do not cause a parse ambiguity. + Specifically, parentheses are allowed if and only if the expression contains a function + call with a trailing closure. + +`NoParensAroundConditions` rule can format your code automatically. + +### NoPlaygroundLiterals + +The playground literals (`#colorLiteral`, `#fileLiteral`, and `#imageLiteral`) are forbidden. + +Lint: Using a playground literal will yield a lint error with a suggestion of an API to replace +it. + +`NoPlaygroundLiterals` is a linter-only rule. + +### NoVoidReturnOnFunctionSignature + +Functions that return `()` or `Void` should omit the return signature. + +Lint: Function declarations that explicitly return `()` or `Void` will yield a lint error. + +Format: Function declarations with explicit returns of `()` or `Void` will have their return + signature stripped. + +`NoVoidReturnOnFunctionSignature` rule can format your code automatically. + +### OmitExplicitReturns + +Single-expression functions, closures, subscripts can omit `return` statement. + +Lint: `func () { return ... }` and similar single expression constructs will yield a lint error. + +Format: `func () { return ... }` constructs will be replaced with + equivalent `func () { ... }` constructs. + +`OmitExplicitReturns` rule can format your code automatically. + +### OneCasePerLine + +Each enum case with associated values or a raw value should appear in its own case declaration. + +Lint: If a single `case` declaration declares multiple cases, and any of them have associated + values or raw values, a lint error is raised. + +Format: All case declarations with associated values or raw values will be moved to their own + case declarations. + +`OneCasePerLine` rule can format your code automatically. + +### OneVariableDeclarationPerLine + +Each variable declaration, with the exception of tuple destructuring, should +declare 1 variable. + +Lint: If a variable declaration declares multiple variables, a lint error is +raised. + +Format: If a variable declaration declares multiple variables, it will be +split into multiple declarations, each declaring one of the variables, as +long as the result would still be syntactically valid. + +`OneVariableDeclarationPerLine` rule can format your code automatically. + +### OnlyOneTrailingClosureArgument + +Function calls should never mix normal closure arguments and trailing closures. + +Lint: If a function call with a trailing closure also contains a non-trailing closure argument, + a lint error is raised. + +`OnlyOneTrailingClosureArgument` is a linter-only rule. + +### OrderedImports + +Imports must be lexicographically ordered and logically grouped at the top of each source file. +The order of the import groups is 1) regular imports, 2) declaration imports, and 3) @testable +imports. These groups are separated by a single blank line. Blank lines in between the import +declarations are removed. + +Lint: If an import appears anywhere other than the beginning of the file it resides in, + not lexicographically ordered, or not in the appropriate import group, a lint error is + raised. + +Format: Imports will be reordered and grouped at the top of the file. + +`OrderedImports` rule can format your code automatically. + +### ReplaceForEachWithForLoop + +Replace `forEach` with `for-in` loop unless its argument is a function reference. + +Lint: invalid use of `forEach` yield will yield a lint error. + +`ReplaceForEachWithForLoop` is a linter-only rule. + +### ReturnVoidInsteadOfEmptyTuple + +Return `Void`, not `()`, in signatures. + +Note that this rule does *not* apply to function declaration signatures in order to avoid +conflicting with `NoVoidReturnOnFunctionSignature`. + +Lint: Returning `()` in a signature yields a lint error. + +Format: `-> ()` is replaced with `-> Void` + +`ReturnVoidInsteadOfEmptyTuple` rule can format your code automatically. + +### TypeNamesShouldBeCapitalized + +`struct`, `class`, `enum` and `protocol` declarations should have a capitalized name. + +Lint: Types with un-capitalized names will yield a lint error. + +`TypeNamesShouldBeCapitalized` is a linter-only rule. + +### UseEarlyExits + +Early exits should be used whenever possible. + +This means that `if ... else { return/throw/break/continue }` constructs should be replaced by +`guard ... else { return/throw/break/continue }` constructs in order to keep indentation levels +low. Specifically, code of the following form: + +```swift +if condition { + trueBlock +} else { + falseBlock + return/throw/break/continue +} +``` + +will be transformed into: + +```swift +guard condition else { + falseBlock + return/throw/break/continue +} +trueBlock +``` + +Lint: `if ... else { return/throw/break/continue }` constructs will yield a lint error. + +Format: `if ... else { return/throw/break/continue }` constructs will be replaced with + equivalent `guard ... else { return/throw/break/continue }` constructs. + +`UseEarlyExits` rule can format your code automatically. + +### UseExplicitNilCheckInConditions + +When checking an optional value for `nil`-ness, prefer writing an explicit `nil` check rather +than binding and immediately discarding the value. + +For example, `if let _ = someValue { ... }` is forbidden. Use `if someValue != nil { ... }` +instead. + +Lint: `let _ = expr` inside a condition list will yield a lint error. + +Format: `let _ = expr` inside a condition list will be replaced by `expr != nil`. + +`UseExplicitNilCheckInConditions` rule can format your code automatically. + +### UseLetInEveryBoundCaseVariable + +Every variable bound in a `case` pattern must have its own `let/var`. + +For example, `case let .identifier(x, y)` is forbidden. Use +`case .identifier(let x, let y)` instead. + +Lint: `case let .identifier(...)` will yield a lint error. + +`UseLetInEveryBoundCaseVariable` is a linter-only rule. + +### UseShorthandTypeNames + +Shorthand type forms must be used wherever possible. + +Lint: Using a non-shorthand form (e.g. `Array`) yields a lint error unless the long + form is necessary (e.g. `Array.Index` cannot be shortened today.) + +Format: Where possible, shorthand types replace long form types; e.g. `Array` is + converted to `[Element]`. + +`UseShorthandTypeNames` rule can format your code automatically. + +### UseSingleLinePropertyGetter + +Read-only computed properties must use implicit `get` blocks. + +Lint: Read-only computed properties with explicit `get` blocks yield a lint error. + +Format: Explicit `get` blocks are rendered implicit by removing the `get`. + +`UseSingleLinePropertyGetter` rule can format your code automatically. + +### UseSynthesizedInitializer + +When possible, the synthesized `struct` initializer should be used. + +This means the creation of a (non-public) memberwise initializer with the same structure as the +synthesized initializer is forbidden. + +Lint: (Non-public) memberwise initializers with the same structure as the synthesized + initializer will yield a lint error. + +`UseSynthesizedInitializer` is a linter-only rule. + +### UseTripleSlashForDocumentationComments + +Documentation comments must use the `///` form. + +This is similar to `NoBlockComments` but is meant to prevent documentation block comments. + +Lint: If a doc block comment appears, a lint error is raised. + +Format: If a doc block comment appears on its own on a line, or if a doc block comment spans + multiple lines without appearing on the same line as code, it will be replaced with + multiple doc line comments. + +`UseTripleSlashForDocumentationComments` rule can format your code automatically. + +### UseWhereClausesInForLoops + +`for` loops that consist of a single `if` statement must use `where` clauses instead. + +Lint: `for` loops that consist of a single `if` statement yield a lint error. + +Format: `for` loops that consist of a single `if` statement have the conditional of that + statement factored out to a `where` clause. + +`UseWhereClausesInForLoops` rule can format your code automatically. + +### ValidateDocumentationComments + +Documentation comments must be complete and valid. + +"Command + Option + /" in Xcode produces a minimal valid documentation comment. + +Lint: Documentation comments that are incomplete (e.g. missing parameter documentation) or + invalid (uses `Parameters` when there is only one parameter) will yield a lint error. + +`ValidateDocumentationComments` is a linter-only rule. diff --git a/Package.swift b/Package.swift index 3e204811d..f960544aa 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.6 +// swift-tools-version:5.8 //===----------------------------------------------------------------------===// // // This source file is part of the Swift.org open source project @@ -14,154 +14,201 @@ import Foundation import PackageDescription +var products: [Product] = [ + .executable( + name: "swift-format", + targets: ["swift-format"] + ), + .library( + name: "SwiftFormat", + targets: ["SwiftFormat"] + ), + .plugin( + name: "FormatPlugin", + targets: ["Format Source Code"] + ), + .plugin( + name: "LintPlugin", + targets: ["Lint Source Code"] + ), +] + +var targets: [Target] = [ + .target( + name: "_SwiftFormatInstructionCounter", + exclude: ["CMakeLists.txt"] + ), + + .target( + name: "SwiftFormat", + dependencies: [ + .product(name: "Markdown", package: "swift-markdown") + ] + + swiftSyntaxDependencies([ + "SwiftOperators", "SwiftParser", "SwiftParserDiagnostics", "SwiftSyntax", "SwiftSyntaxBuilder", + ]), + exclude: ["CMakeLists.txt"] + ), + .target( + name: "_SwiftFormatTestSupport", + dependencies: [ + "SwiftFormat" + ] + + swiftSyntaxDependencies([ + "SwiftOperators", "SwiftParser", "SwiftParserDiagnostics", "SwiftSyntax", "SwiftSyntaxBuilder", + ]) + ), + .plugin( + name: "Format Source Code", + capability: .command( + intent: .sourceCodeFormatting(), + permissions: [ + .writeToPackageDirectory(reason: "This command formats the Swift source files") + ] + ), + dependencies: [ + .target(name: "swift-format") + ], + path: "Plugins/FormatPlugin" + ), + .plugin( + name: "Lint Source Code", + capability: .command( + intent: .custom( + verb: "lint-source-code", + description: "Lint source code for a specified target." + ) + ), + dependencies: [ + .target(name: "swift-format") + ], + path: "Plugins/LintPlugin" + ), + .executableTarget( + name: "generate-swift-format", + dependencies: [ + "SwiftFormat" + ] + ), + .executableTarget( + name: "swift-format", + dependencies: [ + "_SwiftFormatInstructionCounter", + "SwiftFormat", + .product(name: "ArgumentParser", package: "swift-argument-parser"), + ] + swiftSyntaxDependencies(["SwiftParser", "SwiftSyntax"]), + exclude: ["CMakeLists.txt"], + linkerSettings: swiftformatLinkSettings + ), + + .testTarget( + name: "SwiftFormatPerformanceTests", + dependencies: [ + "SwiftFormat", + "_SwiftFormatTestSupport", + ] + swiftSyntaxDependencies(["SwiftParser", "SwiftSyntax"]) + ), + .testTarget( + name: "SwiftFormatTests", + dependencies: [ + "SwiftFormat", + "_SwiftFormatTestSupport", + .product(name: "Markdown", package: "swift-markdown"), + ] + swiftSyntaxDependencies(["SwiftOperators", "SwiftParser", "SwiftSyntax", "SwiftSyntaxBuilder"]) + ), +] + +if buildOnlyTests { + products = [] + targets = targets.compactMap { target in + guard target.isTest || target.name == "_SwiftFormatTestSupport" else { + return nil + } + target.dependencies = target.dependencies.filter { dependency in + if case .byNameItem(name: "_SwiftFormatTestSupport", _) = dependency { + return true + } + return false + } + return target + } +} + let package = Package( name: "swift-format", platforms: [ + .macOS("12.0"), .iOS("13.0"), - .macOS("10.15") - ], - products: [ - .executable( - name: "swift-format", - targets: ["swift-format"] - ), - .library( - name: "SwiftFormat", - targets: ["SwiftFormat", "SwiftFormatConfiguration"] - ), - .library( - name: "SwiftFormatConfiguration", - targets: ["SwiftFormatConfiguration"] - ), - .plugin( - name: "FormatPlugin", - targets: ["Format Source Code"] - ), - .plugin( - name: "LintPlugin", - targets: ["Lint Source Code"] - ), - ], - dependencies: [ - // See the "Dependencies" section below. ], - targets: [ - .target( - name: "SwiftFormat", - dependencies: [ - "SwiftFormatConfiguration", - "SwiftFormatCore", - "SwiftFormatPrettyPrint", - "SwiftFormatRules", - "SwiftFormatWhitespaceLinter", - .product(name: "SwiftSyntax", package: "swift-syntax"), - .product(name: "SwiftOperators", package: "swift-syntax"), - .product(name: "SwiftParser", package: "swift-syntax"), - .product(name: "SwiftParserDiagnostics", package: "swift-syntax"), - ] - ), - .target( - name: "SwiftFormatConfiguration" - ), - .target( - name: "SwiftFormatCore", - dependencies: [ - "SwiftFormatConfiguration", - .product(name: "SwiftOperators", package: "swift-syntax"), - .product(name: "SwiftSyntax", package: "swift-syntax"), - ] - ), - .target( - name: "SwiftFormatRules", - dependencies: ["SwiftFormatCore", "SwiftFormatConfiguration"] - ), - .target( - name: "SwiftFormatPrettyPrint", - dependencies: [ - "SwiftFormatCore", - "SwiftFormatConfiguration", - .product(name: "SwiftOperators", package: "swift-syntax"), - ] - ), - .target( - name: "SwiftFormatWhitespaceLinter", - dependencies: [ - "SwiftFormatCore", - .product(name: "SwiftSyntax", package: "swift-syntax"), - ] - ), - .plugin( - name: "Format Source Code", - capability: .command( - intent: .sourceCodeFormatting(), - permissions: [ - .writeToPackageDirectory(reason: "This command formats the Swift source files") - ] - ), - dependencies: [ - .target(name: "swift-format") - ], - path: "Plugins/FormatPlugin" - ), - .plugin( - name: "Lint Source Code", - capability: .command( - intent: .custom( - verb: "lint-source-code", - description: "Lint source code for a specified target." - ) - ), - dependencies: [ - .target(name: "swift-format") - ], - path: "Plugins/LintPlugin" - ), - .executableTarget( - name: "generate-pipeline", - dependencies: [ - "SwiftFormatCore", - "SwiftFormatRules", - .product(name: "SwiftSyntax", package: "swift-syntax"), - .product(name: "SwiftParser", package: "swift-syntax"), - ] - ), - .executableTarget( - name: "swift-format", - dependencies: [ - "SwiftFormat", - "SwiftFormatConfiguration", - "SwiftFormatCore", - .product(name: "ArgumentParser", package: "swift-argument-parser"), - .product(name: "SwiftSyntax", package: "swift-syntax"), - .product(name: "SwiftParser", package: "swift-syntax"), - .product(name: "TSCBasic", package: "swift-tools-support-core"), - ] - ), - ] + products: products, + dependencies: dependencies, + targets: targets ) -// MARK: Dependencies +func swiftSyntaxDependencies(_ names: [String]) -> [Target.Dependency] { + if buildDynamicSwiftSyntaxLibrary { + return [.product(name: "_SwiftSyntaxDynamic", package: "swift-syntax")] + } else { + return names.map { .product(name: $0, package: "swift-syntax") } + } +} -if ProcessInfo.processInfo.environment["SWIFTCI_USE_LOCAL_DEPS"] == nil { - // Building standalone. - package.dependencies += [ - .package( - url: "https://github.com/apple/swift-argument-parser.git", - from: "1.0.1" - ), - .package( - url: "https://github.com/apple/swift-syntax.git", - from: "508.0.0" - ), - .package( - url: "https://github.com/apple/swift-tools-support-core.git", - from: "0.5.0" - ), - ] -} else { - package.dependencies += [ - .package(path: "../swift-argument-parser"), - .package(path: "../swift-syntax"), - .package(path: "../swift-tools-support-core"), - ] +// MARK: - Parse build arguments + +func hasEnvironmentVariable(_ name: String) -> Bool { + return ProcessInfo.processInfo.environment[name] != nil +} + +// When building the toolchain on the CI, don't add the CI's runpath for the +// final build before installing. +var installAction: Bool { hasEnvironmentVariable("SWIFTFORMAT_CI_INSTALL") } + +/// Assume that all the package dependencies are checked out next to sourcekit-lsp and use that instead of fetching a +/// remote dependency. +var useLocalDependencies: Bool { hasEnvironmentVariable("SWIFTCI_USE_LOCAL_DEPS") } + +/// Build only tests targets and test support modules. +/// +/// This is used to test swift-format on Windows, where the modules required for the `swift-format` executable are +/// built using CMake. When using this setting, the caller is responsible for passing the required search paths to +/// the `swift test` invocation so that all pre-built modules can be found. +var buildOnlyTests: Bool { hasEnvironmentVariable("SWIFTFORMAT_BUILD_ONLY_TESTS") } + +/// Whether swift-syntax is being built as a single dynamic library instead of as a separate library per module. +/// +/// This means that the swift-syntax symbols don't need to be statically linked, which alles us to stay below the +/// maximum number of exported symbols on Windows, in turn allowing us to build sourcekit-lsp using SwiftPM on Windows +/// and run its tests. +var buildDynamicSwiftSyntaxLibrary: Bool { + hasEnvironmentVariable("SWIFTSYNTAX_BUILD_DYNAMIC_LIBRARY") +} + +// MARK: - Dependencies + +var dependencies: [Package.Dependency] { + if buildOnlyTests { + return [] + } else if useLocalDependencies { + return [ + .package(path: "../swift-argument-parser"), + .package(path: "../swift-markdown"), + .package(path: "../swift-syntax"), + ] + } else { + return [ + .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.2.2"), + .package(url: "https://github.com/apple/swift-markdown.git", from: "0.2.0"), + .package(url: "https://github.com/swiftlang/swift-syntax.git", branch: "release/6.1"), + ] + } +} + +// MARK: - Compute custom build settings + +var swiftformatLinkSettings: [LinkerSetting] { + if installAction { + return [.unsafeFlags(["-no-toolchain-stdlib-rpath"], .when(platforms: [.linux, .android]))] + } else { + return [] + } } diff --git a/Plugins/FormatPlugin/plugin.swift b/Plugins/FormatPlugin/plugin.swift index a89f7d652..66bf3b74e 100644 --- a/Plugins/FormatPlugin/plugin.swift +++ b/Plugins/FormatPlugin/plugin.swift @@ -1,28 +1,27 @@ -import PackagePlugin import Foundation +import PackagePlugin @main struct FormatPlugin { func format(tool: PluginContext.Tool, targetDirectories: [String], configurationFilePath: String?) throws { let swiftFormatExec = URL(fileURLWithPath: tool.path.string) - + var arguments: [String] = ["format"] - + arguments.append(contentsOf: targetDirectories) - + arguments.append(contentsOf: ["--recursive", "--parallel", "--in-place"]) - + if let configurationFilePath = configurationFilePath { arguments.append(contentsOf: ["--configuration", configurationFilePath]) } - + let process = try Process.run(swiftFormatExec, arguments: arguments) process.waitUntilExit() - + if process.terminationReason == .exit && process.terminationStatus == 0 { print("Formatted the source code.") - } - else { + } else { let problem = "\(process.terminationReason):\(process.terminationStatus)" Diagnostics.error("swift-format invocation failed: \(problem)") } @@ -35,15 +34,16 @@ extension FormatPlugin: CommandPlugin { arguments: [String] ) async throws { let swiftFormatTool = try context.tool(named: "swift-format") - + var argExtractor = ArgumentExtractor(arguments) let targetNames = argExtractor.extractOption(named: "target") - let targetsToFormat = try context.package.targets(named: targetNames) - - let configurationFilePath = argExtractor.extractOption(named: "configuration").first - - let sourceCodeTargets = targetsToFormat.compactMap{ $0 as? SourceModuleTarget } - + let targetsToFormat = + targetNames.isEmpty ? context.package.targets : try context.package.targets(named: targetNames) + + let configurationFilePath = argExtractor.extractOption(named: "swift-format-configuration").first + + let sourceCodeTargets = targetsToFormat.compactMap { $0 as? SourceModuleTarget } + try format( tool: swiftFormatTool, targetDirectories: sourceCodeTargets.map(\.directory.string), @@ -58,10 +58,10 @@ import XcodeProjectPlugin extension FormatPlugin: XcodeCommandPlugin { func performCommand(context: XcodeProjectPlugin.XcodePluginContext, arguments: [String]) throws { let swiftFormatTool = try context.tool(named: "swift-format") - + var argExtractor = ArgumentExtractor(arguments) - let configurationFilePath = argExtractor.extractOption(named: "configuration").first - + let configurationFilePath = argExtractor.extractOption(named: "swift-format-configuration").first + try format( tool: swiftFormatTool, targetDirectories: [context.xcodeProject.directory.string], diff --git a/Plugins/LintPlugin/plugin.swift b/Plugins/LintPlugin/plugin.swift index 4dd72204f..bb59c34a1 100644 --- a/Plugins/LintPlugin/plugin.swift +++ b/Plugins/LintPlugin/plugin.swift @@ -1,28 +1,27 @@ -import PackagePlugin import Foundation +import PackagePlugin @main struct LintPlugin { func lint(tool: PluginContext.Tool, targetDirectories: [String], configurationFilePath: String?) throws { let swiftFormatExec = URL(fileURLWithPath: tool.path.string) - + var arguments: [String] = ["lint"] - + arguments.append(contentsOf: targetDirectories) - + arguments.append(contentsOf: ["--recursive", "--parallel", "--strict"]) - + if let configurationFilePath = configurationFilePath { arguments.append(contentsOf: ["--configuration", configurationFilePath]) } - + let process = try Process.run(swiftFormatExec, arguments: arguments) process.waitUntilExit() - + if process.terminationReason == .exit && process.terminationStatus == 0 { print("Linted the source code.") - } - else { + } else { let problem = "\(process.terminationReason):\(process.terminationStatus)" Diagnostics.error("swift-format invocation failed: \(problem)") } @@ -35,16 +34,17 @@ extension LintPlugin: CommandPlugin { arguments: [String] ) async throws { let swiftFormatTool = try context.tool(named: "swift-format") - + // Extract the arguments that specify what targets to format. var argExtractor = ArgumentExtractor(arguments) let targetNames = argExtractor.extractOption(named: "target") - let targetsToFormat = try context.package.targets(named: targetNames) - - let configurationFilePath = argExtractor.extractOption(named: "configuration").first - + + let targetsToFormat = + targetNames.isEmpty ? context.package.targets : try context.package.targets(named: targetNames) + let configurationFilePath = argExtractor.extractOption(named: "swift-format-configuration").first + let sourceCodeTargets = targetsToFormat.compactMap { $0 as? SourceModuleTarget } - + try lint( tool: swiftFormatTool, targetDirectories: sourceCodeTargets.map(\.directory.string), @@ -60,8 +60,8 @@ extension LintPlugin: XcodeCommandPlugin { func performCommand(context: XcodeProjectPlugin.XcodePluginContext, arguments: [String]) throws { let swiftFormatTool = try context.tool(named: "swift-format") var argExtractor = ArgumentExtractor(arguments) - let configurationFilePath = argExtractor.extractOption(named: "configuration").first - + let configurationFilePath = argExtractor.extractOption(named: "swift-format-configuration").first + try lint( tool: swiftFormatTool, targetDirectories: [context.xcodeProject.directory.string], diff --git a/README.md b/README.md index f78af4826..db2e090a9 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # swift-format `swift-format` provides the formatting technology for -[SourceKit-LSP](https://github.com/apple/sourcekit-lsp) and the building +[SourceKit-LSP](https://github.com/swiftlang/sourcekit-lsp) and the building blocks for doing code formatting transformations. This package can be used as a [command line tool](#command-line-usage) @@ -18,7 +18,7 @@ invoked via an [API](#api-usage). ### Swift 5.8 and later As of Swift 5.8, swift-format depends on the version of -[SwiftSyntax](https://github.com/apple/swift-syntax) whose parser has been +[SwiftSyntax](https://github.com/swiftlang/swift-syntax) whose parser has been rewritten in Swift and no longer has dependencies on libraries in the Swift toolchain. @@ -34,7 +34,7 @@ SwiftSyntax; the 5.8 release of swift-format is `508.0.0`, not `0.50800.0`. ### Swift 5.7 and earlier `swift-format` versions 0.50700.0 and earlier depend on versions of -[SwiftSyntax](https://github.com/apple/swift-syntax) that used a standalone +[SwiftSyntax](https://github.com/swiftlang/swift-syntax) that used a standalone parsing library distributed as part of the Swift toolchain. When using these versions, you should check out and build `swift-format` from the release tag or branch that is compatible with the version of Swift you are using. @@ -61,12 +61,23 @@ For example, if you are using Xcode 13.3 (Swift 5.6), you will need ## Getting swift-format If you are mainly interested in using swift-format (rather than developing it), -then once you have identified the version you need, you can check out the -source and build it using the following commands: +then you can get it in three different ways: + +### Included in the Swift Toolchain + +Swift 6 (included with Xcode 16) and above include swift-format in the toolchain. You can run `swift-format` from anywhere on the system using `swift format` (notice the space instead of dash). To find the path at which `swift-format` is installed in Xcode, run `xcrun --find swift-format`. + +### Installing via Homebrew + +Run `brew install swift-format` to install the latest version. + +### Building from source + +Install `swift-format` using the following commands: ```sh -VERSION=508.0.0 # replace this with the version you need -git clone https://github.com/apple/swift-format.git +VERSION=510.1.0 # replace this with the version you need +git clone https://github.com/swiftlang/swift-format.git cd swift-format git checkout "tags/$VERSION" swift build -c release @@ -196,7 +207,7 @@ file is not found, then it looks in the parent directory, and so on. If no configuration file is found, a default configuration is used. The settings in the default configuration can be viewed by running -`swift-format --mode dump-configuration`, which will dump it to standard +`swift-format dump-configuration`, which will dump it to standard output. If the `--configuration ` option is passed to `swift-format`, then that @@ -230,8 +241,8 @@ creates. Instead, it can pass the in-memory syntax tree to the `SwiftFormat` API and receive perfectly formatted code as output. Please see the documentation in the -[`SwiftFormatter`](Sources/SwiftFormat/SwiftFormatter.swift) and -[`SwiftLinter`](Sources/SwiftFormat/SwiftLinter.swift) classes for more +[`SwiftFormatter`](Sources/SwiftFormat/API/SwiftFormatter.swift) and +[`SwiftLinter`](Sources/SwiftFormat/API/SwiftLinter.swift) classes for more information about their usage. ### Checking Out the Source Code for Development @@ -243,3 +254,24 @@ been merged into `main`. If you are interested in developing `swift-format`, there is additional documentation about that [here](Documentation/Development.md). + +## Contributing + +Contributions to Swift are welcomed and encouraged! Please see the +[Contributing to Swift guide](https://swift.org/contributing/). + +Before submitting the pull request, please make sure you have [tested your + changes](https://github.com/apple/swift/blob/main/docs/ContinuousIntegration.md) + and that they follow the Swift project [guidelines for contributing + code](https://swift.org/contributing/#contributing-code). + +To be a truly great community, [Swift.org](https://swift.org/) needs to welcome +developers from all walks of life, with different backgrounds, and with a wide +range of experience. A diverse and friendly community will have more great +ideas, more unique perspectives, and produce more great code. We will work +diligently to make the Swift community welcoming to everyone. + +To give clarity of what is expected of our members, Swift has adopted the +code of conduct defined by the Contributor Covenant. This document is used +across many open source communities, and we think it articulates our values +well. For more, see the [Code of Conduct](https://swift.org/code-of-conduct/). diff --git a/Sources/CMakeLists.txt b/Sources/CMakeLists.txt new file mode 100644 index 000000000..32fdad63e --- /dev/null +++ b/Sources/CMakeLists.txt @@ -0,0 +1,12 @@ +#[[ +This source file is part of the swift-format open source project + +Copyright (c) 2024 Apple Inc. and the swift-format project authors +Licensed under Apache License v2.0 with Runtime Library Exception + +See https://swift.org/LICENSE.txt for license information +#]] + +add_subdirectory(SwiftFormat) +add_subdirectory(_SwiftFormatInstructionCounter) +add_subdirectory(swift-format) diff --git a/Sources/SwiftFormat/API/Configuration+Default.swift b/Sources/SwiftFormat/API/Configuration+Default.swift new file mode 100644 index 000000000..1af06a121 --- /dev/null +++ b/Sources/SwiftFormat/API/Configuration+Default.swift @@ -0,0 +1,46 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +extension Configuration { + /// Creates a new `Configuration` with default values. + /// + /// This initializer is isolated to its own file to make it easier for users who are forking or + /// building swift-format themselves to hardcode a different default configuration. To do this, + /// simply replace this file with your own default initializer that sets the values to whatever + /// you want. + /// + /// When swift-format reads a configuration file from disk, any values that are not specified in + /// the JSON will be populated from this default configuration. + public init() { + self.rules = Self.defaultRuleEnablements + self.maximumBlankLines = 1 + self.lineLength = 100 + self.tabWidth = 8 + self.indentation = .spaces(2) + self.spacesBeforeEndOfLineComments = 2 + self.respectsExistingLineBreaks = true + self.lineBreakBeforeControlFlowKeywords = false + self.lineBreakBeforeEachArgument = false + self.lineBreakBeforeEachGenericRequirement = false + self.lineBreakBetweenDeclarationAttributes = false + self.prioritizeKeepingFunctionOutputTogether = false + self.indentConditionalCompilationBlocks = true + self.lineBreakAroundMultilineExpressionChainComponents = false + self.fileScopedDeclarationPrivacy = FileScopedDeclarationPrivacyConfiguration() + self.indentSwitchCaseLabels = false + self.spacesAroundRangeFormationOperators = false + self.noAssignmentInExpressions = NoAssignmentInExpressionsConfiguration() + self.multiElementCollectionTrailingCommas = true + self.reflowMultilineStringLiterals = .never + self.indentBlankLines = false + } +} diff --git a/Sources/SwiftFormatConfiguration/Configuration.swift b/Sources/SwiftFormat/API/Configuration.swift similarity index 52% rename from Sources/SwiftFormatConfiguration/Configuration.swift rename to Sources/SwiftFormat/API/Configuration.swift index f698a3982..70ac916aa 100644 --- a/Sources/SwiftFormatConfiguration/Configuration.swift +++ b/Sources/SwiftFormat/API/Configuration.swift @@ -2,7 +2,7 @@ // // This source file is part of the Swift.org open source project // -// Copyright (c) 2014 - 2019 Apple Inc. and the Swift project authors +// Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors // Licensed under Apache License v2.0 with Runtime Library Exception // // See https://swift.org/LICENSE.txt for license information @@ -14,7 +14,12 @@ import Foundation /// A version number that can be specified in the configuration file, which allows us to change the /// format in the future if desired and still support older files. -fileprivate let highestSupportedConfigurationVersion = 1 +/// +/// Note that *adding* new configuration values is not a version-breaking change; swift-format will +/// use default values when loading older configurations that don't contain the new settings. This +/// value only needs to be updated if the configuration changes in a way that would be incompatible +/// with the previous format. +internal let highestSupportedConfigurationVersion = 1 /// Holds the complete set of configured values and defaults. public struct Configuration: Codable, Equatable { @@ -23,12 +28,14 @@ public struct Configuration: Codable, Equatable { case version case maximumBlankLines case lineLength + case spacesBeforeEndOfLineComments case tabWidth case indentation case respectsExistingLineBreaks case lineBreakBeforeControlFlowKeywords case lineBreakBeforeEachArgument case lineBreakBeforeEachGenericRequirement + case lineBreakBetweenDeclarationAttributes case prioritizeKeepingFunctionOutputTogether case indentConditionalCompilationBlocks case lineBreakAroundMultilineExpressionChainComponents @@ -36,32 +43,45 @@ public struct Configuration: Codable, Equatable { case indentSwitchCaseLabels case rules case spacesAroundRangeFormationOperators + case noAssignmentInExpressions + case multiElementCollectionTrailingCommas + case reflowMultilineStringLiterals + case indentBlankLines } + /// A dictionary containing the default enabled/disabled states of rules, keyed by the rules' + /// names. + /// + /// This value is generated by `generate-swift-format` based on the `isOptIn` value of each rule. + public static let defaultRuleEnablements: [String: Bool] = RuleRegistry.rules + /// The version of this configuration. - private let version: Int + private var version: Int = highestSupportedConfigurationVersion /// MARK: Common configuration /// The dictionary containing the rule names that we wish to run on. A rule is not used if it is /// marked as `false`, or if it is missing from the dictionary. - public var rules: [String: Bool] = RuleRegistry.rules + public var rules: [String: Bool] /// The maximum number of consecutive blank lines that may appear in a file. - public var maximumBlankLines = 1 + public var maximumBlankLines: Int /// The maximum length of a line of source code, after which the formatter will break lines. - public var lineLength = 100 + public var lineLength: Int + + /// Number of spaces that precede line comments. + public var spacesBeforeEndOfLineComments: Int /// The width of the horizontal tab in spaces. /// /// This value is used when converting indentation types (for example, from tabs into spaces). - public var tabWidth = 8 + public var tabWidth: Int /// A value representing a single level of indentation. /// /// All indentation will be conducted in multiples of this configuration. - public var indentation: Indent = .spaces(2) + public var indentation: Indent /// Indicates that the formatter should try to respect users' discretionary line breaks when /// possible. @@ -70,7 +90,7 @@ public struct Configuration: Codable, Equatable { /// line, but for readability the user might break it inside the curly braces. If this setting is /// true, those line breaks will be kept. If this setting is false, the formatter will act more /// "opinionated" and collapse the statement onto a single line. - public var respectsExistingLineBreaks = true + public var respectsExistingLineBreaks: Bool /// MARK: Rule-specific configuration @@ -80,7 +100,7 @@ public struct Configuration: Codable, Equatable { /// If true, a line break will be added before the keyword, forcing it onto its own line. If /// false (the default), the keyword will be placed after the closing brace (separated by a /// space). - public var lineBreakBeforeControlFlowKeywords = false + public var lineBreakBeforeControlFlowKeywords: Bool /// Determines the line-breaking behavior for generic arguments and function arguments when a /// declaration is wrapped onto multiple lines. @@ -88,7 +108,7 @@ public struct Configuration: Codable, Equatable { /// If false (the default), arguments will be laid out horizontally first, with line breaks only /// being fired when the line length would be exceeded. If true, a line break will be added before /// each argument, forcing the entire argument list to be laid out vertically. - public var lineBreakBeforeEachArgument = false + public var lineBreakBeforeEachArgument: Bool /// Determines the line-breaking behavior for generic requirements when the requirements list /// is wrapped onto multiple lines. @@ -96,7 +116,10 @@ public struct Configuration: Codable, Equatable { /// If true, a line break will be added before each requirement, forcing the entire requirements /// list to be laid out vertically. If false (the default), requirements will be laid out /// horizontally first, with line breaks only being fired when the line length would be exceeded. - public var lineBreakBeforeEachGenericRequirement = false + public var lineBreakBeforeEachGenericRequirement: Bool + + /// If true, a line break will be added between adjacent attributes. + public var lineBreakBetweenDeclarationAttributes: Bool /// Determines if function-like declaration outputs should be prioritized to be together with the /// function signature right (closing) parenthesis. @@ -106,21 +129,21 @@ public struct Configuration: Codable, Equatable { /// a line break will be fired after the function signature first, indenting the declaration output /// one additional level. If true, A line break will be fired further up in the function's /// declaration (e.g. generic parameters, parameters) before breaking on the function's output. - public var prioritizeKeepingFunctionOutputTogether = false + public var prioritizeKeepingFunctionOutputTogether: Bool /// Determines the indentation behavior for `#if`, `#elseif`, and `#else`. - public var indentConditionalCompilationBlocks = true + public var indentConditionalCompilationBlocks: Bool /// Determines whether line breaks should be forced before and after multiline components of /// dot-chained expressions, such as function calls and subscripts chained together through member /// access (i.e. "." expressions). When any component is multiline and this option is true, a line /// break is forced before the "." of the component and after the component's closing delimiter /// (i.e. right paren, right bracket, right brace, etc.). - public var lineBreakAroundMultilineExpressionChainComponents = false + public var lineBreakAroundMultilineExpressionChainComponents: Bool /// Determines the formal access level (i.e., the level specified in source code) for file-scoped /// declarations whose effective access level is private to the containing file. - public var fileScopedDeclarationPrivacy = FileScopedDeclarationPrivacyConfiguration() + public var fileScopedDeclarationPrivacy: FileScopedDeclarationPrivacyConfiguration /// Determines if `case` statements should be indented compared to the containing `switch` block. /// @@ -141,80 +164,232 @@ public struct Configuration: Codable, Equatable { /// ... /// } ///``` - public var indentSwitchCaseLabels = false + public var indentSwitchCaseLabels: Bool /// Determines whether whitespace should be forced before and after the range formation operators /// `...` and `..<`. - public var spacesAroundRangeFormationOperators = false + public var spacesAroundRangeFormationOperators: Bool + + /// Contains exceptions for the `NoAssignmentInExpressions` rule. + public var noAssignmentInExpressions: NoAssignmentInExpressionsConfiguration - /// Constructs a Configuration with all default values. - public init() { - self.version = highestSupportedConfigurationVersion + /// Determines if multi-element collection literals should have trailing commas. + /// + /// When `true` (default), the correct form is: + /// ```swift + /// let MyCollection = [1, 2] + /// ... + /// let MyCollection = [ + /// "a": 1, + /// "b": 2, + /// ] + /// ``` + /// + /// When `false`, the correct form is: + /// ```swift + /// let MyCollection = [1, 2] + /// ... + /// let MyCollection = [ + /// "a": 1, + /// "b": 2 + /// ] + /// ``` + public var multiElementCollectionTrailingCommas: Bool + + /// Determines how multiline string literals should reflow when formatted. + public enum MultilineStringReflowBehavior: Codable { + /// Never reflow multiline string literals. + case never + /// Reflow lines in string literal that exceed the maximum line length. For example with a line length of 10: + /// ```swift + /// """ + /// an escape\ + /// line break + /// a hard line break + /// """ + /// ``` + /// will be formatted as: + /// ```swift + /// """ + /// an esacpe\ + /// line break + /// a hard \ + /// line break + /// """ + /// ``` + /// The existing `\` is left in place, but the line over line length is broken. + case onlyLinesOverLength + /// Always reflow multiline string literals, this will ignore existing escaped newlines in the literal and reflow each line. Hard linebreaks are still respected. + /// For example, with a line length of 10: + /// ```swift + /// """ + /// one \ + /// word \ + /// a line. + /// this is too long. + /// """ + /// ``` + /// will be formatted as: + /// ```swift + /// """ + /// one word \ + /// a line. + /// this is \ + /// too long. + /// """ + /// ``` + case always + + var isNever: Bool { + switch self { + case .never: + return true + default: + return false + } + } + + var isAlways: Bool { + switch self { + case .always: + return true + default: + return false + } + } } - /// Constructs a Configuration by loading it from a configuration file. + public var reflowMultilineStringLiterals: MultilineStringReflowBehavior + + /// Determines whether to add indentation whitespace to blank lines or remove it entirely. + /// + /// If true, blank lines will be modified to match the current indentation level: + /// if they contain whitespace, the existing whitespace will be adjusted, and if they are empty, spaces will be added to match the indentation. + /// If false (the default), the whitespace in blank lines will be removed entirely. + public var indentBlankLines: Bool + + /// Creates a new `Configuration` by loading it from a configuration file. public init(contentsOf url: URL) throws { let data = try Data(contentsOf: url) + try self.init(data: data) + } + + /// Creates a new `Configuration` by decoding it from the UTF-8 representation in the given data. + public init(data: Data) throws { self = try JSONDecoder().decode(Configuration.self, from: data) } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - // Unfortunately, to allow the user to leave out configuration options in the JSON, we would - // have to make them optional properties, but that makes using the type in the rest of the code - // more annoying because we'd have to unwrap everything. So, we override this initializer and - // provide the defaults ourselves if needed. - // If the version number is not present, assume it is 1. self.version = try container.decodeIfPresent(Int.self, forKey: .version) ?? 1 guard version <= highestSupportedConfigurationVersion else { throw DecodingError.dataCorruptedError( - forKey: .version, in: container, + forKey: .version, + in: container, debugDescription: - "This version of the formatter does not support configuration version \(version).") + "This version of the formatter does not support configuration version \(version)." + ) } // If we ever introduce a new version, this is where we should switch on the decoded version // number and dispatch to different decoding methods. - self.maximumBlankLines - = try container.decodeIfPresent(Int.self, forKey: .maximumBlankLines) ?? 1 - self.lineLength = try container.decodeIfPresent(Int.self, forKey: .lineLength) ?? 100 - self.tabWidth = try container.decodeIfPresent(Int.self, forKey: .tabWidth) ?? 8 - self.indentation - = try container.decodeIfPresent(Indent.self, forKey: .indentation) ?? .spaces(2) - self.respectsExistingLineBreaks - = try container.decodeIfPresent(Bool.self, forKey: .respectsExistingLineBreaks) ?? true - self.lineBreakBeforeControlFlowKeywords - = try container.decodeIfPresent(Bool.self, forKey: .lineBreakBeforeControlFlowKeywords) ?? false - self.lineBreakBeforeEachArgument - = try container.decodeIfPresent(Bool.self, forKey: .lineBreakBeforeEachArgument) ?? false - self.lineBreakBeforeEachGenericRequirement - = try container.decodeIfPresent(Bool.self, forKey: .lineBreakBeforeEachGenericRequirement) ?? false - self.prioritizeKeepingFunctionOutputTogether - = try container.decodeIfPresent(Bool.self, forKey: .prioritizeKeepingFunctionOutputTogether) ?? false - self.indentConditionalCompilationBlocks - = try container.decodeIfPresent(Bool.self, forKey: .indentConditionalCompilationBlocks) ?? true + // Unfortunately, to allow the user to leave out configuration options in the JSON, we would + // have to make them optional properties, but that makes using the type in the rest of the code + // more annoying because we'd have to unwrap everything. So, we override this initializer and + // provide the defaults ourselves if needed. We get those defaults by pulling them from a + // default-initialized instance. + let defaults = Configuration() + + self.maximumBlankLines = + try container.decodeIfPresent(Int.self, forKey: .maximumBlankLines) + ?? defaults.maximumBlankLines + self.lineLength = + try container.decodeIfPresent(Int.self, forKey: .lineLength) + ?? defaults.lineLength + self.spacesBeforeEndOfLineComments = + try container.decodeIfPresent(Int.self, forKey: .spacesBeforeEndOfLineComments) + ?? defaults.spacesBeforeEndOfLineComments + self.tabWidth = + try container.decodeIfPresent(Int.self, forKey: .tabWidth) + ?? defaults.tabWidth + self.indentation = + try container.decodeIfPresent(Indent.self, forKey: .indentation) + ?? defaults.indentation + self.respectsExistingLineBreaks = + try container.decodeIfPresent(Bool.self, forKey: .respectsExistingLineBreaks) + ?? defaults.respectsExistingLineBreaks + self.lineBreakBeforeControlFlowKeywords = + try container.decodeIfPresent(Bool.self, forKey: .lineBreakBeforeControlFlowKeywords) + ?? defaults.lineBreakBeforeControlFlowKeywords + self.lineBreakBeforeEachArgument = + try container.decodeIfPresent(Bool.self, forKey: .lineBreakBeforeEachArgument) + ?? defaults.lineBreakBeforeEachArgument + self.lineBreakBeforeEachGenericRequirement = + try container.decodeIfPresent(Bool.self, forKey: .lineBreakBeforeEachGenericRequirement) + ?? defaults.lineBreakBeforeEachGenericRequirement + self.lineBreakBetweenDeclarationAttributes = + try container.decodeIfPresent(Bool.self, forKey: .lineBreakBetweenDeclarationAttributes) + ?? defaults.lineBreakBetweenDeclarationAttributes + self.prioritizeKeepingFunctionOutputTogether = + try container.decodeIfPresent(Bool.self, forKey: .prioritizeKeepingFunctionOutputTogether) + ?? defaults.prioritizeKeepingFunctionOutputTogether + self.indentConditionalCompilationBlocks = + try container.decodeIfPresent(Bool.self, forKey: .indentConditionalCompilationBlocks) + ?? defaults.indentConditionalCompilationBlocks self.lineBreakAroundMultilineExpressionChainComponents = try container.decodeIfPresent( - Bool.self, forKey: .lineBreakAroundMultilineExpressionChainComponents) ?? false + Bool.self, + forKey: .lineBreakAroundMultilineExpressionChainComponents + ) + ?? defaults.lineBreakAroundMultilineExpressionChainComponents self.spacesAroundRangeFormationOperators = try container.decodeIfPresent( - Bool.self, forKey: .spacesAroundRangeFormationOperators) ?? false + Bool.self, + forKey: .spacesAroundRangeFormationOperators + ) + ?? defaults.spacesAroundRangeFormationOperators self.fileScopedDeclarationPrivacy = try container.decodeIfPresent( - FileScopedDeclarationPrivacyConfiguration.self, forKey: .fileScopedDeclarationPrivacy) - ?? FileScopedDeclarationPrivacyConfiguration() - self.indentSwitchCaseLabels - = try container.decodeIfPresent(Bool.self, forKey: .indentSwitchCaseLabels) ?? false + FileScopedDeclarationPrivacyConfiguration.self, + forKey: .fileScopedDeclarationPrivacy + ) + ?? defaults.fileScopedDeclarationPrivacy + self.indentSwitchCaseLabels = + try container.decodeIfPresent(Bool.self, forKey: .indentSwitchCaseLabels) + ?? defaults.indentSwitchCaseLabels + self.noAssignmentInExpressions = + try container.decodeIfPresent( + NoAssignmentInExpressionsConfiguration.self, + forKey: .noAssignmentInExpressions + ) + ?? defaults.noAssignmentInExpressions + self.multiElementCollectionTrailingCommas = + try container.decodeIfPresent( + Bool.self, + forKey: .multiElementCollectionTrailingCommas + ) + ?? defaults.multiElementCollectionTrailingCommas + + self.reflowMultilineStringLiterals = + try container.decodeIfPresent(MultilineStringReflowBehavior.self, forKey: .reflowMultilineStringLiterals) + ?? defaults.reflowMultilineStringLiterals + self.indentBlankLines = + try container.decodeIfPresent( + Bool.self, + forKey: .indentBlankLines + ) + ?? defaults.indentBlankLines // If the `rules` key is not present at all, default it to the built-in set // so that the behavior is the same as if the configuration had been // default-initialized. To get an empty rules dictionary, one can explicitly // set the `rules` key to `{}`. - self.rules - = try container.decodeIfPresent([String: Bool].self, forKey: .rules) ?? RuleRegistry.rules + self.rules = + try container.decodeIfPresent([String: Bool].self, forKey: .rules) + ?? defaults.rules } public func encode(to encoder: Encoder) throws { @@ -223,6 +398,7 @@ public struct Configuration: Codable, Equatable { try container.encode(version, forKey: .version) try container.encode(maximumBlankLines, forKey: .maximumBlankLines) try container.encode(lineLength, forKey: .lineLength) + try container.encode(spacesBeforeEndOfLineComments, forKey: .spacesBeforeEndOfLineComments) try container.encode(tabWidth, forKey: .tabWidth) try container.encode(indentation, forKey: .indentation) try container.encode(respectsExistingLineBreaks, forKey: .respectsExistingLineBreaks) @@ -231,13 +407,20 @@ public struct Configuration: Codable, Equatable { try container.encode(lineBreakBeforeEachGenericRequirement, forKey: .lineBreakBeforeEachGenericRequirement) try container.encode(prioritizeKeepingFunctionOutputTogether, forKey: .prioritizeKeepingFunctionOutputTogether) try container.encode(indentConditionalCompilationBlocks, forKey: .indentConditionalCompilationBlocks) + try container.encode(lineBreakBetweenDeclarationAttributes, forKey: .lineBreakBetweenDeclarationAttributes) try container.encode( lineBreakAroundMultilineExpressionChainComponents, - forKey: .lineBreakAroundMultilineExpressionChainComponents) + forKey: .lineBreakAroundMultilineExpressionChainComponents + ) try container.encode( - spacesAroundRangeFormationOperators, forKey: .spacesAroundRangeFormationOperators) + spacesAroundRangeFormationOperators, + forKey: .spacesAroundRangeFormationOperators + ) try container.encode(fileScopedDeclarationPrivacy, forKey: .fileScopedDeclarationPrivacy) try container.encode(indentSwitchCaseLabels, forKey: .indentSwitchCaseLabels) + try container.encode(noAssignmentInExpressions, forKey: .noAssignmentInExpressions) + try container.encode(multiElementCollectionTrailingCommas, forKey: .multiElementCollectionTrailingCommas) + try container.encode(reflowMultilineStringLiterals, forKey: .reflowMultilineStringLiterals) try container.encode(rules, forKey: .rules) } @@ -261,7 +444,7 @@ public struct Configuration: Codable, Equatable { if FileManager.default.isReadableFile(atPath: candidateFile.path) { return candidateFile } - } while candidateDirectory.path != "/" + } while !candidateDirectory.isRoot return nil } @@ -286,4 +469,20 @@ public struct FileScopedDeclarationPrivacyConfiguration: Codable, Equatable { /// The formal access level to use when encountering a file-scoped declaration with effective /// private access. public var accessLevel: AccessLevel = .private + + public init() {} +} + +/// Configuration for the `NoAssignmentInExpressions` rule. +public struct NoAssignmentInExpressionsConfiguration: Codable, Equatable { + /// A list of function names where assignments are allowed to be embedded in expressions that are + /// passed as parameters to that function. + public var allowedFunctions: [String] = [ + // Allow `XCTAssertNoThrow` because `XCTAssertNoThrow(x = try ...)` is clearer about intent than + // `x = try XCTUnwrap(try? ...)` or force-unwrapped if you need to use the value `x` later on + // in the test. + "XCTAssertNoThrow" + ] + + public init() {} } diff --git a/Sources/SwiftFormat/DebugOptions.swift b/Sources/SwiftFormat/API/DebugOptions.swift similarity index 100% rename from Sources/SwiftFormat/DebugOptions.swift rename to Sources/SwiftFormat/API/DebugOptions.swift diff --git a/Sources/SwiftFormatCore/Finding.swift b/Sources/SwiftFormat/API/Finding.swift similarity index 98% rename from Sources/SwiftFormatCore/Finding.swift rename to Sources/SwiftFormat/API/Finding.swift index 7e002a5d8..c4fe886ab 100644 --- a/Sources/SwiftFormatCore/Finding.swift +++ b/Sources/SwiftFormat/API/Finding.swift @@ -16,6 +16,8 @@ public struct Finding { public enum Severity { case warning case error + case refactoring + case convention } /// The file path and location in that file where a finding was encountered. diff --git a/Sources/SwiftFormatCore/FindingCategorizing.swift b/Sources/SwiftFormat/API/FindingCategorizing.swift similarity index 100% rename from Sources/SwiftFormatCore/FindingCategorizing.swift rename to Sources/SwiftFormat/API/FindingCategorizing.swift diff --git a/Sources/SwiftFormatConfiguration/Indent.swift b/Sources/SwiftFormat/API/Indent.swift similarity index 97% rename from Sources/SwiftFormatConfiguration/Indent.swift rename to Sources/SwiftFormat/API/Indent.swift index c8ca49c5c..5039da135 100644 --- a/Sources/SwiftFormatConfiguration/Indent.swift +++ b/Sources/SwiftFormat/API/Indent.swift @@ -36,7 +36,9 @@ public enum Indent: Hashable, Codable { throw DecodingError.dataCorrupted( DecodingError.Context( codingPath: decoder.codingPath, - debugDescription: "Only one of \"tabs\" or \"spaces\" may be specified")) + debugDescription: "Only one of \"tabs\" or \"spaces\" may be specified" + ) + ) } if let spacesCount = spacesCount { self = .spaces(spacesCount) @@ -50,7 +52,9 @@ public enum Indent: Hashable, Codable { throw DecodingError.dataCorrupted( DecodingError.Context( codingPath: decoder.codingPath, - debugDescription: "One of \"tabs\" or \"spaces\" must be specified")) + debugDescription: "One of \"tabs\" or \"spaces\" must be specified" + ) + ) } public func encode(to encoder: Encoder) throws { diff --git a/Sources/SwiftFormat/API/Selection.swift b/Sources/SwiftFormat/API/Selection.swift new file mode 100644 index 000000000..2e7d00109 --- /dev/null +++ b/Sources/SwiftFormat/API/Selection.swift @@ -0,0 +1,62 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Foundation +import SwiftSyntax + +/// The selection as given on the command line - an array of offets and lengths +public enum Selection { + case infinite + case ranges([Range]) + + /// Create a selection from an array of utf8 ranges. An empty array means an infinite selection. + public init(offsetRanges: [Range]) { + if offsetRanges.isEmpty { + self = .infinite + } else { + let ranges = offsetRanges.map { + AbsolutePosition(utf8Offset: $0.lowerBound).. Bool { + switch self { + case .infinite: + return true + case .ranges(let ranges): + return ranges.contains { $0.contains(position) } + } + } + + public func overlapsOrTouches(_ range: Range) -> Bool { + switch self { + case .infinite: + return true + case .ranges(let ranges): + return ranges.contains { $0.overlapsOrTouches(range) } + } + } +} + +public extension Syntax { + /// - Returns: `true` if the node is _completely_ inside any range in the selection + func isInsideSelection(_ selection: Selection) -> Bool { + switch selection { + case .infinite: + return true + case .ranges(let ranges): + return ranges.contains { return $0.lowerBound <= position && endPosition <= $0.upperBound } + } + } +} diff --git a/Sources/SwiftFormat/SwiftFormatError.swift b/Sources/SwiftFormat/API/SwiftFormatError.swift similarity index 55% rename from Sources/SwiftFormat/SwiftFormatError.swift rename to Sources/SwiftFormat/API/SwiftFormatError.swift index 2e3a890cc..6e4183162 100644 --- a/Sources/SwiftFormat/SwiftFormatError.swift +++ b/Sources/SwiftFormat/API/SwiftFormatError.swift @@ -10,10 +10,11 @@ // //===----------------------------------------------------------------------===// +import Foundation import SwiftSyntax /// Errors that can be thrown by the `SwiftFormatter` and `SwiftLinter` APIs. -public enum SwiftFormatError: Error { +public enum SwiftFormatError: LocalizedError { /// The requested file was not readable or it did not exist. case fileNotReadable @@ -23,4 +24,23 @@ public enum SwiftFormatError: Error { /// The file contains invalid or unrecognized Swift syntax and cannot be handled safely. case fileContainsInvalidSyntax + + /// The requested experimental feature name was not recognized by the parser. + case unrecognizedExperimentalFeature(String) + + public var errorDescription: String? { + switch self { + case .fileNotReadable: + return "file is not readable or does not exist" + case .isDirectory: + return "requested path is a directory, not a file" + case .fileContainsInvalidSyntax: + return "file contains invalid Swift syntax" + case .unrecognizedExperimentalFeature(let name): + return "experimental feature '\(name)' was not recognized by the Swift parser" + } + } } + +extension SwiftFormatError: Equatable {} +extension SwiftFormatError: Hashable {} diff --git a/Sources/SwiftFormat/SwiftFormatter.swift b/Sources/SwiftFormat/API/SwiftFormatter.swift similarity index 75% rename from Sources/SwiftFormat/SwiftFormatter.swift rename to Sources/SwiftFormat/API/SwiftFormatter.swift index a4a1654af..6e06015cf 100644 --- a/Sources/SwiftFormat/SwiftFormatter.swift +++ b/Sources/SwiftFormat/API/SwiftFormatter.swift @@ -2,7 +2,7 @@ // // This source file is part of the Swift.org open source project // -// Copyright (c) 2014 - 2019 Apple Inc. and the Swift project authors +// Copyright (c) 2014 - 2024 Apple Inc. and the Swift project authors // Licensed under Apache License v2.0 with Runtime Library Exception // // See https://swift.org/LICENSE.txt for license information @@ -12,10 +12,6 @@ import Foundation import SwiftDiagnostics -import SwiftFormatConfiguration -import SwiftFormatCore -import SwiftFormatPrettyPrint -import SwiftFormatRules import SwiftOperators import SwiftSyntax @@ -49,7 +45,7 @@ public final class SwiftFormatter { /// This form of the `format` function automatically folds expressions using the default operator /// set defined in Swift. If you need more control over this—for example, to provide the correct /// precedence relationships for custom operators—you must parse and fold the syntax tree - /// manually and then call ``format(syntax:assumingFileURL:to:)``. + /// manually and then call ``format(syntax:source:operatorTable:assumingFileURL:selection:to:)``. /// /// - Parameters: /// - url: The URL of the file containing the code to format. @@ -70,15 +66,14 @@ public final class SwiftFormatter { if FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir), isDir.boolValue { throw SwiftFormatError.isDirectory } - let source = try String(contentsOf: url, encoding: .utf8) - let sourceFile = try parseAndEmitDiagnostics( - source: source, - operatorTable: .standardOperators, - assumingFileURL: url, - parsingDiagnosticHandler: parsingDiagnosticHandler) + try format( - syntax: sourceFile, operatorTable: .standardOperators, assumingFileURL: url, source: source, - to: &outputStream) + source: String(contentsOf: url, encoding: .utf8), + assumingFileURL: url, + selection: .infinite, + to: &outputStream, + parsingDiagnosticHandler: parsingDiagnosticHandler + ) } /// Formats the given Swift source code and writes the result to an output stream. @@ -86,13 +81,18 @@ public final class SwiftFormatter { /// This form of the `format` function automatically folds expressions using the default operator /// set defined in Swift. If you need more control over this—for example, to provide the correct /// precedence relationships for custom operators—you must parse and fold the syntax tree - /// manually and then call ``format(syntax:assumingFileURL:to:)``. + /// manually and then call ``format(syntax:source:operatorTable:assumingFileURL:selection:to:)``. /// /// - Parameters: /// - source: The Swift source code to be formatted. /// - url: A file URL denoting the filename/path that should be assumed for this syntax tree, /// which is associated with any diagnostics emitted during formatting. If this is nil, a /// dummy value will be used. + /// - selection: The ranges to format + /// - experimentalFeatures: The set of experimental features that should be enabled in the + /// parser. These names must be from the set of parser-recognized experimental language + /// features in `SwiftParser`'s `Parser.ExperimentalFeatures` enum, which match the spelling + /// defined in the compiler's `Features.def` file. /// - outputStream: A value conforming to `TextOutputStream` to which the formatted output will /// be written. /// - parsingDiagnosticHandler: An optional callback that will be notified if there are any @@ -101,17 +101,31 @@ public final class SwiftFormatter { public func format( source: String, assumingFileURL url: URL?, + selection: Selection, + experimentalFeatures: Set = [], to outputStream: inout Output, parsingDiagnosticHandler: ((Diagnostic, SourceLocation) -> Void)? = nil ) throws { + // If the file or input string is completely empty, do nothing. This prevents even a trailing + // newline from being emitted for an empty file. (This is consistent with clang-format, which + // also does not touch an empty file even if the setting to add trailing newlines is enabled.) + guard !source.isEmpty else { return } + let sourceFile = try parseAndEmitDiagnostics( source: source, operatorTable: .standardOperators, assumingFileURL: url, - parsingDiagnosticHandler: parsingDiagnosticHandler) + experimentalFeatures: experimentalFeatures, + parsingDiagnosticHandler: parsingDiagnosticHandler + ) try format( - syntax: sourceFile, operatorTable: .standardOperators, assumingFileURL: url, source: source, - to: &outputStream) + syntax: sourceFile, + source: source, + operatorTable: .standardOperators, + assumingFileURL: url, + selection: selection, + to: &outputStream + ) } /// Formats the given Swift syntax tree and writes the result to an output stream. @@ -124,34 +138,38 @@ public final class SwiftFormatter { /// /// - Parameters: /// - syntax: The Swift syntax tree to be converted to source code and formatted. + /// - source: The original Swift source code used to build the syntax tree. /// - operatorTable: The table that defines the operators and their precedence relationships. /// This must be the same operator table that was used to fold the expressions in the `syntax` /// argument. /// - url: A file URL denoting the filename/path that should be assumed for this syntax tree, /// which is associated with any diagnostics emitted during formatting. If this is nil, a /// dummy value will be used. + /// - selection: The ranges to format /// - outputStream: A value conforming to `TextOutputStream` to which the formatted output will /// be written. /// - Throws: If an unrecoverable error occurs when formatting the code. public func format( - syntax: SourceFileSyntax, operatorTable: OperatorTable, assumingFileURL url: URL?, + syntax: SourceFileSyntax, + source: String, + operatorTable: OperatorTable, + assumingFileURL url: URL?, + selection: Selection, to outputStream: inout Output - ) throws { - try format( - syntax: syntax, operatorTable: operatorTable, assumingFileURL: url, source: nil, - to: &outputStream) - } - - private func format( - syntax: SourceFileSyntax, operatorTable: OperatorTable, - assumingFileURL url: URL?, source: String?, to outputStream: inout Output ) throws { let assumedURL = url ?? URL(fileURLWithPath: "source") let context = Context( - configuration: configuration, operatorTable: operatorTable, findingConsumer: findingConsumer, - fileURL: assumedURL, sourceFileSyntax: syntax, source: source, ruleNameCache: ruleNameCache) + configuration: configuration, + operatorTable: operatorTable, + findingConsumer: findingConsumer, + fileURL: assumedURL, + selection: selection, + sourceFileSyntax: syntax, + source: source, + ruleNameCache: ruleNameCache + ) let pipeline = FormatPipeline(context: context) - let transformedSyntax = pipeline.visit(Syntax(syntax)) + let transformedSyntax = pipeline.rewrite(Syntax(syntax)) if debugOptions.contains(.disablePrettyPrint) { outputStream.write(transformedSyntax.description) @@ -160,9 +178,11 @@ public final class SwiftFormatter { let printer = PrettyPrinter( context: context, + source: source, node: transformedSyntax, printTokenStream: debugOptions.contains(.dumpTokenStream), - whitespaceOnly: false) + whitespaceOnly: false + ) outputStream.write(printer.prettyPrint()) } } diff --git a/Sources/SwiftFormat/SwiftLinter.swift b/Sources/SwiftFormat/API/SwiftLinter.swift similarity index 78% rename from Sources/SwiftFormat/SwiftLinter.swift rename to Sources/SwiftFormat/API/SwiftLinter.swift index 10b4e8089..d70f30673 100644 --- a/Sources/SwiftFormat/SwiftLinter.swift +++ b/Sources/SwiftFormat/API/SwiftLinter.swift @@ -12,11 +12,6 @@ import Foundation import SwiftDiagnostics -import SwiftFormatConfiguration -import SwiftFormatCore -import SwiftFormatPrettyPrint -import SwiftFormatRules -import SwiftFormatWhitespaceLinter import SwiftOperators import SwiftSyntax @@ -50,7 +45,7 @@ public final class SwiftLinter { /// This form of the `lint` function automatically folds expressions using the default operator /// set defined in Swift. If you need more control over this—for example, to provide the correct /// precedence relationships for custom operators—you must parse and fold the syntax tree - /// manually and then call ``lint(syntax:assumingFileURL:)``. + /// manually and then call ``lint(syntax:source:operatorTable:assumingFileURL:)``. /// /// - Parameters: /// - url: The URL of the file containing the code to format. @@ -68,14 +63,12 @@ public final class SwiftLinter { if FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir), isDir.boolValue { throw SwiftFormatError.isDirectory } - let source = try String(contentsOf: url, encoding: .utf8) - let sourceFile = try parseAndEmitDiagnostics( - source: source, - operatorTable: .standardOperators, - assumingFileURL: url, - parsingDiagnosticHandler: parsingDiagnosticHandler) + try lint( - syntax: sourceFile, operatorTable: .standardOperators, assumingFileURL: url, source: source) + source: String(contentsOf: url, encoding: .utf8), + assumingFileURL: url, + parsingDiagnosticHandler: parsingDiagnosticHandler + ) } /// Lints the given Swift source code. @@ -83,26 +76,42 @@ public final class SwiftLinter { /// This form of the `lint` function automatically folds expressions using the default operator /// set defined in Swift. If you need more control over this—for example, to provide the correct /// precedence relationships for custom operators—you must parse and fold the syntax tree - /// manually and then call ``lint(syntax:assumingFileURL:)``. + /// manually and then call ``lint(syntax:source:operatorTable:assumingFileURL:)``. /// /// - Parameters: /// - source: The Swift source code to be linted. /// - url: A file URL denoting the filename/path that should be assumed for this source code. + /// - experimentalFeatures: The set of experimental features that should be enabled in the + /// parser. These names must be from the set of parser-recognized experimental language + /// features in `SwiftParser`'s `Parser.ExperimentalFeatures` enum, which match the spelling + /// defined in the compiler's `Features.def` file. /// - parsingDiagnosticHandler: An optional callback that will be notified if there are any /// errors when parsing the source code. /// - Throws: If an unrecoverable error occurs when formatting the code. public func lint( source: String, assumingFileURL url: URL, + experimentalFeatures: Set = [], parsingDiagnosticHandler: ((Diagnostic, SourceLocation) -> Void)? = nil ) throws { + // If the file or input string is completely empty, do nothing. This prevents even a trailing + // newline from being diagnosed for an empty file. (This is consistent with clang-format, which + // also does not touch an empty file even if the setting to add trailing newlines is enabled.) + guard !source.isEmpty else { return } + let sourceFile = try parseAndEmitDiagnostics( source: source, operatorTable: .standardOperators, assumingFileURL: url, - parsingDiagnosticHandler: parsingDiagnosticHandler) + experimentalFeatures: experimentalFeatures, + parsingDiagnosticHandler: parsingDiagnosticHandler + ) try lint( - syntax: sourceFile, operatorTable: .standardOperators, assumingFileURL: url, source: source) + syntax: sourceFile, + operatorTable: .standardOperators, + assumingFileURL: url, + source: source + ) } /// Lints the given Swift syntax tree. @@ -115,6 +124,7 @@ public final class SwiftLinter { /// /// - Parameters: /// - syntax: The Swift syntax tree to be converted to be linted. + /// - source: The Swift source code to be linted. /// - operatorTable: The table that defines the operators and their precedence relationships. /// This must be the same operator table that was used to fold the expressions in the `syntax` /// argument. @@ -122,21 +132,28 @@ public final class SwiftLinter { /// - Throws: If an unrecoverable error occurs when formatting the code. public func lint( syntax: SourceFileSyntax, + source: String, operatorTable: OperatorTable, assumingFileURL url: URL ) throws { - try lint(syntax: syntax, operatorTable: operatorTable, assumingFileURL: url, source: nil) + try lint(syntax: syntax, operatorTable: operatorTable, assumingFileURL: url, source: source) } private func lint( syntax: SourceFileSyntax, operatorTable: OperatorTable, assumingFileURL url: URL, - source: String? + source: String ) throws { let context = Context( - configuration: configuration, operatorTable: operatorTable, findingConsumer: findingConsumer, - fileURL: url, sourceFileSyntax: syntax, source: source, ruleNameCache: ruleNameCache) + configuration: configuration, + operatorTable: operatorTable, + findingConsumer: findingConsumer, + fileURL: url, + sourceFileSyntax: syntax, + source: source, + ruleNameCache: ruleNameCache + ) let pipeline = LintPipeline(context: context) pipeline.walk(Syntax(syntax)) @@ -148,9 +165,11 @@ public final class SwiftLinter { // pretty-printer. let printer = PrettyPrinter( context: context, + source: source, node: Syntax(syntax), printTokenStream: debugOptions.contains(.dumpTokenStream), - whitespaceOnly: true) + whitespaceOnly: true + ) let formatted = printer.prettyPrint() let ws = WhitespaceLinter(user: syntax.description, formatted: formatted, context: context) ws.lint() diff --git a/Sources/SwiftFormat/CMakeLists.txt b/Sources/SwiftFormat/CMakeLists.txt new file mode 100644 index 000000000..46937f713 --- /dev/null +++ b/Sources/SwiftFormat/CMakeLists.txt @@ -0,0 +1,112 @@ +#[[ +This source file is part of the swift-format open source project + +Copyright (c) 2024 Apple Inc. and the swift-format project authors +Licensed under Apache License v2.0 with Runtime Library Exception + +See https://swift.org/LICENSE.txt for license information +#]] + +add_library(SwiftFormat + API/Configuration+Default.swift + API/Configuration.swift + API/DebugOptions.swift + API/Finding.swift + API/FindingCategorizing.swift + API/Indent.swift + API/Selection.swift + API/SwiftFormatError.swift + API/SwiftFormatter.swift + API/SwiftLinter.swift + Core/Context.swift + Core/DocumentationComment.swift + Core/DocumentationCommentText.swift + Core/Finding+Convenience.swift + Core/FindingEmitter.swift + Core/FormatPipeline.swift + Core/FunctionDeclSyntax+Convenience.swift + Core/ImportsXCTestVisitor.swift + Core/LazySplitSequence.swift + Core/LintPipeline.swift + Core/ModifierListSyntax+Convenience.swift + Core/Parsing.swift + Core/Pipelines+Generated.swift + Core/RememberingIterator.swift + Core/Rule.swift + Core/RuleBasedFindingCategory.swift + Core/RuleMask.swift + Core/RuleNameCache+Generated.swift + Core/RuleRegistry+Generated.swift + Core/RuleState.swift + Core/SyntaxFormatRule.swift + Core/SyntaxLintRule.swift + Core/SyntaxProtocol+Convenience.swift + Core/Trivia+Convenience.swift + Core/WithAttributesSyntax+Convenience.swift + Core/WithSemicolonSyntax.swift + PrettyPrint/Comment.swift + PrettyPrint/Indent+Length.swift + PrettyPrint/PrettyPrint.swift + PrettyPrint/PrettyPrintBuffer.swift + PrettyPrint/PrettyPrintFindingCategory.swift + PrettyPrint/Token.swift + PrettyPrint/TokenStreamCreator.swift + PrettyPrint/Verbatim.swift + PrettyPrint/WhitespaceFindingCategory.swift + PrettyPrint/WhitespaceLinter.swift + Rules/AllPublicDeclarationsHaveDocumentation.swift + Rules/AlwaysUseLiteralForEmptyCollectionInit.swift + Rules/AlwaysUseLowerCamelCase.swift + Rules/AmbiguousTrailingClosureOverload.swift + Rules/AvoidRetroactiveConformances.swift + Rules/BeginDocumentationCommentWithOneLineSummary.swift + Rules/DoNotUseSemicolons.swift + Rules/DontRepeatTypeInStaticProperties.swift + Rules/FileScopedDeclarationPrivacy.swift + Rules/FullyIndirectEnum.swift + Rules/GroupNumericLiterals.swift + Rules/IdentifiersMustBeASCII.swift + Rules/NeverForceUnwrap.swift + Rules/NeverUseForceTry.swift + Rules/NeverUseImplicitlyUnwrappedOptionals.swift + Rules/NoAccessLevelOnExtensionDeclaration.swift + Rules/NoAssignmentInExpressions.swift + Rules/NoBlockComments.swift + Rules/NoCasesWithOnlyFallthrough.swift + Rules/NoEmptyLineOpeningClosingBraces.swift + Rules/NoEmptyTrailingClosureParentheses.swift + Rules/NoLabelsInCasePatterns.swift + Rules/NoLeadingUnderscores.swift + Rules/NoParensAroundConditions.swift + Rules/NoPlaygroundLiterals.swift + Rules/NoVoidReturnOnFunctionSignature.swift + Rules/OmitExplicitReturns.swift + Rules/OneCasePerLine.swift + Rules/OneVariableDeclarationPerLine.swift + Rules/OnlyOneTrailingClosureArgument.swift + Rules/OrderedImports.swift + Rules/ReplaceForEachWithForLoop.swift + Rules/ReturnVoidInsteadOfEmptyTuple.swift + Rules/TypeNamesShouldBeCapitalized.swift + Rules/UseEarlyExits.swift + Rules/UseExplicitNilCheckInConditions.swift + Rules/UseLetInEveryBoundCaseVariable.swift + Rules/UseShorthandTypeNames.swift + Rules/UseSingleLinePropertyGetter.swift + Rules/UseSynthesizedInitializer.swift + Rules/UseTripleSlashForDocumentationComments.swift + Rules/UseWhereClausesInForLoops.swift + Rules/ValidateDocumentationComments.swift + Utilities/FileIterator.swift + Utilities/URL+isRoot.swift) +target_link_libraries(SwiftFormat PUBLIC + SwiftMarkdown::Markdown + SwiftSyntax::SwiftSyntax + SwiftSyntax::SwiftSyntaxBuilder + SwiftSyntax::SwiftOperators + SwiftSyntax::SwiftParser + SwiftSyntax::SwiftParserDiagnostics + libcmark-gfm + libcmark-gfm-extensions) + +_install_target(SwiftFormat) diff --git a/Sources/SwiftFormatCore/Context.swift b/Sources/SwiftFormat/Core/Context.swift similarity index 78% rename from Sources/SwiftFormatCore/Context.swift rename to Sources/SwiftFormat/Core/Context.swift index 870a3ebc9..e00e38b20 100644 --- a/Sources/SwiftFormatCore/Context.swift +++ b/Sources/SwiftFormat/Core/Context.swift @@ -2,7 +2,7 @@ // // This source file is part of the Swift.org open source project // -// Copyright (c) 2014 - 2019 Apple Inc. and the Swift project authors +// Copyright (c) 2014 - 2024 Apple Inc. and the Swift project authors // Licensed under Apache License v2.0 with Runtime Library Exception // // See https://swift.org/LICENSE.txt for license information @@ -11,14 +11,15 @@ //===----------------------------------------------------------------------===// import Foundation -import SwiftFormatConfiguration import SwiftOperators +import SwiftParser import SwiftSyntax /// Context contains the bits that each formatter and linter will need access to. /// /// Specifically, it is the container for the shared configuration, diagnostic consumer, and URL of /// the current file. +@_spi(Rules) public final class Context { /// Tracks whether `XCTest` has been imported so that certain logic can be modified for files that @@ -36,16 +37,19 @@ public final class Context { } /// The configuration for this run of the pipeline, provided by a configuration JSON file. - public let configuration: Configuration + let configuration: Configuration + + /// The selection to process + let selection: Selection /// Defines the operators and their precedence relationships that were used during parsing. - public let operatorTable: OperatorTable + let operatorTable: OperatorTable /// Emits findings to the finding consumer. - public let findingEmitter: FindingEmitter + let findingEmitter: FindingEmitter /// The URL of the file being linted or formatted. - public let fileURL: URL + let fileURL: URL /// Indicates whether the file is known to import XCTest. public var importsXCTest: XCTestImportState @@ -54,10 +58,10 @@ public final class Context { public let sourceLocationConverter: SourceLocationConverter /// Contains the rules have been disabled by comments for certain line numbers. - public let ruleMask: RuleMask + let ruleMask: RuleMask /// Contains all the available rules' names associated to their types' object identifiers. - public let ruleNameCache: [ObjectIdentifier: String] + let ruleNameCache: [ObjectIdentifier: String] /// Creates a new Context with the provided configuration, diagnostic engine, and file URL. public init( @@ -65,6 +69,7 @@ public final class Context { operatorTable: OperatorTable, findingConsumer: ((Finding) -> Void)?, fileURL: URL, + selection: Selection = .infinite, sourceFileSyntax: SourceFileSyntax, source: String? = nil, ruleNameCache: [ObjectIdentifier: String] @@ -73,10 +78,11 @@ public final class Context { self.operatorTable = operatorTable self.findingEmitter = FindingEmitter(consumer: findingConsumer) self.fileURL = fileURL + self.selection = selection self.importsXCTest = .notDetermined + let tree = source.map { Parser.parse(source: $0) } ?? sourceFileSyntax self.sourceLocationConverter = - source.map { SourceLocationConverter(file: fileURL.relativePath, source: $0) } - ?? SourceLocationConverter(file: fileURL.relativePath, tree: sourceFileSyntax) + SourceLocationConverter(fileName: fileURL.relativePath, tree: tree) self.ruleMask = RuleMask( syntaxNode: Syntax(sourceFileSyntax), sourceLocationConverter: sourceLocationConverter @@ -85,16 +91,19 @@ public final class Context { } /// Given a rule's name and the node it is examining, determine if the rule is disabled at this - /// location or not. - public func isRuleEnabled(_ rule: R.Type, node: Syntax) -> Bool { + /// location or not. Also makes sure the entire node is contained inside any selection. + func shouldFormat(_ rule: R.Type, node: Syntax) -> Bool { + guard node.isInsideSelection(selection) else { return false } + let loc = node.startLocation(converter: self.sourceLocationConverter) assert( ruleNameCache[ObjectIdentifier(rule)] != nil, """ Missing cached rule name for '\(rule)'! \ - Ensure `generate-pipelines` has been run and `ruleNameCache` was injected. - """) + Ensure `generate-swift-format` has been run and `ruleNameCache` was injected. + """ + ) let ruleName = ruleNameCache[ObjectIdentifier(rule)] ?? R.ruleName switch ruleMask.ruleState(ruleName, at: loc) { diff --git a/Sources/SwiftFormat/Core/DocumentationComment.swift b/Sources/SwiftFormat/Core/DocumentationComment.swift new file mode 100644 index 000000000..d89b4c1c6 --- /dev/null +++ b/Sources/SwiftFormat/Core/DocumentationComment.swift @@ -0,0 +1,346 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Markdown +import SwiftSyntax + +/// A structured representation of information extracted from a documentation comment. +/// +/// This type represents both the top-level content of a documentation comment on a declaration and +/// also the nested information that can be provided on a parameter. For example, when a parameter +/// is a function type, it can provide not only a brief summary but also its own parameter and +/// return value descriptions. +@_spi(Testing) +public struct DocumentationComment { + /// A description of a parameter in a documentation comment. + public struct Parameter { + /// The name of the parameter. + public var name: String + + /// The documentation comment of the parameter. + /// + /// Typically, only the `briefSummary` field of this value will be populated. However, for more + /// complex cases like parameters whose types are functions, the grammar permits full + /// descriptions including `Parameter(s)`, `Returns`, and `Throws` fields to be present. + public var comment: DocumentationComment + } + + /// Describes the structural layout of the parameter descriptions in the comment. + public enum ParameterLayout { + /// All parameters were written under a single `Parameters` outline section at the top level of + /// the comment. + case outline + + /// All parameters were written as individual `Parameter` items at the top level of the comment. + case separated + + /// Parameters were written as a combination of one or more `Parameters` outlines and individual + /// `Parameter` items. + case mixed + } + + /// A single paragraph representing a brief summary of the declaration, if present. + public var briefSummary: Paragraph? = nil + + /// A collection of otherwise uncategorized body nodes at the top level of the comment text. + /// + /// If a brief summary paragraph was extracted from the comment, it will not be present in this + /// collection. + public var bodyNodes: [Markup] = [] + + /// The structural layout of the parameter descriptions in the comment. + public var parameterLayout: ParameterLayout? = nil + + /// Descriptions of parameters to a function, if any. + public var parameters: [Parameter] = [] + + /// A description of the return value of a function. + /// + /// If present, this value is a copy of the `Paragraph` node from the comment but with the + /// `Returns:` prefix removed for convenience. + public var returns: Paragraph? = nil + + /// A description of an error thrown by a function. + /// + /// If present, this value is a copy of the `Paragraph` node from the comment but with the + /// `Throws:` prefix removed for convenience. + public var `throws`: Paragraph? = nil + + /// Creates a new `DocumentationComment` with information extracted from the leading trivia of the + /// given syntax node. + /// + /// If the syntax node does not have a preceding documentation comment, this initializer returns + /// `nil`. + /// + /// - Parameter node: The syntax node from which the documentation comment should be extracted. + public init?(extractedFrom node: Node) { + guard let commentInfo = DocumentationCommentText(extractedFrom: node.leadingTrivia) else { + return nil + } + + // Disable smart quotes and dash conversion since we want to preserve the original content of + // the comments instead of doing documentation generation. + let doc = Document(parsing: commentInfo.text, options: [.disableSmartOpts]) + self.init(markup: doc) + } + + /// Creates a new `DocumentationComment` from the given `Markup` node. + private init(markup: Markup) { + // Extract the first paragraph as the brief summary. It will *not* be included in the body + // nodes. + let remainingChildren: DropFirstSequence + if let firstParagraph = markup.child(through: [(0, Paragraph.self)]) { + briefSummary = firstParagraph.detachedFromParent as? Paragraph + remainingChildren = markup.children.dropFirst() + } else { + briefSummary = nil + remainingChildren = markup.children.dropFirst(0) + } + + for child in remainingChildren { + if var list = child.detachedFromParent as? UnorderedList { + // An unordered list could be one of the following: + // + // 1. A parameter outline: + // - Parameters: + // - x: ... + // - y: ... + // + // 2. An exploded parameter list: + // - Parameter x: ... + // - Parameter y: ... + // + // 3. Some other simple field, like `Returns:`. + // + // Note that the order of execution of these two functions matters for the correct value of + // `parameterLayout` to be computed. If these ever change, make sure to update that + // computation inside the functions. + extractParameterOutline(from: &list) + extractSeparatedParameters(from: &list) + + extractSimpleFields(from: &list) + + // If the list is now empty, don't add it to the body nodes below. + guard !list.isEmpty else { continue } + } + + bodyNodes.append(child.detachedFromParent) + } + } + + /// Extracts parameter fields in an outlined parameters list (i.e., `- Parameters:` containing a + /// nested list of parameter fields) from the given unordered list. + /// + /// If parameters were successfully extracted, the provided list is mutated to remove them as a + /// side effect of this function. + private mutating func extractParameterOutline(from list: inout UnorderedList) { + var unprocessedChildren: [Markup] = [] + + for child in list.children { + guard + let listItem = child as? ListItem, + let firstText = listItem.child(through: [ + (0, Paragraph.self), + (0, Text.self), + ]) as? Text, + firstText.string.trimmingCharacters(in: .whitespaces).lowercased() == "parameters:" + else { + unprocessedChildren.append(child.detachedFromParent) + continue + } + + for index in 1..:` items in + /// a top-level list in the comment text) from the given unordered list. + /// + /// If parameters were successfully extracted, the provided list is mutated to remove them as a + /// side effect of this function. + private mutating func extractSeparatedParameters(from list: inout UnorderedList) { + var unprocessedChildren: [Markup] = [] + + for child in list.children { + guard + let listItem = child as? ListItem, + let paramField = parameterField(extractedFrom: listItem, expectParameterLabel: true) + else { + unprocessedChildren.append(child.detachedFromParent) + continue + } + + self.parameters.append(paramField) + + switch self.parameterLayout { + case nil: + self.parameterLayout = .separated + case .outline: + self.parameterLayout = .mixed + default: + break + } + } + + list = list.withUncheckedChildren(unprocessedChildren) as! UnorderedList + } + + /// Returns a new `ParameterField` containing parameter information extracted from the given list + /// item, or `nil` if it was not a valid parameter field. + private func parameterField( + extractedFrom listItem: ListItem, + expectParameterLabel: Bool + ) -> Parameter? { + var rewriter = ParameterOutlineMarkupRewriter( + origin: listItem, + expectParameterLabel: expectParameterLabel + ) + guard + let newListItem = listItem.accept(&rewriter) as? ListItem, + let name = rewriter.parameterName + else { return nil } + + return Parameter(name: name, comment: DocumentationComment(markup: newListItem)) + } + + /// Extracts simple fields like `- Returns:` and `- Throws:` from the top-level list in the + /// comment text. + /// + /// If fields were successfully extracted, the provided list is mutated to remove them. + private mutating func extractSimpleFields(from list: inout UnorderedList) { + var unprocessedChildren: [Markup] = [] + + for child in list.children { + guard + let listItem = child as? ListItem, + case var rewriter = SimpleFieldMarkupRewriter(origin: listItem), + listItem.accept(&rewriter) as? ListItem != nil, + let name = rewriter.fieldName, + let paragraph = rewriter.paragraph + else { + unprocessedChildren.append(child) + continue + } + + switch name.lowercased() { + case "returns": + self.returns = paragraph + case "throws": + self.throws = paragraph + default: + unprocessedChildren.append(child) + } + } + + list = list.withUncheckedChildren(unprocessedChildren) as! UnorderedList + } +} + +/// Visits a list item representing a parameter in a documentation comment and rewrites it to remove +/// any `Parameter` tag (if present), the name of the parameter, and the subsequent colon. +private struct ParameterOutlineMarkupRewriter: MarkupRewriter { + /// The list item to which the rewriter will be applied. + let origin: ListItem + + /// If true, the `Parameter` prefix is expected on the list item content and it should be dropped. + let expectParameterLabel: Bool + + /// Populated if the list item to which this is applied represents a valid parameter field. + private(set) var parameterName: String? = nil + + mutating func visitListItem(_ listItem: ListItem) -> Markup? { + // Only recurse into the exact list item we're applying this to; otherwise, return it unchanged. + guard listItem.isIdentical(to: origin) else { return listItem } + return defaultVisit(listItem) + } + + mutating func visitParagraph(_ paragraph: Paragraph) -> Markup? { + // Only recurse into the first paragraph in the list item. + guard paragraph.indexInParent == 0 else { return paragraph } + return defaultVisit(paragraph) + } + + mutating func visitText(_ text: Text) -> Markup? { + // Only manipulate the first text node (of the first paragraph). + guard text.indexInParent == 0 else { return text } + + let parameterPrefix = "parameter " + if expectParameterLabel && !text.string.lowercased().hasPrefix(parameterPrefix) { return text } + + let string = + expectParameterLabel ? text.string.dropFirst(parameterPrefix.count) : text.string[...] + let nameAndRemainder = string.split(separator: ":", maxSplits: 1) + guard nameAndRemainder.count == 2 else { return text } + + let name = nameAndRemainder[0].trimmingCharacters(in: .whitespaces) + guard !name.isEmpty else { return text } + + self.parameterName = name + return Text(String(nameAndRemainder[1])) + } +} + +/// Visits a list item representing a simple field in a documentation comment and rewrites it to +/// extract the field name, removing it and the subsequent colon from the item. +private struct SimpleFieldMarkupRewriter: MarkupRewriter { + /// The list item to which the rewriter will be applied. + let origin: ListItem + + /// Populated if the list item to which this is applied represents a valid simple field. + private(set) var fieldName: String? = nil + + /// Populated if the list item to which this is applied represents a valid simple field. + private(set) var paragraph: Paragraph? = nil + + mutating func visitListItem(_ listItem: ListItem) -> Markup? { + // Only recurse into the exact list item we're applying this to; otherwise, return it unchanged. + guard listItem.isIdentical(to: origin) else { return listItem } + return defaultVisit(listItem) + } + + mutating func visitParagraph(_ paragraph: Paragraph) -> Markup? { + // Only recurse into the first paragraph in the list item. + guard paragraph.indexInParent == 0 else { return paragraph } + guard let newNode = defaultVisit(paragraph) else { return nil } + guard let newParagraph = newNode as? Paragraph else { return newNode } + self.paragraph = newParagraph.detachedFromParent as? Paragraph + return newParagraph + } + + mutating func visitText(_ text: Text) -> Markup? { + // Only manipulate the first text node (of the first paragraph). + guard text.indexInParent == 0 else { return text } + + let nameAndRemainder = text.string.split(separator: ":", maxSplits: 1) + guard nameAndRemainder.count == 2 else { return text } + + let name = nameAndRemainder[0].trimmingCharacters(in: .whitespaces) + guard !name.isEmpty else { return text } + + self.fieldName = name + return Text(String(nameAndRemainder[1])) + } +} diff --git a/Sources/SwiftFormat/Core/DocumentationCommentText.swift b/Sources/SwiftFormat/Core/DocumentationCommentText.swift new file mode 100644 index 000000000..7ee21b7ba --- /dev/null +++ b/Sources/SwiftFormat/Core/DocumentationCommentText.swift @@ -0,0 +1,234 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import SwiftSyntax + +/// The text contents of a documentation comment extracted from trivia. +/// +/// This type should be used when only the text of the comment is important, not the Markdown +/// structural organization. It automatically handles trimming leading indentation from comments as +/// well as "ASCII art" in block comments (i.e., leading asterisks on each line). +@_spi(Testing) +@_spi(Rules) +public struct DocumentationCommentText { + /// Denotes the kind of punctuation used to introduce the comment. + public enum Introducer { + /// The comment was introduced entirely by line-style comments (`///`). + case line + + /// The comment was introduced entirely by block-style comments (`/** ... */`). + case block + + /// The comment was introduced by a mixture of line-style and block-style comments. + case mixed + } + + /// The comment text extracted from the trivia. + public let text: String + + /// The index in the trivia collection passed to the initializer where the comment started. + public let startIndex: Trivia.Index + + /// The kind of punctuation used to introduce the comment. + public let introducer: Introducer + + /// Extracts and returns the body text of a documentation comment represented as a trivia + /// collection. + /// + /// This implementation is based on + /// https://github.com/apple/swift/blob/main/lib/Markup/LineList.cpp. + /// + /// - Parameter trivia: The trivia collection from which to extract the comment text. + /// - Returns: If a comment was found, a tuple containing the `String` containing the extracted + /// text and the index into the trivia collection where the comment began is returned. + /// Otherwise, `nil` is returned. + public init?(extractedFrom trivia: Trivia) { + /// Represents a line of text and its leading indentation. + struct Line { + var text: Substring + var firstNonspaceDistance: Int + + init(_ text: Substring) { + self.text = text + self.firstNonspaceDistance = indentationDistance(of: text) + } + } + + // Look backwards from the end of the trivia collection to find the logical start of the + // comment. We have to copy it into an array since `Trivia` doesn't support bidirectional + // indexing. + let triviaArray = Array(trivia) + let commentStartIndex = findCommentStartIndex(triviaArray) + + // Determine the indentation level of the first line of the comment. This is used to adjust + // block comments, whose text spans multiple lines. + let leadingWhitespace = contiguousWhitespace(in: triviaArray, before: commentStartIndex) + var lines = [Line]() + + var introducer: Introducer? + func updateIntroducer(_ newIntroducer: Introducer) { + if let knownIntroducer = introducer, knownIntroducer != newIntroducer { + introducer = .mixed + } else { + introducer = newIntroducer + } + } + + // Extract the raw lines of text (which will include their leading comment punctuation, which is + // stripped). + for triviaPiece in trivia[commentStartIndex...] { + switch triviaPiece { + case .docLineComment(let line): + updateIntroducer(.line) + lines.append(Line(line.dropFirst(3))) + + case .docBlockComment(let line): + updateIntroducer(.block) + + var cleaned = line.dropFirst(3) + if cleaned.hasSuffix("*/") { + cleaned = cleaned.dropLast(2) + } + + var hasASCIIArt = false + if cleaned.hasPrefix("\n") { + cleaned = cleaned.dropFirst() + hasASCIIArt = asciiArtLength(of: cleaned, leadingSpaces: leadingWhitespace) != 0 + } + + while !cleaned.isEmpty { + var index = cleaned.firstIndex(where: \.isNewline) ?? cleaned.endIndex + if hasASCIIArt { + cleaned = + cleaned.dropFirst(asciiArtLength(of: cleaned, leadingSpaces: leadingWhitespace)) + index = cleaned.firstIndex(where: \.isNewline) ?? cleaned.endIndex + } + + // Don't add an unnecessary blank line at the end when `*/` is on its own line. + guard cleaned.firstIndex(where: { !$0.isWhitespace }) != nil else { + break + } + + let line = cleaned.prefix(upTo: index) + lines.append(Line(line)) + cleaned = cleaned[index...].dropFirst() + } + + default: + break + } + } + + // Concatenate the lines into a single string, trimming any leading indentation that might be + // present. + guard + let introducer = introducer, + !lines.isEmpty, + let firstLineIndex = lines.firstIndex(where: { !$0.text.isEmpty }) + else { return nil } + + let initialIndentation = indentationDistance(of: lines[firstLineIndex].text) + var result = "" + for line in lines[firstLineIndex...] { + let countToDrop = min(initialIndentation, line.firstNonspaceDistance) + result.append(contentsOf: "\(line.text.dropFirst(countToDrop))\n") + } + + guard !result.isEmpty else { return nil } + + let commentStartDistance = + triviaArray.distance(from: triviaArray.startIndex, to: commentStartIndex) + self.text = result + self.startIndex = trivia.index(trivia.startIndex, offsetBy: commentStartDistance) + self.introducer = introducer + } +} + +/// Returns the distance from the start of the string to the first non-whitespace character. +private func indentationDistance(of text: Substring) -> Int { + return text.distance( + from: text.startIndex, + to: text.firstIndex { !$0.isWhitespace } ?? text.endIndex + ) +} + +/// Returns the number of contiguous whitespace characters (spaces and tabs only) that precede the +/// given trivia piece. +private func contiguousWhitespace( + in trivia: [TriviaPiece], + before index: Array.Index +) -> Int { + var index = index + var whitespace = 0 + loop: while index != trivia.startIndex { + index = trivia.index(before: index) + switch trivia[index] { + case .spaces(let count): whitespace += count + case .tabs(let count): whitespace += count + default: break loop + } + } + return whitespace +} + +/// Returns the number of characters considered block comment "ASCII art" at the beginning of the +/// given string. +private func asciiArtLength(of string: Substring, leadingSpaces: Int) -> Int { + let spaces = string.prefix(leadingSpaces) + if spaces.count != leadingSpaces { + return 0 + } + if spaces.contains(where: { !$0.isWhitespace }) { + return 0 + } + + let string = string.dropFirst(leadingSpaces) + if string.hasPrefix(" * ") { + return leadingSpaces + 3 + } + if string.hasPrefix(" *\n") { + return leadingSpaces + 2 + } + return 0 +} + +/// Returns the start index of the earliest comment in the Trivia if we work backwards and +/// skip through comments, newlines, and whitespace. Then we advance a bit forward to be sure +/// the returned index is actually a comment and not whitespace. +private func findCommentStartIndex(_ triviaArray: Array) -> Array.Index { + func firstCommentIndex(_ slice: ArraySlice) -> Array.Index { + return slice.firstIndex(where: { + switch $0 { + case .docLineComment, .docBlockComment: + return true + default: + return false + } + }) ?? slice.endIndex + } + + if let lastNonDocCommentIndex = triviaArray.lastIndex(where: { + switch $0 { + case .docBlockComment, .docLineComment, + .newlines(1), .carriageReturns(1), .carriageReturnLineFeeds(1), + .spaces, .tabs: + return false + default: + return true + } + }) { + let nextIndex = triviaArray.index(after: lastNonDocCommentIndex) + return firstCommentIndex(triviaArray[nextIndex...]) + } else { + return firstCommentIndex(triviaArray[...]) + } +} diff --git a/Sources/SwiftFormatCore/Finding+Convenience.swift b/Sources/SwiftFormat/Core/Finding+Convenience.swift similarity index 63% rename from Sources/SwiftFormatCore/Finding+Convenience.swift rename to Sources/SwiftFormat/Core/Finding+Convenience.swift index 78dc2905c..b532e815d 100644 --- a/Sources/SwiftFormatCore/Finding+Convenience.swift +++ b/Sources/SwiftFormat/Core/Finding+Convenience.swift @@ -14,18 +14,7 @@ import SwiftSyntax extension Finding.Location { /// Creates a new `Finding.Location` by converting the given `SourceLocation` from `SwiftSyntax`. - /// - /// If the source location is invalid (i.e., any of its fields are nil), then the initializer will - /// return nil. - public init?(_ sourceLocation: SourceLocation) { - guard - let file = sourceLocation.file, - let line = sourceLocation.line, - let column = sourceLocation.column - else { - return nil - } - - self.init(file: file, line: line, column: column) + init(_ sourceLocation: SourceLocation) { + self.init(file: sourceLocation.file, line: sourceLocation.line, column: sourceLocation.column) } } diff --git a/Sources/SwiftFormatCore/FindingEmitter.swift b/Sources/SwiftFormat/Core/FindingEmitter.swift similarity index 97% rename from Sources/SwiftFormatCore/FindingEmitter.swift rename to Sources/SwiftFormat/Core/FindingEmitter.swift index e425448fd..b42e101f1 100644 --- a/Sources/SwiftFormatCore/FindingEmitter.swift +++ b/Sources/SwiftFormat/Core/FindingEmitter.swift @@ -19,7 +19,7 @@ /// If the consumer function is nil, then the `emit` function is a no-op. This allows callers, such /// as lint/format rules and the pretty-printer, to emit findings unconditionally, without wrapping /// each call in a check about whether the client is interested in receiving those findings or not. -public final class FindingEmitter { +final class FindingEmitter { /// An optional function that will be called and passed a finding each time one is emitted. private let consumer: ((Finding) -> Void)? @@ -56,6 +56,8 @@ public final class FindingEmitter { message: message, severity: category.defaultSeverity, location: location, - notes: notes)) + notes: notes + ) + ) } } diff --git a/Sources/SwiftFormat/FormatPipeline.swift b/Sources/SwiftFormat/Core/FormatPipeline.swift similarity index 98% rename from Sources/SwiftFormat/FormatPipeline.swift rename to Sources/SwiftFormat/Core/FormatPipeline.swift index 09492cd99..e72242e00 100644 --- a/Sources/SwiftFormat/FormatPipeline.swift +++ b/Sources/SwiftFormat/Core/FormatPipeline.swift @@ -10,8 +10,6 @@ // //===----------------------------------------------------------------------===// -import SwiftFormatCore - /// A type that invokes individual format rules. /// /// Note that this type is not a `SyntaxVisitor` or `SyntaxRewriter`. That is because, at this time, diff --git a/Sources/SwiftFormatRules/FunctionDeclSyntax+Convenience.swift b/Sources/SwiftFormat/Core/FunctionDeclSyntax+Convenience.swift similarity index 81% rename from Sources/SwiftFormatRules/FunctionDeclSyntax+Convenience.swift rename to Sources/SwiftFormat/Core/FunctionDeclSyntax+Convenience.swift index 2ee98b4e9..e0a98d0ec 100644 --- a/Sources/SwiftFormatRules/FunctionDeclSyntax+Convenience.swift +++ b/Sources/SwiftFormat/Core/FunctionDeclSyntax+Convenience.swift @@ -15,9 +15,9 @@ import SwiftSyntax extension FunctionDeclSyntax { /// Constructs a name for a function that includes parameter labels, i.e. `foo(_:bar:)`. var fullDeclName: String { - let params = signature.input.parameterList.map { param in - "\(param.firstName?.text ?? "_"):" + let params = signature.parameterClause.parameters.map { param in + "\(param.firstName.text):" } - return "\(identifier.text)(\(params.joined()))" + return "\(name.text)(\(params.joined()))" } } diff --git a/Sources/SwiftFormatRules/ImportsXCTestVisitor.swift b/Sources/SwiftFormat/Core/ImportsXCTestVisitor.swift similarity index 99% rename from Sources/SwiftFormatRules/ImportsXCTestVisitor.swift rename to Sources/SwiftFormat/Core/ImportsXCTestVisitor.swift index 29b608180..ed9e9750f 100644 --- a/Sources/SwiftFormatRules/ImportsXCTestVisitor.swift +++ b/Sources/SwiftFormat/Core/ImportsXCTestVisitor.swift @@ -10,7 +10,6 @@ // //===----------------------------------------------------------------------===// -import SwiftFormatCore import SwiftSyntax /// A visitor that determines if the target source file imports `XCTest`. @@ -53,6 +52,7 @@ private class ImportsXCTestVisitor: SyntaxVisitor { /// - Parameters: /// - context: The context information of the target source file. /// - sourceFile: The file to be visited. +@_spi(Testing) public func setImportsXCTest(context: Context, sourceFile: SourceFileSyntax) { guard context.importsXCTest == .notDetermined else { return } let visitor = ImportsXCTestVisitor(context: context) diff --git a/Sources/SwiftFormatWhitespaceLinter/LazySplitSequence.swift b/Sources/SwiftFormat/Core/LazySplitSequence.swift similarity index 100% rename from Sources/SwiftFormatWhitespaceLinter/LazySplitSequence.swift rename to Sources/SwiftFormat/Core/LazySplitSequence.swift diff --git a/Sources/SwiftFormat/LintPipeline.swift b/Sources/SwiftFormat/Core/LintPipeline.swift similarity index 73% rename from Sources/SwiftFormat/LintPipeline.swift rename to Sources/SwiftFormat/Core/LintPipeline.swift index 2921975e4..d83e161d0 100644 --- a/Sources/SwiftFormat/LintPipeline.swift +++ b/Sources/SwiftFormat/Core/LintPipeline.swift @@ -10,7 +10,6 @@ // //===----------------------------------------------------------------------===// -import SwiftFormatCore import SwiftSyntax /// A syntax visitor that delegates to individual rules for linting. @@ -27,11 +26,17 @@ extension LintPipeline { /// - node: The syntax node on which the rule will be applied. This lets us check whether the /// rule is enabled for the particular source range where the node occurs. func visitIfEnabled( - _ visitor: (Rule) -> (Node) -> SyntaxVisitorContinueKind, for node: Node + _ visitor: (Rule) -> (Node) -> SyntaxVisitorContinueKind, + for node: Node ) { - guard context.isRuleEnabled(Rule.self, node: Syntax(node)) else { return } + guard context.shouldFormat(Rule.self, node: Syntax(node)) else { return } + let ruleId = ObjectIdentifier(Rule.self) + guard self.shouldSkipChildren[ruleId] == nil else { return } let rule = self.rule(Rule.self) - _ = visitor(rule)(node) + let continueKind = visitor(rule)(node) + if case .skipChildren = continueKind { + self.shouldSkipChildren[ruleId] = node + } } /// Calls the `visit` method of a rule for the given node if that rule is enabled for the node. @@ -44,17 +49,36 @@ extension LintPipeline { /// - node: The syntax node on which the rule will be applied. This lets us check whether the /// rule is enabled for the particular source range where the node occurs. func visitIfEnabled( - _ visitor: (Rule) -> (Node) -> Any, for node: Node + _ visitor: (Rule) -> (Node) -> Any, + for node: Node ) { // Note that visitor function type is expressed as `Any` because we ignore the return value, but // more importantly because the `visit` methods return protocol refinements of `Syntax` that // cannot currently be expressed as constraints without duplicating this function for each of // them individually. - guard context.isRuleEnabled(Rule.self, node: Syntax(node)) else { return } + guard context.shouldFormat(Rule.self, node: Syntax(node)) else { return } + guard self.shouldSkipChildren[ObjectIdentifier(Rule.self)] == nil else { return } let rule = self.rule(Rule.self) _ = visitor(rule)(node) } + /// Cleans up any state associated with `rule` when we leave syntax node `node` + /// + /// - Parameters: + /// - rule: The type of the syntax rule we're cleaning up. + /// - node: The syntax node htat our traversal has left. + func onVisitPost( + rule: R.Type, + for node: Node + ) { + let rule = ObjectIdentifier(rule) + if case .some(let skipNode) = self.shouldSkipChildren[rule] { + if node.id == skipNode.id { + self.shouldSkipChildren.removeValue(forKey: rule) + } + } + } + /// Retrieves an instance of a lint or format rule based on its type. /// /// There is at most 1 instance of each rule allocated per `LintPipeline`. This method will diff --git a/Sources/SwiftFormat/Core/ModifierListSyntax+Convenience.swift b/Sources/SwiftFormat/Core/ModifierListSyntax+Convenience.swift new file mode 100644 index 000000000..043f3b893 --- /dev/null +++ b/Sources/SwiftFormat/Core/ModifierListSyntax+Convenience.swift @@ -0,0 +1,59 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2019 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import SwiftSyntax + +extension DeclModifierListSyntax { + /// Returns the declaration's access level modifier, if present. + var accessLevelModifier: DeclModifierSyntax? { + for modifier in self { + switch modifier.name.tokenKind { + case .keyword(.public), .keyword(.private), .keyword(.fileprivate), .keyword(.internal), + .keyword(.package): + return modifier + default: + continue + } + } + return nil + } + + /// Returns true if the modifier list contains any of the keywords in the given set. + func contains(anyOf keywords: Set) -> Bool { + return contains { + switch $0.name.tokenKind { + case .keyword(let keyword): return keywords.contains(keyword) + default: return false + } + } + } + + /// Removes any of the modifiers in the given set from the modifier list, mutating it in-place. + mutating func remove(anyOf keywords: Set) { + self = filter { + switch $0.name.tokenKind { + case .keyword(let keyword): return !keywords.contains(keyword) + default: return true + } + } + } + + /// Returns a copy of the modifier list with any of the modifiers in the given set removed. + func removing(anyOf keywords: Set) -> DeclModifierListSyntax { + return filter { + switch $0.name.tokenKind { + case .keyword(let keyword): return !keywords.contains(keyword) + default: return true + } + } + } +} diff --git a/Sources/SwiftFormat/Core/Parsing.swift b/Sources/SwiftFormat/Core/Parsing.swift new file mode 100644 index 000000000..09b20ef0a --- /dev/null +++ b/Sources/SwiftFormat/Core/Parsing.swift @@ -0,0 +1,107 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2022 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Foundation +import SwiftDiagnostics +import SwiftOperators +@_spi(ExperimentalLanguageFeatures) import SwiftParser +import SwiftParserDiagnostics +import SwiftSyntax + +/// Parses the given source code and returns a valid `SourceFileSyntax` node. +/// +/// This helper function automatically folds sequence expressions using the given operator table, +/// ignoring errors so that formatting can do something reasonable in the presence of unrecognized +/// operators. +/// +/// - Parameters: +/// - source: The Swift source code to be formatted. +/// - operatorTable: The operator table to use for sequence folding. +/// - url: A file URL denoting the filename/path that should be assumed for this syntax tree, +/// which is associated with any diagnostics emitted during formatting. If this is nil, a +/// dummy value will be used. +/// - experimentalFeatures: The set of experimental features that should be enabled in the parser. +/// These names must be from the set of parser-recognized experimental language features in +/// `SwiftParser`'s `Parser.ExperimentalFeatures` enum, which match the spelling defined in the +/// compiler's `Features.def` file. +/// - parsingDiagnosticHandler: An optional callback that will be notified if there are any +/// errors when parsing the source code. +/// - Throws: If an unrecoverable error occurs when formatting the code. +func parseAndEmitDiagnostics( + source: String, + operatorTable: OperatorTable, + assumingFileURL url: URL?, + experimentalFeatures: Set, + parsingDiagnosticHandler: ((Diagnostic, SourceLocation) -> Void)? = nil +) throws -> SourceFileSyntax { + var experimentalFeaturesSet: Parser.ExperimentalFeatures = [] + for featureName in experimentalFeatures { + guard let featureValue = Parser.ExperimentalFeatures(name: featureName) else { + throw SwiftFormatError.unrecognizedExperimentalFeature(featureName) + } + experimentalFeaturesSet.formUnion(featureValue) + } + var source = source + let sourceFile = source.withUTF8 { sourceBytes in + operatorTable.foldAll(Parser.parse(source: sourceBytes, experimentalFeatures: experimentalFeaturesSet)) { _ in } + .as(SourceFileSyntax.self)! + } + let diagnostics = ParseDiagnosticsGenerator.diagnostics(for: sourceFile) + var hasErrors = false + if let parsingDiagnosticHandler = parsingDiagnosticHandler { + let expectedConverter = + SourceLocationConverter(fileName: url?.path ?? "", tree: sourceFile) + for diagnostic in diagnostics { + let location = diagnostic.location(converter: expectedConverter) + + // Downgrade editor placeholders to warnings, because it is useful to support formatting + // in-progress files that contain those. + if diagnostic.diagnosticID == StaticTokenError.editorPlaceholder.diagnosticID { + parsingDiagnosticHandler(downgradedToWarning(diagnostic), location) + } else { + parsingDiagnosticHandler(diagnostic, location) + hasErrors = true + } + } + } + + guard !hasErrors else { + throw SwiftFormatError.fileContainsInvalidSyntax + } + return sourceFile +} + +// Wraps a `DiagnosticMessage` but forces its severity to be that of a warning instead of an error. +struct DowngradedDiagnosticMessage: DiagnosticMessage { + var originalDiagnostic: DiagnosticMessage + + var message: String { originalDiagnostic.message } + + var diagnosticID: SwiftDiagnostics.MessageID { originalDiagnostic.diagnosticID } + + var severity: DiagnosticSeverity { .warning } +} + +/// Returns a new `Diagnostic` that is identical to the given diagnostic, except that its severity +/// has been downgraded to a warning. +func downgradedToWarning(_ diagnostic: Diagnostic) -> Diagnostic { + // `Diagnostic` is immutable, so create a new one with the same values except for the + // severity-downgraded message. + return Diagnostic( + node: diagnostic.node, + position: diagnostic.position, + message: DowngradedDiagnosticMessage(originalDiagnostic: diagnostic.diagMessage), + highlights: diagnostic.highlights, + notes: diagnostic.notes, + fixIts: diagnostic.fixIts + ) +} diff --git a/Sources/SwiftFormat/Core/Pipelines+Generated.swift b/Sources/SwiftFormat/Core/Pipelines+Generated.swift new file mode 100644 index 000000000..6f22e1384 --- /dev/null +++ b/Sources/SwiftFormat/Core/Pipelines+Generated.swift @@ -0,0 +1,636 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2019 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +// This file is automatically generated with generate-swift-format. Do not edit! + +import SwiftSyntax + +/// A syntax visitor that delegates to individual rules for linting. +/// +/// This file will be extended with `visit` methods in Pipelines+Generated.swift. +class LintPipeline: SyntaxVisitor { + + /// The formatter context. + let context: Context + + /// Stores lint and format rule instances, indexed by the `ObjectIdentifier` of a rule's + /// class type. + var ruleCache = [ObjectIdentifier: Rule]() + + /// Rules present in this dictionary skip visiting children until they leave the + /// syntax node stored as their value + var shouldSkipChildren = [ObjectIdentifier: SyntaxProtocol]() + + /// Creates a new lint pipeline. + init(context: Context) { + self.context = context + super.init(viewMode: .sourceAccurate) + } + + override func visit(_ node: AccessorBlockSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(NoEmptyLinesOpeningClosingBraces.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: AccessorBlockSyntax) { + onVisitPost(rule: NoEmptyLinesOpeningClosingBraces.self, for: node) + } + + override func visit(_ node: ActorDeclSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(AllPublicDeclarationsHaveDocumentation.visit, for: node) + visitIfEnabled(TypeNamesShouldBeCapitalized.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: ActorDeclSyntax) { + onVisitPost(rule: AllPublicDeclarationsHaveDocumentation.self, for: node) + onVisitPost(rule: TypeNamesShouldBeCapitalized.self, for: node) + } + + override func visit(_ node: AsExprSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(NeverForceUnwrap.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: AsExprSyntax) { + onVisitPost(rule: NeverForceUnwrap.self, for: node) + } + + override func visit(_ node: AssociatedTypeDeclSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(BeginDocumentationCommentWithOneLineSummary.visit, for: node) + visitIfEnabled(NoLeadingUnderscores.visit, for: node) + visitIfEnabled(TypeNamesShouldBeCapitalized.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: AssociatedTypeDeclSyntax) { + onVisitPost(rule: BeginDocumentationCommentWithOneLineSummary.self, for: node) + onVisitPost(rule: NoLeadingUnderscores.self, for: node) + onVisitPost(rule: TypeNamesShouldBeCapitalized.self, for: node) + } + + override func visit(_ node: AttributeSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(AvoidRetroactiveConformances.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: AttributeSyntax) { + onVisitPost(rule: AvoidRetroactiveConformances.self, for: node) + } + + override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(AllPublicDeclarationsHaveDocumentation.visit, for: node) + visitIfEnabled(AlwaysUseLowerCamelCase.visit, for: node) + visitIfEnabled(BeginDocumentationCommentWithOneLineSummary.visit, for: node) + visitIfEnabled(NoLeadingUnderscores.visit, for: node) + visitIfEnabled(TypeNamesShouldBeCapitalized.visit, for: node) + visitIfEnabled(UseTripleSlashForDocumentationComments.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: ClassDeclSyntax) { + onVisitPost(rule: AllPublicDeclarationsHaveDocumentation.self, for: node) + onVisitPost(rule: AlwaysUseLowerCamelCase.self, for: node) + onVisitPost(rule: BeginDocumentationCommentWithOneLineSummary.self, for: node) + onVisitPost(rule: NoLeadingUnderscores.self, for: node) + onVisitPost(rule: TypeNamesShouldBeCapitalized.self, for: node) + onVisitPost(rule: UseTripleSlashForDocumentationComments.self, for: node) + } + + override func visit(_ node: ClosureExprSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(NoEmptyLinesOpeningClosingBraces.visit, for: node) + visitIfEnabled(OmitExplicitReturns.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: ClosureExprSyntax) { + onVisitPost(rule: NoEmptyLinesOpeningClosingBraces.self, for: node) + onVisitPost(rule: OmitExplicitReturns.self, for: node) + } + + override func visit(_ node: ClosureParameterSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(NoLeadingUnderscores.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: ClosureParameterSyntax) { + onVisitPost(rule: NoLeadingUnderscores.self, for: node) + } + + override func visit(_ node: ClosureSignatureSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(AlwaysUseLowerCamelCase.visit, for: node) + visitIfEnabled(ReturnVoidInsteadOfEmptyTuple.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: ClosureSignatureSyntax) { + onVisitPost(rule: AlwaysUseLowerCamelCase.self, for: node) + onVisitPost(rule: ReturnVoidInsteadOfEmptyTuple.self, for: node) + } + + override func visit(_ node: CodeBlockItemListSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(DoNotUseSemicolons.visit, for: node) + visitIfEnabled(NoAssignmentInExpressions.visit, for: node) + visitIfEnabled(OneVariableDeclarationPerLine.visit, for: node) + visitIfEnabled(UseEarlyExits.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: CodeBlockItemListSyntax) { + onVisitPost(rule: DoNotUseSemicolons.self, for: node) + onVisitPost(rule: NoAssignmentInExpressions.self, for: node) + onVisitPost(rule: OneVariableDeclarationPerLine.self, for: node) + onVisitPost(rule: UseEarlyExits.self, for: node) + } + + override func visit(_ node: CodeBlockSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(AmbiguousTrailingClosureOverload.visit, for: node) + visitIfEnabled(NoEmptyLinesOpeningClosingBraces.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: CodeBlockSyntax) { + onVisitPost(rule: AmbiguousTrailingClosureOverload.self, for: node) + onVisitPost(rule: NoEmptyLinesOpeningClosingBraces.self, for: node) + } + + override func visit(_ node: ConditionElementSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(NoParensAroundConditions.visit, for: node) + visitIfEnabled(UseExplicitNilCheckInConditions.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: ConditionElementSyntax) { + onVisitPost(rule: NoParensAroundConditions.self, for: node) + onVisitPost(rule: UseExplicitNilCheckInConditions.self, for: node) + } + + override func visit(_ node: DeinitializerDeclSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(AllPublicDeclarationsHaveDocumentation.visit, for: node) + visitIfEnabled(BeginDocumentationCommentWithOneLineSummary.visit, for: node) + visitIfEnabled(UseTripleSlashForDocumentationComments.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: DeinitializerDeclSyntax) { + onVisitPost(rule: AllPublicDeclarationsHaveDocumentation.self, for: node) + onVisitPost(rule: BeginDocumentationCommentWithOneLineSummary.self, for: node) + onVisitPost(rule: UseTripleSlashForDocumentationComments.self, for: node) + } + + override func visit(_ node: EnumCaseElementSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(AlwaysUseLowerCamelCase.visit, for: node) + visitIfEnabled(NoLeadingUnderscores.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: EnumCaseElementSyntax) { + onVisitPost(rule: AlwaysUseLowerCamelCase.self, for: node) + onVisitPost(rule: NoLeadingUnderscores.self, for: node) + } + + override func visit(_ node: EnumCaseParameterSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(NoLeadingUnderscores.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: EnumCaseParameterSyntax) { + onVisitPost(rule: NoLeadingUnderscores.self, for: node) + } + + override func visit(_ node: EnumDeclSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(AllPublicDeclarationsHaveDocumentation.visit, for: node) + visitIfEnabled(BeginDocumentationCommentWithOneLineSummary.visit, for: node) + visitIfEnabled(FullyIndirectEnum.visit, for: node) + visitIfEnabled(NoLeadingUnderscores.visit, for: node) + visitIfEnabled(OneCasePerLine.visit, for: node) + visitIfEnabled(TypeNamesShouldBeCapitalized.visit, for: node) + visitIfEnabled(UseTripleSlashForDocumentationComments.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: EnumDeclSyntax) { + onVisitPost(rule: AllPublicDeclarationsHaveDocumentation.self, for: node) + onVisitPost(rule: BeginDocumentationCommentWithOneLineSummary.self, for: node) + onVisitPost(rule: FullyIndirectEnum.self, for: node) + onVisitPost(rule: NoLeadingUnderscores.self, for: node) + onVisitPost(rule: OneCasePerLine.self, for: node) + onVisitPost(rule: TypeNamesShouldBeCapitalized.self, for: node) + onVisitPost(rule: UseTripleSlashForDocumentationComments.self, for: node) + } + + override func visit(_ node: ExtensionDeclSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(AvoidRetroactiveConformances.visit, for: node) + visitIfEnabled(NoAccessLevelOnExtensionDeclaration.visit, for: node) + visitIfEnabled(UseTripleSlashForDocumentationComments.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: ExtensionDeclSyntax) { + onVisitPost(rule: AvoidRetroactiveConformances.self, for: node) + onVisitPost(rule: NoAccessLevelOnExtensionDeclaration.self, for: node) + onVisitPost(rule: UseTripleSlashForDocumentationComments.self, for: node) + } + + override func visit(_ node: ForStmtSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(UseWhereClausesInForLoops.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: ForStmtSyntax) { + onVisitPost(rule: UseWhereClausesInForLoops.self, for: node) + } + + override func visit(_ node: ForceUnwrapExprSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(NeverForceUnwrap.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: ForceUnwrapExprSyntax) { + onVisitPost(rule: NeverForceUnwrap.self, for: node) + } + + override func visit(_ node: FunctionCallExprSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(NoEmptyTrailingClosureParentheses.visit, for: node) + visitIfEnabled(OnlyOneTrailingClosureArgument.visit, for: node) + visitIfEnabled(ReplaceForEachWithForLoop.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: FunctionCallExprSyntax) { + onVisitPost(rule: NoEmptyTrailingClosureParentheses.self, for: node) + onVisitPost(rule: OnlyOneTrailingClosureArgument.self, for: node) + onVisitPost(rule: ReplaceForEachWithForLoop.self, for: node) + } + + override func visit(_ node: FunctionDeclSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(AllPublicDeclarationsHaveDocumentation.visit, for: node) + visitIfEnabled(AlwaysUseLowerCamelCase.visit, for: node) + visitIfEnabled(BeginDocumentationCommentWithOneLineSummary.visit, for: node) + visitIfEnabled(NoLeadingUnderscores.visit, for: node) + visitIfEnabled(OmitExplicitReturns.visit, for: node) + visitIfEnabled(UseTripleSlashForDocumentationComments.visit, for: node) + visitIfEnabled(ValidateDocumentationComments.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: FunctionDeclSyntax) { + onVisitPost(rule: AllPublicDeclarationsHaveDocumentation.self, for: node) + onVisitPost(rule: AlwaysUseLowerCamelCase.self, for: node) + onVisitPost(rule: BeginDocumentationCommentWithOneLineSummary.self, for: node) + onVisitPost(rule: NoLeadingUnderscores.self, for: node) + onVisitPost(rule: OmitExplicitReturns.self, for: node) + onVisitPost(rule: UseTripleSlashForDocumentationComments.self, for: node) + onVisitPost(rule: ValidateDocumentationComments.self, for: node) + } + + override func visit(_ node: FunctionParameterSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(AlwaysUseLiteralForEmptyCollectionInit.visit, for: node) + visitIfEnabled(NoLeadingUnderscores.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: FunctionParameterSyntax) { + onVisitPost(rule: AlwaysUseLiteralForEmptyCollectionInit.self, for: node) + onVisitPost(rule: NoLeadingUnderscores.self, for: node) + } + + override func visit(_ node: FunctionSignatureSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(NoVoidReturnOnFunctionSignature.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: FunctionSignatureSyntax) { + onVisitPost(rule: NoVoidReturnOnFunctionSignature.self, for: node) + } + + override func visit(_ node: FunctionTypeSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(ReturnVoidInsteadOfEmptyTuple.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: FunctionTypeSyntax) { + onVisitPost(rule: ReturnVoidInsteadOfEmptyTuple.self, for: node) + } + + override func visit(_ node: GenericParameterSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(NoLeadingUnderscores.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: GenericParameterSyntax) { + onVisitPost(rule: NoLeadingUnderscores.self, for: node) + } + + override func visit(_ node: GenericSpecializationExprSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(UseShorthandTypeNames.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: GenericSpecializationExprSyntax) { + onVisitPost(rule: UseShorthandTypeNames.self, for: node) + } + + override func visit(_ node: GuardStmtSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(NoParensAroundConditions.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: GuardStmtSyntax) { + onVisitPost(rule: NoParensAroundConditions.self, for: node) + } + + override func visit(_ node: IdentifierPatternSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(IdentifiersMustBeASCII.visit, for: node) + visitIfEnabled(NoLeadingUnderscores.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: IdentifierPatternSyntax) { + onVisitPost(rule: IdentifiersMustBeASCII.self, for: node) + onVisitPost(rule: NoLeadingUnderscores.self, for: node) + } + + override func visit(_ node: IdentifierTypeSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(UseShorthandTypeNames.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: IdentifierTypeSyntax) { + onVisitPost(rule: UseShorthandTypeNames.self, for: node) + } + + override func visit(_ node: IfExprSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(NoParensAroundConditions.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: IfExprSyntax) { + onVisitPost(rule: NoParensAroundConditions.self, for: node) + } + + override func visit(_ node: InfixOperatorExprSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(NoAssignmentInExpressions.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: InfixOperatorExprSyntax) { + onVisitPost(rule: NoAssignmentInExpressions.self, for: node) + } + + override func visit(_ node: InitializerDeclSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(AllPublicDeclarationsHaveDocumentation.visit, for: node) + visitIfEnabled(BeginDocumentationCommentWithOneLineSummary.visit, for: node) + visitIfEnabled(UseTripleSlashForDocumentationComments.visit, for: node) + visitIfEnabled(ValidateDocumentationComments.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: InitializerDeclSyntax) { + onVisitPost(rule: AllPublicDeclarationsHaveDocumentation.self, for: node) + onVisitPost(rule: BeginDocumentationCommentWithOneLineSummary.self, for: node) + onVisitPost(rule: UseTripleSlashForDocumentationComments.self, for: node) + onVisitPost(rule: ValidateDocumentationComments.self, for: node) + } + + override func visit(_ node: IntegerLiteralExprSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(GroupNumericLiterals.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: IntegerLiteralExprSyntax) { + onVisitPost(rule: GroupNumericLiterals.self, for: node) + } + + override func visit(_ node: MacroExpansionExprSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(NoPlaygroundLiterals.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: MacroExpansionExprSyntax) { + onVisitPost(rule: NoPlaygroundLiterals.self, for: node) + } + + override func visit(_ node: MemberBlockItemListSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(DoNotUseSemicolons.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: MemberBlockItemListSyntax) { + onVisitPost(rule: DoNotUseSemicolons.self, for: node) + } + + override func visit(_ node: MemberBlockSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(AmbiguousTrailingClosureOverload.visit, for: node) + visitIfEnabled(NoEmptyLinesOpeningClosingBraces.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: MemberBlockSyntax) { + onVisitPost(rule: AmbiguousTrailingClosureOverload.self, for: node) + onVisitPost(rule: NoEmptyLinesOpeningClosingBraces.self, for: node) + } + + override func visit(_ node: OptionalBindingConditionSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(AlwaysUseLowerCamelCase.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: OptionalBindingConditionSyntax) { + onVisitPost(rule: AlwaysUseLowerCamelCase.self, for: node) + } + + override func visit(_ node: PatternBindingSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(AlwaysUseLiteralForEmptyCollectionInit.visit, for: node) + visitIfEnabled(OmitExplicitReturns.visit, for: node) + visitIfEnabled(UseSingleLinePropertyGetter.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: PatternBindingSyntax) { + onVisitPost(rule: AlwaysUseLiteralForEmptyCollectionInit.self, for: node) + onVisitPost(rule: OmitExplicitReturns.self, for: node) + onVisitPost(rule: UseSingleLinePropertyGetter.self, for: node) + } + + override func visit(_ node: PrecedenceGroupDeclSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(NoEmptyLinesOpeningClosingBraces.visit, for: node) + visitIfEnabled(NoLeadingUnderscores.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: PrecedenceGroupDeclSyntax) { + onVisitPost(rule: NoEmptyLinesOpeningClosingBraces.self, for: node) + onVisitPost(rule: NoLeadingUnderscores.self, for: node) + } + + override func visit(_ node: ProtocolDeclSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(AllPublicDeclarationsHaveDocumentation.visit, for: node) + visitIfEnabled(BeginDocumentationCommentWithOneLineSummary.visit, for: node) + visitIfEnabled(NoLeadingUnderscores.visit, for: node) + visitIfEnabled(TypeNamesShouldBeCapitalized.visit, for: node) + visitIfEnabled(UseTripleSlashForDocumentationComments.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: ProtocolDeclSyntax) { + onVisitPost(rule: AllPublicDeclarationsHaveDocumentation.self, for: node) + onVisitPost(rule: BeginDocumentationCommentWithOneLineSummary.self, for: node) + onVisitPost(rule: NoLeadingUnderscores.self, for: node) + onVisitPost(rule: TypeNamesShouldBeCapitalized.self, for: node) + onVisitPost(rule: UseTripleSlashForDocumentationComments.self, for: node) + } + + override func visit(_ node: RepeatStmtSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(NoParensAroundConditions.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: RepeatStmtSyntax) { + onVisitPost(rule: NoParensAroundConditions.self, for: node) + } + + override func visit(_ node: SourceFileSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(AlwaysUseLowerCamelCase.visit, for: node) + visitIfEnabled(AmbiguousTrailingClosureOverload.visit, for: node) + visitIfEnabled(FileScopedDeclarationPrivacy.visit, for: node) + visitIfEnabled(NeverForceUnwrap.visit, for: node) + visitIfEnabled(NeverUseForceTry.visit, for: node) + visitIfEnabled(NeverUseImplicitlyUnwrappedOptionals.visit, for: node) + visitIfEnabled(OrderedImports.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: SourceFileSyntax) { + onVisitPost(rule: AlwaysUseLowerCamelCase.self, for: node) + onVisitPost(rule: AmbiguousTrailingClosureOverload.self, for: node) + onVisitPost(rule: FileScopedDeclarationPrivacy.self, for: node) + onVisitPost(rule: NeverForceUnwrap.self, for: node) + onVisitPost(rule: NeverUseForceTry.self, for: node) + onVisitPost(rule: NeverUseImplicitlyUnwrappedOptionals.self, for: node) + onVisitPost(rule: OrderedImports.self, for: node) + } + + override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(AllPublicDeclarationsHaveDocumentation.visit, for: node) + visitIfEnabled(BeginDocumentationCommentWithOneLineSummary.visit, for: node) + visitIfEnabled(NoLeadingUnderscores.visit, for: node) + visitIfEnabled(TypeNamesShouldBeCapitalized.visit, for: node) + visitIfEnabled(UseSynthesizedInitializer.visit, for: node) + visitIfEnabled(UseTripleSlashForDocumentationComments.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: StructDeclSyntax) { + onVisitPost(rule: AllPublicDeclarationsHaveDocumentation.self, for: node) + onVisitPost(rule: BeginDocumentationCommentWithOneLineSummary.self, for: node) + onVisitPost(rule: NoLeadingUnderscores.self, for: node) + onVisitPost(rule: TypeNamesShouldBeCapitalized.self, for: node) + onVisitPost(rule: UseSynthesizedInitializer.self, for: node) + onVisitPost(rule: UseTripleSlashForDocumentationComments.self, for: node) + } + + override func visit(_ node: SubscriptDeclSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(AllPublicDeclarationsHaveDocumentation.visit, for: node) + visitIfEnabled(BeginDocumentationCommentWithOneLineSummary.visit, for: node) + visitIfEnabled(OmitExplicitReturns.visit, for: node) + visitIfEnabled(UseTripleSlashForDocumentationComments.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: SubscriptDeclSyntax) { + onVisitPost(rule: AllPublicDeclarationsHaveDocumentation.self, for: node) + onVisitPost(rule: BeginDocumentationCommentWithOneLineSummary.self, for: node) + onVisitPost(rule: OmitExplicitReturns.self, for: node) + onVisitPost(rule: UseTripleSlashForDocumentationComments.self, for: node) + } + + override func visit(_ node: SwitchCaseLabelSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(NoLabelsInCasePatterns.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: SwitchCaseLabelSyntax) { + onVisitPost(rule: NoLabelsInCasePatterns.self, for: node) + } + + override func visit(_ node: SwitchCaseListSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(NoCasesWithOnlyFallthrough.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: SwitchCaseListSyntax) { + onVisitPost(rule: NoCasesWithOnlyFallthrough.self, for: node) + } + + override func visit(_ node: SwitchExprSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(NoEmptyLinesOpeningClosingBraces.visit, for: node) + visitIfEnabled(NoParensAroundConditions.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: SwitchExprSyntax) { + onVisitPost(rule: NoEmptyLinesOpeningClosingBraces.self, for: node) + onVisitPost(rule: NoParensAroundConditions.self, for: node) + } + + override func visit(_ node: TokenSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(NoBlockComments.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: TokenSyntax) { + onVisitPost(rule: NoBlockComments.self, for: node) + } + + override func visit(_ node: TryExprSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(NeverUseForceTry.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: TryExprSyntax) { + onVisitPost(rule: NeverUseForceTry.self, for: node) + } + + override func visit(_ node: TypeAliasDeclSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(AllPublicDeclarationsHaveDocumentation.visit, for: node) + visitIfEnabled(BeginDocumentationCommentWithOneLineSummary.visit, for: node) + visitIfEnabled(NoLeadingUnderscores.visit, for: node) + visitIfEnabled(TypeNamesShouldBeCapitalized.visit, for: node) + visitIfEnabled(UseTripleSlashForDocumentationComments.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: TypeAliasDeclSyntax) { + onVisitPost(rule: AllPublicDeclarationsHaveDocumentation.self, for: node) + onVisitPost(rule: BeginDocumentationCommentWithOneLineSummary.self, for: node) + onVisitPost(rule: NoLeadingUnderscores.self, for: node) + onVisitPost(rule: TypeNamesShouldBeCapitalized.self, for: node) + onVisitPost(rule: UseTripleSlashForDocumentationComments.self, for: node) + } + + override func visit(_ node: ValueBindingPatternSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(UseLetInEveryBoundCaseVariable.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: ValueBindingPatternSyntax) { + onVisitPost(rule: UseLetInEveryBoundCaseVariable.self, for: node) + } + + override func visit(_ node: VariableDeclSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(AllPublicDeclarationsHaveDocumentation.visit, for: node) + visitIfEnabled(AlwaysUseLowerCamelCase.visit, for: node) + visitIfEnabled(BeginDocumentationCommentWithOneLineSummary.visit, for: node) + visitIfEnabled(DontRepeatTypeInStaticProperties.visit, for: node) + visitIfEnabled(NeverUseImplicitlyUnwrappedOptionals.visit, for: node) + visitIfEnabled(UseTripleSlashForDocumentationComments.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: VariableDeclSyntax) { + onVisitPost(rule: AllPublicDeclarationsHaveDocumentation.self, for: node) + onVisitPost(rule: AlwaysUseLowerCamelCase.self, for: node) + onVisitPost(rule: BeginDocumentationCommentWithOneLineSummary.self, for: node) + onVisitPost(rule: DontRepeatTypeInStaticProperties.self, for: node) + onVisitPost(rule: NeverUseImplicitlyUnwrappedOptionals.self, for: node) + onVisitPost(rule: UseTripleSlashForDocumentationComments.self, for: node) + } + + override func visit(_ node: WhileStmtSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(NoParensAroundConditions.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: WhileStmtSyntax) { + onVisitPost(rule: NoParensAroundConditions.self, for: node) + } +} + +extension FormatPipeline { + + func rewrite(_ node: Syntax) -> Syntax { + var node = node + node = AlwaysUseLiteralForEmptyCollectionInit(context: context).rewrite(node) + node = DoNotUseSemicolons(context: context).rewrite(node) + node = FileScopedDeclarationPrivacy(context: context).rewrite(node) + node = FullyIndirectEnum(context: context).rewrite(node) + node = GroupNumericLiterals(context: context).rewrite(node) + node = NoAccessLevelOnExtensionDeclaration(context: context).rewrite(node) + node = NoAssignmentInExpressions(context: context).rewrite(node) + node = NoCasesWithOnlyFallthrough(context: context).rewrite(node) + node = NoEmptyLinesOpeningClosingBraces(context: context).rewrite(node) + node = NoEmptyTrailingClosureParentheses(context: context).rewrite(node) + node = NoLabelsInCasePatterns(context: context).rewrite(node) + node = NoParensAroundConditions(context: context).rewrite(node) + node = NoVoidReturnOnFunctionSignature(context: context).rewrite(node) + node = OmitExplicitReturns(context: context).rewrite(node) + node = OneCasePerLine(context: context).rewrite(node) + node = OneVariableDeclarationPerLine(context: context).rewrite(node) + node = OrderedImports(context: context).rewrite(node) + node = ReturnVoidInsteadOfEmptyTuple(context: context).rewrite(node) + node = UseEarlyExits(context: context).rewrite(node) + node = UseExplicitNilCheckInConditions(context: context).rewrite(node) + node = UseShorthandTypeNames(context: context).rewrite(node) + node = UseSingleLinePropertyGetter(context: context).rewrite(node) + node = UseTripleSlashForDocumentationComments(context: context).rewrite(node) + node = UseWhereClausesInForLoops(context: context).rewrite(node) + return node + } +} diff --git a/Sources/SwiftFormatWhitespaceLinter/RememberingIterator.swift b/Sources/SwiftFormat/Core/RememberingIterator.swift similarity index 100% rename from Sources/SwiftFormatWhitespaceLinter/RememberingIterator.swift rename to Sources/SwiftFormat/Core/RememberingIterator.swift diff --git a/Sources/SwiftFormatCore/Rule.swift b/Sources/SwiftFormat/Core/Rule.swift similarity index 54% rename from Sources/SwiftFormatCore/Rule.swift rename to Sources/SwiftFormat/Core/Rule.swift index 264bafd82..368c7087e 100644 --- a/Sources/SwiftFormatCore/Rule.swift +++ b/Sources/SwiftFormat/Core/Rule.swift @@ -14,6 +14,7 @@ import Foundation import SwiftSyntax /// A Rule is a linting or formatting pass that executes in a given context. +@_spi(Rules) public protocol Rule { /// The context in which the rule is executed. var context: Context { get } @@ -28,6 +29,22 @@ public protocol Rule { init(context: Context) } +/// The part of a node where an emitted finding should be anchored. +@_spi(Rules) +public enum FindingAnchor { + /// The finding is anchored at the beginning of the node's actual content, skipping any leading + /// trivia. + case start + + /// The finding is anchored at the beginning of the trivia piece at the given index in the node's + /// leading trivia. + case leadingTrivia(Trivia.Index) + + /// The finding is anchored at the beginning of the trivia piece at the given index in the node's + /// trailing trivia. + case trailingTrivia(Trivia.Index) +} + extension Rule { /// By default, the `ruleName` is just the name of the implementing rule class. public static var ruleName: String { String("\(self)".split(separator: ".").last!) } @@ -39,28 +56,42 @@ extension Rule { /// - node: The syntax node to which the finding should be attached. The finding's location will /// be set to the start of the node (excluding leading trivia, unless `leadingTriviaIndex` is /// provided). - /// - leadingTriviaIndex: If non-nil, the index of a trivia piece in the node's leading trivia - /// that should be used to determine the location of the finding. Otherwise, the finding's - /// location will be the start of the node after any leading trivia. + /// - anchor: The part of the node where the finding should be anchored. Defaults to the start + /// of the node's content (after any leading trivia). /// - notes: An array of notes that provide additional detail about the finding. public func diagnose( _ message: Finding.Message, on node: SyntaxType?, - leadingTriviaIndex: Trivia.Index? = nil, + severity: Finding.Severity? = nil, + anchor: FindingAnchor = .start, notes: [Finding.Note] = [] ) { let syntaxLocation: SourceLocation? - if let leadingTriviaIndex = leadingTriviaIndex { - syntaxLocation = node?.startLocation( - ofLeadingTriviaAt: leadingTriviaIndex, converter: context.sourceLocationConverter) + if let node = node { + switch anchor { + case .start: + syntaxLocation = node.startLocation(converter: context.sourceLocationConverter) + case .leadingTrivia(let index): + syntaxLocation = node.startLocation( + ofLeadingTriviaAt: index, + converter: context.sourceLocationConverter + ) + case .trailingTrivia(let index): + syntaxLocation = node.startLocation( + ofTrailingTriviaAt: index, + converter: context.sourceLocationConverter + ) + } } else { - syntaxLocation = node?.startLocation(converter: context.sourceLocationConverter) + syntaxLocation = nil } + let category = RuleBasedFindingCategory(ruleType: type(of: self), severity: severity) context.findingEmitter.emit( - message, - category: RuleBasedFindingCategory(ruleType: type(of: self)), - location: syntaxLocation.flatMap(Finding.Location.init), - notes: notes) + message, + category: category, + location: syntaxLocation.flatMap(Finding.Location.init), + notes: notes + ) } } diff --git a/Sources/SwiftFormatCore/RuleBasedFindingCategory.swift b/Sources/SwiftFormat/Core/RuleBasedFindingCategory.swift similarity index 90% rename from Sources/SwiftFormatCore/RuleBasedFindingCategory.swift rename to Sources/SwiftFormat/Core/RuleBasedFindingCategory.swift index 03a2f4541..a43dade37 100644 --- a/Sources/SwiftFormatCore/RuleBasedFindingCategory.swift +++ b/Sources/SwiftFormat/Core/RuleBasedFindingCategory.swift @@ -22,8 +22,11 @@ struct RuleBasedFindingCategory: FindingCategorizing { var description: String { ruleType.ruleName } + var severity: Finding.Severity? + /// Creates a finding category that wraps the given rule type. - init(ruleType: Rule.Type) { + init(ruleType: Rule.Type, severity: Finding.Severity? = nil) { self.ruleType = ruleType + self.severity = severity } } diff --git a/Sources/SwiftFormatCore/RuleMask.swift b/Sources/SwiftFormat/Core/RuleMask.swift similarity index 93% rename from Sources/SwiftFormatCore/RuleMask.swift rename to Sources/SwiftFormat/Core/RuleMask.swift index b614b17b0..9edf2449c 100644 --- a/Sources/SwiftFormatCore/RuleMask.swift +++ b/Sources/SwiftFormat/Core/RuleMask.swift @@ -37,6 +37,7 @@ import SwiftSyntax /// /// The rules themselves reference RuleMask to see if it is disabled for the line it is currently /// examining. +@_spi(Testing) public class RuleMask { /// Stores the source ranges in which all rules are ignored. private var allRulesIgnoredRanges: [SourceRange] = [] @@ -136,7 +137,7 @@ fileprivate class RuleStatusCollectionVisitor: SyntaxVisitor { // MARK: - Syntax Visitation Methods override func visit(_ node: SourceFileSyntax) -> SyntaxVisitorContinueKind { - guard let firstToken = node.firstToken else { + guard let firstToken = node.firstToken(viewMode: .sourceAccurate) else { return .visitChildren } let comments = loneLineComments(in: firstToken.leadingTrivia, isFirstToken: true) @@ -153,20 +154,23 @@ fileprivate class RuleStatusCollectionVisitor: SyntaxVisitor { } let sourceRange = node.sourceRange( - converter: sourceLocationConverter, afterLeadingTrivia: false, afterTrailingTrivia: true) + converter: sourceLocationConverter, + afterLeadingTrivia: false, + afterTrailingTrivia: true + ) allRulesIgnoredRanges.append(sourceRange) return .skipChildren } override func visit(_ node: CodeBlockItemSyntax) -> SyntaxVisitorContinueKind { - guard let firstToken = node.firstToken else { + guard let firstToken = node.firstToken(viewMode: .sourceAccurate) else { return .visitChildren } return appendRuleStatusDirectives(from: firstToken, of: Syntax(node)) } - override func visit(_ node: MemberDeclListItemSyntax) -> SyntaxVisitorContinueKind { - guard let firstToken = node.firstToken else { + override func visit(_ node: MemberBlockItemSyntax) -> SyntaxVisitorContinueKind { + guard let firstToken = node.firstToken(viewMode: .sourceAccurate) else { return .visitChildren } return appendRuleStatusDirectives(from: firstToken, of: Syntax(node)) @@ -180,10 +184,11 @@ fileprivate class RuleStatusCollectionVisitor: SyntaxVisitor { /// - Parameters: /// - token: A token that may have comments that modify the status of rules. /// - node: The node to which the token belongs. - private func appendRuleStatusDirectives(from token: TokenSyntax, of node: Syntax) - -> SyntaxVisitorContinueKind - { - let isFirstInFile = token.previousToken == nil + private func appendRuleStatusDirectives( + from token: TokenSyntax, + of node: Syntax + ) -> SyntaxVisitorContinueKind { + let isFirstInFile = token.previousToken(viewMode: .sourceAccurate) == nil let matches = loneLineComments(in: token.leadingTrivia, isFirstToken: isFirstInFile) .compactMap(ruleStatusDirectiveMatch) let sourceRange = node.sourceRange(converter: sourceLocationConverter) diff --git a/Sources/SwiftFormatRules/RuleNameCache+Generated.swift b/Sources/SwiftFormat/Core/RuleNameCache+Generated.swift similarity index 81% rename from Sources/SwiftFormatRules/RuleNameCache+Generated.swift rename to Sources/SwiftFormat/Core/RuleNameCache+Generated.swift index ec75e73b1..ed06b5577 100644 --- a/Sources/SwiftFormatRules/RuleNameCache+Generated.swift +++ b/Sources/SwiftFormat/Core/RuleNameCache+Generated.swift @@ -10,13 +10,16 @@ // //===----------------------------------------------------------------------===// -// This file is automatically generated with generate-pipeline. Do Not Edit! +// This file is automatically generated with generate-swift-format. Do not edit! /// By default, the `Rule.ruleName` should be the name of the implementing rule type. +@_spi(Testing) public let ruleNameCache: [ObjectIdentifier: String] = [ ObjectIdentifier(AllPublicDeclarationsHaveDocumentation.self): "AllPublicDeclarationsHaveDocumentation", + ObjectIdentifier(AlwaysUseLiteralForEmptyCollectionInit.self): "AlwaysUseLiteralForEmptyCollectionInit", ObjectIdentifier(AlwaysUseLowerCamelCase.self): "AlwaysUseLowerCamelCase", ObjectIdentifier(AmbiguousTrailingClosureOverload.self): "AmbiguousTrailingClosureOverload", + ObjectIdentifier(AvoidRetroactiveConformances.self): "AvoidRetroactiveConformances", ObjectIdentifier(BeginDocumentationCommentWithOneLineSummary.self): "BeginDocumentationCommentWithOneLineSummary", ObjectIdentifier(DoNotUseSemicolons.self): "DoNotUseSemicolons", ObjectIdentifier(DontRepeatTypeInStaticProperties.self): "DontRepeatTypeInStaticProperties", @@ -31,17 +34,23 @@ public let ruleNameCache: [ObjectIdentifier: String] = [ ObjectIdentifier(NoAssignmentInExpressions.self): "NoAssignmentInExpressions", ObjectIdentifier(NoBlockComments.self): "NoBlockComments", ObjectIdentifier(NoCasesWithOnlyFallthrough.self): "NoCasesWithOnlyFallthrough", + ObjectIdentifier(NoEmptyLinesOpeningClosingBraces.self): "NoEmptyLinesOpeningClosingBraces", ObjectIdentifier(NoEmptyTrailingClosureParentheses.self): "NoEmptyTrailingClosureParentheses", ObjectIdentifier(NoLabelsInCasePatterns.self): "NoLabelsInCasePatterns", ObjectIdentifier(NoLeadingUnderscores.self): "NoLeadingUnderscores", ObjectIdentifier(NoParensAroundConditions.self): "NoParensAroundConditions", + ObjectIdentifier(NoPlaygroundLiterals.self): "NoPlaygroundLiterals", ObjectIdentifier(NoVoidReturnOnFunctionSignature.self): "NoVoidReturnOnFunctionSignature", + ObjectIdentifier(OmitExplicitReturns.self): "OmitExplicitReturns", ObjectIdentifier(OneCasePerLine.self): "OneCasePerLine", ObjectIdentifier(OneVariableDeclarationPerLine.self): "OneVariableDeclarationPerLine", ObjectIdentifier(OnlyOneTrailingClosureArgument.self): "OnlyOneTrailingClosureArgument", ObjectIdentifier(OrderedImports.self): "OrderedImports", + ObjectIdentifier(ReplaceForEachWithForLoop.self): "ReplaceForEachWithForLoop", ObjectIdentifier(ReturnVoidInsteadOfEmptyTuple.self): "ReturnVoidInsteadOfEmptyTuple", + ObjectIdentifier(TypeNamesShouldBeCapitalized.self): "TypeNamesShouldBeCapitalized", ObjectIdentifier(UseEarlyExits.self): "UseEarlyExits", + ObjectIdentifier(UseExplicitNilCheckInConditions.self): "UseExplicitNilCheckInConditions", ObjectIdentifier(UseLetInEveryBoundCaseVariable.self): "UseLetInEveryBoundCaseVariable", ObjectIdentifier(UseShorthandTypeNames.self): "UseShorthandTypeNames", ObjectIdentifier(UseSingleLinePropertyGetter.self): "UseSingleLinePropertyGetter", diff --git a/Sources/SwiftFormatConfiguration/RuleRegistry+Generated.swift b/Sources/SwiftFormat/Core/RuleRegistry+Generated.swift similarity index 79% rename from Sources/SwiftFormatConfiguration/RuleRegistry+Generated.swift rename to Sources/SwiftFormat/Core/RuleRegistry+Generated.swift index 6264ade27..d5c9c9ba1 100644 --- a/Sources/SwiftFormatConfiguration/RuleRegistry+Generated.swift +++ b/Sources/SwiftFormat/Core/RuleRegistry+Generated.swift @@ -10,13 +10,15 @@ // //===----------------------------------------------------------------------===// -// This file is automatically generated with generate-pipeline. Do Not Edit! +// This file is automatically generated with generate-swift-format. Do not edit! -enum RuleRegistry { - static let rules: [String: Bool] = [ +@_spi(Internal) public enum RuleRegistry { + public static let rules: [String: Bool] = [ "AllPublicDeclarationsHaveDocumentation": false, + "AlwaysUseLiteralForEmptyCollectionInit": false, "AlwaysUseLowerCamelCase": true, "AmbiguousTrailingClosureOverload": true, + "AvoidRetroactiveConformances": true, "BeginDocumentationCommentWithOneLineSummary": false, "DoNotUseSemicolons": true, "DontRepeatTypeInStaticProperties": true, @@ -31,17 +33,23 @@ enum RuleRegistry { "NoAssignmentInExpressions": true, "NoBlockComments": true, "NoCasesWithOnlyFallthrough": true, + "NoEmptyLinesOpeningClosingBraces": false, "NoEmptyTrailingClosureParentheses": true, "NoLabelsInCasePatterns": true, "NoLeadingUnderscores": false, "NoParensAroundConditions": true, + "NoPlaygroundLiterals": true, "NoVoidReturnOnFunctionSignature": true, + "OmitExplicitReturns": false, "OneCasePerLine": true, "OneVariableDeclarationPerLine": true, "OnlyOneTrailingClosureArgument": true, "OrderedImports": true, + "ReplaceForEachWithForLoop": true, "ReturnVoidInsteadOfEmptyTuple": true, + "TypeNamesShouldBeCapitalized": true, "UseEarlyExits": false, + "UseExplicitNilCheckInConditions": true, "UseLetInEveryBoundCaseVariable": true, "UseShorthandTypeNames": true, "UseSingleLinePropertyGetter": true, diff --git a/Sources/SwiftFormatCore/RuleState.swift b/Sources/SwiftFormat/Core/RuleState.swift similarity index 98% rename from Sources/SwiftFormatCore/RuleState.swift rename to Sources/SwiftFormat/Core/RuleState.swift index 97b49f280..13be8e4da 100644 --- a/Sources/SwiftFormatCore/RuleState.swift +++ b/Sources/SwiftFormat/Core/RuleState.swift @@ -12,6 +12,7 @@ /// The enablement of a lint/format rule based on the presence or absence of comment directives in /// the source file. +@_spi(Testing) public enum RuleState { /// There is no explicit information in the source file about whether the rule should be enabled diff --git a/Sources/SwiftFormatCore/SyntaxFormatRule.swift b/Sources/SwiftFormat/Core/SyntaxFormatRule.swift similarity index 82% rename from Sources/SwiftFormatCore/SyntaxFormatRule.swift rename to Sources/SwiftFormat/Core/SyntaxFormatRule.swift index 4ceb5ce61..92fc7c835 100644 --- a/Sources/SwiftFormatCore/SyntaxFormatRule.swift +++ b/Sources/SwiftFormat/Core/SyntaxFormatRule.swift @@ -13,10 +13,11 @@ import SwiftSyntax /// A rule that both formats and lints a given file. -open class SyntaxFormatRule: SyntaxRewriter, Rule { +@_spi(Rules) +public class SyntaxFormatRule: SyntaxRewriter, Rule { /// Whether this rule is opt-in, meaning it's disabled by default. Rules are opt-out unless they /// override this property. - open class var isOptIn: Bool { + public class var isOptIn: Bool { return false } @@ -28,10 +29,10 @@ open class SyntaxFormatRule: SyntaxRewriter, Rule { self.context = context } - open override func visitAny(_ node: Syntax) -> Syntax? { + public override func visitAny(_ node: Syntax) -> Syntax? { // If the rule is not enabled, then return the node unmodified; otherwise, returning nil tells // SwiftSyntax to continue with the standard dispatch. - guard context.isRuleEnabled(type(of: self), node: node) else { return node } + guard context.shouldFormat(type(of: self), node: node) else { return node } return nil } } diff --git a/Sources/SwiftFormatCore/SyntaxLintRule.swift b/Sources/SwiftFormat/Core/SyntaxLintRule.swift similarity index 90% rename from Sources/SwiftFormatCore/SyntaxLintRule.swift rename to Sources/SwiftFormat/Core/SyntaxLintRule.swift index 3888f5d7a..696cfcb8a 100644 --- a/Sources/SwiftFormatCore/SyntaxLintRule.swift +++ b/Sources/SwiftFormat/Core/SyntaxLintRule.swift @@ -14,10 +14,11 @@ import Foundation import SwiftSyntax /// A rule that lints a given file. -open class SyntaxLintRule: SyntaxVisitor, Rule { +@_spi(Rules) +public class SyntaxLintRule: SyntaxVisitor, Rule { /// Whether this rule is opt-in, meaning it's disabled by default. Rules are opt-out unless they /// override this property. - open class var isOptIn: Bool { + public class var isOptIn: Bool { return false } diff --git a/Sources/SwiftFormat/Core/SyntaxProtocol+Convenience.swift b/Sources/SwiftFormat/Core/SyntaxProtocol+Convenience.swift new file mode 100644 index 000000000..d359a1b23 --- /dev/null +++ b/Sources/SwiftFormat/Core/SyntaxProtocol+Convenience.swift @@ -0,0 +1,176 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2019 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import SwiftSyntax + +extension SyntaxProtocol { + /// Returns the absolute position of the trivia piece at the given index in the receiver's leading + /// trivia collection. + /// + /// If the trivia piece spans multiple characters, the value returned is the position of the first + /// character. + /// + /// - Precondition: `index` is a valid index in the receiver's leading trivia collection. + /// + /// - Parameter index: The index of the trivia piece in the leading trivia whose position should + /// be returned. + /// - Returns: The absolute position of the trivia piece. + func position(ofLeadingTriviaAt index: Trivia.Index) -> AbsolutePosition { + guard leadingTrivia.indices.contains(index) else { + preconditionFailure("Index was out of bounds in the node's leading trivia.") + } + + var offset = SourceLength.zero + for currentIndex in leadingTrivia.startIndex.. AbsolutePosition { + guard trailingTrivia.indices.contains(index) else { + preconditionFailure("Index was out of bounds in the node's trailing trivia.") + } + + var offset = SourceLength.zero + for currentIndex in trailingTrivia.startIndex.. SourceLocation { + return converter.location(for: position(ofLeadingTriviaAt: index)) + } + + /// Returns the source location of the trivia piece at the given index in the receiver's trailing + /// trivia collection. + /// + /// If the trivia piece spans multiple characters, the value returned is the location of the first + /// character. + /// + /// - Precondition: `index` is a valid index in the receiver's trailing trivia collection. + /// + /// - Parameters: + /// - index: The index of the trivia piece in the trailing trivia whose location should be + /// returned. + /// - converter: The `SourceLocationConverter` that was previously initialized using the root + /// tree of this node. + /// - Returns: The source location of the trivia piece. + func startLocation( + ofTrailingTriviaAt index: Trivia.Index, + converter: SourceLocationConverter + ) -> SourceLocation { + return converter.location(for: position(ofTrailingTriviaAt: index)) + } + + /// The collection of all contiguous trivia preceding this node; that is, the trailing trivia of + /// the node before it and the leading trivia of the node itself. + var allPrecedingTrivia: Trivia { + var result: Trivia + if let previousTrailingTrivia = previousToken(viewMode: .sourceAccurate)?.trailingTrivia { + result = previousTrailingTrivia + } else { + result = Trivia() + } + result += leadingTrivia + return result + } + + /// The collection of all contiguous trivia following this node; that is, the trailing trivia of + /// the node and the leading trivia of the node after it. + var allFollowingTrivia: Trivia { + var result = trailingTrivia + if let nextLeadingTrivia = nextToken(viewMode: .sourceAccurate)?.leadingTrivia { + result += nextLeadingTrivia + } + return result + } + + /// Indicates whether the node has any preceding line comments. + /// + /// Due to the way trivia is parsed, a preceding comment might be in either the leading trivia of + /// the node or the trailing trivia of the previous token. + var hasPrecedingLineComment: Bool { + if let previousTrailingTrivia = previousToken(viewMode: .sourceAccurate)?.trailingTrivia, + previousTrailingTrivia.hasLineComment + { + return true + } + return leadingTrivia.hasLineComment + } + + /// Indicates whether the node has any preceding comments of any kind. + /// + /// Due to the way trivia is parsed, a preceding comment might be in either the leading trivia of + /// the node or the trailing trivia of the previous token. + var hasAnyPrecedingComment: Bool { + if let previousTrailingTrivia = previousToken(viewMode: .sourceAccurate)?.trailingTrivia, + previousTrailingTrivia.hasAnyComments + { + return true + } + return leadingTrivia.hasAnyComments + } + + /// Indicates whether the node has any function ancestor marked with `@Test` attribute. + var hasTestAncestor: Bool { + var parent = self.parent + while let existingParent = parent { + if let functionDecl = existingParent.as(FunctionDeclSyntax.self), + functionDecl.hasAttribute("Test", inModule: "Testing") + { + return true + } + parent = existingParent.parent + } + return false + } +} + +extension SyntaxCollection { + /// The first element in the syntax collection if it is the *only* element, or nil otherwise. + var firstAndOnly: Element? { + var iterator = makeIterator() + guard let first = iterator.next() else { return nil } + guard iterator.next() == nil else { return nil } + return first + } +} diff --git a/Sources/SwiftFormat/Core/Trivia+Convenience.swift b/Sources/SwiftFormat/Core/Trivia+Convenience.swift new file mode 100644 index 000000000..c1906ab6c --- /dev/null +++ b/Sources/SwiftFormat/Core/Trivia+Convenience.swift @@ -0,0 +1,92 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2019 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import SwiftSyntax + +extension Trivia { + var hasAnyComments: Bool { + return contains { + switch $0 { + case .lineComment, .docLineComment, .blockComment, .docBlockComment: + return true + default: + return false + } + } + } + + /// Returns whether the trivia contains at least 1 `lineComment`. + var hasLineComment: Bool { + return self.contains { + if case .lineComment = $0 { return true } + return false + } + } + + /// Returns this set of trivia, without any leading spaces. + func withoutLeadingSpaces() -> Trivia { + return Trivia(pieces: self.pieces.drop(while: \.isSpaceOrTab)) + } + + func withoutTrailingSpaces() -> Trivia { + guard let lastNonSpaceIndex = self.pieces.lastIndex(where: \.isSpaceOrTab) else { + return self + } + return Trivia(pieces: self[.. Trivia { + var maybeLastNewlineOffset: Int? = nil + for (offset, piece) in self.enumerated() { + switch piece { + case .newlines, .carriageReturns, .carriageReturnLineFeeds: + maybeLastNewlineOffset = offset + default: + break + } + } + guard let lastNewlineOffset = maybeLastNewlineOffset else { return self } + return Trivia(pieces: self.dropLast(self.count - lastNewlineOffset)) + } + + /// Returns `true` if this trivia contains any newlines. + var containsNewlines: Bool { + return contains( + where: { + if case .newlines = $0 { return true } + return false + }) + } + + /// Returns `true` if this trivia contains any spaces. + var containsSpaces: Bool { + return contains( + where: { + if case .spaces = $0 { return true } + if case .tabs = $0 { return true } + return false + }) + } + + /// Returns `true` if this trivia contains any backslashes (used for multiline string newline + /// suppression). + var containsBackslashes: Bool { + return contains( + where: { + if case .backslashes = $0 { return true } + return false + }) + } +} diff --git a/Sources/SwiftFormat/Core/WithAttributesSyntax+Convenience.swift b/Sources/SwiftFormat/Core/WithAttributesSyntax+Convenience.swift new file mode 100644 index 000000000..f5938d01e --- /dev/null +++ b/Sources/SwiftFormat/Core/WithAttributesSyntax+Convenience.swift @@ -0,0 +1,37 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2019 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import SwiftSyntax + +extension WithAttributesSyntax { + /// Indicates whether the node has attribute with the given `name` and `module`. + /// The `module` is only considered if the attribute is written as `@Module.Attribute`. + /// + /// - Parameter name: The name of the attribute to lookup. + /// - Parameter module: The module name to lookup the attribute in. + /// - Returns: True if the node has an attribute with the given `name`, otherwise false. + func hasAttribute(_ name: String, inModule module: String) -> Bool { + attributes.contains { attribute in + let attributeName = attribute.as(AttributeSyntax.self)?.attributeName + if let identifier = attributeName?.as(IdentifierTypeSyntax.self) { + // @Attribute syntax + return identifier.name.text == name + } + if let memberType = attributeName?.as(MemberTypeSyntax.self) { + // @Module.Attribute syntax + return memberType.name.text == name + && memberType.baseType.as(IdentifierTypeSyntax.self)?.name.text == module + } + return false + } + } +} diff --git a/Sources/SwiftFormatRules/SemicolonSyntaxProtocol.swift b/Sources/SwiftFormat/Core/WithSemicolonSyntax.swift similarity index 53% rename from Sources/SwiftFormatRules/SemicolonSyntaxProtocol.swift rename to Sources/SwiftFormat/Core/WithSemicolonSyntax.swift index efe28452f..42ac7ce42 100644 --- a/Sources/SwiftFormatRules/SemicolonSyntaxProtocol.swift +++ b/Sources/SwiftFormat/Core/WithSemicolonSyntax.swift @@ -13,20 +13,19 @@ import SwiftSyntax /// Protocol that declares support for accessing and modifying a token that represents a semicolon. -protocol SemicolonSyntaxProtocol: SyntaxProtocol { - var semicolon: TokenSyntax? { get } - func withSemicolon(_ newSemicolon: TokenSyntax?) -> Self +protocol WithSemicolonSyntax: SyntaxProtocol { + var semicolon: TokenSyntax? { get set } } -extension MemberDeclListItemSyntax: SemicolonSyntaxProtocol {} -extension CodeBlockItemSyntax: SemicolonSyntaxProtocol {} +extension MemberBlockItemSyntax: WithSemicolonSyntax {} +extension CodeBlockItemSyntax: WithSemicolonSyntax {} -extension Syntax { - func asProtocol(_: SemicolonSyntaxProtocol.Protocol) -> SemicolonSyntaxProtocol? { - return self.asProtocol(SyntaxProtocol.self) as? SemicolonSyntaxProtocol +extension SyntaxProtocol { + func asProtocol(_: WithSemicolonSyntax.Protocol) -> WithSemicolonSyntax? { + return Syntax(self).asProtocol(SyntaxProtocol.self) as? WithSemicolonSyntax } - func isProtocol(_: SemicolonSyntaxProtocol.Protocol) -> Bool { - return self.asProtocol(SemicolonSyntaxProtocol.self) != nil + func isProtocol(_: WithSemicolonSyntax.Protocol) -> Bool { + return self.asProtocol(WithSemicolonSyntax.self) != nil } } diff --git a/Sources/SwiftFormat/Parsing.swift b/Sources/SwiftFormat/Parsing.swift deleted file mode 100644 index a74b32414..000000000 --- a/Sources/SwiftFormat/Parsing.swift +++ /dev/null @@ -1,60 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift.org open source project -// -// Copyright (c) 2014 - 2022 Apple Inc. and the Swift project authors -// Licensed under Apache License v2.0 with Runtime Library Exception -// -// See https://swift.org/LICENSE.txt for license information -// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors -// -//===----------------------------------------------------------------------===// - -import Foundation -import SwiftDiagnostics -import SwiftFormatCore -import SwiftOperators -import SwiftParser -import SwiftParserDiagnostics -import SwiftSyntax - -/// Parses the given source code and returns a valid `SourceFileSyntax` node. -/// -/// This helper function automatically folds sequence expressions using the given operator table, -/// ignoring errors so that formatting can do something reasonable in the presence of unrecognized -/// operators. -/// -/// - Parameters: -/// - source: The Swift source code to be formatted. -/// - url: A file URL denoting the filename/path that should be assumed for this syntax tree, -/// which is associated with any diagnostics emitted during formatting. If this is nil, a -/// dummy value will be used. -/// - operatorTable: The operator table to use for sequence folding. -/// - parsingDiagnosticHandler: An optional callback that will be notified if there are any -/// errors when parsing the source code. -/// - Throws: If an unrecoverable error occurs when formatting the code. -func parseAndEmitDiagnostics( - source: String, - operatorTable: OperatorTable, - assumingFileURL url: URL?, - parsingDiagnosticHandler: ((Diagnostic, SourceLocation) -> Void)? = nil -) throws -> SourceFileSyntax { - let sourceFile = - operatorTable.foldAll(Parser.parse(source: source)) { _ in }.as(SourceFileSyntax.self)! - - let diagnostics = ParseDiagnosticsGenerator.diagnostics(for: sourceFile) - if let parsingDiagnosticHandler = parsingDiagnosticHandler { - let expectedConverter = - SourceLocationConverter(file: url?.path ?? "", tree: sourceFile) - for diagnostic in diagnostics { - let location = diagnostic.location(converter: expectedConverter) - parsingDiagnosticHandler(diagnostic, location) - } - } - - guard diagnostics.isEmpty else { - throw SwiftFormatError.fileContainsInvalidSyntax - } - - return restoringLegacyTriviaBehavior(sourceFile) -} diff --git a/Sources/SwiftFormat/Pipelines+Generated.swift b/Sources/SwiftFormat/Pipelines+Generated.swift deleted file mode 100644 index 6701a0a96..000000000 --- a/Sources/SwiftFormat/Pipelines+Generated.swift +++ /dev/null @@ -1,338 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift.org open source project -// -// Copyright (c) 2014 - 2019 Apple Inc. and the Swift project authors -// Licensed under Apache License v2.0 with Runtime Library Exception -// -// See https://swift.org/LICENSE.txt for license information -// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors -// -//===----------------------------------------------------------------------===// - -// This file is automatically generated with generate-pipeline. Do Not Edit! - -import SwiftFormatCore -import SwiftFormatRules -import SwiftSyntax - -/// A syntax visitor that delegates to individual rules for linting. -/// -/// This file will be extended with `visit` methods in Pipelines+Generated.swift. -class LintPipeline: SyntaxVisitor { - - /// The formatter context. - let context: Context - - /// Stores lint and format rule instances, indexed by the `ObjectIdentifier` of a rule's - /// class type. - var ruleCache = [ObjectIdentifier: Rule]() - - /// Creates a new lint pipeline. - init(context: Context) { - self.context = context - super.init(viewMode: .sourceAccurate) - } - - override func visit(_ node: AsExprSyntax) -> SyntaxVisitorContinueKind { - visitIfEnabled(NeverForceUnwrap.visit, for: node) - return .visitChildren - } - - override func visit(_ node: AssociatedtypeDeclSyntax) -> SyntaxVisitorContinueKind { - visitIfEnabled(BeginDocumentationCommentWithOneLineSummary.visit, for: node) - visitIfEnabled(NoLeadingUnderscores.visit, for: node) - return .visitChildren - } - - override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind { - visitIfEnabled(AllPublicDeclarationsHaveDocumentation.visit, for: node) - visitIfEnabled(AlwaysUseLowerCamelCase.visit, for: node) - visitIfEnabled(BeginDocumentationCommentWithOneLineSummary.visit, for: node) - visitIfEnabled(DontRepeatTypeInStaticProperties.visit, for: node) - visitIfEnabled(NoLeadingUnderscores.visit, for: node) - visitIfEnabled(UseTripleSlashForDocumentationComments.visit, for: node) - return .visitChildren - } - - override func visit(_ node: ClosureSignatureSyntax) -> SyntaxVisitorContinueKind { - visitIfEnabled(AlwaysUseLowerCamelCase.visit, for: node) - visitIfEnabled(ReturnVoidInsteadOfEmptyTuple.visit, for: node) - return .visitChildren - } - - override func visit(_ node: CodeBlockItemListSyntax) -> SyntaxVisitorContinueKind { - visitIfEnabled(DoNotUseSemicolons.visit, for: node) - visitIfEnabled(NoAssignmentInExpressions.visit, for: node) - visitIfEnabled(OneVariableDeclarationPerLine.visit, for: node) - visitIfEnabled(UseEarlyExits.visit, for: node) - return .visitChildren - } - - override func visit(_ node: CodeBlockSyntax) -> SyntaxVisitorContinueKind { - visitIfEnabled(AmbiguousTrailingClosureOverload.visit, for: node) - return .visitChildren - } - - override func visit(_ node: ConditionElementSyntax) -> SyntaxVisitorContinueKind { - visitIfEnabled(NoParensAroundConditions.visit, for: node) - return .visitChildren - } - - override func visit(_ node: DeinitializerDeclSyntax) -> SyntaxVisitorContinueKind { - visitIfEnabled(AllPublicDeclarationsHaveDocumentation.visit, for: node) - visitIfEnabled(BeginDocumentationCommentWithOneLineSummary.visit, for: node) - visitIfEnabled(UseTripleSlashForDocumentationComments.visit, for: node) - return .visitChildren - } - - override func visit(_ node: EnumCaseElementSyntax) -> SyntaxVisitorContinueKind { - visitIfEnabled(AlwaysUseLowerCamelCase.visit, for: node) - visitIfEnabled(NoLeadingUnderscores.visit, for: node) - return .visitChildren - } - - override func visit(_ node: EnumDeclSyntax) -> SyntaxVisitorContinueKind { - visitIfEnabled(BeginDocumentationCommentWithOneLineSummary.visit, for: node) - visitIfEnabled(DontRepeatTypeInStaticProperties.visit, for: node) - visitIfEnabled(FullyIndirectEnum.visit, for: node) - visitIfEnabled(NoLeadingUnderscores.visit, for: node) - visitIfEnabled(OneCasePerLine.visit, for: node) - visitIfEnabled(UseTripleSlashForDocumentationComments.visit, for: node) - return .visitChildren - } - - override func visit(_ node: ExtensionDeclSyntax) -> SyntaxVisitorContinueKind { - visitIfEnabled(DontRepeatTypeInStaticProperties.visit, for: node) - visitIfEnabled(NoAccessLevelOnExtensionDeclaration.visit, for: node) - visitIfEnabled(UseTripleSlashForDocumentationComments.visit, for: node) - return .visitChildren - } - - override func visit(_ node: ForInStmtSyntax) -> SyntaxVisitorContinueKind { - visitIfEnabled(UseWhereClausesInForLoops.visit, for: node) - return .visitChildren - } - - override func visit(_ node: ForcedValueExprSyntax) -> SyntaxVisitorContinueKind { - visitIfEnabled(NeverForceUnwrap.visit, for: node) - return .visitChildren - } - - override func visit(_ node: FunctionCallExprSyntax) -> SyntaxVisitorContinueKind { - visitIfEnabled(NoEmptyTrailingClosureParentheses.visit, for: node) - visitIfEnabled(OnlyOneTrailingClosureArgument.visit, for: node) - return .visitChildren - } - - override func visit(_ node: FunctionDeclSyntax) -> SyntaxVisitorContinueKind { - visitIfEnabled(AllPublicDeclarationsHaveDocumentation.visit, for: node) - visitIfEnabled(AlwaysUseLowerCamelCase.visit, for: node) - visitIfEnabled(BeginDocumentationCommentWithOneLineSummary.visit, for: node) - visitIfEnabled(NoLeadingUnderscores.visit, for: node) - visitIfEnabled(UseTripleSlashForDocumentationComments.visit, for: node) - visitIfEnabled(ValidateDocumentationComments.visit, for: node) - return .visitChildren - } - - override func visit(_ node: FunctionParameterSyntax) -> SyntaxVisitorContinueKind { - visitIfEnabled(NoLeadingUnderscores.visit, for: node) - return .visitChildren - } - - override func visit(_ node: FunctionSignatureSyntax) -> SyntaxVisitorContinueKind { - visitIfEnabled(NoVoidReturnOnFunctionSignature.visit, for: node) - return .visitChildren - } - - override func visit(_ node: FunctionTypeSyntax) -> SyntaxVisitorContinueKind { - visitIfEnabled(ReturnVoidInsteadOfEmptyTuple.visit, for: node) - return .visitChildren - } - - override func visit(_ node: GenericParameterSyntax) -> SyntaxVisitorContinueKind { - visitIfEnabled(NoLeadingUnderscores.visit, for: node) - return .visitChildren - } - - override func visit(_ node: IdentifierPatternSyntax) -> SyntaxVisitorContinueKind { - visitIfEnabled(IdentifiersMustBeASCII.visit, for: node) - visitIfEnabled(NoLeadingUnderscores.visit, for: node) - return .visitChildren - } - - override func visit(_ node: IfStmtSyntax) -> SyntaxVisitorContinueKind { - visitIfEnabled(NoParensAroundConditions.visit, for: node) - return .visitChildren - } - - override func visit(_ node: InfixOperatorExprSyntax) -> SyntaxVisitorContinueKind { - visitIfEnabled(NoAssignmentInExpressions.visit, for: node) - return .visitChildren - } - - override func visit(_ node: InitializerDeclSyntax) -> SyntaxVisitorContinueKind { - visitIfEnabled(AllPublicDeclarationsHaveDocumentation.visit, for: node) - visitIfEnabled(BeginDocumentationCommentWithOneLineSummary.visit, for: node) - visitIfEnabled(UseTripleSlashForDocumentationComments.visit, for: node) - visitIfEnabled(ValidateDocumentationComments.visit, for: node) - return .visitChildren - } - - override func visit(_ node: IntegerLiteralExprSyntax) -> SyntaxVisitorContinueKind { - visitIfEnabled(GroupNumericLiterals.visit, for: node) - return .visitChildren - } - - override func visit(_ node: MemberDeclBlockSyntax) -> SyntaxVisitorContinueKind { - visitIfEnabled(AmbiguousTrailingClosureOverload.visit, for: node) - return .visitChildren - } - - override func visit(_ node: MemberDeclListSyntax) -> SyntaxVisitorContinueKind { - visitIfEnabled(DoNotUseSemicolons.visit, for: node) - return .visitChildren - } - - override func visit(_ node: OptionalBindingConditionSyntax) -> SyntaxVisitorContinueKind { - visitIfEnabled(AlwaysUseLowerCamelCase.visit, for: node) - return .visitChildren - } - - override func visit(_ node: PatternBindingSyntax) -> SyntaxVisitorContinueKind { - visitIfEnabled(UseSingleLinePropertyGetter.visit, for: node) - return .visitChildren - } - - override func visit(_ node: PrecedenceGroupDeclSyntax) -> SyntaxVisitorContinueKind { - visitIfEnabled(NoLeadingUnderscores.visit, for: node) - return .visitChildren - } - - override func visit(_ node: ProtocolDeclSyntax) -> SyntaxVisitorContinueKind { - visitIfEnabled(AllPublicDeclarationsHaveDocumentation.visit, for: node) - visitIfEnabled(BeginDocumentationCommentWithOneLineSummary.visit, for: node) - visitIfEnabled(DontRepeatTypeInStaticProperties.visit, for: node) - visitIfEnabled(NoLeadingUnderscores.visit, for: node) - visitIfEnabled(UseTripleSlashForDocumentationComments.visit, for: node) - return .visitChildren - } - - override func visit(_ node: RepeatWhileStmtSyntax) -> SyntaxVisitorContinueKind { - visitIfEnabled(NoParensAroundConditions.visit, for: node) - return .visitChildren - } - - override func visit(_ node: SimpleTypeIdentifierSyntax) -> SyntaxVisitorContinueKind { - visitIfEnabled(UseShorthandTypeNames.visit, for: node) - return .visitChildren - } - - override func visit(_ node: SourceFileSyntax) -> SyntaxVisitorContinueKind { - visitIfEnabled(AlwaysUseLowerCamelCase.visit, for: node) - visitIfEnabled(AmbiguousTrailingClosureOverload.visit, for: node) - visitIfEnabled(FileScopedDeclarationPrivacy.visit, for: node) - visitIfEnabled(NeverForceUnwrap.visit, for: node) - visitIfEnabled(NeverUseForceTry.visit, for: node) - visitIfEnabled(NeverUseImplicitlyUnwrappedOptionals.visit, for: node) - visitIfEnabled(OrderedImports.visit, for: node) - return .visitChildren - } - - override func visit(_ node: SpecializeExprSyntax) -> SyntaxVisitorContinueKind { - visitIfEnabled(UseShorthandTypeNames.visit, for: node) - return .visitChildren - } - - override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind { - visitIfEnabled(AllPublicDeclarationsHaveDocumentation.visit, for: node) - visitIfEnabled(BeginDocumentationCommentWithOneLineSummary.visit, for: node) - visitIfEnabled(DontRepeatTypeInStaticProperties.visit, for: node) - visitIfEnabled(NoLeadingUnderscores.visit, for: node) - visitIfEnabled(UseSynthesizedInitializer.visit, for: node) - visitIfEnabled(UseTripleSlashForDocumentationComments.visit, for: node) - return .visitChildren - } - - override func visit(_ node: SubscriptDeclSyntax) -> SyntaxVisitorContinueKind { - visitIfEnabled(AllPublicDeclarationsHaveDocumentation.visit, for: node) - visitIfEnabled(BeginDocumentationCommentWithOneLineSummary.visit, for: node) - visitIfEnabled(UseTripleSlashForDocumentationComments.visit, for: node) - return .visitChildren - } - - override func visit(_ node: SwitchCaseLabelSyntax) -> SyntaxVisitorContinueKind { - visitIfEnabled(NoLabelsInCasePatterns.visit, for: node) - return .visitChildren - } - - override func visit(_ node: SwitchCaseListSyntax) -> SyntaxVisitorContinueKind { - visitIfEnabled(NoCasesWithOnlyFallthrough.visit, for: node) - return .visitChildren - } - - override func visit(_ node: SwitchStmtSyntax) -> SyntaxVisitorContinueKind { - visitIfEnabled(NoParensAroundConditions.visit, for: node) - return .visitChildren - } - - override func visit(_ node: TokenSyntax) -> SyntaxVisitorContinueKind { - visitIfEnabled(NoBlockComments.visit, for: node) - return .visitChildren - } - - override func visit(_ node: TryExprSyntax) -> SyntaxVisitorContinueKind { - visitIfEnabled(NeverUseForceTry.visit, for: node) - return .visitChildren - } - - override func visit(_ node: TypealiasDeclSyntax) -> SyntaxVisitorContinueKind { - visitIfEnabled(AllPublicDeclarationsHaveDocumentation.visit, for: node) - visitIfEnabled(BeginDocumentationCommentWithOneLineSummary.visit, for: node) - visitIfEnabled(NoLeadingUnderscores.visit, for: node) - visitIfEnabled(UseTripleSlashForDocumentationComments.visit, for: node) - return .visitChildren - } - - override func visit(_ node: ValueBindingPatternSyntax) -> SyntaxVisitorContinueKind { - visitIfEnabled(UseLetInEveryBoundCaseVariable.visit, for: node) - return .visitChildren - } - - override func visit(_ node: VariableDeclSyntax) -> SyntaxVisitorContinueKind { - visitIfEnabled(AllPublicDeclarationsHaveDocumentation.visit, for: node) - visitIfEnabled(AlwaysUseLowerCamelCase.visit, for: node) - visitIfEnabled(BeginDocumentationCommentWithOneLineSummary.visit, for: node) - visitIfEnabled(NeverUseImplicitlyUnwrappedOptionals.visit, for: node) - visitIfEnabled(UseTripleSlashForDocumentationComments.visit, for: node) - return .visitChildren - } -} - -extension FormatPipeline { - - func visit(_ node: Syntax) -> Syntax { - var node = node - node = DoNotUseSemicolons(context: context).visit(node) - node = FileScopedDeclarationPrivacy(context: context).visit(node) - node = FullyIndirectEnum(context: context).visit(node) - node = GroupNumericLiterals(context: context).visit(node) - node = NoAccessLevelOnExtensionDeclaration(context: context).visit(node) - node = NoAssignmentInExpressions(context: context).visit(node) - node = NoCasesWithOnlyFallthrough(context: context).visit(node) - node = NoEmptyTrailingClosureParentheses(context: context).visit(node) - node = NoLabelsInCasePatterns(context: context).visit(node) - node = NoParensAroundConditions(context: context).visit(node) - node = NoVoidReturnOnFunctionSignature(context: context).visit(node) - node = OneCasePerLine(context: context).visit(node) - node = OneVariableDeclarationPerLine(context: context).visit(node) - node = OrderedImports(context: context).visit(node) - node = ReturnVoidInsteadOfEmptyTuple(context: context).visit(node) - node = UseEarlyExits(context: context).visit(node) - node = UseShorthandTypeNames(context: context).visit(node) - node = UseSingleLinePropertyGetter(context: context).visit(node) - node = UseTripleSlashForDocumentationComments(context: context).visit(node) - node = UseWhereClausesInForLoops(context: context).visit(node) - return node - } -} diff --git a/Sources/SwiftFormatPrettyPrint/Comment.swift b/Sources/SwiftFormat/PrettyPrint/Comment.swift similarity index 72% rename from Sources/SwiftFormatPrettyPrint/Comment.swift rename to Sources/SwiftFormat/PrettyPrint/Comment.swift index ee85cec48..43616a5b4 100644 --- a/Sources/SwiftFormatPrettyPrint/Comment.swift +++ b/Sources/SwiftFormat/PrettyPrint/Comment.swift @@ -11,7 +11,6 @@ //===----------------------------------------------------------------------===// import Foundation -import SwiftFormatConfiguration import SwiftSyntax extension StringProtocol { @@ -59,15 +58,18 @@ struct Comment { let kind: Kind var text: [String] var length: Int + // what was the leading indentation, if any, that preceded this comment? + var leadingIndent: Indent? - init(kind: Kind, text: String) { + init(kind: Kind, leadingIndent: Indent?, text: String) { self.kind = kind + self.leadingIndent = leadingIndent switch kind { case .line, .docLine: - self.text = [text.trimmingTrailingWhitespace()] + self.length = text.count + self.text = [text] self.text[0].removeFirst(kind.prefixLength) - self.length = self.text.reduce(0, { $0 + $1.count + kind.prefixLength + 1 }) case .block, .docBlock: var fulltext: String = text @@ -89,10 +91,30 @@ struct Comment { func print(indent: [Indent]) -> String { switch self.kind { case .line, .docLine: - let separator = "\n" + kind.prefix - return kind.prefix + self.text.joined(separator: separator) + let separator = "\n" + indent.indentation() + kind.prefix + let trimmedLines = self.text.map { $0.trimmingTrailingWhitespace() } + return kind.prefix + trimmedLines.joined(separator: separator) case .block, .docBlock: let separator = "\n" + + // if all the lines after the first matching leadingIndent, replace that prefix with the + // current indentation level + if let leadingIndent { + let rest = self.text.dropFirst() + + let hasLeading = rest.allSatisfy { + let result = $0.hasPrefix(leadingIndent.text) || $0.isEmpty + return result + } + if hasLeading, let first = self.text.first, !rest.isEmpty { + let restStr = rest.map { + let stripped = $0.dropFirst(leadingIndent.text.count) + return indent.indentation() + stripped + }.joined(separator: separator) + return kind.prefix + first + separator + restStr + "*/" + } + } + return kind.prefix + self.text.joined(separator: separator) + "*/" } } diff --git a/Sources/SwiftFormatPrettyPrint/Indent+Length.swift b/Sources/SwiftFormat/PrettyPrint/Indent+Length.swift similarity index 79% rename from Sources/SwiftFormatPrettyPrint/Indent+Length.swift rename to Sources/SwiftFormat/PrettyPrint/Indent+Length.swift index e326894fb..3094735be 100644 --- a/Sources/SwiftFormatPrettyPrint/Indent+Length.swift +++ b/Sources/SwiftFormat/PrettyPrint/Indent+Length.swift @@ -10,8 +10,6 @@ // //===----------------------------------------------------------------------===// -import SwiftFormatConfiguration - extension Indent { var character: Character { switch self { @@ -24,10 +22,10 @@ extension Indent { return String(repeating: character, count: count) } - func length(in configuration: Configuration) -> Int { + func length(tabWidth: Int) -> Int { switch self { case .spaces(let count): return count - case .tabs(let count): return count * configuration.tabWidth + case .tabs(let count): return count * tabWidth } } } @@ -38,6 +36,10 @@ extension Array where Element == Indent { } func length(in configuration: Configuration) -> Int { - return reduce(into: 0) { $0 += $1.length(in: configuration) } + return self.length(tabWidth: configuration.tabWidth) + } + + func length(tabWidth: Int) -> Int { + return reduce(into: 0) { $0 += $1.length(tabWidth: tabWidth) } } } diff --git a/Sources/SwiftFormatPrettyPrint/PrettyPrint.swift b/Sources/SwiftFormat/PrettyPrint/PrettyPrint.swift similarity index 76% rename from Sources/SwiftFormatPrettyPrint/PrettyPrint.swift rename to Sources/SwiftFormat/PrettyPrint/PrettyPrint.swift index a85173487..607364ee8 100644 --- a/Sources/SwiftFormatPrettyPrint/PrettyPrint.swift +++ b/Sources/SwiftFormat/PrettyPrint/PrettyPrint.swift @@ -10,12 +10,12 @@ // //===----------------------------------------------------------------------===// -import SwiftFormatConfiguration -import SwiftFormatCore +import Foundation import SwiftSyntax /// PrettyPrinter takes a Syntax node and outputs a well-formatted, re-indented reproduction of the /// code as a String. +@_spi(Testing) public class PrettyPrinter { /// Information about an open break that has not yet been closed during the printing stage. @@ -67,10 +67,22 @@ public class PrettyPrinter { private var configuration: Configuration { return context.configuration } private let maxLineLength: Int private var tokens: [Token] - private var outputBuffer: String = "" + private var source: String - /// The number of spaces remaining on the current line. - private var spaceRemaining: Int + /// Keep track of where formatting was disabled in the original source + /// + /// To format a selection, we insert `enableFormatting`/`disableFormatting` tokens into the + /// stream when entering/exiting a selection range. Those tokens include utf8 offsets into the + /// original source. When enabling formatting, we copy the text between `disabledPosition` and the + /// current position to `outputBuffer`. From then on, we continue to format until the next + /// `disableFormatting` token. + private var disabledPosition: AbsolutePosition? = nil { + didSet { + outputBuffer.isEnabled = disabledPosition == nil + } + } + + private var outputBuffer: PrettyPrintBuffer /// Keep track of the token lengths. private var lengths = [Int]() @@ -92,7 +104,11 @@ public class PrettyPrinter { /// Keeps track of the line numbers and indentation states of the open (and unclosed) breaks seen /// so far. - private var activeOpenBreaks: [ActiveOpenBreak] = [] + private var activeOpenBreaks: [ActiveOpenBreak] = [] { + didSet { + outputBuffer.currentIndentation = currentIndentation + } + } /// Stack of the active breaking contexts. private var activeBreakingContexts: [ActiveBreakingContext] = [] @@ -100,11 +116,14 @@ public class PrettyPrinter { /// The most recently ended breaking context, used to force certain following `contextual` breaks. private var lastEndedBreakingContext: ActiveBreakingContext? = nil - /// Keeps track of the current line number being printed. - private var lineNumber: Int = 1 - /// Indicates whether or not the current line being printed is a continuation line. - private var currentLineIsContinuation = false + private var currentLineIsContinuation = false { + didSet { + if oldValue != currentLineIsContinuation { + outputBuffer.currentIndentation = currentIndentation + } + } + } /// Keeps track of the continuation line state as you go into and out of open-close break groups. private var continuationStack: [Bool] = [] @@ -113,33 +132,27 @@ public class PrettyPrinter { /// corresponding end token are encountered. private var commaDelimitedRegionStack: [Int] = [] - /// Keeps track of the most recent number of consecutive newlines that have been printed. - /// - /// This value is reset to zero whenever non-newline content is printed. - private var consecutiveNewlineCount = 0 - - /// Keeps track of the most recent number of spaces that should be printed before the next text - /// token. - private var pendingSpaces = 0 - - /// Indicates whether or not the printer is currently at the beginning of a line. - private var isAtStartOfLine = true - /// Tracks how many printer control tokens to suppress firing breaks are active. private var activeBreakSuppressionCount = 0 /// Whether breaks are supressed from firing. When true, no breaks should fire and the only way to - /// move to a new line is an explicit new line token. - private var isBreakingSupressed: Bool { + /// move to a new line is an explicit new line token. Discretionary breaks aren't suppressed + /// if ``allowSuppressedDiscretionaryBreaks`` is true. + private var isBreakingSuppressed: Bool { return activeBreakSuppressionCount > 0 } + /// Indicates whether discretionary breaks should still be included even if break suppression is + /// enabled (see ``isBreakingSuppressed``). + private var allowSuppressedDiscretionaryBreaks = false + /// The computed indentation level, as a number of spaces, based on the state of any unclosed /// delimiters and whether or not the current line is a continuation line. private var currentIndentation: [Indent] { let indentation = configuration.indentation var totalIndentation: [Indent] = activeOpenBreaks.flatMap { (open) -> [Indent] in - let count = (open.contributesBlockIndent ? 1 : 0) + let count = + (open.contributesBlockIndent ? 1 : 0) + (open.contributesContinuationIndent ? 1 : 0) return Array(repeating: indentation, count: count) } @@ -157,7 +170,7 @@ public class PrettyPrinter { /// line number to increase by one by the time we reach the break, when we really wish to consider /// the break as being located at the end of the previous line. private var openCloseBreakCompensatingLineNumber: Int { - return isAtStartOfLine ? lineNumber - 1 : lineNumber + return outputBuffer.lineNumber - (outputBuffer.isAtStartOfLine ? 1 : 0) } /// Creates a new PrettyPrinter with the provided formatting configuration. @@ -168,81 +181,22 @@ public class PrettyPrinter { /// - printTokenStream: Indicates whether debug information about the token stream should be /// printed to standard output. /// - whitespaceOnly: Whether only whitespace changes should be made. - public init(context: Context, node: Syntax, printTokenStream: Bool, whitespaceOnly: Bool) { + public init(context: Context, source: String, node: Syntax, printTokenStream: Bool, whitespaceOnly: Bool) { self.context = context + self.source = source let configuration = context.configuration self.tokens = node.makeTokenStream( - configuration: configuration, operatorTable: context.operatorTable) + configuration: configuration, + selection: context.selection, + operatorTable: context.operatorTable + ) self.maxLineLength = configuration.lineLength - self.spaceRemaining = self.maxLineLength self.printTokenStream = printTokenStream self.whitespaceOnly = whitespaceOnly - } - - /// Append the given string to the output buffer. - /// - /// No further processing is performed on the string. - private func writeRaw(_ str: S) { - outputBuffer.append(String(str)) - } - - /// Writes newlines into the output stream, taking into account any preexisting consecutive - /// newlines and the maximum allowed number of blank lines. - /// - /// This function does some implicit collapsing of consecutive newlines to ensure that the - /// results are consistent when breaks and explicit newlines coincide. For example, imagine a - /// break token that fires (thus creating a single non-discretionary newline) because it is - /// followed by a group that contains 2 discretionary newlines that were found in the user's - /// source code at that location. In that case, the break "overlaps" with the discretionary - /// newlines and it will write a newline before we get to the discretionaries. Thus, we have to - /// subtract the previously written newlines during the second call so that we end up with the - /// correct number overall. - /// - /// - Parameter newlines: The number and type of newlines to write. - private func writeNewlines(_ newlines: NewlineBehavior) { - let numberToPrint: Int - switch newlines { - case .elective: - numberToPrint = consecutiveNewlineCount == 0 ? 1 : 0 - case .soft(let count, _): - // We add 1 to the max blank lines because it takes 2 newlines to create the first blank line. - numberToPrint = min(count, configuration.maximumBlankLines + 1) - consecutiveNewlineCount - case .hard(let count): - numberToPrint = count - } - - guard numberToPrint > 0 else { return } - writeRaw(String(repeating: "\n", count: numberToPrint)) - lineNumber += numberToPrint - isAtStartOfLine = true - consecutiveNewlineCount += numberToPrint - pendingSpaces = 0 - } - - /// Request that the given number of spaces be printed out before the next text token. - /// - /// Spaces are printed only when the next text token is printed in order to prevent us from - /// printing lines that are only whitespace or have trailing whitespace. - private func enqueueSpaces(_ count: Int) { - pendingSpaces += count - spaceRemaining -= count - } - - /// Writes the given text to the output stream. - /// - /// Before printing the text, this function will print any line-leading indentation or interior - /// leading spaces that are required before the text itself. - private func write(_ text: String) { - if isAtStartOfLine { - writeRaw(currentIndentation.indentation()) - spaceRemaining = maxLineLength - currentIndentation.length(in: configuration) - isAtStartOfLine = false - } else if pendingSpaces > 0 { - writeRaw(String(repeating: " ", count: pendingSpaces)) - } - writeRaw(text) - consecutiveNewlineCount = 0 - pendingSpaces = 0 + self.outputBuffer = PrettyPrintBuffer( + maximumBlankLines: configuration.maximumBlankLines, + tabWidth: configuration.tabWidth + ) } /// Print out the provided token, and apply line-wrapping and indentation as needed. @@ -264,8 +218,7 @@ public class PrettyPrinter { switch token { case .contextualBreakingStart: - activeBreakingContexts.append(ActiveBreakingContext(lineNumber: lineNumber)) - + activeBreakingContexts.append(ActiveBreakingContext(lineNumber: outputBuffer.lineNumber)) // Discard the last finished breaking context to keep it from effecting breaks inside of the // new context. The discarded context has already either had an impact on the contextual break // after it or there was no relevant contextual break, so it's safe to discard. @@ -285,7 +238,7 @@ public class PrettyPrinter { // the group. case .open(let breaktype): // Determine if the break tokens in this group need to be forced. - if (length > spaceRemaining || lastBreak), case .consistent = breaktype { + if (!canFit(length) || lastBreak), case .consistent = breaktype { forceBreakStack.append(true) } else { forceBreakStack.append(false) @@ -326,8 +279,9 @@ public class PrettyPrinter { // lines within it (unless they are themselves continuations within that particular // scope), so we need the continuation indentation to persist across all the lines in that // scope. Additionally, continuation open breaks must indent when the break fires. - let continuationBreakWillFire = openKind == .continuation - && (isAtStartOfLine || length > spaceRemaining || mustBreak) + let continuationBreakWillFire = + openKind == .continuation + && (outputBuffer.isAtStartOfLine || !canFit(length) || mustBreak) let contributesContinuationIndent = currentLineIsContinuation || continuationBreakWillFire activeOpenBreaks.append( @@ -336,7 +290,9 @@ public class PrettyPrinter { kind: openKind, lineNumber: currentLineNumber, contributesContinuationIndent: contributesContinuationIndent, - contributesBlockIndent: openKind == .block)) + contributesBlockIndent: openKind == .block + ) + ) continuationStack.append(currentLineIsContinuation) @@ -350,13 +306,12 @@ public class PrettyPrinter { fatalError("Unmatched closing break") } - let openedOnDifferentLine - = openCloseBreakCompensatingLineNumber != matchingOpenBreak.lineNumber + let openedOnDifferentLine = openCloseBreakCompensatingLineNumber != matchingOpenBreak.lineNumber if matchingOpenBreak.contributesBlockIndent { // The actual line number is used, instead of the compensating line number. When the close // break is at the start of a new line, the block indentation isn't carried to the new line. - let currentLine = lineNumber + let currentLine = outputBuffer.lineNumber // When two or more open breaks are encountered on the same line, only the final open // break is allowed to increase the block indent, avoiding multiple block indents. As the // open breaks on that line are closed, the new final open break must be enabled again to @@ -374,7 +329,7 @@ public class PrettyPrinter { // If it's a mandatory breaking close, then we must break (regardless of line length) if // the break is on a different line than its corresponding open break. mustBreak = openedOnDifferentLine - } else if spaceRemaining == 0 { + } else if !canFit() { // If there is no room left on the line, then we must force this break to fire so that the // next token that comes along (typically a closing bracket of some kind) ends up on the // next line. @@ -402,12 +357,14 @@ public class PrettyPrinter { // // Likewise, we need to do this if we popped an old continuation state off the stack, // even if the break *doesn't* fire. - let matchingOpenBreakIndented = matchingOpenBreak.contributesContinuationIndent + let matchingOpenBreakIndented = + matchingOpenBreak.contributesContinuationIndent || matchingOpenBreak.contributesBlockIndent currentLineIsContinuation = matchingOpenBreakIndented && openedOnDifferentLine } - let wasContinuationWhenOpened = (continuationStack.popLast() ?? false) + let wasContinuationWhenOpened = + (continuationStack.popLast() ?? false) || matchingOpenBreak.contributesContinuationIndent // This ensures a continuation indent is propagated to following scope when an initial // scope would've indented if the leading break wasn't at the start of a line. @@ -432,13 +389,13 @@ public class PrettyPrinter { // context includes a multiline trailing closure or multiline function argument list. if let lastBreakingContext = lastEndedBreakingContext { if configuration.lineBreakAroundMultilineExpressionChainComponents { - mustBreak = lastBreakingContext.lineNumber != lineNumber + mustBreak = lastBreakingContext.lineNumber != outputBuffer.lineNumber } } // Wait for a contextual break to fire and then update the breaking behavior for the rest of // the contextual breaks in this scope to match the behavior of the one that fired. - let willFire = (!isAtStartOfLine && length > spaceRemaining) || mustBreak + let willFire = !canFit(length) || mustBreak if willFire { // Update the active breaking context according to the most recently finished breaking // context so all following contextual breaks in this scope to have matching behavior. @@ -447,7 +404,7 @@ public class PrettyPrinter { case .unset = activeContext.contextualBreakingBehavior { activeBreakingContexts[activeBreakingContexts.count - 1].contextualBreakingBehavior = - (closedContext.lineNumber == lineNumber) ? .continuation : .maintain + (closedContext.lineNumber == outputBuffer.lineNumber) ? .continuation : .maintain } } @@ -465,11 +422,11 @@ public class PrettyPrinter { var overrideBreakingSuppressed = false switch newline { - case .elective: break + case .elective, .escaped: break case .soft(_, let discretionary): // A discretionary newline (i.e. from the source) should create a line break even if the // rules for breaking are disabled. - overrideBreakingSuppressed = discretionary + overrideBreakingSuppressed = discretionary && allowSuppressedDiscretionaryBreaks mustBreak = true case .hard: // A hard newline must always create a line break, regardless of the context. @@ -477,13 +434,17 @@ public class PrettyPrinter { mustBreak = true } - let suppressBreaking = isBreakingSupressed && !overrideBreakingSuppressed - if !suppressBreaking && ((!isAtStartOfLine && length > spaceRemaining) || mustBreak) { + let suppressBreaking = isBreakingSuppressed && !overrideBreakingSuppressed + if !suppressBreaking && (!canFit(length) || mustBreak) { currentLineIsContinuation = isContinuationIfBreakFires - writeNewlines(newline) + if case .escaped = newline { + outputBuffer.enqueueSpaces(size) + outputBuffer.write("\\") + } + outputBuffer.writeNewlines(newline, shouldIndentBlankLines: configuration.indentBlankLines) lastBreak = true } else { - if isAtStartOfLine { + if outputBuffer.isAtStartOfLine { // Make sure that the continuation status is correct even at the beginning of a line // (for example, after a newline token). This is necessary because a discretionary newline // might be inserted into the token stream before a continuation break, and the length of @@ -491,44 +452,48 @@ public class PrettyPrinter { // treat the line as a continuation. currentLineIsContinuation = isContinuationIfBreakFires } - enqueueSpaces(size) + outputBuffer.enqueueSpaces(size) lastBreak = false } // Print out the number of spaces according to the size, and adjust spaceRemaining. case .space(let size, _): - enqueueSpaces(size) + if configuration.indentBlankLines, outputBuffer.isAtStartOfLine { + // An empty string write is needed to add line-leading indentation that matches the current indentation on a line that contains only whitespaces. + outputBuffer.write("") + } + outputBuffer.enqueueSpaces(size) // Print any indentation required, followed by the text content of the syntax token. case .syntax(let text): guard !text.isEmpty else { break } lastBreak = false - write(text) - spaceRemaining -= text.count + outputBuffer.write(text) case .comment(let comment, let wasEndOfLine): lastBreak = false - write(comment.print(indent: currentIndentation)) if wasEndOfLine { - if comment.length > spaceRemaining { + if !(canFit(comment.length) || isBreakingSuppressed) { diagnose(.moveEndOfLineComment, category: .endOfLineComment) } - } else { - spaceRemaining -= comment.length } + outputBuffer.write(comment.print(indent: currentIndentation)) case .verbatim(let verbatim): - writeRaw(verbatim.print(indent: currentIndentation)) - consecutiveNewlineCount = 0 - pendingSpaces = 0 + outputBuffer.writeVerbatim(verbatim.print(indent: currentIndentation), length) lastBreak = false - spaceRemaining -= length case .printerControl(let kind): switch kind { - case .disableBreaking: + case .disableBreaking(let allowDiscretionary): activeBreakSuppressionCount += 1 + // Override the supression of discretionary breaks if we're at the top level or + // discretionary breaks are currently allowed (false should override true, but not the other + // way around). + if activeBreakSuppressionCount == 1 || allowSuppressedDiscretionaryBreaks { + allowSuppressedDiscretionaryBreaks = allowDiscretionary + } case .enableBreaking: activeBreakSuppressionCount -= 1 } @@ -548,6 +513,7 @@ public class PrettyPrinter { // single element collections. let shouldHaveTrailingComma = startLineNumber != openCloseBreakCompensatingLineNumber && !isSingleElement + && configuration.multiElementCollectionTrailingCommas if shouldHaveTrailingComma && !hasTrailingComma { diagnose(.addTrailingComma, category: .trailingComma) } else if !shouldHaveTrailingComma && hasTrailingComma { @@ -556,12 +522,46 @@ public class PrettyPrinter { let shouldWriteComma = whitespaceOnly ? hasTrailingComma : shouldHaveTrailingComma if shouldWriteComma { - write(",") - spaceRemaining -= 1 + outputBuffer.write(",") + } + + case .enableFormatting(let enabledPosition): + guard let disabledPosition else { + // if we're not disabled, we ignore the token + break + } + let start = source.utf8.index(source.utf8.startIndex, offsetBy: disabledPosition.utf8Offset) + let end: String.Index + if let enabledPosition { + end = source.utf8.index(source.utf8.startIndex, offsetBy: enabledPosition.utf8Offset) + } else { + end = source.endIndex + } + var text = String(source[start.. Bool { + let spaceRemaining = configuration.lineLength - outputBuffer.column + return outputBuffer.isAtStartOfLine || length <= spaceRemaining + } + /// Scan over the array of Tokens and calculate their lengths. /// /// This method is based on the `scan` function described in Derek Oppen's "Pretty Printing" paper @@ -613,16 +613,48 @@ public class PrettyPrinter { // Break lengths are equal to its size plus the token or group following it. Calculate the // length of any prior break tokens. case .break(_, let size, let newline): - if let index = delimIndexStack.last, case .break = tokens[index] { - lengths[index] += total + if let index = delimIndexStack.last, case .break(_, _, let lastNewline) = tokens[index] { + /// If the last break and this break are both `.escaped` we add an extra 1 to the total for the last `.escaped` break. + /// This is to handle situations where adding the `\` for an escaped line break would put us over the line length. + /// For example, consider the token sequence: + /// `[.syntax("this fits"), .break(.escaped), .syntax("this fits in line length"), .break(.escaped)]` + /// The naive layout of these tokens will incorrectly print as: + /// """ + /// this fits this fits in line length \ + /// """ + /// which will be too long because of the '\' character. Instead we have to print it as: + /// """ + /// this fits \ + /// this fits in line length + /// """ + /// + /// While not prematurely inserting a line in situations where a hard line break is occurring, such as: + /// + /// `[.syntax("some text"), .break(.escaped), .syntax("this is exactly the right length"), .break(.hard)]` + /// + /// We want this to print as: + /// """ + /// some text this is exactly the right length + /// """ + /// and not: + /// """ + /// some text \ + /// this is exactly the right length + /// """ + if case .escaped = newline, case .escaped = lastNewline { + lengths[index] += total + 1 + } else { + lengths[index] += total + } delimIndexStack.removeLast() } lengths.append(-total) delimIndexStack.append(i) - if case .elective = newline { + switch newline { + case .elective, .escaped: total += size - } else { + default: // `size` is never used in this case, because the break always fires. Use `maxLineLength` // to ensure enclosing groups are large enough to force preceding breaks to fire. total += maxLineLength @@ -663,6 +695,10 @@ public class PrettyPrinter { let length = isSingleElement ? 0 : 1 total += length lengths.append(length) + + case .enableFormatting, .disableFormatting: + // no effect on length calculations + lengths.append(0) } } @@ -684,7 +720,7 @@ public class PrettyPrinter { fatalError("At least one .break(.open) was not matched by a .break(.close)") } - return outputBuffer + return outputBuffer.output } /// Used to track the indentation level for the debug token stream output. @@ -765,27 +801,36 @@ public class PrettyPrinter { case .contextualBreakingEnd: printDebugIndent() print("[END BREAKING CONTEXT Idx: \(idx)]") + + case .enableFormatting(let pos): + printDebugIndent() + print("[ENABLE FORMATTING utf8 offset: \(String(describing: pos))]") + + case .disableFormatting(let pos): + printDebugIndent() + print("[DISABLE FORMATTING utf8 offset: \(pos)]") } } /// Emits a finding with the given message and category at the current location in `outputBuffer`. private func diagnose(_ message: Finding.Message, category: PrettyPrintFindingCategory) { // Add 1 since columns uses 1-based indices. - let column = maxLineLength - spaceRemaining + 1 + let column = outputBuffer.column + 1 context.findingEmitter.emit( message, category: category, - location: Finding.Location(file: context.fileURL.path, line: lineNumber, column: column)) + location: Finding.Location(file: context.fileURL.path, line: outputBuffer.lineNumber, column: column) + ) } } extension Finding.Message { - public static let moveEndOfLineComment: Finding.Message = + fileprivate static let moveEndOfLineComment: Finding.Message = "move end-of-line comment that exceeds the line length" - public static let addTrailingComma: Finding.Message = + fileprivate static let addTrailingComma: Finding.Message = "add trailing comma to the last element in multiline collection literal" - public static let removeTrailingComma: Finding.Message = + fileprivate static let removeTrailingComma: Finding.Message = "remove trailing comma from the last element in single line collection literal" } diff --git a/Sources/SwiftFormat/PrettyPrint/PrettyPrintBuffer.swift b/Sources/SwiftFormat/PrettyPrint/PrettyPrintBuffer.swift new file mode 100644 index 000000000..9ea726206 --- /dev/null +++ b/Sources/SwiftFormat/PrettyPrint/PrettyPrintBuffer.swift @@ -0,0 +1,177 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2024 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Foundation + +/// Used by the PrettyPrint class to actually assemble the output string. This struct +/// tracks state specific to the output (line number, column, etc.) rather than the pretty +/// printing algorithm itself. +struct PrettyPrintBuffer { + /// The maximum number of consecutive blank lines that may appear in a file. + let maximumBlankLines: Int + + /// The width of the horizontal tab in spaces. + let tabWidth: Int + + /// If true, output is generated as normal. If false, the various state variables are + /// updated as normal but nothing is appended to the output (used by selection formatting). + var isEnabled: Bool = true + + /// Indicates whether or not the printer is currently at the beginning of a line. + private(set) var isAtStartOfLine: Bool = true + + /// Keeps track of the most recent number of consecutive newlines that have been printed. + /// + /// This value is reset to zero whenever non-newline content is printed. + private(set) var consecutiveNewlineCount: Int = 0 + + /// Keeps track of the current line number being printed. + private(set) var lineNumber: Int = 1 + + /// Keeps track of the most recent number of spaces that should be printed before the next text + /// token. + private(set) var pendingSpaces: Int = 0 + + /// Current column position of the printer. If we just printed a newline and nothing else, it + /// will still point to the position of the previous line. + private(set) var column: Int + + /// The current indentation level to be used when text is appended to a new line. + var currentIndentation: [Indent] + + /// The accumulated output of the pretty printer. + private(set) var output: String = "" + + init(maximumBlankLines: Int, tabWidth: Int, column: Int = 0) { + self.maximumBlankLines = maximumBlankLines + self.tabWidth = tabWidth + self.currentIndentation = [] + self.column = column + } + + /// Writes newlines into the output stream, taking into account any preexisting consecutive + /// newlines and the maximum allowed number of blank lines. + /// + /// This function does some implicit collapsing of consecutive newlines to ensure that the + /// results are consistent when breaks and explicit newlines coincide. For example, imagine a + /// break token that fires (thus creating a single non-discretionary newline) because it is + /// followed by a group that contains 2 discretionary newlines that were found in the user's + /// source code at that location. In that case, the break "overlaps" with the discretionary + /// newlines and it will write a newline before we get to the discretionaries. Thus, we have to + /// subtract the previously written newlines during the second call so that we end up with the + /// correct number overall. + /// + /// - Parameters: + /// - newlines: The number and type of newlines to write. + /// - shouldIndentBlankLines: A Boolean value indicating whether to insert spaces + /// for blank lines based on the current indentation level. + mutating func writeNewlines(_ newlines: NewlineBehavior, shouldIndentBlankLines: Bool) { + let numberToPrint: Int + switch newlines { + case .elective: + numberToPrint = consecutiveNewlineCount == 0 ? 1 : 0 + case .soft(let count, _): + // We add 1 to the max blank lines because it takes 2 newlines to create the first blank line. + numberToPrint = min(count, maximumBlankLines + 1) - consecutiveNewlineCount + case .hard(let count): + numberToPrint = count + case .escaped: + numberToPrint = 1 + } + + guard numberToPrint > 0 else { return } + for number in 0..= 1 { + writeRaw(currentIndentation.indentation()) + } + writeRaw("\n") + } + + lineNumber += numberToPrint + isAtStartOfLine = true + consecutiveNewlineCount += numberToPrint + pendingSpaces = 0 + column = 0 + } + + /// Writes the given text to the output stream. + /// + /// Before printing the text, this function will print any line-leading indentation or interior + /// leading spaces that are required before the text itself. + mutating func write(_ text: String) { + if isAtStartOfLine { + writeRaw(currentIndentation.indentation()) + column = currentIndentation.length(tabWidth: tabWidth) + isAtStartOfLine = false + } else if pendingSpaces > 0 { + writeRaw(String(repeating: " ", count: pendingSpaces)) + } + writeRaw(text) + consecutiveNewlineCount = 0 + pendingSpaces = 0 + + // In case of comments, we may get a multi-line string. To account for that case, we need to correct the + // `lineNumber` count. The new `column` is the position within the last line. + + var lastNewlineIndex: String.Index? = nil + for i in text.utf8.indices { + if text.utf8[i] == UInt8(ascii: "\n") { + lastNewlineIndex = i + lineNumber += 1 + } + } + + if let lastNewlineIndex { + column = text.distance(from: text.utf8.index(after: lastNewlineIndex), to: text.endIndex) + } else { + column += text.count + } + } + + /// Request that the given number of spaces be printed out before the next text token. + /// + /// Spaces are printed only when the next text token is printed in order to prevent us from + /// printing lines that are only whitespace or have trailing whitespace. + mutating func enqueueSpaces(_ count: Int) { + pendingSpaces += count + column += count + } + + mutating func writeVerbatim(_ verbatim: String, _ length: Int) { + writeRaw(verbatim) + consecutiveNewlineCount = 0 + pendingSpaces = 0 + column += length + } + + /// Calls writeRaw, but also updates some state variables that are normally tracked by + /// higher level functions. This is used when we switch from disabled formatting to + /// enabled formatting, writing all the previous information as-is. + mutating func writeVerbatimAfterEnablingFormatting(_ str: S) { + writeRaw(str) + if str.hasSuffix("\n") { + isAtStartOfLine = true + consecutiveNewlineCount = 1 + } else { + isAtStartOfLine = false + consecutiveNewlineCount = 0 + } + } + + /// Append the given string to the output buffer. + /// + /// No further processing is performed on the string. + private mutating func writeRaw(_ str: S) { + guard isEnabled else { return } + output.append(String(str)) + } +} diff --git a/Sources/SwiftFormatPrettyPrint/PrettyPrintFindingCategory.swift b/Sources/SwiftFormat/PrettyPrint/PrettyPrintFindingCategory.swift similarity index 97% rename from Sources/SwiftFormatPrettyPrint/PrettyPrintFindingCategory.swift rename to Sources/SwiftFormat/PrettyPrint/PrettyPrintFindingCategory.swift index 377443350..ee81342f0 100644 --- a/Sources/SwiftFormatPrettyPrint/PrettyPrintFindingCategory.swift +++ b/Sources/SwiftFormat/PrettyPrint/PrettyPrintFindingCategory.swift @@ -10,8 +10,6 @@ // //===----------------------------------------------------------------------===// -import SwiftFormatCore - /// Categories for findings emitted by the pretty printer. enum PrettyPrintFindingCategory: FindingCategorizing { /// Finding related to an end-of-line comment. diff --git a/Sources/SwiftFormatPrettyPrint/Token.swift b/Sources/SwiftFormat/PrettyPrint/Token.swift similarity index 90% rename from Sources/SwiftFormatPrettyPrint/Token.swift rename to Sources/SwiftFormat/PrettyPrint/Token.swift index 431edb288..654f09c54 100644 --- a/Sources/SwiftFormatPrettyPrint/Token.swift +++ b/Sources/SwiftFormat/PrettyPrint/Token.swift @@ -10,6 +10,8 @@ // //===----------------------------------------------------------------------===// +import SwiftSyntax + enum GroupBreakStyle { /// A consistent break indicates that the break will always be finalized as a newline /// if wrapping occurs. @@ -145,6 +147,10 @@ enum NewlineBehavior { /// newlines and the configured maximum number of blank lines. case hard(count: Int) + /// Break onto a new line is allowed if neccessary. If a line break is emitted, it will be escaped with a '\', and this breaks whitespace will be printed prior to the + /// escaped line break. This is useful in multiline strings where we don't want newlines printed in syntax to appear in the literal. + case escaped + /// An elective newline that respects discretionary newlines from the user-entered text. static let elective = NewlineBehavior.elective(ignoresDiscretionary: false) @@ -162,8 +168,10 @@ enum PrinterControlKind { /// control token is encountered. /// /// It's valid to nest `disableBreaking` and `enableBreaking` tokens. Breaks will be suppressed - /// long as there is at least 1 unmatched disable token. - case disableBreaking + /// long as there is at least 1 unmatched disable token. If `allowDiscretionary` is `true`, then + /// discretionary breaks aren't effected. An `allowDiscretionary` value of true never overrides a + /// value of false. Hard breaks are always inserted no matter what. + case disableBreaking(allowDiscretionary: Bool) /// A signal that break tokens should be allowed to fire following this token, as long as there /// are no other unmatched disable tokens. @@ -194,6 +202,13 @@ enum Token { /// Ends a scope where `contextual` breaks have consistent behavior. case contextualBreakingEnd + /// Turn formatting back on at the given position in the original file + /// nil is used to indicate the rest of the file should be output + case enableFormatting(AbsolutePosition?) + + /// Turn formatting off at the given position in the original file. + case disableFormatting(AbsolutePosition) + // Convenience overloads for the enum types static let open = Token.open(.inconsistent, 0) diff --git a/Sources/SwiftFormatPrettyPrint/TokenStreamCreator.swift b/Sources/SwiftFormat/PrettyPrint/TokenStreamCreator.swift similarity index 60% rename from Sources/SwiftFormatPrettyPrint/TokenStreamCreator.swift rename to Sources/SwiftFormat/PrettyPrint/TokenStreamCreator.swift index fa592e4cf..519da82bd 100644 --- a/Sources/SwiftFormatPrettyPrint/TokenStreamCreator.swift +++ b/Sources/SwiftFormat/PrettyPrint/TokenStreamCreator.swift @@ -11,11 +11,20 @@ //===----------------------------------------------------------------------===// import Foundation -import SwiftFormatConfiguration -import SwiftFormatCore import SwiftOperators import SwiftSyntax +fileprivate extension AccessorBlockSyntax { + /// Assuming that the accessor only contains an implicit getter (i.e. no + /// `get` or `set`), return the code block items in that getter. + var getterCodeBlockItems: CodeBlockItemListSyntax { + guard case .getter(let codeBlockItemList) = self.accessors else { + preconditionFailure("AccessorBlock has an accessor list and not just a getter") + } + return codeBlockItemList + } +} + /// Visits the nodes of a syntax tree and constructs a linear stream of formatting tokens that /// tell the pretty printer how the source text should be laid out. fileprivate final class TokenStreamCreator: SyntaxVisitor { @@ -25,6 +34,7 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { private let config: Configuration private let operatorTable: OperatorTable private let maxlinelength: Int + private let selection: Selection /// The index of the most recently appended break, or nil when no break has been appended. private var lastBreakIndex: Int? = nil @@ -33,10 +43,10 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { /// appended since that break. private var canMergeNewlinesIntoLastBreak = false - /// Keeps track of the prefix length of multiline string segments when they are visited so that - /// the prefix can be stripped at the beginning of lines before the text is added to the token - /// stream. - private var pendingMultilineStringSegmentPrefixLengths = [TokenSyntax: Int]() + /// Keeps track of the kind of break that should be used inside a multiline string. This differs + /// depending on surrounding context due to some tricky special cases, so this lets us pass that + /// information down to the strings that need it. + private var pendingMultilineStringBreakKinds = [StringLiteralExprSyntax: BreakKind]() /// Lists tokens that shouldn't be appended to the token stream as `syntax` tokens. They will be /// printed conditionally using a different type of token. @@ -59,17 +69,32 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { /// in a function call containing multiple trailing closures). private var forcedBreakingClosures = Set() - init(configuration: Configuration, operatorTable: OperatorTable) { + /// Tracks whether we last considered ourselves inside the selection + private var isInsideSelection = true + + init(configuration: Configuration, selection: Selection, operatorTable: OperatorTable) { self.config = configuration + self.selection = selection self.operatorTable = operatorTable self.maxlinelength = config.lineLength super.init(viewMode: .all) } func makeStream(from node: Syntax) -> [Token] { + // if we have a selection, then we start outside of it + if case .ranges = selection { + appendToken(.disableFormatting(AbsolutePosition(utf8Offset: 0))) + isInsideSelection = false + } + // Because `walk` takes an `inout` argument, and we're a class, we have to do the following // dance to pass ourselves in. self.walk(node) + + // Make sure we output any trailing text after the last selection range + if case .ranges = selection { + appendToken(.enableFormatting(nil)) + } defer { tokens = [] } return tokens } @@ -109,7 +134,7 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { betweenElementsOf collectionNode: Node ) where Node.Element == Syntax { for element in collectionNode.dropLast() { - after(element.lastToken, tokens: tokens) + after(element.lastToken(viewMode: .sourceAccurate), tokens: tokens) } } @@ -120,7 +145,7 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { betweenElementsOf collectionNode: Node ) where Node.Element: SyntaxProtocol { for element in collectionNode.dropLast() { - after(element.lastToken, tokens: tokens) + after(element.lastToken(viewMode: .sourceAccurate), tokens: tokens) } } @@ -131,18 +156,18 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { betweenElementsOf collectionNode: Node ) where Node.Element == DeclSyntax { for element in collectionNode.dropLast() { - after(element.lastToken, tokens: tokens) + after(element.lastToken(viewMode: .sourceAccurate), tokens: tokens) } } private func verbatimToken(_ node: Syntax, indentingBehavior: IndentingBehavior = .allLines) { - if let firstToken = node.firstToken { + if let firstToken = node.firstToken(viewMode: .sourceAccurate) { appendBeforeTokens(firstToken) } appendToken(.verbatim(Verbatim(text: node.description, indentingBehavior: indentingBehavior))) - if let lastToken = node.lastToken { + if let lastToken = node.lastToken(viewMode: .sourceAccurate) { // Extract any comments that trail the verbatim block since they belong to the next syntax // token. Leading comments don't need special handling since they belong to the current node, // and will get printed. @@ -158,11 +183,12 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { attributes: node.attributes, modifiers: node.modifiers, typeKeyword: node.classKeyword, - identifier: node.identifier, + identifier: node.name, genericParameterOrPrimaryAssociatedTypeClause: node.genericParameterClause.map(Syntax.init), inheritanceClause: node.inheritanceClause, genericWhereClause: node.genericWhereClause, - members: node.members) + memberBlock: node.memberBlock + ) return .visitChildren } @@ -172,11 +198,12 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { attributes: node.attributes, modifiers: node.modifiers, typeKeyword: node.actorKeyword, - identifier: node.identifier, + identifier: node.name, genericParameterOrPrimaryAssociatedTypeClause: node.genericParameterClause.map(Syntax.init), inheritanceClause: node.inheritanceClause, genericWhereClause: node.genericWhereClause, - members: node.members) + memberBlock: node.memberBlock + ) return .visitChildren } @@ -186,11 +213,12 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { attributes: node.attributes, modifiers: node.modifiers, typeKeyword: node.structKeyword, - identifier: node.identifier, + identifier: node.name, genericParameterOrPrimaryAssociatedTypeClause: node.genericParameterClause.map(Syntax.init), inheritanceClause: node.inheritanceClause, genericWhereClause: node.genericWhereClause, - members: node.members) + memberBlock: node.memberBlock + ) return .visitChildren } @@ -200,11 +228,12 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { attributes: node.attributes, modifiers: node.modifiers, typeKeyword: node.enumKeyword, - identifier: node.identifier, - genericParameterOrPrimaryAssociatedTypeClause: node.genericParameters.map(Syntax.init), + identifier: node.name, + genericParameterOrPrimaryAssociatedTypeClause: node.genericParameterClause.map(Syntax.init), inheritanceClause: node.inheritanceClause, genericWhereClause: node.genericWhereClause, - members: node.members) + memberBlock: node.memberBlock + ) return .visitChildren } @@ -214,17 +243,18 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { attributes: node.attributes, modifiers: node.modifiers, typeKeyword: node.protocolKeyword, - identifier: node.identifier, + identifier: node.name, genericParameterOrPrimaryAssociatedTypeClause: node.primaryAssociatedTypeClause.map(Syntax.init), inheritanceClause: node.inheritanceClause, genericWhereClause: node.genericWhereClause, - members: node.members) + memberBlock: node.memberBlock + ) return .visitChildren } override func visit(_ node: ExtensionDeclSyntax) -> SyntaxVisitorContinueKind { - guard let lastTokenOfExtendedType = node.extendedType.lastToken else { + guard let lastTokenOfExtendedType = node.extendedType.lastToken(viewMode: .sourceAccurate) else { fatalError("ExtensionDeclSyntax.extendedType must have at least one token") } arrangeTypeDeclBlock( @@ -236,7 +266,54 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { genericParameterOrPrimaryAssociatedTypeClause: nil, inheritanceClause: node.inheritanceClause, genericWhereClause: node.genericWhereClause, - members: node.members) + memberBlock: node.memberBlock + ) + return .visitChildren + } + + override func visit(_ node: MacroDeclSyntax) -> SyntaxVisitorContinueKind { + // Macro declarations have a syntax that combines the best parts of types and functions while + // adding their own unique flavor, so we have to copy and adapt the relevant parts of those + // `arrange*` functions here. + before(node.firstToken(viewMode: .sourceAccurate), tokens: .open) + + arrangeAttributeList(node.attributes, separateByLineBreaks: config.lineBreakBeforeEachArgument) + + let hasArguments = !node.signature.parameterClause.parameters.isEmpty + + // Prioritize keeping ") -> " together. We can only do this if the macro has + // arguments. + if hasArguments && config.prioritizeKeepingFunctionOutputTogether { + // Due to visitation order, the matching .open break is added in ParameterClauseSyntax. + after(node.signature.lastToken(viewMode: .sourceAccurate), tokens: .close) + } + + let mustBreak = node.signature.returnClause != nil || node.definition != nil + arrangeParameterClause(node.signature.parameterClause, forcesBreakBeforeRightParen: mustBreak) + + // Prioritize keeping " macro (" together. Also include the ")" if the + // parameter list is empty. + let firstTokenAfterAttributes = + node.modifiers.firstToken(viewMode: .sourceAccurate) ?? node.macroKeyword + before(firstTokenAfterAttributes, tokens: .open) + after(node.macroKeyword, tokens: .break) + if hasArguments || node.genericParameterClause != nil { + after(node.signature.parameterClause.leftParen, tokens: .close) + } else { + after(node.signature.parameterClause.rightParen, tokens: .close) + } + + if let genericWhereClause = node.genericWhereClause { + before(genericWhereClause.firstToken(viewMode: .sourceAccurate), tokens: .break(.same), .open) + after(genericWhereClause.lastToken(viewMode: .sourceAccurate), tokens: .close) + } + if let definition = node.definition { + // Start the group *after* the `=` so that it all wraps onto its own line if it doesn't fit. + after(definition.equal, tokens: .open) + after(definition.lastToken(viewMode: .sourceAccurate), tokens: .close) + } + + after(node.lastToken(viewMode: .sourceAccurate), tokens: .close) return .visitChildren } @@ -245,71 +322,72 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { private func arrangeTypeDeclBlock( _ node: Syntax, attributes: AttributeListSyntax?, - modifiers: ModifierListSyntax?, + modifiers: DeclModifierListSyntax?, typeKeyword: TokenSyntax, identifier: TokenSyntax, genericParameterOrPrimaryAssociatedTypeClause: Syntax?, - inheritanceClause: TypeInheritanceClauseSyntax?, + inheritanceClause: InheritanceClauseSyntax?, genericWhereClause: GenericWhereClauseSyntax?, - members: MemberDeclBlockSyntax + memberBlock: MemberBlockSyntax ) { - before(node.firstToken, tokens: .open) + before(node.firstToken(viewMode: .sourceAccurate), tokens: .open) - arrangeAttributeList(attributes) + arrangeAttributeList(attributes, separateByLineBreaks: config.lineBreakBetweenDeclarationAttributes) // Prioritize keeping " :" together (corresponding group close is // below at `lastTokenBeforeBrace`). - let firstTokenAfterAttributes = modifiers?.firstToken ?? typeKeyword + let firstTokenAfterAttributes = modifiers?.firstToken(viewMode: .sourceAccurate) ?? typeKeyword before(firstTokenAfterAttributes, tokens: .open) after(typeKeyword, tokens: .break) - arrangeBracesAndContents(of: members, contentsKeyPath: \.members) + arrangeBracesAndContents(of: memberBlock, contentsKeyPath: \.members) if let genericWhereClause = genericWhereClause { - before(genericWhereClause.firstToken, tokens: .break(.same), .open) - after(members.leftBrace, tokens: .close) + before(genericWhereClause.firstToken(viewMode: .sourceAccurate), tokens: .break(.same), .open) + after(memberBlock.leftBrace, tokens: .close) } - let lastTokenBeforeBrace = inheritanceClause?.colon - ?? genericParameterOrPrimaryAssociatedTypeClause?.lastToken + let lastTokenBeforeBrace = + inheritanceClause?.colon + ?? genericParameterOrPrimaryAssociatedTypeClause?.lastToken(viewMode: .sourceAccurate) ?? identifier after(lastTokenBeforeBrace, tokens: .close) - after(node.lastToken, tokens: .close) + after(node.lastToken(viewMode: .sourceAccurate), tokens: .close) } // MARK: - Function and function-like declaration nodes (initializers, deinitializers, subscripts) override func visit(_ node: FunctionDeclSyntax) -> SyntaxVisitorContinueKind { - let hasArguments = !node.signature.input.parameterList.isEmpty + let hasArguments = !node.signature.parameterClause.parameters.isEmpty // Prioritize keeping ") throws -> " together. We can only do this if the function // has arguments. if hasArguments && config.prioritizeKeepingFunctionOutputTogether { // Due to visitation order, the matching .open break is added in ParameterClauseSyntax. - after(node.signature.lastToken, tokens: .close) + after(node.signature.lastToken(viewMode: .sourceAccurate), tokens: .close) } - let mustBreak = node.body != nil || node.signature.output != nil - arrangeParameterClause(node.signature.input, forcesBreakBeforeRightParen: mustBreak) + let mustBreak = node.body != nil || node.signature.returnClause != nil + arrangeParameterClause(node.signature.parameterClause, forcesBreakBeforeRightParen: mustBreak) // Prioritize keeping " func (" together. Also include the ")" if the parameter // list is empty. - let firstTokenAfterAttributes = node.modifiers?.firstToken ?? node.funcKeyword + let firstTokenAfterAttributes = node.modifiers.firstToken(viewMode: .sourceAccurate) ?? node.funcKeyword before(firstTokenAfterAttributes, tokens: .open) after(node.funcKeyword, tokens: .break) if hasArguments || node.genericParameterClause != nil { - after(node.signature.input.leftParen, tokens: .close) + after(node.signature.parameterClause.leftParen, tokens: .close) } else { - after(node.signature.input.rightParen, tokens: .close) + after(node.signature.parameterClause.rightParen, tokens: .close) } // Add a non-breaking space after the identifier if it's an operator, to separate it visually // from the following parenthesis or generic argument list. Note that even if the function is - // defining a prefix or postfix operator, or even if the operator isn't originally followed by a - // space, the token kind always comes through as `spacedBinaryOperator`. - if case .spacedBinaryOperator = node.identifier.tokenKind { - after(node.identifier.lastToken, tokens: .space) + // defining a prefix or postfix operator, the token kind always comes through as + // `binaryOperator`. + if case .binaryOperator = node.name.tokenKind { + after(node.name.lastToken(viewMode: .sourceAccurate), tokens: .space) } arrangeFunctionLikeDecl( @@ -317,31 +395,32 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { attributes: node.attributes, genericWhereClause: node.genericWhereClause, body: node.body, - bodyContentsKeyPath: \.statements) + bodyContentsKeyPath: \.statements + ) return .visitChildren } override func visit(_ node: InitializerDeclSyntax) -> SyntaxVisitorContinueKind { - let hasArguments = !node.signature.input.parameterList.isEmpty + let hasArguments = !node.signature.parameterClause.parameters.isEmpty // Prioritize keeping ") throws" together. We can only do this if the function // has arguments. if hasArguments && config.prioritizeKeepingFunctionOutputTogether { // Due to visitation order, the matching .open break is added in ParameterClauseSyntax. - after(node.signature.lastToken, tokens: .close) + after(node.signature.lastToken(viewMode: .sourceAccurate), tokens: .close) } - arrangeParameterClause(node.signature.input, forcesBreakBeforeRightParen: node.body != nil) + arrangeParameterClause(node.signature.parameterClause, forcesBreakBeforeRightParen: node.body != nil) // Prioritize keeping " init" together. - let firstTokenAfterAttributes = node.modifiers?.firstToken ?? node.initKeyword + let firstTokenAfterAttributes = node.modifiers.firstToken(viewMode: .sourceAccurate) ?? node.initKeyword before(firstTokenAfterAttributes, tokens: .open) if hasArguments || node.genericParameterClause != nil { - after(node.signature.input.leftParen, tokens: .close) + after(node.signature.parameterClause.leftParen, tokens: .close) } else { - after(node.signature.input.rightParen, tokens: .close) + after(node.signature.parameterClause.rightParen, tokens: .close) } arrangeFunctionLikeDecl( @@ -349,7 +428,8 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { attributes: node.attributes, genericWhereClause: node.genericWhereClause, body: node.body, - bodyContentsKeyPath: \.statements) + bodyContentsKeyPath: \.statements + ) return .visitChildren } @@ -360,23 +440,24 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { attributes: node.attributes, genericWhereClause: nil, body: node.body, - bodyContentsKeyPath: \.statements) + bodyContentsKeyPath: \.statements + ) return .visitChildren } override func visit(_ node: SubscriptDeclSyntax) -> SyntaxVisitorContinueKind { - let hasArguments = !node.indices.parameterList.isEmpty + let hasArguments = !node.parameterClause.parameters.isEmpty - before(node.firstToken, tokens: .open) + before(node.firstToken(viewMode: .sourceAccurate), tokens: .open) // Prioritize keeping " subscript" together. - if let firstModifierToken = node.modifiers?.firstToken { + if let firstModifierToken = node.modifiers.firstToken(viewMode: .sourceAccurate) { before(firstModifierToken, tokens: .open) if hasArguments || node.genericParameterClause != nil { - after(node.indices.leftParen, tokens: .close) + after(node.parameterClause.leftParen, tokens: .close) } else { - after(node.indices.rightParen, tokens: .close) + after(node.parameterClause.rightParen, tokens: .close) } } @@ -384,31 +465,50 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { // arguments. if hasArguments && config.prioritizeKeepingFunctionOutputTogether { // Due to visitation order, the matching .open break is added in ParameterClauseSyntax. - after(node.result.lastToken, tokens: .close) + after(node.returnClause.lastToken(viewMode: .sourceAccurate), tokens: .close) } - arrangeAttributeList(node.attributes) + arrangeAttributeList(node.attributes, separateByLineBreaks: config.lineBreakBetweenDeclarationAttributes) if let genericWhereClause = node.genericWhereClause { - before(genericWhereClause.firstToken, tokens: .break(.same), .open) - after(genericWhereClause.lastToken, tokens: .close) + before(genericWhereClause.firstToken(viewMode: .sourceAccurate), tokens: .break(.same), .open) + after(genericWhereClause.lastToken(viewMode: .sourceAccurate), tokens: .close) + } + + before(node.returnClause.firstToken(viewMode: .sourceAccurate), tokens: .break) + + if let accessorBlock = node.accessorBlock { + switch accessorBlock.accessors { + case .accessors(let accessors): + arrangeBracesAndContents( + leftBrace: accessorBlock.leftBrace, + accessors: accessors, + rightBrace: accessorBlock.rightBrace + ) + case .getter: + arrangeBracesAndContents(of: accessorBlock, contentsKeyPath: \.getterCodeBlockItems) + } } - before(node.result.firstToken, tokens: .break) + after(node.lastToken(viewMode: .sourceAccurate), tokens: .close) - if let accessorOrCodeBlock = node.accessor { - switch accessorOrCodeBlock { - case .accessors(let accessorBlock): - arrangeBracesAndContents(of: accessorBlock) - case .getter(let codeBlock): - arrangeBracesAndContents(of: codeBlock, contentsKeyPath: \.statements) - } - } + arrangeParameterClause(node.parameterClause, forcesBreakBeforeRightParen: true) + + return .visitChildren + } - after(node.lastToken, tokens: .close) + override func visit(_ node: AccessorEffectSpecifiersSyntax) -> SyntaxVisitorContinueKind { + arrangeEffectSpecifiers(node) + return .visitChildren + } - arrangeParameterClause(node.indices, forcesBreakBeforeRightParen: true) + override func visit(_ node: FunctionEffectSpecifiersSyntax) -> SyntaxVisitorContinueKind { + arrangeEffectSpecifiers(node) + return .visitChildren + } + override func visit(_ node: TypeEffectSpecifiersSyntax) -> SyntaxVisitorContinueKind { + arrangeEffectSpecifiers(node) return .visitChildren } @@ -421,62 +521,57 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { body: Node?, bodyContentsKeyPath: KeyPath? ) where BodyContents.Element: SyntaxProtocol { - before(node.firstToken, tokens: .open) + before(node.firstToken(viewMode: .sourceAccurate), tokens: .open) - arrangeAttributeList(attributes) + arrangeAttributeList(attributes, separateByLineBreaks: config.lineBreakBetweenDeclarationAttributes) arrangeBracesAndContents(of: body, contentsKeyPath: bodyContentsKeyPath) if let genericWhereClause = genericWhereClause { - before(genericWhereClause.firstToken, tokens: .break(.same), .open) - after(body?.leftBrace ?? genericWhereClause.lastToken, tokens: .close) + before(genericWhereClause.firstToken(viewMode: .sourceAccurate), tokens: .break(.same), .open) + after(body?.leftBrace ?? genericWhereClause.lastToken(viewMode: .sourceAccurate), tokens: .close) } - after(node.lastToken, tokens: .close) + after(node.lastToken(viewMode: .sourceAccurate), tokens: .close) + } + + /// Arranges the `async` and `throws` effect specifiers of a function or accessor declaration. + private func arrangeEffectSpecifiers(_ node: Node) { + before(node.asyncSpecifier, tokens: .break) + before(node.throwsClause?.throwsSpecifier, tokens: .break) + // Keep them together if both `async` and `throws` are present. + if let asyncSpecifier = node.asyncSpecifier, let throwsSpecifier = node.throwsClause?.throwsSpecifier { + before(asyncSpecifier, tokens: .open) + after(throwsSpecifier, tokens: .close) + } } // MARK: - Property and subscript accessor block nodes - override func visit(_ node: AccessorListSyntax) -> SyntaxVisitorContinueKind { + override func visit(_ node: AccessorDeclListSyntax) -> SyntaxVisitorContinueKind { for child in node.dropLast() { // If the child doesn't have a body (it's just the `get`/`set` keyword), then we're in a // protocol and we want to let them be placed on the same line if possible. Otherwise, we // place a newline between each accessor. let newlines: NewlineBehavior = child.body == nil ? .elective : .soft - after(child.lastToken, tokens: .break(.same, size: 1, newlines: newlines)) + after(child.lastToken(viewMode: .sourceAccurate), tokens: .break(.same, size: 1, newlines: newlines)) } return .visitChildren } override func visit(_ node: AccessorDeclSyntax) -> SyntaxVisitorContinueKind { - arrangeAttributeList(node.attributes) - - if let asyncKeyword = node.asyncKeyword { - if node.throwsKeyword != nil { - before(asyncKeyword, tokens: .break, .open) - } else { - before(asyncKeyword, tokens: .break) - } - } - - if let throwsKeyword = node.throwsKeyword { - before(node.throwsKeyword, tokens: .break) - if node.asyncKeyword != nil { - after(throwsKeyword, tokens: .close) - } - } - + arrangeAttributeList(node.attributes, separateByLineBreaks: config.lineBreakBetweenDeclarationAttributes) arrangeBracesAndContents(of: node.body, contentsKeyPath: \.statements) return .visitChildren } - override func visit(_ node: AccessorParameterSyntax) -> SyntaxVisitorContinueKind { + override func visit(_ node: AccessorParametersSyntax) -> SyntaxVisitorContinueKind { return .visitChildren } // MARK: - Control flow statement nodes override func visit(_ node: LabeledStmtSyntax) -> SyntaxVisitorContinueKind { - after(node.labelColon, tokens: .space) + after(node.colon, tokens: .space) return .visitChildren } @@ -484,8 +579,8 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { // There may be a consistent breaking group around this node, see `CodeBlockItemSyntax`. This // group is necessary so that breaks around and inside of the conditions aren't forced to break // when the if-stmt spans multiple lines. - before(node.conditions.firstToken, tokens: .open) - after(node.conditions.lastToken, tokens: .close) + before(node.conditions.firstToken(viewMode: .sourceAccurate), tokens: .open) + after(node.conditions.lastToken(viewMode: .sourceAccurate), tokens: .close) after(node.ifKeyword, tokens: .space) @@ -494,8 +589,8 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { // the conditions. There are no breaks around the first condition because if-statements look // better without a break between the "if" and the first condition. for condition in node.conditions.dropFirst() { - before(condition.firstToken, tokens: .break(.open(kind: .continuation), size: 0)) - after(condition.lastToken, tokens: .break(.close(mustBreak: false), size: 0)) + before(condition.firstToken(viewMode: .sourceAccurate), tokens: .break(.open(kind: .continuation), size: 0)) + after(condition.lastToken(viewMode: .sourceAccurate), tokens: .break(.close(mustBreak: false), size: 0)) } arrangeBracesAndContents(of: node.body, contentsKeyPath: \.statements) @@ -505,7 +600,7 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { // there's a comment. if config.lineBreakBeforeControlFlowKeywords { before(elseKeyword, tokens: .break(.same, newlines: .soft)) - } else if elseKeyword.leadingTrivia.hasLineComment { + } else if elseKeyword.hasPrecedingLineComment { before(elseKeyword, tokens: .break(.same, size: 1)) } else { before(elseKeyword, tokens: .space) @@ -513,7 +608,9 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { // Breaks are only allowed after `else` when there's a comment; otherwise there shouldn't be // any newlines between `else` and the open brace or a following `if`. - if let tokenAfterElse = elseKeyword.nextToken(viewMode: .all), tokenAfterElse.leadingTrivia.hasLineComment { + if let tokenAfterElse = elseKeyword.nextToken(viewMode: .all), + tokenAfterElse.hasPrecedingLineComment + { after(node.elseKeyword, tokens: .break(.same, size: 1)) } else if let elseBody = node.elseBody, elseBody.is(IfStmtSyntax.self) { after(node.elseKeyword, tokens: .space) @@ -531,8 +628,8 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { // Add break groups, using open continuation breaks, around all conditions so that continuations // inside of the conditions can stack in addition to continuations between the conditions. for condition in node.conditions { - before(condition.firstToken, tokens: .break(.open(kind: .continuation), size: 0)) - after(condition.lastToken, tokens: .break(.close(mustBreak: false), size: 0)) + before(condition.firstToken(viewMode: .sourceAccurate), tokens: .break(.open(kind: .continuation), size: 0)) + after(condition.lastToken(viewMode: .sourceAccurate), tokens: .break(.close(mustBreak: false), size: 0)) } before(node.elseKeyword, tokens: .break(.reset), .open) @@ -540,12 +637,15 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { before(node.body.leftBrace, tokens: .close) arrangeBracesAndContents( - of: node.body, contentsKeyPath: \.statements, shouldResetBeforeLeftBrace: false) + of: node.body, + contentsKeyPath: \.statements, + shouldResetBeforeLeftBrace: false + ) return .visitChildren } - override func visit(_ node: ForInStmtSyntax) -> SyntaxVisitorContinueKind { + override func visit(_ node: ForStmtSyntax) -> SyntaxVisitorContinueKind { // If we have a `(try) await` clause, allow breaking after the `for` so that the `(try) await` // can fall onto the next line if needed, and if both `try await` are present, keep them // together. Otherwise, keep `for` glued to the token after it so that we break somewhere later @@ -570,8 +670,9 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { if let typeAnnotation = node.typeAnnotation { after( typeAnnotation.colon, - tokens: .break(.open(kind: .continuation), newlines: .elective(ignoresDiscretionary: true))) - after(typeAnnotation.lastToken, tokens: .break(.close(mustBreak: false), size: 0)) + tokens: .break(.open(kind: .continuation), newlines: .elective(ignoresDiscretionary: true)) + ) + after(typeAnnotation.lastToken(viewMode: .sourceAccurate), tokens: .break(.close(mustBreak: false), size: 0)) } arrangeBracesAndContents(of: node.body, contentsKeyPath: \.statements) @@ -591,8 +692,8 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { // condition could be longer than the column limit since there are no breaks between the label // or while token. for condition in node.conditions.dropFirst() { - before(condition.firstToken, tokens: .break(.open(kind: .continuation), size: 0)) - after(condition.lastToken, tokens: .break(.close(mustBreak: false), size: 0)) + before(condition.firstToken(viewMode: .sourceAccurate), tokens: .break(.open(kind: .continuation), size: 0)) + after(condition.lastToken(viewMode: .sourceAccurate), tokens: .break(.close(mustBreak: false), size: 0)) } arrangeBracesAndContents(of: node.body, contentsKeyPath: \.statements) @@ -600,12 +701,12 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { return .visitChildren } - override func visit(_ node: RepeatWhileStmtSyntax) -> SyntaxVisitorContinueKind { + override func visit(_ node: RepeatStmtSyntax) -> SyntaxVisitorContinueKind { arrangeBracesAndContents(of: node.body, contentsKeyPath: \.statements) if config.lineBreakBeforeControlFlowKeywords { before(node.whileKeyword, tokens: .break(.same), .open) - after(node.condition.lastToken, tokens: .close) + after(node.condition.lastToken(viewMode: .sourceAccurate), tokens: .close) } else { // The length of the condition needs to force the breaks around the braces of the repeat // stmt's body, so that there's always a break before the right brace when the while & @@ -613,34 +714,36 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { before(node.whileKeyword, tokens: .space) // The `open` token occurs after the ending tokens for the braced `body` node. before(node.body.rightBrace, tokens: .open) - after(node.condition.lastToken, tokens: .close) + after(node.condition.lastToken(viewMode: .sourceAccurate), tokens: .close) } after(node.whileKeyword, tokens: .space) return .visitChildren } override func visit(_ node: DoStmtSyntax) -> SyntaxVisitorContinueKind { + if node.throwsClause != nil { + after(node.doKeyword, tokens: .break(.same, size: 1)) + } arrangeBracesAndContents(of: node.body, contentsKeyPath: \.statements) return .visitChildren } override func visit(_ node: CatchClauseSyntax) -> SyntaxVisitorContinueKind { - let catchPrecedingBreak = config.lineBreakBeforeControlFlowKeywords + let catchPrecedingBreak = + config.lineBreakBeforeControlFlowKeywords ? Token.break(.same, newlines: .soft) : Token.space before(node.catchKeyword, tokens: catchPrecedingBreak) - if let catchItems = node.catchItems { - // If there are multiple items in the `catch` clause, wrap each in open/close breaks so that - // their internal breaks stack correctly. Otherwise, if there is only a single clause, use the - // old (pre-SE-0276) behavior (a fixed space after the `catch` keyword). - if catchItems.count > 1 { - for catchItem in catchItems { - before(catchItem.firstToken, tokens: .break(.open(kind: .continuation))) - after(catchItem.lastToken, tokens: .break(.close(mustBreak: false), size: 0)) - } - } else { - before(node.catchItems?.firstToken, tokens: .space) + // If there are multiple items in the `catch` clause, wrap each in open/close breaks so that + // their internal breaks stack correctly. Otherwise, if there is only a single clause, use the + // old (pre-SE-0276) behavior (a fixed space after the `catch` keyword). + if node.catchItems.count > 1 { + for catchItem in node.catchItems { + before(catchItem.firstToken(viewMode: .sourceAccurate), tokens: .break(.open(kind: .continuation))) + after(catchItem.lastToken(viewMode: .sourceAccurate), tokens: .break(.close(mustBreak: false), size: 0)) } + } else { + before(node.catchItems.firstToken(viewMode: .sourceAccurate), tokens: .space) } arrangeBracesAndContents(of: node.body, contentsKeyPath: \.statements) @@ -659,12 +762,19 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { } override func visit(_ node: ReturnStmtSyntax) -> SyntaxVisitorContinueKind { - before(node.expression?.firstToken, tokens: .break) + if let expression = node.expression { + if leftmostMultilineStringLiteral(of: expression) != nil { + before(expression.firstToken(viewMode: .sourceAccurate), tokens: .break(.open)) + after(expression.lastToken(viewMode: .sourceAccurate), tokens: .break(.close(mustBreak: false))) + } else { + before(expression.firstToken(viewMode: .sourceAccurate), tokens: .break) + } + } return .visitChildren } override func visit(_ node: ThrowStmtSyntax) -> SyntaxVisitorContinueKind { - before(node.expression.firstToken, tokens: .break) + before(node.expression.firstToken(viewMode: .sourceAccurate), tokens: .break) return .visitChildren } @@ -681,12 +791,12 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { // An if-configuration clause around a switch-case encloses the case's node, so an // if-configuration clause requires a break here in order to be allowed on a new line. - for ifConfigDecl in node.cases.filter({ $0.is(IfConfigDeclSyntax.self) }) { + for ifConfigDecl in node.cases where ifConfigDecl.is(IfConfigDeclSyntax.self) { if config.indentSwitchCaseLabels { - before(ifConfigDecl.firstToken, tokens: .break(.open)) - after(ifConfigDecl.lastToken, tokens: .break(.close, size: 0)) + before(ifConfigDecl.firstToken(viewMode: .sourceAccurate), tokens: .break(.open)) + after(ifConfigDecl.lastToken(viewMode: .sourceAccurate), tokens: .break(.close, size: 0)) } else { - before(ifConfigDecl.firstToken, tokens: .break(.same)) + before(ifConfigDecl.firstToken(viewMode: .sourceAccurate), tokens: .break(.same)) } } @@ -706,18 +816,28 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { } else { openBreak = .break(.same, newlines: .soft) } - before(node.firstToken, tokens: openBreak) + before(node.firstToken(viewMode: .sourceAccurate), tokens: openBreak) - after(node.unknownAttr?.lastToken, tokens: .space) - after(node.label.lastToken, tokens: .break(.reset, size: 0), .break(.open), .open) + after(node.attribute?.lastToken(viewMode: .sourceAccurate), tokens: .space) + after(node.label.lastToken(viewMode: .sourceAccurate), tokens: .break(.reset, size: 0), .break(.open), .open) - // If switch/case labels were configured to be indented, insert an extra `close` break after the - // case body to match the `open` break above + // If switch/case labels were configured to be indented, insert an extra `close` break after + // the case body to match the `open` break above var afterLastTokenTokens: [Token] = [.break(.close, size: 0), .close] if config.indentSwitchCaseLabels { afterLastTokenTokens.append(.break(.close, size: 0)) } - after(node.lastToken, tokens: afterLastTokenTokens) + + // If the case contains statements, add the closing tokens after the last token of the case. + // Otherwise, add the closing tokens before the next case (or the end of the switch) to have the + // same effect. If instead the opening and closing tokens were omitted completely in the absence + // of statements, comments within the empty case would be incorrectly indented to the same level + // as the case label. + if node.label.lastToken(viewMode: .sourceAccurate) != node.lastToken(viewMode: .sourceAccurate) { + after(node.lastToken(viewMode: .sourceAccurate), tokens: afterLastTokenTokens) + } else { + before(node.nextToken(viewMode: .sourceAccurate), tokens: afterLastTokenTokens) + } return .visitChildren } @@ -732,7 +852,7 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { // following a `NoCasesWithOnlyFallthrough` transformation that might merge cases. let caseItems = Array(node.caseItems) for (index, item) in caseItems.enumerated() { - before(item.firstToken, tokens: .open) + before(item.firstToken(viewMode: .sourceAccurate), tokens: .open) if let trailingComma = item.trailingComma { // Insert a newline before the next item if it has a where clause and this item doesn't. let nextItemHasWhereClause = @@ -741,7 +861,7 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { let newlines: NewlineBehavior = requiresNewline ? .soft : .elective after(trailingComma, tokens: .close, .break(.continue, size: 1, newlines: newlines)) } else { - after(item.lastToken, tokens: .close) + after(item.lastToken(viewMode: .sourceAccurate), tokens: .close) } } @@ -751,7 +871,7 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { } override func visit(_ node: YieldStmtSyntax) -> SyntaxVisitorContinueKind { - // As of https://github.com/apple/swift-syntax/pull/895, the token following a `yield` keyword + // As of https://github.com/swiftlang/swift-syntax/pull/895, the token following a `yield` keyword // *must* be on the same line, so we cannot break here. after(node.yieldKeyword, tokens: .space) return .visitChildren @@ -769,7 +889,7 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { override func visit(_ node: TupleExprSyntax) -> SyntaxVisitorContinueKind { // We'll do nothing if it's a zero-element tuple, because we just want to keep the empty `()` // together. - let elementCount = node.elementList.count + let elementCount = node.elements.count if elementCount == 1 { // A tuple with one element is a parenthesized expression; add a group around it to keep it @@ -783,7 +903,7 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { // to exist at the EOL with the left paren or on its own line. The contents are always // indented on the following lines, since parens always create a scope. An open/close break // pair isn't used here to avoid forcing the closing paren down onto a new line. - if node.leftParen.nextToken(viewMode: .all)?.leadingTrivia.hasLineComment ?? false { + if node.leftParen.nextToken(viewMode: .all)?.hasPrecedingLineComment ?? false { after(node.leftParen, tokens: .break(.continue, size: 0)) } } else if elementCount > 1 { @@ -791,9 +911,9 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { after(node.leftParen, tokens: .break(.open, size: 0), .open) before(node.rightParen, tokens: .break(.close, size: 0), .close) - insertTokens(.break(.same), betweenElementsOf: node.elementList) + insertTokens(.break(.same), betweenElementsOf: node.elements) - for element in node.elementList { + for element in node.elements { arrangeAsTupleExprElement(element) } } @@ -801,14 +921,14 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { return .visitChildren } - override func visit(_ node: TupleExprElementListSyntax) -> SyntaxVisitorContinueKind { + override func visit(_ node: LabeledExprListSyntax) -> SyntaxVisitorContinueKind { // Intentionally do nothing here. Since `TupleExprElement`s are used both in tuple expressions // and function argument lists, which need to be formatted, differently, those nodes manually // loop over the nodes and arrange them in those contexts. return .visitChildren } - override func visit(_ node: TupleExprElementSyntax) -> SyntaxVisitorContinueKind { + override func visit(_ node: LabeledExprSyntax) -> SyntaxVisitorContinueKind { // Intentionally do nothing here. Since `TupleExprElement`s are used both in tuple expressions // and function argument lists, which need to be formatted, differently, those nodes manually // loop over the nodes and arrange them in those contexts. @@ -819,17 +939,17 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { /// argument). /// /// - Parameter node: The tuple expression element to be arranged. - private func arrangeAsTupleExprElement(_ node: TupleExprElementSyntax) { - before(node.firstToken, tokens: .open) + private func arrangeAsTupleExprElement(_ node: LabeledExprSyntax) { + before(node.firstToken(viewMode: .sourceAccurate), tokens: .open) after(node.colon, tokens: .break) - after(node.lastToken, tokens: .close) + after(node.lastToken(viewMode: .sourceAccurate), tokens: .close) if let trailingComma = node.trailingComma { closingDelimiterTokens.insert(trailingComma) } } override func visit(_ node: ArrayExprSyntax) -> SyntaxVisitorContinueKind { - if !node.elements.isEmpty || node.rightSquare.leadingTrivia.numberOfComments > 0 { + if !node.elements.isEmpty || node.rightSquare.hasAnyPrecedingComment { after(node.leftSquare, tokens: .break(.open, size: 0), .open) before(node.rightSquare, tokens: .break(.close, size: 0), .close) } @@ -840,8 +960,8 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { insertTokens(.break(.same), betweenElementsOf: node) for element in node { - before(element.firstToken, tokens: .open) - after(element.lastToken, tokens: .close) + before(element.firstToken(viewMode: .sourceAccurate), tokens: .open) + after(element.lastToken(viewMode: .sourceAccurate), tokens: .close) if let trailingComma = element.trailingComma { closingDelimiterTokens.insert(trailingComma) } @@ -851,12 +971,13 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { if let trailingComma = lastElement.trailingComma { ignoredTokens.insert(trailingComma) } - before(node.first?.firstToken, tokens: .commaDelimitedRegionStart) + before(node.first?.firstToken(viewMode: .sourceAccurate), tokens: .commaDelimitedRegionStart) let endToken = Token.commaDelimitedRegionEnd( hasTrailingComma: lastElement.trailingComma != nil, - isSingleElement: node.first == lastElement) - after(lastElement.expression.lastToken, tokens: [endToken]) + isSingleElement: node.first == lastElement + ) + after(lastElement.expression.lastToken(viewMode: .sourceAccurate), tokens: [endToken]) } return .visitChildren } @@ -869,8 +990,8 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { // The node's content is either a `DictionaryElementListSyntax` or a `TokenSyntax` for a colon // token (for an empty dictionary). if !(node.content.as(DictionaryElementListSyntax.self)?.isEmpty ?? true) - || node.content.leadingTrivia?.numberOfComments ?? 0 > 0 - || node.rightSquare.leadingTrivia.numberOfComments > 0 + || node.content.hasAnyPrecedingComment + || node.rightSquare.hasAnyPrecedingComment { after(node.leftSquare, tokens: .break(.open, size: 0), .open) before(node.rightSquare, tokens: .break(.close, size: 0), .close) @@ -882,9 +1003,9 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { insertTokens(.break(.same), betweenElementsOf: node) for element in node { - before(element.firstToken, tokens: .open) + before(element.firstToken(viewMode: .sourceAccurate), tokens: .open) after(element.colon, tokens: .break) - after(element.lastToken, tokens: .close) + after(element.lastToken(viewMode: .sourceAccurate), tokens: .close) if let trailingComma = element.trailingComma { closingDelimiterTokens.insert(trailingComma) } @@ -894,12 +1015,13 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { if let trailingComma = lastElement.trailingComma { ignoredTokens.insert(trailingComma) } - before(node.first?.firstToken, tokens: .commaDelimitedRegionStart) + before(node.first?.firstToken(viewMode: .sourceAccurate), tokens: .commaDelimitedRegionStart) let endToken = Token.commaDelimitedRegionEnd( hasTrailingComma: lastElement.trailingComma != nil, - isSingleElement: node.first == node.last) - after(lastElement.lastToken, tokens: endToken) + isSingleElement: node.first == node.last + ) + after(lastElement.lastToken(viewMode: .sourceAccurate), tokens: endToken) } return .visitChildren } @@ -915,7 +1037,6 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { override func visit(_ node: MemberAccessExprSyntax) -> SyntaxVisitorContinueKind { preVisitInsertingContextualBreaks(node) - return .visitChildren } @@ -923,49 +1044,63 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { clearContextualBreakState(node) } + override func visit(_ node: PostfixIfConfigExprSyntax) -> SyntaxVisitorContinueKind { + preVisitInsertingContextualBreaks(node) + return .visitChildren + } + + override func visitPost(_ node: PostfixIfConfigExprSyntax) { + clearContextualBreakState(node) + } + override func visit(_ node: FunctionCallExprSyntax) -> SyntaxVisitorContinueKind { preVisitInsertingContextualBreaks(node) // If there are multiple trailing closures, force all the closures in the call to break. - if let additionalTrailingClosures = node.additionalTrailingClosures { + if !node.additionalTrailingClosures.isEmpty { if let closure = node.trailingClosure { forcedBreakingClosures.insert(closure.id) } - for additionalTrailingClosure in additionalTrailingClosures { + for additionalTrailingClosure in node.additionalTrailingClosures { forcedBreakingClosures.insert(additionalTrailingClosure.closure.id) } } if let calledMemberAccessExpr = node.calledExpression.as(MemberAccessExprSyntax.self) { - if let base = calledMemberAccessExpr.base, base.is(IdentifierExprSyntax.self) { + if let base = calledMemberAccessExpr.base, base.is(DeclReferenceExprSyntax.self) { // When this function call is wrapped by a try-expr or await-expr, the group applied when // visiting that wrapping expression is sufficient. Adding another group here in that case // can result in unnecessarily breaking after the try/await keyword. - if !(base.firstToken?.previousToken(viewMode: .all)?.parent?.is(TryExprSyntax.self) ?? false - || base.firstToken?.previousToken(viewMode: .all)?.parent?.is(AwaitExprSyntax.self) ?? false) { - before(base.firstToken, tokens: .open) - after(calledMemberAccessExpr.name.lastToken, tokens: .close) + if !(base.firstToken(viewMode: .sourceAccurate)?.previousToken(viewMode: .all)?.parent?.is(TryExprSyntax.self) + ?? false + || base.firstToken(viewMode: .sourceAccurate)?.previousToken(viewMode: .all)?.parent?.is(AwaitExprSyntax.self) + ?? false) + { + before(base.firstToken(viewMode: .sourceAccurate), tokens: .open) + after(calledMemberAccessExpr.declName.baseName.lastToken(viewMode: .sourceAccurate), tokens: .close) } } } - let arguments = node.argumentList + let arguments = node.arguments // If there is a trailing closure, force the right parenthesis down to the next line so it // stays with the open curly brace. let breakBeforeRightParen = (node.trailingClosure != nil && !isCompactSingleFunctionCallArgument(arguments)) - || mustBreakBeforeClosingDelimiter(of: node, argumentListPath: \.argumentList) + || mustBreakBeforeClosingDelimiter(of: node, argumentListPath: \.arguments) before( node.trailingClosure?.leftBrace, - tokens: .break(.same, newlines: .elective(ignoresDiscretionary: true))) + tokens: .break(.same, newlines: .elective(ignoresDiscretionary: true)) + ) arrangeFunctionCallArgumentList( arguments, leftDelimiter: node.leftParen, rightDelimiter: node.rightParen, - forcesBreakBeforeRightDelimiter: breakBeforeRightParen) + forcesBreakBeforeRightDelimiter: breakBeforeRightParen + ) return .visitChildren } @@ -974,9 +1109,9 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { clearContextualBreakState(node) } - override func visit(_ node: MultipleTrailingClosureElementSyntax) - -> SyntaxVisitorContinueKind - { + override func visit( + _ node: MultipleTrailingClosureElementSyntax + ) -> SyntaxVisitorContinueKind { before(node.label, tokens: .space) after(node.colon, tokens: .space) return .visitChildren @@ -991,9 +1126,9 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { /// - rightDelimiter: The right parenthesis or bracket surrounding the arguments, if any. /// - forcesBreakBeforeRightDelimiter: True if a line break should be forced before the right /// right delimiter if a line break occurred after the left delimiter, or false if the right - /// delimiter is allowed to hang on the same line as the final argument. + /// delimiter is allowed to hang on the same line as the final argument. # ignore-unacceptable-language private func arrangeFunctionCallArgumentList( - _ arguments: TupleExprElementListSyntax, + _ arguments: LabeledExprListSyntax, leftDelimiter: TokenSyntax?, rightDelimiter: TokenSyntax?, forcesBreakBeforeRightDelimiter: Bool @@ -1001,7 +1136,7 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { if !arguments.isEmpty { var afterLeftDelimiter: [Token] = [.break(.open, size: 0)] var beforeRightDelimiter: [Token] = [ - .break(.close(mustBreak: forcesBreakBeforeRightDelimiter), size: 0), + .break(.close(mustBreak: forcesBreakBeforeRightDelimiter), size: 0) ] if shouldGroupAroundArgumentList(arguments) { @@ -1028,34 +1163,45 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { /// - node: The tuple expression element. /// - shouldGroup: If true, group around the argument to prefer keeping it together if possible. private func arrangeAsFunctionCallArgument( - _ node: TupleExprElementSyntax, + _ node: LabeledExprSyntax, shouldGroup: Bool ) { if shouldGroup { - before(node.firstToken, tokens: .open) + before(node.firstToken(viewMode: .sourceAccurate), tokens: .open) } - // If we have an open delimiter following the colon, use a space instead of a continuation - // break so that we don't awkwardly shift the delimiter down and indent it further if it - // wraps. - let tokenAfterColon: Token = startsWithOpenDelimiter(Syntax(node.expression)) ? .space : .break + var additionalEndTokens = [Token]() + if let colon = node.colon { + // If we have an open delimiter following the colon, use a space instead of a continuation + // break so that we don't awkwardly shift the delimiter down and indent it further if it + // wraps. + var tokensAfterColon: [Token] = [ + startsWithOpenDelimiter(Syntax(node.expression)) ? .space : .break + ] + + if leftmostMultilineStringLiteral(of: node.expression) != nil { + tokensAfterColon.append(.break(.open(kind: .block), size: 0)) + additionalEndTokens = [.break(.close(mustBreak: false), size: 0)] + } - after(node.colon, tokens: tokenAfterColon) + after(colon, tokens: tokensAfterColon) + } if let trailingComma = node.trailingComma { + before(trailingComma, tokens: additionalEndTokens) var afterTrailingComma: [Token] = [.break(.same)] if shouldGroup { afterTrailingComma.insert(.close, at: 0) } after(trailingComma, tokens: afterTrailingComma) } else if shouldGroup { - after(node.lastToken, tokens: .close) + after(node.lastToken(viewMode: .sourceAccurate), tokens: additionalEndTokens + [.close]) } } override func visit(_ node: ClosureExprSyntax) -> SyntaxVisitorContinueKind { let newlineBehavior: NewlineBehavior - if forcedBreakingClosures.remove(node.id) != nil { + if forcedBreakingClosures.remove(node.id) != nil || node.statements.count > 1 { newlineBehavior = .soft } else { newlineBehavior = .elective @@ -1064,9 +1210,9 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { if let signature = node.signature { after(node.leftBrace, tokens: .break(.open)) if node.statements.count > 0 { - after(signature.inTok, tokens: .break(.same, newlines: newlineBehavior)) + after(signature.inKeyword, tokens: .break(.same, newlines: newlineBehavior)) } else { - after(signature.inTok, tokens: .break(.same, size: 0, newlines: newlineBehavior)) + after(signature.inKeyword, tokens: .break(.same, size: 0, newlines: newlineBehavior)) } before(node.rightBrace, tokens: .break(.close)) } else { @@ -1079,124 +1225,120 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { of: node, contentsKeyPath: \.statements, shouldResetBeforeLeftBrace: false, - openBraceNewlineBehavior: newlineBehavior) + openBraceNewlineBehavior: newlineBehavior + ) } return .visitChildren } - override func visit(_ node: ClosureParamSyntax) -> SyntaxVisitorContinueKind { + override func visit(_ node: ClosureShorthandParameterSyntax) -> SyntaxVisitorContinueKind { after(node.trailingComma, tokens: .break(.same)) return .visitChildren } override func visit(_ node: ClosureSignatureSyntax) -> SyntaxVisitorContinueKind { - before(node.firstToken, tokens: .open) + before(node.firstToken(viewMode: .sourceAccurate), tokens: .open) arrangeAttributeList( - node.attributes, suppressFinalBreak: node.input == nil && node.capture == nil) + node.attributes, + suppressFinalBreak: node.parameterClause == nil && node.capture == nil + ) - if let input = node.input { + if let parameterClause = node.parameterClause { // We unconditionally put a break before the `in` keyword below, so we should only put a break // after the capture list's right bracket if there are arguments following it or we'll end up // with an extra space if the line doesn't wrap. after(node.capture?.rightSquare, tokens: .break(.same)) - // When it's parenthesized, the input is a `ParameterClauseSyntax`. Otherwise, it's a + // When it's parenthesized, the parameterClause is a `ParameterClauseSyntax`. Otherwise, it's a // `ClosureParamListSyntax`. The parenthesized version is wrapped in open/close breaks so that // the parens create an extra level of indentation. - if let parameterClause = input.as(ParameterClauseSyntax.self) { + if let closureParameterClause = parameterClause.as(ClosureParameterClauseSyntax.self) { // Whether we should prioritize keeping ") throws -> " together. We can only do // this if the closure has arguments. let keepOutputTogether = - !parameterClause.parameterList.isEmpty && config.prioritizeKeepingFunctionOutputTogether + !closureParameterClause.parameters.isEmpty && config.prioritizeKeepingFunctionOutputTogether // Keep the output together by grouping from the right paren to the end of the output. if keepOutputTogether { // Due to visitation order, the matching .open break is added in ParameterClauseSyntax. // Since the output clause is optional but the in-token is required, placing the .close // before `inTok` ensures the close gets into the token stream. - before(node.inTok, tokens: .close) - } else { + before(node.inKeyword, tokens: .close) + } else { // Group outside of the parens, so that the argument list together, preferring to break // between the argument list and the output. - before(input.firstToken, tokens: .open) - after(input.lastToken, tokens: .close) + before(parameterClause.firstToken(viewMode: .sourceAccurate), tokens: .open) + after(parameterClause.lastToken(viewMode: .sourceAccurate), tokens: .close) } - arrangeParameterClause(parameterClause, forcesBreakBeforeRightParen: true) + arrangeClosureParameterClause(closureParameterClause, forcesBreakBeforeRightParen: true) } else { // Group around the arguments, but don't use open/close breaks because there are no parens // to create a new scope. - before(input.firstToken, tokens: .open(argumentListConsistency())) - after(input.lastToken, tokens: .close) + before(parameterClause.firstToken(viewMode: .sourceAccurate), tokens: .open(argumentListConsistency())) + after(parameterClause.lastToken(viewMode: .sourceAccurate), tokens: .close) } } - before(node.asyncKeyword, tokens: .break) - before(node.throwsTok, tokens: .break) - if let asyncKeyword = node.asyncKeyword, let throwsTok = node.throwsTok { - before(asyncKeyword, tokens: .open) - after(throwsTok, tokens: .close) - } - - before(node.output?.arrow, tokens: .break) - after(node.lastToken, tokens: .close) - before(node.inTok, tokens: .break(.same)) + before(node.returnClause?.arrow, tokens: .break) + after(node.lastToken(viewMode: .sourceAccurate), tokens: .close) + before(node.inKeyword, tokens: .break(.same)) return .visitChildren } - override func visit(_ node: ClosureCaptureSignatureSyntax) -> SyntaxVisitorContinueKind { + override func visit(_ node: ClosureCaptureClauseSyntax) -> SyntaxVisitorContinueKind { after(node.leftSquare, tokens: .break(.open, size: 0), .open) before(node.rightSquare, tokens: .break(.close, size: 0), .close) return .visitChildren } - override func visit(_ node: ClosureCaptureItemSyntax) -> SyntaxVisitorContinueKind { - before(node.firstToken, tokens: .open) - after(node.specifier?.lastToken, tokens: .break) - before(node.assignToken, tokens: .break) - after(node.assignToken, tokens: .break) + override func visit(_ node: ClosureCaptureSyntax) -> SyntaxVisitorContinueKind { + before(node.firstToken(viewMode: .sourceAccurate), tokens: .open) + after(node.specifier?.lastToken(viewMode: .sourceAccurate), tokens: .break) if let trailingComma = node.trailingComma { before(trailingComma, tokens: .close) after(trailingComma, tokens: .break(.same)) } else { - after(node.lastToken, tokens: .close) + after(node.lastToken(viewMode: .sourceAccurate), tokens: .close) } return .visitChildren } - override func visit(_ node: SubscriptExprSyntax) -> SyntaxVisitorContinueKind { + override func visit(_ node: SubscriptCallExprSyntax) -> SyntaxVisitorContinueKind { preVisitInsertingContextualBreaks(node) if let calledMemberAccessExpr = node.calledExpression.as(MemberAccessExprSyntax.self) { - if let base = calledMemberAccessExpr.base, base.is(IdentifierExprSyntax.self) { - before(base.firstToken, tokens: .open) - after(calledMemberAccessExpr.name.lastToken, tokens: .close) + if let base = calledMemberAccessExpr.base, base.is(DeclReferenceExprSyntax.self) { + before(base.firstToken(viewMode: .sourceAccurate), tokens: .open) + after(calledMemberAccessExpr.declName.baseName.lastToken(viewMode: .sourceAccurate), tokens: .close) } } - let arguments = node.argumentList + let arguments = node.arguments // If there is a trailing closure, force the right bracket down to the next line so it stays // with the open curly brace. let breakBeforeRightBracket = node.trailingClosure != nil - || mustBreakBeforeClosingDelimiter(of: node, argumentListPath: \.argumentList) + || mustBreakBeforeClosingDelimiter(of: node, argumentListPath: \.arguments) before( node.trailingClosure?.leftBrace, - tokens: .break(.same, newlines: .elective(ignoresDiscretionary: true))) + tokens: .break(.same, newlines: .elective(ignoresDiscretionary: true)) + ) arrangeFunctionCallArgumentList( arguments, - leftDelimiter: node.leftBracket, - rightDelimiter: node.rightBracket, - forcesBreakBeforeRightDelimiter: breakBeforeRightBracket) + leftDelimiter: node.leftSquare, + rightDelimiter: node.rightSquare, + forcesBreakBeforeRightDelimiter: breakBeforeRightBracket + ) return .visitChildren } - override func visitPost(_ node: SubscriptExprSyntax) { + override func visitPost(_ node: SubscriptCallExprSyntax) { clearContextualBreakState(node) } @@ -1210,27 +1352,65 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { } override func visit(_ node: MacroExpansionDeclSyntax) -> SyntaxVisitorContinueKind { + arrangeAttributeList(node.attributes, separateByLineBreaks: config.lineBreakBetweenDeclarationAttributes) + + before( + node.trailingClosure?.leftBrace, + tokens: .break(.same, newlines: .elective(ignoresDiscretionary: true)) + ) + arrangeFunctionCallArgumentList( - node.argumentList, + node.arguments, leftDelimiter: node.leftParen, rightDelimiter: node.rightParen, - forcesBreakBeforeRightDelimiter: false) + forcesBreakBeforeRightDelimiter: false + ) return .visitChildren } override func visit(_ node: MacroExpansionExprSyntax) -> SyntaxVisitorContinueKind { + let arguments = node.arguments + + // If there is a trailing closure, force the right parenthesis down to the next line so it + // stays with the open curly brace. + let breakBeforeRightParen = + (node.trailingClosure != nil && !isCompactSingleFunctionCallArgument(arguments)) + || mustBreakBeforeClosingDelimiter(of: node, argumentListPath: \.arguments) + + before( + node.trailingClosure?.leftBrace, + tokens: .break(.same, newlines: .elective(ignoresDiscretionary: true)) + ) + arrangeFunctionCallArgumentList( - node.argumentList, + arguments, leftDelimiter: node.leftParen, rightDelimiter: node.rightParen, - forcesBreakBeforeRightDelimiter: false) + forcesBreakBeforeRightDelimiter: breakBeforeRightParen + ) + return .visitChildren + } + + override func visit(_ node: ClosureParameterClauseSyntax) -> SyntaxVisitorContinueKind { + // Prioritize keeping ") throws -> " together. We can only do this if the function + // has arguments. + if !node.parameters.isEmpty && config.prioritizeKeepingFunctionOutputTogether { + // Due to visitation order, this .open corresponds to a .close added in FunctionDeclSyntax + // or SubscriptDeclSyntax. + before(node.rightParen, tokens: .open) + } + + return .visitChildren + } + + override func visit(_ node: EnumCaseParameterClauseSyntax) -> SyntaxVisitorContinueKind { return .visitChildren } - override func visit(_ node: ParameterClauseSyntax) -> SyntaxVisitorContinueKind { + override func visit(_ node: FunctionParameterClauseSyntax) -> SyntaxVisitorContinueKind { // Prioritize keeping ") throws -> " together. We can only do this if the function // has arguments. - if !node.parameterList.isEmpty && config.prioritizeKeepingFunctionOutputTogether { + if !node.parameters.isEmpty && config.prioritizeKeepingFunctionOutputTogether { // Due to visitation order, this .open corresponds to a .close added in FunctionDeclSyntax // or SubscriptDeclSyntax. before(node.rightParen, tokens: .open) @@ -1239,43 +1419,88 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { return .visitChildren } + override func visit(_ node: ClosureParameterSyntax) -> SyntaxVisitorContinueKind { + before(node.firstToken(viewMode: .sourceAccurate), tokens: .open) + arrangeAttributeList(node.attributes) + before( + node.secondName, + tokens: .break(.continue, newlines: .elective(ignoresDiscretionary: true)) + ) + after(node.colon, tokens: .break) + + if let trailingComma = node.trailingComma { + after(trailingComma, tokens: .close, .break(.same)) + } else { + after(node.lastToken(viewMode: .sourceAccurate), tokens: .close) + } + return .visitChildren + } + + override func visit(_ node: EnumCaseParameterSyntax) -> SyntaxVisitorContinueKind { + before(node.firstToken(viewMode: .sourceAccurate), tokens: .open) + before( + node.secondName, + tokens: .break(.continue, newlines: .elective(ignoresDiscretionary: true)) + ) + after(node.colon, tokens: .break) + + if let trailingComma = node.trailingComma { + after(trailingComma, tokens: .close, .break(.same)) + } else { + after(node.lastToken(viewMode: .sourceAccurate), tokens: .close) + } + return .visitChildren + } + override func visit(_ node: FunctionParameterSyntax) -> SyntaxVisitorContinueKind { - before(node.firstToken, tokens: .open) + before(node.firstToken(viewMode: .sourceAccurate), tokens: .open) arrangeAttributeList(node.attributes) before( node.secondName, - tokens: .break(.continue, newlines: .elective(ignoresDiscretionary: true))) + tokens: .break(.continue, newlines: .elective(ignoresDiscretionary: true)) + ) after(node.colon, tokens: .break) if let trailingComma = node.trailingComma { after(trailingComma, tokens: .close, .break(.same)) } else { - after(node.lastToken, tokens: .close) + after(node.lastToken(viewMode: .sourceAccurate), tokens: .close) } return .visitChildren } override func visit(_ node: ReturnClauseSyntax) -> SyntaxVisitorContinueKind { - after(node.arrow, tokens: .space) + if node.parent?.is(FunctionTypeSyntax.self) ?? false { + // `FunctionTypeSyntax` used to not use `ReturnClauseSyntax` and had + // slightly different formatting behavior than the normal + // `ReturnClauseSyntax`. To maintain the previous formatting behavior, + // add a special case. + before(node.arrow, tokens: .break) + before(node.type.firstToken(viewMode: .sourceAccurate), tokens: .break) + } else { + after(node.arrow, tokens: .space) + } // Member type identifier is used when the return type is a member of another type. Add a group // here so that the base, dot, and member type are kept together when they fit. - if node.returnType.is(MemberTypeIdentifierSyntax.self) { - before(node.returnType.firstToken, tokens: .open) - after(node.returnType.lastToken, tokens: .close) + if node.type.is(MemberTypeSyntax.self) { + before(node.type.firstToken(viewMode: .sourceAccurate), tokens: .open) + after(node.type.lastToken(viewMode: .sourceAccurate), tokens: .close) } return .visitChildren } override func visit(_ node: IfConfigDeclSyntax) -> SyntaxVisitorContinueKind { + // there has to be a break after an #endif + after(node.poundEndif, tokens: .break(.same, size: 0)) return .visitChildren } override func visit(_ node: IfConfigClauseSyntax) -> SyntaxVisitorContinueKind { switch node.poundKeyword.tokenKind { - case .poundIfKeyword, .poundElseifKeyword: + case .poundIf, .poundElseif: after(node.poundKeyword, tokens: .space) - case .poundElseKeyword: + case .poundElse: break default: preconditionFailure() @@ -1291,54 +1516,55 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { breakKindClose = .same } - let tokenToOpenWith = node.condition?.lastToken ?? node.poundKeyword + let tokenToOpenWith = node.condition?.lastToken(viewMode: .sourceAccurate) ?? node.poundKeyword after(tokenToOpenWith, tokens: .break(breakKindOpen), .open) // Unlike other code blocks, where we may want a single statement to be laid out on the same // line as a parent construct, the content of an `#if` block must always be on its own line; // the newline token inserted at the end enforces this. - if let lastElemTok = node.elements?.lastToken { + if let lastElemTok = node.elements?.lastToken(viewMode: .sourceAccurate) { after(lastElemTok, tokens: .break(breakKindClose, newlines: .soft), .close) } else { before(tokenToOpenWith.nextToken(viewMode: .all), tokens: .break(breakKindClose, newlines: .soft), .close) } - if isNestedInPostfixIfConfig(node: Syntax(node)) { + if !isNestedInPostfixIfConfig(node: Syntax(node)), let condition = node.condition { before( - node.firstToken, - tokens: [ - .printerControl(kind: .enableBreaking), - .break(.reset), - ] + condition.firstToken(viewMode: .sourceAccurate), + tokens: .printerControl(kind: .disableBreaking(allowDiscretionary: true)) ) - } else if let condition = node.condition { - before(condition.firstToken, tokens: .printerControl(kind: .disableBreaking)) after( - condition.lastToken, - tokens: .printerControl(kind: .enableBreaking), .break(.reset, size: 0)) + condition.lastToken(viewMode: .sourceAccurate), + tokens: .printerControl(kind: .enableBreaking), + .break(.reset, size: 0) + ) } return .visitChildren } - override func visit(_ node: MemberDeclBlockSyntax) -> SyntaxVisitorContinueKind { + override func visit(_ node: MemberBlockSyntax) -> SyntaxVisitorContinueKind { return .visitChildren } - override func visit(_ node: MemberDeclListSyntax) -> SyntaxVisitorContinueKind { + override func visit(_ node: MemberBlockItemListSyntax) -> SyntaxVisitorContinueKind { // Skip ignored items, because the tokens after `item.lastToken` would be ignored and leave // unclosed open tokens. for item in node where !shouldFormatterIgnore(node: Syntax(item)) { - before(item.firstToken, tokens: .open) + before(item.firstToken(viewMode: .sourceAccurate), tokens: .open) let newlines: NewlineBehavior = item != node.last && shouldInsertNewline(basedOn: item.semicolon) ? .soft : .elective let resetSize = item.semicolon != nil ? 1 : 0 - after(item.lastToken, tokens: .close, .break(.reset, size: resetSize, newlines: newlines)) + after( + item.lastToken(viewMode: .sourceAccurate), + tokens: .close, + .break(.reset, size: resetSize, newlines: newlines) + ) } return .visitChildren } - override func visit(_ node: MemberDeclListItemSyntax) -> SyntaxVisitorContinueKind { + override func visit(_ node: MemberBlockItemSyntax) -> SyntaxVisitorContinueKind { if shouldFormatterIgnore(node: Syntax(node)) { appendFormatterIgnored(node: Syntax(node)) return .skipChildren @@ -1348,24 +1574,26 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { override func visit(_ node: SourceFileSyntax) -> SyntaxVisitorContinueKind { if shouldFormatterIgnore(file: node) { - appendFormatterIgnored(node: Syntax(node)) + appendToken(.verbatim(Verbatim(text: "\(node)", indentingBehavior: .none))) return .skipChildren } - after(node.eofToken, tokens: .break(.same, newlines: .soft)) + after(node.shebang, tokens: .break(.same, newlines: .soft)) + after(node.endOfFileToken, tokens: .break(.same, newlines: .soft)) return .visitChildren } override func visit(_ node: EnumCaseDeclSyntax) -> SyntaxVisitorContinueKind { - before(node.firstToken, tokens: .open) + before(node.firstToken(viewMode: .sourceAccurate), tokens: .open) - arrangeAttributeList(node.attributes) + arrangeAttributeList(node.attributes, separateByLineBreaks: config.lineBreakBetweenDeclarationAttributes) after(node.caseKeyword, tokens: .break) - after(node.lastToken, tokens: .close) + after(node.lastToken(viewMode: .sourceAccurate), tokens: .close) return .visitChildren } override func visit(_ node: OperatorDeclSyntax) -> SyntaxVisitorContinueKind { + after(node.fixitySpecifier, tokens: .break) after(node.operatorKeyword, tokens: .break) return .visitChildren } @@ -1373,11 +1601,15 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { override func visit(_ node: OperatorPrecedenceAndTypesSyntax) -> SyntaxVisitorContinueKind { before(node.colon, tokens: .space) after(node.colon, tokens: .break(.open), .open) - after(node.designatedTypes.lastToken ?? node.lastToken, tokens: .break(.close, size: 0), .close) + after( + node.designatedTypes.lastToken(viewMode: .sourceAccurate) ?? node.lastToken(viewMode: .sourceAccurate), + tokens: .break(.close, size: 0), + .close + ) return .visitChildren } - override func visit(_ node: DesignatedTypeElementSyntax) -> SyntaxVisitorContinueKind { + override func visit(_ node: DesignatedTypeSyntax) -> SyntaxVisitorContinueKind { after(node.leadingComma, tokens: .break(.same)) return .visitChildren } @@ -1385,21 +1617,41 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { override func visit(_ node: EnumCaseElementSyntax) -> SyntaxVisitorContinueKind { after(node.trailingComma, tokens: .break) - if let associatedValue = node.associatedValue { - arrangeParameterClause(associatedValue, forcesBreakBeforeRightParen: false) + if let associatedValue = node.parameterClause { + arrangeEnumCaseParameterClause(associatedValue, forcesBreakBeforeRightParen: false) + } + + if let initializer = node.rawValue { + if let (unindentingNode, _, breakKind, shouldGroup) = + stackedIndentationBehavior(rhs: initializer.value) + { + var openTokens: [Token] = [.break(.open(kind: breakKind))] + if shouldGroup { + openTokens.append(.open) + } + after(initializer.equal, tokens: openTokens) + + var closeTokens: [Token] = [.break(.close(mustBreak: false), size: 0)] + if shouldGroup { + closeTokens.append(.close) + } + after(unindentingNode.lastToken(viewMode: .sourceAccurate), tokens: closeTokens) + } else { + after(initializer.equal, tokens: .break(.continue)) + } } return .visitChildren } - override func visit(_ node: ObjCSelectorSyntax) -> SyntaxVisitorContinueKind { + override func visit(_ node: ObjCSelectorPieceListSyntax) -> SyntaxVisitorContinueKind { insertTokens(.break(.same, size: 0), betweenElementsOf: node) return .visitChildren } override func visit(_ node: PrecedenceGroupDeclSyntax) -> SyntaxVisitorContinueKind { after(node.precedencegroupKeyword, tokens: .break) - after(node.identifier, tokens: .break(.reset)) + after(node.name, tokens: .break(.reset)) after(node.leftBrace, tokens: .break(.open, newlines: .soft)) before(node.rightBrace, tokens: .break(.close)) return .visitChildren @@ -1407,28 +1659,24 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { override func visit(_ node: PrecedenceGroupRelationSyntax) -> SyntaxVisitorContinueKind { after(node.colon, tokens: .break(.open)) - after(node.lastToken, tokens: .break(.close, newlines: .soft)) + after(node.lastToken(viewMode: .sourceAccurate), tokens: .break(.close, newlines: .soft)) return .visitChildren } override func visit(_ node: PrecedenceGroupAssignmentSyntax) -> SyntaxVisitorContinueKind { after(node.colon, tokens: .break(.open)) - after(node.lastToken, tokens: .break(.close, newlines: .soft)) + after(node.lastToken(viewMode: .sourceAccurate), tokens: .break(.close, newlines: .soft)) return .visitChildren } - override func visit(_ node: PrecedenceGroupNameElementSyntax) -> SyntaxVisitorContinueKind { + override func visit(_ node: PrecedenceGroupNameSyntax) -> SyntaxVisitorContinueKind { after(node.trailingComma, tokens: .break(.same)) return .visitChildren } override func visit(_ node: PrecedenceGroupAssociativitySyntax) -> SyntaxVisitorContinueKind { after(node.colon, tokens: .break(.open)) - after(node.lastToken, tokens: .break(.close, newlines: .soft)) - return .visitChildren - } - - override func visit(_ node: AccessLevelModifierSyntax) -> SyntaxVisitorContinueKind { + after(node.lastToken(viewMode: .sourceAccurate), tokens: .break(.close, newlines: .soft)) return .visitChildren } @@ -1440,11 +1688,15 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { // Skip ignored items, because the tokens after `item.lastToken` would be ignored and leave // unclosed open tokens. for item in node where !shouldFormatterIgnore(node: Syntax(item)) { - before(item.firstToken, tokens: .open) + before(item.firstToken(viewMode: .sourceAccurate), tokens: .open) let newlines: NewlineBehavior = item != node.last && shouldInsertNewline(basedOn: item.semicolon) ? .soft : .elective let resetSize = item.semicolon != nil ? 1 : 0 - after(item.lastToken, tokens: .close, .break(.reset, size: resetSize, newlines: newlines)) + after( + item.lastToken(viewMode: .sourceAccurate), + tokens: .close, + .break(.reset, size: resetSize, newlines: newlines) + ) } return .visitChildren } @@ -1457,22 +1709,24 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { // This group applies to a top-level if-stmt so that all of the bodies will have the same // breaking behavior. - if let ifStmt = node.item.as(IfStmtSyntax.self) { - before(ifStmt.conditions.firstToken, tokens: .open(.consistent)) - after(ifStmt.lastToken, tokens: .close) + if let exprStmt = node.item.as(ExpressionStmtSyntax.self), + let ifStmt = exprStmt.expression.as(IfExprSyntax.self) + { + before(ifStmt.conditions.firstToken(viewMode: .sourceAccurate), tokens: .open(.consistent)) + after(ifStmt.lastToken(viewMode: .sourceAccurate), tokens: .close) } return .visitChildren } override func visit(_ node: GenericParameterClauseSyntax) -> SyntaxVisitorContinueKind { - after(node.leftAngleBracket, tokens: .break(.open, size: 0), .open(argumentListConsistency())) - before(node.rightAngleBracket, tokens: .break(.close, size: 0), .close) + after(node.leftAngle, tokens: .break(.open, size: 0), .open(argumentListConsistency())) + before(node.rightAngle, tokens: .break(.close, size: 0), .close) return .visitChildren } override func visit(_ node: PrimaryAssociatedTypeClauseSyntax) -> SyntaxVisitorContinueKind { - after(node.leftAngleBracket, tokens: .break(.open, size: 0), .open(argumentListConsistency())) - before(node.rightAngleBracket, tokens: .break(.close, size: 0), .close) + after(node.leftAngle, tokens: .break(.open, size: 0), .open(argumentListConsistency())) + before(node.rightAngle, tokens: .break(.close, size: 0), .close) return .visitChildren } @@ -1487,16 +1741,17 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { } override func visit(_ node: TupleTypeElementSyntax) -> SyntaxVisitorContinueKind { - before(node.firstToken, tokens: .open) + before(node.firstToken(viewMode: .sourceAccurate), tokens: .open) before( node.secondName, - tokens: .break(.continue, newlines: .elective(ignoresDiscretionary: true))) + tokens: .break(.continue, newlines: .elective(ignoresDiscretionary: true)) + ) after(node.colon, tokens: .break) if let trailingComma = node.trailingComma { after(trailingComma, tokens: .close, .break(.same)) } else { - after(node.lastToken, tokens: .close) + after(node.lastToken(viewMode: .sourceAccurate), tokens: .close) } return .visitChildren } @@ -1504,16 +1759,12 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { override func visit(_ node: FunctionTypeSyntax) -> SyntaxVisitorContinueKind { after(node.leftParen, tokens: .break(.open, size: 0), .open) before(node.rightParen, tokens: .break(.close, size: 0), .close) - before(node.asyncKeyword, tokens: .break) - before(node.throwsOrRethrowsKeyword, tokens: .break) - before(node.arrow, tokens: .break) - before(node.returnType.firstToken, tokens: .break) return .visitChildren } override func visit(_ node: GenericArgumentClauseSyntax) -> SyntaxVisitorContinueKind { - after(node.leftAngleBracket, tokens: .break(.open, size: 0), .open) - before(node.rightAngleBracket, tokens: .break(.close, size: 0), .close) + after(node.leftAngle, tokens: .break(.open, size: 0), .open) + before(node.rightAngle, tokens: .break(.close, size: 0), .close) return .visitChildren } @@ -1525,8 +1776,9 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { override func visit(_ node: TryExprSyntax) -> SyntaxVisitorContinueKind { before( - node.expression.firstToken, - tokens: .break(.continue, newlines: .elective(ignoresDiscretionary: true))) + node.expression.firstToken(viewMode: .sourceAccurate), + tokens: .break(.continue, newlines: .elective(ignoresDiscretionary: true)) + ) // Check for an anchor token inside of the expression to group with the try keyword. if let anchorToken = findTryAwaitExprConnectingToken(inExpr: node.expression) { @@ -1539,8 +1791,9 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { override func visit(_ node: AwaitExprSyntax) -> SyntaxVisitorContinueKind { before( - node.expression.firstToken, - tokens: .break(.continue, newlines: .elective(ignoresDiscretionary: true))) + node.expression.firstToken(viewMode: .sourceAccurate), + tokens: .break(.continue, newlines: .elective(ignoresDiscretionary: true)) + ) // Check for an anchor token inside of the expression to group with the await keyword. if !(node.parent?.is(TryExprSyntax.self) ?? false), @@ -1566,18 +1819,17 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { if let callingExpr = expr.asProtocol(CallingExprSyntaxProtocol.self) { return findTryAwaitExprConnectingToken(inExpr: callingExpr.calledExpression) } - if let memberAccessExpr = expr.as(MemberAccessExprSyntax.self), let base = memberAccessExpr.base - { + if let memberAccessExpr = expr.as(MemberAccessExprSyntax.self), let base = memberAccessExpr.base { // When there's a simple base (i.e. identifier), group the entire `try/await .` // sequence. This check has to happen here so that the `MemberAccessExprSyntax.name` is // available. - if base.is(IdentifierExprSyntax.self) { - return memberAccessExpr.name.lastToken + if base.is(DeclReferenceExprSyntax.self) { + return memberAccessExpr.declName.baseName.lastToken(viewMode: .sourceAccurate) } return findTryAwaitExprConnectingToken(inExpr: base) } - if expr.is(IdentifierExprSyntax.self) { - return expr.lastToken + if expr.is(DeclReferenceExprSyntax.self) { + return expr.lastToken(viewMode: .sourceAccurate) } return nil } @@ -1587,61 +1839,109 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { } override func visit(_ node: AttributeSyntax) -> SyntaxVisitorContinueKind { - before(node.firstToken, tokens: .open) - if node.argument != nil { + before(node.firstToken(viewMode: .sourceAccurate), tokens: .open) + switch node.arguments { + case .argumentList(let argumentList)?: + if let leftParen = node.leftParen, let rightParen = node.rightParen { + arrangeFunctionCallArgumentList( + argumentList, + leftDelimiter: leftParen, + rightDelimiter: rightParen, + forcesBreakBeforeRightDelimiter: false + ) + } + case .some: // Wrap the attribute's arguments in their own group, so arguments stay together with a higher // affinity than the overall attribute (e.g. allows a break after the opening "(" and then // having the entire argument list on 1 line). Necessary spaces and breaks are added inside of // the argument, using type specific visitor methods. after(node.leftParen, tokens: .break(.open, size: 0), .open(argumentListConsistency())) before(node.rightParen, tokens: .break(.close, size: 0), .close) + case nil: + break } - after(node.lastToken, tokens: .close) + after(node.lastToken(viewMode: .sourceAccurate), tokens: .close) return .visitChildren } - override func visit(_ node: CustomAttributeSyntax) -> SyntaxVisitorContinueKind { - // "Custom attributes" are better known to users as "property wrappers". - before(node.firstToken, tokens: .open) - if let argumentList = node.argumentList, - let leftParen = node.leftParen, let rightParen = node.rightParen - { - arrangeFunctionCallArgumentList( - argumentList, - leftDelimiter: leftParen, - rightDelimiter: rightParen, - forcesBreakBeforeRightDelimiter: false) - } - after(node.lastToken, tokens: .close) + override func visit(_ node: AvailabilityArgumentListSyntax) -> SyntaxVisitorContinueKind { + insertTokens(.break(.same, size: 1), betweenElementsOf: node) return .visitChildren } - override func visit(_ node: AvailabilitySpecListSyntax) -> SyntaxVisitorContinueKind { - insertTokens(.break(.same, size: 1), betweenElementsOf: node) + override func visit(_ node: OriginallyDefinedInAttributeArgumentsSyntax) -> SyntaxVisitorContinueKind { + after(node.colon.lastToken(viewMode: .sourceAccurate), tokens: .break(.same, size: 1)) + after(node.comma.lastToken(viewMode: .sourceAccurate), tokens: .break(.same, size: 1)) + return .visitChildren + } + + override func visit(_ node: DocumentationAttributeArgumentSyntax) -> SyntaxVisitorContinueKind { + after(node.colon, tokens: .break(.same, size: 1)) + return .visitChildren + } + + override func visit(_ node: ExposeAttributeArgumentsSyntax) -> SyntaxVisitorContinueKind { + after(node.comma, tokens: .break(.same, size: 1)) return .visitChildren } override func visit(_ node: AvailabilityLabeledArgumentSyntax) -> SyntaxVisitorContinueKind { before(node.label, tokens: .open) - after(node.colon, tokens: .break(.continue, newlines: .elective(ignoresDiscretionary: true))) - after(node.value.lastToken, tokens: .close) + + let tokensAfterColon: [Token] + let endTokens: [Token] + + if case .string(let string) = node.value, + string.openingQuote.tokenKind == .multilineStringQuote + { + tokensAfterColon = + [.break(.open(kind: .block), newlines: .elective(ignoresDiscretionary: true))] + endTokens = [.break(.close(mustBreak: false), size: 0), .close] + } else { + tokensAfterColon = [.break(.continue, newlines: .elective(ignoresDiscretionary: true))] + endTokens = [.close] + } + + after(node.colon, tokens: tokensAfterColon) + after(node.value.lastToken(viewMode: .sourceAccurate), tokens: endTokens) return .visitChildren } - override func visit(_ node: AvailabilityVersionRestrictionSyntax) -> SyntaxVisitorContinueKind { - before(node.firstToken, tokens: .open) + override func visit( + _ node: PlatformVersionItemListSyntax + ) -> SyntaxVisitorContinueKind { + insertTokens(.break(.same, size: 1), betweenElementsOf: node) + return .visitChildren + } + + override func visit(_ node: PlatformVersionSyntax) -> SyntaxVisitorContinueKind { + before(node.firstToken(viewMode: .sourceAccurate), tokens: .open) after(node.platform, tokens: .break(.continue, size: 1)) - after(node.lastToken, tokens: .close) + after(node.lastToken(viewMode: .sourceAccurate), tokens: .close) + return .visitChildren + } + + override func visit(_ node: BackDeployedAttributeArgumentsSyntax) -> SyntaxVisitorContinueKind { + before( + node.platforms.firstToken(viewMode: .sourceAccurate), + tokens: .break(.open, size: 1), + .open(argumentListConsistency()) + ) + after( + node.platforms.lastToken(viewMode: .sourceAccurate), + tokens: .break(.close, size: 0), + .close + ) return .visitChildren } override func visit(_ node: ConditionElementSyntax) -> SyntaxVisitorContinueKind { - before(node.firstToken, tokens: .open) + before(node.firstToken(viewMode: .sourceAccurate), tokens: .open) if let comma = node.trailingComma { after(comma, tokens: .close, .break(.same)) closingDelimiterTokens.insert(comma) } else { - after(node.lastToken, tokens: .close) + after(node.lastToken(viewMode: .sourceAccurate), tokens: .close) } return .visitChildren } @@ -1651,16 +1951,69 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { } override func visit(_ node: ImportDeclSyntax) -> SyntaxVisitorContinueKind { - after(node.attributes?.lastToken, tokens: .space) - after(node.importTok, tokens: .space) - after(node.importKind, tokens: .space) + // Import declarations should never be wrapped. + before( + node.firstToken(viewMode: .sourceAccurate), + tokens: .printerControl(kind: .disableBreaking(allowDiscretionary: false)) + ) + + arrangeAttributeList(node.attributes) + after(node.importKeyword, tokens: .space) + after(node.importKindSpecifier, tokens: .space) + + after(node.lastToken(viewMode: .sourceAccurate), tokens: .printerControl(kind: .enableBreaking)) return .visitChildren } override func visit(_ node: KeyPathExprSyntax) -> SyntaxVisitorContinueKind { + before(node.backslash, tokens: .open) + after(node.lastToken(viewMode: .sourceAccurate), tokens: .close) return .visitChildren } + override func visit(_ node: KeyPathComponentSyntax) -> SyntaxVisitorContinueKind { + // If this is the first component (immediately after the backslash), allow a break after the + // slash only if a typename follows it. Do not break in the middle of `\.`. + var breakBeforePeriod = true + if let keyPathComponents = node.parent?.as(KeyPathComponentListSyntax.self), + let keyPathExpr = keyPathComponents.parent?.as(KeyPathExprSyntax.self), + node == keyPathExpr.components.first, keyPathExpr.root == nil + { + breakBeforePeriod = false + } + if breakBeforePeriod { + before(node.period, tokens: .break(.continue, size: 0)) + } + return .visitChildren + } + + override func visit(_ node: KeyPathSubscriptComponentSyntax) -> SyntaxVisitorContinueKind { + var breakBeforeRightParen = !isCompactSingleFunctionCallArgument(node.arguments) + if let component = node.parent?.as(KeyPathComponentSyntax.self) { + breakBeforeRightParen = !isLastKeyPathComponent(component) + } + + arrangeFunctionCallArgumentList( + node.arguments, + leftDelimiter: node.leftSquare, + rightDelimiter: node.rightSquare, + forcesBreakBeforeRightDelimiter: breakBeforeRightParen + ) + return .visitChildren + } + + /// Returns a value indicating whether the given key path component was the last component in the + /// list containing it. + private func isLastKeyPathComponent(_ component: KeyPathComponentSyntax) -> Bool { + guard + let componentList = component.parent?.as(KeyPathComponentListSyntax.self), + let lastComponent = componentList.last + else { + return false + } + return component == lastComponent + } + override func visit(_ node: TernaryExprSyntax) -> SyntaxVisitorContinueKind { // The order of the .open/.close tokens here is intentional. They are normally paired with the // corresponding breaks, but in this case, we want to prioritize keeping the entire `? a : b` @@ -1669,17 +2022,20 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { before(node.questionMark, tokens: .break(.open(kind: .continuation)), .open) after(node.questionMark, tokens: .space) before( - node.colonMark, - tokens: .break(.close(mustBreak: false), size: 0), .break(.open(kind: .continuation)), .open) - after(node.colonMark, tokens: .space) + node.colon, + tokens: .break(.close(mustBreak: false), size: 0), + .break(.open(kind: .continuation)), + .open + ) + after(node.colon, tokens: .space) // When the ternary is wrapped in parens, absorb the closing paren into the ternary's group so // that it is glued to the last token of the ternary. let closeScopeToken: TokenSyntax? - if let parenExpr = outermostEnclosingNode(from: Syntax(node.secondChoice)) { - closeScopeToken = parenExpr.lastToken + if let parenExpr = outermostEnclosingNode(from: Syntax(node.elseExpression)) { + closeScopeToken = parenExpr.lastToken(viewMode: .sourceAccurate) } else { - closeScopeToken = node.secondChoice.lastToken + closeScopeToken = node.elseExpression.lastToken(viewMode: .sourceAccurate) } after(closeScopeToken, tokens: .break(.close(mustBreak: false), size: 0), .close, .close) return .visitChildren @@ -1713,7 +2069,7 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { } before(node.whereKeyword, tokens: wherePrecedingBreak, .open) after(node.whereKeyword, tokens: .break) - after(node.lastToken, tokens: .close) + after(node.lastToken(viewMode: .sourceAccurate), tokens: .close) return .visitChildren } @@ -1722,25 +2078,17 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { // this special exception for `async let` statements to avoid breaking prematurely between the // `async` and `let` keywords. let breakOrSpace: Token - if node.name.tokenKind == .contextualKeyword("async") { + if node.name.tokenKind == .keyword(.async) { breakOrSpace = .space } else { breakOrSpace = .break } - after(node.lastToken, tokens: breakOrSpace) + after(node.lastToken(viewMode: .sourceAccurate), tokens: breakOrSpace) return .visitChildren } override func visit(_ node: FunctionSignatureSyntax) -> SyntaxVisitorContinueKind { - before(node.asyncOrReasyncKeyword, tokens: .break) - before(node.throwsOrRethrowsKeyword, tokens: .break) - if let asyncOrReasyncKeyword = node.asyncOrReasyncKeyword, - let throwsOrRethrowsKeyword = node.throwsOrRethrowsKeyword - { - before(asyncOrReasyncKeyword, tokens: .open) - after(throwsOrRethrowsKeyword, tokens: .close) - } - before(node.output?.firstToken, tokens: .break) + before(node.returnClause?.firstToken(viewMode: .sourceAccurate), tokens: .break) return .visitChildren } @@ -1753,25 +2101,15 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { } override func visit(_ node: InfixOperatorExprSyntax) -> SyntaxVisitorContinueKind { - // FIXME: This is a workaround/hack for https://github.com/apple/swift-syntax/issues/928. For - // keypaths like `\.?.foo`, they get represented (after folding) as an infix operator expression - // with an empty keypath, followed by the "binary operator" `.?.`, followed by other - // expressions. We can detect this and treat the whole thing as a verbatim node, which mimics - // what we do today for keypaths (i.e., nothing). - if let keyPathExpr = node.leftOperand.as(KeyPathExprSyntax.self), - keyPathExpr.components.isEmpty - { - // If there were spaces in the trailing trivia of the previous token, they would have been - // ignored (since this expression would be expected to insert its own preceding breaks). - // Preserve that whitespace verbatim for now. - if let previousToken = node.firstToken?.previousToken { - appendTrailingTrivia(previousToken, forced: true) - } - verbatimToken(Syntax(node), indentingBehavior: .none) - return .skipChildren + let binOp = node.operator + if binOp.is(ArrowExprSyntax.self) { + // `ArrowExprSyntax` nodes occur when a function type is written in an expression context; + // for example, `let x = [(Int) throws -> Void]()`. We want to treat those consistently like + // we do other function return clauses and not treat them as regular binary operators, so + // handle that behavior there instead. + return .visitChildren } - let binOp = node.operatorOperand let rhs = node.rightOperand maybeGroupAroundSubexpression(rhs, combiningOperator: binOp) @@ -1783,9 +2121,19 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { // If the rhs starts with a parenthesized expression, stack indentation around it. // Otherwise, use regular continuation breaks. - if let (unindentingNode, _) = stackedIndentationBehavior(after: binOp, rhs: rhs) { - beforeTokens = [.break(.open(kind: .continuation))] - after(unindentingNode.lastToken, tokens: [.break(.close(mustBreak: false), size: 0)]) + if let (unindentingNode, _, breakKind, shouldGroup) = + stackedIndentationBehavior(after: binOp, rhs: rhs) + { + beforeTokens = [.break(.open(kind: breakKind))] + var afterTokens: [Token] = [.break(.close(mustBreak: false), size: 0)] + if shouldGroup { + beforeTokens.append(.open) + afterTokens.append(.close) + } + after( + unindentingNode.lastToken(viewMode: .sourceAccurate), + tokens: afterTokens + ) } else { beforeTokens = [.break(.continue)] } @@ -1793,13 +2141,13 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { // When the RHS is a simple expression, even if is requires multiple lines, we don't add a // group so that as much of the expression as possible can stay on the same line as the // operator token. - if isCompoundExpression(rhs) { + if isCompoundExpression(rhs) && leftmostMultilineStringLiteral(of: rhs) == nil { beforeTokens.append(.open) - after(rhs.lastToken, tokens: .close) + after(rhs.lastToken(viewMode: .sourceAccurate), tokens: .close) } - after(binOp.lastToken, tokens: beforeTokens) - } else if let (unindentingNode, shouldReset) = + after(binOp.lastToken(viewMode: .sourceAccurate), tokens: beforeTokens) + } else if let (unindentingNode, shouldReset, breakKind, shouldGroup) = stackedIndentationBehavior(after: binOp, rhs: rhs) { // For parenthesized expressions and for unparenthesized usages of `&&` and `||`, we don't @@ -1809,29 +2157,35 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { // use open-continuation/close pairs around such operators and their right-hand sides so // that the continuation breaks inside those scopes "stack", instead of receiving the // usual single-level "continuation line or not" behavior. - let openBreakTokens: [Token] = [.break(.open(kind: .continuation)), .open] + var openBreakTokens: [Token] = [.break(.open(kind: breakKind))] + if shouldGroup { + openBreakTokens.append(.open) + } if wrapsBeforeOperator { - before(binOp.firstToken, tokens: openBreakTokens) + before(binOp.firstToken(viewMode: .sourceAccurate), tokens: openBreakTokens) } else { - after(binOp.lastToken, tokens: openBreakTokens) + after(binOp.lastToken(viewMode: .sourceAccurate), tokens: openBreakTokens) } - let closeBreakTokens: [Token] = + var closeBreakTokens: [Token] = (shouldReset ? [.break(.reset, size: 0)] : []) - + [.break(.close(mustBreak: false), size: 0), .close] - after(unindentingNode.lastToken, tokens: closeBreakTokens) + + [.break(.close(mustBreak: false), size: 0)] + if shouldGroup { + closeBreakTokens.append(.close) + } + after(unindentingNode.lastToken(viewMode: .sourceAccurate), tokens: closeBreakTokens) } else { if wrapsBeforeOperator { - before(binOp.firstToken, tokens: .break(.continue)) + before(binOp.firstToken(viewMode: .sourceAccurate), tokens: .break(.continue)) } else { - after(binOp.lastToken, tokens: .break(.continue)) + after(binOp.lastToken(viewMode: .sourceAccurate), tokens: .break(.continue)) } } if wrapsBeforeOperator { - after(binOp.lastToken, tokens: .space) + after(binOp.lastToken(viewMode: .sourceAccurate), tokens: .space) } else { - before(binOp.firstToken, tokens: .space) + before(binOp.firstToken(viewMode: .sourceAccurate), tokens: .space) } } @@ -1842,26 +2196,31 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { return .visitChildren } - override func visit(_ node: PostfixUnaryExprSyntax) -> SyntaxVisitorContinueKind { + override func visit(_ node: PostfixOperatorExprSyntax) -> SyntaxVisitorContinueKind { return .visitChildren } override func visit(_ node: AsExprSyntax) -> SyntaxVisitorContinueKind { - before(node.asTok, tokens: .break(.continue), .open) - before(node.typeName.firstToken, tokens: .space) - after(node.lastToken, tokens: .close) + before(node.asKeyword, tokens: .break(.continue), .open) + before(node.type.firstToken(viewMode: .sourceAccurate), tokens: .space) + after(node.lastToken(viewMode: .sourceAccurate), tokens: .close) return .visitChildren } override func visit(_ node: IsExprSyntax) -> SyntaxVisitorContinueKind { - before(node.isTok, tokens: .break(.continue), .open) - before(node.typeName.firstToken, tokens: .space) - after(node.lastToken, tokens: .close) + before(node.isKeyword, tokens: .break(.continue), .open) + before(node.type.firstToken(viewMode: .sourceAccurate), tokens: .space) + after(node.lastToken(viewMode: .sourceAccurate), tokens: .close) return .visitChildren } override func visit(_ node: SequenceExprSyntax) -> SyntaxVisitorContinueKind { - preconditionFailure("SequenceExpr should have already been folded.") + preconditionFailure( + """ + SequenceExpr should have already been folded; found at byte offsets \ + \(node.position.utf8Offset)..<\(node.endPosition.utf8Offset) + """ + ) } override func visit(_ node: AssignmentExprSyntax) -> SyntaxVisitorContinueKind { @@ -1875,38 +2234,37 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { } override func visit(_ node: ArrowExprSyntax) -> SyntaxVisitorContinueKind { - // The break before the `throws` keyword is inserted at the `InfixOperatorExpr` level so that it - // is placed in the correct relative position to the group surrounding the "operator". - after(node.throwsToken, tokens: .break) + before(node.arrow, tokens: .break) + after(node.arrow, tokens: .space) return .visitChildren } - override func visit(_ node: SuperRefExprSyntax) -> SyntaxVisitorContinueKind { + override func visit(_ node: SuperExprSyntax) -> SyntaxVisitorContinueKind { return .visitChildren } override func visit(_ node: VariableDeclSyntax) -> SyntaxVisitorContinueKind { - arrangeAttributeList(node.attributes) + arrangeAttributeList(node.attributes, separateByLineBreaks: config.lineBreakBetweenDeclarationAttributes) if node.bindings.count == 1 { // If there is only a single binding, don't allow a break between the `let/var` keyword and // the identifier; there are better places to break later on. - after(node.letOrVarKeyword, tokens: .space) + after(node.bindingSpecifier, tokens: .space) } else { // If there is more than one binding, we permit an open-break after `let/var` so that each of // the comma-delimited items will potentially receive indentation. We also add a group around // the individual bindings to bind them together better. (This is done here, not in // `visit(_: PatternBindingSyntax)`, because we only want that behavior when there are // multiple bindings.) - after(node.letOrVarKeyword, tokens: .break(.open)) + after(node.bindingSpecifier, tokens: .break(.open)) for binding in node.bindings { - before(binding.firstToken, tokens: .open) + before(binding.firstToken(viewMode: .sourceAccurate), tokens: .open) after(binding.trailingComma, tokens: .break(.same)) - after(binding.lastToken, tokens: .close) + after(binding.lastToken(viewMode: .sourceAccurate), tokens: .close) } - after(node.lastToken, tokens: .break(.close, size: 0)) + after(node.lastToken(viewMode: .sourceAccurate), tokens: .break(.close, size: 0)) } return .visitChildren @@ -1923,36 +2281,49 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { if let typeAnnotation = node.typeAnnotation, !typeAnnotation.type.is(MissingTypeSyntax.self) { after( typeAnnotation.colon, - tokens: .break(.open(kind: .continuation), newlines: .elective(ignoresDiscretionary: true))) + tokens: .break(.open(kind: .continuation), newlines: .elective(ignoresDiscretionary: true)) + ) closesNeeded += 1 - closeAfterToken = typeAnnotation.lastToken + closeAfterToken = typeAnnotation.lastToken(viewMode: .sourceAccurate) } if let initializer = node.initializer { let expr = initializer.value - if let (unindentingNode, _) = stackedIndentationBehavior(rhs: expr) { - after(initializer.equal, tokens: .break(.open(kind: .continuation))) - after(unindentingNode.lastToken, tokens: .break(.close(mustBreak: false), size: 0)) + if let (unindentingNode, _, breakKind, shouldGroup) = stackedIndentationBehavior(rhs: expr) { + var openTokens: [Token] = [.break(.open(kind: breakKind))] + if shouldGroup { + openTokens.append(.open) + } + after(initializer.equal, tokens: openTokens) + var closeTokens: [Token] = [.break(.close(mustBreak: false), size: 0)] + if shouldGroup { + closeTokens.append(.close) + } + after(unindentingNode.lastToken(viewMode: .sourceAccurate), tokens: closeTokens) } else { after(initializer.equal, tokens: .break(.continue)) } - closeAfterToken = initializer.lastToken + closeAfterToken = initializer.lastToken(viewMode: .sourceAccurate) // When the RHS is a simple expression, even if is requires multiple lines, we don't add a // group so that as much of the expression as possible can stay on the same line as the // operator token. - if isCompoundExpression(expr) { - before(expr.firstToken, tokens: .open) - after(expr.lastToken, tokens: .close) + if isCompoundExpression(expr) && leftmostMultilineStringLiteral(of: expr) == nil { + before(expr.firstToken(viewMode: .sourceAccurate), tokens: .open) + after(expr.lastToken(viewMode: .sourceAccurate), tokens: .close) } } - if let accessorOrCodeBlock = node.accessor { - switch accessorOrCodeBlock { - case .accessors(let accessorBlock): - arrangeBracesAndContents(of: accessorBlock) - case .getter(let codeBlock): - arrangeBracesAndContents(of: codeBlock, contentsKeyPath: \.statements) + if let accessorBlock = node.accessorBlock { + switch accessorBlock.accessors { + case .accessors(let accessors): + arrangeBracesAndContents( + leftBrace: accessorBlock.leftBrace, + accessors: accessors, + rightBrace: accessorBlock.rightBrace + ) + case .getter: + arrangeBracesAndContents(of: accessorBlock, contentsKeyPath: \.getterCodeBlockItems) } } else if let trailingComma = node.trailingComma { // If this is one of multiple comma-delimited bindings, move any pending close breaks to @@ -1969,10 +2340,6 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { return .visitChildren } - override func visit(_ node: AsTypePatternSyntax) -> SyntaxVisitorContinueKind { - return .visitChildren - } - override func visit(_ node: InheritedTypeSyntax) -> SyntaxVisitorContinueKind { after(node.trailingComma, tokens: .break(.same)) return .visitChildren @@ -1983,14 +2350,14 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { return .visitChildren } - override func visit(_ node: TypealiasDeclSyntax) -> SyntaxVisitorContinueKind { - arrangeAttributeList(node.attributes) + override func visit(_ node: TypeAliasDeclSyntax) -> SyntaxVisitorContinueKind { + arrangeAttributeList(node.attributes, separateByLineBreaks: config.lineBreakBetweenDeclarationAttributes) after(node.typealiasKeyword, tokens: .break) if let genericWhereClause = node.genericWhereClause { - before(genericWhereClause.firstToken, tokens: .break(.same), .open) - after(node.lastToken, tokens: .close) + before(genericWhereClause.firstToken(viewMode: .sourceAccurate), tokens: .break(.same), .open) + after(node.lastToken(viewMode: .sourceAccurate), tokens: .close) } return .visitChildren } @@ -2003,17 +2370,16 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { override func visit(_ node: AttributedTypeSyntax) -> SyntaxVisitorContinueKind { arrangeAttributeList(node.attributes) - after( - node.specifier, - tokens: .break(.continue, newlines: .elective(ignoresDiscretionary: true))) - return .visitChildren - } - - override func visit(_ node: ExpressionStmtSyntax) -> SyntaxVisitorContinueKind { + for specifier in node.specifiers { + after( + specifier.firstToken(viewMode: .sourceAccurate), + tokens: .break(.continue, newlines: .elective(ignoresDiscretionary: true)) + ) + } return .visitChildren } - override func visit(_ node: IdentifierExprSyntax) -> SyntaxVisitorContinueKind { + override func visit(_ node: DeclReferenceExprSyntax) -> SyntaxVisitorContinueKind { return .visitChildren } @@ -2021,17 +2387,17 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { return .visitChildren } - override func visit(_ node: SpecializeExprSyntax) -> SyntaxVisitorContinueKind { + override func visit(_ node: GenericSpecializationExprSyntax) -> SyntaxVisitorContinueKind { return .visitChildren } override func visit(_ node: TypeAnnotationSyntax) -> SyntaxVisitorContinueKind { - before(node.type.firstToken, tokens: .open) - after(node.type.lastToken, tokens: .close) + before(node.type.firstToken(viewMode: .sourceAccurate), tokens: .open) + after(node.type.lastToken(viewMode: .sourceAccurate), tokens: .close) return .visitChildren } - override func visit(_ node: ConstrainedSugarTypeSyntax) -> SyntaxVisitorContinueKind { + override func visit(_ node: SomeOrAnyTypeSyntax) -> SyntaxVisitorContinueKind { after(node.someOrAnySpecifier, tokens: .space) return .visitChildren } @@ -2040,36 +2406,24 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { return .visitChildren } - override func visit(_ node: DeclarationStmtSyntax) -> SyntaxVisitorContinueKind { + override func visit(_ node: FallThroughStmtSyntax) -> SyntaxVisitorContinueKind { return .visitChildren } - override func visit(_ node: EnumCasePatternSyntax) -> SyntaxVisitorContinueKind { - return .visitChildren - } - - override func visit(_ node: FallthroughStmtSyntax) -> SyntaxVisitorContinueKind { - return .visitChildren - } - - override func visit(_ node: ForcedValueExprSyntax) -> SyntaxVisitorContinueKind { + override func visit(_ node: ForceUnwrapExprSyntax) -> SyntaxVisitorContinueKind { return .visitChildren } override func visit(_ node: GenericArgumentSyntax) -> SyntaxVisitorContinueKind { - before(node.firstToken, tokens: .open) + before(node.firstToken(viewMode: .sourceAccurate), tokens: .open) if let trailingComma = node.trailingComma { after(trailingComma, tokens: .close, .break(.same)) } else { - after(node.lastToken, tokens: .close) + after(node.lastToken(viewMode: .sourceAccurate), tokens: .close) } return .visitChildren } - override func visit(_ node: OptionalPatternSyntax) -> SyntaxVisitorContinueKind { - return .visitChildren - } - override func visit(_ node: WildcardPatternSyntax) -> SyntaxVisitorContinueKind { return .visitChildren } @@ -2083,32 +2437,55 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { } override func visit(_ node: GenericParameterSyntax) -> SyntaxVisitorContinueKind { - before(node.firstToken, tokens: .open) + before(node.firstToken(viewMode: .sourceAccurate), tokens: .open) + after(node.specifier, tokens: .break) after(node.colon, tokens: .break) if let trailingComma = node.trailingComma { after(trailingComma, tokens: .close, .break(.same)) } else { - after(node.lastToken, tokens: .close) + after(node.lastToken(viewMode: .sourceAccurate), tokens: .close) } return .visitChildren } override func visit(_ node: PrimaryAssociatedTypeSyntax) -> SyntaxVisitorContinueKind { - before(node.firstToken, tokens: .open) + before(node.firstToken(viewMode: .sourceAccurate), tokens: .open) if let trailingComma = node.trailingComma { after(trailingComma, tokens: .close, .break(.same)) } else { - after(node.lastToken, tokens: .close) + after(node.lastToken(viewMode: .sourceAccurate), tokens: .close) } return .visitChildren } + override func visit(_ node: PackElementExprSyntax) -> SyntaxVisitorContinueKind { + // `each` cannot be separated from the following token, or it is parsed as an identifier itself. + after(node.eachKeyword, tokens: .space) + return .visitChildren + } + + override func visit(_ node: PackElementTypeSyntax) -> SyntaxVisitorContinueKind { + // `each` cannot be separated from the following token, or it is parsed as an identifier itself. + after(node.eachKeyword, tokens: .space) + return .visitChildren + } + + override func visit(_ node: PackExpansionExprSyntax) -> SyntaxVisitorContinueKind { + after(node.repeatKeyword, tokens: .break) + return .visitChildren + } + + override func visit(_ node: PackExpansionTypeSyntax) -> SyntaxVisitorContinueKind { + after(node.repeatKeyword, tokens: .break) + return .visitChildren + } + override func visit(_ node: ExpressionPatternSyntax) -> SyntaxVisitorContinueKind { return .visitChildren } override func visit(_ node: ValueBindingPatternSyntax) -> SyntaxVisitorContinueKind { - after(node.letOrVarKeyword, tokens: .break) + after(node.bindingSpecifier, tokens: .break) return .visitChildren } @@ -2119,52 +2496,147 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { override func visit(_ node: InitializerClauseSyntax) -> SyntaxVisitorContinueKind { before(node.equal, tokens: .space) - // InitializerClauses that are children of a PatternBindingSyntax are already handled in the - // latter node, to ensure that continuations stack appropriately. - if node.parent == nil || !node.parent!.is(PatternBindingSyntax.self) { + // InitializerClauses that are children of a PatternBindingSyntax, EnumCaseElementSyntax, or + // OptionalBindingConditionSyntax are already handled in the latter node, to ensure that + // continuations stack appropriately. + if let parent = node.parent, + !parent.is(PatternBindingSyntax.self) + && !parent.is(OptionalBindingConditionSyntax.self) + && !parent.is(EnumCaseElementSyntax.self) + { after(node.equal, tokens: .break) } return .visitChildren } override func visit(_ node: StringLiteralExprSyntax) -> SyntaxVisitorContinueKind { - if node.openQuote.tokenKind == .multilineStringQuote { - // If it's a multiline string, the last segment of the literal will end with a newline and - // zero or more whitespace that indicates the amount of whitespace stripped from each line of - // the string literal. - if let lastSegment = node.segments.last?.as(StringSegmentSyntax.self), - let lastLine - = lastSegment.content.text.split(separator: "\n", omittingEmptySubsequences: false).last - { - let prefixCount = lastLine.count + if node.openingQuote.tokenKind == .multilineStringQuote { + // Looks up the correct break kind based on prior context. + let breakKind = pendingMultilineStringBreakKinds[node, default: .same] + after(node.openingQuote, tokens: .break(breakKind, size: 0, newlines: .hard(count: 1))) + if !node.segments.isEmpty { + before(node.closingQuote, tokens: .break(breakKind, newlines: .hard(count: 1))) + } + if shouldFormatterIgnore(node: Syntax(node)) { + appendFormatterIgnored(node: Syntax(node)) + // Mirror the tokens we'd normally append on '"""' + appendTrailingTrivia(node.closingQuote) + appendAfterTokensAndTrailingComments(node.closingQuote) + return .skipChildren + } + } + return .visitChildren + } - // Segments may be `StringSegmentSyntax` or `ExpressionSegmentSyntax`; for the purposes of - // newline handling and whitespace stripping, we only need to handle the former. - for segmentSyntax in node.segments { - guard let segment = segmentSyntax.as(StringSegmentSyntax.self) else { - continue - } - // Register the content tokens of the segments and the amount of leading whitespace to - // strip; this will be retrieved when we visit the token. - pendingMultilineStringSegmentPrefixLengths[segment.content] = prefixCount - } + override func visit(_ node: SimpleStringLiteralExprSyntax) -> SyntaxVisitorContinueKind { + if node.openingQuote.tokenKind == .multilineStringQuote { + after(node.openingQuote, tokens: .break(.same, size: 0, newlines: .hard(count: 1))) + if !node.segments.isEmpty { + before(node.closingQuote, tokens: .break(.same, newlines: .hard(count: 1))) } } return .visitChildren } + // Insert an `.escaped` break token after each series of whitespace in a substring + private func emitMultilineSegmentTextTokens(breakKind: BreakKind, segment: Substring) { + var currentWord = [Unicode.Scalar]() + var currentBreak = [Unicode.Scalar]() + + func emitWord() { + if !currentWord.isEmpty { + var str = "" + str.unicodeScalars.append(contentsOf: currentWord) + appendToken(.syntax(str)) + currentWord = [] + } + } + func emitBreak() { + if !currentBreak.isEmpty { + // We append this as a syntax, instead of a `.space`, so that it is always included in the output. + var str = "" + str.unicodeScalars.append(contentsOf: currentBreak) + appendToken(.syntax(str)) + appendToken(.break(breakKind, size: 0, newlines: .escaped)) + currentBreak = [] + } + } + + for scalar in segment.unicodeScalars { + // We don't have to worry about newlines occurring in segments. + // Either a segment will end in a newline character or the newline will be in trivia. + if scalar.properties.isWhitespace { + emitWord() + currentBreak.append(scalar) + } else { + emitBreak() + currentWord.append(scalar) + } + } + + // Only one of these will actually do anything based on whether our last char was whitespace or not. + emitWord() + emitBreak() + } + override func visit(_ node: StringSegmentSyntax) -> SyntaxVisitorContinueKind { - return .visitChildren + // Looks up the correct break kind based on prior context. + let stringLiteralParent = + node.parent? + .as(StringLiteralSegmentListSyntax.self)? + .parent? + .as(StringLiteralExprSyntax.self) + let breakKind = + stringLiteralParent.map { + pendingMultilineStringBreakKinds[$0, default: .same] + } ?? .same + + let isMultiLineString = + stringLiteralParent?.openingQuote.tokenKind == .multilineStringQuote + // We don't reflow raw strings, so treat them as if they weren't multiline + && stringLiteralParent?.openingPounds == nil + + let emitSegmentTextTokens = + // If our configure reflow behavior is never, always use the single line emit segment text tokens. + isMultiLineString && !config.reflowMultilineStringLiterals.isNever + ? { (segment) in self.emitMultilineSegmentTextTokens(breakKind: breakKind, segment: segment) } + // For single line strings we don't allow line breaks, so emit the string as a single `.syntax` token + : { (segment) in self.appendToken(.syntax(String(segment))) } + + let segmentText = node.content.text + if segmentText.hasSuffix("\n") { + // If this is a multiline string segment, it will end in a newline. Remove the newline and + // append the rest of the string, followed by a break if it's not the last line before the + // closing quotes. (The `StringLiteralExpr` above does the closing break.) + let remainder = node.content.text.dropLast() + + if !remainder.isEmpty { + // Replace each space in the segment text by an elective break of size 1 + emitSegmentTextTokens(remainder) + } + appendToken(.break(breakKind, newlines: .hard(count: 1))) + } else { + emitSegmentTextTokens(segmentText[...]) + } + + if node.trailingTrivia.containsBackslashes && !config.reflowMultilineStringLiterals.isAlways { + // Segments with trailing backslashes won't end with a literal newline; the backslash is + // considered trivia. To preserve the original text and wrapping, we need to manually render + // the backslash and a break into the token stream. + appendToken(.syntax("\\")) + appendToken(.break(breakKind, newlines: .hard(count: 1))) + } + return .skipChildren } - override func visit(_ node: AssociatedtypeDeclSyntax) -> SyntaxVisitorContinueKind { - arrangeAttributeList(node.attributes) + override func visit(_ node: AssociatedTypeDeclSyntax) -> SyntaxVisitorContinueKind { + arrangeAttributeList(node.attributes, separateByLineBreaks: config.lineBreakBetweenDeclarationAttributes) after(node.associatedtypeKeyword, tokens: .break) if let genericWhereClause = node.genericWhereClause { - before(genericWhereClause.firstToken, tokens: .break(.same), .open) - after(node.lastToken, tokens: .close) + before(genericWhereClause.firstToken(viewMode: .sourceAccurate), tokens: .break(.same), .open) + after(node.lastToken(viewMode: .sourceAccurate), tokens: .close) } return .visitChildren } @@ -2174,16 +2646,16 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { } override func visit(_ node: GenericWhereClauseSyntax) -> SyntaxVisitorContinueKind { - guard node.whereKeyword != node.lastToken else { + guard node.whereKeyword != node.lastToken(viewMode: .sourceAccurate) else { verbatimToken(Syntax(node)) return .skipChildren } after(node.whereKeyword, tokens: .break(.open)) - after(node.lastToken, tokens: .break(.close, size: 0)) + after(node.lastToken(viewMode: .sourceAccurate), tokens: .break(.close, size: 0)) - before(node.requirementList.firstToken, tokens: .open(genericRequirementListConsistency())) - after(node.requirementList.lastToken, tokens: .close) + before(node.requirements.firstToken(viewMode: .sourceAccurate), tokens: .open(genericRequirementListConsistency())) + after(node.requirements.lastToken(viewMode: .sourceAccurate), tokens: .close) return .visitChildren } @@ -2192,23 +2664,23 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { return .visitChildren } - override func visit(_ node: AccessPathComponentSyntax) -> SyntaxVisitorContinueKind { + override func visit(_ node: ImportPathComponentSyntax) -> SyntaxVisitorContinueKind { return .visitChildren } override func visit(_ node: GenericRequirementSyntax) -> SyntaxVisitorContinueKind { - before(node.firstToken, tokens: .open) + before(node.firstToken(viewMode: .sourceAccurate), tokens: .open) if let trailingComma = node.trailingComma { after(trailingComma, tokens: .close, .break(.same)) } else { - after(node.lastToken, tokens: .close) + after(node.lastToken(viewMode: .sourceAccurate), tokens: .close) } return .visitChildren } override func visit(_ node: SameTypeRequirementSyntax) -> SyntaxVisitorContinueKind { - before(node.equalityToken, tokens: .break) - after(node.equalityToken, tokens: .space) + before(node.equal, tokens: .break) + after(node.equal, tokens: .space) return .visitChildren } @@ -2224,7 +2696,7 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { return .visitChildren } - override func visit(_ node: MemberTypeIdentifierSyntax) -> SyntaxVisitorContinueKind { + override func visit(_ node: MemberTypeSyntax) -> SyntaxVisitorContinueKind { before(node.period, tokens: .break(.continue, size: 0)) return .visitChildren } @@ -2233,7 +2705,7 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { return .visitChildren } - override func visit(_ node: SimpleTypeIdentifierSyntax) -> SyntaxVisitorContinueKind { + override func visit(_ node: IdentifierTypeSyntax) -> SyntaxVisitorContinueKind { return .visitChildren } @@ -2249,20 +2721,37 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { return .visitChildren } - override func visit(_ node: SymbolicReferenceExprSyntax) -> SyntaxVisitorContinueKind { + override func visit(_ node: ConsumeExprSyntax) -> SyntaxVisitorContinueKind { + // The `consume` keyword cannot be separated from the following token or it will be parsed as + // an identifier. + after(node.consumeKeyword, tokens: .space) + return .visitChildren + } + + override func visit(_ node: CopyExprSyntax) -> SyntaxVisitorContinueKind { + // The `copy` keyword cannot be separated from the following token or it will be parsed as an + // identifier. + after(node.copyKeyword, tokens: .space) return .visitChildren } - override func visit(_ node: TypeInheritanceClauseSyntax) -> SyntaxVisitorContinueKind { + override func visit(_ node: DiscardStmtSyntax) -> SyntaxVisitorContinueKind { + // The `discard` keyword cannot be separated from the following token or it will be parsed as + // an identifier. + after(node.discardKeyword, tokens: .space) + return .visitChildren + } + + override func visit(_ node: InheritanceClauseSyntax) -> SyntaxVisitorContinueKind { // Normally, the open-break is placed before the open token. In this case, it's intentionally // ordered differently so that the inheritance list can start on the current line and only // breaks if the first item in the list would overflow the column limit. - before(node.inheritedTypeCollection.firstToken, tokens: .open, .break(.open, size: 1)) - after(node.inheritedTypeCollection.lastToken, tokens: .break(.close, size: 0), .close) + before(node.inheritedTypes.firstToken(viewMode: .sourceAccurate), tokens: .open, .break(.open, size: 1)) + after(node.inheritedTypes.lastToken(viewMode: .sourceAccurate), tokens: .break(.close, size: 0), .close) return .visitChildren } - override func visit(_ node: UnresolvedPatternExprSyntax) -> SyntaxVisitorContinueKind { + override func visit(_ node: PatternExprSyntax) -> SyntaxVisitorContinueKind { return .visitChildren } @@ -2273,20 +2762,41 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { } override func visit(_ node: MatchingPatternConditionSyntax) -> SyntaxVisitorContinueKind { - before(node.firstToken, tokens: .open) + before(node.firstToken(viewMode: .sourceAccurate), tokens: .open) after(node.caseKeyword, tokens: .break) - after(node.lastToken, tokens: .close) + after(node.lastToken(viewMode: .sourceAccurate), tokens: .close) return .visitChildren } override func visit(_ node: OptionalBindingConditionSyntax) -> SyntaxVisitorContinueKind { - after(node.letOrVarKeyword, tokens: .break) + after(node.bindingSpecifier, tokens: .break) if let typeAnnotation = node.typeAnnotation { after( typeAnnotation.colon, - tokens: .break(.open(kind: .continuation), newlines: .elective(ignoresDiscretionary: true))) - after(typeAnnotation.lastToken, tokens: .break(.close(mustBreak: false), size: 0)) + tokens: .break(.open(kind: .continuation), newlines: .elective(ignoresDiscretionary: true)) + ) + after(typeAnnotation.lastToken(viewMode: .sourceAccurate), tokens: .break(.close(mustBreak: false), size: 0)) + } + + if let initializer = node.initializer { + if let (unindentingNode, _, breakKind, shouldGroup) = + stackedIndentationBehavior(rhs: initializer.value) + { + var openTokens: [Token] = [.break(.open(kind: breakKind))] + if shouldGroup { + openTokens.append(.open) + } + after(initializer.equal, tokens: openTokens) + + var closeTokens: [Token] = [.break(.close(mustBreak: false), size: 0)] + if shouldGroup { + closeTokens.append(.close) + } + after(unindentingNode.lastToken(viewMode: .sourceAccurate), tokens: closeTokens) + } else { + after(initializer.equal, tokens: .break(.continue)) + } } return .visitChildren @@ -2300,57 +2810,54 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { // This node encapsulates the entire list of arguments in a `@differentiable(...)` attribute. var needsBreakBeforeWhereClause = false - if let diffParamsComma = node.diffParamsComma { + if let diffParamsComma = node.argumentsComma { after(diffParamsComma, tokens: .break(.same)) - } else if node.diffParams != nil { + } else if node.arguments != nil { // If there were diff params but no comma following them, then we have "wrt: foo where ..." // and we need a break before the where clause. needsBreakBeforeWhereClause = true } - if let whereClause = node.whereClause { + if let whereClause = node.genericWhereClause { if needsBreakBeforeWhereClause { - before(whereClause.firstToken, tokens: .break(.same)) + before(whereClause.firstToken(viewMode: .sourceAccurate), tokens: .break(.same)) } - before(whereClause.firstToken, tokens: .open) - after(whereClause.lastToken, tokens: .close) + before(whereClause.firstToken(viewMode: .sourceAccurate), tokens: .open) + after(whereClause.lastToken(viewMode: .sourceAccurate), tokens: .close) } return .visitChildren } - override func visit(_ node: DifferentiabilityParamsSyntax) -> SyntaxVisitorContinueKind { + override func visit(_ node: DifferentiabilityArgumentsSyntax) -> SyntaxVisitorContinueKind { after(node.leftParen, tokens: .break(.open, size: 0), .open) before(node.rightParen, tokens: .break(.close, size: 0), .close) return .visitChildren } - override func visit(_ node: DifferentiabilityParamSyntax) -> SyntaxVisitorContinueKind { + override func visit(_ node: DifferentiabilityArgumentSyntax) -> SyntaxVisitorContinueKind { after(node.trailingComma, tokens: .break(.same)) return .visitChildren } - // `DerivativeRegistrationAttributeArguments` was added after the Swift 5.2 release was cut. - #if HAS_DERIVATIVE_REGISTRATION_ATTRIBUTE - override func visit(_ node: DerivativeRegistrationAttributeArgumentsSyntax) - -> SyntaxVisitorContinueKind - { - // This node encapsulates the entire list of arguments in a `@derivative(...)` or - // `@transpose(...)` attribute. - before(node.ofLabel, tokens: .open) - after(node.colon, tokens: .break(.continue, newlines: .elective(ignoresDiscretionary: true))) - // The comma after originalDeclName is optional and is only present if there are diffParams. - after(node.comma ?? node.originalDeclName.lastToken, tokens: .close) - - if let diffParams = node.diffParams { - before(diffParams.firstToken, tokens: .break(.same), .open) - after(diffParams.lastToken, tokens: .close) - } + override func visit( + _ node: DerivativeAttributeArgumentsSyntax + ) -> SyntaxVisitorContinueKind { + // This node encapsulates the entire list of arguments in a `@derivative(...)` or + // `@transpose(...)` attribute. + before(node.ofLabel, tokens: .open) + after(node.colon, tokens: .break(.continue, newlines: .elective(ignoresDiscretionary: true))) + // The comma after originalDeclName is optional and is only present if there are diffParams. + after(node.comma ?? node.originalDeclName.lastToken(viewMode: .sourceAccurate), tokens: .close) - return .visitChildren + if let diffParams = node.arguments { + before(diffParams.firstToken(viewMode: .sourceAccurate), tokens: .break(.same), .open) + after(diffParams.lastToken(viewMode: .sourceAccurate), tokens: .close) } - #endif - override func visit(_ node: DifferentiabilityParamsClauseSyntax) -> SyntaxVisitorContinueKind { + return .visitChildren + } + + override func visit(_ node: DifferentiabilityWithRespectToArgumentSyntax) -> SyntaxVisitorContinueKind { // This node encapsulates the `wrt:` label and value/variable in a `@differentiable`, // `@derivative`, or `@transpose` attribute. after(node.colon, tokens: .break(.continue, newlines: .elective(ignoresDiscretionary: true))) @@ -2364,11 +2871,6 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { return .skipChildren } - override func visit(_ node: NonEmptyTokenListSyntax) -> SyntaxVisitorContinueKind { - verbatimToken(Syntax(node)) - return .skipChildren - } - // MARK: - Token handling override func visit(_ token: TokenSyntax) -> SyntaxVisitorContinueKind { @@ -2381,13 +2883,17 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { extractLeadingTrivia(token) closeScopeTokens.forEach(appendToken) - if let pendingSegmentIndex = pendingMultilineStringSegmentPrefixLengths.index(forKey: token) { - appendMultilineStringSegments(at: pendingSegmentIndex) - } else if !ignoredTokens.contains(token) { + generateEnableFormattingIfNecessary( + token.positionAfterSkippingLeadingTrivia...Index) { - let (token, prefixCount) = pendingMultilineStringSegmentPrefixLengths[index] - pendingMultilineStringSegmentPrefixLengths.remove(at: index) - - let lines = token.text.split(separator: "\n", omittingEmptySubsequences: false) - - // The first "line" is a special case. If it is non-empty, then it is a piece of text that - // immediately followed an interpolation segment on the same line of the string, like the - // " baz" in "foo bar \(x + y) baz". If that is the case, we need to insert that text before - // anything else. - let firstLine = lines.first! - if !firstLine.isEmpty { - appendToken(.syntax(String(firstLine))) - } - - // Add the remaining lines of the segment, preceding each with a newline and stripping the - // leading whitespace so that the pretty-printer can re-indent the string according to the - // standard rules that it would apply. - for line in lines.dropFirst() as ArraySlice { - appendNewlines(.hard) - - // Verify that the characters to be stripped are all spaces. If they are not, the string - // is not valid (no line should contain less leading whitespace than the line with the - // closing quotes), but the parser still allows this and it's flagged as an error later during - // compilation, so we don't want to destroy the user's text in that case. - let stringToAppend: Substring - if (line.prefix(prefixCount).allSatisfy { $0 == " " }) { - stringToAppend = line.dropFirst(prefixCount) - } else { - // Only strip as many spaces as we have. This will force the misaligned line to line up with - // the others; let's assume that's what the user wanted anyway. - stringToAppend = line.drop { $0 == " " } - } - if !stringToAppend.isEmpty { - appendToken(.syntax(String(stringToAppend))) - } + private func generateEnableFormattingIfNecessary(_ range: Range) { + if case .infinite = selection { return } + if !isInsideSelection && selection.overlapsOrTouches(range) { + appendToken(.enableFormatting(range.lowerBound)) + isInsideSelection = true + } + } + + private func generateDisableFormattingIfNecessary(_ position: AbsolutePosition) { + if case .infinite = selection { return } + if isInsideSelection && !selection.contains(position) { + appendToken(.disableFormatting(position)) + isInsideSelection = false } } @@ -2460,8 +2940,8 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { /// some legitimate uses), this is a reasonable compromise to keep the garbage text roughly in the /// same place but still let surrounding formatting occur somewhat as expected. private func appendTrailingTrivia(_ token: TokenSyntax, forced: Bool = false) { - let trailingTrivia = Array(token.trailingTrivia) - let lastIndex: Array.Index + let trailingTrivia = Array(partitionTrailingTrivia(token.trailingTrivia).0) + let lastIndex: Array.Index if forced { lastIndex = trailingTrivia.index(before: trailingTrivia.endIndex) } else { @@ -2476,7 +2956,7 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { var verbatimText = "" for piece in trailingTrivia[...lastIndex] { switch piece { - case .shebang, .unexpectedText, .spaces, .tabs, .formfeeds, .verticalTabs: + case .unexpectedText, .spaces, .tabs, .formfeeds, .verticalTabs: piece.write(to: &verbatimText) default: // The implementation of the lexer today ensures that newlines, carriage returns, and @@ -2502,9 +2982,10 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { /// will stay inside the group. /// /// * If the trailing comment is a line comment, we first append any enqueued after-tokens - /// that are *not* breaks or newlines, then we append the comment, and then the remaining - /// after-tokens. Due to visitation ordering, this ensures that a trailing line comment is - /// not incorrectly inserted into the token stream *after* a break or newline. + /// that are *not* related to breaks or newlines (e.g. includes print control tokens), then + /// we append the comment, and then the remaining after-tokens. Due to visitation ordering, + /// this ensures that a trailing line comment is not incorrectly inserted into the token stream + /// *after* a break or newline. private func appendAfterTokensAndTrailingComments(_ token: TokenSyntax) { let (wasLineComment, trailingCommentTokens) = afterTokensForTrailingComment(token) let afterGroups = afterMap.removeValue(forKey: token) ?? [] @@ -2519,8 +3000,16 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { var shouldExtractTrailingComment = false if wasLineComment && !hasAppendedTrailingComment { switch afterToken { - case .break: shouldExtractTrailingComment = true - default: break + case let .break(kind, _, _): + if case let .close(mustBreak) = kind { + shouldExtractTrailingComment = mustBreak + } else { + shouldExtractTrailingComment = true + } + case .printerControl: + shouldExtractTrailingComment = true + default: + break } } if shouldExtractTrailingComment { @@ -2541,16 +3030,42 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { /// Applies formatting tokens around and between the attributes in an attribute list. private func arrangeAttributeList( _ attributes: AttributeListSyntax?, - suppressFinalBreak: Bool = false + suppressFinalBreak: Bool = false, + separateByLineBreaks: Bool = false ) { if let attributes = attributes { - before(attributes.firstToken, tokens: .open) - insertTokens(.break(.same), betweenElementsOf: attributes) + let behavior: NewlineBehavior = separateByLineBreaks ? .hard : .elective + before(attributes.firstToken(viewMode: .sourceAccurate), tokens: .open) + if attributes.dropLast().isEmpty, + let ifConfig = attributes.first?.as(IfConfigDeclSyntax.self) + { + for clause in ifConfig.clauses { + if let nestedAttributes = AttributeListSyntax(clause.elements) { + arrangeAttributeList(nestedAttributes, suppressFinalBreak: true, separateByLineBreaks: separateByLineBreaks) + } + } + } else { + for element in attributes.dropLast() { + if let ifConfig = element.as(IfConfigDeclSyntax.self) { + for clause in ifConfig.clauses { + if let nestedAttributes = AttributeListSyntax(clause.elements) { + arrangeAttributeList( + nestedAttributes, + suppressFinalBreak: true, + separateByLineBreaks: separateByLineBreaks + ) + } + } + } else { + after(element.lastToken(viewMode: .sourceAccurate), tokens: .break(.same, newlines: behavior)) + } + } + } var afterAttributeTokens = [Token.close] if !suppressFinalBreak { - afterAttributeTokens.append(.break(.same)) + afterAttributeTokens.append(.break(.same, newlines: behavior)) } - after(attributes.lastToken, tokens: afterAttributeTokens) + after(attributes.lastToken(viewMode: .sourceAccurate), tokens: afterAttributeTokens) } } @@ -2578,7 +3093,7 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { ) -> Bool where BodyContents.Element: SyntaxProtocol { // If the collection is empty, then any comments that might be present in the block must be // leading trivia of the right brace. - let commentPrecedesRightBrace = node.rightBrace.leadingTrivia.numberOfComments > 0 + let commentPrecedesRightBrace = node.rightBrace.hasAnyPrecedingComment // We can't use `count` here because it also includes missing children. Instead, we get an // iterator and check if it returns `nil` immediately. var contentsIterator = node[keyPath: contentsKeyPath].makeIterator() @@ -2599,7 +3114,7 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { ) -> Bool where BodyContents.Element == Syntax { // If the collection is empty, then any comments that might be present in the block must be // leading trivia of the right brace. - let commentPrecedesRightBrace = node.rightBrace.leadingTrivia.numberOfComments > 0 + let commentPrecedesRightBrace = node.rightBrace.hasAnyPrecedingComment // We can't use `count` here because it also includes missing children. Instead, we get an // iterator and check if it returns `nil` immediately. var contentsIterator = node[keyPath: contentsKeyPath].makeIterator() @@ -2620,13 +3135,55 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { ) -> Bool where BodyContents.Element == DeclSyntax { // If the collection is empty, then any comments that might be present in the block must be // leading trivia of the right brace. - let commentPrecedesRightBrace = node.rightBrace.leadingTrivia.numberOfComments > 0 + let commentPrecedesRightBrace = node.rightBrace.hasAnyPrecedingComment // We can't use `count` here because it also includes missing children. Instead, we get an // iterator and check if it returns `nil` immediately. var contentsIterator = node[keyPath: contentsKeyPath].makeIterator() return contentsIterator.next() == nil && !commentPrecedesRightBrace } + /// Applies formatting to a collection of parameters for a decl. + /// + /// - Parameters: + /// - parameters: A node that contains the parameters that can be passed to a decl when its + /// called. + /// - forcesBreakBeforeRightParen: Whether a break should be required before the right paren + /// when the right paren is on a different line than the corresponding left paren. + private func arrangeClosureParameterClause( + _ parameters: ClosureParameterClauseSyntax, + forcesBreakBeforeRightParen: Bool + ) { + guard !parameters.parameters.isEmpty else { return } + + after(parameters.leftParen, tokens: .break(.open, size: 0), .open(argumentListConsistency())) + before( + parameters.rightParen, + tokens: .break(.close(mustBreak: forcesBreakBeforeRightParen), size: 0), + .close + ) + } + + /// Applies formatting to a collection of enum case parameters for a decl. + /// + /// - Parameters: + /// - parameters: A node that contains the parameters that can be passed to a decl when its + /// called. + /// - forcesBreakBeforeRightParen: Whether a break should be required before the right paren + /// when the right paren is on a different line than the corresponding left paren. + private func arrangeEnumCaseParameterClause( + _ parameters: EnumCaseParameterClauseSyntax, + forcesBreakBeforeRightParen: Bool + ) { + guard !parameters.parameters.isEmpty else { return } + + after(parameters.leftParen, tokens: .break(.open, size: 0), .open(argumentListConsistency())) + before( + parameters.rightParen, + tokens: .break(.close(mustBreak: forcesBreakBeforeRightParen), size: 0), + .close + ) + } + /// Applies formatting to a collection of parameters for a decl. /// /// - Parameters: @@ -2635,14 +3192,17 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { /// - forcesBreakBeforeRightParen: Whether a break should be required before the right paren /// when the right paren is on a different line than the corresponding left paren. private func arrangeParameterClause( - _ parameters: ParameterClauseSyntax, forcesBreakBeforeRightParen: Bool + _ parameters: FunctionParameterClauseSyntax, + forcesBreakBeforeRightParen: Bool ) { - guard !parameters.parameterList.isEmpty else { return } + guard !parameters.parameters.isEmpty else { return } after(parameters.leftParen, tokens: .break(.open, size: 0), .open(argumentListConsistency())) before( parameters.rightParen, - tokens: .break(.close(mustBreak: forcesBreakBeforeRightParen), size: 0), .close) + tokens: .break(.close(mustBreak: forcesBreakBeforeRightParen), size: 0), + .close + ) } /// Applies consistent formatting to the braces and contents of the given node. @@ -2669,11 +3229,15 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { if shouldResetBeforeLeftBrace { before( node.leftBrace, - tokens: .break(.reset, size: 1, newlines: .elective(ignoresDiscretionary: true))) + tokens: .break(.reset, size: 1, newlines: .elective(ignoresDiscretionary: true)) + ) } if !areBracesCompletelyEmpty(node, contentsKeyPath: contentsKeyPath) { after( - node.leftBrace, tokens: .break(.open, size: 1, newlines: openBraceNewlineBehavior), .open) + node.leftBrace, + tokens: .break(.open, size: 1, newlines: openBraceNewlineBehavior), + .open + ) before(node.rightBrace, tokens: .break(.close, size: 1), .close) } else { after(node.leftBrace, tokens: .break(.open, size: 0, newlines: openBraceNewlineBehavior)) @@ -2744,24 +3308,28 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { /// Applies consistent formatting to the braces and contents of the given node. /// /// - Parameter node: An `AccessorBlockSyntax` node. - private func arrangeBracesAndContents(of node: AccessorBlockSyntax) { + private func arrangeBracesAndContents( + leftBrace: TokenSyntax, + accessors: AccessorDeclListSyntax, + rightBrace: TokenSyntax + ) { // If the collection is empty, then any comments that might be present in the block must be // leading trivia of the right brace. - let commentPrecedesRightBrace = node.rightBrace.leadingTrivia.numberOfComments > 0 + let commentPrecedesRightBrace = rightBrace.hasAnyPrecedingComment // We can't use `count` here because it also includes missing children. Instead, we get an // iterator and check if it returns `nil` immediately. - var accessorsIterator = node.accessors.makeIterator() + var accessorsIterator = accessors.makeIterator() let areAccessorsEmpty = accessorsIterator.next() == nil let bracesAreCompletelyEmpty = areAccessorsEmpty && !commentPrecedesRightBrace - before(node.leftBrace, tokens: .break(.reset, size: 1)) + before(leftBrace, tokens: .break(.reset, size: 1)) if !bracesAreCompletelyEmpty { - after(node.leftBrace, tokens: .break(.open, size: 1), .open) - before(node.rightBrace, tokens: .break(.close, size: 1), .close) + after(leftBrace, tokens: .break(.open, size: 1), .open) + before(rightBrace, tokens: .break(.close, size: 1), .close) } else { - after(node.leftBrace, tokens: .break(.open, size: 0)) - before(node.rightBrace, tokens: .break(.close, size: 0)) + after(leftBrace, tokens: .break(.open, size: 0)) + before(rightBrace, tokens: .break(.close, size: 0)) } } @@ -2777,13 +3345,15 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { return config.lineBreakBeforeEachGenericRequirement ? .consistent : .inconsistent } - private func afterTokensForTrailingComment(_ token: TokenSyntax) - -> (isLineComment: Bool, tokens: [Token]) - { - let nextToken = token.nextToken - guard let trivia = nextToken?.leadingTrivia, - let firstPiece = trivia.first - else { + private func afterTokensForTrailingComment( + _ token: TokenSyntax + ) -> (isLineComment: Bool, tokens: [Token]) { + let (_, trailingComments) = partitionTrailingTrivia(token.trailingTrivia) + let trivia = + Trivia(pieces: trailingComments) + + (token.nextToken(viewMode: .sourceAccurate)?.leadingTrivia ?? []) + + guard let firstPiece = trivia.first else { return (false, []) } @@ -2792,19 +3362,20 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { return ( true, [ - .space(size: 2, flexible: true), - .comment(Comment(kind: .line, text: text), wasEndOfLine: true), + .space(size: config.spacesBeforeEndOfLineComments, flexible: true), + .comment(Comment(kind: .line, leadingIndent: nil, text: text), wasEndOfLine: true), // There must be a break with a soft newline after the comment, but it's impossible to // know which kind of break must be used. Adding this newline is deferred until the // comment is added to the token stream. - ]) + ] + ) case .blockComment(let text): return ( false, [ .space(size: 1, flexible: true), - .comment(Comment(kind: .block, text: text), wasEndOfLine: false), + .comment(Comment(kind: .block, leadingIndent: nil, text: text), wasEndOfLine: false), // We place a size-0 break after the comment to allow a discretionary newline after // the comment if the user places one here but the comment is otherwise adjacent to a // text token. @@ -2821,9 +3392,9 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { /// closing-scope collection. The opening-scope collection contains `.open` and `.break` tokens /// that start a "scope" before the token. The closing-scope collection contains `.close` and /// `.break` tokens that end a "scope" after the token. - private func splitScopingBeforeTokens(of token: TokenSyntax) -> ( - openingScope: [Token], closingScope: [Token] - ) { + private func splitScopingBeforeTokens( + of token: TokenSyntax + ) -> (openingScope: [Token], closingScope: [Token]) { guard let beforeTokens = beforeMap[token] else { return ([], []) } @@ -2846,14 +3417,38 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { return (beforeTokens, []) } + /// Partitions the given trailing trivia into two contiguous slices: the first containing only + /// whitespace and unexpected text, and the second containing everything else from the first + /// non-whitespace/non-unexpected-text. + /// + /// It is possible that one or both of the slices will be empty. + private func partitionTrailingTrivia(_ trailingTrivia: Trivia) -> (Slice, Slice) { + let pivot = + trailingTrivia.firstIndex { !$0.isSpaceOrTab && !$0.isUnexpectedText } + ?? trailingTrivia.endIndex + return (trailingTrivia[.. 0 || isStartOfFile { - appendToken(.comment(Comment(kind: .line, text: text), wasEndOfLine: false)) + generateEnableFormattingIfNecessary(position.. 0 || isStartOfFile { - appendToken(.comment(Comment(kind: .block, text: text), wasEndOfLine: false)) + generateEnableFormattingIfNecessary(position.. 0 + { + requiresNextNewline = true + } + + leadingIndent = .spaces(0) guard !isStartOfFile else { break } - if requiresNextNewline || - (config.respectsExistingLineBreaks && isDiscretionaryNewlineAllowed(before: token)) - { + if requiresNextNewline || (config.respectsExistingLineBreaks && isDiscretionaryNewlineAllowed(before: token)) { appendNewlines(.soft(count: count, discretionary: true)) } else { // Even if discretionary line breaks are not being respected, we still respect multiple @@ -2927,7 +3543,7 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { } } - case .shebang(let text), .unexpectedText(let text): + case .unexpectedText(let text): // Garbage text in leading trivia might be something meaningful that would be disruptive to // throw away when formatting the file, like a hashbang line or Unicode byte-order marker at // the beginning of a file, or source control conflict markers. Keep it as verbatim text so @@ -2939,10 +3555,20 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { let isBOM = text == "\u{feff}" requiresNextNewline = !isBOM isStartOfFile = isStartOfFile && isBOM + leadingIndent = nil - default: - break + case .backslashes, .formfeeds, .pounds, .verticalTabs: + leadingIndent = nil + + case .spaces(let n): + guard leadingIndent == .spaces(0) else { break } + leadingIndent = .spaces(n) + + case .tabs(let n): + guard leadingIndent == .spaces(0) else { break } + leadingIndent = .tabs(n) } + position += piece.sourceLength } } @@ -3030,14 +3656,43 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { /// This function also handles collapsing neighboring tokens in situations where that is /// desired, like merging adjacent comments and newlines. private func appendToken(_ token: Token) { + func breakAllowsCommentMerge(_ breakKind: BreakKind) -> Bool { + return breakKind == .same || breakKind == .continue || breakKind == .contextual + } + if let last = tokens.last { switch (last, token) { - case (.comment(let c1, _), .comment(let c2, _)) - where c1.kind == .docLine && c2.kind == .docLine: - var newComment = c1 - newComment.addText(c2.text) - tokens[tokens.count - 1] = .comment(newComment, wasEndOfLine: false) + case (.break(_, _, .escaped), _), (_, .break(_, _, .escaped)): + lastBreakIndex = tokens.endIndex + // Don't allow merging for .escaped breaks + canMergeNewlinesIntoLastBreak = false + tokens.append(token) return + case (.break(let breakKind, _, .soft(1, _)), .comment(let c2, _)) + where breakAllowsCommentMerge(breakKind) && (c2.kind == .docLine || c2.kind == .line): + // we are search for the pattern of [line comment] - [soft break 1] - [line comment] + // where the comment type is the same; these can be merged into a single comment + if let nextToLast = tokens.dropLast().last, + case let .comment(c1, false) = nextToLast, + c1.kind == c2.kind + { + var mergedComment = c1 + mergedComment.addText(c2.text) + tokens.removeLast() // remove the soft break + // replace the original comment with the merged one + tokens[tokens.count - 1] = .comment(mergedComment, wasEndOfLine: false) + + // need to fix lastBreakIndex because we just removed the last break + lastBreakIndex = tokens.lastIndex(where: { + switch $0 { + case .break: return true + default: return false + } + }) + canMergeNewlinesIntoLastBreak = false + + return + } // If we see a pair of spaces where one or both are flexible, combine them into a new token // with the maximum of their counts. @@ -3055,7 +3710,7 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { case .break: lastBreakIndex = tokens.endIndex canMergeNewlinesIntoLastBreak = true - case .open, .printerControl, .contextualBreakingStart: + case .open, .printerControl, .contextualBreakingStart, .enableFormatting, .disableFormatting: break default: canMergeNewlinesIntoLastBreak = false @@ -3066,16 +3721,16 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { /// Returns true if the first token of the given node is an open delimiter that may desire /// special breaking behavior in some cases. private func startsWithOpenDelimiter(_ node: Syntax) -> Bool { - guard let token = node.firstToken else { return false } + guard let token = node.firstToken(viewMode: .sourceAccurate) else { return false } switch token.tokenKind { - case .leftBrace, .leftParen, .leftSquareBracket: return true + case .leftBrace, .leftParen, .leftSquare: return true default: return false } } /// Returns true if open/close breaks should be inserted around the entire function call argument /// list. - private func shouldGroupAroundArgumentList(_ arguments: TupleExprElementListSyntax) -> Bool { + private func shouldGroupAroundArgumentList(_ arguments: LabeledExprListSyntax) -> Bool { let argumentCount = arguments.count // If there are no arguments, there's no reason to break. @@ -3093,9 +3748,14 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { /// - expr: An expression that includes opening and closing delimiters and arguments. /// - argumentListPath: A key path for accessing the expression's function call argument list. private func mustBreakBeforeClosingDelimiter( - of expr: T, argumentListPath: KeyPath + of expr: T, + argumentListPath: KeyPath ) -> Bool { - guard let parent = expr.parent, parent.is(MemberAccessExprSyntax.self) else { return false } + guard + let parent = expr.parent, + parent.is(MemberAccessExprSyntax.self) || parent.is(PostfixIfConfigExprSyntax.self) + else { return false } + let argumentList = expr[keyPath: argumentListPath] // When there's a single compact argument, there is no extra indentation for the argument and @@ -3108,7 +3768,7 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { /// /// This is true for any argument list that contains a single argument (labeled or unlabeled) that /// is an array, dictionary, or closure literal. - func isCompactSingleFunctionCallArgument(_ argumentList: TupleExprElementListSyntax) -> Bool { + func isCompactSingleFunctionCallArgument(_ argumentList: LabeledExprListSyntax) -> Bool { guard argumentList.count == 1 else { return false } let expression = argumentList.first!.expression @@ -3138,12 +3798,13 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { /// the expression. This is a hand-crafted list of expressions that generally look better when the /// break(s) before the expression fire before breaks inside of the expression. private func maybeGroupAroundSubexpression( - _ expr: ExprSyntax, combiningOperator operatorExpr: ExprSyntax? = nil + _ expr: ExprSyntax, + combiningOperator operatorExpr: ExprSyntax? = nil ) { - switch Syntax(expr).as(SyntaxEnum.self) { - case .memberAccessExpr, .subscriptExpr: - before(expr.firstToken, tokens: .open) - after(expr.lastToken, tokens: .close) + switch Syntax(expr).kind { + case .memberAccessExpr, .subscriptCallExpr: + before(expr.firstToken(viewMode: .sourceAccurate), tokens: .open) + after(expr.lastToken(viewMode: .sourceAccurate), tokens: .close) default: break } @@ -3155,8 +3816,8 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { if expr.is(FunctionCallExprSyntax.self), let operatorExpr = operatorExpr, !isAssigningOperator(operatorExpr) { - before(expr.firstToken, tokens: .open) - after(expr.lastToken, tokens: .close) + before(expr.firstToken(viewMode: .sourceAccurate), tokens: .open) + after(expr.lastToken(viewMode: .sourceAccurate), tokens: .close) } } @@ -3171,8 +3832,8 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { return true case .tryExpr(let tryExpr): return isCompoundExpression(tryExpr.expression) - case .tupleExpr(let tupleExpr) where tupleExpr.elementList.count == 1: - return isCompoundExpression(tupleExpr.elementList.first!.expression) + case .tupleExpr(let tupleExpr) where tupleExpr.elements.count == 1: + return isCompoundExpression(tupleExpr.elements.first!.expression) default: return false } @@ -3188,7 +3849,7 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { return true } if let binOpExpr = operatorExpr.as(BinaryOperatorExprSyntax.self) { - if let binOp = operatorTable.infixOperator(named: binOpExpr.operatorToken.text), + if let binOp = operatorTable.infixOperator(named: binOpExpr.operator.text), let precedenceGroup = binOp.precedenceGroup, precedenceGroup == "AssignmentPrecedence" { return true @@ -3206,27 +3867,85 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { /// not parenthesized. private func parenthesizedLeftmostExpr(of expr: ExprSyntax) -> TupleExprSyntax? { switch Syntax(expr).as(SyntaxEnum.self) { - case .tupleExpr(let tupleExpr) where tupleExpr.elementList.count == 1: + case .tupleExpr(let tupleExpr) where tupleExpr.elements.count == 1: return tupleExpr case .infixOperatorExpr(let infixOperatorExpr): return parenthesizedLeftmostExpr(of: infixOperatorExpr.leftOperand) case .ternaryExpr(let ternaryExpr): - return parenthesizedLeftmostExpr(of: ternaryExpr.conditionExpression) + return parenthesizedLeftmostExpr(of: ternaryExpr.condition) + default: + return nil + } + } + + /// Walks the expression and returns the leftmost subexpression (which might be the expression + /// itself) if the leftmost child is a node of the given type or if it is a unary operation + /// applied to a node of the given type. + /// + /// - Parameter expr: The expression whose leftmost matching subexpression should be returned. + /// - Returns: The leftmost subexpression, or nil if the leftmost subexpression was not the + /// desired type. + private func leftmostExpr( + of expr: ExprSyntax, + ifMatching predicate: (ExprSyntax) -> Bool + ) -> ExprSyntax? { + if predicate(expr) { + return expr + } + switch Syntax(expr).as(SyntaxEnum.self) { + case .infixOperatorExpr(let infixOperatorExpr): + return leftmostExpr(of: infixOperatorExpr.leftOperand, ifMatching: predicate) + case .asExpr(let asExpr): + return leftmostExpr(of: asExpr.expression, ifMatching: predicate) + case .isExpr(let isExpr): + return leftmostExpr(of: isExpr.expression, ifMatching: predicate) + case .forceUnwrapExpr(let forcedValueExpr): + return leftmostExpr(of: forcedValueExpr.expression, ifMatching: predicate) + case .optionalChainingExpr(let optionalChainingExpr): + return leftmostExpr(of: optionalChainingExpr.expression, ifMatching: predicate) + case .postfixOperatorExpr(let postfixUnaryExpr): + return leftmostExpr(of: postfixUnaryExpr.expression, ifMatching: predicate) + case .prefixOperatorExpr(let prefixOperatorExpr): + return leftmostExpr(of: prefixOperatorExpr.expression, ifMatching: predicate) + case .ternaryExpr(let ternaryExpr): + return leftmostExpr(of: ternaryExpr.condition, ifMatching: predicate) + case .functionCallExpr(let functionCallExpr): + return leftmostExpr(of: functionCallExpr.calledExpression, ifMatching: predicate) + case .subscriptCallExpr(let subscriptExpr): + return leftmostExpr(of: subscriptExpr.calledExpression, ifMatching: predicate) + case .memberAccessExpr(let memberAccessExpr): + return memberAccessExpr.base.flatMap { leftmostExpr(of: $0, ifMatching: predicate) } + case .postfixIfConfigExpr(let postfixIfConfigExpr): + return postfixIfConfigExpr.base.flatMap { leftmostExpr(of: $0, ifMatching: predicate) } default: return nil } } + /// Walks the expression and returns the leftmost multiline string literal (which might be the + /// expression itself) if the leftmost child is a multiline string literal or if it is a unary + /// operation applied to a multiline string literal. + /// + /// - Parameter expr: The expression whose leftmost multiline string literal should be returned. + /// - Returns: The leftmost multiline string literal, or nil if the leftmost subexpression was + /// not a multiline string literal. + private func leftmostMultilineStringLiteral(of expr: ExprSyntax) -> StringLiteralExprSyntax? { + return leftmostExpr(of: expr) { + $0.as(StringLiteralExprSyntax.self)?.openingQuote.tokenKind == .multilineStringQuote + }?.as(StringLiteralExprSyntax.self) + } + /// Returns the outermost node enclosing the given node whose closing delimiter(s) must be kept /// alongside the last token of the given node. Any tokens between `node.lastToken` and the /// returned node's `lastToken` are delimiter tokens that shouldn't be preceded by a break. private func outermostEnclosingNode(from node: Syntax) -> Syntax? { - guard let afterToken = node.lastToken?.nextToken(viewMode: .all), closingDelimiterTokens.contains(afterToken) + guard let afterToken = node.lastToken(viewMode: .sourceAccurate)?.nextToken(viewMode: .all), + closingDelimiterTokens.contains(afterToken) else { return nil } var parenthesizedExpr = afterToken.parent - while let nextToken = parenthesizedExpr?.lastToken?.nextToken(viewMode: .all), + while let nextToken = parenthesizedExpr?.lastToken(viewMode: .sourceAccurate)?.nextToken(viewMode: .all), closingDelimiterTokens.contains(nextToken), let nextExpr = nextToken.parent { @@ -3236,8 +3955,9 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { } /// Determines if indentation should be stacked around a subexpression to the right of the given - /// operator, and, if so, returns the node after which indentation stacking should be closed and - /// whether or not the continuation state should be reset as well. + /// operator, and, if so, returns the node after which indentation stacking should be closed, + /// whether or not the continuation state should be reset as well, and whether or not a group + /// should be placed around the operator and the expression. /// /// Stacking is applied around parenthesized expressions, but also for low-precedence operators /// that frequently occur in long chains, such as logical AND (`&&`) and OR (`||`) in conditional @@ -3246,7 +3966,7 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { private func stackedIndentationBehavior( after operatorExpr: ExprSyntax? = nil, rhs: ExprSyntax - ) -> (unindentingNode: Syntax, shouldReset: Bool)? { + ) -> (unindentingNode: Syntax, shouldReset: Bool, breakKind: OpenBreakKind, shouldGroup: Bool)? { // Check for logical operators first, and if it's that kind of operator, stack indentation // around the entire right-hand-side. We have to do this check before checking the RHS for // parentheses because if the user writes something like `... && (foo) > bar || ...`, we don't @@ -3256,7 +3976,7 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { // We also want to reset after undoing the stacked indentation so that we have a visual // indication that the subexpression has ended. if let binOpExpr = operatorExpr?.as(BinaryOperatorExprSyntax.self) { - if let binOp = operatorTable.infixOperator(named: binOpExpr.operatorToken.text), + if let binOp = operatorTable.infixOperator(named: binOpExpr.operator.text), let precedenceGroup = binOp.precedenceGroup, precedenceGroup == "LogicalConjunctionPrecedence" || precedenceGroup == "LogicalDisjunctionPrecedence" @@ -3265,9 +3985,19 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { // paren into the right hand side by unindenting after the final closing paren. This glues // the paren to the last token of `rhs`. if let unindentingParenExpr = outermostEnclosingNode(from: Syntax(rhs)) { - return (unindentingNode: unindentingParenExpr, shouldReset: true) + return ( + unindentingNode: unindentingParenExpr, + shouldReset: true, + breakKind: .continuation, + shouldGroup: true + ) } - return (unindentingNode: Syntax(rhs), shouldReset: true) + return ( + unindentingNode: Syntax(rhs), + shouldReset: true, + breakKind: .continuation, + shouldGroup: true + ) } } @@ -3276,7 +4006,12 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { if let ternaryExpr = rhs.as(TernaryExprSyntax.self) { // We don't try to absorb any parens in this case, because the condition of a ternary cannot // be grouped with any exprs outside of the condition. - return (unindentingNode: Syntax(ternaryExpr.conditionExpression), shouldReset: false) + return ( + unindentingNode: Syntax(ternaryExpr.condition), + shouldReset: false, + breakKind: .continuation, + shouldGroup: true + ) } // If the right-hand-side of the operator is or starts with a parenthesized expression, stack @@ -3287,9 +4022,54 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { // paren into the right hand side by unindenting after the final closing paren. This glues the // paren to the last token of `rhs`. if let unindentingParenExpr = outermostEnclosingNode(from: Syntax(rhs)) { - return (unindentingNode: unindentingParenExpr, shouldReset: true) + return ( + unindentingNode: unindentingParenExpr, + shouldReset: true, + breakKind: .continuation, + shouldGroup: false + ) + } + + if let innerExpr = parenthesizedExpr.elements.first?.expression, + let stringLiteralExpr = innerExpr.as(StringLiteralExprSyntax.self), + stringLiteralExpr.openingQuote.tokenKind == .multilineStringQuote + { + pendingMultilineStringBreakKinds[stringLiteralExpr] = .continue + return nil } - return (unindentingNode: Syntax(parenthesizedExpr), shouldReset: false) + + return ( + unindentingNode: Syntax(parenthesizedExpr), + shouldReset: false, + breakKind: .continuation, + shouldGroup: false + ) + } + + // If the expression is a multiline string that is unparenthesized, create a block-based + // indentation scope and have the segments aligned inside it. + if let stringLiteralExpr = leftmostMultilineStringLiteral(of: rhs) { + pendingMultilineStringBreakKinds[stringLiteralExpr] = .same + return ( + unindentingNode: Syntax(stringLiteralExpr), + shouldReset: false, + breakKind: .block, + shouldGroup: false + ) + } + + if let leftmostExpr = leftmostExpr( + of: rhs, + ifMatching: { + $0.is(IfExprSyntax.self) || $0.is(SwitchExprSyntax.self) + } + ) { + return ( + unindentingNode: Syntax(leftmostExpr), + shouldReset: false, + breakKind: .block, + shouldGroup: true + ) } // Otherwise, don't stack--use regular continuation breaks instead. @@ -3308,10 +4088,10 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { // The token kind (spaced or unspaced operator) represents how the *user* wrote it, and we want // to ignore that and apply our own rules. if let binaryOperator = operatorExpr.as(BinaryOperatorExprSyntax.self) { - let token = binaryOperator.operatorToken + let token = binaryOperator.operator if !config.spacesAroundRangeFormationOperators, - let binOp = operatorTable.infixOperator(named: token.text), - let precedenceGroup = binOp.precedenceGroup, precedenceGroup == "RangeFormationPrecedence" + let binOp = operatorTable.infixOperator(named: token.text), + let precedenceGroup = binOp.precedenceGroup, precedenceGroup == "RangeFormationPrecedence" { // We want to omit whitespace around range formation operators if possible. We can't do this // if the token is either preceded by a postfix operator, followed by a prefix operator, or @@ -3321,7 +4101,7 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { if case .postfixOperator? = token.previousToken(viewMode: .all)?.tokenKind { return true } switch token.nextToken(viewMode: .all)?.tokenKind { - case .prefixOperator?, .prefixPeriod?: return true + case .prefixOperator?, .period?: return true default: return false } } @@ -3343,7 +4123,7 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { // `verbatim` token in order for the first token to be printed with correct indentation. All // following lines in the ignored node are printed as-is with no changes to indentation. var nodeText = node.description - if let firstToken = node.firstToken { + if let firstToken = node.firstToken(viewMode: .sourceAccurate) { extractLeadingTrivia(firstToken) let leadingTriviaText = firstToken.leadingTrivia.reduce(into: "") { $1.write(to: &$0) } nodeText = String(nodeText.dropFirst(leadingTriviaText.count)) @@ -3352,7 +4132,9 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { // The leading trivia of the next token, after the ignored node, may contain content that // belongs with the ignored node. The trivia extraction that is performed for `lastToken` later // excludes that content so it needs to be extracted and added to the token stream here. - if let next = node.lastToken?.nextToken(viewMode: .all), let trivia = next.leadingTrivia.first { + if let next = node.lastToken(viewMode: .sourceAccurate)?.nextToken(viewMode: .all), + let trivia = next.leadingTrivia.first + { switch trivia { case .lineComment, .blockComment: trivia.write(to: &nodeText) @@ -3394,27 +4176,45 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { /// tokens. When visiting an expression node, `preVisitInsertingContextualBreaks(_:)` should be /// called instead of this helper. @discardableResult - private func insertContextualBreaks(_ expr: ExprSyntax, isTopLevel: Bool) -> ( - hasCompoundExpression: Bool, hasMemberAccess: Bool - ) { + private func insertContextualBreaks( + _ expr: ExprSyntax, + isTopLevel: Bool + ) -> (hasCompoundExpression: Bool, hasMemberAccess: Bool) { preVisitedExprs.insert(expr.id) if let memberAccessExpr = expr.as(MemberAccessExprSyntax.self) { // When the member access is part of a calling expression, the break before the dot is // inserted when visiting the parent node instead so that the break is inserted before any // scoping tokens (e.g. `contextualBreakingStart`, `open`). - if memberAccessExpr.base != nil && - expr.parent?.isProtocol(CallingExprSyntaxProtocol.self) != true { - before(memberAccessExpr.dot, tokens: .break(.contextual, size: 0)) + if memberAccessExpr.base != nil && expr.parent?.isProtocol(CallingExprSyntaxProtocol.self) != true { + before(memberAccessExpr.period, tokens: .break(.contextual, size: 0)) } var hasCompoundExpression = false if let base = memberAccessExpr.base { (hasCompoundExpression, _) = insertContextualBreaks(base, isTopLevel: false) } if isTopLevel { - before(expr.firstToken, tokens: .contextualBreakingStart) - after(expr.lastToken, tokens: .contextualBreakingEnd) + before(expr.firstToken(viewMode: .sourceAccurate), tokens: .contextualBreakingStart) + after(expr.lastToken(viewMode: .sourceAccurate), tokens: .contextualBreakingEnd) } return (hasCompoundExpression, true) + } else if let postfixIfExpr = expr.as(PostfixIfConfigExprSyntax.self), + let base = postfixIfExpr.base + { + // For postfix-if expressions with bases (i.e., they aren't the first `#if` nested inside + // another `#if`), add contextual breaks before the top-level clauses (and the terminating + // `#endif`) so that they nest or line-up properly based on the preceding node. We don't do + // this for initial nested `#if`s because they will already get open/close breaks to control + // their indentation from their parent clause. + before(postfixIfExpr.firstToken(viewMode: .sourceAccurate), tokens: .contextualBreakingStart) + after(postfixIfExpr.lastToken(viewMode: .sourceAccurate), tokens: .contextualBreakingEnd) + + for clause in postfixIfExpr.config.clauses { + before(clause.poundKeyword, tokens: .break(.contextual, size: 0)) + } + before(postfixIfExpr.config.poundEndif, tokens: .break(.contextual, size: 0)) + after(postfixIfExpr.config.poundEndif, tokens: .break(.same, size: 0)) + + return insertContextualBreaks(base, isTopLevel: false) } else if let callingExpr = expr.asProtocol(CallingExprSyntaxProtocol.self) { let calledExpression = callingExpr.calledExpression let (hasCompoundExpression, hasMemberAccess) = @@ -3431,20 +4231,20 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { if let calledMemberAccessExpr = calledExpression.as(MemberAccessExprSyntax.self) { if calledMemberAccessExpr.base != nil { if isNestedInPostfixIfConfig(node: Syntax(calledMemberAccessExpr)) { - before(calledMemberAccessExpr.dot, tokens: [.break(.same, size: 0)]) + before(calledMemberAccessExpr.period, tokens: [.break(.same, size: 0)]) } else { - before(calledMemberAccessExpr.dot, tokens: [.break(.contextual, size: 0)]) + before(calledMemberAccessExpr.period, tokens: [.break(.contextual, size: 0)]) } } - before(calledMemberAccessExpr.dot, tokens: beforeTokens) - after(expr.lastToken, tokens: afterTokens) + before(calledMemberAccessExpr.period, tokens: beforeTokens) + after(expr.lastToken(viewMode: .sourceAccurate), tokens: afterTokens) if isTopLevel { - before(expr.firstToken, tokens: .contextualBreakingStart) - after(expr.lastToken, tokens: .contextualBreakingEnd) + before(expr.firstToken(viewMode: .sourceAccurate), tokens: .contextualBreakingStart) + after(expr.lastToken(viewMode: .sourceAccurate), tokens: .contextualBreakingEnd) } } else { - before(expr.firstToken, tokens: beforeTokens) - after(expr.lastToken, tokens: afterTokens) + before(expr.firstToken(viewMode: .sourceAccurate), tokens: beforeTokens) + after(expr.lastToken(viewMode: .sourceAccurate), tokens: afterTokens) } return (true, hasMemberAccess) } @@ -3452,33 +4252,47 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor { // Otherwise, it's an expression that isn't calling another expression (e.g. array or // dictionary, identifier, etc.). Wrap it in a breaking context but don't try to pre-visit // children nodes. - before(expr.firstToken, tokens: .contextualBreakingStart) - after(expr.lastToken, tokens: .contextualBreakingEnd) - let hasCompoundExpression = !expr.is(IdentifierExprSyntax.self) + before(expr.firstToken(viewMode: .sourceAccurate), tokens: .contextualBreakingStart) + after(expr.lastToken(viewMode: .sourceAccurate), tokens: .contextualBreakingEnd) + let hasCompoundExpression = !expr.is(DeclReferenceExprSyntax.self) return (hasCompoundExpression, false) } } private func isNestedInPostfixIfConfig(node: Syntax) -> Bool { - var this: Syntax? = node + var this: Syntax? = node - while this?.parent != nil { - if this?.parent?.is(PostfixIfConfigExprSyntax.self) == true { - return true - } + while this?.parent != nil { + // This guard handles the situation where a type with its own modifiers + // is nested inside of an if config. That type should not count as being + // in a postfix if config because its entire body is inside the if config. + if this?.is(LabeledExprSyntax.self) == true { + return false + } - this = this?.parent + if this?.is(IfConfigDeclSyntax.self) == true && this?.parent?.is(PostfixIfConfigExprSyntax.self) == true { + return true } - return false + this = this?.parent + } + + return false } extension Syntax { /// Creates a pretty-printable token stream for the provided Syntax node. - func makeTokenStream(configuration: Configuration, operatorTable: OperatorTable) -> [Token] { - let commentsMoved = CommentMovingRewriter().visit(self) - return TokenStreamCreator(configuration: configuration, operatorTable: operatorTable) - .makeStream(from: commentsMoved) + func makeTokenStream( + configuration: Configuration, + selection: Selection, + operatorTable: OperatorTable + ) -> [Token] { + let commentsMoved = CommentMovingRewriter(selection: selection).rewrite(self) + return TokenStreamCreator( + configuration: configuration, + selection: selection, + operatorTable: operatorTable + ).makeStream(from: commentsMoved) } } @@ -3488,8 +4302,11 @@ extension Syntax { /// For example, comments after binary operators are relocated to be before the operator, which /// results in fewer line breaks with the comment closer to the relevant tokens. class CommentMovingRewriter: SyntaxRewriter { - /// Map of tokens to alternate trivia to use as the token's leading trivia. - var rewriteTokenTriviaMap: [TokenSyntax: Trivia] = [:] + init(selection: Selection = .infinite) { + self.selection = selection + } + + private let selection: Selection override func visit(_ node: SourceFileSyntax) -> SourceFileSyntax { if shouldFormatterIgnore(file: node) { @@ -3499,47 +4316,62 @@ class CommentMovingRewriter: SyntaxRewriter { } override func visit(_ node: CodeBlockItemSyntax) -> CodeBlockItemSyntax { - if shouldFormatterIgnore(node: Syntax(node)) { + if shouldFormatterIgnore(node: Syntax(node)) || !Syntax(node).isInsideSelection(selection) { return node } return super.visit(node) } - override func visit(_ node: MemberDeclListItemSyntax) -> MemberDeclListItemSyntax { - if shouldFormatterIgnore(node: Syntax(node)) { + override func visit(_ node: MemberBlockItemSyntax) -> MemberBlockItemSyntax { + if shouldFormatterIgnore(node: Syntax(node)) || !Syntax(node).isInsideSelection(selection) { return node } return super.visit(node) } - override func visit(_ token: TokenSyntax) -> TokenSyntax { - if let rewrittenTrivia = rewriteTokenTriviaMap[token] { - return token.withLeadingTrivia(rewrittenTrivia) + override func visit(_ node: InfixOperatorExprSyntax) -> ExprSyntax { + var node = super.visit(node).as(InfixOperatorExprSyntax.self)! + guard node.rightOperand.hasAnyPrecedingComment else { + return ExprSyntax(node) + } + + // Rearrange the comments around the operators to make it easier to break properly later. + // Since we break on the left of operators (except for assignment), line comments between an + // operator and the right-hand-side of an expression should be moved to the left of the + // operator. Block comments can remain where they're originally located since they don't force + // breaks. + let operatorLeading = node.operator.leadingTrivia + var operatorTrailing = node.operator.trailingTrivia + let rhsLeading = node.rightOperand.leadingTrivia + + let operatorTrailingLineComment: Trivia + if operatorTrailing.hasLineComment { + operatorTrailingLineComment = [operatorTrailing.pieces.last!] + operatorTrailing = Trivia(pieces: operatorTrailing.dropLast()) + } else { + operatorTrailingLineComment = [] } - return token - } - override func visit(_ node: InfixOperatorExprSyntax) -> ExprSyntax { - if let binaryOperatorExpr = node.operatorOperand.as(BinaryOperatorExprSyntax.self), - let followingToken = binaryOperatorExpr.operatorToken.nextToken(viewMode: .all), - followingToken.leadingTrivia.hasLineComment - { - // Rewrite the trivia so that the comment is in the operator token's leading trivia. - let (remainingTrivia, extractedTrivia) = extractLineCommentTrivia(from: followingToken) - let combinedTrivia = binaryOperatorExpr.operatorToken.leadingTrivia + extractedTrivia - rewriteTokenTriviaMap[binaryOperatorExpr.operatorToken] = combinedTrivia - rewriteTokenTriviaMap[followingToken] = remainingTrivia + if operatorLeading.containsNewlines { + node.operator.leadingTrivia = operatorLeading + operatorTrailingLineComment + rhsLeading + node.operator.trailingTrivia = operatorTrailing + } else { + node.leftOperand.trailingTrivia += operatorTrailingLineComment + node.operator.leadingTrivia = rhsLeading + node.operator.trailingTrivia = operatorTrailing } - return super.visit(node) + node.rightOperand.leadingTrivia = [] + + return ExprSyntax(node) } /// Extracts trivia containing and related to line comments from `token`'s leading trivia. Returns /// 2 trivia collections: the trivia that wasn't extracted and should remain in `token`'s leading /// trivia and the trivia that meets the criteria for extraction. /// - Parameter token: A token whose leading trivia should be split to extract line comments. - private func extractLineCommentTrivia(from token: TokenSyntax) -> ( - remainingTrivia: Trivia, extractedTrivia: Trivia - ) { + private func extractLineCommentTrivia( + from token: TokenSyntax + ) -> (remainingTrivia: Trivia, extractedTrivia: Trivia) { var pendingPieces = [TriviaPiece]() var keepWithTokenPieces = [TriviaPiece]() var extractingPieces = [TriviaPiece]() @@ -3593,8 +4425,8 @@ fileprivate func isFormatterIgnorePresent(inTrivia trivia: Trivia, isWholeFile: func isFormatterIgnore(in commentText: String, prefix: String, suffix: String) -> Bool { let trimmed = commentText.dropFirst(prefix.count) - .dropLast(suffix.count) - .trimmingCharacters(in: .whitespaces) + .dropLast(suffix.count) + .trimmingCharacters(in: .whitespaces) let pattern = isWholeFile ? "swift-format-ignore-file" : "swift-format-ignore" return trimmed == pattern } @@ -3625,10 +4457,7 @@ fileprivate func isFormatterIgnorePresent(inTrivia trivia: Trivia, isWholeFile: fileprivate func shouldFormatterIgnore(node: Syntax) -> Bool { // Regardless of the level of nesting, if the ignore directive is present on the first token // contained within the node then the entire node is eligible for ignoring. - if let firstTrivia = node.firstToken?.leadingTrivia { - return isFormatterIgnorePresent(inTrivia: firstTrivia, isWholeFile: false) - } - return false + return isFormatterIgnorePresent(inTrivia: node.allPrecedingTrivia, isWholeFile: false) } /// Returns whether the formatter should ignore the given file by printing it without changing the @@ -3637,14 +4466,11 @@ fileprivate func shouldFormatterIgnore(node: Syntax) -> Bool { /// /// - Parameter file: The root syntax node for a source file. fileprivate func shouldFormatterIgnore(file: SourceFileSyntax) -> Bool { - if let firstTrivia = file.firstToken?.leadingTrivia { - return isFormatterIgnorePresent(inTrivia: firstTrivia, isWholeFile: true) - } - return false + return isFormatterIgnorePresent(inTrivia: file.allPrecedingTrivia, isWholeFile: true) } extension NewlineBehavior { - static func +(lhs: NewlineBehavior, rhs: NewlineBehavior) -> NewlineBehavior { + static func + (lhs: NewlineBehavior, rhs: NewlineBehavior) -> NewlineBehavior { switch (lhs, rhs) { case (.elective, _): // `rhs` is either also elective or a required newline, which overwrites elective. @@ -3653,6 +4479,10 @@ extension NewlineBehavior { // `lhs` is either also elective or a required newline, which overwrites elective. return lhs + case (.escaped, _): + return rhs + case (_, .escaped): + return lhs case (.soft(let lhsCount, let lhsDiscretionary), .soft(let rhsCount, let rhsDiscretionary)): let mergedCount: Int if lhsDiscretionary && rhsDiscretionary { @@ -3681,8 +4511,8 @@ protocol CallingExprSyntaxProtocol: ExprSyntaxProtocol { var calledExpression: ExprSyntax { get } } -extension FunctionCallExprSyntax: CallingExprSyntaxProtocol { } -extension SubscriptExprSyntax: CallingExprSyntaxProtocol { } +extension FunctionCallExprSyntax: CallingExprSyntaxProtocol {} +extension SubscriptCallExprSyntax: CallingExprSyntaxProtocol {} extension Syntax { func asProtocol(_: CallingExprSyntaxProtocol.Protocol) -> CallingExprSyntaxProtocol? { diff --git a/Sources/SwiftFormatPrettyPrint/Verbatim.swift b/Sources/SwiftFormat/PrettyPrint/Verbatim.swift similarity index 99% rename from Sources/SwiftFormatPrettyPrint/Verbatim.swift rename to Sources/SwiftFormat/PrettyPrint/Verbatim.swift index 81ff2749a..75be7b2ea 100644 --- a/Sources/SwiftFormatPrettyPrint/Verbatim.swift +++ b/Sources/SwiftFormat/PrettyPrint/Verbatim.swift @@ -11,7 +11,6 @@ //===----------------------------------------------------------------------===// import Foundation -import SwiftFormatConfiguration /// Describes options for behavior when applying the indentation of the current context when /// printing a verbatim token. diff --git a/Sources/SwiftFormatWhitespaceLinter/WhitespaceFindingCategory.swift b/Sources/SwiftFormat/PrettyPrint/WhitespaceFindingCategory.swift similarity index 98% rename from Sources/SwiftFormatWhitespaceLinter/WhitespaceFindingCategory.swift rename to Sources/SwiftFormat/PrettyPrint/WhitespaceFindingCategory.swift index ea8e3b449..bc50fb38f 100644 --- a/Sources/SwiftFormatWhitespaceLinter/WhitespaceFindingCategory.swift +++ b/Sources/SwiftFormat/PrettyPrint/WhitespaceFindingCategory.swift @@ -10,8 +10,6 @@ // //===----------------------------------------------------------------------===// -import SwiftFormatCore - /// Categories for findings emitted by the whitespace linter. enum WhitespaceFindingCategory: FindingCategorizing { /// Findings related to trailing whitespace on a line. diff --git a/Sources/SwiftFormatWhitespaceLinter/WhitespaceLinter.swift b/Sources/SwiftFormat/PrettyPrint/WhitespaceLinter.swift similarity index 90% rename from Sources/SwiftFormatWhitespaceLinter/WhitespaceLinter.swift rename to Sources/SwiftFormat/PrettyPrint/WhitespaceLinter.swift index 1fb99126b..30f733952 100644 --- a/Sources/SwiftFormatWhitespaceLinter/WhitespaceLinter.swift +++ b/Sources/SwiftFormat/PrettyPrint/WhitespaceLinter.swift @@ -10,8 +10,6 @@ // //===----------------------------------------------------------------------===// -import SwiftFormatConfiguration -import SwiftFormatCore import SwiftSyntax private let utf8Newline = UTF8.CodeUnit(ascii: "\n") @@ -19,6 +17,7 @@ private let utf8Tab = UTF8.CodeUnit(ascii: "\t") /// Emits linter errors for whitespace style violations by comparing the raw text of the input Swift /// code with formatted text. +@_spi(Testing) public class WhitespaceLinter { /// The text of the input source code to be linted. @@ -60,7 +59,8 @@ public class WhitespaceLinter { assert( safeCodeUnit(at: userWhitespace.endIndex, in: userText) == safeCodeUnit(at: formattedWhitespace.endIndex, in: formattedText), - "Non-whitespace characters do not match") + "Non-whitespace characters do not match" + ) compareWhitespace(userWhitespace: userWhitespace, formattedWhitespace: formattedWhitespace) @@ -83,7 +83,8 @@ public class WhitespaceLinter { /// - formattedWhitespace: A slice of formatted text representing the current span of contiguous /// whitespace that will be compared to the user whitespace. private func compareWhitespace( - userWhitespace: ArraySlice, formattedWhitespace: ArraySlice + userWhitespace: ArraySlice, + formattedWhitespace: ArraySlice ) { // We use a custom-crafted lazy-splitting iterator here instead of the standard // `Collection.split` function because Time Profiler indicated that a very large proportion of @@ -98,7 +99,8 @@ public class WhitespaceLinter { userIndex: userWhitespace.startIndex, formattedIndex: formattedWhitespace.startIndex, userRuns: userRuns, - formattedRuns: formattedRuns) + formattedRuns: formattedRuns + ) // No need to perform any further checks if the whitespace is identical. guard userWhitespace != formattedWhitespace else { return } @@ -136,7 +138,10 @@ public class WhitespaceLinter { // If this isn't the last whitespace run, then it must precede a newline, so we check // for trailing whitespace violations. checkForTrailingWhitespaceErrors( - userIndex: userIndex, userRun: userRun, formattedRun: formattedRun) + userIndex: userIndex, + userRun: userRun, + formattedRun: formattedRun + ) } userIndex += userRun.count + 1 } @@ -153,7 +158,8 @@ public class WhitespaceLinter { checkForIndentationErrors( userIndex: userIndex, userRun: userRunsIterator.latestElement!, - formattedRun: lastFormattedRun) + formattedRun: lastFormattedRun + ) } } @@ -164,7 +170,8 @@ public class WhitespaceLinter { diagnose( .addLinesError(excessFormattedLines), category: .addLines, - utf8Offset: userWhitespace.startIndex) + utf8Offset: userWhitespace.startIndex + ) } } @@ -258,7 +265,9 @@ public class WhitespaceLinter { /// - userRun: A run of whitespace from the user text. /// - formattedRun: A run of whitespace from the formatted text. private func checkForIndentationErrors( - userIndex: Int, userRun: ArraySlice, formattedRun: ArraySlice + userIndex: Int, + userRun: ArraySlice, + formattedRun: ArraySlice ) { guard userRun != formattedRun else { return } @@ -267,7 +276,8 @@ public class WhitespaceLinter { diagnose( .indentationError(expected: expected, actual: actual), category: .indentation, - utf8Offset: userIndex) + utf8Offset: userIndex + ) } /// Compare user and formatted whitespace buffers, and check for trailing whitespace. @@ -277,7 +287,9 @@ public class WhitespaceLinter { /// - userRun: The tokenized user whitespace buffer. /// - formattedRun: The tokenized formatted whitespace buffer. private func checkForTrailingWhitespaceErrors( - userIndex: Int, userRun: ArraySlice, formattedRun: ArraySlice + userIndex: Int, + userRun: ArraySlice, + formattedRun: ArraySlice ) { if userRun != formattedRun { diagnose(.trailingWhitespaceError, category: .trailingWhitespace, utf8Offset: userIndex) @@ -295,7 +307,9 @@ public class WhitespaceLinter { /// - userRun: The tokenized user whitespace buffer. /// - formattedRun: The tokenized formatted whitespace buffer. private func checkForSpacingErrors( - userIndex: Int, userRun: ArraySlice, formattedRun: ArraySlice + userIndex: Int, + userRun: ArraySlice, + formattedRun: ArraySlice ) { guard userRun != formattedRun else { return } @@ -321,11 +335,20 @@ public class WhitespaceLinter { /// - data: The input string. /// - Returns: A slice of `data` that covers the contiguous whitespace starting at the given /// index. - private func contiguousWhitespace(startingAt offset: Int, in data: [UTF8.CodeUnit]) - -> ArraySlice - { - guard let whitespaceEnd = - data[offset...].firstIndex(where: { !UnicodeScalar($0).properties.isWhitespace }) + private func contiguousWhitespace( + startingAt offset: Int, + in data: [UTF8.CodeUnit] + ) -> ArraySlice { + func isWhitespace(_ char: UTF8.CodeUnit) -> Bool { + switch char { + case UInt8(ascii: " "), UInt8(ascii: "\n"), UInt8(ascii: "\t"), UInt8(ascii: "\r"), /*VT*/ 0x0B, /*FF*/ 0x0C: + return true + default: + return false + } + } + guard + let whitespaceEnd = data[offset...].firstIndex(where: { !isWhitespace($0) }) else { return data[offset.. Finding.Message { @@ -471,17 +497,20 @@ extension Finding.Message { } } - public static func spacingError(_ spaces: Int) -> Finding.Message { + fileprivate static func spacingError(_ spaces: Int) -> Finding.Message { let verb = spaces > 0 ? "add" : "remove" let noun = abs(spaces) == 1 ? "space" : "spaces" return "\(verb) \(abs(spaces)) \(noun)" } - public static let spacingCharError: Finding.Message = "use spaces for spacing" + fileprivate static let spacingCharError: Finding.Message = "use spaces for spacing" - public static let removeLineError: Finding.Message = "remove line break" + fileprivate static let removeLineError: Finding.Message = "remove line break" - public static func addLinesError(_ lines: Int) -> Finding.Message { "add \(lines) line breaks" } + fileprivate static func addLinesError(_ lines: Int) -> Finding.Message { + let noun = lines == 1 ? "break" : "breaks" + return "add \(lines) line \(noun)" + } - public static let lineLengthError: Finding.Message = "line is too long" + fileprivate static let lineLengthError: Finding.Message = "line is too long" } diff --git a/Sources/SwiftFormatRules/AllPublicDeclarationsHaveDocumentation.swift b/Sources/SwiftFormat/Rules/AllPublicDeclarationsHaveDocumentation.swift similarity index 70% rename from Sources/SwiftFormatRules/AllPublicDeclarationsHaveDocumentation.swift rename to Sources/SwiftFormat/Rules/AllPublicDeclarationsHaveDocumentation.swift index ed5b0ee4d..feaa3a550 100644 --- a/Sources/SwiftFormatRules/AllPublicDeclarationsHaveDocumentation.swift +++ b/Sources/SwiftFormat/Rules/AllPublicDeclarationsHaveDocumentation.swift @@ -10,12 +10,12 @@ // //===----------------------------------------------------------------------===// -import SwiftFormatCore import SwiftSyntax /// All public or open declarations must have a top-level documentation comment. /// /// Lint: If a public declaration is missing a documentation comment, a lint error is raised. +@_spi(Rules) public final class AllPublicDeclarationsHaveDocumentation: SyntaxLintRule { /// Identifies this rule as being opt-in. While docs on most public declarations are beneficial, @@ -45,8 +45,8 @@ public final class AllPublicDeclarationsHaveDocumentation: SyntaxLintRule { } public override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind { - diagnoseMissingDocComment(DeclSyntax(node), name: node.identifier.text, modifiers: node.modifiers) - return .skipChildren + diagnoseMissingDocComment(DeclSyntax(node), name: node.name.text, modifiers: node.modifiers) + return .visitChildren } public override func visit(_ node: VariableDeclSyntax) -> SyntaxVisitorContinueKind { @@ -56,29 +56,39 @@ public final class AllPublicDeclarationsHaveDocumentation: SyntaxLintRule { } public override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind { - diagnoseMissingDocComment(DeclSyntax(node), name: node.identifier.text, modifiers: node.modifiers) - return .skipChildren + diagnoseMissingDocComment(DeclSyntax(node), name: node.name.text, modifiers: node.modifiers) + return .visitChildren + } + + public override func visit(_ node: EnumDeclSyntax) -> SyntaxVisitorContinueKind { + diagnoseMissingDocComment(DeclSyntax(node), name: node.name.text, modifiers: node.modifiers) + return .visitChildren + } + + public override func visit(_ node: ActorDeclSyntax) -> SyntaxVisitorContinueKind { + diagnoseMissingDocComment(DeclSyntax(node), name: node.name.text, modifiers: node.modifiers) + return .visitChildren } public override func visit(_ node: ProtocolDeclSyntax) -> SyntaxVisitorContinueKind { - diagnoseMissingDocComment(DeclSyntax(node), name: node.identifier.text, modifiers: node.modifiers) + diagnoseMissingDocComment(DeclSyntax(node), name: node.name.text, modifiers: node.modifiers) return .skipChildren } - public override func visit(_ node: TypealiasDeclSyntax) -> SyntaxVisitorContinueKind { - diagnoseMissingDocComment(DeclSyntax(node), name: node.identifier.text, modifiers: node.modifiers) + public override func visit(_ node: TypeAliasDeclSyntax) -> SyntaxVisitorContinueKind { + diagnoseMissingDocComment(DeclSyntax(node), name: node.name.text, modifiers: node.modifiers) return .skipChildren } private func diagnoseMissingDocComment( _ decl: DeclSyntax, name: String, - modifiers: ModifierListSyntax? + modifiers: DeclModifierListSyntax ) { - guard decl.docComment == nil else { return } - guard let mods = modifiers, - mods.has(modifier: "public"), - !mods.has(modifier: "override") + guard + DocumentationCommentText(extractedFrom: decl.leadingTrivia) == nil, + modifiers.contains(anyOf: [.public]), + !modifiers.contains(anyOf: [.override]) else { return } @@ -88,7 +98,7 @@ public final class AllPublicDeclarationsHaveDocumentation: SyntaxLintRule { } extension Finding.Message { - public static func declRequiresComment(_ name: String) -> Finding.Message { + fileprivate static func declRequiresComment(_ name: String) -> Finding.Message { "add a documentation comment for '\(name)'" } } diff --git a/Sources/SwiftFormat/Rules/AlwaysUseLiteralForEmptyCollectionInit.swift b/Sources/SwiftFormat/Rules/AlwaysUseLiteralForEmptyCollectionInit.swift new file mode 100644 index 000000000..124ad63f7 --- /dev/null +++ b/Sources/SwiftFormat/Rules/AlwaysUseLiteralForEmptyCollectionInit.swift @@ -0,0 +1,225 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Foundation +import SwiftParser +import SwiftSyntax + +/// Never use `[]()` syntax. In call sites that should be replaced with `[]`, +/// for initializations use explicit type combined with empty array literal `let _: [] = []` +/// Static properties of a type that return that type should not include a reference to their type. +/// +/// Lint: Non-literal empty array initialization will yield a lint error. +/// Format: All invalid use sites would be related with empty literal (with or without explicit type annotation). +@_spi(Rules) +public final class AlwaysUseLiteralForEmptyCollectionInit: SyntaxFormatRule { + public override class var isOptIn: Bool { return true } + + public override func visit(_ node: PatternBindingSyntax) -> PatternBindingSyntax { + guard let initializer = node.initializer, + let type = isRewritable(initializer) + else { + return node + } + + if let type = type.as(ArrayTypeSyntax.self) { + return rewrite(node, type: type) + } + + if let type = type.as(DictionaryTypeSyntax.self) { + return rewrite(node, type: type) + } + + return node + } + + public override func visit(_ param: FunctionParameterSyntax) -> FunctionParameterSyntax { + guard let initializer = param.defaultValue, + let type = isRewritable(initializer) + else { + return param + } + + if let type = type.as(ArrayTypeSyntax.self) { + return rewrite(param, type: type) + } + + if let type = type.as(DictionaryTypeSyntax.self) { + return rewrite(param, type: type) + } + + return param + } + + /// Check whether the initializer is `[]()` and, if so, it could be rewritten to use an empty collection literal. + /// Return a type of the collection. + public func isRewritable(_ initializer: InitializerClauseSyntax) -> TypeSyntax? { + guard let initCall = initializer.value.as(FunctionCallExprSyntax.self), + initCall.arguments.isEmpty + else { + return nil + } + + if let arrayLiteral = initCall.calledExpression.as(ArrayExprSyntax.self) { + return getLiteralType(arrayLiteral) + } + + if let dictLiteral = initCall.calledExpression.as(DictionaryExprSyntax.self) { + return getLiteralType(dictLiteral) + } + + return nil + } + + private func rewrite( + _ node: PatternBindingSyntax, + type: ArrayTypeSyntax + ) -> PatternBindingSyntax { + var replacement = node + + diagnose(node, type: type) + + if replacement.typeAnnotation == nil { + // Drop trailing trivia after pattern because ':' has to appear connected to it. + replacement.pattern = node.pattern.with(\.trailingTrivia, []) + // Add explicit type annotation: ': []` + replacement.typeAnnotation = .init( + type: type.with(\.leadingTrivia, .space) + .with(\.trailingTrivia, .space) + ) + } + + let initializer = node.initializer! + let emptyArrayExpr = ArrayExprSyntax(elements: ArrayElementListSyntax.init([])) + + // Replace initializer call with empty array literal: `[]()` -> `[]` + replacement.initializer = initializer.with(\.value, ExprSyntax(emptyArrayExpr)) + + return replacement + } + + private func rewrite( + _ node: PatternBindingSyntax, + type: DictionaryTypeSyntax + ) -> PatternBindingSyntax { + var replacement = node + + diagnose(node, type: type) + + if replacement.typeAnnotation == nil { + // Drop trailing trivia after pattern because ':' has to appear connected to it. + replacement.pattern = node.pattern.with(\.trailingTrivia, []) + // Add explicit type annotation: ': []` + replacement.typeAnnotation = .init( + type: type.with(\.leadingTrivia, .space) + .with(\.trailingTrivia, .space) + ) + } + + let initializer = node.initializer! + // Replace initializer call with empty dictionary literal: `[]()` -> `[]` + replacement.initializer = initializer.with(\.value, ExprSyntax(getEmptyDictionaryLiteral())) + + return replacement + } + + private func rewrite( + _ param: FunctionParameterSyntax, + type: ArrayTypeSyntax + ) -> FunctionParameterSyntax { + guard let initializer = param.defaultValue else { + return param + } + + emitDiagnostic(replace: "\(initializer.value)", with: "[]", on: initializer.value) + return param.with(\.defaultValue, initializer.with(\.value, getEmptyArrayLiteral())) + } + + private func rewrite( + _ param: FunctionParameterSyntax, + type: DictionaryTypeSyntax + ) -> FunctionParameterSyntax { + guard let initializer = param.defaultValue else { + return param + } + + emitDiagnostic(replace: "\(initializer.value)", with: "[:]", on: initializer.value) + return param.with(\.defaultValue, initializer.with(\.value, getEmptyDictionaryLiteral())) + } + + private func diagnose(_ node: PatternBindingSyntax, type: ArrayTypeSyntax) { + var withFixIt = "[]" + if node.typeAnnotation == nil { + withFixIt = ": \(type) = []" + } + + let initCall = node.initializer!.value + emitDiagnostic(replace: "\(initCall)", with: withFixIt, on: initCall) + } + + private func diagnose(_ node: PatternBindingSyntax, type: DictionaryTypeSyntax) { + var withFixIt = "[:]" + if node.typeAnnotation == nil { + withFixIt = ": \(type) = [:]" + } + + let initCall = node.initializer!.value + emitDiagnostic(replace: "\(initCall)", with: withFixIt, on: initCall) + } + + private func emitDiagnostic(replace: String, with fixIt: String, on: ExprSyntax?) { + diagnose(.refactorIntoEmptyLiteral(replace: replace, with: fixIt), on: on) + } + + private func getLiteralType(_ arrayLiteral: ArrayExprSyntax) -> TypeSyntax? { + guard arrayLiteral.elements.count == 1 else { + return nil + } + + var parser = Parser(arrayLiteral.description) + let elementType = TypeSyntax.parse(from: &parser) + + guard !elementType.hasError, elementType.is(ArrayTypeSyntax.self) else { + return nil + } + + return elementType + } + + private func getLiteralType(_ dictLiteral: DictionaryExprSyntax) -> TypeSyntax? { + var parser = Parser(dictLiteral.description) + let elementType = TypeSyntax.parse(from: &parser) + + guard !elementType.hasError, elementType.is(DictionaryTypeSyntax.self) else { + return nil + } + + return elementType + } + + private func getEmptyArrayLiteral() -> ExprSyntax { + ExprSyntax(ArrayExprSyntax(elements: ArrayElementListSyntax.init([]))) + } + + private func getEmptyDictionaryLiteral() -> ExprSyntax { + ExprSyntax(DictionaryExprSyntax(content: .colon(.colonToken()))) + } +} + +extension Finding.Message { + fileprivate static func refactorIntoEmptyLiteral( + replace: String, + with: String + ) -> Finding.Message { + "replace '\(replace)' with '\(with)'" + } +} diff --git a/Sources/SwiftFormatRules/AlwaysUseLowerCamelCase.swift b/Sources/SwiftFormat/Rules/AlwaysUseLowerCamelCase.swift similarity index 68% rename from Sources/SwiftFormatRules/AlwaysUseLowerCamelCase.swift rename to Sources/SwiftFormat/Rules/AlwaysUseLowerCamelCase.swift index a9eccee21..214daa253 100644 --- a/Sources/SwiftFormatRules/AlwaysUseLowerCamelCase.swift +++ b/Sources/SwiftFormat/Rules/AlwaysUseLowerCamelCase.swift @@ -10,14 +10,18 @@ // //===----------------------------------------------------------------------===// -import SwiftFormatCore import SwiftSyntax /// All values should be written in lower camel-case (`lowerCamelCase`). /// Underscores (except at the beginning of an identifier) are disallowed. /// +/// This rule does not apply to test code, defined as code which: +/// * Contains the line `import XCTest` +/// * The function is marked with `@Test` attribute +/// /// Lint: If an identifier contains underscores or begins with a capital letter, a lint error is /// raised. +@_spi(Rules) public final class AlwaysUseLowerCamelCase: SyntaxLintRule { /// Stores function decls that are test cases. private var testCaseFuncs = Set() @@ -31,7 +35,7 @@ public final class AlwaysUseLowerCamelCase: SyntaxLintRule { public override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind { guard context.importsXCTest == .importsXCTest else { return .visitChildren } - collectTestMethods(from: node.members.members, into: &testCaseFuncs) + collectTestMethods(from: node.memberBlock.members, into: &testCaseFuncs) return .visitChildren } @@ -43,7 +47,7 @@ public final class AlwaysUseLowerCamelCase: SyntaxLintRule { // Don't diagnose any issues when the variable is overriding, because this declaration can't // rename the variable. If the user analyzes the code where the variable is really declared, // then the diagnostic can be raised for just that location. - if let modifiers = node.modifiers, modifiers.has(modifier: "override") { + if node.modifiers.contains(anyOf: [.override]) { return .visitChildren } @@ -52,7 +56,10 @@ public final class AlwaysUseLowerCamelCase: SyntaxLintRule { continue } diagnoseLowerCamelCaseViolations( - pat.identifier, allowUnderscores: false, description: identifierDescription(for: node)) + pat.identifier, + allowUnderscores: false, + description: identifierDescription(for: node) + ) } return .visitChildren } @@ -62,26 +69,36 @@ public final class AlwaysUseLowerCamelCase: SyntaxLintRule { return .visitChildren } diagnoseLowerCamelCaseViolations( - pattern.identifier, allowUnderscores: false, description: identifierDescription(for: node)) + pattern.identifier, + allowUnderscores: false, + description: identifierDescription(for: node) + ) return .visitChildren } public override func visit(_ node: ClosureSignatureSyntax) -> SyntaxVisitorContinueKind { - if let input = node.input { - if let closureParamList = input.as(ClosureParamListSyntax.self) { + if let input = node.parameterClause { + if let closureParamList = input.as(ClosureShorthandParameterListSyntax.self) { for param in closureParamList { diagnoseLowerCamelCaseViolations( - param.name, allowUnderscores: false, description: identifierDescription(for: node)) + param.name, + allowUnderscores: false, + description: identifierDescription(for: node) + ) } - } else if let parameterClause = input.as(ParameterClauseSyntax.self) { - for param in parameterClause.parameterList { - if let firstName = param.firstName { - diagnoseLowerCamelCaseViolations( - firstName, allowUnderscores: false, description: identifierDescription(for: node)) - } + } else if let parameterClause = input.as(ClosureParameterClauseSyntax.self) { + for param in parameterClause.parameters { + diagnoseLowerCamelCaseViolations( + param.firstName, + allowUnderscores: false, + description: identifierDescription(for: node) + ) if let secondName = param.secondName { diagnoseLowerCamelCaseViolations( - secondName, allowUnderscores: false, description: identifierDescription(for: node)) + secondName, + allowUnderscores: false, + description: identifierDescription(for: node) + ) } } } @@ -93,26 +110,33 @@ public final class AlwaysUseLowerCamelCase: SyntaxLintRule { // Don't diagnose any issues when the function is overriding, because this declaration can't // rename the function. If the user analyzes the code where the function is really declared, // then the diagnostic can be raised for just that location. - if let modifiers = node.modifiers, modifiers.has(modifier: "override") { + if node.modifiers.contains(anyOf: [.override]) { return .visitChildren } // We allow underscores in test names, because there's an existing convention of using // underscores to separate phrases in very detailed test names. - let allowUnderscores = testCaseFuncs.contains(node) + let allowUnderscores = testCaseFuncs.contains(node) || node.hasAttribute("Test", inModule: "Testing") + diagnoseLowerCamelCaseViolations( - node.identifier, allowUnderscores: allowUnderscores, - description: identifierDescription(for: node)) - for param in node.signature.input.parameterList { + node.name, + allowUnderscores: allowUnderscores, + description: identifierDescription(for: node) + ) + for param in node.signature.parameterClause.parameters { // These identifiers aren't described using `identifierDescription(for:)` because no single // node can disambiguate the argument label from the parameter name. - if let label = param.firstName { - diagnoseLowerCamelCaseViolations( - label, allowUnderscores: false, description: "argument label") - } + diagnoseLowerCamelCaseViolations( + param.firstName, + allowUnderscores: false, + description: "argument label" + ) if let paramName = param.secondName { diagnoseLowerCamelCaseViolations( - paramName, allowUnderscores: false, description: "function parameter") + paramName, + allowUnderscores: false, + description: "function parameter" + ) } } return .visitChildren @@ -120,30 +144,33 @@ public final class AlwaysUseLowerCamelCase: SyntaxLintRule { public override func visit(_ node: EnumCaseElementSyntax) -> SyntaxVisitorContinueKind { diagnoseLowerCamelCaseViolations( - node.identifier, allowUnderscores: false, description: identifierDescription(for: node)) + node.name, + allowUnderscores: false, + description: identifierDescription(for: node) + ) return .skipChildren } /// Collects methods that look like XCTest test case methods from the given member list, inserting /// them into the given set. private func collectTestMethods( - from members: MemberDeclListSyntax, + from members: MemberBlockItemListSyntax, into set: inout Set ) { for member in members { if let ifConfigDecl = member.decl.as(IfConfigDeclSyntax.self) { // Recurse into any conditional member lists and collect their test methods as well. for clause in ifConfigDecl.clauses { - if let clauseMembers = clause.elements?.as(MemberDeclListSyntax.self) { + if let clauseMembers = clause.elements?.as(MemberBlockItemListSyntax.self) { collectTestMethods(from: clauseMembers, into: &set) } } } else if let functionDecl = member.decl.as(FunctionDeclSyntax.self) { // Identify test methods using the same heuristics as XCTest: name starts with "test", has // no arguments, and returns a void type. - if functionDecl.identifier.text.starts(with: "test") - && functionDecl.signature.input.parameterList.isEmpty - && (functionDecl.signature.output.map(\.isVoid) ?? true) + if functionDecl.name.text.starts(with: "test") + && functionDecl.signature.parameterClause.parameters.isEmpty + && (functionDecl.signature.returnClause.map(\.isVoid) ?? true) { set.insert(functionDecl) } @@ -152,7 +179,9 @@ public final class AlwaysUseLowerCamelCase: SyntaxLintRule { } private func diagnoseLowerCamelCaseViolations( - _ identifier: TokenSyntax, allowUnderscores: Bool, description: String + _ identifier: TokenSyntax, + allowUnderscores: Bool, + description: String ) { guard case .identifier(let text) = identifier.tokenKind else { return } if text.isEmpty { return } @@ -173,9 +202,9 @@ fileprivate func identifierDescription(for node: NodeT case .enumCaseElement: return "enum case" case .functionDecl: return "function" case .optionalBindingCondition(let binding): - return binding.letOrVarKeyword.tokenKind == .varKeyword ? "variable" : "constant" + return binding.bindingSpecifier.tokenKind == .keyword(.var) ? "variable" : "constant" case .variableDecl(let variableDecl): - return variableDecl.letOrVarKeyword.tokenKind == .varKeyword ? "variable" : "constant" + return variableDecl.bindingSpecifier.tokenKind == .keyword(.var) ? "variable" : "constant" default: return "identifier" } @@ -184,10 +213,10 @@ fileprivate func identifierDescription(for node: NodeT extension ReturnClauseSyntax { /// Whether this return clause specifies an explicit `Void` return type. fileprivate var isVoid: Bool { - if let returnTypeIdentifier = returnType.as(SimpleTypeIdentifierSyntax.self) { + if let returnTypeIdentifier = type.as(IdentifierTypeSyntax.self) { return returnTypeIdentifier.name.text == "Void" } - if let returnTypeTuple = returnType.as(TupleTypeSyntax.self) { + if let returnTypeTuple = type.as(TupleTypeSyntax.self) { return returnTypeTuple.elements.isEmpty } return false @@ -195,9 +224,10 @@ extension ReturnClauseSyntax { } extension Finding.Message { - public static func nameMustBeLowerCamelCase( - _ name: String, description: String + fileprivate static func nameMustBeLowerCamelCase( + _ name: String, + description: String ) -> Finding.Message { - "rename \(description) '\(name)' using lower-camel-case" + "rename the \(description) '\(name)' using lowerCamelCase" } } diff --git a/Sources/SwiftFormatRules/AmbiguousTrailingClosureOverload.swift b/Sources/SwiftFormat/Rules/AmbiguousTrailingClosureOverload.swift similarity index 73% rename from Sources/SwiftFormatRules/AmbiguousTrailingClosureOverload.swift rename to Sources/SwiftFormat/Rules/AmbiguousTrailingClosureOverload.swift index d20310197..72eb28cc3 100644 --- a/Sources/SwiftFormatRules/AmbiguousTrailingClosureOverload.swift +++ b/Sources/SwiftFormat/Rules/AmbiguousTrailingClosureOverload.swift @@ -10,13 +10,13 @@ // //===----------------------------------------------------------------------===// -import SwiftFormatCore import SwiftSyntax /// Overloads with only a closure argument should not be disambiguated by parameter labels. /// /// Lint: If two overloaded functions with one closure parameter appear in the same scope, a lint /// error is raised. +@_spi(Rules) public final class AmbiguousTrailingClosureOverload: SyntaxLintRule { private func diagnoseBadOverloads(_ overloads: [String: [FunctionDeclSyntax]]) { @@ -24,14 +24,16 @@ public final class AmbiguousTrailingClosureOverload: SyntaxLintRule { let decl = decls[0] diagnose( .ambiguousTrailingClosureOverload(decl.fullDeclName), - on: decl.identifier, + on: decl.name, notes: decls.dropFirst().map { decl in Finding.Note( message: .otherAmbiguousOverloadHere(decl.fullDeclName), location: Finding.Location( - decl.identifier.startLocation(converter: self.context.sourceLocationConverter)) + decl.name.startLocation(converter: self.context.sourceLocationConverter) + ) ) - }) + } + ) } } @@ -39,13 +41,13 @@ public final class AmbiguousTrailingClosureOverload: SyntaxLintRule { var overloads = [String: [FunctionDeclSyntax]]() var staticOverloads = [String: [FunctionDeclSyntax]]() for fn in functions { - let params = fn.signature.input.parameterList + let params = fn.signature.parameterClause.parameters guard let firstParam = params.firstAndOnly else { continue } - guard let type = firstParam.type, type.is(FunctionTypeSyntax.self) else { continue } - if let mods = fn.modifiers, mods.has(modifier: "static") || mods.has(modifier: "class") { - staticOverloads[fn.identifier.text, default: []].append(fn) + guard firstParam.type.is(FunctionTypeSyntax.self) else { continue } + if fn.modifiers.contains(anyOf: [.class, .static]) { + staticOverloads[fn.name.text, default: []].append(fn) } else { - overloads[fn.identifier.text, default: []].append(fn) + overloads[fn.name.text, default: []].append(fn) } } @@ -65,7 +67,7 @@ public final class AmbiguousTrailingClosureOverload: SyntaxLintRule { return .visitChildren } - public override func visit(_ decls: MemberDeclBlockSyntax) -> SyntaxVisitorContinueKind { + public override func visit(_ decls: MemberBlockSyntax) -> SyntaxVisitorContinueKind { let functions = decls.members.compactMap { $0.decl.as(FunctionDeclSyntax.self) } discoverAndDiagnoseOverloads(functions) return .visitChildren @@ -73,11 +75,11 @@ public final class AmbiguousTrailingClosureOverload: SyntaxLintRule { } extension Finding.Message { - public static func ambiguousTrailingClosureOverload(_ decl: String) -> Finding.Message { - "rename '\(decl)' so it is no longer ambiguous with a trailing closure" + fileprivate static func ambiguousTrailingClosureOverload(_ decl: String) -> Finding.Message { + "rename '\(decl)' so it is no longer ambiguous when called with a trailing closure" } - public static func otherAmbiguousOverloadHere(_ decl: String) -> Finding.Message { + fileprivate static func otherAmbiguousOverloadHere(_ decl: String) -> Finding.Message { "ambiguous overload '\(decl)' is here" } } diff --git a/Sources/SwiftFormat/Rules/AvoidRetroactiveConformances.swift b/Sources/SwiftFormat/Rules/AvoidRetroactiveConformances.swift new file mode 100644 index 000000000..1573d3de2 --- /dev/null +++ b/Sources/SwiftFormat/Rules/AvoidRetroactiveConformances.swift @@ -0,0 +1,38 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2024 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import SwiftSyntax + +/// `@retroactive` conformances are forbidden. +/// +/// Lint: Using `@retroactive` results in a lint error. +@_spi(Rules) +public final class AvoidRetroactiveConformances: SyntaxLintRule { + public override func visit(_ node: ExtensionDeclSyntax) -> SyntaxVisitorContinueKind { + if let inheritanceClause = node.inheritanceClause { + walk(inheritanceClause) + } + return .skipChildren + } + public override func visit(_ type: AttributeSyntax) -> SyntaxVisitorContinueKind { + if let identifier = type.attributeName.as(IdentifierTypeSyntax.self) { + if identifier.name.text == "retroactive" { + diagnose(.doNotUseRetroactive, on: type) + } + } + return .skipChildren + } +} + +extension Finding.Message { + fileprivate static let doNotUseRetroactive: Finding.Message = "do not declare retroactive conformances" +} diff --git a/Sources/SwiftFormatRules/BeginDocumentationCommentWithOneLineSummary.swift b/Sources/SwiftFormat/Rules/BeginDocumentationCommentWithOneLineSummary.swift similarity index 74% rename from Sources/SwiftFormatRules/BeginDocumentationCommentWithOneLineSummary.swift rename to Sources/SwiftFormat/Rules/BeginDocumentationCommentWithOneLineSummary.swift index 49b76b0e5..6caf28ddc 100644 --- a/Sources/SwiftFormatRules/BeginDocumentationCommentWithOneLineSummary.swift +++ b/Sources/SwiftFormat/Rules/BeginDocumentationCommentWithOneLineSummary.swift @@ -11,12 +11,16 @@ //===----------------------------------------------------------------------===// import Foundation -import SwiftFormatCore import SwiftSyntax +#if os(macOS) +import NaturalLanguage +#endif + /// All documentation comments must begin with a one-line summary of the declaration. /// /// Lint: If a comment does not begin with a single-line summary, a lint error is raised. +@_spi(Rules) public final class BeginDocumentationCommentWithOneLineSummary: SyntaxLintRule { /// Unit tests can testably import this module and set this to true in order to force the rule @@ -37,7 +41,7 @@ public final class BeginDocumentationCommentWithOneLineSummary: SyntaxLintRule { public override func visit(_ node: EnumDeclSyntax) -> SyntaxVisitorContinueKind { diagnoseDocComments(in: DeclSyntax(node)) - return .skipChildren + return .visitChildren } public override func visit(_ node: InitializerDeclSyntax) -> SyntaxVisitorContinueKind { @@ -57,7 +61,7 @@ public final class BeginDocumentationCommentWithOneLineSummary: SyntaxLintRule { public override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind { diagnoseDocComments(in: DeclSyntax(node)) - return .skipChildren + return .visitChildren } public override func visit(_ node: VariableDeclSyntax) -> SyntaxVisitorContinueKind { @@ -67,7 +71,7 @@ public final class BeginDocumentationCommentWithOneLineSummary: SyntaxLintRule { public override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind { diagnoseDocComments(in: DeclSyntax(node)) - return .skipChildren + return .visitChildren } public override func visit(_ node: ProtocolDeclSyntax) -> SyntaxVisitorContinueKind { @@ -75,23 +79,26 @@ public final class BeginDocumentationCommentWithOneLineSummary: SyntaxLintRule { return .skipChildren } - public override func visit(_ node: TypealiasDeclSyntax) -> SyntaxVisitorContinueKind { + public override func visit(_ node: TypeAliasDeclSyntax) -> SyntaxVisitorContinueKind { diagnoseDocComments(in: DeclSyntax(node)) return .skipChildren } - public override func visit(_ node: AssociatedtypeDeclSyntax) -> SyntaxVisitorContinueKind { + public override func visit(_ node: AssociatedTypeDeclSyntax) -> SyntaxVisitorContinueKind { diagnoseDocComments(in: DeclSyntax(node)) return .skipChildren } /// Diagnose documentation comments that don't start with one sentence summary. private func diagnoseDocComments(in decl: DeclSyntax) { - guard let commentText = decl.docComment else { return } - let docComments = commentText.components(separatedBy: "\n") - guard let firstPart = firstParagraph(docComments) else { return } - - let trimmedText = firstPart.trimmingCharacters(in: .whitespacesAndNewlines) + guard + let docComment = DocumentationComment(extractedFrom: decl), + let briefSummary = docComment.briefSummary + else { return } + + // For the purposes of checking the sentence structure of the comment, we can operate on the + // plain text; we don't need any of the styling. + let trimmedText = briefSummary.plainText.trimmingCharacters(in: .whitespacesAndNewlines) let (commentSentences, trailingText) = sentences(in: trimmedText) if commentSentences.count == 0 { diagnose(.terminateSentenceWithPeriod(trimmedText), on: decl) @@ -103,17 +110,6 @@ public final class BeginDocumentationCommentWithOneLineSummary: SyntaxLintRule { } } - /// Returns the text of the first part of the comment, - private func firstParagraph(_ comments: [String]) -> String? { - var text = [String]() - var index = 0 - while index < comments.count && comments[index] != "*" && comments[index] != "" { - text.append(comments[index]) - index += 1 - } - return comments.isEmpty ? nil : text.joined(separator: " ") - } - /// Returns all the sentences in the given text. /// /// This function uses linguistic APIs if they are available on the current platform; otherwise, @@ -128,43 +124,63 @@ public final class BeginDocumentationCommentWithOneLineSummary: SyntaxLintRule { /// actual text). private func sentences(in text: String) -> (sentences: [String], trailingText: Substring) { #if os(macOS) - if BeginDocumentationCommentWithOneLineSummary._forcesFallbackModeForTesting { - return nonLinguisticSentenceApproximations(in: text) - } + if BeginDocumentationCommentWithOneLineSummary._forcesFallbackModeForTesting { + return nonLinguisticSentenceApproximations(in: text) + } - var sentences = [String]() - var tokenRanges = [Range]() - let tags = text.linguisticTags( - in: text.startIndex..]() + + let tagger = NLTagger(tagSchemes: [.lexicalClass]) + tagger.string = text + tagger.enumerateTags( + in: text.startIndex.. ( + private func nonLinguisticSentenceApproximations( + in text: String + ) -> ( sentences: [String], trailingText: Substring ) { // If we find a period followed by a space, then there is definitely one (approximate) sentence; @@ -203,15 +219,15 @@ public final class BeginDocumentationCommentWithOneLineSummary: SyntaxLintRule { } extension Finding.Message { - public static func terminateSentenceWithPeriod(_ text: Sentence) - -> Finding.Message - { + fileprivate static func terminateSentenceWithPeriod( + _ text: Sentence + ) -> Finding.Message { "terminate this sentence with a period: \"\(text)\"" } - public static func addBlankLineAfterFirstSentence(_ text: Sentence) - -> Finding.Message - { + fileprivate static func addBlankLineAfterFirstSentence( + _ text: Sentence + ) -> Finding.Message { "add a blank comment line after this sentence: \"\(text)\"" } } diff --git a/Sources/SwiftFormat/Rules/DoNotUseSemicolons.swift b/Sources/SwiftFormat/Rules/DoNotUseSemicolons.swift new file mode 100644 index 000000000..7559d72ed --- /dev/null +++ b/Sources/SwiftFormat/Rules/DoNotUseSemicolons.swift @@ -0,0 +1,129 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2019 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import SwiftSyntax + +/// Semicolons should not be present in Swift code. +/// +/// Lint: If a semicolon appears anywhere, a lint error is raised. +/// +/// Format: All semicolons will be replaced with line breaks. +@_spi(Rules) +public final class DoNotUseSemicolons: SyntaxFormatRule { + /// Creates a new version of the given node which doesn't contain any semicolons. The node's + /// items are recursively modified to remove semicolons, replacing with line breaks where needed. + /// Items are checked recursively to support items that contain code blocks, which may have + /// semicolons to be removed. + /// + /// - Parameters: + /// - node: A node that contains items which may have semicolons or nested code blocks. + /// - nodeCreator: A closure that creates a new node given an array of items. + private func nodeByRemovingSemicolons< + ItemType: SyntaxProtocol & WithSemicolonSyntax & Equatable, + NodeType: SyntaxCollection + >(from node: NodeType) -> NodeType where NodeType.Element == ItemType { + var newItems = Array(node) + + // Keeps track of trailing trivia after a semicolon when it needs to be moved to precede the + // next statement. + var pendingTrivia = Trivia() + + for (idx, item) in node.enumerated() { + // Check for semicolons in statements inside of the item, because code blocks may be nested + // inside of other code blocks. + guard var newItem = rewrite(Syntax(item)).as(ItemType.self) else { + return node + } + + // Check if we need to make any modifications (removing semicolon/adding newlines). + guard newItem != item || item.semicolon != nil || !pendingTrivia.isEmpty else { + continue + } + + // Check if the leading trivia for this statement needs a new line. + if !pendingTrivia.isEmpty { + newItem.leadingTrivia = pendingTrivia + newItem.leadingTrivia + } + pendingTrivia = [] + + // If there's a semicolon, diagnose and remove it. + // Exception: Do not remove the semicolon if it is separating a `do` statement from a `while` + // statement. + if let semicolon = item.semicolon, + !(idx < node.count - 1 + && isCodeBlockItem(item, containingStmtType: DoStmtSyntax.self) + && isCodeBlockItem(newItems[idx + 1], containingStmtType: WhileStmtSyntax.self)) + { + // When emitting the finding, tell the user to move the next statement down if there is + // another statement following this one. Otherwise, just tell them to remove the semicolon. + var hasNextStatement: Bool + if let nextToken = semicolon.nextToken(viewMode: .sourceAccurate), + nextToken.tokenKind != .rightBrace && nextToken.tokenKind != .endOfFile + && !nextToken.leadingTrivia.containsNewlines + { + hasNextStatement = true + pendingTrivia = [.newlines(1)] + diagnose(.removeSemicolonAndMove, on: semicolon) + } else { + hasNextStatement = false + diagnose(.removeSemicolon, on: semicolon) + } + + // We treat block comments after the semicolon slightly differently from end-of-line + // comments. Assume that an end-of-line comment should stay on the same line when a + // semicolon is removed, but if we have something like `f(); /* blah */ g()`, assume that + // the comment is meant to be associated with `g()` (because it's not separated from that + // statement). + let trailingTrivia = newItem.trailingTrivia + newItem.semicolon = nil + if trailingTrivia.hasLineComment || !hasNextStatement { + newItem.trailingTrivia = trailingTrivia + } else { + pendingTrivia += trailingTrivia.withoutLeadingSpaces() + } + } + newItems[idx] = newItem + } + + return NodeType(newItems) + } + + public override func visit(_ node: CodeBlockItemListSyntax) -> CodeBlockItemListSyntax { + return nodeByRemovingSemicolons(from: node) + } + + public override func visit(_ node: MemberBlockItemListSyntax) -> MemberBlockItemListSyntax { + return nodeByRemovingSemicolons(from: node) + } + + /// Returns true if the given syntax node is a `CodeBlockItem` containing a statement node of the + /// given type. + private func isCodeBlockItem( + _ node: some SyntaxProtocol, + containingStmtType stmtType: StmtSyntaxProtocol.Type + ) -> Bool { + if let codeBlockItem = node.as(CodeBlockItemSyntax.self), + case .stmt(let stmt) = codeBlockItem.item, + stmt.is(stmtType) + { + return true + } + return false + } +} + +extension Finding.Message { + fileprivate static let removeSemicolon: Finding.Message = "remove ';'" + + fileprivate static let removeSemicolonAndMove: Finding.Message = + "remove ';' and move the next statement to a new line" +} diff --git a/Sources/SwiftFormat/Rules/DontRepeatTypeInStaticProperties.swift b/Sources/SwiftFormat/Rules/DontRepeatTypeInStaticProperties.swift new file mode 100644 index 000000000..3bf853d2f --- /dev/null +++ b/Sources/SwiftFormat/Rules/DontRepeatTypeInStaticProperties.swift @@ -0,0 +1,108 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2019 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import SwiftSyntax + +/// Static properties of a type that return that type should not include a reference to their type. +/// +/// "Reference to their type" means that the property name includes part, or all, of the type. If +/// the type contains a namespace (i.e. `UIColor`) the namespace is ignored; +/// `public class var redColor: UIColor` would trigger this rule. +/// +/// Lint: Static properties of a type that return that type will yield a lint error. +@_spi(Rules) +public final class DontRepeatTypeInStaticProperties: SyntaxLintRule { + + /// Visits the static/class properties and diagnoses any where the name has the containing + /// type name (excluding possible namespace prefixes, like `NS` or `UI`) as a suffix. + public override func visit(_ node: VariableDeclSyntax) -> SyntaxVisitorContinueKind { + guard node.modifiers.contains(anyOf: [.class, .static]), + let typeName = Syntax(node).containingDeclName + else { + return .visitChildren + } + + let bareTypeName = removingPossibleNamespacePrefix(from: typeName) + for binding in node.bindings { + guard let identifierPattern = binding.pattern.as(IdentifierPatternSyntax.self) else { + continue + } + + let varName = identifierPattern.identifier.text + if varName.contains(bareTypeName) { + diagnose(.removeTypeFromName(name: varName, type: bareTypeName), on: identifierPattern) + } + } + + return .visitChildren + } + + /// Returns the portion of the given string that excludes a possible Objective-C-style capitalized + /// namespace prefix (a leading sequence of more than one uppercase letter). + /// + /// If the name has zero or one leading uppercase letters, the entire name is returned. + private func removingPossibleNamespacePrefix(from name: String) -> Substring { + guard let first = name.first, first.isUppercase else { return name[...] } + + for index in name.indices.dropLast() { + let nextIndex = name.index(after: index) + if name[index].isUppercase && !name[nextIndex].isUppercase { + return name[index...] + } + } + + return name[...] + } +} + +extension Finding.Message { + fileprivate static func removeTypeFromName(name: String, type: Substring) -> Finding.Message { + "remove the suffix '\(type)' from the name of the variable '\(name)'" + } +} + +extension Syntax { + /// Returns the name of the immediately enclosing type of this decl if there is one, + /// otherwise nil. + fileprivate var containingDeclName: String? { + switch Syntax(self).as(SyntaxEnum.self) { + case .actorDecl(let node): + return node.name.text + case .classDecl(let node): + return node.name.text + case .enumDecl(let node): + return node.name.text + case .protocolDecl(let node): + return node.name.text + case .structDecl(let node): + return node.name.text + case .extensionDecl(let node): + switch Syntax(node.extendedType).as(SyntaxEnum.self) { + case .identifierType(let simpleType): + return simpleType.name.text + case .memberType(let memberType): + // the final component of the top type `A.B.C.D` is what we want `D`. + return memberType.name.text + default: + // Do nothing for non-nominal types. If Swift adds support for extensions on non-nominals, + // we'll need to update this if we need to support some subset of those. + return nil + } + default: + if let parent = self.parent { + return parent.containingDeclName + } + + return nil + } + } +} diff --git a/Sources/SwiftFormatRules/FileScopedDeclarationPrivacy.swift b/Sources/SwiftFormat/Rules/FileScopedDeclarationPrivacy.swift similarity index 65% rename from Sources/SwiftFormatRules/FileScopedDeclarationPrivacy.swift rename to Sources/SwiftFormat/Rules/FileScopedDeclarationPrivacy.swift index 098b23c72..e0f9af4c5 100644 --- a/Sources/SwiftFormatRules/FileScopedDeclarationPrivacy.swift +++ b/Sources/SwiftFormat/Rules/FileScopedDeclarationPrivacy.swift @@ -10,7 +10,6 @@ // //===----------------------------------------------------------------------===// -import SwiftFormatCore import SwiftSyntax /// Declarations at file scope with effective private access should be consistently declared as @@ -21,10 +20,12 @@ import SwiftSyntax /// /// Format: File-scoped declarations that have formal access opposite to the desired access level in /// the formatter's configuration will have their access level changed. +@_spi(Rules) public final class FileScopedDeclarationPrivacy: SyntaxFormatRule { public override func visit(_ node: SourceFileSyntax) -> SourceFileSyntax { - let newStatements = rewrittenCodeBlockItems(node.statements) - return node.withStatements(newStatements) + var result = node + result.statements = rewrittenCodeBlockItems(node.statements) + return result } /// Returns a list of code block items equivalent to the given list, but where any file-scoped @@ -34,13 +35,15 @@ public final class FileScopedDeclarationPrivacy: SyntaxFormatRule { /// /// - Parameter codeBlockItems: The list of code block items to rewrite. /// - Returns: A new `CodeBlockItemListSyntax` that has possibly been rewritten. - private func rewrittenCodeBlockItems(_ codeBlockItems: CodeBlockItemListSyntax) - -> CodeBlockItemListSyntax - { + private func rewrittenCodeBlockItems( + _ codeBlockItems: CodeBlockItemListSyntax + ) -> CodeBlockItemListSyntax { let newCodeBlockItems = codeBlockItems.map { codeBlockItem -> CodeBlockItemSyntax in switch codeBlockItem.item { case .decl(let decl): - return codeBlockItem.withItem(.decl(rewrittenDecl(decl))) + var result = codeBlockItem + result.item = .decl(rewrittenDecl(decl)) + return result default: return codeBlockItem } @@ -56,46 +59,25 @@ public final class FileScopedDeclarationPrivacy: SyntaxFormatRule { return DeclSyntax(rewrittenIfConfigDecl(ifConfigDecl)) case .functionDecl(let functionDecl): - return DeclSyntax(rewrittenDecl( - functionDecl, - modifiers: functionDecl.modifiers, - factory: functionDecl.withModifiers)) + return DeclSyntax(rewrittenDecl(functionDecl)) case .variableDecl(let variableDecl): - return DeclSyntax(rewrittenDecl( - variableDecl, - modifiers: variableDecl.modifiers, - factory: variableDecl.withModifiers)) + return DeclSyntax(rewrittenDecl(variableDecl)) case .classDecl(let classDecl): - return DeclSyntax(rewrittenDecl( - classDecl, - modifiers: classDecl.modifiers, - factory: classDecl.withModifiers)) + return DeclSyntax(rewrittenDecl(classDecl)) case .structDecl(let structDecl): - return DeclSyntax(rewrittenDecl( - structDecl, - modifiers: structDecl.modifiers, - factory: structDecl.withModifiers)) + return DeclSyntax(rewrittenDecl(structDecl)) case .enumDecl(let enumDecl): - return DeclSyntax(rewrittenDecl( - enumDecl, - modifiers: enumDecl.modifiers, - factory: enumDecl.withModifiers)) + return DeclSyntax(rewrittenDecl(enumDecl)) case .protocolDecl(let protocolDecl): - return DeclSyntax(rewrittenDecl( - protocolDecl, - modifiers: protocolDecl.modifiers, - factory: protocolDecl.withModifiers)) + return DeclSyntax(rewrittenDecl(protocolDecl)) - case .typealiasDecl(let typealiasDecl): - return DeclSyntax(rewrittenDecl( - typealiasDecl, - modifiers: typealiasDecl.modifiers, - factory: typealiasDecl.withModifiers)) + case .typeAliasDecl(let typealiasDecl): + return DeclSyntax(rewrittenDecl(typealiasDecl)) default: return decl @@ -113,12 +95,17 @@ public final class FileScopedDeclarationPrivacy: SyntaxFormatRule { let newClauses = ifConfigDecl.clauses.map { clause -> IfConfigClauseSyntax in switch clause.elements { case .statements(let codeBlockItemList)?: - return clause.withElements(.statements(rewrittenCodeBlockItems(codeBlockItemList))) + var result = clause + result.elements = .statements(rewrittenCodeBlockItems(codeBlockItemList)) + return result default: return clause } } - return ifConfigDecl.withClauses(IfConfigClauseListSyntax(newClauses)) + + var result = ifConfigDecl + result.clauses = IfConfigClauseListSyntax(newClauses) + return result } /// Returns a rewritten version of the given declaration if its modifier list contains `private` @@ -133,46 +120,49 @@ public final class FileScopedDeclarationPrivacy: SyntaxFormatRule { /// - factory: A reference to the `decl`'s `withModifiers` instance method that is called to /// rewrite the node if needed. /// - Returns: A new node if the modifiers were rewritten, or the original node if not. - private func rewrittenDecl( - _ decl: DeclType, - modifiers: ModifierListSyntax?, - factory: (ModifierListSyntax?) -> DeclType + private func rewrittenDecl( + _ decl: DeclType ) -> DeclType { - let invalidAccess: TokenKind - let validAccess: TokenKind + let invalidAccess: Keyword + let validAccess: Keyword let diagnostic: Finding.Message switch context.configuration.fileScopedDeclarationPrivacy.accessLevel { case .private: - invalidAccess = .fileprivateKeyword - validAccess = .privateKeyword + invalidAccess = .fileprivate + validAccess = .private diagnostic = .replaceFileprivateWithPrivate case .fileprivate: - invalidAccess = .privateKeyword - validAccess = .fileprivateKeyword + invalidAccess = .private + validAccess = .fileprivate diagnostic = .replacePrivateWithFileprivate } - guard let modifiers = modifiers, modifiers.has(modifier: invalidAccess) else { + guard decl.modifiers.contains(anyOf: [invalidAccess]) else { return decl } - let newModifiers = modifiers.map { modifier -> DeclModifierSyntax in + let newModifiers = decl.modifiers.map { modifier -> DeclModifierSyntax in + var modifier = modifier + let name = modifier.name - if name.tokenKind == invalidAccess { + if case .keyword(invalidAccess) = name.tokenKind { diagnose(diagnostic, on: name) - return modifier.withName(name.withKind(validAccess)) + modifier.name.tokenKind = .keyword(validAccess) } return modifier } - return factory(ModifierListSyntax(newModifiers)) + + var result = decl + result.modifiers = DeclModifierListSyntax(newModifiers) + return result } } extension Finding.Message { - public static let replacePrivateWithFileprivate: Finding.Message = + fileprivate static let replacePrivateWithFileprivate: Finding.Message = "replace 'private' with 'fileprivate' on file-scoped declarations" - public static let replaceFileprivateWithPrivate: Finding.Message = + fileprivate static let replaceFileprivateWithPrivate: Finding.Message = "replace 'fileprivate' with 'private' on file-scoped declarations" } diff --git a/Sources/SwiftFormat/Rules/FullyIndirectEnum.swift b/Sources/SwiftFormat/Rules/FullyIndirectEnum.swift new file mode 100644 index 000000000..53273f676 --- /dev/null +++ b/Sources/SwiftFormat/Rules/FullyIndirectEnum.swift @@ -0,0 +1,143 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2019 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import SwiftSyntax + +/// If all cases of an enum are `indirect`, the entire enum should be marked `indirect`. +/// +/// Lint: If every case of an enum is `indirect`, but the enum itself is not, a lint error is +/// raised. +/// +/// Format: Enums where all cases are `indirect` will be rewritten such that the enum is marked +/// `indirect`, and each case is not. +@_spi(Rules) +public final class FullyIndirectEnum: SyntaxFormatRule { + + public override func visit(_ node: EnumDeclSyntax) -> DeclSyntax { + let enumMembers = node.memberBlock.members + guard !node.modifiers.contains(anyOf: [.indirect]), + case let indirectModifiers = indirectModifiersIfAllCasesIndirect(in: enumMembers), + !indirectModifiers.isEmpty + else { + return DeclSyntax(node) + } + + let notes = indirectModifiers.map { modifier in + Finding.Note( + message: .removeIndirect, + location: Finding.Location( + modifier.startLocation(converter: self.context.sourceLocationConverter) + ) + ) + } + diagnose( + .moveIndirectKeywordToEnumDecl(name: node.name.text), + on: node.enumKeyword, + notes: notes + ) + + // Removes 'indirect' keyword from cases, reformats + let newMembers = enumMembers.map { + (member: MemberBlockItemSyntax) -> MemberBlockItemSyntax in + guard let caseMember = member.decl.as(EnumCaseDeclSyntax.self), + caseMember.modifiers.contains(anyOf: [.indirect]), + let firstModifier = caseMember.modifiers.first + else { + return member + } + + var newCase = caseMember + newCase.modifiers.remove(anyOf: [.indirect]) + + var newMember = member + newMember.decl = DeclSyntax(rearrangeLeadingTrivia(firstModifier.leadingTrivia, on: newCase)) + return newMember + } + + // If the `indirect` keyword being added would be the first token in the decl, we need to move + // the leading trivia from the `enum` keyword to the new modifier to preserve the existing + // line breaks/comments/indentation. + let firstTok = node.firstToken(viewMode: .sourceAccurate)! + let leadingTrivia: Trivia + var newEnumDecl = node + + if firstTok.tokenKind == .keyword(.enum) { + leadingTrivia = firstTok.leadingTrivia + newEnumDecl.leadingTrivia = [] + } else { + leadingTrivia = [] + } + + let newModifier = DeclModifierSyntax( + name: TokenSyntax.identifier( + "indirect", + leadingTrivia: leadingTrivia, + trailingTrivia: .spaces(1) + ), + detail: nil + ) + + newEnumDecl.modifiers = newEnumDecl.modifiers + [newModifier] + newEnumDecl.memberBlock.members = MemberBlockItemListSyntax(newMembers) + return DeclSyntax(newEnumDecl) + } + + /// Returns a value indicating whether all enum cases in the given list are indirect. + /// + /// Note that if the enum has no cases, this returns false. + private func indirectModifiersIfAllCasesIndirect( + in members: MemberBlockItemListSyntax + ) -> [DeclModifierSyntax] { + var indirectModifiers = [DeclModifierSyntax]() + for member in members { + if let caseMember = member.decl.as(EnumCaseDeclSyntax.self) { + guard + let indirectModifier = caseMember.modifiers.first( + where: { $0.name.text == "indirect" } + ) + else { + return [] + } + indirectModifiers.append(indirectModifier) + } + } + return indirectModifiers + } + + /// Transfers given leading trivia to the first token in the case declaration. + private func rearrangeLeadingTrivia( + _ leadingTrivia: Trivia, + on enumCaseDecl: EnumCaseDeclSyntax + ) -> EnumCaseDeclSyntax { + var formattedCase = enumCaseDecl + + if var firstModifier = formattedCase.modifiers.first { + // If the case has modifiers, attach the leading trivia to the first one. + firstModifier.leadingTrivia = leadingTrivia + formattedCase.modifiers[formattedCase.modifiers.startIndex] = firstModifier + formattedCase.modifiers = formattedCase.modifiers + } else { + // Otherwise, attach the trivia to the `case` keyword itself. + formattedCase.caseKeyword.leadingTrivia = leadingTrivia + } + + return formattedCase + } +} + +extension Finding.Message { + fileprivate static func moveIndirectKeywordToEnumDecl(name: String) -> Finding.Message { + "declare enum '\(name)' itself as indirect when all cases are indirect" + } + + fileprivate static let removeIndirect: Finding.Message = "remove 'indirect' here" +} diff --git a/Sources/SwiftFormatRules/GroupNumericLiterals.swift b/Sources/SwiftFormat/Rules/GroupNumericLiterals.swift similarity index 81% rename from Sources/SwiftFormatRules/GroupNumericLiterals.swift rename to Sources/SwiftFormat/Rules/GroupNumericLiterals.swift index 2ca47b091..b29089efb 100644 --- a/Sources/SwiftFormatRules/GroupNumericLiterals.swift +++ b/Sources/SwiftFormat/Rules/GroupNumericLiterals.swift @@ -10,7 +10,6 @@ // //===----------------------------------------------------------------------===// -import SwiftFormatCore import SwiftSyntax /// Numeric literals should be grouped with `_`s to delimit common separators. @@ -25,9 +24,10 @@ import SwiftSyntax /// TODO: Minimum numeric literal length bounds and numeric groupings have been selected arbitrarily; /// these could be reevaluated. /// TODO: Handle floating point literals. +@_spi(Rules) public final class GroupNumericLiterals: SyntaxFormatRule { public override func visit(_ node: IntegerLiteralExprSyntax) -> ExprSyntax { - var originalDigits = node.digits.text + var originalDigits = node.literal.text guard !originalDigits.contains("_") else { return ExprSyntax(node) } let isNegative = originalDigits.first == "-" @@ -40,13 +40,13 @@ public final class GroupNumericLiterals: SyntaxFormatRule { // Hexadecimal let digitsNoPrefix = String(originalDigits.dropFirst(2)) guard digitsNoPrefix.count >= 8 else { return ExprSyntax(node) } - diagnose(.groupNumericLiteral(every: 4), on: node) + diagnose(.groupNumericLiteral(every: 4, base: "hexadecimal"), on: node) newDigits = "0x" + digits(digitsNoPrefix, groupedEvery: 4) case "0b": // Binary let digitsNoPrefix = String(originalDigits.dropFirst(2)) guard digitsNoPrefix.count >= 10 else { return ExprSyntax(node) } - diagnose(.groupNumericLiteral(every: 8), on: node) + diagnose(.groupNumericLiteral(every: 8, base: "binary"), on: node) newDigits = "0b" + digits(digitsNoPrefix, groupedEvery: 8) case "0o": // Octal @@ -54,16 +54,13 @@ public final class GroupNumericLiterals: SyntaxFormatRule { default: // Decimal guard originalDigits.count >= 7 else { return ExprSyntax(node) } - diagnose(.groupNumericLiteral(every: 3), on: node) + diagnose(.groupNumericLiteral(every: 3, base: "decimal"), on: node) newDigits = digits(originalDigits, groupedEvery: 3) } newDigits = isNegative ? "-" + newDigits : newDigits - let result = node.withDigits( - TokenSyntax.integerLiteral( - newDigits, - leadingTrivia: node.digits.leadingTrivia, - trailingTrivia: node.digits.trailingTrivia)) + var result = node + result.literal.tokenKind = .integerLiteral(newDigits) return ExprSyntax(result) } @@ -83,8 +80,7 @@ public final class GroupNumericLiterals: SyntaxFormatRule { } extension Finding.Message { - public static func groupNumericLiteral(every stride: Int) -> Finding.Message { - let ending = stride == 3 ? "rd" : "th" - return "group numeric literal using '_' every \(stride)\(ending) number" + fileprivate static func groupNumericLiteral(every stride: Int, base: String) -> Finding.Message { + return "group every \(stride) digits in this \(base) literal using a '_' separator" } } diff --git a/Sources/SwiftFormatRules/IdentifiersMustBeASCII.swift b/Sources/SwiftFormat/Rules/IdentifiersMustBeASCII.swift similarity index 90% rename from Sources/SwiftFormatRules/IdentifiersMustBeASCII.swift rename to Sources/SwiftFormat/Rules/IdentifiersMustBeASCII.swift index b42376e54..913dc2a06 100644 --- a/Sources/SwiftFormatRules/IdentifiersMustBeASCII.swift +++ b/Sources/SwiftFormat/Rules/IdentifiersMustBeASCII.swift @@ -10,12 +10,12 @@ // //===----------------------------------------------------------------------===// -import SwiftFormatCore import SwiftSyntax /// All identifiers must be ASCII. /// /// Lint: If an identifier contains non-ASCII characters, a lint error is raised. +@_spi(Rules) public final class IdentifiersMustBeASCII: SyntaxLintRule { public override func visit(_ node: IdentifierPatternSyntax) -> SyntaxVisitorContinueKind { @@ -31,8 +31,9 @@ public final class IdentifiersMustBeASCII: SyntaxLintRule { } extension Finding.Message { - public static func nonASCIICharsNotAllowed( - _ invalidCharacters: [String], _ identifierName: String + fileprivate static func nonASCIICharsNotAllowed( + _ invalidCharacters: [String], + _ identifierName: String ) -> Finding.Message { """ remove non-ASCII characters from '\(identifierName)': \ diff --git a/Sources/SwiftFormatRules/NeverForceUnwrap.swift b/Sources/SwiftFormat/Rules/NeverForceUnwrap.swift similarity index 71% rename from Sources/SwiftFormatRules/NeverForceUnwrap.swift rename to Sources/SwiftFormat/Rules/NeverForceUnwrap.swift index 7d23fd27e..f81c9953d 100644 --- a/Sources/SwiftFormatRules/NeverForceUnwrap.swift +++ b/Sources/SwiftFormat/Rules/NeverForceUnwrap.swift @@ -10,12 +10,16 @@ // //===----------------------------------------------------------------------===// -import SwiftFormatCore import SwiftSyntax /// Force-unwraps are strongly discouraged and must be documented. /// +/// This rule does not apply to test code, defined as code which: +/// * Contains the line `import XCTest` +/// * The function is marked with `@Test` attribute +/// /// Lint: If a force unwrap is used, a lint warning is raised. +@_spi(Rules) public final class NeverForceUnwrap: SyntaxLintRule { /// Identifies this rule as being opt-in. While force unwrap is an unsafe pattern (i.e. it can @@ -29,9 +33,11 @@ public final class NeverForceUnwrap: SyntaxLintRule { return .visitChildren } - public override func visit(_ node: ForcedValueExprSyntax) -> SyntaxVisitorContinueKind { + public override func visit(_ node: ForceUnwrapExprSyntax) -> SyntaxVisitorContinueKind { guard context.importsXCTest == .doesNotImportXCTest else { return .skipChildren } - diagnose(.doNotForceUnwrap(name: node.expression.withoutTrivia().description), on: node) + // Allow force unwrapping if it is in a function marked with @Test attribute. + if node.hasTestAncestor { return .skipChildren } + diagnose(.doNotForceUnwrap(name: node.expression.trimmedDescription), on: node) return .skipChildren } @@ -41,17 +47,19 @@ public final class NeverForceUnwrap: SyntaxLintRule { guard context.importsXCTest == .doesNotImportXCTest else { return .skipChildren } guard let questionOrExclamation = node.questionOrExclamationMark else { return .skipChildren } guard questionOrExclamation.tokenKind == .exclamationMark else { return .skipChildren } - diagnose(.doNotForceCast(name: node.typeName.withoutTrivia().description), on: node) + // Allow force cast if it is in a function marked with @Test attribute. + if node.hasTestAncestor { return .skipChildren } + diagnose(.doNotForceCast(name: node.type.trimmedDescription), on: node) return .skipChildren } } extension Finding.Message { - public static func doNotForceUnwrap(name: String) -> Finding.Message { + fileprivate static func doNotForceUnwrap(name: String) -> Finding.Message { "do not force unwrap '\(name)'" } - public static func doNotForceCast(name: String) -> Finding.Message { + fileprivate static func doNotForceCast(name: String) -> Finding.Message { "do not force cast to '\(name)'" } } diff --git a/Sources/SwiftFormatRules/NeverUseForceTry.swift b/Sources/SwiftFormat/Rules/NeverUseForceTry.swift similarity index 86% rename from Sources/SwiftFormatRules/NeverUseForceTry.swift rename to Sources/SwiftFormat/Rules/NeverUseForceTry.swift index 253c64f69..2eada1a78 100644 --- a/Sources/SwiftFormatRules/NeverUseForceTry.swift +++ b/Sources/SwiftFormat/Rules/NeverUseForceTry.swift @@ -10,17 +10,18 @@ // //===----------------------------------------------------------------------===// -import SwiftFormatCore import SwiftSyntax /// Force-try (`try!`) is forbidden. /// /// This rule does not apply to test code, defined as code which: /// * Contains the line `import XCTest` +/// * The function is marked with `@Test` attribute /// /// Lint: Using `try!` results in a lint error. /// /// TODO: Create exception for NSRegularExpression +@_spi(Rules) public final class NeverUseForceTry: SyntaxLintRule { /// Identifies this rule as being opt-in. While force try is an unsafe pattern (i.e. it can @@ -36,6 +37,8 @@ public final class NeverUseForceTry: SyntaxLintRule { public override func visit(_ node: TryExprSyntax) -> SyntaxVisitorContinueKind { guard context.importsXCTest == .doesNotImportXCTest else { return .skipChildren } guard let mark = node.questionOrExclamationMark else { return .visitChildren } + // Allow force try if it is in a function marked with @Test attribute. + if node.hasTestAncestor { return .skipChildren } if mark.tokenKind == .exclamationMark { diagnose(.doNotForceTry, on: node.tryKeyword) } @@ -44,5 +47,5 @@ public final class NeverUseForceTry: SyntaxLintRule { } extension Finding.Message { - public static let doNotForceTry: Finding.Message = "do not use force try" + fileprivate static let doNotForceTry: Finding.Message = "do not use force try" } diff --git a/Sources/SwiftFormatRules/NeverUseImplicitlyUnwrappedOptionals.swift b/Sources/SwiftFormat/Rules/NeverUseImplicitlyUnwrappedOptionals.swift similarity index 77% rename from Sources/SwiftFormatRules/NeverUseImplicitlyUnwrappedOptionals.swift rename to Sources/SwiftFormat/Rules/NeverUseImplicitlyUnwrappedOptionals.swift index dc5d82210..009969ddc 100644 --- a/Sources/SwiftFormatRules/NeverUseImplicitlyUnwrappedOptionals.swift +++ b/Sources/SwiftFormat/Rules/NeverUseImplicitlyUnwrappedOptionals.swift @@ -10,7 +10,6 @@ // //===----------------------------------------------------------------------===// -import SwiftFormatCore import SwiftSyntax /// Implicitly unwrapped optionals (e.g. `var s: String!`) are forbidden. @@ -19,10 +18,12 @@ import SwiftSyntax /// /// This rule does not apply to test code, defined as code which: /// * Contains the line `import XCTest` +/// * The function is marked with `@Test` attribute /// /// TODO: Create exceptions for other UI elements (ex: viewDidLoad) /// /// Lint: Declaring a property with an implicitly unwrapped type yields a lint error. +@_spi(Rules) public final class NeverUseImplicitlyUnwrappedOptionals: SyntaxLintRule { /// Identifies this rule as being opt-in. While accessing implicitly unwrapped optionals is an @@ -39,12 +40,12 @@ public final class NeverUseImplicitlyUnwrappedOptionals: SyntaxLintRule { public override func visit(_ node: VariableDeclSyntax) -> SyntaxVisitorContinueKind { guard context.importsXCTest == .doesNotImportXCTest else { return .skipChildren } + // Allow implicitly unwrapping if it is in a function marked with @Test attribute. + if node.hasTestAncestor { return .skipChildren } // Ignores IBOutlet variables - if let attributes = node.attributes { - for attribute in attributes { - if (attribute.as(AttributeSyntax.self))?.attributeName.text == "IBOutlet" { - return .skipChildren - } + for attribute in node.attributes { + if (attribute.as(AttributeSyntax.self))?.attributeName.as(IdentifierTypeSyntax.self)?.name.text == "IBOutlet" { + return .skipChildren } } // Finds type annotation for variable(s) @@ -58,13 +59,14 @@ public final class NeverUseImplicitlyUnwrappedOptionals: SyntaxLintRule { private func diagnoseImplicitWrapViolation(_ type: TypeSyntax) { guard let violation = type.as(ImplicitlyUnwrappedOptionalTypeSyntax.self) else { return } diagnose( - .doNotUseImplicitUnwrapping( - identifier: violation.wrappedType.withoutTrivia().description), on: type) + .doNotUseImplicitUnwrapping(identifier: violation.wrappedType.trimmedDescription), + on: type + ) } } extension Finding.Message { - public static func doNotUseImplicitUnwrapping(identifier: String) -> Finding.Message { - "use \(identifier) or \(identifier)? instead of \(identifier)!" + fileprivate static func doNotUseImplicitUnwrapping(identifier: String) -> Finding.Message { + "use '\(identifier)' or '\(identifier)?' instead of '\(identifier)!'" } } diff --git a/Sources/SwiftFormat/Rules/NoAccessLevelOnExtensionDeclaration.swift b/Sources/SwiftFormat/Rules/NoAccessLevelOnExtensionDeclaration.swift new file mode 100644 index 000000000..ea71fc510 --- /dev/null +++ b/Sources/SwiftFormat/Rules/NoAccessLevelOnExtensionDeclaration.swift @@ -0,0 +1,202 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2019 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import SwiftSyntax + +/// Specifying an access level for an extension declaration is forbidden. +/// +/// Lint: Specifying an access level for an extension declaration yields a lint error. +/// +/// Format: The access level is removed from the extension declaration and is added to each +/// declaration in the extension; declarations with redundant access levels (e.g. +/// `internal`, as that is the default access level) have the explicit access level removed. +@_spi(Rules) +public final class NoAccessLevelOnExtensionDeclaration: SyntaxFormatRule { + public override func visit(_ node: ExtensionDeclSyntax) -> DeclSyntax { + guard + let accessKeyword = node.modifiers.accessLevelModifier, + case .keyword(let keyword) = accessKeyword.name.tokenKind + else { + return DeclSyntax(node) + } + + var result = node + + switch keyword { + // Public, private, fileprivate, or package keywords need to be moved to members + case .public, .private, .fileprivate, .package: + // The effective access level of the members of a `private` extension is `fileprivate`, so + // we have to update the keyword to ensure that the result is correct. + var accessKeywordToAdd = accessKeyword + let message: Finding.Message + if keyword == .private { + accessKeywordToAdd.name.tokenKind = .keyword(.fileprivate) + message = .moveAccessKeywordAndMakeFileprivate(keyword: accessKeyword.name.text) + } else { + message = .moveAccessKeyword(keyword: accessKeyword.name.text) + } + + let (newMembers, notes) = + addMemberAccessKeyword(accessKeywordToAdd, toMembersIn: node.memberBlock) + diagnose(message, on: accessKeyword, notes: notes) + + result.modifiers.remove(anyOf: [keyword]) + result.extensionKeyword.leadingTrivia = accessKeyword.leadingTrivia + result.memberBlock.members = newMembers + return DeclSyntax(result) + + // Internal keyword redundant, delete + case .internal: + diagnose(.removeRedundantAccessKeyword, on: accessKeyword) + + result.modifiers.remove(anyOf: [keyword]) + result.extensionKeyword.leadingTrivia = accessKeyword.leadingTrivia + return DeclSyntax(result) + + default: + break + } + + return DeclSyntax(result) + } + + // Adds given keyword to all members in declaration block + private func addMemberAccessKeyword( + _ modifier: DeclModifierSyntax, + toMembersIn memberBlock: MemberBlockSyntax + ) -> (MemberBlockItemListSyntax, [Finding.Note]) { + var newMembers: [MemberBlockItemSyntax] = [] + var notes: [Finding.Note] = [] + + for memberItem in memberBlock.members { + let decl = memberItem.decl + guard + let modifiers = decl.asProtocol(WithModifiersSyntax.self)?.modifiers, + modifiers.accessLevelModifier == nil + else { + newMembers.append(memberItem) + continue + } + + // Create a note associated with each declaration that needs to have an access level modifier + // added to it. + notes.append( + Finding.Note( + message: .addModifierToExtensionMember(keyword: modifier.name.text), + location: + Finding.Location(decl.startLocation(converter: context.sourceLocationConverter)) + ) + ) + + var newItem = memberItem + newItem.decl = applyingAccessModifierIfNone(modifier, to: decl) + newMembers.append(newItem) + } + + return (MemberBlockItemListSyntax(newMembers), notes) + } +} + +extension Finding.Message { + fileprivate static let removeRedundantAccessKeyword: Finding.Message = + "remove this redundant 'internal' access modifier from this extension" + + fileprivate static func moveAccessKeyword(keyword: String) -> Finding.Message { + "move this '\(keyword)' access modifier to precede each member inside this extension" + } + + fileprivate static func moveAccessKeywordAndMakeFileprivate(keyword: String) -> Finding.Message { + "remove this '\(keyword)' access modifier and declare each member inside this extension as 'fileprivate'" + } + + fileprivate static func addModifierToExtensionMember(keyword: String) -> Finding.Message { + "add '\(keyword)' access modifier to this declaration" + } +} + +/// Adds `modifier` to `decl` if it doesn't already have an explicit access level modifier and +/// returns the new declaration. +/// +/// If `decl` already has an access level modifier, it is returned unchanged. +private func applyingAccessModifierIfNone( + _ modifier: DeclModifierSyntax, + to decl: DeclSyntax +) -> DeclSyntax { + switch Syntax(decl).as(SyntaxEnum.self) { + case .actorDecl(let actorDecl): + return applyingAccessModifierIfNone(modifier, to: actorDecl, declKeywordKeyPath: \.actorKeyword) + case .classDecl(let classDecl): + return applyingAccessModifierIfNone(modifier, to: classDecl, declKeywordKeyPath: \.classKeyword) + case .enumDecl(let enumDecl): + return applyingAccessModifierIfNone(modifier, to: enumDecl, declKeywordKeyPath: \.enumKeyword) + case .initializerDecl(let initDecl): + return applyingAccessModifierIfNone(modifier, to: initDecl, declKeywordKeyPath: \.initKeyword) + case .functionDecl(let funcDecl): + return applyingAccessModifierIfNone(modifier, to: funcDecl, declKeywordKeyPath: \.funcKeyword) + case .structDecl(let structDecl): + return applyingAccessModifierIfNone( + modifier, + to: structDecl, + declKeywordKeyPath: \.structKeyword + ) + case .subscriptDecl(let subscriptDecl): + return applyingAccessModifierIfNone( + modifier, + to: subscriptDecl, + declKeywordKeyPath: \.subscriptKeyword + ) + case .typeAliasDecl(let typeAliasDecl): + return applyingAccessModifierIfNone( + modifier, + to: typeAliasDecl, + declKeywordKeyPath: \.typealiasKeyword + ) + case .variableDecl(let varDecl): + return applyingAccessModifierIfNone( + modifier, + to: varDecl, + declKeywordKeyPath: \.bindingSpecifier + ) + default: + return decl + } +} + +private func applyingAccessModifierIfNone( + _ modifier: DeclModifierSyntax, + to decl: Decl, + declKeywordKeyPath: WritableKeyPath +) -> DeclSyntax { + // If there's already an access modifier among the modifier list, bail out. + guard decl.modifiers.accessLevelModifier == nil else { return DeclSyntax(decl) } + + var result = decl + var modifier = modifier + modifier.trailingTrivia = [.spaces(1)] + + guard var firstModifier = decl.modifiers.first else { + // If there are no modifiers at all, add the one being requested, moving the leading trivia + // from the decl keyword to that modifier (to preserve leading comments, newlines, etc.). + modifier.leadingTrivia = decl[keyPath: declKeywordKeyPath].leadingTrivia + result[keyPath: declKeywordKeyPath].leadingTrivia = [] + result.modifiers = .init([modifier]) + return DeclSyntax(result) + } + + // Otherwise, insert the modifier at the front of the modifier list, moving the (original) first + // modifier's leading trivia to the new one (to preserve leading comments, newlines, etc.). + modifier.leadingTrivia = firstModifier.leadingTrivia + firstModifier.leadingTrivia = [] + result.modifiers[result.modifiers.startIndex] = firstModifier + result.modifiers.insert(modifier, at: result.modifiers.startIndex) + return DeclSyntax(result) +} diff --git a/Sources/SwiftFormat/Rules/NoAssignmentInExpressions.swift b/Sources/SwiftFormat/Rules/NoAssignmentInExpressions.swift new file mode 100644 index 000000000..62db9130d --- /dev/null +++ b/Sources/SwiftFormat/Rules/NoAssignmentInExpressions.swift @@ -0,0 +1,165 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2019 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import SwiftSyntax + +/// Assignment expressions must be their own statements. +/// +/// Assignment should not be used in an expression context that expects a `Void` value. For example, +/// assigning a variable within a `return` statement existing a `Void` function is prohibited. +/// +/// Lint: If an assignment expression is found in a position other than a standalone statement, a +/// lint finding is emitted. +/// +/// Format: A `return` statement containing an assignment expression is expanded into two separate +/// statements. +@_spi(Rules) +public final class NoAssignmentInExpressions: SyntaxFormatRule { + public override func visit(_ node: InfixOperatorExprSyntax) -> ExprSyntax { + // Diagnose any assignment that isn't directly a child of a `CodeBlockItem` (which would be the + // case if it was its own statement). + if isAssignmentExpression(node) + && !isStandaloneAssignmentStatement(node) + && !isInAllowedFunction(node) + { + diagnose(.moveAssignmentToOwnStatement, on: node) + } + return ExprSyntax(node) + } + + public override func visit(_ node: CodeBlockItemListSyntax) -> CodeBlockItemListSyntax { + var newItems = [CodeBlockItemSyntax]() + newItems.reserveCapacity(node.count) + + for item in node { + // Make sure to visit recursively so that any nested decls get processed first. + let visitedItem = visit(item) + + // Rewrite any `return ` expressions as `return`. + switch visitedItem.item { + case .stmt(let stmt): + guard + var returnStmt = stmt.as(ReturnStmtSyntax.self), + let assignmentExpr = assignmentExpression(from: returnStmt) + else { + // Head to the default case where we just keep the original item. + fallthrough + } + + // Move the leading trivia from the `return` statement to the new assignment statement, + // since that's a more sensible place than between the two. + var assignmentItem = CodeBlockItemSyntax(item: .expr(ExprSyntax(assignmentExpr))) + assignmentItem.leadingTrivia = + returnStmt.leadingTrivia + + returnStmt.returnKeyword.trailingTrivia.withoutLeadingSpaces() + + assignmentExpr.leadingTrivia + assignmentItem.trailingTrivia = [] + + let trailingTrivia = returnStmt.trailingTrivia + returnStmt.expression = nil + returnStmt.returnKeyword.trailingTrivia = [] + var returnItem = CodeBlockItemSyntax(item: .stmt(StmtSyntax(returnStmt))) + returnItem.leadingTrivia = [.newlines(1)] + returnItem.trailingTrivia = trailingTrivia + + newItems.append(assignmentItem) + newItems.append(returnItem) + + default: + newItems.append(visitedItem) + } + } + + return CodeBlockItemListSyntax(newItems) + } + + /// Extracts and returns the assignment expression in the given `return` statement, if there was + /// one. + /// + /// If the `return` statement did not have an expression or if its expression was not an + /// assignment expression, nil is returned. + private func assignmentExpression(from returnStmt: ReturnStmtSyntax) -> InfixOperatorExprSyntax? { + guard + let returnExpr = returnStmt.expression, + let infixOperatorExpr = returnExpr.as(InfixOperatorExprSyntax.self) + else { + return nil + } + return isAssignmentExpression(infixOperatorExpr) ? infixOperatorExpr : nil + } + + /// Returns a value indicating whether the given infix operator expression is an assignment + /// expression (either simple assignment with `=` or compound assignment with an operator like + /// `+=`). + private func isAssignmentExpression(_ expr: InfixOperatorExprSyntax) -> Bool { + if expr.operator.is(AssignmentExprSyntax.self) { + return true + } + guard let binaryOp = expr.operator.as(BinaryOperatorExprSyntax.self) else { + return false + } + return context.operatorTable.infixOperator(named: binaryOp.operator.text)?.precedenceGroup + == "AssignmentPrecedence" + } + + /// Returns a value indicating whether the given node is a standalone assignment statement. + /// + /// This function considers try/await expressions and automatically walks up through them as + /// needed. This is because `try f().x = y` should still be a standalone assignment for our + /// purposes, even though a `TryExpr` will wrap the `InfixOperatorExpr` and thus would not be + /// considered a standalone assignment if we only checked the infix expression for a + /// `CodeBlockItem` parent. + private func isStandaloneAssignmentStatement(_ node: InfixOperatorExprSyntax) -> Bool { + var node = Syntax(node) + while let parent = node.parent, + parent.is(TryExprSyntax.self) || parent.is(AwaitExprSyntax.self) + { + node = parent + } + + guard let parent = node.parent else { + // This shouldn't happen under normal circumstances (i.e., unless the expression is detached + // from the rest of a tree). In that case, we may as well consider it to be "standalone". + return true + } + return parent.is(CodeBlockItemSyntax.self) + } + + /// Returns true if the infix operator expression is in the (non-closure) parameters of an allowed + /// function call. + private func isInAllowedFunction(_ node: InfixOperatorExprSyntax) -> Bool { + let allowedFunctions = context.configuration.noAssignmentInExpressions.allowedFunctions + // Walk up the tree until we find a FunctionCallExprSyntax, and if the name matches, return + // true. However, stop early if we hit a CodeBlockItemSyntax first; this would represent a + // closure context where we *don't* want the exception to apply (for example, in + // `someAllowedFunction(a, b) { return c = d }`, the `c = d` is a descendent of a function call + // but we want it to be evaluated in its own context. + var node = Syntax(node) + while let parent = node.parent { + node = parent + if node.is(CodeBlockItemSyntax.self) { + break + } + if let functionCallExpr = node.as(FunctionCallExprSyntax.self), + allowedFunctions.contains(functionCallExpr.calledExpression.trimmedDescription) + { + return true + } + } + return false + } +} + +extension Finding.Message { + fileprivate static let moveAssignmentToOwnStatement: Finding.Message = + "move this assignment expression into its own statement" +} diff --git a/Sources/SwiftFormatRules/NoBlockComments.swift b/Sources/SwiftFormat/Rules/NoBlockComments.swift similarity index 69% rename from Sources/SwiftFormatRules/NoBlockComments.swift rename to Sources/SwiftFormat/Rules/NoBlockComments.swift index 3b9f79310..1f5f0647c 100644 --- a/Sources/SwiftFormatRules/NoBlockComments.swift +++ b/Sources/SwiftFormat/Rules/NoBlockComments.swift @@ -10,18 +10,24 @@ // //===----------------------------------------------------------------------===// -import SwiftFormatCore import SwiftSyntax /// Block comments should be avoided in favor of line comments. /// /// Lint: If a block comment appears, a lint error is raised. +@_spi(Rules) public final class NoBlockComments: SyntaxLintRule { public override func visit(_ token: TokenSyntax) -> SyntaxVisitorContinueKind { for triviaIndex in token.leadingTrivia.indices { let piece = token.leadingTrivia[triviaIndex] if case .blockComment = piece { - diagnose(.avoidBlockComment, on: token, leadingTriviaIndex: triviaIndex) + diagnose(.avoidBlockComment, on: token, anchor: .leadingTrivia(triviaIndex)) + } + } + for triviaIndex in token.trailingTrivia.indices { + let piece = token.trailingTrivia[triviaIndex] + if case .blockComment = piece { + diagnose(.avoidBlockComment, on: token, anchor: .trailingTrivia(triviaIndex)) } } return .skipChildren @@ -29,6 +35,6 @@ public final class NoBlockComments: SyntaxLintRule { } extension Finding.Message { - public static let avoidBlockComment: Finding.Message = - "replace block comment with line comments" + fileprivate static let avoidBlockComment: Finding.Message = + "replace this block comment with line comments" } diff --git a/Sources/SwiftFormatRules/NoCasesWithOnlyFallthrough.swift b/Sources/SwiftFormat/Rules/NoCasesWithOnlyFallthrough.swift similarity index 83% rename from Sources/SwiftFormatRules/NoCasesWithOnlyFallthrough.swift rename to Sources/SwiftFormat/Rules/NoCasesWithOnlyFallthrough.swift index 1ff77089f..79bebd135 100644 --- a/Sources/SwiftFormatRules/NoCasesWithOnlyFallthrough.swift +++ b/Sources/SwiftFormat/Rules/NoCasesWithOnlyFallthrough.swift @@ -10,7 +10,6 @@ // //===----------------------------------------------------------------------===// -import SwiftFormatCore import SwiftSyntax /// Cases that contain only the `fallthrough` statement are forbidden. @@ -19,6 +18,7 @@ import SwiftSyntax /// /// Format: The fallthrough `case` is added as a prefix to the next case unless the next case is /// `default`; in that case, the fallthrough `case` is deleted. +@_spi(Rules) public final class NoCasesWithOnlyFallthrough: SyntaxFormatRule { public override func visit(_ node: SwitchCaseListSyntax) -> SwitchCaseListSyntax { @@ -28,7 +28,7 @@ public final class NoCasesWithOnlyFallthrough: SyntaxFormatRule { /// Flushes any un-collapsed violations to the new cases list. func flushViolations() { fallthroughOnlyCases.forEach { - newChildren.append(.switchCase(super.visit($0))) + newChildren.append(.switchCase(visit($0))) } fallthroughOnlyCases.removeAll() } @@ -57,7 +57,8 @@ public final class NoCasesWithOnlyFallthrough: SyntaxFormatRule { if canMergeWithPreviousCases(switchCase) { // If the current case can be merged with the ones before it, merge them all, leaving no // `fallthrough`-only cases behind. - newChildren.append(.switchCase(visit(mergedCases(fallthroughOnlyCases + [switchCase])))) + let newSwitchCase = visit(switchCase) + newChildren.append(.switchCase(visit(mergedCases(fallthroughOnlyCases + [newSwitchCase])))) } else { // If the current case can't be merged with the ones before it, merge the previous ones // into a single `fallthrough`-only case and then append the current one. This could @@ -126,7 +127,7 @@ public final class NoCasesWithOnlyFallthrough: SyntaxFormatRule { // When there are any additional or non-fallthrough statements, it isn't only a fallthrough. guard let onlyStatement = switchCase.statements.firstAndOnly, - onlyStatement.item.is(FallthroughStmtSyntax.self) + onlyStatement.item.is(FallThroughStmtSyntax.self) else { return false } @@ -139,21 +140,20 @@ public final class NoCasesWithOnlyFallthrough: SyntaxFormatRule { } // Check for any comments that are adjacent to the case or fallthrough statement. - if let leadingTrivia = switchCase.leadingTrivia, - leadingTrivia.drop(while: { !$0.isNewline }).contains(where: { $0.isComment }) + if switchCase.allPrecedingTrivia + .drop(while: { !$0.isNewline }).contains(where: { $0.isComment }) { return false } - if let leadingTrivia = onlyStatement.leadingTrivia, - leadingTrivia.drop(while: { !$0.isNewline }).contains(where: { $0.isComment }) + if onlyStatement.allPrecedingTrivia + .drop(while: { !$0.isNewline }).contains(where: { $0.isComment }) { return false } - // Check for any comments that are inline on the fallthrough statement. Inline comments are - // always stored in the next token's leading trivia. - if let nextLeadingTrivia = onlyStatement.nextToken?.leadingTrivia, - nextLeadingTrivia.prefix(while: { !$0.isNewline }).contains(where: { $0.isComment }) + // Check for any comments that are inline on the fallthrough statement. + if onlyStatement.allFollowingTrivia + .prefix(while: { !$0.isNewline }).contains(where: { $0.isComment }) { return false } @@ -170,36 +170,35 @@ public final class NoCasesWithOnlyFallthrough: SyntaxFormatRule { return cases.first! } - var newCaseItems: [CaseItemSyntax] = [] + var newCaseItems: [SwitchCaseItemSyntax] = [] let labels = cases.lazy.compactMap({ $0.label.as(SwitchCaseLabelSyntax.self) }) for label in labels.dropLast() { + // Diagnose the cases being collapsed. We do this for all but the last one in the array; the + // last one isn't diagnosed because it will contain the body that applies to all the previous + // cases. + diagnose(.collapseCase, on: label) + // We can blindly append all but the last case item because they must already have a trailing // comma. Then, we need to add a trailing comma to the last one, since it will be followed by // more items. newCaseItems.append(contentsOf: label.caseItems.dropLast()) - newCaseItems.append( - label.caseItems.last!.withTrailingComma( - TokenSyntax.commaToken(trailingTrivia: .spaces(1)))) - // Diagnose the cases being collapsed. We do this for all but the last one in the array; the - // last one isn't diagnosed because it will contain the body that applies to all the previous - // cases. - diagnose(.collapseCase(name: label.caseItems.withoutTrivia().description), on: label) + var lastItem = label.caseItems.last! + lastItem.trailingComma = TokenSyntax.commaToken(trailingTrivia: [.spaces(1)]) + newCaseItems.append(lastItem) } newCaseItems.append(contentsOf: labels.last!.caseItems) - let newCase = cases.last!.withLabel(.case( - labels.last!.withCaseItems(CaseItemListSyntax(newCaseItems)))) + var lastLabel = labels.last! + lastLabel.caseItems = SwitchCaseItemListSyntax(newCaseItems) + + var lastCase = cases.last! + lastCase.label = .case(lastLabel) // Only the first violation case can have displaced trivia, because any non-whitespace // trivia in the other violation cases would've prevented collapsing. - if let displacedLeadingTrivia = cases.first!.leadingTrivia?.withoutLastLine() { - let existingLeadingTrivia = newCase.leadingTrivia ?? [] - let mergedLeadingTrivia = displacedLeadingTrivia + existingLeadingTrivia - return newCase.withLeadingTrivia(mergedLeadingTrivia) - } else { - return newCase - } + lastCase.leadingTrivia = cases.first!.leadingTrivia.withoutLastLine() + lastCase.leadingTrivia + return lastCase } } @@ -226,7 +225,7 @@ extension TriviaPiece { } extension Finding.Message { - public static func collapseCase(name: String) -> Finding.Message { - "combine fallthrough-only case \(name) with a following case" + fileprivate static var collapseCase: Finding.Message { + "combine this fallthrough-only 'case' and the following 'case' into a single 'case'" } } diff --git a/Sources/SwiftFormat/Rules/NoEmptyLineOpeningClosingBraces.swift b/Sources/SwiftFormat/Rules/NoEmptyLineOpeningClosingBraces.swift new file mode 100644 index 000000000..5158dbb95 --- /dev/null +++ b/Sources/SwiftFormat/Rules/NoEmptyLineOpeningClosingBraces.swift @@ -0,0 +1,160 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2024 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import SwiftSyntax + +/// Empty lines are forbidden after opening braces and before closing braces. +/// +/// Lint: Empty lines after opening braces and before closing braces yield a lint error. +/// +/// Format: Empty lines after opening braces and before closing braces will be removed. +@_spi(Rules) +public final class NoEmptyLinesOpeningClosingBraces: SyntaxFormatRule { + public override class var isOptIn: Bool { return true } + + public override func visit(_ node: AccessorBlockSyntax) -> AccessorBlockSyntax { + var result = node + switch node.accessors { + case .accessors(let accessors): + result.accessors = .init(rewritten(accessors)) + case .getter(let getter): + result.accessors = .init(rewritten(getter)) + } + result.rightBrace = rewritten(node.rightBrace) + return result + } + + public override func visit(_ node: CodeBlockSyntax) -> CodeBlockSyntax { + var result = node + result.statements = rewritten(node.statements) + result.rightBrace = rewritten(node.rightBrace) + return result + } + + public override func visit(_ node: MemberBlockSyntax) -> MemberBlockSyntax { + var result = node + result.members = rewritten(node.members) + result.rightBrace = rewritten(node.rightBrace) + return result + } + + public override func visit(_ node: ClosureExprSyntax) -> ExprSyntax { + var result = node + result.statements = rewritten(node.statements) + result.rightBrace = rewritten(node.rightBrace) + return ExprSyntax(result) + } + + public override func visit(_ node: SwitchExprSyntax) -> ExprSyntax { + var result = node + result.cases = rewritten(node.cases) + result.rightBrace = rewritten(node.rightBrace) + return ExprSyntax(result) + } + + public override func visit(_ node: PrecedenceGroupDeclSyntax) -> DeclSyntax { + var result = node + result.attributes = rewritten(node.attributes) + result.rightBrace = rewritten(node.rightBrace) + return DeclSyntax(result) + } + + func rewritten(_ token: TokenSyntax) -> TokenSyntax { + let (trimmedLeadingTrivia, count) = token.leadingTrivia.trimmingSuperfluousNewlines( + fromClosingBrace: token.tokenKind == .rightBrace + ) + if trimmedLeadingTrivia.sourceLength != token.leadingTriviaLength { + diagnose(.removeEmptyLinesBefore(count), on: token, anchor: .start) + return token.with(\.leadingTrivia, trimmedLeadingTrivia) + } else { + return token + } + } + + func rewritten(_ collection: C) -> C { + var result = collection + if let first = collection.first, first.leadingTrivia.containsNewlines, + let index = collection.index(of: first) + { + let (trimmedLeadingTrivia, count) = first.leadingTrivia.trimmingSuperfluousNewlines(fromClosingBrace: false) + if trimmedLeadingTrivia.sourceLength != first.leadingTriviaLength { + diagnose(.removeEmptyLinesAfter(count), on: first, anchor: .leadingTrivia(0)) + var first = first + first.leadingTrivia = trimmedLeadingTrivia + result[index] = first + } + } + return rewrite(result).as(C.self)! + } +} + +extension Trivia { + func trimmingSuperfluousNewlines(fromClosingBrace: Bool) -> (Trivia, Int) { + var trimmmed = 0 + var pendingNewlineCount = 0 + let pieces = self.indices.reduce([TriviaPiece]()) { (partialResult, index) in + let piece = self[index] + // Collapse consecutive newlines into a single one + if case .newlines(let count) = piece { + if fromClosingBrace { + if index == self.count - 1 { + // For the last index(newline right before the closing brace), collapse into a single newline + trimmmed += count - 1 + return partialResult + [.newlines(1)] + } else { + pendingNewlineCount += count + return partialResult + } + } else { + if let last = partialResult.last, last.isNewline { + trimmmed += count + return partialResult + } else if index == 0 { + // For leading trivia not associated with a closing brace, collapse the first newline into a single one + trimmmed += count - 1 + return partialResult + [.newlines(1)] + } else { + return partialResult + [piece] + } + } + } + // Remove spaces/tabs surrounded by newlines + if piece.isSpaceOrTab, index > 0, index < self.count - 1, self[index - 1].isNewline, self[index + 1].isNewline { + return partialResult + } + // Handle pending newlines if there are any + if pendingNewlineCount > 0 { + if index < self.count - 1 { + let newlines = TriviaPiece.newlines(pendingNewlineCount) + pendingNewlineCount = 0 + return partialResult + [newlines] + [piece] + } else { + return partialResult + [.newlines(1)] + [piece] + } + } + // Retain other trivia pieces + return partialResult + [piece] + } + + return (Trivia(pieces: pieces), trimmmed) + } +} + +extension Finding.Message { + fileprivate static func removeEmptyLinesAfter(_ count: Int) -> Finding.Message { + "remove empty \(count > 1 ? "lines" : "line") after '{'" + } + + fileprivate static func removeEmptyLinesBefore(_ count: Int) -> Finding.Message { + "remove empty \(count > 1 ? "lines" : "line") before '}'" + } +} diff --git a/Sources/SwiftFormatRules/NoEmptyTrailingClosureParentheses.swift b/Sources/SwiftFormat/Rules/NoEmptyTrailingClosureParentheses.swift similarity index 56% rename from Sources/SwiftFormatRules/NoEmptyTrailingClosureParentheses.swift rename to Sources/SwiftFormat/Rules/NoEmptyTrailingClosureParentheses.swift index 36924c962..d3c17eedd 100644 --- a/Sources/SwiftFormatRules/NoEmptyTrailingClosureParentheses.swift +++ b/Sources/SwiftFormat/Rules/NoEmptyTrailingClosureParentheses.swift @@ -10,7 +10,6 @@ // //===----------------------------------------------------------------------===// -import SwiftFormatCore import SwiftSyntax /// Function calls with no arguments and a trailing closure should not have empty parentheses. @@ -19,40 +18,46 @@ import SwiftSyntax /// a lint error is raised. /// /// Format: Empty parentheses in function calls with trailing closures will be removed. +@_spi(Rules) public final class NoEmptyTrailingClosureParentheses: SyntaxFormatRule { public override func visit(_ node: FunctionCallExprSyntax) -> ExprSyntax { - guard node.argumentList.count == 0 else { return super.visit(node) } + guard node.arguments.count == 0 else { return super.visit(node) } - guard let trailingClosure = node.trailingClosure, - node.argumentList.isEmpty && node.leftParen != nil else - { + guard + let trailingClosure = node.trailingClosure, + let leftParen = node.leftParen, + let rightParen = node.rightParen, + node.arguments.isEmpty, + !leftParen.trailingTrivia.hasAnyComments, + !rightParen.leadingTrivia.hasAnyComments + else { return super.visit(node) } - guard let name = node.calledExpression.lastToken?.withoutTrivia() else { + guard let name = node.calledExpression.lastToken(viewMode: .sourceAccurate) else { return super.visit(node) } - diagnose(.removeEmptyTrailingParentheses(name: "\(name)"), on: node) + diagnose(.removeEmptyTrailingParentheses(name: "\(name.trimmedDescription)"), on: leftParen) // Need to visit `calledExpression` before creating a new node so that the location data (column // and line numbers) is available. - guard let rewrittenCalledExpr = ExprSyntax(visit(Syntax(node.calledExpression))) else { + guard var rewrittenCalledExpr = ExprSyntax(rewrite(Syntax(node.calledExpression))) else { return super.visit(node) } - let formattedExp = replaceTrivia( - on: rewrittenCalledExpr, - token: rewrittenCalledExpr.lastToken, - trailingTrivia: .spaces(1)) - let formattedClosure = visit(trailingClosure).as(ClosureExprSyntax.self) - let result = node.withLeftParen(nil).withRightParen(nil).withCalledExpression(formattedExp) - .withTrailingClosure(formattedClosure) + rewrittenCalledExpr.trailingTrivia = [.spaces(1)] + + var result = node + result.leftParen = nil + result.rightParen = nil + result.calledExpression = rewrittenCalledExpr + result.trailingClosure = rewrite(trailingClosure).as(ClosureExprSyntax.self) return ExprSyntax(result) } } extension Finding.Message { - public static func removeEmptyTrailingParentheses(name: String) -> Finding.Message { - "remove '()' after \(name)" + fileprivate static func removeEmptyTrailingParentheses(name: String) -> Finding.Message { + "remove the empty parentheses following '\(name)'" } } diff --git a/Sources/SwiftFormatRules/NoLabelsInCasePatterns.swift b/Sources/SwiftFormat/Rules/NoLabelsInCasePatterns.swift similarity index 55% rename from Sources/SwiftFormatRules/NoLabelsInCasePatterns.swift rename to Sources/SwiftFormat/Rules/NoLabelsInCasePatterns.swift index 99525809b..aba7cdf1f 100644 --- a/Sources/SwiftFormatRules/NoLabelsInCasePatterns.swift +++ b/Sources/SwiftFormat/Rules/NoLabelsInCasePatterns.swift @@ -11,7 +11,6 @@ //===----------------------------------------------------------------------===// import Foundation -import SwiftFormatCore import SwiftSyntax /// Redundant labels are forbidden in case patterns. @@ -22,56 +21,61 @@ import SwiftSyntax /// binding identifier. /// /// Format: Redundant labels in case patterns are removed. +@_spi(Rules) public final class NoLabelsInCasePatterns: SyntaxFormatRule { public override func visit(_ node: SwitchCaseLabelSyntax) -> SwitchCaseLabelSyntax { - var newCaseItems: [CaseItemSyntax] = [] + var newCaseItems: [SwitchCaseItemSyntax] = [] + for item in node.caseItems { - guard let expPat = item.pattern.as(ExpressionPatternSyntax.self) else { - newCaseItems.append(item) - continue - } - guard let funcCall = expPat.expression.as(FunctionCallExprSyntax.self) else { + guard + var exprPattern = item.pattern.as(ExpressionPatternSyntax.self), + var funcCall = exprPattern.expression.as(FunctionCallExprSyntax.self) + else { newCaseItems.append(item) continue } // Search function call argument list for violations - var newArgs: [TupleExprElementSyntax] = [] - for argument in funcCall.argumentList { - guard let label = argument.label else { - newArgs.append(argument) - continue - } - guard let unresolvedPat = argument.expression.as(UnresolvedPatternExprSyntax.self), + var newArguments = LabeledExprListSyntax() + for argument in funcCall.arguments { + guard + let label = argument.label, + let unresolvedPat = argument.expression.as(PatternExprSyntax.self), let valueBinding = unresolvedPat.pattern.as(ValueBindingPatternSyntax.self) else { - newArgs.append(argument) + newArguments.append(argument) continue } // Remove label if it's the same as the value identifier - let name = valueBinding.valuePattern.withoutTrivia().description + let name = valueBinding.pattern.trimmedDescription guard name == label.text else { - newArgs.append(argument) + newArguments.append(argument) continue } diagnose(.removeRedundantLabel(name: name), on: label) - newArgs.append(argument.withLabel(nil).withColon(nil)) + + var newArgument = argument + newArgument.label = nil + newArgument.colon = nil + newArguments.append(newArgument) } - let newArgList = TupleExprElementListSyntax(newArgs) - let newFuncCall = funcCall.withArgumentList(newArgList) - let newExpPat = expPat.withExpression(ExprSyntax(newFuncCall)) - let newItem = item.withPattern(PatternSyntax(newExpPat)) + var newItem = item + funcCall.arguments = newArguments + exprPattern.expression = ExprSyntax(funcCall) + newItem.pattern = PatternSyntax(exprPattern) newCaseItems.append(newItem) } - let newCaseItemList = CaseItemListSyntax(newCaseItems) - return node.withCaseItems(newCaseItemList) + + var result = node + result.caseItems = SwitchCaseItemListSyntax(newCaseItems) + return result } } extension Finding.Message { - public static func removeRedundantLabel(name: String) -> Finding.Message { - "remove \(name) label from case argument" + fileprivate static func removeRedundantLabel(name: String) -> Finding.Message { + "remove the label '\(name)' from this 'case' pattern" } } diff --git a/Sources/SwiftFormatRules/NoLeadingUnderscores.swift b/Sources/SwiftFormat/Rules/NoLeadingUnderscores.swift similarity index 69% rename from Sources/SwiftFormatRules/NoLeadingUnderscores.swift rename to Sources/SwiftFormat/Rules/NoLeadingUnderscores.swift index 051818b2b..bc3a7fff5 100644 --- a/Sources/SwiftFormatRules/NoLeadingUnderscores.swift +++ b/Sources/SwiftFormat/Rules/NoLeadingUnderscores.swift @@ -10,7 +10,6 @@ // //===----------------------------------------------------------------------===// -import SwiftFormatCore import SwiftSyntax /// Identifiers in declarations and patterns should not have leading underscores. @@ -23,6 +22,7 @@ import SwiftSyntax /// sites. /// /// Lint: Declaring an identifier with a leading underscore yields a lint error. +@_spi(Rules) public final class NoLeadingUnderscores: SyntaxLintRule { /// Identifies this rule as being opt-in. While leading underscores aren't meant to be used in @@ -31,32 +31,40 @@ public final class NoLeadingUnderscores: SyntaxLintRule { /// doesn't intend for arbitrary usage. public override class var isOptIn: Bool { return true } - public override func visit(_ node: AssociatedtypeDeclSyntax) -> SyntaxVisitorContinueKind { - diagnoseIfNameStartsWithUnderscore(node.identifier) + public override func visit(_ node: AssociatedTypeDeclSyntax) -> SyntaxVisitorContinueKind { + diagnoseIfNameStartsWithUnderscore(node.name) return .visitChildren } public override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind { - diagnoseIfNameStartsWithUnderscore(node.identifier) + diagnoseIfNameStartsWithUnderscore(node.name) return .visitChildren } public override func visit(_ node: EnumCaseElementSyntax) -> SyntaxVisitorContinueKind { - diagnoseIfNameStartsWithUnderscore(node.identifier) + diagnoseIfNameStartsWithUnderscore(node.name) return .visitChildren } public override func visit(_ node: EnumDeclSyntax) -> SyntaxVisitorContinueKind { - diagnoseIfNameStartsWithUnderscore(node.identifier) + diagnoseIfNameStartsWithUnderscore(node.name) return .visitChildren } public override func visit(_ node: FunctionDeclSyntax) -> SyntaxVisitorContinueKind { - diagnoseIfNameStartsWithUnderscore(node.identifier) + diagnoseIfNameStartsWithUnderscore(node.name) return .visitChildren } - public override func visit(_ node: FunctionParameterSyntax) -> SyntaxVisitorContinueKind { + public override func visit(_ node: ClosureParameterSyntax) -> SyntaxVisitorContinueKind { + // If both names are provided, we want to check `secondName`, which will be the parameter name + // (in that case, `firstName` is the label). If only one name is present, then it is recorded in + // `firstName`, and it is both the label and the parameter name. + diagnoseIfNameStartsWithUnderscore(node.secondName ?? node.firstName) + return .visitChildren + } + + public override func visit(_ node: EnumCaseParameterSyntax) -> SyntaxVisitorContinueKind { // If both names are provided, we want to check `secondName`, which will be the parameter name // (in that case, `firstName` is the label). If only one name is present, then it is recorded in // `firstName`, and it is both the label and the parameter name. @@ -66,6 +74,14 @@ public final class NoLeadingUnderscores: SyntaxLintRule { return .visitChildren } + public override func visit(_ node: FunctionParameterSyntax) -> SyntaxVisitorContinueKind { + // If both names are provided, we want to check `secondName`, which will be the parameter name + // (in that case, `firstName` is the label). If only one name is present, then it is recorded in + // `firstName`, and it is both the label and the parameter name. + diagnoseIfNameStartsWithUnderscore(node.secondName ?? node.firstName) + return .visitChildren + } + public override func visit(_ node: GenericParameterSyntax) -> SyntaxVisitorContinueKind { diagnoseIfNameStartsWithUnderscore(node.name) return .visitChildren @@ -77,22 +93,22 @@ public final class NoLeadingUnderscores: SyntaxLintRule { } public override func visit(_ node: PrecedenceGroupDeclSyntax) -> SyntaxVisitorContinueKind { - diagnoseIfNameStartsWithUnderscore(node.identifier) + diagnoseIfNameStartsWithUnderscore(node.name) return .visitChildren } public override func visit(_ node: ProtocolDeclSyntax) -> SyntaxVisitorContinueKind { - diagnoseIfNameStartsWithUnderscore(node.identifier) + diagnoseIfNameStartsWithUnderscore(node.name) return .visitChildren } public override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind { - diagnoseIfNameStartsWithUnderscore(node.identifier) + diagnoseIfNameStartsWithUnderscore(node.name) return .visitChildren } - public override func visit(_ node: TypealiasDeclSyntax) -> SyntaxVisitorContinueKind { - diagnoseIfNameStartsWithUnderscore(node.identifier) + public override func visit(_ node: TypeAliasDeclSyntax) -> SyntaxVisitorContinueKind { + diagnoseIfNameStartsWithUnderscore(node.name) return .visitChildren } @@ -109,7 +125,7 @@ public final class NoLeadingUnderscores: SyntaxLintRule { } extension Finding.Message { - public static func doNotStartWithUnderscore(identifier: String) -> Finding.Message { - "remove leading '_' from identifier '\(identifier)'" + fileprivate static func doNotStartWithUnderscore(identifier: String) -> Finding.Message { + "remove the leading '_' from the name '\(identifier)'" } } diff --git a/Sources/SwiftFormat/Rules/NoParensAroundConditions.swift b/Sources/SwiftFormat/Rules/NoParensAroundConditions.swift new file mode 100644 index 000000000..7c9f1dc9d --- /dev/null +++ b/Sources/SwiftFormat/Rules/NoParensAroundConditions.swift @@ -0,0 +1,131 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2019 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import SwiftSyntax + +/// Enforces rules around parentheses in conditions or matched expressions. +/// +/// Parentheses are not used around any condition of an `if`, `guard`, or `while` statement, or +/// around the matched expression in a `switch` statement. +/// +/// Lint: If a top-most expression in a `switch`, `if`, `guard`, or `while` statement is surrounded +/// by parentheses, and it does not include a function call with a trailing closure, a lint +/// error is raised. +/// +/// Format: Parentheses around such expressions are removed, if they do not cause a parse ambiguity. +/// Specifically, parentheses are allowed if and only if the expression contains a function +/// call with a trailing closure. +@_spi(Rules) +public final class NoParensAroundConditions: SyntaxFormatRule { + public override func visit(_ node: IfExprSyntax) -> ExprSyntax { + var result = node + fixKeywordTrailingTrivia(&result.ifKeyword.trailingTrivia) + result.conditions = visit(node.conditions) + result.body = visit(node.body) + if let elseBody = node.elseBody { + result.elseBody = visit(elseBody) + } + return ExprSyntax(result) + } + + public override func visit(_ node: ConditionElementSyntax) -> ConditionElementSyntax { + guard + case .expression(let condition) = node.condition, + let newExpr = minimalSingleExpression(condition) + else { + return super.visit(node) + } + + var result = node + result.condition = .expression(newExpr) + return result + } + + public override func visit(_ node: GuardStmtSyntax) -> StmtSyntax { + var result = node + fixKeywordTrailingTrivia(&result.guardKeyword.trailingTrivia) + result.conditions = visit(node.conditions) + result.body = visit(node.body) + return StmtSyntax(result) + } + + public override func visit(_ node: SwitchExprSyntax) -> ExprSyntax { + guard let newSubject = minimalSingleExpression(node.subject) else { + return super.visit(node) + } + + var result = node + fixKeywordTrailingTrivia(&result.switchKeyword.trailingTrivia) + result.subject = newSubject + result.cases = visit(node.cases) + return ExprSyntax(result) + } + + public override func visit(_ node: RepeatStmtSyntax) -> StmtSyntax { + guard let newCondition = minimalSingleExpression(node.condition) else { + return super.visit(node) + } + + var result = node + fixKeywordTrailingTrivia(&result.whileKeyword.trailingTrivia) + result.condition = newCondition + result.body = visit(node.body) + return StmtSyntax(result) + } + + public override func visit(_ node: WhileStmtSyntax) -> StmtSyntax { + var result = node + fixKeywordTrailingTrivia(&result.whileKeyword.trailingTrivia) + result.conditions = visit(node.conditions) + result.body = visit(node.body) + return StmtSyntax(result) + } + + private func fixKeywordTrailingTrivia(_ trivia: inout Trivia) { + guard trivia.isEmpty else { return } + trivia = [.spaces(1)] + } + + private func minimalSingleExpression(_ original: ExprSyntax) -> ExprSyntax? { + guard + let tuple = original.as(TupleExprSyntax.self), + tuple.elements.count == 1, + let expr = tuple.elements.first?.expression + else { + return nil + } + + // If the condition is a function with a trailing closure or if it's an immediately called + // closure, removing the outer set of parentheses introduces a parse ambiguity. + if let fnCall = expr.as(FunctionCallExprSyntax.self) { + if fnCall.trailingClosure != nil { + // Leave parentheses around call with trailing closure. + return ExprSyntax(tuple) + } else if fnCall.calledExpression.as(ClosureExprSyntax.self) != nil { + // Leave parentheses around immediately called closure. + return ExprSyntax(tuple) + } + } + + diagnose(.removeParensAroundExpression, on: tuple.leftParen) + + var visitedExpr = visit(expr) + visitedExpr.leadingTrivia = tuple.leftParen.leadingTrivia + visitedExpr.trailingTrivia = tuple.rightParen.trailingTrivia + return visitedExpr + } +} + +extension Finding.Message { + fileprivate static let removeParensAroundExpression: Finding.Message = + "remove the parentheses around this expression" +} diff --git a/Sources/SwiftFormat/Rules/NoPlaygroundLiterals.swift b/Sources/SwiftFormat/Rules/NoPlaygroundLiterals.swift new file mode 100644 index 000000000..e9d5d653a --- /dev/null +++ b/Sources/SwiftFormat/Rules/NoPlaygroundLiterals.swift @@ -0,0 +1,88 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2019 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Foundation +import SwiftSyntax + +/// The playground literals (`#colorLiteral`, `#fileLiteral`, and `#imageLiteral`) are forbidden. +/// +/// Lint: Using a playground literal will yield a lint error with a suggestion of an API to replace +/// it. +@_spi(Rules) +public final class NoPlaygroundLiterals: SyntaxLintRule { + override public func visit(_ node: MacroExpansionExprSyntax) -> SyntaxVisitorContinueKind { + switch node.macroName.text { + case "colorLiteral": + diagnosedColorLiteralMacroExpansion(node) + case "fileLiteral": + diagnosedFileLiteralMacroExpansion(node) + case "imageLiteral": + diagnosedImageLiteralMacroExpansion(node) + default: + break + } + return .visitChildren + } + + private func diagnosedColorLiteralMacroExpansion(_ node: MacroExpansionExprSyntax) { + guard isLiteralMacroCall(node, matchingLabels: ["red", "green", "blue", "alpha"]) else { + return + } + diagnose(.replaceColorLiteral, on: node) + } + + private func diagnosedFileLiteralMacroExpansion(_ node: MacroExpansionExprSyntax) { + guard isLiteralMacroCall(node, matchingLabels: ["resourceName"]) else { + return + } + diagnose(.replaceFileLiteral, on: node) + } + + private func diagnosedImageLiteralMacroExpansion(_ node: MacroExpansionExprSyntax) { + guard isLiteralMacroCall(node, matchingLabels: ["resourceName"]) else { + return + } + diagnose(.replaceImageLiteral, on: node) + } + + /// Returns true if the given macro expansion is a correctly constructed call with the given + /// argument labels and has no trailing closures or generic arguments. + private func isLiteralMacroCall( + _ node: MacroExpansionExprSyntax, + matchingLabels labels: [String] + ) -> Bool { + guard + node.genericArgumentClause == nil, + node.trailingClosure == nil, + node.additionalTrailingClosures.isEmpty, + node.arguments.count == labels.count + else { + return false + } + + for (actual, expected) in zip(node.arguments, labels) { + guard actual.label?.text == expected else { return false } + } + return true + } +} + +extension Finding.Message { + fileprivate static let replaceColorLiteral: Finding.Message = + "replace '#colorLiteral' with a call to an initializer on 'NSColor' or 'UIColor'" + + fileprivate static let replaceFileLiteral: Finding.Message = + "replace '#fileLiteral' with a call to a method such as 'Bundle.url(forResource:withExtension:)'" + + fileprivate static let replaceImageLiteral: Finding.Message = + "replace '#imageLiteral' with a call to an initializer on 'NSImage' or 'UIImage'" +} diff --git a/Sources/SwiftFormatRules/NoVoidReturnOnFunctionSignature.swift b/Sources/SwiftFormat/Rules/NoVoidReturnOnFunctionSignature.swift similarity index 55% rename from Sources/SwiftFormatRules/NoVoidReturnOnFunctionSignature.swift rename to Sources/SwiftFormat/Rules/NoVoidReturnOnFunctionSignature.swift index 61487b801..3cacd29e0 100644 --- a/Sources/SwiftFormatRules/NoVoidReturnOnFunctionSignature.swift +++ b/Sources/SwiftFormat/Rules/NoVoidReturnOnFunctionSignature.swift @@ -10,7 +10,6 @@ // //===----------------------------------------------------------------------===// -import SwiftFormatCore import SwiftSyntax /// Functions that return `()` or `Void` should omit the return signature. @@ -19,25 +18,41 @@ import SwiftSyntax /// /// Format: Function declarations with explicit returns of `()` or `Void` will have their return /// signature stripped. +@_spi(Rules) public final class NoVoidReturnOnFunctionSignature: SyntaxFormatRule { /// Remove the `-> Void` return type for function signatures. Do not remove /// it for closure signatures, because that may introduce an ambiguity when closure signatures /// are inferred. public override func visit(_ node: FunctionSignatureSyntax) -> FunctionSignatureSyntax { - if let ret = node.output?.returnType.as(SimpleTypeIdentifierSyntax.self), ret.name.text == "Void" { - diagnose(.removeRedundantReturn("Void"), on: ret) - return node.withOutput(nil) + guard let returnType = node.returnClause?.type else { return node } + + if let identifierType = returnType.as(IdentifierTypeSyntax.self), + identifierType.name.text == "Void", + identifierType.genericArgumentClause?.arguments.isEmpty ?? true + { + diagnose(.removeRedundantReturn("Void"), on: identifierType) + return removingReturnClause(from: node) } - if let tup = node.output?.returnType.as(TupleTypeSyntax.self), tup.elements.isEmpty { - diagnose(.removeRedundantReturn("()"), on: tup) - return node.withOutput(nil) + if let tupleType = returnType.as(TupleTypeSyntax.self), tupleType.elements.isEmpty { + diagnose(.removeRedundantReturn("()"), on: tupleType) + return removingReturnClause(from: node) } + return node } + + /// Returns a copy of the given function signature with the return clause removed. + private func removingReturnClause( + from signature: FunctionSignatureSyntax + ) -> FunctionSignatureSyntax { + var result = signature + result.returnClause = nil + return result + } } extension Finding.Message { - public static func removeRedundantReturn(_ type: String) -> Finding.Message { - "remove explicit '\(type)' return type" + fileprivate static func removeRedundantReturn(_ type: String) -> Finding.Message { + "remove the explicit return type '\(type)' from this function" } } diff --git a/Sources/SwiftFormat/Rules/OmitExplicitReturns.swift b/Sources/SwiftFormat/Rules/OmitExplicitReturns.swift new file mode 100644 index 000000000..63c65fc2a --- /dev/null +++ b/Sources/SwiftFormat/Rules/OmitExplicitReturns.swift @@ -0,0 +1,159 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import SwiftSyntax + +/// Single-expression functions, closures, subscripts can omit `return` statement. +/// +/// Lint: `func () { return ... }` and similar single expression constructs will yield a lint error. +/// +/// Format: `func () { return ... }` constructs will be replaced with +/// equivalent `func () { ... }` constructs. +@_spi(Rules) +public final class OmitExplicitReturns: SyntaxFormatRule { + public override class var isOptIn: Bool { return true } + + public override func visit(_ node: FunctionDeclSyntax) -> DeclSyntax { + let decl = super.visit(node) + + // func () -> { return ... } + guard var funcDecl = decl.as(FunctionDeclSyntax.self), + let body = funcDecl.body, + let returnStmt = containsSingleReturn(body.statements) + else { + return decl + } + + funcDecl.body?.statements = rewrapReturnedExpression(returnStmt) + diagnose(.omitReturnStatement, on: returnStmt, severity: .refactoring) + return DeclSyntax(funcDecl) + } + + public override func visit(_ node: SubscriptDeclSyntax) -> DeclSyntax { + let decl = super.visit(node) + + guard var subscriptDecl = decl.as(SubscriptDeclSyntax.self), + let accessorBlock = subscriptDecl.accessorBlock, + // We are assuming valid Swift code here where only + // one `get { ... }` is allowed. + let transformed = transformAccessorBlock(accessorBlock) + else { + return decl + } + + subscriptDecl.accessorBlock = transformed + return DeclSyntax(subscriptDecl) + } + + public override func visit(_ node: PatternBindingSyntax) -> PatternBindingSyntax { + var binding = node + + guard let accessorBlock = binding.accessorBlock, + let transformed = transformAccessorBlock(accessorBlock) + else { + return node + } + + binding.accessorBlock = transformed + return binding + } + + public override func visit(_ node: ClosureExprSyntax) -> ExprSyntax { + let expr = super.visit(node) + + // test { return ... } + guard var closureExpr = expr.as(ClosureExprSyntax.self), + let returnStmt = containsSingleReturn(closureExpr.statements) + else { + return expr + } + + closureExpr.statements = rewrapReturnedExpression(returnStmt) + diagnose(.omitReturnStatement, on: returnStmt, severity: .refactoring) + return ExprSyntax(closureExpr) + } + + private func transformAccessorBlock(_ accessorBlock: AccessorBlockSyntax) -> AccessorBlockSyntax? { + // We are assuming valid Swift code here where only + // one `get { ... }` is allowed. + switch accessorBlock.accessors { + case .accessors(var accessors): + guard + var getter = accessors.filter({ + $0.accessorSpecifier.tokenKind == .keyword(.get) + }).first + else { + return nil + } + + guard let body = getter.body, + let returnStmt = containsSingleReturn(body.statements) + else { + return nil + } + + guard + let getterAt = accessors.firstIndex(where: { + $0.accessorSpecifier.tokenKind == .keyword(.get) + }) + else { + return nil + } + + getter.body?.statements = rewrapReturnedExpression(returnStmt) + + diagnose(.omitReturnStatement, on: returnStmt, severity: .refactoring) + + accessors[getterAt] = getter + var newBlock = accessorBlock + newBlock.accessors = .accessors(accessors) + return newBlock + + case .getter(let getter): + guard let returnStmt = containsSingleReturn(getter) else { + return nil + } + + diagnose(.omitReturnStatement, on: returnStmt, severity: .refactoring) + + var newBlock = accessorBlock + newBlock.accessors = .getter(rewrapReturnedExpression(returnStmt)) + return newBlock + } + } + + private func containsSingleReturn(_ body: CodeBlockItemListSyntax) -> ReturnStmtSyntax? { + guard let element = body.firstAndOnly, + let returnStmt = element.item.as(ReturnStmtSyntax.self) + else { + return nil + } + + return !returnStmt.children(viewMode: .all).isEmpty && returnStmt.expression != nil ? returnStmt : nil + } + + private func rewrapReturnedExpression(_ returnStmt: ReturnStmtSyntax) -> CodeBlockItemListSyntax { + CodeBlockItemListSyntax([ + CodeBlockItemSyntax( + leadingTrivia: returnStmt.leadingTrivia, + item: .expr(returnStmt.expression!), + semicolon: nil, + trailingTrivia: returnStmt.trailingTrivia + ) + ]) + } +} + +extension Finding.Message { + fileprivate static let omitReturnStatement: Finding.Message = + "'return' can be omitted because body consists of a single expression" +} diff --git a/Sources/SwiftFormatRules/OneCasePerLine.swift b/Sources/SwiftFormat/Rules/OneCasePerLine.swift similarity index 77% rename from Sources/SwiftFormatRules/OneCasePerLine.swift rename to Sources/SwiftFormat/Rules/OneCasePerLine.swift index 65c88db4f..08f50e703 100644 --- a/Sources/SwiftFormatRules/OneCasePerLine.swift +++ b/Sources/SwiftFormat/Rules/OneCasePerLine.swift @@ -10,7 +10,6 @@ // //===----------------------------------------------------------------------===// -import SwiftFormatCore import SwiftSyntax /// Each enum case with associated values or a raw value should appear in its own case declaration. @@ -20,6 +19,7 @@ import SwiftSyntax /// /// Format: All case declarations with associated values or raw values will be moved to their own /// case declarations. +@_spi(Rules) public final class OneCasePerLine: SyntaxFormatRule { /// A state machine that collects case elements encountered during visitation and allows new case @@ -56,12 +56,10 @@ public final class OneCasePerLine: SyntaxFormatRule { /// This will return nil if there are no elements collected since the last time this was called /// (or the collector was created). mutating func makeCaseDeclAndReset() -> EnumCaseDeclSyntax? { - guard let last = elements.last else { return nil } + guard !elements.isEmpty else { return nil } // Remove the trailing comma on the final element, if there was one. - if last.trailingComma != nil { - elements[elements.count - 1] = last.withTrailingComma(nil) - } + elements[elements.count - 1].trailingComma = nil defer { elements.removeAll() } return makeCaseDeclFromBasis(elements: elements) @@ -70,14 +68,15 @@ public final class OneCasePerLine: SyntaxFormatRule { /// Creates and returns a new `EnumCaseDeclSyntax` with the given elements, based on the current /// basis declaration, and updates the comment preserving state if needed. mutating func makeCaseDeclFromBasis(elements: [EnumCaseElementSyntax]) -> EnumCaseDeclSyntax { - let caseDecl = basis.withElements(EnumCaseElementListSyntax(elements)) + var caseDecl = basis + caseDecl.elements = EnumCaseElementListSyntax(elements) if shouldKeepLeadingTrivia { shouldKeepLeadingTrivia = false // We don't bother preserving any indentation because the pretty printer will fix that up. // All we need to do here is ensure that there is a newline. - basis = basis.withLeadingTrivia(Trivia.newlines(1)) + basis.leadingTrivia = Trivia.newlines(1) } return caseDecl @@ -85,9 +84,9 @@ public final class OneCasePerLine: SyntaxFormatRule { } public override func visit(_ node: EnumDeclSyntax) -> DeclSyntax { - var newMembers: [MemberDeclListItemSyntax] = [] + var newMembers: [MemberBlockItemSyntax] = [] - for member in node.members.members { + for member in node.memberBlock.members { // If it's not a case declaration, or it's a case declaration with only one element, leave it // alone. guard let caseDecl = member.decl.as(EnumCaseDeclSyntax.self), caseDecl.elements.count > 1 else { @@ -100,18 +99,24 @@ public final class OneCasePerLine: SyntaxFormatRule { // Collect the elements of the case declaration until we see one that has either an associated // value or a raw value. for element in caseDecl.elements { - if element.associatedValue != nil || element.rawValue != nil { + if element.parameterClause != nil || element.rawValue != nil { // Once we reach one of these, we need to write out the ones we've collected so far, then // emit a separate case declaration with the associated/raw value element. - diagnose(.moveAssociatedOrRawValueCase(name: element.identifier.text), on: element) + diagnose(.moveAssociatedOrRawValueCase(name: element.name.text), on: element) if let caseDeclForCollectedElements = collector.makeCaseDeclAndReset() { - newMembers.append(member.withDecl(DeclSyntax(caseDeclForCollectedElements))) + var newMember = member + newMember.decl = DeclSyntax(caseDeclForCollectedElements) + newMembers.append(newMember) } - let separatedCaseDecl = - collector.makeCaseDeclFromBasis(elements: [element.withTrailingComma(nil)]) - newMembers.append(member.withDecl(DeclSyntax(separatedCaseDecl))) + var basisElement = element + basisElement.trailingComma = nil + let separatedCaseDecl = collector.makeCaseDeclFromBasis(elements: [basisElement]) + + var newMember = member + newMember.decl = DeclSyntax(separatedCaseDecl) + newMembers.append(newMember) } else { collector.addElement(element) } @@ -119,17 +124,20 @@ public final class OneCasePerLine: SyntaxFormatRule { // Make sure to emit any trailing collected elements. if let caseDeclForCollectedElements = collector.makeCaseDeclAndReset() { - newMembers.append(member.withDecl(DeclSyntax(caseDeclForCollectedElements))) + var newMember = member + newMember.decl = DeclSyntax(caseDeclForCollectedElements) + newMembers.append(newMember) } } - let newMemberBlock = node.members.withMembers(MemberDeclListSyntax(newMembers)) - return DeclSyntax(node.withMembers(newMemberBlock)) + var result = node + result.memberBlock.members = MemberBlockItemListSyntax(newMembers) + return DeclSyntax(result) } } extension Finding.Message { - public static func moveAssociatedOrRawValueCase(name: String) -> Finding.Message { - "move '\(name)' to its own case declaration" + fileprivate static func moveAssociatedOrRawValueCase(name: String) -> Finding.Message { + "move '\(name)' to its own 'case' declaration" } } diff --git a/Sources/SwiftFormatRules/OneVariableDeclarationPerLine.swift b/Sources/SwiftFormat/Rules/OneVariableDeclarationPerLine.swift similarity index 80% rename from Sources/SwiftFormatRules/OneVariableDeclarationPerLine.swift rename to Sources/SwiftFormat/Rules/OneVariableDeclarationPerLine.swift index a8829bebc..0387942fa 100644 --- a/Sources/SwiftFormatRules/OneVariableDeclarationPerLine.swift +++ b/Sources/SwiftFormat/Rules/OneVariableDeclarationPerLine.swift @@ -10,7 +10,6 @@ // //===----------------------------------------------------------------------===// -import SwiftFormatCore import SwiftSyntax /// Each variable declaration, with the exception of tuple destructuring, should @@ -22,6 +21,7 @@ import SwiftSyntax /// Format: If a variable declaration declares multiple variables, it will be /// split into multiple declarations, each declaring one of the variables, as /// long as the result would still be syntactically valid. +@_spi(Rules) public final class OneVariableDeclarationPerLine: SyntaxFormatRule { public override func visit(_ node: CodeBlockItemListSyntax) -> CodeBlockItemListSyntax { guard node.contains(where: codeBlockItemHasMultipleVariableBindings) else { @@ -41,7 +41,7 @@ public final class OneVariableDeclarationPerLine: SyntaxFormatRule { continue } - diagnose(.onlyOneVariableDeclaration, on: varDecl) + diagnose(.onlyOneVariableDeclaration(specifier: varDecl.bindingSpecifier.text), on: varDecl) // Visit the decl recursively to make sure nested code block items in the // bindings (for example, an initializer expression that contains a @@ -51,8 +51,8 @@ public final class OneVariableDeclarationPerLine: SyntaxFormatRule { var splitter = VariableDeclSplitter { CodeBlockItemSyntax( item: .decl(DeclSyntax($0)), - semicolon: nil, - errorTokens: nil) + semicolon: nil + ) } newItems.append(contentsOf: splitter.nodes(bySplitting: visitedDecl)) } @@ -75,8 +75,9 @@ public final class OneVariableDeclarationPerLine: SyntaxFormatRule { } extension Finding.Message { - public static let onlyOneVariableDeclaration: Finding.Message = - "split this variable declaration to have one variable per declaration" + fileprivate static func onlyOneVariableDeclaration(specifier: String) -> Finding.Message { + "split this variable declaration to introduce only one variable per '\(specifier)'" + } } /// Splits a variable declaration with multiple bindings into individual @@ -103,8 +104,8 @@ private struct VariableDeclSplitter { /// as a `CodeBlockItemSyntax`, that wraps it. private let generator: (VariableDeclSyntax) -> Node - /// Bindings that have been collected so far. - private var bindingQueue = [PatternBindingSyntax]() + /// Bindings that have been collected so far and the trivia that preceded them. + private var bindingQueue = [(PatternBindingSyntax, Trivia)]() /// The variable declaration being split. /// @@ -134,20 +135,29 @@ private struct VariableDeclSplitter { self.varDecl = varDecl self.nodes = [] + // We keep track of trivia that precedes each binding (which is reflected as trailing trivia + // on the previous token) so that we can reassociate it if we flush the bindings out as + // individual variable decls. This means that we can rewrite `let /*a*/ a, /*b*/ b: Int` as + // `let /*a*/ a: Int; let /*b*/ b: Int`, for example. + var precedingTrivia = varDecl.bindingSpecifier.trailingTrivia + for binding in varDecl.bindings { if binding.initializer != nil { // If this is the only initializer in the queue so far, that's ok. If // it's an initializer following other un-flushed lone identifier // bindings, that's not valid Swift. But in either case, we'll flush // them as a single decl. - bindingQueue.append(binding.withTrailingComma(nil)) + var newBinding = binding + newBinding.trailingComma = nil + bindingQueue.append((newBinding, precedingTrivia)) flushRemaining() } else if let typeAnnotation = binding.typeAnnotation { - bindingQueue.append(binding) + bindingQueue.append((binding, precedingTrivia)) flushIndividually(typeAnnotation: typeAnnotation) } else { - bindingQueue.append(binding) + bindingQueue.append((binding, precedingTrivia)) } + precedingTrivia = binding.trailingComma?.trailingTrivia ?? [] } flushRemaining() @@ -163,8 +173,7 @@ private struct VariableDeclSplitter { // We intentionally don't try to infer the indentation for subsequent // lines because the pretty printer will re-indent them correctly; we just // need to ensure that a newline is inserted before new decls. - varDecl = replaceTrivia( - on: varDecl, token: varDecl.firstToken, leadingTrivia: .newlines(1)) + varDecl.leadingTrivia = [.newlines(1)] fixedUpTrivia = true } @@ -172,8 +181,8 @@ private struct VariableDeclSplitter { private mutating func flushRemaining() { guard !bindingQueue.isEmpty else { return } - let newDecl = - varDecl.withBindings(PatternBindingListSyntax(bindingQueue)) + var newDecl = varDecl! + newDecl.bindings = PatternBindingListSyntax(bindingQueue.map(\.0)) nodes.append(generator(newDecl)) fixOriginalVarDeclTrivia() @@ -188,13 +197,16 @@ private struct VariableDeclSplitter { ) { assert(!bindingQueue.isEmpty) - for binding in bindingQueue { + for (binding, trailingTrivia) in bindingQueue { assert(binding.initializer == nil) - let newBinding = - binding.withTrailingComma(nil).withTypeAnnotation(typeAnnotation) - let newDecl = - varDecl.withBindings(PatternBindingListSyntax([newBinding])) + var newBinding = binding + newBinding.typeAnnotation = typeAnnotation + newBinding.trailingComma = nil + + var newDecl = varDecl! + newDecl.bindingSpecifier.trailingTrivia = trailingTrivia + newDecl.bindings = PatternBindingListSyntax([newBinding]) nodes.append(generator(newDecl)) fixOriginalVarDeclTrivia() @@ -203,4 +215,3 @@ private struct VariableDeclSplitter { bindingQueue = [] } } - diff --git a/Sources/SwiftFormatRules/OnlyOneTrailingClosureArgument.swift b/Sources/SwiftFormat/Rules/OnlyOneTrailingClosureArgument.swift similarity index 81% rename from Sources/SwiftFormatRules/OnlyOneTrailingClosureArgument.swift rename to Sources/SwiftFormat/Rules/OnlyOneTrailingClosureArgument.swift index 7b7c959e8..ea62a06ff 100644 --- a/Sources/SwiftFormatRules/OnlyOneTrailingClosureArgument.swift +++ b/Sources/SwiftFormat/Rules/OnlyOneTrailingClosureArgument.swift @@ -10,17 +10,17 @@ // //===----------------------------------------------------------------------===// -import SwiftFormatCore import SwiftSyntax /// Function calls should never mix normal closure arguments and trailing closures. /// /// Lint: If a function call with a trailing closure also contains a non-trailing closure argument, /// a lint error is raised. +@_spi(Rules) public final class OnlyOneTrailingClosureArgument: SyntaxLintRule { public override func visit(_ node: FunctionCallExprSyntax) -> SyntaxVisitorContinueKind { - guard (node.argumentList.contains { $0.expression.is(ClosureExprSyntax.self) }) else { + guard (node.arguments.contains { $0.expression.is(ClosureExprSyntax.self) }) else { return .skipChildren } guard node.trailingClosure != nil else { return .skipChildren } @@ -30,6 +30,6 @@ public final class OnlyOneTrailingClosureArgument: SyntaxLintRule { } extension Finding.Message { - public static let removeTrailingClosure: Finding.Message = - "revise function call to avoid using both closure arguments and a trailing closure" + fileprivate static let removeTrailingClosure: Finding.Message = + "revise this function call to avoid using both closure arguments and a trailing closure" } diff --git a/Sources/SwiftFormatRules/OrderedImports.swift b/Sources/SwiftFormat/Rules/OrderedImports.swift similarity index 84% rename from Sources/SwiftFormatRules/OrderedImports.swift rename to Sources/SwiftFormat/Rules/OrderedImports.swift index 81e0fe2e3..75fcb4572 100644 --- a/Sources/SwiftFormatRules/OrderedImports.swift +++ b/Sources/SwiftFormat/Rules/OrderedImports.swift @@ -10,7 +10,6 @@ // //===----------------------------------------------------------------------===// -import SwiftFormatCore import SwiftSyntax /// Imports must be lexicographically ordered and logically grouped at the top of each source file. @@ -23,6 +22,7 @@ import SwiftSyntax /// raised. /// /// Format: Imports will be reordered and grouped at the top of the file. +@_spi(Rules) public final class OrderedImports: SyntaxFormatRule { public override func visit(_ node: SourceFileSyntax) -> SourceFileSyntax { @@ -131,9 +131,8 @@ public final class OrderedImports: SyntaxFormatRule { formatAndAppend(linesSection: lines[lastSliceStartIndex.. [Line] { /// This function transforms the statements in a CodeBlockItemListSyntax object into a list of Line /// objects. Blank lines and standalone comments are represented by their own Line object. Code with /// a trailing comment are represented together in the same Line. -fileprivate func generateLines(codeBlockItemList: CodeBlockItemListSyntax, context: Context) - -> [Line] -{ +fileprivate func generateLines( + codeBlockItemList: CodeBlockItemListSyntax, + context: Context +) -> [Line] { var lines: [Line] = [] var currentLine = Line() - var afterNewline = false - var isFirstBlock = true func appendNewLine() { lines.append(currentLine) currentLine = Line() - afterNewline = true // Note: trailing line comments always come before any newlines. } for block in codeBlockItemList { - - if let leadingTrivia = block.leadingTrivia { - afterNewline = false - - for piece in leadingTrivia { - switch piece { - // Create new Line objects when we encounter newlines. - case .newlines(let N): - for _ in 0.. [CodeBlockItemSyntax] { var output: [CodeBlockItemSyntax] = [] - var triviaBuffer: [TriviaPiece] = [] + var pendingLeadingTrivia: [TriviaPiece] = [] for line in lines { - triviaBuffer += line.leadingTrivia + pendingLeadingTrivia += line.leadingTrivia func append(codeBlockItem: CodeBlockItemSyntax) { - // Comments and newlines are always located in the leading trivia of an AST node, so we need - // not deal with trailing trivia. - output.append( - replaceTrivia( - on: codeBlockItem, - token: codeBlockItem.firstToken, - leadingTrivia: Trivia(pieces: triviaBuffer) - ) - ) - triviaBuffer = [] - triviaBuffer += line.trailingTrivia + var codeBlockItem = codeBlockItem + codeBlockItem.leadingTrivia = Trivia(pieces: pendingLeadingTrivia) + codeBlockItem.trailingTrivia = Trivia(pieces: line.trailingTrivia) + output.append(codeBlockItem) + + pendingLeadingTrivia = [] } if let syntaxNode = line.syntaxNode { @@ -381,11 +368,11 @@ fileprivate func convertToCodeBlockItems(lines: [Line]) -> [CodeBlockItemSyntax] } } - // Merge multiple newlines together into a single trivia piece by updating it's N value. - if let lastPiece = triviaBuffer.last, case .newlines(let N) = lastPiece { - triviaBuffer[triviaBuffer.endIndex - 1] = TriviaPiece.newlines(N + 1) + // Merge multiple newlines together into a single trivia piece by updating its count. + if let lastPiece = pendingLeadingTrivia.last, case .newlines(let count) = lastPiece { + pendingLeadingTrivia[pendingLeadingTrivia.endIndex - 1] = .newlines(count + 1) } else { - triviaBuffer.append(TriviaPiece.newlines(1)) + pendingLeadingTrivia.append(.newlines(1)) } } @@ -490,9 +477,9 @@ fileprivate class Line { // description includes all leading and trailing trivia. It would be unusual to have any // non-whitespace trivia on the components of the import. Trim off the leading trivia, where // comments could be, and trim whitespace that might be after the import. - let leadingText = importDecl.leadingTrivia?.reduce(into: "") { $1.write(to: &$0) } ?? "" - return importDecl.description.dropFirst(leadingText.count) - .trimmingCharacters(in: .whitespacesAndNewlines) + var declForDescription = importDecl + declForDescription.leadingTrivia = [] + return declForDescription.description.trimmingCharacters(in: .whitespacesAndNewlines) } /// Returns the path that is imported by this line's import statement if it's an import statement. @@ -512,21 +499,21 @@ fileprivate class Line { guard let syntaxNode = syntaxNode else { return nil } switch syntaxNode { case .importCodeBlock(let codeBlock, _): - return codeBlock.firstToken + return codeBlock.firstToken(viewMode: .sourceAccurate) case .nonImportCodeBlocks(let codeBlocks): - return codeBlocks.first?.firstToken + return codeBlocks.first?.firstToken(viewMode: .sourceAccurate) } } /// Returns a `LineType` the represents the type of import from the given import decl. private func importType(of importDecl: ImportDeclSyntax) -> LineType { - if let attr = importDecl.attributes?.firstToken, + if let attr = importDecl.attributes.firstToken(viewMode: .sourceAccurate), attr.tokenKind == .atSign, - attr.nextToken?.text == "testable" + attr.nextToken(viewMode: .sourceAccurate)?.text == "testable" { return .testableImport } - if importDecl.importKind != nil { + if importDecl.importKindSpecifier != nil { return .declImport } return .regularImport @@ -576,13 +563,13 @@ extension Line: CustomDebugStringConvertible { } extension Finding.Message { - public static let placeAtTopOfFile: Finding.Message = "place imports at the top of the file" + fileprivate static let placeAtTopOfFile: Finding.Message = "place imports at the top of the file" - public static func groupImports(before: LineType, after: LineType) -> Finding.Message { + fileprivate static func groupImports(before: LineType, after: LineType) -> Finding.Message { "place \(before) imports before \(after) imports" } - public static let removeDuplicateImport: Finding.Message = "remove duplicate import" + fileprivate static let removeDuplicateImport: Finding.Message = "remove this duplicate import" - public static let sortImports: Finding.Message = "sort import statements lexicographically" + fileprivate static let sortImports: Finding.Message = "sort import statements lexicographically" } diff --git a/Sources/SwiftFormat/Rules/ReplaceForEachWithForLoop.swift b/Sources/SwiftFormat/Rules/ReplaceForEachWithForLoop.swift new file mode 100644 index 000000000..14e6dcbff --- /dev/null +++ b/Sources/SwiftFormat/Rules/ReplaceForEachWithForLoop.swift @@ -0,0 +1,52 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import SwiftSyntax + +/// Replace `forEach` with `for-in` loop unless its argument is a function reference. +/// +/// Lint: invalid use of `forEach` yield will yield a lint error. +@_spi(Rules) +public final class ReplaceForEachWithForLoop: SyntaxLintRule { + public override func visit(_ node: FunctionCallExprSyntax) -> SyntaxVisitorContinueKind { + // We are only interested in calls with a single trailing closure + // argument. + if !node.arguments.isEmpty || node.trailingClosure == nil || !node.additionalTrailingClosures.isEmpty { + return .visitChildren + } + + guard let member = node.calledExpression.as(MemberAccessExprSyntax.self) else { + return .visitChildren + } + + let memberName = member.declName.baseName + guard memberName.text == "forEach" else { + return .visitChildren + } + + // If there is another chained member after `.forEach`, + // let's skip the diagnostic because resulting code might + // be less understandable. + if node.parent?.is(MemberAccessExprSyntax.self) == true { + return .visitChildren + } + + diagnose(.replaceForEachWithLoop(), on: memberName) + return .visitChildren + } +} + +extension Finding.Message { + fileprivate static func replaceForEachWithLoop() -> Finding.Message { + "replace use of '.forEach { ... }' with for-in loop" + } +} diff --git a/Sources/SwiftFormatRules/ReturnVoidInsteadOfEmptyTuple.swift b/Sources/SwiftFormat/Rules/ReturnVoidInsteadOfEmptyTuple.swift similarity index 68% rename from Sources/SwiftFormatRules/ReturnVoidInsteadOfEmptyTuple.swift rename to Sources/SwiftFormat/Rules/ReturnVoidInsteadOfEmptyTuple.swift index e8e7c89d5..977c9bfe0 100644 --- a/Sources/SwiftFormatRules/ReturnVoidInsteadOfEmptyTuple.swift +++ b/Sources/SwiftFormat/Rules/ReturnVoidInsteadOfEmptyTuple.swift @@ -10,7 +10,6 @@ // //===----------------------------------------------------------------------===// -import SwiftFormatCore import SwiftSyntax /// Return `Void`, not `()`, in signatures. @@ -21,9 +20,10 @@ import SwiftSyntax /// Lint: Returning `()` in a signature yields a lint error. /// /// Format: `-> ()` is replaced with `-> Void` +@_spi(Rules) public final class ReturnVoidInsteadOfEmptyTuple: SyntaxFormatRule { public override func visit(_ node: FunctionTypeSyntax) -> TypeSyntax { - guard let returnType = node.returnType.as(TupleTypeSyntax.self), + guard let returnType = node.returnClause.type.as(TupleTypeSyntax.self), returnType.elements.count == 0 else { return super.visit(node) @@ -35,20 +35,25 @@ public final class ReturnVoidInsteadOfEmptyTuple: SyntaxFormatRule { // still diagnose it as a lint error but we don't replace it because it's not obvious where the // comment should go. if hasNonWhitespaceTrivia(returnType.leftParen, at: .trailing) - || hasNonWhitespaceTrivia(returnType.rightParen, at: .leading) { + || hasNonWhitespaceTrivia(returnType.rightParen, at: .leading) + { return super.visit(node) } - // Make sure that function types nested in the argument list are also rewritten (for example, + // Make sure that function types nested in the parameter list are also rewritten (for example, // `(Int -> ()) -> ()` should become `(Int -> Void) -> Void`). - let arguments = visit(node.arguments) + let parameters = visit(node.parameters) let voidKeyword = makeVoidIdentifierType(toReplace: returnType) - return TypeSyntax(node.withArguments(arguments).withReturnType(TypeSyntax(voidKeyword))) + var rewrittenNode = node + rewrittenNode.parameters = parameters + rewrittenNode.returnClause.type = TypeSyntax(voidKeyword) + return TypeSyntax(rewrittenNode) } public override func visit(_ node: ClosureSignatureSyntax) -> ClosureSignatureSyntax { - guard let output = node.output, - let returnType = output.returnType.as(TupleTypeSyntax.self), + guard + let returnClause = node.returnClause, + let returnType = returnClause.type.as(TupleTypeSyntax.self), returnType.elements.count == 0 else { return super.visit(node) @@ -60,24 +65,32 @@ public final class ReturnVoidInsteadOfEmptyTuple: SyntaxFormatRule { // still diagnose it as a lint error but we don't replace it because it's not obvious where the // comment should go. if hasNonWhitespaceTrivia(returnType.leftParen, at: .trailing) - || hasNonWhitespaceTrivia(returnType.rightParen, at: .leading) { + || hasNonWhitespaceTrivia(returnType.rightParen, at: .leading) + { return super.visit(node) } - let input: ClosureSignatureSyntax.Input? - switch node.input { - case .input(let parameterClause)?: + let closureParameterClause: ClosureSignatureSyntax.ParameterClause? + switch node.parameterClause { + case .parameterClause(let parameterClause)?: // If the closure input is a complete parameter clause (variables and types), make sure that // nested function types are also rewritten (for example, `label: (Int -> ()) -> ()` should // become `label: (Int -> Void) -> Void`). - input = .input(visit(parameterClause)) + closureParameterClause = .parameterClause(visit(parameterClause)) default: // Otherwise, it's a simple signature (just variable names, no types), so there is nothing to // rewrite. - input = node.input + closureParameterClause = node.parameterClause } let voidKeyword = makeVoidIdentifierType(toReplace: returnType) - return node.withInput(input).withOutput(output.withReturnType(TypeSyntax(voidKeyword))) + + var newReturnClause = returnClause + newReturnClause.type = TypeSyntax(voidKeyword) + + var result = node + result.parameterClause = closureParameterClause + result.returnClause = newReturnClause + return result } /// Returns a value indicating whether the leading trivia of the given token contained any @@ -85,8 +98,7 @@ public final class ReturnVoidInsteadOfEmptyTuple: SyntaxFormatRule { private func hasNonWhitespaceTrivia(_ token: TokenSyntax, at position: TriviaPosition) -> Bool { for piece in position == .leading ? token.leadingTrivia : token.trailingTrivia { switch piece { - case .blockComment, .docBlockComment, .docLineComment, .unexpectedText, .lineComment, - .shebang: + case .blockComment, .docBlockComment, .docLineComment, .unexpectedText, .lineComment: return true default: break @@ -97,17 +109,18 @@ public final class ReturnVoidInsteadOfEmptyTuple: SyntaxFormatRule { /// Returns a type syntax node with the identifier `Void` whose leading and trailing trivia have /// been copied from the tuple type syntax node it is replacing. - private func makeVoidIdentifierType(toReplace node: TupleTypeSyntax) -> SimpleTypeIdentifierSyntax - { - return SimpleTypeIdentifierSyntax( + private func makeVoidIdentifierType(toReplace node: TupleTypeSyntax) -> IdentifierTypeSyntax { + return IdentifierTypeSyntax( name: TokenSyntax.identifier( "Void", - leadingTrivia: node.firstToken?.leadingTrivia ?? [], - trailingTrivia: node.lastToken?.trailingTrivia ?? []), - genericArgumentClause: nil) + leadingTrivia: node.firstToken(viewMode: .sourceAccurate)?.leadingTrivia ?? [], + trailingTrivia: node.lastToken(viewMode: .sourceAccurate)?.trailingTrivia ?? [] + ), + genericArgumentClause: nil + ) } } extension Finding.Message { - public static let returnVoid: Finding.Message = "replace '()' with 'Void'" + fileprivate static let returnVoid: Finding.Message = "replace '()' with 'Void'" } diff --git a/Sources/SwiftFormat/Rules/TypeNamesShouldBeCapitalized.swift b/Sources/SwiftFormat/Rules/TypeNamesShouldBeCapitalized.swift new file mode 100644 index 000000000..44912bad7 --- /dev/null +++ b/Sources/SwiftFormat/Rules/TypeNamesShouldBeCapitalized.swift @@ -0,0 +1,77 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import SwiftSyntax + +/// `struct`, `class`, `enum` and `protocol` declarations should have a capitalized name. +/// +/// Lint: Types with un-capitalized names will yield a lint error. +@_spi(Rules) +public final class TypeNamesShouldBeCapitalized: SyntaxLintRule { + public override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind { + diagnoseNameConventionMismatch(node, name: node.name, kind: "struct") + return .visitChildren + } + + public override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind { + diagnoseNameConventionMismatch(node, name: node.name, kind: "class") + return .visitChildren + } + + public override func visit(_ node: EnumDeclSyntax) -> SyntaxVisitorContinueKind { + diagnoseNameConventionMismatch(node, name: node.name, kind: "enum") + return .visitChildren + } + + public override func visit(_ node: ProtocolDeclSyntax) -> SyntaxVisitorContinueKind { + diagnoseNameConventionMismatch(node, name: node.name, kind: "protocol") + return .visitChildren + } + + public override func visit(_ node: ActorDeclSyntax) -> SyntaxVisitorContinueKind { + diagnoseNameConventionMismatch(node, name: node.name, kind: "actor") + return .visitChildren + } + + public override func visit(_ node: AssociatedTypeDeclSyntax) -> SyntaxVisitorContinueKind { + diagnoseNameConventionMismatch(node, name: node.name, kind: "associated type") + return .visitChildren + } + + public override func visit(_ node: TypeAliasDeclSyntax) -> SyntaxVisitorContinueKind { + diagnoseNameConventionMismatch(node, name: node.name, kind: "type alias") + return .visitChildren + } + + private func diagnoseNameConventionMismatch( + _ type: T, + name: TokenSyntax, + kind: String + ) { + let leadingUnderscores = name.text.prefix { $0 == "_" } + if let firstChar = name.text[leadingUnderscores.endIndex...].first, + firstChar.uppercased() != String(firstChar) + { + diagnose(.capitalizeTypeName(name: name.text, kind: kind), on: name, severity: .convention) + } + } +} + +extension Finding.Message { + fileprivate static func capitalizeTypeName(name: String, kind: String) -> Finding.Message { + var capitalized = name + let leadingUnderscores = capitalized.prefix { $0 == "_" } + let charAt = leadingUnderscores.endIndex + capitalized.replaceSubrange(charAt...charAt, with: capitalized[charAt].uppercased()) + return "rename the \(kind) '\(name)' using UpperCamelCase; for example, '\(capitalized)'" + } +} diff --git a/Sources/SwiftFormatRules/UseEarlyExits.swift b/Sources/SwiftFormat/Rules/UseEarlyExits.swift similarity index 58% rename from Sources/SwiftFormatRules/UseEarlyExits.swift rename to Sources/SwiftFormat/Rules/UseEarlyExits.swift index c2c0f62ac..3f1e53912 100644 --- a/Sources/SwiftFormatRules/UseEarlyExits.swift +++ b/Sources/SwiftFormat/Rules/UseEarlyExits.swift @@ -10,7 +10,6 @@ // //===----------------------------------------------------------------------===// -import SwiftFormatCore import SwiftSyntax /// Early exits should be used whenever possible. @@ -42,6 +41,7 @@ import SwiftSyntax /// /// Format: `if ... else { return/throw/break/continue }` constructs will be replaced with /// equivalent `guard ... else { return/throw/break/continue }` constructs. +@_spi(Rules) public final class UseEarlyExits: SyntaxFormatRule { /// Identifies this rule as being opt-in. This rule is experimental and not yet stable enough to @@ -49,41 +49,44 @@ public final class UseEarlyExits: SyntaxFormatRule { public override class var isOptIn: Bool { return true } public override func visit(_ node: CodeBlockItemListSyntax) -> CodeBlockItemListSyntax { - // Continue recursing down the tree first, so that any nested/child nodes get transformed first. - let codeBlockItems = super.visit(node) - - let result = CodeBlockItemListSyntax( - codeBlockItems.flatMap { (codeBlockItem: CodeBlockItemSyntax) -> [CodeBlockItemSyntax] in - // The `elseBody` of an `IfStmtSyntax` will be a `CodeBlockSyntax` if it's an `else` block, - // or another `IfStmtSyntax` if it's an `else if` block. We only want to handle the former. - guard let ifStatement = codeBlockItem.item.as(IfStmtSyntax.self), - let elseBody = ifStatement.elseBody?.as(CodeBlockSyntax.self), - codeBlockEndsWithEarlyExit(elseBody) - else { - return [codeBlockItem] - } + var newItems = [CodeBlockItemSyntax]() - diagnose(.useGuardStatement, on: ifStatement.elseKeyword) + for codeBlockItem in node { + // The `elseBody` of an `IfExprSyntax` will be a `CodeBlockSyntax` if it's an `else` block, + // or another `IfExprSyntax` if it's an `else if` block. We only want to handle the former. + guard + let exprStmt = codeBlockItem.item.as(ExpressionStmtSyntax.self), + let ifStatement = exprStmt.expression.as(IfExprSyntax.self), + let elseBody = ifStatement.elseBody?.as(CodeBlockSyntax.self), + codeBlockEndsWithEarlyExit(elseBody) + else { + newItems.append(visit(codeBlockItem)) + continue + } + + diagnose(.useGuardStatement, on: ifStatement) + + let guardKeyword = TokenSyntax.keyword( + .guard, + leadingTrivia: ifStatement.ifKeyword.leadingTrivia, + trailingTrivia: .spaces(1) + ) + let guardStatement = GuardStmtSyntax( + guardKeyword: guardKeyword, + conditions: ifStatement.conditions, + elseKeyword: TokenSyntax.keyword(.else, trailingTrivia: .spaces(1)), + body: visit(elseBody) + ) - let trueBlock = ifStatement.body.withLeftBrace(nil).withRightBrace(nil) + newItems.append(CodeBlockItemSyntax(item: .stmt(StmtSyntax(guardStatement)))) - let guardKeyword = TokenSyntax.guardKeyword( - leadingTrivia: ifStatement.ifKeyword.leadingTrivia, - trailingTrivia: .spaces(1)) - let guardStatement = GuardStmtSyntax( - guardKeyword: guardKeyword, - conditions: ifStatement.conditions, - elseKeyword: TokenSyntax.elseKeyword(trailingTrivia: .spaces(1)), - body: elseBody) + let trueBlock = visit(ifStatement.body) + for trueStmt in trueBlock.statements { + newItems.append(trueStmt) + } + } - var items = [ - CodeBlockItemSyntax( - item: .stmt(StmtSyntax(guardStatement)), semicolon: nil, errorTokens: nil), - ] - items.append(contentsOf: trueBlock.statements) - return items - }) - return result + return CodeBlockItemListSyntax(newItems) } /// Returns true if the last statement in the given code block is one that will cause an early @@ -106,6 +109,6 @@ public final class UseEarlyExits: SyntaxFormatRule { } extension Finding.Message { - public static let useGuardStatement: Finding.Message = - "replace the `if/else` block with a `guard` statement containing the early exit" + fileprivate static let useGuardStatement: Finding.Message = + "replace this 'if/else' block with a 'guard' statement containing the early exit" } diff --git a/Sources/SwiftFormat/Rules/UseExplicitNilCheckInConditions.swift b/Sources/SwiftFormat/Rules/UseExplicitNilCheckInConditions.swift new file mode 100644 index 000000000..6e7e3e6c0 --- /dev/null +++ b/Sources/SwiftFormat/Rules/UseExplicitNilCheckInConditions.swift @@ -0,0 +1,125 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import SwiftSyntax +import SwiftSyntaxBuilder + +/// When checking an optional value for `nil`-ness, prefer writing an explicit `nil` check rather +/// than binding and immediately discarding the value. +/// +/// For example, `if let _ = someValue { ... }` is forbidden. Use `if someValue != nil { ... }` +/// instead. +/// +/// Lint: `let _ = expr` inside a condition list will yield a lint error. +/// +/// Format: `let _ = expr` inside a condition list will be replaced by `expr != nil`. +@_spi(Rules) +public final class UseExplicitNilCheckInConditions: SyntaxFormatRule { + public override func visit(_ node: ConditionElementSyntax) -> ConditionElementSyntax { + switch node.condition { + case .optionalBinding(let optionalBindingCondition): + guard + let initializerClause = optionalBindingCondition.initializer, + isDiscardedAssignmentPattern(optionalBindingCondition.pattern) + else { + return node + } + + diagnose(.useExplicitNilComparison, on: optionalBindingCondition) + + // Since we're moving the initializer value from the RHS to the LHS of an expression/pattern, + // preserve the relative position of the trailing trivia. Similarly, preserve the leading + // trivia of the original node, since that token is being removed entirely. + var value = initializerClause.value + let trailingTrivia = value.trailingTrivia + value.trailingTrivia = [.spaces(1)] + + var operatorExpr = BinaryOperatorExprSyntax(text: "!=") + operatorExpr.trailingTrivia = [.spaces(1)] + + var inequalExpr = InfixOperatorExprSyntax( + leftOperand: addingParenthesesIfNecessary(to: value), + operator: operatorExpr, + rightOperand: NilLiteralExprSyntax() + ) + inequalExpr.leadingTrivia = node.leadingTrivia + inequalExpr.trailingTrivia = trailingTrivia + + var result = node + result.condition = .expression(ExprSyntax(inequalExpr)) + return result + default: + return node + } + } + + /// Returns true if the given pattern is a discarding assignment expression (for example, the `_` + /// in `let _ = x`). + private func isDiscardedAssignmentPattern(_ pattern: PatternSyntax) -> Bool { + guard let exprPattern = pattern.as(ExpressionPatternSyntax.self) else { + return false + } + return exprPattern.expression.is(DiscardAssignmentExprSyntax.self) + } + + /// Adds parentheses around the given expression if necessary to ensure that it will be parsed + /// correctly when followed by `!= nil`. + /// + /// Specifically, if `expr` is a `try` expression, ternary expression, or an infix operator with + /// the same or lower precedence, we wrap it. + private func addingParenthesesIfNecessary(to expr: ExprSyntax) -> ExprSyntax { + func addingParentheses(to expr: ExprSyntax) -> ExprSyntax { + var expr = expr + let leadingTrivia = expr.leadingTrivia + let trailingTrivia = expr.trailingTrivia + expr.leadingTrivia = [] + expr.trailingTrivia = [] + + var tupleExpr = TupleExprSyntax(elements: [LabeledExprSyntax(expression: expr)]) + tupleExpr.leadingTrivia = leadingTrivia + tupleExpr.trailingTrivia = trailingTrivia + return ExprSyntax(tupleExpr) + } + + switch Syntax(expr).as(SyntaxEnum.self) { + case .tryExpr, .ternaryExpr: + return addingParentheses(to: expr) + + case .infixOperatorExpr: + // There's no public API in SwiftSyntax to get the relationship between two precedence groups. + // Until that exists, here's a workaround I'm only mildly ashamed of: we reparse + // "\(expr) != nil" and then fold it. If the top-level node is anything but an + // `InfixOperatorExpr` whose operator is `!=` and whose RHS is `nil`, then it parsed + // incorrectly and we need to add parentheses around `expr`. + // + // Note that we could also cover the `tryExpr` and `ternaryExpr` cases above with this, but + // this reparsing trick is going to be slower so we should avoid it whenever we can. + let reparsedExpr = "\(expr) != nil" as ExprSyntax + if let infixExpr = reparsedExpr.as(InfixOperatorExprSyntax.self), + let binOp = infixExpr.operator.as(BinaryOperatorExprSyntax.self), + binOp.operator.text == "!=", + infixExpr.rightOperand.is(NilLiteralExprSyntax.self) + { + return expr + } + return addingParentheses(to: expr) + + default: + return expr + } + } +} + +extension Finding.Message { + fileprivate static let useExplicitNilComparison: Finding.Message = + "compare this value using `!= nil` instead of binding and discarding it" +} diff --git a/Sources/SwiftFormatRules/UseLetInEveryBoundCaseVariable.swift b/Sources/SwiftFormat/Rules/UseLetInEveryBoundCaseVariable.swift similarity index 89% rename from Sources/SwiftFormatRules/UseLetInEveryBoundCaseVariable.swift rename to Sources/SwiftFormat/Rules/UseLetInEveryBoundCaseVariable.swift index 698643c30..840a8496f 100644 --- a/Sources/SwiftFormatRules/UseLetInEveryBoundCaseVariable.swift +++ b/Sources/SwiftFormat/Rules/UseLetInEveryBoundCaseVariable.swift @@ -10,7 +10,6 @@ // //===----------------------------------------------------------------------===// -import SwiftFormatCore import SwiftSyntax /// Every variable bound in a `case` pattern must have its own `let/var`. @@ -19,12 +18,13 @@ import SwiftSyntax /// `case .identifier(let x, let y)` instead. /// /// Lint: `case let .identifier(...)` will yield a lint error. +@_spi(Rules) public final class UseLetInEveryBoundCaseVariable: SyntaxLintRule { public override func visit(_ node: ValueBindingPatternSyntax) -> SyntaxVisitorContinueKind { // Diagnose a pattern binding if it is a function call and the callee is a member access // expression (e.g., `case let .x(y)` or `case let T.x(y)`). - if canDistributeLetVarThroughPattern(node.valuePattern) { + if canDistributeLetVarThroughPattern(node.pattern) { diagnose(.useLetInBoundCaseVariables, on: node) } return .visitChildren @@ -40,7 +40,7 @@ public final class UseLetInEveryBoundCaseVariable: SyntaxLintRule { while true { if let optionalExpr = expression.as(OptionalChainingExprSyntax.self) { expression = optionalExpr.expression - } else if let forcedExpr = expression.as(ForcedValueExprSyntax.self) { + } else if let forcedExpr = expression.as(ForceUnwrapExprSyntax.self) { expression = forcedExpr.expression } else { break @@ -66,6 +66,6 @@ public final class UseLetInEveryBoundCaseVariable: SyntaxLintRule { } extension Finding.Message { - public static let useLetInBoundCaseVariables: Finding.Message = - "move 'let' keyword to precede each variable bound in the `case` pattern" + fileprivate static let useLetInBoundCaseVariables: Finding.Message = + "move this 'let' keyword inside the 'case' pattern, before each of the bound variables" } diff --git a/Sources/SwiftFormatRules/UseShorthandTypeNames.swift b/Sources/SwiftFormat/Rules/UseShorthandTypeNames.swift similarity index 61% rename from Sources/SwiftFormatRules/UseShorthandTypeNames.swift rename to Sources/SwiftFormat/Rules/UseShorthandTypeNames.swift index 1a57c415a..28d048fac 100644 --- a/Sources/SwiftFormatRules/UseShorthandTypeNames.swift +++ b/Sources/SwiftFormat/Rules/UseShorthandTypeNames.swift @@ -10,7 +10,6 @@ // //===----------------------------------------------------------------------===// -import SwiftFormatCore import SwiftSyntax /// Shorthand type forms must be used wherever possible. @@ -20,9 +19,10 @@ import SwiftSyntax /// /// Format: Where possible, shorthand types replace long form types; e.g. `Array` is /// converted to `[Element]`. +@_spi(Rules) public final class UseShorthandTypeNames: SyntaxFormatRule { - public override func visit(_ node: SimpleTypeIdentifierSyntax) -> TypeSyntax { + public override func visit(_ node: IdentifierTypeSyntax) -> TypeSyntax { // Ignore types that don't have generic arguments. guard let genericArgumentClause = node.genericArgumentClause else { return super.visit(node) @@ -34,7 +34,7 @@ public final class UseShorthandTypeNames: SyntaxFormatRule { // `Foo>.Bar` can still be transformed to `Foo<[Int]>.Bar` because the member // reference is not directly attached to the type that will be transformed, but we need to visit // the children so that we don't skip this). - guard let parent = node.parent, !parent.is(MemberTypeIdentifierSyntax.self) else { + guard let parent = node.parent, !parent.is(MemberTypeSyntax.self) else { return super.visit(node) } @@ -47,39 +47,44 @@ public final class UseShorthandTypeNames: SyntaxFormatRule { switch node.name.text { case "Array": - guard let typeArgument = genericArgumentList.firstAndOnly else { + guard case .type(let typeArgument) = genericArgumentList.firstAndOnly?.argument else { newNode = nil break } newNode = shorthandArrayType( - element: typeArgument.argumentType, + element: typeArgument, leadingTrivia: leadingTrivia, - trailingTrivia: trailingTrivia) + trailingTrivia: trailingTrivia + ) case "Dictionary": - guard let typeArguments = exactlyTwoChildren(of: genericArgumentList) else { + guard let arguments = exactlyTwoChildren(of: genericArgumentList), + case (.type(let type0Argument), .type(let type1Argument)) = (arguments.0.argument, arguments.1.argument) + else { newNode = nil break } newNode = shorthandDictionaryType( - key: typeArguments.0.argumentType, - value: typeArguments.1.argumentType, + key: type0Argument, + value: type1Argument, leadingTrivia: leadingTrivia, - trailingTrivia: trailingTrivia) + trailingTrivia: trailingTrivia + ) case "Optional": guard !isTypeOfUninitializedStoredVar(node) else { newNode = nil break } - guard let typeArgument = genericArgumentList.firstAndOnly else { + guard case .type(let typeArgument) = genericArgumentList.firstAndOnly?.argument else { newNode = nil break } newNode = shorthandOptionalType( - wrapping: typeArgument.argumentType, + wrapping: typeArgument, leadingTrivia: leadingTrivia, - trailingTrivia: trailingTrivia) + trailingTrivia: trailingTrivia + ) default: newNode = nil @@ -93,20 +98,23 @@ public final class UseShorthandTypeNames: SyntaxFormatRule { // Even if we don't shorten this specific type that we're visiting, we may have rewritten // something in the generic argument list that we recursively visited, so return the original // node with that swapped out. - let result = node.withGenericArgumentClause( - genericArgumentClause.withArguments(genericArgumentList)) + var newGenericArgumentClause = genericArgumentClause + newGenericArgumentClause.arguments = genericArgumentList + + var result = node + result.genericArgumentClause = newGenericArgumentClause return TypeSyntax(result) } - public override func visit(_ node: SpecializeExprSyntax) -> ExprSyntax { - // `SpecializeExpr`s are found in the syntax tree when a generic type is encountered in an - // expression context, such as `Array()`. In these situations, the corresponding array and - // dictionary shorthand nodes will be expression nodes, not type nodes, so we may need to - // translate the arguments inside the generic argument list---which are types---to the - // appropriate equivalent. + public override func visit(_ node: GenericSpecializationExprSyntax) -> ExprSyntax { + // `GenericSpecializationExprSyntax`s are found in the syntax tree when a generic type is + // encountered in an expression context, such as `Array()`. In these situations, the + // corresponding array and dictionary shorthand nodes will be expression nodes, not type nodes, + // so we may need to translate the arguments inside the generic argument list---which are + // types---to the appropriate equivalent. // Ignore nodes where the expression being specialized isn't a simple identifier. - guard let expression = node.expression.as(IdentifierExprSyntax.self) else { + guard let expression = node.expression.as(DeclReferenceExprSyntax.self) else { return super.visit(node) } @@ -124,48 +132,50 @@ public final class UseShorthandTypeNames: SyntaxFormatRule { // Ensure that all arguments in the clause are shortened and in the expected format by visiting // the argument list, first. - let genericArgumentList = - visit(node.genericArgumentClause.arguments) + let genericArgumentList = visit(node.genericArgumentClause.arguments) let (leadingTrivia, trailingTrivia) = boundaryTrivia(around: Syntax(node)) let newNode: ExprSyntax? - switch expression.identifier.text { + switch expression.baseName.text { case "Array": - guard let typeArgument = genericArgumentList.firstAndOnly else { + guard case .type(let typeArgument) = genericArgumentList.firstAndOnly?.argument else { newNode = nil break } let arrayTypeExpr = makeArrayTypeExpression( - elementType: typeArgument.argumentType, - leftSquareBracket: TokenSyntax.leftSquareBracketToken(leadingTrivia: leadingTrivia), - rightSquareBracket: - TokenSyntax.rightSquareBracketToken(trailingTrivia: trailingTrivia)) + elementType: typeArgument, + leftSquare: TokenSyntax.leftSquareToken(leadingTrivia: leadingTrivia), + rightSquare: TokenSyntax.rightSquareToken(trailingTrivia: trailingTrivia) + ) newNode = ExprSyntax(arrayTypeExpr) case "Dictionary": - guard let typeArguments = exactlyTwoChildren(of: genericArgumentList) else { + guard let arguments = exactlyTwoChildren(of: genericArgumentList), + case (.type(let type0Argument), .type(let type1Argument)) = (arguments.0.argument, arguments.1.argument) + else { newNode = nil break } let dictTypeExpr = makeDictionaryTypeExpression( - keyType: typeArguments.0.argumentType, - valueType: typeArguments.1.argumentType, - leftSquareBracket: TokenSyntax.leftSquareBracketToken(leadingTrivia: leadingTrivia), + keyType: type0Argument, + valueType: type1Argument, + leftSquare: TokenSyntax.leftSquareToken(leadingTrivia: leadingTrivia), colon: TokenSyntax.colonToken(trailingTrivia: .spaces(1)), - rightSquareBracket: - TokenSyntax.rightSquareBracketToken(trailingTrivia: trailingTrivia)) + rightSquare: TokenSyntax.rightSquareToken(trailingTrivia: trailingTrivia) + ) newNode = ExprSyntax(dictTypeExpr) case "Optional": - guard let typeArgument = genericArgumentList.firstAndOnly else { + guard case .type(let typeArgument) = genericArgumentList.firstAndOnly?.argument else { newNode = nil break } let optionalTypeExpr = makeOptionalTypeExpression( - wrapping: typeArgument.argumentType, + wrapping: typeArgument, leadingTrivia: leadingTrivia, - questionMark: TokenSyntax.postfixQuestionMarkToken(trailingTrivia: trailingTrivia)) + questionMark: TokenSyntax.postfixQuestionMarkToken(trailingTrivia: trailingTrivia) + ) newNode = ExprSyntax(optionalTypeExpr) default: @@ -173,23 +183,23 @@ public final class UseShorthandTypeNames: SyntaxFormatRule { } if let newNode = newNode { - diagnose(.useTypeShorthand(type: expression.identifier.text), on: expression) + diagnose(.useTypeShorthand(type: expression.baseName.text), on: expression) return newNode } // Even if we don't shorten this specific expression that we're visiting, we may have // rewritten something in the generic argument list that we recursively visited, so return the // original node with that swapped out. - let result = node.withGenericArgumentClause( - node.genericArgumentClause.withArguments(genericArgumentList)) + var result = node + result.genericArgumentClause.arguments = genericArgumentList return ExprSyntax(result) } /// Returns the two arguments in the given argument list, if there are exactly two elements; /// otherwise, it returns nil. - private func exactlyTwoChildren(of argumentList: GenericArgumentListSyntax) - -> (GenericArgumentSyntax, GenericArgumentSyntax)? - { + private func exactlyTwoChildren( + of argumentList: GenericArgumentListSyntax + ) -> (GenericArgumentSyntax, GenericArgumentSyntax)? { var iterator = argumentList.makeIterator() guard let first = iterator.next() else { return nil } guard let second = iterator.next() else { return nil } @@ -205,9 +215,10 @@ public final class UseShorthandTypeNames: SyntaxFormatRule { trailingTrivia: Trivia ) -> TypeSyntax { let result = ArrayTypeSyntax( - leftSquareBracket: TokenSyntax.leftSquareBracketToken(leadingTrivia: leadingTrivia), - elementType: element, - rightSquareBracket: TokenSyntax.rightSquareBracketToken(trailingTrivia: trailingTrivia)) + leftSquare: TokenSyntax.leftSquareToken(leadingTrivia: leadingTrivia), + element: element, + rightSquare: TokenSyntax.rightSquareToken(trailingTrivia: trailingTrivia) + ) return TypeSyntax(result) } @@ -220,11 +231,12 @@ public final class UseShorthandTypeNames: SyntaxFormatRule { trailingTrivia: Trivia ) -> TypeSyntax { let result = DictionaryTypeSyntax( - leftSquareBracket: TokenSyntax.leftSquareBracketToken(leadingTrivia: leadingTrivia), - keyType: key, + leftSquare: TokenSyntax.leftSquareToken(leadingTrivia: leadingTrivia), + key: key, colon: TokenSyntax.colonToken(trailingTrivia: .spaces(1)), - valueType: value, - rightSquareBracket: TokenSyntax.rightSquareBracketToken(trailingTrivia: trailingTrivia)) + value: value, + rightSquare: TokenSyntax.rightSquareToken(trailingTrivia: trailingTrivia) + ) return TypeSyntax(result) } @@ -238,32 +250,28 @@ public final class UseShorthandTypeNames: SyntaxFormatRule { ) -> TypeSyntax { var wrappedType = wrappedType - if let functionType = wrappedType.as(FunctionTypeSyntax.self) { - // Function types must be wrapped as a tuple before using shorthand optional syntax, - // otherwise the "?" applies to the return type instead of the function type. Attach the - // leading trivia to the left-paren that we're adding in this case. - let tupleTypeElement = TupleTypeElementSyntax( - inOut: nil, name: nil, secondName: nil, colon: nil, type: TypeSyntax(functionType), - ellipsis: nil, initializer: nil, trailingComma: nil) - let tupleTypeElementList = TupleTypeElementListSyntax([tupleTypeElement]) - let tupleType = TupleTypeSyntax( - leftParen: TokenSyntax.leftParenToken(leadingTrivia: leadingTrivia), - elements: tupleTypeElementList, - rightParen: TokenSyntax.rightParenToken()) - wrappedType = TypeSyntax(tupleType) - } else { + // Certain types must be wrapped in parentheses before using shorthand optional syntax to avoid + // the "?" from binding incorrectly when re-parsed. Attach the leading trivia to the left-paren + // that we're adding in these cases. + switch Syntax(wrappedType).as(SyntaxEnum.self) { + case .attributedType(let attributedType): + wrappedType = parenthesizedType(attributedType, leadingTrivia: leadingTrivia) + case .functionType(let functionType): + wrappedType = parenthesizedType(functionType, leadingTrivia: leadingTrivia) + case .someOrAnyType(let someOrAnyType): + wrappedType = parenthesizedType(someOrAnyType, leadingTrivia: leadingTrivia) + default: // Otherwise, the argument type can safely become an optional by simply appending a "?", but // we need to transfer the leading trivia from the original `Optional` token over to it. // By doing so, something like `/* comment */ Optional` will become `/* comment */ Foo?` // instead of discarding the comment. - wrappedType = - replaceTrivia( - on: wrappedType, token: wrappedType.firstToken, leadingTrivia: leadingTrivia) + wrappedType.leadingTrivia = leadingTrivia } let optionalType = OptionalTypeSyntax( wrappedType: wrappedType, - questionMark: TokenSyntax.postfixQuestionMarkToken(trailingTrivia: trailingTrivia)) + questionMark: TokenSyntax.postfixQuestionMarkToken(trailingTrivia: trailingTrivia) + ) return TypeSyntax(optionalType) } @@ -272,18 +280,19 @@ public final class UseShorthandTypeNames: SyntaxFormatRule { /// have a valid expression representation. private func makeArrayTypeExpression( elementType: TypeSyntax, - leftSquareBracket: TokenSyntax, - rightSquareBracket: TokenSyntax + leftSquare: TokenSyntax, + rightSquare: TokenSyntax ) -> ArrayExprSyntax? { guard let elementTypeExpr = expressionRepresentation(of: elementType) else { return nil } return ArrayExprSyntax( - leftSquare: leftSquareBracket, + leftSquare: leftSquare, elements: ArrayElementListSyntax([ - ArrayElementSyntax(expression: elementTypeExpr, trailingComma: nil), + ArrayElementSyntax(expression: elementTypeExpr, trailingComma: nil) ]), - rightSquare: rightSquareBracket) + rightSquare: rightSquare + ) } /// Returns a `DictionaryExprSyntax` whose single key/value pair is the expression representations @@ -292,9 +301,9 @@ public final class UseShorthandTypeNames: SyntaxFormatRule { private func makeDictionaryTypeExpression( keyType: TypeSyntax, valueType: TypeSyntax, - leftSquareBracket: TokenSyntax, + leftSquare: TokenSyntax, colon: TokenSyntax, - rightSquareBracket: TokenSyntax + rightSquare: TokenSyntax ) -> DictionaryExprSyntax? { guard let keyTypeExpr = expressionRepresentation(of: keyType), @@ -304,15 +313,17 @@ public final class UseShorthandTypeNames: SyntaxFormatRule { } let dictElementList = DictionaryElementListSyntax([ DictionaryElementSyntax( - keyExpression: keyTypeExpr, + key: keyTypeExpr, colon: colon, - valueExpression: valueTypeExpr, - trailingComma: nil), + value: valueTypeExpr, + trailingComma: nil + ) ]) return DictionaryExprSyntax( - leftSquare: leftSquareBracket, + leftSquare: leftSquare, content: .elements(dictElementList), - rightSquare: rightSquareBracket) + rightSquare: rightSquare + ) } /// Returns an `OptionalChainingExprSyntax` whose wrapped expression is the expression @@ -320,38 +331,58 @@ public final class UseShorthandTypeNames: SyntaxFormatRule { /// key type or value type does not have a valid expression representation. private func makeOptionalTypeExpression( wrapping wrappedType: TypeSyntax, - leadingTrivia: Trivia? = nil, + leadingTrivia: Trivia = [], questionMark: TokenSyntax ) -> OptionalChainingExprSyntax? { guard var wrappedTypeExpr = expressionRepresentation(of: wrappedType) else { return nil } - if wrappedType.is(FunctionTypeSyntax.self) { - // Function types must be wrapped as a tuple before using shorthand optional syntax, - // otherwise the "?" applies to the return type instead of the function type. Attach the - // leading trivia to the left-paren that we're adding in this case. - let tupleExprElement = - TupleExprElementSyntax( - label: nil, colon: nil, expression: wrappedTypeExpr, trailingComma: nil) - let tupleExprElementList = TupleExprElementListSyntax([tupleExprElement]) - let tupleExpr = TupleExprSyntax( - leftParen: TokenSyntax.leftParenToken(leadingTrivia: leadingTrivia ?? []), - elementList: tupleExprElementList, - rightParen: TokenSyntax.rightParenToken()) - wrappedTypeExpr = ExprSyntax(tupleExpr) - } else if let leadingTrivia = leadingTrivia { + // Certain types must be wrapped in parentheses before using shorthand optional syntax to avoid + // the "?" from binding incorrectly when re-parsed. Attach the leading trivia to the left-paren + // that we're adding in these cases. + switch Syntax(wrappedType).as(SyntaxEnum.self) { + case .attributedType, .functionType, .someOrAnyType: + wrappedTypeExpr = parenthesizedExpr(wrappedTypeExpr, leadingTrivia: leadingTrivia) + default: // Otherwise, the argument type can safely become an optional by simply appending a "?". If // we were given leading trivia from another node (for example, from `Optional` when // converting a long-form to short-form), we need to transfer it over. By doing so, something // like `/* comment */ Optional` will become `/* comment */ Foo?` instead of discarding // the comment. - wrappedTypeExpr = - replaceTrivia( - on: wrappedTypeExpr, token: wrappedTypeExpr.firstToken, leadingTrivia: leadingTrivia) + wrappedTypeExpr.leadingTrivia = leadingTrivia } return OptionalChainingExprSyntax( expression: wrappedTypeExpr, - questionMark: questionMark) + questionMark: questionMark + ) + } + + /// Returns the given type wrapped in parentheses. + private func parenthesizedType( + _ typeToWrap: TypeNode, + leadingTrivia: Trivia + ) -> TypeSyntax { + let tupleTypeElement = TupleTypeElementSyntax(type: TypeSyntax(typeToWrap)) + let tupleType = TupleTypeSyntax( + leftParen: .leftParenToken(leadingTrivia: leadingTrivia), + elements: TupleTypeElementListSyntax([tupleTypeElement]), + rightParen: .rightParenToken() + ) + return TypeSyntax(tupleType) + } + + /// Returns the given expression wrapped in parentheses. + private func parenthesizedExpr( + _ exprToWrap: ExprNode, + leadingTrivia: Trivia + ) -> ExprSyntax { + let tupleExprElement = LabeledExprSyntax(expression: exprToWrap) + let tupleExpr = TupleExprSyntax( + leftParen: .leftParenToken(leadingTrivia: leadingTrivia), + elements: LabeledExprListSyntax([tupleExprElement]), + rightParen: .rightParenToken() + ) + return ExprSyntax(tupleExpr) } /// Returns an `ExprSyntax` that is syntactically equivalent to the given `TypeSyntax`, or nil if @@ -362,67 +393,71 @@ public final class UseShorthandTypeNames: SyntaxFormatRule { /// written `Array.Index` to compile correctly. private func expressionRepresentation(of type: TypeSyntax) -> ExprSyntax? { switch Syntax(type).as(SyntaxEnum.self) { - case .simpleTypeIdentifier(let simpleTypeIdentifier): - let identifierExpr = IdentifierExprSyntax( - identifier: simpleTypeIdentifier.name, - declNameArguments: nil) + case .identifierType(let simpleTypeIdentifier): + let identifierExpr = DeclReferenceExprSyntax( + baseName: simpleTypeIdentifier.name, + argumentNames: nil + ) // If the type has a generic argument clause, we need to construct a `SpecializeExpr` to wrap // the identifier and the generic arguments. Otherwise, we can return just the // `IdentifierExpr` itself. if let genericArgumentClause = simpleTypeIdentifier.genericArgumentClause { let newGenericArgumentClause = visit(genericArgumentClause) - let result = SpecializeExprSyntax( + let result = GenericSpecializationExprSyntax( expression: ExprSyntax(identifierExpr), - genericArgumentClause: newGenericArgumentClause) + genericArgumentClause: newGenericArgumentClause + ) return ExprSyntax(result) } else { return ExprSyntax(identifierExpr) } - case .memberTypeIdentifier(let memberTypeIdentifier): + case .memberType(let memberTypeIdentifier): guard let baseType = expressionRepresentation(of: memberTypeIdentifier.baseType) else { return nil } let result = MemberAccessExprSyntax( base: baseType, - dot: memberTypeIdentifier.period, - name: memberTypeIdentifier.name, - declNameArguments: nil) + period: memberTypeIdentifier.period, + name: memberTypeIdentifier.name + ) return ExprSyntax(result) case .arrayType(let arrayType): let result = makeArrayTypeExpression( - elementType: arrayType.elementType, - leftSquareBracket: arrayType.leftSquareBracket, - rightSquareBracket: arrayType.rightSquareBracket) + elementType: arrayType.element, + leftSquare: arrayType.leftSquare, + rightSquare: arrayType.rightSquare + ) return ExprSyntax(result) case .dictionaryType(let dictionaryType): let result = makeDictionaryTypeExpression( - keyType: dictionaryType.keyType, - valueType: dictionaryType.valueType, - leftSquareBracket: dictionaryType.leftSquareBracket, + keyType: dictionaryType.key, + valueType: dictionaryType.value, + leftSquare: dictionaryType.leftSquare, colon: dictionaryType.colon, - rightSquareBracket: dictionaryType.rightSquareBracket) + rightSquare: dictionaryType.rightSquare + ) return ExprSyntax(result) case .optionalType(let optionalType): let result = makeOptionalTypeExpression( wrapping: optionalType.wrappedType, - leadingTrivia: optionalType.firstToken?.leadingTrivia, - questionMark: optionalType.questionMark) + leadingTrivia: optionalType.firstToken(viewMode: .sourceAccurate)?.leadingTrivia ?? [], + questionMark: optionalType.questionMark + ) return ExprSyntax(result) case .functionType(let functionType): let result = makeFunctionTypeExpression( leftParen: functionType.leftParen, - argumentTypes: functionType.arguments, + parameters: functionType.parameters, rightParen: functionType.rightParen, - asyncKeyword: functionType.asyncKeyword, - throwsOrRethrowsKeyword: functionType.throwsOrRethrowsKeyword, - arrow: functionType.arrow, - returnType: functionType.returnType + effectSpecifiers: functionType.effectSpecifiers, + arrow: functionType.returnClause.arrow, + returnType: functionType.returnClause.type ) return ExprSyntax(result) @@ -430,44 +465,52 @@ public final class UseShorthandTypeNames: SyntaxFormatRule { guard let elementExprs = expressionRepresentation(of: tupleType.elements) else { return nil } let result = TupleExprSyntax( leftParen: tupleType.leftParen, - elementList: elementExprs, - rightParen: tupleType.rightParen) + elements: elementExprs, + rightParen: tupleType.rightParen + ) return ExprSyntax(result) + case .someOrAnyType(let someOrAnyType): + return ExprSyntax(TypeExprSyntax(type: someOrAnyType)) + + case .attributedType(let attributedType): + return ExprSyntax(TypeExprSyntax(type: attributedType)) + default: return nil } } - private func expressionRepresentation(of tupleTypeElements: TupleTypeElementListSyntax) - -> TupleExprElementListSyntax? - { + private func expressionRepresentation( + of tupleTypeElements: TupleTypeElementListSyntax + ) -> LabeledExprListSyntax? { guard !tupleTypeElements.isEmpty else { return nil } - var exprElements = [TupleExprElementSyntax]() + var exprElements = [LabeledExprSyntax]() for typeElement in tupleTypeElements { guard let elementExpr = expressionRepresentation(of: typeElement.type) else { return nil } exprElements.append( - TupleExprElementSyntax( - label: typeElement.name, + LabeledExprSyntax( + label: typeElement.firstName, colon: typeElement.colon, expression: elementExpr, - trailingComma: typeElement.trailingComma)) + trailingComma: typeElement.trailingComma + ) + ) } - return TupleExprElementListSyntax(exprElements) + return LabeledExprListSyntax(exprElements) } private func makeFunctionTypeExpression( leftParen: TokenSyntax, - argumentTypes: TupleTypeElementListSyntax, + parameters: TupleTypeElementListSyntax, rightParen: TokenSyntax, - asyncKeyword: TokenSyntax?, - throwsOrRethrowsKeyword: TokenSyntax?, + effectSpecifiers: TypeEffectSpecifiersSyntax?, arrow: TokenSyntax, returnType: TypeSyntax - ) -> SequenceExprSyntax? { + ) -> InfixOperatorExprSyntax? { guard - let argumentTypeExprs = expressionRepresentation(of: argumentTypes), + let parameterExprs = expressionRepresentation(of: parameters), let returnTypeExpr = expressionRepresentation(of: returnType) else { return nil @@ -475,57 +518,57 @@ public final class UseShorthandTypeNames: SyntaxFormatRule { let tupleExpr = TupleExprSyntax( leftParen: leftParen, - elementList: argumentTypeExprs, - rightParen: rightParen) + elements: parameterExprs, + rightParen: rightParen + ) let arrowExpr = ArrowExprSyntax( - asyncKeyword: asyncKeyword, - throwsToken: throwsOrRethrowsKeyword, - arrowToken: arrow) - - return SequenceExprSyntax( - elements: ExprListSyntax([ - ExprSyntax(tupleExpr), ExprSyntax(arrowExpr), returnTypeExpr, - ])) + effectSpecifiers: effectSpecifiers, + arrow: arrow + ) + return InfixOperatorExprSyntax( + leftOperand: tupleExpr, + operator: arrowExpr, + rightOperand: returnTypeExpr + ) } /// Returns the leading and trailing trivia from the front and end of the entire given node. /// /// In other words, this is the leading trivia from the first token of the node and the trailing /// trivia from the last token. - private func boundaryTrivia(around node: Syntax) - -> (leadingTrivia: Trivia, trailingTrivia: Trivia) - { + private func boundaryTrivia( + around node: Syntax + ) -> (leadingTrivia: Trivia, trailingTrivia: Trivia) { return ( - leadingTrivia: node.firstToken?.leadingTrivia ?? [], - trailingTrivia: node.lastToken?.trailingTrivia ?? [] + leadingTrivia: node.firstToken(viewMode: .sourceAccurate)?.leadingTrivia ?? [], + trailingTrivia: node.lastToken(viewMode: .sourceAccurate)?.trailingTrivia ?? [] ) } /// Returns true if the given pattern binding represents a stored property/variable (as opposed to /// a computed property/variable). private func isStoredProperty(_ node: PatternBindingSyntax) -> Bool { - guard let accessor = node.accessor else { + guard let accessor = node.accessorBlock else { // If it has no accessors at all, it is definitely a stored property. return true } - guard let accessorBlock = accessor.as(AccessorBlockSyntax.self) else { + guard case .accessors(let accessors) = accessor.accessors else { // If the accessor isn't an `AccessorBlockSyntax`, then it is a `CodeBlockSyntax`; i.e., the // accessor an implicit `get`. So, it is definitely not a stored property. - assert(accessor.is(CodeBlockSyntax.self)) return false } - for accessorDecl in accessorBlock.accessors { + for accessorDecl in accessors { // Look for accessors that indicate that this is a computed property. If none are found, then // it is a stored property (e.g., having only observers like `willSet/didSet`). - switch accessorDecl.accessorKind.tokenKind { - case .contextualKeyword("get"), - .contextualKeyword("set"), - .contextualKeyword("unsafeAddress"), - .contextualKeyword("unsafeMutableAddress"), - .contextualKeyword("_read"), - .contextualKeyword("_modify"): + switch accessorDecl.accessorSpecifier.tokenKind { + case .keyword(.get), + .keyword(.set), + .keyword(.unsafeAddress), + .keyword(.unsafeMutableAddress), + .keyword(._read), + .keyword(._modify): return false default: return true @@ -539,13 +582,13 @@ public final class UseShorthandTypeNames: SyntaxFormatRule { /// Returns true if the given type identifier node represents the type of a mutable variable or /// stored property that does not have an initializer clause. - private func isTypeOfUninitializedStoredVar(_ node: SimpleTypeIdentifierSyntax) -> Bool { + private func isTypeOfUninitializedStoredVar(_ node: IdentifierTypeSyntax) -> Bool { if let typeAnnotation = node.parent?.as(TypeAnnotationSyntax.self), let patternBinding = nearestAncestor(of: typeAnnotation, type: PatternBindingSyntax.self), isStoredProperty(patternBinding), patternBinding.initializer == nil, let variableDecl = nearestAncestor(of: patternBinding, type: VariableDeclSyntax.self), - variableDecl.letOrVarKeyword.tokenKind == .varKeyword + variableDecl.bindingSpecifier.tokenKind == .keyword(.var) { return true } @@ -569,7 +612,7 @@ public final class UseShorthandTypeNames: SyntaxFormatRule { } extension Finding.Message { - public static func useTypeShorthand(type: String) -> Finding.Message { - "use \(type) type shorthand form" + fileprivate static func useTypeShorthand(type: String) -> Finding.Message { + "use shorthand syntax for this '\(type)' type" } } diff --git a/Sources/SwiftFormatRules/UseSingleLinePropertyGetter.swift b/Sources/SwiftFormat/Rules/UseSingleLinePropertyGetter.swift similarity index 63% rename from Sources/SwiftFormatRules/UseSingleLinePropertyGetter.swift rename to Sources/SwiftFormat/Rules/UseSingleLinePropertyGetter.swift index 3cb217026..abc722570 100644 --- a/Sources/SwiftFormatRules/UseSingleLinePropertyGetter.swift +++ b/Sources/SwiftFormat/Rules/UseSingleLinePropertyGetter.swift @@ -10,7 +10,6 @@ // //===----------------------------------------------------------------------===// -import SwiftFormatCore import SwiftSyntax /// Read-only computed properties must use implicit `get` blocks. @@ -18,31 +17,30 @@ import SwiftSyntax /// Lint: Read-only computed properties with explicit `get` blocks yield a lint error. /// /// Format: Explicit `get` blocks are rendered implicit by removing the `get`. +@_spi(Rules) public final class UseSingleLinePropertyGetter: SyntaxFormatRule { public override func visit(_ node: PatternBindingSyntax) -> PatternBindingSyntax { guard - let accessorBlock = node.accessor?.as(AccessorBlockSyntax.self), - let acc = accessorBlock.accessors.first, + let accessorBlock = node.accessorBlock, + case .accessors(let accessors) = accessorBlock.accessors, + let acc = accessors.first, let body = acc.body, - accessorBlock.accessors.count == 1, - acc.accessorKind.tokenKind == .contextualKeyword("get"), - acc.attributes == nil, + accessors.count == 1, + acc.accessorSpecifier.tokenKind == .keyword(.get), acc.modifier == nil, - acc.asyncKeyword == nil, - acc.throwsKeyword == nil + acc.effectSpecifiers == nil else { return node } diagnose(.removeExtraneousGetBlock, on: acc) - let newBlock = CodeBlockSyntax( - leftBrace: accessorBlock.leftBrace, statements: body.statements, - rightBrace: accessorBlock.rightBrace) - return node.withAccessor(.getter(newBlock)) + var result = node + result.accessorBlock?.accessors = .getter(body.statements) + return result } } extension Finding.Message { - public static let removeExtraneousGetBlock: Finding.Message = - "remove extraneous 'get {}' block" + fileprivate static let removeExtraneousGetBlock: Finding.Message = + "remove 'get {...}' around the accessor and move its body directly into the computed property" } diff --git a/Sources/SwiftFormatRules/UseSynthesizedInitializer.swift b/Sources/SwiftFormat/Rules/UseSynthesizedInitializer.swift similarity index 68% rename from Sources/SwiftFormatRules/UseSynthesizedInitializer.swift rename to Sources/SwiftFormat/Rules/UseSynthesizedInitializer.swift index c514820d2..a523dd47c 100644 --- a/Sources/SwiftFormatRules/UseSynthesizedInitializer.swift +++ b/Sources/SwiftFormat/Rules/UseSynthesizedInitializer.swift @@ -11,7 +11,6 @@ //===----------------------------------------------------------------------===// import Foundation -import SwiftFormatCore import SwiftSyntax /// When possible, the synthesized `struct` initializer should be used. @@ -21,26 +20,23 @@ import SwiftSyntax /// /// Lint: (Non-public) memberwise initializers with the same structure as the synthesized /// initializer will yield a lint error. +@_spi(Rules) public final class UseSynthesizedInitializer: SyntaxLintRule { public override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind { var storedProperties: [VariableDeclSyntax] = [] var initializers: [InitializerDeclSyntax] = [] - for memberItem in node.members.members { + for memberItem in node.memberBlock.members { let member = memberItem.decl // Collect all stored variables into a list if let varDecl = member.as(VariableDeclSyntax.self) { - guard let modifiers = varDecl.modifiers else { - storedProperties.append(varDecl) - continue - } - guard !modifiers.has(modifier: "static") else { continue } + guard !varDecl.modifiers.contains(anyOf: [.static]) else { continue } storedProperties.append(varDecl) // Collect any possible redundant initializers into a list } else if let initDecl = member.as(InitializerDeclSyntax.self) { guard initDecl.optionalMark == nil else { continue } - guard initDecl.signature.throwsOrRethrowsKeyword == nil else { continue } + guard initDecl.signature.effectSpecifiers?.throwsClause == nil else { continue } initializers.append(initDecl) } } @@ -50,29 +46,36 @@ public final class UseSynthesizedInitializer: SyntaxLintRule { var extraneousInitializers = [InitializerDeclSyntax]() for initializer in initializers { guard + // Attributes signify intent that isn't automatically synthesized by the compiler. + initializer.attributes.isEmpty, matchesPropertyList( - parameters: initializer.signature.input.parameterList, - properties: storedProperties) - else { continue } - guard + parameters: initializer.signature.parameterClause.parameters, + properties: storedProperties + ), matchesAssignmentBody( variables: storedProperties, - initBody: initializer.body) - else { continue } - guard matchesAccessLevel(modifiers: initializer.modifiers, properties: storedProperties) - else { continue } + initBody: initializer.body + ), + matchesAccessLevel( + modifiers: initializer.modifiers, + properties: storedProperties + ) + else { + continue + } + extraneousInitializers.append(initializer) } // The synthesized memberwise initializer(s) are only created when there are no initializers. // If there are other initializers that cannot be replaced by a synthesized memberwise // initializer, then all of the initializers must remain. - let initializersCount = node.members.members.filter { $0.decl.is(InitializerDeclSyntax.self) }.count + let initializersCount = node.memberBlock.members.filter { $0.decl.is(InitializerDeclSyntax.self) }.count if extraneousInitializers.count == initializersCount { extraneousInitializers.forEach { diagnose(.removeRedundantInitializer, on: $0) } } - return .skipChildren + return .visitChildren } /// Compares the actual access level of an initializer with the access level of a synthesized @@ -83,18 +86,19 @@ public final class UseSynthesizedInitializer: SyntaxLintRule { /// - properties: The properties from the enclosing type. /// - Returns: Whether the initializer has the same access level as the synthesized initializer. private func matchesAccessLevel( - modifiers: ModifierListSyntax?, properties: [VariableDeclSyntax] + modifiers: DeclModifierListSyntax?, + properties: [VariableDeclSyntax] ) -> Bool { let synthesizedAccessLevel = synthesizedInitAccessLevel(using: properties) let accessLevel = modifiers?.accessLevelModifier switch synthesizedAccessLevel { case .internal: // No explicit access level or internal are equivalent. - return accessLevel == nil || accessLevel!.name.tokenKind == .internalKeyword + return accessLevel == nil || accessLevel!.name.tokenKind == .keyword(.internal) case .fileprivate: - return accessLevel != nil && accessLevel!.name.tokenKind == .fileprivateKeyword + return accessLevel != nil && accessLevel!.name.tokenKind == .keyword(.fileprivate) case .private: - return accessLevel != nil && accessLevel!.name.tokenKind == .privateKeyword + return accessLevel != nil && accessLevel!.name.tokenKind == .keyword(.private) } } @@ -106,8 +110,7 @@ public final class UseSynthesizedInitializer: SyntaxLintRule { guard parameters.count == properties.count else { return false } for (idx, parameter) in parameters.enumerated() { - guard let paramId = parameter.firstName, parameter.secondName == nil else { return false } - guard let paramType = parameter.type else { return false } + guard parameter.secondName == nil else { return false } let property = properties[idx] let propertyId = property.firstIdentifier @@ -116,18 +119,21 @@ public final class UseSynthesizedInitializer: SyntaxLintRule { // Ensure that parameters that correspond to properties declared using 'var' have a default // argument that is identical to the property's default value. Otherwise, a default argument // doesn't match the memberwise initializer. - let isVarDecl = property.letOrVarKeyword.tokenKind == .varKeyword + let isVarDecl = property.bindingSpecifier.tokenKind == .keyword(.var) if isVarDecl, let initializer = property.firstInitializer { - guard let defaultArg = parameter.defaultArgument else { return false } + guard let defaultArg = parameter.defaultValue else { return false } guard initializer.value.description == defaultArg.value.description else { return false } - } else if parameter.defaultArgument != nil { + } else if parameter.defaultValue != nil { return false } - if propertyId.identifier.text != paramId.text + if propertyId.identifier.text != parameter.firstName.text || propertyType.description.trimmingCharacters( - in: .whitespaces) != paramType.description.trimmingCharacters(in: .whitespacesAndNewlines) - { return false } + in: .whitespaces + ) != parameter.type.description.trimmingCharacters(in: .whitespacesAndNewlines) + { + return false + } } return true } @@ -144,7 +150,7 @@ public final class UseSynthesizedInitializer: SyntaxLintRule { for statement in initBody.statements { guard let expr = statement.item.as(InfixOperatorExprSyntax.self), - expr.operatorOperand.is(AssignmentExprSyntax.self) + expr.operator.is(AssignmentExprSyntax.self) else { return false } @@ -160,13 +166,13 @@ public final class UseSynthesizedInitializer: SyntaxLintRule { return false } - leftName = memberAccessExpr.name.text + leftName = memberAccessExpr.declName.baseName.text } else { return false } - if let identifierExpr = expr.rightOperand.as(IdentifierExprSyntax.self) { - rightName = identifierExpr.identifier.text + if let identifierExpr = expr.rightOperand.as(DeclReferenceExprSyntax.self) { + rightName = identifierExpr.baseName.text } else { return false } @@ -186,8 +192,8 @@ public final class UseSynthesizedInitializer: SyntaxLintRule { } extension Finding.Message { - public static let removeRedundantInitializer: Finding.Message = - "remove initializer and use the synthesized initializer" + fileprivate static let removeRedundantInitializer: Finding.Message = + "remove this explicit initializer, which is identical to the compiler-synthesized initializer" } /// Defines the access levels which may be assigned to a synthesized memberwise initializer. @@ -209,16 +215,43 @@ fileprivate enum AccessLevel { fileprivate func synthesizedInitAccessLevel(using properties: [VariableDeclSyntax]) -> AccessLevel { var hasFileprivate = false for property in properties { - guard let modifiers = property.modifiers else { continue } - // Private takes precedence, so finding 1 private property defines the access level. - if modifiers.contains(where: {$0.name.tokenKind == .privateKeyword && $0.detail == nil}) { + if property.modifiers.contains(where: { $0.name.tokenKind == .keyword(.private) && $0.detail == nil }) { return .private } - if modifiers.contains(where: {$0.name.tokenKind == .fileprivateKeyword && $0.detail == nil}) { + if property.modifiers.contains(where: { $0.name.tokenKind == .keyword(.fileprivate) && $0.detail == nil }) { hasFileprivate = true // Can't break here because a later property might be private. } } return hasFileprivate ? .fileprivate : .internal } + +// FIXME: Stop using these extensions; they make assumptions about the structure of stored +// properties and may miss some valid cases, like tuple patterns. +extension VariableDeclSyntax { + /// Returns array of all identifiers listed in the declaration. + fileprivate var identifiers: [IdentifierPatternSyntax] { + var ids: [IdentifierPatternSyntax] = [] + for binding in bindings { + guard let id = binding.pattern.as(IdentifierPatternSyntax.self) else { continue } + ids.append(id) + } + return ids + } + + /// Returns the first identifier. + fileprivate var firstIdentifier: IdentifierPatternSyntax { + return identifiers[0] + } + + /// Returns the first type explicitly stated in the declaration, if present. + fileprivate var firstType: TypeSyntax? { + return bindings.first?.typeAnnotation?.type + } + + /// Returns the first initializer clause, if present. + fileprivate var firstInitializer: InitializerClauseSyntax? { + return bindings.first?.initializer + } +} diff --git a/Sources/SwiftFormatRules/UseTripleSlashForDocumentationComments.swift b/Sources/SwiftFormat/Rules/UseTripleSlashForDocumentationComments.swift similarity index 53% rename from Sources/SwiftFormatRules/UseTripleSlashForDocumentationComments.swift rename to Sources/SwiftFormat/Rules/UseTripleSlashForDocumentationComments.swift index bbede2979..319c478eb 100644 --- a/Sources/SwiftFormatRules/UseTripleSlashForDocumentationComments.swift +++ b/Sources/SwiftFormat/Rules/UseTripleSlashForDocumentationComments.swift @@ -11,7 +11,6 @@ //===----------------------------------------------------------------------===// import Foundation -import SwiftFormatCore import SwiftSyntax /// Documentation comments must use the `///` form. @@ -23,6 +22,7 @@ import SwiftSyntax /// Format: If a doc block comment appears on its own on a line, or if a doc block comment spans /// multiple lines without appearing on the same line as code, it will be replaced with /// multiple doc line comments. +@_spi(Rules) public final class UseTripleSlashForDocumentationComments: SyntaxFormatRule { public override func visit(_ node: FunctionDeclSyntax) -> DeclSyntax { return convertDocBlockCommentToDocLineComment(DeclSyntax(node)) @@ -60,7 +60,7 @@ public final class UseTripleSlashForDocumentationComments: SyntaxFormatRule { return convertDocBlockCommentToDocLineComment(DeclSyntax(node)) } - public override func visit(_ node: TypealiasDeclSyntax) -> DeclSyntax { + public override func visit(_ node: TypeAliasDeclSyntax) -> DeclSyntax { return convertDocBlockCommentToDocLineComment(DeclSyntax(node)) } @@ -68,64 +68,47 @@ public final class UseTripleSlashForDocumentationComments: SyntaxFormatRule { return convertDocBlockCommentToDocLineComment(DeclSyntax(node)) } - /// In the case the given declaration has a docBlockComment as it's documentation - /// comment. Returns the declaration with the docBlockComment converted to - /// a docLineComment. + /// If the declaration has a doc block comment, return the declaration with the comment rewritten + /// as a line comment. + /// + /// If the declaration had no comment or had only line comments, it is returned unchanged. private func convertDocBlockCommentToDocLineComment(_ decl: DeclSyntax) -> DeclSyntax { - guard let commentText = decl.docComment else { return decl } - guard let declLeadingTrivia = decl.leadingTrivia else { return decl } - let docComments = commentText.components(separatedBy: "\n") - var pieces = [TriviaPiece]() - - // Ensures the documentation comment is a docLineComment. - var hasFoundDocComment = false - for piece in declLeadingTrivia.reversed() { - if case .docBlockComment(_) = piece, !hasFoundDocComment { - hasFoundDocComment = true - diagnose(.avoidDocBlockComment, on: decl) - pieces.append(contentsOf: separateDocBlockIntoPieces(docComments).reversed()) - } else if case .docLineComment(_) = piece, !hasFoundDocComment { - // The comment was a doc-line comment all along. Leave it alone. - // This intentionally only considers the comment closest to the decl. There may be other - // comments, including block or doc-block comments, which are left as-is because they aren't - // necessarily related to the decl and are unlikely part of the decl's documentation. - return decl - } else { - pieces.append(piece) - } + guard + let commentInfo = DocumentationCommentText(extractedFrom: decl.leadingTrivia), + commentInfo.introducer != .line + else { + return decl } - return !hasFoundDocComment - ? decl : replaceTrivia( - on: decl, - token: decl.firstToken, - leadingTrivia: Trivia(pieces: pieces.reversed()) - ) - } + diagnose(.avoidDocBlockComment, on: decl, anchor: .leadingTrivia(commentInfo.startIndex)) - /// Breaks down the docBlock comment into the correct trivia pieces - /// for a docLineComment. - private func separateDocBlockIntoPieces(_ docComments: [String]) -> [TriviaPiece] { - var pieces = [TriviaPiece]() - for lineText in docComments.dropLast() { - // Adds an space as indentation for the lines that needed it. - let docLineMark = lineText.first == " " || lineText.trimmingCharacters(in: .whitespaces) == "" - ? "///" : "/// " - pieces.append(.docLineComment(docLineMark + lineText)) - pieces.append(.newlines(1)) + // Keep any trivia leading up to the doc comment. + var pieces = Array(decl.leadingTrivia[.. StmtSyntax { + public override func visit(_ node: ForStmtSyntax) -> StmtSyntax { // Extract IfStmt node if it's the only node in the function's body. guard !node.body.statements.isEmpty else { return StmtSyntax(node) } let firstStatement = node.body.statements.first! // Ignore for-loops with a `where` clause already. - // FIXME: Create an `&&` expression with both conditions? + // TODO: Create an `&&` expression with both conditions? guard node.whereClause == nil else { return StmtSyntax(node) } // Match: @@ -49,16 +49,27 @@ public final class UseWhereClausesInForLoops: SyntaxFormatRule { private func diagnoseAndUpdateForInStatement( firstStmt: StmtSyntax, - forInStmt: ForInStmtSyntax - ) -> ForInStmtSyntax { + forInStmt: ForStmtSyntax + ) -> ForStmtSyntax { switch Syntax(firstStmt).as(SyntaxEnum.self) { - case .ifStmt(let ifStmt) - where ifStmt.conditions.count == 1 - && ifStmt.elseKeyword == nil - && forInStmt.body.statements.count == 1: - // Extract the condition of the IfStmt. - let conditionElement = ifStmt.conditions.first! - guard let condition = conditionElement.condition.as(ExprSyntax.self) else { + case .expressionStmt(let exprStmt): + switch Syntax(exprStmt.expression).as(SyntaxEnum.self) { + case .ifExpr(let ifExpr) + where ifExpr.conditions.count == 1 + && ifExpr.elseKeyword == nil + && forInStmt.body.statements.count == 1: + // Extract the condition of the IfExpr. + let conditionElement = ifExpr.conditions.first! + guard let condition = conditionElement.condition.as(ExprSyntax.self) else { + return forInStmt + } + diagnose(.useWhereInsteadOfIf, on: ifExpr) + return updateWithWhereCondition( + node: forInStmt, + condition: condition, + statements: ifExpr.body.statements + ) + default: return forInStmt } diagnose(.useWhereInsteadOfIf, on: ifStmt) @@ -81,7 +92,7 @@ public final class UseWhereClausesInForLoops: SyntaxFormatRule { return updateWithWhereCondition( node: forInStmt, condition: condition, - statements: forInStmt.body.statements.removingFirst() + statements: CodeBlockItemListSyntax(forInStmt.body.statements.dropFirst()) ) default: @@ -91,34 +102,37 @@ public final class UseWhereClausesInForLoops: SyntaxFormatRule { } fileprivate func updateWithWhereCondition( - node: ForInStmtSyntax, + node: ForStmtSyntax, condition: ExprSyntax, statements: CodeBlockItemListSyntax -) -> ForInStmtSyntax { +) -> ForStmtSyntax { // Construct a new `where` clause with the condition. - let lastToken = node.sequenceExpr.lastToken + let lastToken = node.sequence.lastToken(viewMode: .sourceAccurate) var whereLeadingTrivia = Trivia() if lastToken?.trailingTrivia.containsSpaces == false { whereLeadingTrivia = .spaces(1) } - let whereKeyword = TokenSyntax.whereKeyword( + let whereKeyword = TokenSyntax.keyword( + .where, leadingTrivia: whereLeadingTrivia, trailingTrivia: .spaces(1) ) let whereClause = WhereClauseSyntax( whereKeyword: whereKeyword, - guardResult: condition + condition: condition ) // Replace the where clause and extract the body from the IfStmt. - let newBody = node.body.withStatements(statements) - return node.withWhereClause(whereClause).withBody(newBody) + var result = node + result.whereClause = whereClause + result.body.statements = statements + return result } extension Finding.Message { - public static let useWhereInsteadOfIf: Finding.Message = + fileprivate static let useWhereInsteadOfIf: Finding.Message = "replace this 'if' statement with a 'where' clause" - public static let useWhereInsteadOfGuard: Finding.Message = + fileprivate static let useWhereInsteadOfGuard: Finding.Message = "replace this 'guard' statement with a 'where' clause" } diff --git a/Sources/SwiftFormatRules/ValidateDocumentationComments.swift b/Sources/SwiftFormat/Rules/ValidateDocumentationComments.swift similarity index 58% rename from Sources/SwiftFormatRules/ValidateDocumentationComments.swift rename to Sources/SwiftFormat/Rules/ValidateDocumentationComments.swift index c142e5d08..e99d1556e 100644 --- a/Sources/SwiftFormatRules/ValidateDocumentationComments.swift +++ b/Sources/SwiftFormat/Rules/ValidateDocumentationComments.swift @@ -11,7 +11,7 @@ //===----------------------------------------------------------------------===// import Foundation -import SwiftFormatCore +import Markdown import SwiftSyntax /// Documentation comments must be complete and valid. @@ -20,8 +20,8 @@ import SwiftSyntax /// /// Lint: Documentation comments that are incomplete (e.g. missing parameter documentation) or /// invalid (uses `Parameters` when there is only one parameter) will yield a lint error. +@_spi(Rules) public final class ValidateDocumentationComments: SyntaxLintRule { - /// Identifies this rule as being opt-in. Accurate and complete documentation comments are /// important, but this rule isn't able to handle situations where portions of documentation are /// redundant. For example when the returns clause is redundant for a simple declaration. @@ -29,13 +29,19 @@ public final class ValidateDocumentationComments: SyntaxLintRule { public override func visit(_ node: InitializerDeclSyntax) -> SyntaxVisitorContinueKind { return checkFunctionLikeDocumentation( - DeclSyntax(node), name: "init", signature: node.signature) + DeclSyntax(node), + name: "init", + signature: node.signature + ) } public override func visit(_ node: FunctionDeclSyntax) -> SyntaxVisitorContinueKind { return checkFunctionLikeDocumentation( - DeclSyntax(node), name: node.identifier.text, signature: node.signature, - returnClause: node.signature.output) + DeclSyntax(node), + name: node.name.text, + signature: node.signature, + returnClause: node.signature.returnClause + ) } private func checkFunctionLikeDocumentation( @@ -44,45 +50,52 @@ public final class ValidateDocumentationComments: SyntaxLintRule { signature: FunctionSignatureSyntax, returnClause: ReturnClauseSyntax? = nil ) -> SyntaxVisitorContinueKind { - guard let declComment = node.docComment else { return .skipChildren } - guard let commentInfo = node.docCommentInfo else { return .skipChildren } - guard let params = commentInfo.parameters else { return .skipChildren } + guard + let docComment = DocumentationComment(extractedFrom: node), + !docComment.parameters.isEmpty + else { + return .skipChildren + } // If a single sentence summary is the only documentation, parameter(s) and // returns tags may be omitted. - if commentInfo.oneSentenceSummary != nil && commentInfo.commentParagraphs!.isEmpty && params - .isEmpty && commentInfo.returnsDescription == nil + if docComment.briefSummary != nil + && docComment.bodyNodes.isEmpty + && docComment.parameters.isEmpty + && docComment.returns == nil { return .skipChildren } - // Indicates if the documentation uses 'Parameters' as description of the - // documented parameters. - let hasPluralDesc = declComment.components(separatedBy: .newlines).contains { - $0.trimmingCharacters(in: .whitespaces).starts(with: "- Parameters") - } - validateThrows( - signature.throwsOrRethrowsKeyword, name: name, throwsDesc: commentInfo.throwsDescription, node: node) + signature.effectSpecifiers?.throwsClause?.throwsSpecifier, + name: name, + throwsDescription: docComment.throws, + node: node + ) validateReturn( - returnClause, name: name, returnDesc: commentInfo.returnsDescription, node: node) - let funcParameters = funcParametersIdentifiers(in: signature.input.parameterList) + returnClause, + name: name, + returnsDescription: docComment.returns, + node: node + ) + let funcParameters = funcParametersIdentifiers(in: signature.parameterClause.parameters) // If the documentation of the parameters is wrong 'docCommentInfo' won't // parse the parameters correctly. First the documentation has to be fix // in order to validate the other conditions. - if hasPluralDesc && funcParameters.count == 1 { + if docComment.parameterLayout != .separated && funcParameters.count == 1 { diagnose(.useSingularParameter, on: node) return .skipChildren - } else if !hasPluralDesc && funcParameters.count > 1 { + } else if docComment.parameterLayout != .outline && funcParameters.count > 1 { diagnose(.usePluralParameters, on: node) return .skipChildren } // Ensures that the parameters of the documentation and the function signature // are the same. - if (params.count != funcParameters.count) || !parametersAreEqual( - params: params, funcParam: funcParameters) + if (docComment.parameters.count != funcParameters.count) + || !parametersAreEqual(params: docComment.parameters, funcParam: funcParameters) { diagnose(.parametersDontMatch(funcName: name), on: node) } @@ -95,14 +108,14 @@ public final class ValidateDocumentationComments: SyntaxLintRule { private func validateReturn( _ returnClause: ReturnClauseSyntax?, name: String, - returnDesc: String?, + returnsDescription: Paragraph?, node: DeclSyntax ) { - if returnClause == nil && returnDesc != nil { + if returnClause == nil && returnsDescription != nil { diagnose(.removeReturnComment(funcName: name), on: node) - } else if let returnClause = returnClause, returnDesc == nil { - if let returnTypeIdentifier = returnClause.returnType.as(SimpleTypeIdentifierSyntax.self), - returnTypeIdentifier.name.text == "Never" + } else if let returnClause = returnClause, returnsDescription == nil { + if let returnTypeIdentifier = returnClause.type.as(IdentifierTypeSyntax.self), + returnTypeIdentifier.name.text == "Never" { return } @@ -115,17 +128,20 @@ public final class ValidateDocumentationComments: SyntaxLintRule { private func validateThrows( _ throwsOrRethrowsKeyword: TokenSyntax?, name: String, - throwsDesc: String?, + throwsDescription: Paragraph?, node: DeclSyntax ) { // If a function is marked as `rethrows`, it doesn't have any errors of its // own that should be documented. So only require documentation for // functions marked `throws`. - let needsThrowsDesc = throwsOrRethrowsKeyword?.tokenKind == .throwsKeyword - - if !needsThrowsDesc && throwsDesc != nil { - diagnose(.removeThrowsComment(funcName: name), on: throwsOrRethrowsKeyword ?? node.firstToken) - } else if needsThrowsDesc && throwsDesc == nil { + let needsThrowsDesc = throwsOrRethrowsKeyword?.tokenKind == .keyword(.throws) + + if !needsThrowsDesc && throwsDescription != nil { + diagnose( + .removeThrowsComment(funcName: name), + on: throwsOrRethrowsKeyword ?? node.firstToken(viewMode: .sourceAccurate) + ) + } else if needsThrowsDesc && throwsDescription == nil { diagnose(.documentErrorsThrown(funcName: name), on: throwsOrRethrowsKeyword) } } @@ -139,9 +155,7 @@ fileprivate func funcParametersIdentifiers(in paramList: FunctionParameterListSy // If there is a label and an identifier, then the identifier (`secondName`) is the name that // should be documented. Otherwise, the label and identifier are the same, occupying // `firstName`. - guard let parameterIdentifier = parameter.secondName ?? parameter.firstName else { - continue - } + let parameterIdentifier = parameter.secondName ?? parameter.firstName funcParameters.append(parameterIdentifier.text) } return funcParameters @@ -149,7 +163,10 @@ fileprivate func funcParametersIdentifiers(in paramList: FunctionParameterListSy /// Indicates if the parameters name from the documentation and the parameters /// from the declaration are the same. -fileprivate func parametersAreEqual(params: [ParseComment.Parameter], funcParam: [String]) -> Bool { +fileprivate func parametersAreEqual( + params: [DocumentationComment.Parameter], + funcParam: [String] +) -> Bool { for index in 0.. Finding.Message { - "document the return value of \(funcName)" + fileprivate static func documentReturnValue(funcName: String) -> Finding.Message { + "add a 'Returns:' section to document the return value of '\(funcName)'" } - public static func removeReturnComment(funcName: String) -> Finding.Message { - "remove the return comment of \(funcName), it doesn't return a value" + fileprivate static func removeReturnComment(funcName: String) -> Finding.Message { + "remove the 'Returns:' section of '\(funcName)'; it does not return a value" } - public static func parametersDontMatch(funcName: String) -> Finding.Message { - "change the parameters of \(funcName)'s documentation to match its parameters" + fileprivate static func parametersDontMatch(funcName: String) -> Finding.Message { + "change the parameters of the documentation of '\(funcName)' to match its parameters" } - public static let useSingularParameter: Finding.Message = - "replace the plural form of 'Parameters' with a singular inline form of the 'Parameter' tag" + fileprivate static let useSingularParameter: Finding.Message = + "replace the plural 'Parameters:' section with a singular inline 'Parameter' section" - public static let usePluralParameters: Finding.Message = + fileprivate static let usePluralParameters: Finding.Message = """ - replace the singular inline form of 'Parameter' tag with a plural 'Parameters' tag \ - and group each parameter as a nested list + replace the singular inline 'Parameter' section with a plural 'Parameters:' section \ + that has the parameters nested inside it """ - public static func removeThrowsComment(funcName: String) -> Finding.Message { - "remove the 'Throws' tag for non-throwing function \(funcName)" + fileprivate static func removeThrowsComment(funcName: String) -> Finding.Message { + "remove the 'Throws:' sections of '\(funcName)'; it does not throw any errors" } - public static func documentErrorsThrown(funcName: String) -> Finding.Message { - "add a 'Throws' tag describing the errors thrown by \(funcName)" + fileprivate static func documentErrorsThrown(funcName: String) -> Finding.Message { + "add a 'Throws:' section to document the errors thrown by '\(funcName)'" } } diff --git a/Sources/SwiftFormat/Utilities/FileIterator.swift b/Sources/SwiftFormat/Utilities/FileIterator.swift new file mode 100644 index 000000000..b0a8d2f06 --- /dev/null +++ b/Sources/SwiftFormat/Utilities/FileIterator.swift @@ -0,0 +1,181 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2019 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Foundation + +#if os(Windows) +import WinSDK +#endif + +/// Iterator for looping over lists of files and directories. Directories are automatically +/// traversed recursively, and we check for files with a ".swift" extension. +@_spi(Internal) +public struct FileIterator: Sequence, IteratorProtocol { + + /// List of file and directory URLs to iterate over. + private let urls: [URL] + + /// If true, symlinks will be followed when iterating over directories and files. If not, they + /// will be ignored. + private let followSymlinks: Bool + + /// Iterator for the list of URLs. + private var urlIterator: Array.Iterator + + /// Iterator for recursing through directories. + private var dirIterator: FileManager.DirectoryEnumerator? = nil + + /// The current working directory of the process, which is used to relativize URLs of files found + /// during iteration. + private let workingDirectory: URL + + /// Keep track of the current directory we're recursing through. + private var currentDirectory = URL(fileURLWithPath: "") + + /// Keep track of files we have visited to prevent duplicates. + private var visited: Set = [] + + /// The file extension to check for when recursing through directories. + private let fileSuffix = ".swift" + + /// Create a new file iterator over the given list of file URLs. + /// + /// The given URLs may be files or directories. If they are directories, the iterator will recurse + /// into them. Symlinks are never followed on Windows platforms as Foundation doesn't support it. + /// - Parameters: + /// - urls: `Array` of files or directories to iterate. + /// - followSymlinks: `Bool` to indicate if symbolic links should be followed when iterating. + /// - workingDirectory: `URL` that indicates the current working directory. Used for testing. + public init(urls: [URL], followSymlinks: Bool, workingDirectory: URL = URL(fileURLWithPath: ".")) { + self.workingDirectory = workingDirectory + self.urls = urls + self.urlIterator = self.urls.makeIterator() + self.followSymlinks = followSymlinks + } + + /// Iterate through the "paths" list, and emit the file paths in it. If we encounter a directory, + /// recurse through it and emit .swift file paths. + public mutating func next() -> URL? { + var output: URL? = nil + while output == nil { + // Check if we're recursing through a directory. + if dirIterator != nil { + output = nextInDirectory() + } else { + guard var next = urlIterator.next() else { + // If we've reached the end of all the URLs we wanted to iterate over, exit now. + return nil + } + + guard let fileType = fileType(at: next) else { + continue + } + + switch fileType { + case .typeSymbolicLink: + guard + followSymlinks, + let destination = try? FileManager.default.destinationOfSymbolicLink(atPath: next.path) + else { + break + } + next = URL(fileURLWithPath: destination, relativeTo: next) + fallthrough + + case .typeDirectory: + dirIterator = FileManager.default.enumerator( + at: next, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) + currentDirectory = next + + default: + // We'll get here if the path is a file, or if it doesn't exist. In the latter case, + // return the path anyway; we'll turn the error we get when we try to open the file into + // an appropriate diagnostic instead of trying to handle it here. + output = next + } + } + if let out = output, visited.contains(out.standardizedFileURL.path) { + output = nil + } + } + if let out = output { + visited.insert(out.standardizedFileURL.path) + } + return output + } + + /// Use the FileManager API to recurse through directories and emit .swift file paths. + private mutating func nextInDirectory() -> URL? { + var output: URL? = nil + while output == nil { + guard let item = dirIterator?.nextObject() as? URL else { + break + } + #if os(Windows) + // Windows does not consider files and directories starting with `.` as hidden but we don't want to traverse + // into eg. `.build`. Manually skip any items starting with `.`. + if item.lastPathComponent.hasPrefix(".") { + dirIterator?.skipDescendants() + continue + } + #endif + + guard item.lastPathComponent.hasSuffix(fileSuffix), let fileType = fileType(at: item) else { + continue + } + + var path = item.path + switch fileType { + case .typeSymbolicLink where followSymlinks: + guard + let destination = try? FileManager.default.destinationOfSymbolicLink(atPath: path) + else { + break + } + path = URL(fileURLWithPath: destination, relativeTo: item).path + fallthrough + + case .typeRegular: + // We attempt to relativize the URLs based on the current working directory, not the + // directory being iterated over, so that they can be displayed better in diagnostics. Thus, + // if the user passes paths that are relative to the current working directory, they will + // be displayed as relative paths. Otherwise, they will still be displayed as absolute + // paths. + let relativePath: String + if !workingDirectory.isRoot, path.hasPrefix(workingDirectory.path) { + relativePath = String(path.dropFirst(workingDirectory.path.count).drop(while: { $0 == "/" || $0 == #"\"# })) + } else { + relativePath = path + } + output = URL(fileURLWithPath: relativePath, isDirectory: false, relativeTo: workingDirectory) + + default: + break + } + } + // If we've exhausted the files in the directory recursion, unset the directory iterator. + if output == nil { + dirIterator = nil + } + return output + } +} + +/// Returns the type of the file at the given URL. +private func fileType(at url: URL) -> FileAttributeType? { + // We cannot use `URL.resourceValues(forKeys:)` here because it appears to behave incorrectly on + // Linux. + return try? FileManager.default.attributesOfItem(atPath: url.path)[.type] as? FileAttributeType +} diff --git a/Sources/SwiftFormat/Utilities/URL+isRoot.swift b/Sources/SwiftFormat/Utilities/URL+isRoot.swift new file mode 100644 index 000000000..6a8522889 --- /dev/null +++ b/Sources/SwiftFormat/Utilities/URL+isRoot.swift @@ -0,0 +1,55 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Foundation + +#if os(Windows) +import WinSDK +#endif + +extension URL { + /// Returns a `Bool` to indicate if the given `URL` leads to the root of a filesystem. + /// A non-filesystem type `URL` will always return false. + @_spi(Testing) public var isRoot: Bool { + guard isFileURL else { return false } + + #if compiler(>=6.1) + #if os(Windows) + let filePath = self.withUnsafeFileSystemRepresentation { pointer in + guard let pointer else { + return "" + } + return String(cString: pointer) + } + return filePath.withCString(encodedAs: UTF16.self, PathCchIsRoot) + #else // os(Windows) + return self.path == "/" + #endif // os(Windows) + #else // compiler(>=6.1) + + #if os(Windows) + // This is needed as the fixes from #844 aren't in the Swift 6.0 toolchain. + // https://github.com/swiftlang/swift-format/issues/844 + var pathComponents = self.pathComponents + if pathComponents.first == "/" { + // Canonicalize `/C:/` to `C:/`. + pathComponents = Array(pathComponents.dropFirst()) + } + return pathComponents.count <= 1 + #else // os(Windows) + // On Linux, we may end up with an string for the path due to https://github.com/swiftlang/swift-foundation/issues/980 + // This is needed as the fixes from #980 aren't in the Swift 6.0 toolchain. + return self.path == "/" || self.path == "" + #endif // os(Windows) + #endif // compiler(>=6.1) + } +} diff --git a/Sources/SwiftFormatCore/LegacyTriviaBehavior.swift b/Sources/SwiftFormatCore/LegacyTriviaBehavior.swift deleted file mode 100644 index 6f42bedab..000000000 --- a/Sources/SwiftFormatCore/LegacyTriviaBehavior.swift +++ /dev/null @@ -1,44 +0,0 @@ -import SwiftSyntax - -/// Rewrites the trivia on tokens in the given source file to restore the legacy trivia behavior -/// before https://github.com/apple/swift-syntax/pull/985 was merged. -/// -/// Eventually we should get rid of this and update the core formatting code to adjust to the new -/// behavior, but this workaround lets us keep the current implementation without larger changes. -public func restoringLegacyTriviaBehavior(_ sourceFile: SourceFileSyntax) -> SourceFileSyntax { - return LegacyTriviaBehaviorRewriter().visit(sourceFile) -} - -private final class LegacyTriviaBehaviorRewriter: SyntaxRewriter { - /// Trivia that was extracted from the trailing trivia of a token to be prepended to the leading - /// trivia of the next token. - private var pendingLeadingTrivia: Trivia? - - override func visit(_ token: TokenSyntax) -> TokenSyntax { - var token = token - if let pendingLeadingTrivia = pendingLeadingTrivia { - token = token.withLeadingTrivia(pendingLeadingTrivia + token.leadingTrivia) - self.pendingLeadingTrivia = nil - } - if token.nextToken != nil, - let firstIndexToMove = token.trailingTrivia.firstIndex(where: shouldTriviaPieceBeMoved) - { - pendingLeadingTrivia = Trivia(pieces: Array(token.trailingTrivia[firstIndexToMove...])) - token = - token.withTrailingTrivia(Trivia(pieces: Array(token.trailingTrivia[.. Bool { - switch piece { - case .spaces, .tabs, .unexpectedText: - return false - default: - return true - } -} diff --git a/Sources/SwiftFormatCore/SyntaxProtocol+Convenience.swift b/Sources/SwiftFormatCore/SyntaxProtocol+Convenience.swift deleted file mode 100644 index 01673ffcb..000000000 --- a/Sources/SwiftFormatCore/SyntaxProtocol+Convenience.swift +++ /dev/null @@ -1,70 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift.org open source project -// -// Copyright (c) 2014 - 2019 Apple Inc. and the Swift project authors -// Licensed under Apache License v2.0 with Runtime Library Exception -// -// See https://swift.org/LICENSE.txt for license information -// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors -// -//===----------------------------------------------------------------------===// - -import SwiftSyntax - -extension SyntaxProtocol { - /// Returns the absolute position of the trivia piece at the given index in the receiver's leading - /// trivia collection. - /// - /// If the trivia piece spans multiple characters, the value returned is the position of the first - /// character. - /// - /// - Precondition: `index` is a valid index in the receiver's leading trivia collection. - /// - /// - Parameter index: The index of the trivia piece in the leading trivia whose position should - /// be returned. - /// - Returns: The absolute position of the trivia piece. - public func position(ofLeadingTriviaAt index: Trivia.Index) -> AbsolutePosition { - let leadingTrivia = self.leadingTrivia ?? [] - guard leadingTrivia.indices.contains(index) else { - preconditionFailure("Index was out of bounds in the node's leading trivia.") - } - - var offset = SourceLength.zero - for currentIndex in leadingTrivia.startIndex.. SourceLocation { - return converter.location(for: position(ofLeadingTriviaAt: index)) - } -} - -extension SyntaxCollection { - /// The first element in the syntax collection if it is the *only* element, or nil otherwise. - public var firstAndOnly: Element? { - var iterator = makeIterator() - guard let first = iterator.next() else { return nil } - guard iterator.next() == nil else { return nil } - return first - } -} diff --git a/Sources/SwiftFormatCore/Trivia+Convenience.swift b/Sources/SwiftFormatCore/Trivia+Convenience.swift deleted file mode 100644 index f69091b88..000000000 --- a/Sources/SwiftFormatCore/Trivia+Convenience.swift +++ /dev/null @@ -1,156 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift.org open source project -// -// Copyright (c) 2014 - 2019 Apple Inc. and the Swift project authors -// Licensed under Apache License v2.0 with Runtime Library Exception -// -// See https://swift.org/LICENSE.txt for license information -// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors -// -//===----------------------------------------------------------------------===// - -import SwiftSyntax - -extension Trivia { - public var numberOfComments: Int { - var count = 0 - for piece in self { - switch piece { - case .lineComment, .docLineComment, .blockComment, .docBlockComment: - count += 1 - default: - continue - } - } - return count - } - - /// Returns whether the trivia contains at least 1 `lineComment`. - public var hasLineComment: Bool { - return self.contains { - if case .lineComment = $0 { return true } - return false - } - } - - /// Returns this set of trivia, without any whitespace characters. - public func withoutSpaces() -> Trivia { - return Trivia( - pieces: filter { - if case .spaces = $0 { return false } - if case .tabs = $0 { return false } - return true - }) - } - - /// Returns this set of trivia, without any leading spaces. - public func withoutLeadingSpaces() -> Trivia { - return Trivia( - pieces: Array(drop { - if case .spaces = $0 { return false } - if case .tabs = $0 { return false } - return true - })) - } - - /// Returns this set of trivia, without any newlines. - public func withoutNewlines() -> Trivia { - return Trivia( - pieces: filter { - if case .newlines = $0 { return false } - return true - }) - } - - /// Returns this trivia, excluding the last newline and anything following it. - /// - /// If there is no newline in the trivia, it is returned unmodified. - public func withoutLastLine() -> Trivia { - var maybeLastNewlineOffset: Int? = nil - for (offset, piece) in self.enumerated() { - switch piece { - case .newlines, .carriageReturns, .carriageReturnLineFeeds: - maybeLastNewlineOffset = offset - default: - break - } - } - guard let lastNewlineOffset = maybeLastNewlineOffset else { return self } - return Trivia(pieces: self.dropLast(self.count - lastNewlineOffset)) - } - - /// Returns this set of trivia, with all spaces removed except for one at the - /// end. - public func withOneTrailingSpace() -> Trivia { - return withoutSpaces() + .spaces(1) - } - - /// Returns this set of trivia, with all spaces removed except for one at the - /// beginning. - public func withOneLeadingSpace() -> Trivia { - return .spaces(1) + withoutSpaces() - } - - /// Returns this set of trivia, with all newlines removed except for one. - public func withOneLeadingNewline() -> Trivia { - return .newlines(1) + withoutNewlines() - } - - /// Returns this set of trivia, with all newlines removed except for one. - public func withOneTrailingNewline() -> Trivia { - return withoutNewlines() + .newlines(1) - } - - /// Walks through trivia looking for multiple separate trivia entities with - /// the same base kind, and condenses them. - /// `[.spaces(1), .spaces(2)]` becomes `[.spaces(3)]`. - public func condensed() -> Trivia { - guard var prev = first else { return self } - var pieces = [TriviaPiece]() - for piece in dropFirst() { - switch (prev, piece) { - case (.spaces(let l), .spaces(let r)): - prev = .spaces(l + r) - case (.tabs(let l), .tabs(let r)): - prev = .tabs(l + r) - case (.newlines(let l), .newlines(let r)): - prev = .newlines(l + r) - case (.carriageReturns(let l), .carriageReturns(let r)): - prev = .carriageReturns(l + r) - case (.carriageReturnLineFeeds(let l), .carriageReturnLineFeeds(let r)): - prev = .carriageReturnLineFeeds(l + r) - case (.verticalTabs(let l), .verticalTabs(let r)): - prev = .verticalTabs(l + r) - case (.unexpectedText(let l), .unexpectedText(let r)): - prev = .unexpectedText(l + r) - case (.formfeeds(let l), .formfeeds(let r)): - prev = .formfeeds(l + r) - default: - pieces.append(prev) - prev = piece - } - } - pieces.append(prev) - return Trivia(pieces: pieces) - } - - /// Returns `true` if this trivia contains any newlines. - public var containsNewlines: Bool { - return contains( - where: { - if case .newlines = $0 { return true } - return false - }) - } - - /// Returns `true` if this trivia contains any spaces. - public var containsSpaces: Bool { - return contains( - where: { - if case .spaces = $0 { return true } - if case .tabs = $0 { return true } - return false - }) - } -} diff --git a/Sources/SwiftFormatRules/AddModifierRewriter.swift b/Sources/SwiftFormatRules/AddModifierRewriter.swift deleted file mode 100644 index 495038d74..000000000 --- a/Sources/SwiftFormatRules/AddModifierRewriter.swift +++ /dev/null @@ -1,190 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift.org open source project -// -// Copyright (c) 2014 - 2019 Apple Inc. and the Swift project authors -// Licensed under Apache License v2.0 with Runtime Library Exception -// -// See https://swift.org/LICENSE.txt for license information -// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors -// -//===----------------------------------------------------------------------===// - -import SwiftFormatCore -import SwiftSyntax - -fileprivate final class AddModifierRewriter: SyntaxRewriter { - private let modifierKeyword: DeclModifierSyntax - - init(modifierKeyword: DeclModifierSyntax) { - self.modifierKeyword = modifierKeyword - } - - override func visit(_ node: VariableDeclSyntax) -> DeclSyntax { - // Check for modifiers, and, if none, insert the modifier and relocate trivia from the displaced - // token. - guard let modifiers = node.modifiers else { - let nodeWithModifier = node.addModifier(modifierKeyword) - let result = nodeByRelocatingTrivia(in: nodeWithModifier) { $0.modifiers } - return DeclSyntax(result) - } - // If variable already has an accessor keyword, skip (do not overwrite) - guard modifiers.accessLevelModifier == nil else { return DeclSyntax(node) } - - // Put accessor keyword before the first modifier keyword in the declaration - let newModifiers = modifiers.prepend(modifier: modifierKeyword) - return DeclSyntax(node.withModifiers(newModifiers)) - } - - override func visit(_ node: FunctionDeclSyntax) -> DeclSyntax { - // Check for modifiers, and, if none, insert the modifier and relocate trivia from the displaced - // token. - guard let modifiers = node.modifiers else { - let nodeWithModifier = node.addModifier(modifierKeyword) - let result = nodeByRelocatingTrivia(in: nodeWithModifier) { $0.modifiers } - return DeclSyntax(result) - } - guard modifiers.accessLevelModifier == nil else { return DeclSyntax(node) } - let newModifiers = modifiers.prepend(modifier: modifierKeyword) - return DeclSyntax(node.withModifiers(newModifiers)) - } - - override func visit(_ node: AssociatedtypeDeclSyntax) -> DeclSyntax { - // Check for modifiers, and, if none, insert the modifier and relocate trivia from the displaced - // token. - guard let modifiers = node.modifiers else { - let nodeWithModifier = node.addModifier(modifierKeyword) - let result = nodeByRelocatingTrivia(in: nodeWithModifier) { $0.modifiers } - return DeclSyntax(result) - } - guard modifiers.accessLevelModifier == nil else { return DeclSyntax(node) } - let newModifiers = modifiers.prepend(modifier: modifierKeyword) - return DeclSyntax(node.withModifiers(newModifiers)) - } - - override func visit(_ node: ClassDeclSyntax) -> DeclSyntax { - // Check for modifiers, and, if none, insert the modifier and relocate trivia from the displaced - // token. - guard let modifiers = node.modifiers else { - let nodeWithModifier = node.addModifier(modifierKeyword) - let result = nodeByRelocatingTrivia(in: nodeWithModifier) { $0.modifiers } - return DeclSyntax(result) - } - guard modifiers.accessLevelModifier == nil else { return DeclSyntax(node) } - let newModifiers = modifiers.prepend(modifier: modifierKeyword) - return DeclSyntax(node.withModifiers(newModifiers)) - } - - override func visit(_ node: EnumDeclSyntax) -> DeclSyntax { - // Check for modifiers, and, if none, insert the modifier and relocate trivia from the displaced - // token. - guard let modifiers = node.modifiers else { - let nodeWithModifier = node.addModifier(modifierKeyword) - let result = nodeByRelocatingTrivia(in: nodeWithModifier) { $0.modifiers } - return DeclSyntax(result) - } - guard modifiers.accessLevelModifier == nil else { return DeclSyntax(node) } - let newModifiers = modifiers.prepend(modifier: modifierKeyword) - return DeclSyntax(node.withModifiers(newModifiers)) - } - - override func visit(_ node: ProtocolDeclSyntax) -> DeclSyntax { - // Check for modifiers, and, if none, insert the modifier and relocate trivia from the displaced - // token. - guard let modifiers = node.modifiers else { - let nodeWithModifier = node.addModifier(modifierKeyword) - let result = nodeByRelocatingTrivia(in: nodeWithModifier) { $0.modifiers } - return DeclSyntax(result) - } - guard modifiers.accessLevelModifier == nil else { return DeclSyntax(node) } - let newModifiers = modifiers.prepend(modifier: modifierKeyword) - return DeclSyntax(node.withModifiers(newModifiers)) - } - - override func visit(_ node: StructDeclSyntax) -> DeclSyntax { - // Check for modifiers, and, if none, insert the modifier and relocate trivia from the displaced - // token. - guard let modifiers = node.modifiers else { - let nodeWithModifier = node.addModifier(modifierKeyword) - let result = nodeByRelocatingTrivia(in: nodeWithModifier) { $0.modifiers } - return DeclSyntax(result) - } - guard modifiers.accessLevelModifier == nil else { return DeclSyntax(node) } - let newModifiers = modifiers.prepend(modifier: modifierKeyword) - return DeclSyntax(node.withModifiers(newModifiers)) - } - - override func visit(_ node: TypealiasDeclSyntax) -> DeclSyntax { - // Check for modifiers, and, if none, insert the modifier and relocate trivia from the displaced - // token. - guard let modifiers = node.modifiers else { - let nodeWithModifier = node.addModifier(modifierKeyword) - let result = nodeByRelocatingTrivia(in: nodeWithModifier) { $0.modifiers } - return DeclSyntax(result) - } - guard modifiers.accessLevelModifier == nil else { return DeclSyntax(node) } - let newModifiers = modifiers.prepend(modifier: modifierKeyword) - return DeclSyntax(node.withModifiers(newModifiers)) - } - - override func visit(_ node: InitializerDeclSyntax) -> DeclSyntax { - // Check for modifiers, and, if none, insert the modifier and relocate trivia from the displaced - // token. - guard let modifiers = node.modifiers else { - let nodeWithModifier = node.addModifier(modifierKeyword) - let result = nodeByRelocatingTrivia(in: nodeWithModifier) { $0.modifiers } - return DeclSyntax(result) - } - guard modifiers.accessLevelModifier == nil else { return DeclSyntax(node) } - let newModifiers = modifiers.prepend(modifier: modifierKeyword) - return DeclSyntax(node.withModifiers(newModifiers)) - } - - override func visit(_ node: SubscriptDeclSyntax) -> DeclSyntax { - // Check for modifiers, and, if none, insert the modifier and relocate trivia from the displaced - // token. - guard let modifiers = node.modifiers else { - let nodeWithModifier = node.addModifier(modifierKeyword) - let result = nodeByRelocatingTrivia(in: nodeWithModifier) { $0.modifiers } - return DeclSyntax(result) - } - guard modifiers.accessLevelModifier == nil else { return DeclSyntax(node) } - let newModifiers = modifiers.prepend(modifier: modifierKeyword) - return DeclSyntax(node.withModifiers(newModifiers)) - } - - /// Moves trivia in the given node to correct the placement of potentially displaced trivia in the - /// node after the first modifier was added to the given node. The added modifier is assumed to be - /// the first and only modifier of the node. After the first modifier is added to a node, any - /// leading trivia on the token immediately after the modifier is considered displaced. This - /// method moves that displaced trivia onto the new modifier. When there is no displaced trivia, - /// this method does nothing and returns the given node as-is. - /// - Parameter node: A node that was updated to include a new modifier. - /// - Parameter modifiersProvider: A closure that returns all modifiers for the given node. - private func nodeByRelocatingTrivia( - in node: NodeType, - for modifiersProvider: (NodeType) -> ModifierListSyntax? - ) -> NodeType { - guard let modifier = modifiersProvider(node)?.firstAndOnly, - let movingLeadingTrivia = modifier.nextToken?.leadingTrivia - else { - // Otherwise, there's no trivia that needs to be relocated so the node is fine. - return node - } - let nodeWithTrivia = replaceTrivia( - on: node, - token: modifier.firstToken, - leadingTrivia: movingLeadingTrivia) - return replaceTrivia( - on: nodeWithTrivia, - token: modifiersProvider(nodeWithTrivia)?.first?.nextToken, - leadingTrivia: []) - } -} - -func addModifier( - declaration: DeclSyntax, - modifierKeyword: DeclModifierSyntax -) -> Syntax { - return AddModifierRewriter(modifierKeyword: modifierKeyword).visit(Syntax(declaration)) -} diff --git a/Sources/SwiftFormatRules/DeclSyntaxProtocol+Comments.swift b/Sources/SwiftFormatRules/DeclSyntaxProtocol+Comments.swift deleted file mode 100644 index a496c92c8..000000000 --- a/Sources/SwiftFormatRules/DeclSyntaxProtocol+Comments.swift +++ /dev/null @@ -1,168 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift.org open source project -// -// Copyright (c) 2014 - 2019 Apple Inc. and the Swift project authors -// Licensed under Apache License v2.0 with Runtime Library Exception -// -// See https://swift.org/LICENSE.txt for license information -// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors -// -//===----------------------------------------------------------------------===// - -import SwiftSyntax - -extension DeclSyntaxProtocol { - /// Searches through the leading trivia of this decl for a documentation comment. - var docComment: String? { - guard let tok = firstToken else { return nil } - var comment = [String]() - - // We need to skip trivia until we see the first comment. This trivia will include all the - // spaces and newlines before the doc comment. - var hasSeenFirstLineComment = false - - // Look through for discontiguous doc comments, separated by more than 1 newline. - gatherComments: for piece in tok.leadingTrivia.reversed() { - switch piece { - case .docBlockComment(let text): - // If we see a single doc block comment, then check to see if we've seen any line comments. - // If so, then use the line comments so far. Otherwise, return this block comment. - if hasSeenFirstLineComment { - break gatherComments - } - let blockComment = text.components(separatedBy: "\n") - // Removes the marks of the block comment. - var isTheFirstLine = true - let blockCommentWithoutMarks = blockComment.map { (line: String) -> String in - // Only the first line of the block comment start with '/**' - let markToRemove = isTheFirstLine ? "/**" : "* " - let trimmedLine = line.trimmingCharacters(in: .whitespaces) - if trimmedLine.starts(with: markToRemove) { - let numCharsToRemove = isTheFirstLine ? markToRemove.count : markToRemove.count - 1 - isTheFirstLine = false - return trimmedLine.hasSuffix("*/") - ? String(trimmedLine.dropFirst(numCharsToRemove).dropLast(3)) : String( - trimmedLine.dropFirst(numCharsToRemove)) - } else if trimmedLine == "*" { - return "" - } else if trimmedLine.hasSuffix("*/") { - return String(line.dropLast(3)) - } - isTheFirstLine = false - return line - } - - return blockCommentWithoutMarks.joined(separator: "\n").trimmingCharacters(in: .newlines) - case .docLineComment(let text): - // Mark that we've started grabbing sequential line comments and append it to the - // comment buffer. - hasSeenFirstLineComment = true - comment.append(text) - case .newlines(let n), .carriageReturns(let n), .carriageReturnLineFeeds(let n): - // Only allow for 1 newline between doc line comments, but allow for newlines between the - // doc comment and the declaration. - guard n == 1 || !hasSeenFirstLineComment else { break gatherComments } - case .spaces, .tabs: - // Skip all spaces/tabs. They're irrelevant here. - break - default: - if hasSeenFirstLineComment { - break gatherComments - } - } - } - - /// Removes the "///" from every line of comment - let docLineComments = comment.reversed().map { $0.dropFirst(3) } - return comment.isEmpty ? nil : docLineComments.joined(separator: "\n") - } - - var docCommentInfo: ParseComment? { - guard let docComment = self.docComment else { return nil } - let comments = docComment.components(separatedBy: .newlines) - var params = [ParseComment.Parameter]() - var commentParagraphs = [String]() - var currentSection: DocCommentSection = .commentParagraphs - var returnsDescription: String? - var throwsDescription: String? - // Takes the first sentence of the comment, and counts the number of lines it uses. - let oneSentenceSummary = docComment.components(separatedBy: ".").first - let numOfOneSentenceLines = oneSentenceSummary!.components(separatedBy: .newlines).count - - // Iterates to all the comments after the one sentence summary to find the parameter(s) - // return tags and get their description. - for line in comments.dropFirst(numOfOneSentenceLines) { - let trimmedLine = line.trimmingCharacters(in: .whitespaces) - - if trimmedLine.starts(with: "- Parameters") { - currentSection = .parameters - } else if trimmedLine.starts(with: "- Parameter") { - // If it's only a parameter it's information is inline with the parameter - // tag, just after the ':'. - guard let index = trimmedLine.firstIndex(of: ":") else { continue } - let name = trimmedLine.dropFirst("- Parameter".count)[..(from node: NodeType, nodeCreator: ([ItemType]) -> NodeType) -> NodeType - where NodeType.Element == ItemType { - var newItems = Array(node) - - // Because newlines belong to the _first_ token on the new line, if we remove a semicolon, we - // need to keep track of the fact that the next statement needs a new line. - var previousHadSemicolon = false - for (idx, item) in node.enumerated() { - - // Store the previous statement's semicolon-ness. - defer { previousHadSemicolon = item.semicolon != nil } - - // Check for semicolons in statements inside of the item, because code blocks may be nested - // inside of other code blocks. - guard let visitedItem = visit(Syntax(item)).as(ItemType.self) else { - return node - } - - // Check if we need to make any modifications (removing semicolon/adding newlines) - guard visitedItem != item || item.semicolon != nil || previousHadSemicolon else { - continue - } - - var newItem = visitedItem - defer { newItems[idx] = newItem } - - // Check if the leading trivia for this statement needs a new line. - if previousHadSemicolon, let firstToken = newItem.firstToken, - !firstToken.leadingTrivia.containsNewlines - { - let leadingTrivia = .newlines(1) + firstToken.leadingTrivia - newItem = replaceTrivia( - on: newItem, - token: firstToken, - leadingTrivia: leadingTrivia - ) - } - - // If there's a semicolon, diagnose and remove it. - if let semicolon = item.semicolon { - - // Exception: do not remove the semicolon if it is separating a 'do' statement from a - // 'while' statement. - if Syntax(item).as(CodeBlockItemSyntax.self)? - .children(viewMode: .sourceAccurate).first?.is(DoStmtSyntax.self) == true, - idx < node.count - 1 - { - let children = node.children(viewMode: .sourceAccurate) - let nextItem = children[children.index(after: item.index)] - if Syntax(nextItem).as(CodeBlockItemSyntax.self)? - .children(viewMode: .sourceAccurate).first?.is(WhileStmtSyntax.self) == true - { - continue - } - } - - // This discards any trailingTrivia from the semicolon. That trivia is at most some spaces, - // and the pretty printer adds any necessary spaces so it's safe to discard. - newItem = newItem.withSemicolon(nil) - if idx < node.count - 1 { - diagnose(.removeSemicolonAndMove, on: semicolon) - } else { - diagnose(.removeSemicolon, on: semicolon) - } - } - } - return nodeCreator(newItems) - } - - public override func visit(_ node: CodeBlockItemListSyntax) -> CodeBlockItemListSyntax { - return nodeByRemovingSemicolons(from: node, nodeCreator: CodeBlockItemListSyntax.init) - } - - public override func visit(_ node: MemberDeclListSyntax) -> MemberDeclListSyntax { - return nodeByRemovingSemicolons(from: node, nodeCreator: MemberDeclListSyntax.init) - } -} - -extension Finding.Message { - public static let removeSemicolon: Finding.Message = "remove ';'" - - public static let removeSemicolonAndMove: Finding.Message = - "remove ';' and move the next statement to a new line" -} diff --git a/Sources/SwiftFormatRules/DontRepeatTypeInStaticProperties.swift b/Sources/SwiftFormatRules/DontRepeatTypeInStaticProperties.swift deleted file mode 100644 index caeab7393..000000000 --- a/Sources/SwiftFormatRules/DontRepeatTypeInStaticProperties.swift +++ /dev/null @@ -1,109 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift.org open source project -// -// Copyright (c) 2014 - 2019 Apple Inc. and the Swift project authors -// Licensed under Apache License v2.0 with Runtime Library Exception -// -// See https://swift.org/LICENSE.txt for license information -// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors -// -//===----------------------------------------------------------------------===// - -import SwiftFormatCore -import SwiftSyntax - -/// Static properties of a type that return that type should not include a reference to their type. -/// -/// "Reference to their type" means that the property name includes part, or all, of the type. If -/// the type contains a namespace (i.e. `UIColor`) the namespace is ignored; -/// `public class var redColor: UIColor` would trigger this rule. -/// -/// Lint: Static properties of a type that return that type will yield a lint error. -public final class DontRepeatTypeInStaticProperties: SyntaxLintRule { - - public override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind { - diagnoseStaticMembers(node.members.members, endingWith: node.identifier.text) - return .skipChildren - } - - public override func visit(_ node: EnumDeclSyntax) -> SyntaxVisitorContinueKind { - diagnoseStaticMembers(node.members.members, endingWith: node.identifier.text) - return .skipChildren - } - - public override func visit(_ node: ProtocolDeclSyntax) -> SyntaxVisitorContinueKind { - diagnoseStaticMembers(node.members.members, endingWith: node.identifier.text) - return .skipChildren - } - - public override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind { - diagnoseStaticMembers(node.members.members, endingWith: node.identifier.text) - return .skipChildren - } - - public override func visit(_ node: ExtensionDeclSyntax) -> SyntaxVisitorContinueKind { - let members = node.members.members - - switch Syntax(node.extendedType).as(SyntaxEnum.self) { - case .simpleTypeIdentifier(let simpleType): - diagnoseStaticMembers(members, endingWith: simpleType.name.text) - case .memberTypeIdentifier(let memberType): - // We don't need to drill recursively into this structure because types with more than two - // components are constructed left-heavy; that is, `A.B.C.D` is structured as `((A.B).C).D`, - // and the final component of the top type is what we want. - diagnoseStaticMembers(members, endingWith: memberType.name.text) - default: - // Do nothing for non-nominal types. If Swift adds support for extensions on non-nominals, - // we'll need to update this if we need to support some subset of those. - break - } - - return .skipChildren - } - - /// Iterates over the static/class properties in the given member list and diagnoses any where the - /// name has the containing type name (excluding possible namespace prefixes, like `NS` or `UI`) - /// as a suffix. - private func diagnoseStaticMembers(_ members: MemberDeclListSyntax, endingWith typeName: String) { - for member in members { - guard - let varDecl = member.decl.as(VariableDeclSyntax.self), - let modifiers = varDecl.modifiers, - modifiers.has(modifier: "static") || modifiers.has(modifier: "class") - else { continue } - - let bareTypeName = removingPossibleNamespacePrefix(from: typeName) - - for pattern in varDecl.identifiers { - let varName = pattern.identifier.text - if varName.contains(bareTypeName) { - diagnose(.removeTypeFromName(name: varName, type: bareTypeName), on: varDecl) - } - } - } - } - - /// Returns the portion of the given string that excludes a possible Objective-C-style capitalized - /// namespace prefix (a leading sequence of more than one uppercase letter). - /// - /// If the name has zero or one leading uppercase letters, the entire name is returned. - private func removingPossibleNamespacePrefix(from name: String) -> Substring { - guard let first = name.first, first.isUppercase else { return name[...] } - - for index in name.indices.dropLast() { - let nextIndex = name.index(after: index) - if name[index].isUppercase && !name[nextIndex].isUppercase { - return name[index...] - } - } - - return name[...] - } -} - -extension Finding.Message { - public static func removeTypeFromName(name: String, type: Substring) -> Finding.Message { - "remove '\(type)' from '\(name)'" - } -} diff --git a/Sources/SwiftFormatRules/FullyIndirectEnum.swift b/Sources/SwiftFormatRules/FullyIndirectEnum.swift deleted file mode 100644 index 384293ad5..000000000 --- a/Sources/SwiftFormatRules/FullyIndirectEnum.swift +++ /dev/null @@ -1,114 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift.org open source project -// -// Copyright (c) 2014 - 2019 Apple Inc. and the Swift project authors -// Licensed under Apache License v2.0 with Runtime Library Exception -// -// See https://swift.org/LICENSE.txt for license information -// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors -// -//===----------------------------------------------------------------------===// - -import SwiftFormatCore -import SwiftSyntax - -/// If all cases of an enum are `indirect`, the entire enum should be marked `indirect`. -/// -/// Lint: If every case of an enum is `indirect`, but the enum itself is not, a lint error is -/// raised. -/// -/// Format: Enums where all cases are `indirect` will be rewritten such that the enum is marked -/// `indirect`, and each case is not. -public final class FullyIndirectEnum: SyntaxFormatRule { - - public override func visit(_ node: EnumDeclSyntax) -> DeclSyntax { - let enumMembers = node.members.members - guard let enumModifiers = node.modifiers, - !enumModifiers.has(modifier: "indirect"), - allCasesAreIndirect(in: enumMembers) - else { - return DeclSyntax(node) - } - - diagnose(.moveIndirectKeywordToEnumDecl(name: node.identifier.text), on: node.identifier) - - // Removes 'indirect' keyword from cases, reformats - let newMembers = enumMembers.map { - (member: MemberDeclListItemSyntax) -> MemberDeclListItemSyntax in - guard let caseMember = member.decl.as(EnumCaseDeclSyntax.self), - let modifiers = caseMember.modifiers, - modifiers.has(modifier: "indirect"), - let firstModifier = modifiers.first - else { - return member - } - - let newCase = caseMember.withModifiers(modifiers.remove(name: "indirect")) - let formattedCase = formatCase( - unformattedCase: newCase, leadingTrivia: firstModifier.leadingTrivia) - return member.withDecl(DeclSyntax(formattedCase)) - } - - // If the `indirect` keyword being added would be the first token in the decl, we need to move - // the leading trivia from the `enum` keyword to the new modifier to preserve the existing - // line breaks/comments/indentation. - let firstTok = node.firstToken! - let leadingTrivia: Trivia - let newEnumDecl: EnumDeclSyntax - - if firstTok.tokenKind == .enumKeyword { - leadingTrivia = firstTok.leadingTrivia - newEnumDecl = replaceTrivia( - on: node, token: node.firstToken, leadingTrivia: []) - } else { - leadingTrivia = [] - newEnumDecl = node - } - - let newModifier = DeclModifierSyntax( - name: TokenSyntax.identifier( - "indirect", leadingTrivia: leadingTrivia, trailingTrivia: .spaces(1)), detail: nil) - - let newMemberBlock = node.members.withMembers(MemberDeclListSyntax(newMembers)) - return DeclSyntax(newEnumDecl.addModifier(newModifier).withMembers(newMemberBlock)) - } - - /// Returns a value indicating whether all enum cases in the given list are indirect. - /// - /// Note that if the enum has no cases, this returns false. - private func allCasesAreIndirect(in members: MemberDeclListSyntax) -> Bool { - var hadCases = false - for member in members { - if let caseMember = member.decl.as(EnumCaseDeclSyntax.self) { - hadCases = true - guard let modifiers = caseMember.modifiers, modifiers.has(modifier: "indirect") else { - return false - } - } - } - return hadCases - } - - /// Transfers given leading trivia to the first token in the case declaration. - private func formatCase( - unformattedCase: EnumCaseDeclSyntax, - leadingTrivia: Trivia? - ) -> EnumCaseDeclSyntax { - if let modifiers = unformattedCase.modifiers, let first = modifiers.first { - return replaceTrivia( - on: unformattedCase, token: first.firstToken, leadingTrivia: leadingTrivia - ) - } else { - return replaceTrivia( - on: unformattedCase, token: unformattedCase.caseKeyword, leadingTrivia: leadingTrivia - ) - } - } -} - -extension Finding.Message { - public static func moveIndirectKeywordToEnumDecl(name: String) -> Finding.Message { - "move 'indirect' to \(name) enum declaration when all cases are indirect" - } -} diff --git a/Sources/SwiftFormatRules/ModifierListSyntax+Convenience.swift b/Sources/SwiftFormatRules/ModifierListSyntax+Convenience.swift deleted file mode 100644 index f0ffafead..000000000 --- a/Sources/SwiftFormatRules/ModifierListSyntax+Convenience.swift +++ /dev/null @@ -1,105 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift.org open source project -// -// Copyright (c) 2014 - 2019 Apple Inc. and the Swift project authors -// Licensed under Apache License v2.0 with Runtime Library Exception -// -// See https://swift.org/LICENSE.txt for license information -// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors -// -//===----------------------------------------------------------------------===// - -import SwiftSyntax - -extension ModifierListSyntax { - - func has(modifier: String) -> Bool { - return contains { $0.name.text == modifier } - } - - func has(modifier: TokenKind) -> Bool { - return contains { $0.name.tokenKind == modifier } - } - - /// Returns the declaration's access level modifier, if present. - var accessLevelModifier: DeclModifierSyntax? { - for modifier in self { - switch modifier.name.tokenKind { - case .publicKeyword, .privateKeyword, .fileprivateKeyword, .internalKeyword: - return modifier - default: - continue - } - } - return nil - } - - /// Returns modifier list without the given modifier. - func remove(name: String) -> ModifierListSyntax { - guard has(modifier: name) else { return self } - for mod in self { - if mod.name.text == name { - return removing(childAt: mod.indexInParent) - } - } - return self - } - - /// Returns a formatted declaration modifier token with the given name. - func createModifierToken(name: String) -> DeclModifierSyntax { - let id = TokenSyntax.identifier(name, trailingTrivia: .spaces(1)) - let newModifier = DeclModifierSyntax(name: id, detail: nil) - return newModifier - } - - /// Returns modifiers with the given modifier inserted at the given index. - /// Preserves existing trivia and formats new trivia, given true for 'formatTrivia.' - func insert( - modifier: DeclModifierSyntax, at index: Int, - formatTrivia: Bool = true - ) -> ModifierListSyntax { - guard index >= 0, index <= count else { return self } - - var newModifiers: [DeclModifierSyntax] = [] - newModifiers.append(contentsOf: self) - - let modifier = formatTrivia - ? replaceTrivia( - on: modifier, - token: modifier.name, - trailingTrivia: .spaces(1)) : modifier - - if index == 0 { - guard formatTrivia else { return inserting(modifier, at: index) } - guard let firstMod = first, let firstTok = firstMod.firstToken else { - return inserting(modifier, at: index) - } - let formattedMod = replaceTrivia( - on: modifier, - token: modifier.firstToken, - leadingTrivia: firstTok.leadingTrivia) - newModifiers[0] = replaceTrivia( - on: firstMod, - token: firstTok, - leadingTrivia: [], - trailingTrivia: .spaces(1)) - newModifiers.insert(formattedMod, at: 0) - return ModifierListSyntax(newModifiers) - } else { - return inserting(modifier, at: index) - } - } - - /// Returns modifier list with the given modifier at the end. - /// Trivia manipulation optional by 'formatTrivia' - func append(modifier: DeclModifierSyntax, formatTrivia: Bool = true) -> ModifierListSyntax { - return insert(modifier: modifier, at: count, formatTrivia: formatTrivia) - } - - /// Returns modifier list with the given modifier at the beginning. - /// Trivia manipulation optional by 'formatTrivia' - func prepend(modifier: DeclModifierSyntax, formatTrivia: Bool = true) -> ModifierListSyntax { - return insert(modifier: modifier, at: 0, formatTrivia: formatTrivia) - } -} diff --git a/Sources/SwiftFormatRules/NoAccessLevelOnExtensionDeclaration.swift b/Sources/SwiftFormatRules/NoAccessLevelOnExtensionDeclaration.swift deleted file mode 100644 index 84b8eb1c9..000000000 --- a/Sources/SwiftFormatRules/NoAccessLevelOnExtensionDeclaration.swift +++ /dev/null @@ -1,111 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift.org open source project -// -// Copyright (c) 2014 - 2019 Apple Inc. and the Swift project authors -// Licensed under Apache License v2.0 with Runtime Library Exception -// -// See https://swift.org/LICENSE.txt for license information -// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors -// -//===----------------------------------------------------------------------===// - -import SwiftFormatCore -import SwiftSyntax - -/// Specifying an access level for an extension declaration is forbidden. -/// -/// Lint: Specifying an access level for an extension declaration yields a lint error. -/// -/// Format: The access level is removed from the extension declaration and is added to each -/// declaration in the extension; declarations with redundant access levels (e.g. -/// `internal`, as that is the default access level) have the explicit access level removed. -/// -/// TODO: Find a better way to access modifiers and keyword tokens besides casting each declaration -public final class NoAccessLevelOnExtensionDeclaration: SyntaxFormatRule { - - public override func visit(_ node: ExtensionDeclSyntax) -> DeclSyntax { - guard let modifiers = node.modifiers, modifiers.count != 0 else { return DeclSyntax(node) } - guard let accessKeyword = modifiers.accessLevelModifier else { return DeclSyntax(node) } - - let keywordKind = accessKeyword.name.tokenKind - switch keywordKind { - // Public, private, or fileprivate keywords need to be moved to members - case .publicKeyword, .privateKeyword, .fileprivateKeyword: - diagnose(.moveAccessKeyword(keyword: accessKeyword.name.text), on: accessKeyword) - - // The effective access level of the members of a `private` extension is `fileprivate`, so - // we have to update the keyword to ensure that the result is correct. - let accessKeywordToAdd: DeclModifierSyntax - if keywordKind == .privateKeyword { - accessKeywordToAdd - = accessKeyword.withName(accessKeyword.name.withKind(.fileprivateKeyword)) - } else { - accessKeywordToAdd = accessKeyword - } - - let newMembers = MemberDeclBlockSyntax( - leftBrace: node.members.leftBrace, - members: addMemberAccessKeywords(memDeclBlock: node.members, keyword: accessKeywordToAdd), - rightBrace: node.members.rightBrace) - let newKeyword = replaceTrivia( - on: node.extensionKeyword, - token: node.extensionKeyword, - leadingTrivia: accessKeyword.leadingTrivia) - let result = node.withMembers(newMembers) - .withModifiers(modifiers.remove(name: accessKeyword.name.text)) - .withExtensionKeyword(newKeyword) - return DeclSyntax(result) - - // Internal keyword redundant, delete - case .internalKeyword: - diagnose( - .removeRedundantAccessKeyword(name: node.extendedType.description), - on: accessKeyword) - let newKeyword = replaceTrivia( - on: node.extensionKeyword, - token: node.extensionKeyword, - leadingTrivia: accessKeyword.leadingTrivia) - let result = node.withModifiers(modifiers.remove(name: accessKeyword.name.text)) - .withExtensionKeyword(newKeyword) - return DeclSyntax(result) - - default: - break - } - return DeclSyntax(node) - } - - // Adds given keyword to all members in declaration block - private func addMemberAccessKeywords( - memDeclBlock: MemberDeclBlockSyntax, - keyword: DeclModifierSyntax - ) -> MemberDeclListSyntax { - var newMembers: [MemberDeclListItemSyntax] = [] - let formattedKeyword = replaceTrivia( - on: keyword, - token: keyword.name, - leadingTrivia: []) - - for memberItem in memDeclBlock.members { - let member = memberItem.decl - guard - // addModifier relocates trivia for any token(s) displaced by the new modifier. - let newDecl = addModifier(declaration: member, modifierKeyword: formattedKeyword) - .as(DeclSyntax.self) - else { continue } - newMembers.append(memberItem.withDecl(newDecl)) - } - return MemberDeclListSyntax(newMembers) - } -} - -extension Finding.Message { - public static func removeRedundantAccessKeyword(name: String) -> Finding.Message { - "remove redundant 'internal' access keyword from \(name)" - } - - public static func moveAccessKeyword(keyword: String) -> Finding.Message { - "specify \(keyword) access level for each member inside the extension" - } -} diff --git a/Sources/SwiftFormatRules/NoAssignmentInExpressions.swift b/Sources/SwiftFormatRules/NoAssignmentInExpressions.swift deleted file mode 100644 index 16ddb416f..000000000 --- a/Sources/SwiftFormatRules/NoAssignmentInExpressions.swift +++ /dev/null @@ -1,116 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift.org open source project -// -// Copyright (c) 2014 - 2019 Apple Inc. and the Swift project authors -// Licensed under Apache License v2.0 with Runtime Library Exception -// -// See https://swift.org/LICENSE.txt for license information -// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors -// -//===----------------------------------------------------------------------===// - -import SwiftFormatCore -import SwiftSyntax - -/// Assignment expressions must be their own statements. -/// -/// Assignment should not be used in an expression context that expects a `Void` value. For example, -/// assigning a variable within a `return` statement existing a `Void` function is prohibited. -/// -/// Lint: If an assignment expression is found in a position other than a standalone statement, a -/// lint finding is emitted. -/// -/// Format: A `return` statement containing an assignment expression is expanded into two separate -/// statements. -public final class NoAssignmentInExpressions: SyntaxFormatRule { - public override func visit(_ node: InfixOperatorExprSyntax) -> ExprSyntax { - // Diagnose any assignment that isn't directly a child of a `CodeBlockItem` (which would be the - // case if it was its own statement). - if isAssignmentExpression(node) && node.parent?.is(CodeBlockItemSyntax.self) == false { - diagnose(.moveAssignmentToOwnStatement, on: node) - } - return ExprSyntax(node) - } - - public override func visit(_ node: CodeBlockItemListSyntax) -> CodeBlockItemListSyntax { - var newItems = [CodeBlockItemSyntax]() - newItems.reserveCapacity(node.count) - - for item in node { - // Make sure to visit recursively so that any nested decls get processed first. - let newItem = visit(item) - - // Rewrite any `return ` expressions as `return`. - switch newItem.item { - case .stmt(let stmt): - guard - let returnStmt = stmt.as(ReturnStmtSyntax.self), - let assignmentExpr = assignmentExpression(from: returnStmt) - else { - // Head to the default case where we just keep the original item. - fallthrough - } - - // Move the leading trivia from the `return` statement to the new assignment statement, - // since that's a more sensible place than between the two. - newItems.append( - CodeBlockItemSyntax( - item: .expr(ExprSyntax(assignmentExpr)), - semicolon: nil, - errorTokens: nil - ) - .withLeadingTrivia( - (returnStmt.leadingTrivia ?? []) + (assignmentExpr.leadingTrivia ?? [])) - .withTrailingTrivia([])) - newItems.append( - CodeBlockItemSyntax( - item: .stmt(StmtSyntax(returnStmt.withExpression(nil))), - semicolon: nil, - errorTokens: nil - ) - .withLeadingTrivia([.newlines(1)]) - .withTrailingTrivia(returnStmt.trailingTrivia?.withoutLeadingSpaces() ?? [])) - - default: - newItems.append(newItem) - } - } - - return CodeBlockItemListSyntax(newItems) - } - - /// Extracts and returns the assignment expression in the given `return` statement, if there was - /// one. - /// - /// If the `return` statement did not have an expression or if its expression was not an - /// assignment expression, nil is returned. - private func assignmentExpression(from returnStmt: ReturnStmtSyntax) -> InfixOperatorExprSyntax? { - guard - let returnExpr = returnStmt.expression, - let infixOperatorExpr = returnExpr.as(InfixOperatorExprSyntax.self) - else { - return nil - } - return isAssignmentExpression(infixOperatorExpr) ? infixOperatorExpr : nil - } - - /// Returns a value indicating whether the given infix operator expression is an assignment - /// expression (either simple assignment with `=` or compound assignment with an operator like - /// `+=`). - private func isAssignmentExpression(_ expr: InfixOperatorExprSyntax) -> Bool { - if expr.operatorOperand.is(AssignmentExprSyntax.self) { - return true - } - guard let binaryOp = expr.operatorOperand.as(BinaryOperatorExprSyntax.self) else { - return false - } - return context.operatorTable.infixOperator(named: binaryOp.operatorToken.text)?.precedenceGroup - == "AssignmentPrecedence" - } -} - -extension Finding.Message { - public static let moveAssignmentToOwnStatement: Finding.Message = - "move assignment expression into its own statement" -} diff --git a/Sources/SwiftFormatRules/NoParensAroundConditions.swift b/Sources/SwiftFormatRules/NoParensAroundConditions.swift deleted file mode 100644 index a2aa8df1e..000000000 --- a/Sources/SwiftFormatRules/NoParensAroundConditions.swift +++ /dev/null @@ -1,102 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift.org open source project -// -// Copyright (c) 2014 - 2019 Apple Inc. and the Swift project authors -// Licensed under Apache License v2.0 with Runtime Library Exception -// -// See https://swift.org/LICENSE.txt for license information -// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors -// -//===----------------------------------------------------------------------===// - -import SwiftFormatCore -import SwiftSyntax - -/// Enforces rules around parentheses in conditions or matched expressions. -/// -/// Parentheses are not used around any condition of an `if`, `guard`, or `while` statement, or -/// around the matched expression in a `switch` statement. -/// -/// Lint: If a top-most expression in a `switch`, `if`, `guard`, or `while` statement is surrounded -/// by parentheses, and it does not include a function call with a trailing closure, a lint -/// error is raised. -/// -/// Format: Parentheses around such expressions are removed, if they do not cause a parse ambiguity. -/// Specifically, parentheses are allowed if and only if the expression contains a function -/// call with a trailing closure. -public final class NoParensAroundConditions: SyntaxFormatRule { - private func extractExpr(_ tuple: TupleExprSyntax) -> ExprSyntax { - assert(tuple.elementList.count == 1) - let expr = tuple.elementList.first!.expression - - // If the condition is a function with a trailing closure, removing the - // outer set of parentheses introduces a parse ambiguity. - if let fnCall = expr.as(FunctionCallExprSyntax.self), fnCall.trailingClosure != nil { - return ExprSyntax(tuple) - } - - diagnose(.removeParensAroundExpression, on: expr) - - guard - let visitedTuple = visit(tuple).as(TupleExprSyntax.self), - let visitedExpr = visitedTuple.elementList.first?.expression - else { - return expr - } - return replaceTrivia( - on: visitedExpr, - token: visitedExpr.lastToken, - leadingTrivia: visitedTuple.leftParen.leadingTrivia, - trailingTrivia: visitedTuple.rightParen.trailingTrivia - ) - } - - public override func visit(_ node: IfStmtSyntax) -> StmtSyntax { - let conditions = visit(node.conditions) - var result = node.withIfKeyword(node.ifKeyword.withOneTrailingSpace()) - .withConditions(conditions) - .withBody(visit(node.body)) - if let elseBody = node.elseBody { - result = result.withElseBody(visit(elseBody)) - } - return StmtSyntax(result) - } - - public override func visit(_ node: ConditionElementSyntax) -> ConditionElementSyntax { - guard let tup = node.condition.as(TupleExprSyntax.self), - tup.elementList.firstAndOnly != nil - else { - return super.visit(node) - } - return node.withCondition(.expression(extractExpr(tup))) - } - - /// FIXME(hbh): Parsing for SwitchStmtSyntax is not implemented. - public override func visit(_ node: SwitchStmtSyntax) -> StmtSyntax { - guard let tup = node.expression.as(TupleExprSyntax.self), - tup.elementList.firstAndOnly != nil - else { - return super.visit(node) - } - return StmtSyntax( - node.withExpression(extractExpr(tup)).withCases(visit(node.cases))) - } - - public override func visit(_ node: RepeatWhileStmtSyntax) -> StmtSyntax { - guard let tup = node.condition.as(TupleExprSyntax.self), - tup.elementList.firstAndOnly != nil - else { - return super.visit(node) - } - let newNode = node.withCondition(extractExpr(tup)) - .withWhileKeyword(node.whileKeyword.withOneTrailingSpace()) - .withBody(visit(node.body)) - return StmtSyntax(newNode) - } -} - -extension Finding.Message { - public static let removeParensAroundExpression: Finding.Message = - "remove parentheses around this expression" -} diff --git a/Sources/SwiftFormatRules/NoPlaygroundLiterals.swift b/Sources/SwiftFormatRules/NoPlaygroundLiterals.swift deleted file mode 100644 index c06a69fc6..000000000 --- a/Sources/SwiftFormatRules/NoPlaygroundLiterals.swift +++ /dev/null @@ -1,31 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift.org open source project -// -// Copyright (c) 2014 - 2019 Apple Inc. and the Swift project authors -// Licensed under Apache License v2.0 with Runtime Library Exception -// -// See https://swift.org/LICENSE.txt for license information -// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors -// -//===----------------------------------------------------------------------===// - -import Foundation -import SwiftFormatCore -import SwiftSyntax - -/// Playground literals (e.g. `#colorLiteral`) are forbidden. -/// -/// For the case of `#colorLiteral`, if `import AppKit` is present, `NSColor` will be used. -/// If `import UIKit` is present, `UIColor` will be used. -/// If neither `import` is present, `resolveAmbiguousColor` will be used to determine behavior. -/// -/// Lint: Using a playground literal will yield a lint error. -/// -/// Format: The playground literal will be replaced with the matching class; e.g. -/// `#colorLiteral(...)` becomes `UIColor(...)` -/// -/// Configuration: resolveAmbiguousColor -public final class NoPlaygroundLiterals: SyntaxFormatRule { - -} diff --git a/Sources/SwiftFormatRules/ReplaceTrivia.swift b/Sources/SwiftFormatRules/ReplaceTrivia.swift deleted file mode 100644 index 51833300c..000000000 --- a/Sources/SwiftFormatRules/ReplaceTrivia.swift +++ /dev/null @@ -1,63 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift.org open source project -// -// Copyright (c) 2014 - 2019 Apple Inc. and the Swift project authors -// Licensed under Apache License v2.0 with Runtime Library Exception -// -// See https://swift.org/LICENSE.txt for license information -// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors -// -//===----------------------------------------------------------------------===// - -import Foundation -import SwiftSyntax - -/// Rewriter that replaces the trivia of a given token inside a node with the provided -/// leading/trailing trivia. -fileprivate final class ReplaceTrivia: SyntaxRewriter { - private let leadingTrivia: Trivia? - private let trailingTrivia: Trivia? - private let token: TokenSyntax - - init(token: TokenSyntax, leadingTrivia: Trivia? = nil, trailingTrivia: Trivia? = nil) { - self.token = token - self.leadingTrivia = leadingTrivia - self.trailingTrivia = trailingTrivia - } - - override func visit(_ token: TokenSyntax) -> TokenSyntax { - guard token == self.token else { return token } - return token - .withLeadingTrivia(leadingTrivia ?? token.leadingTrivia) - .withTrailingTrivia(trailingTrivia ?? token.trailingTrivia) - } -} - -/// Replaces the leading or trailing trivia of a given node to the provided -/// leading and trailing trivia. -/// - Parameters: -/// - node: The Syntax node whose containing token will have its trivia replaced. -/// - token: The token whose trivia will be replaced. Must be a child of `node`. If `nil`, this -/// function is a no-op. -/// - leadingTrivia: The new leading trivia, if applicable. If nothing is provided, no change -/// will be made. -/// - trailingTrivia: The new trailing trivia, if applicable. If nothing is provided, no change -/// will be made. -/// - Note: Most of the time this function is called, `token` will be `node.firstToken` or -/// `node.lastToken`, which is almost always not `nil`. But in some very rare cases, like a -/// collection, it may be empty and not have a `firstToken`. Since there's nothing to -/// replace if token is `nil`, this function just exits early. -func replaceTrivia( - on node: SyntaxType, - token: TokenSyntax?, - leadingTrivia: Trivia? = nil, - trailingTrivia: Trivia? = nil -) -> SyntaxType { - guard let token = token else { return node } - return ReplaceTrivia( - token: token, - leadingTrivia: leadingTrivia, - trailingTrivia: trailingTrivia - ).visit(Syntax(node)).as(SyntaxType.self)! -} diff --git a/Sources/SwiftFormatRules/TokenSyntax+Convenience.swift b/Sources/SwiftFormatRules/TokenSyntax+Convenience.swift deleted file mode 100644 index 078b54fd1..000000000 --- a/Sources/SwiftFormatRules/TokenSyntax+Convenience.swift +++ /dev/null @@ -1,37 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift.org open source project -// -// Copyright (c) 2014 - 2019 Apple Inc. and the Swift project authors -// Licensed under Apache License v2.0 with Runtime Library Exception -// -// See https://swift.org/LICENSE.txt for license information -// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors -// -//===----------------------------------------------------------------------===// - -import SwiftSyntax - -extension TokenSyntax { - /// Returns this token with only one space at the end of its trailing trivia. - func withOneTrailingSpace() -> TokenSyntax { - return withTrailingTrivia(trailingTrivia.withOneTrailingSpace()) - } - - /// Returns this token with only one space at the beginning of its leading - /// trivia. - func withOneLeadingSpace() -> TokenSyntax { - return withLeadingTrivia(leadingTrivia.withOneLeadingSpace()) - } - - /// Returns this token with only one newline at the end of its leading trivia. - func withOneTrailingNewline() -> TokenSyntax { - return withTrailingTrivia(trailingTrivia.withOneTrailingNewline()) - } - - /// Returns this token with only one newline at the beginning of its leading - /// trivia. - func withOneLeadingNewline() -> TokenSyntax { - return withLeadingTrivia(leadingTrivia.withOneLeadingNewline()) - } -} diff --git a/Sources/SwiftFormatRules/VarDeclSyntax+Convenience.swift b/Sources/SwiftFormatRules/VarDeclSyntax+Convenience.swift deleted file mode 100644 index 9dddaa7e6..000000000 --- a/Sources/SwiftFormatRules/VarDeclSyntax+Convenience.swift +++ /dev/null @@ -1,41 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift.org open source project -// -// Copyright (c) 2014 - 2019 Apple Inc. and the Swift project authors -// Licensed under Apache License v2.0 with Runtime Library Exception -// -// See https://swift.org/LICENSE.txt for license information -// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors -// -//===----------------------------------------------------------------------===// - -import SwiftSyntax - -extension VariableDeclSyntax { - - /// Returns array of all identifiers listed in the declaration. - var identifiers: [IdentifierPatternSyntax] { - var ids: [IdentifierPatternSyntax] = [] - for binding in bindings { - guard let id = binding.pattern.as(IdentifierPatternSyntax.self) else { continue } - ids.append(id) - } - return ids - } - - /// Returns the first identifier. - var firstIdentifier: IdentifierPatternSyntax { - return identifiers[0] - } - - /// Returns the first type explicitly stated in the declaration, if present. - var firstType: TypeSyntax? { - return bindings.first?.typeAnnotation?.type - } - - /// Returns the first initializer clause, if present. - var firstInitializer: InitializerClauseSyntax? { - return bindings.first?.initializer - } -} diff --git a/Sources/SwiftFormatTestSupport/DiagnosingTestCase.swift b/Sources/SwiftFormatTestSupport/DiagnosingTestCase.swift deleted file mode 100644 index 6dad4f204..000000000 --- a/Sources/SwiftFormatTestSupport/DiagnosingTestCase.swift +++ /dev/null @@ -1,172 +0,0 @@ -import SwiftFormatConfiguration -import SwiftFormatCore -import SwiftFormatRules -import SwiftSyntax -import XCTest - -/// DiagnosingTestCase is an XCTestCase subclass meant to inject diagnostic-specific testing -/// routines into specific formatting test cases. -open class DiagnosingTestCase: XCTestCase { - /// Set during lint tests to indicate that we should check for unasserted diagnostics when the - /// test is torn down and fail if there were any. - public var shouldCheckForUnassertedDiagnostics = false - - /// A helper that will keep track of the findings that were emitted. - private var consumer = TestingFindingConsumer() - - override open func setUp() { - shouldCheckForUnassertedDiagnostics = false - } - - override open func tearDown() { - guard shouldCheckForUnassertedDiagnostics else { return } - - // This will emit a test failure if a diagnostic is thrown but we don't explicitly call - // XCTAssertDiagnosed for it. - for finding in consumer.emittedFindings { - XCTFail("unexpected finding '\(finding)' emitted") - } - } - - /// Creates and returns a new `Context` from the given syntax tree and configuration. - /// - /// The returned context is configured with a diagnostic consumer that records diagnostics emitted - /// during the tests, which can then be asserted using the `XCTAssertDiagnosed` and - /// `XCTAssertNotDiagnosed` methods. - public func makeContext(sourceFileSyntax: SourceFileSyntax, configuration: Configuration? = nil) - -> Context - { - consumer = TestingFindingConsumer() - let context = Context( - configuration: configuration ?? Configuration(), - operatorTable: .standardOperators, - findingConsumer: consumer.consume, - fileURL: URL(fileURLWithPath: "/tmp/test.swift"), - sourceFileSyntax: sourceFileSyntax, - ruleNameCache: ruleNameCache) - return context - } - - /// Stops tracking diagnostics emitted during formatting/linting. - /// - /// This used by the pretty-printer tests to suppress any diagnostics that might be emitted during - /// the second format pass (which checks for idempotence). - public func stopTrackingDiagnostics() { - consumer.stopTrackingFindings() - } - - /// Asserts that a specific diagnostic message was emitted. - /// - /// - Parameters: - /// - message: The diagnostic message expected to be emitted. - /// - file: The file in which failure occurred. Defaults to the file name of the test case in - /// which this function was called. - /// - line: The line number on which failure occurred. Defaults to the line number on which this - /// function was called. - public final func XCTAssertDiagnosed( - _ message: Finding.Message, - line diagnosticLine: Int? = nil, - column diagnosticColumn: Int? = nil, - file: StaticString = #file, - line: UInt = #line - ) { - let wasEmitted: Bool - if let diagnosticLine = diagnosticLine, let diagnosticColumn = diagnosticColumn { - wasEmitted = consumer.popFinding( - containing: message.text, atLine: diagnosticLine, column: diagnosticColumn) - } else { - wasEmitted = consumer.popFinding(containing: message.text) - } - if !wasEmitted { - XCTFail("diagnostic '\(message.text)' not emitted", file: file, line: line) - } - } - - /// Asserts that a specific diagnostic message was not emitted. - /// - /// - Parameters: - /// - message: The diagnostic message expected to not be emitted. - /// - file: The file in which failure occurred. Defaults to the file name of the test case in - /// which this function was called. - /// - line: The line number on which failure occurred. Defaults to the line number on which this - /// function was called. - public final func XCTAssertNotDiagnosed( - _ message: Finding.Message, - file: StaticString = #file, - line: UInt = #line - ) { - let wasEmitted = consumer.popFinding(containing: message.text) - XCTAssertFalse( - wasEmitted, - "diagnostic '\(message.text)' should not have been emitted", - file: file, line: line) - } - - /// Asserts that the two strings are equal, providing Unix `diff`-style output if they are not. - /// - /// - Parameters: - /// - actual: The actual string. - /// - expected: The expected string. - /// - message: An optional description of the failure. - /// - file: The file in which failure occurred. Defaults to the file name of the test case in - /// which this function was called. - /// - line: The line number on which failure occurred. Defaults to the line number on which this - /// function was called. - public final func XCTAssertStringsEqualWithDiff( - _ actual: String, - _ expected: String, - _ message: String = "", - file: StaticString = #file, - line: UInt = #line - ) { - // Use `CollectionDifference` on supported platforms to get `diff`-like line-based output. On - // older platforms, fall back to simple string comparison. - if #available(macOS 10.15, *) { - let actualLines = actual.components(separatedBy: .newlines) - let expectedLines = expected.components(separatedBy: .newlines) - - let difference = actualLines.difference(from: expectedLines) - if difference.isEmpty { return } - - var result = "" - - var insertions = [Int: String]() - var removals = [Int: String]() - - for change in difference { - switch change { - case .insert(let offset, let element, _): - insertions[offset] = element - case .remove(let offset, let element, _): - removals[offset] = element - } - } - - var expectedLine = 0 - var actualLine = 0 - - while expectedLine < expectedLines.count || actualLine < actualLines.count { - if let removal = removals[expectedLine] { - result += "-\(removal)\n" - expectedLine += 1 - } else if let insertion = insertions[actualLine] { - result += "+\(insertion)\n" - actualLine += 1 - } else { - result += " \(expectedLines[expectedLine])\n" - expectedLine += 1 - actualLine += 1 - } - } - - let failureMessage = "Actual output (+) differed from expected output (-):\n\(result)" - let fullMessage = message.isEmpty ? failureMessage : "\(message) - \(failureMessage)" - XCTFail(fullMessage, file: file, line: line) - } else { - // Fall back to simple string comparison on platforms that don't support CollectionDifference. - let failureMessage = "Actual output differed from expected output:" - let fullMessage = message.isEmpty ? failureMessage : "\(message) - \(failureMessage)" - XCTAssertEqual(actual, expected, fullMessage, file: file, line: line) - } - } -} diff --git a/Sources/SwiftFormatTestSupport/TestingFindingConsumer.swift b/Sources/SwiftFormatTestSupport/TestingFindingConsumer.swift deleted file mode 100644 index 972981250..000000000 --- a/Sources/SwiftFormatTestSupport/TestingFindingConsumer.swift +++ /dev/null @@ -1,97 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift.org open source project -// -// Copyright (c) 2014 - 2020 Apple Inc. and the Swift project authors -// Licensed under Apache License v2.0 with Runtime Library Exception -// -// See https://swift.org/LICENSE.txt for license information -// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors -// -//===----------------------------------------------------------------------===// - -import SwiftFormatCore - -/// Information about a finding tracked by `TestingFindingConsumer`. -/// -/// This type acts as a kind of type-erasing or flattening operation on `Finding`s and -/// `Finding.Note`s, allowing them to be queried during tests based on their text and location -/// without worrying about their precise nesting structure. -struct EmittedFinding { - /// The message text of the finding. - var message: String - - /// The line number of the finding, if it was provided. - var line: Int? - - /// The column number of the finding, if it was provided. - var column: Int? - - /// Creates an emitted finding from the given `Finding`. - init(_ finding: Finding) { - self.message = finding.message.text - self.line = finding.location?.line - self.column = finding.location?.column - } - - /// Creates an emitted finding from the given `Finding.Note`. - init(_ note: Finding.Note) { - self.message = note.message.text - self.line = note.location?.line - self.column = note.location?.column - } -} - -/// Tracks the findings that were emitted and allows them to be queried during tests. -class TestingFindingConsumer { - /// The findings that have been emitted. - private(set) var emittedFindings = [EmittedFinding]() - - /// Indicates whether findings are being tracked. - private var isTracking = true - - func consume(_ finding: Finding) { - guard isTracking else { return } - - emittedFindings.append(EmittedFinding(finding)) - for note in finding.notes { - emittedFindings.append(EmittedFinding(note)) - } - } - - /// Pops the first finding that contains the given text and occurred at the given location from - /// the collection of emitted findings, if possible. - /// - /// - Parameters: - /// - text: The message text to match. - /// - line: The expected line number of the finding. - /// - column: The expected column number of the finding. - /// - Returns: True if a finding was found and popped, or false otherwise. - func popFinding(containing text: String, atLine line: Int, column: Int) -> Bool { - let maybeIndex = emittedFindings.firstIndex { - $0.message.contains(text) && line == $0.line && column == $0.column - } - guard let index = maybeIndex else { return false } - - emittedFindings.remove(at: index) - return true - } - - /// Pops the first finding that contains the given text (regardless of location) from the - /// collection of emitted findings, if possible. - /// - /// - Parameter text: The message text to match. - /// - Returns: True if a finding was found and popped, or false otherwise. - func popFinding(containing text: String) -> Bool { - let maybeIndex = emittedFindings.firstIndex { $0.message.contains(text) } - guard let index = maybeIndex else { return false } - - emittedFindings.remove(at: index) - return true - } - - /// Stops tracking findings. - func stopTrackingFindings() { - isTracking = false - } -} diff --git a/Sources/_SwiftFormatInstructionCounter/CMakeLists.txt b/Sources/_SwiftFormatInstructionCounter/CMakeLists.txt new file mode 100644 index 000000000..77f863786 --- /dev/null +++ b/Sources/_SwiftFormatInstructionCounter/CMakeLists.txt @@ -0,0 +1,13 @@ +#[[ +This source file is part of the swift-format open source project + +Copyright (c) 2024 Apple Inc. and the swift-format project authors +Licensed under Apache License v2.0 with Runtime Library Exception + +See https://swift.org/LICENSE.txt for license information +#]] + +add_library(_SwiftFormatInstructionCounter STATIC + src/InstructionsExecuted.c) +target_include_directories(_SwiftFormatInstructionCounter PUBLIC + include) diff --git a/Sources/SwiftFormat/Exports.swift b/Sources/_SwiftFormatInstructionCounter/include/InstructionsExecuted.h similarity index 52% rename from Sources/SwiftFormat/Exports.swift rename to Sources/_SwiftFormatInstructionCounter/include/InstructionsExecuted.h index be70db5b8..af9d9a415 100644 --- a/Sources/SwiftFormat/Exports.swift +++ b/Sources/_SwiftFormatInstructionCounter/include/InstructionsExecuted.h @@ -2,7 +2,7 @@ // // This source file is part of the Swift.org open source project // -// Copyright (c) 2014 - 2021 Apple Inc. and the Swift project authors +// Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors // Licensed under Apache License v2.0 with Runtime Library Exception // // See https://swift.org/LICENSE.txt for license information @@ -10,11 +10,8 @@ // //===----------------------------------------------------------------------===// -import SwiftFormatCore +#include -// The `SwiftFormatCore` module isn't meant for public use, but these types need to be since they -// are also part of the public `SwiftFormat` API. Use public typealiases to "re-export" them for -// now. - -public typealias Finding = SwiftFormatCore.Finding -public typealias FindingCategorizing = SwiftFormatCore.FindingCategorizing +/// On macOS returns the number of instructions the process has executed since +/// it was launched, on all other platforms returns 0. +uint64_t getInstructionsExecuted(); diff --git a/Sources/_SwiftFormatInstructionCounter/include/module.modulemap b/Sources/_SwiftFormatInstructionCounter/include/module.modulemap new file mode 100644 index 000000000..afe65db85 --- /dev/null +++ b/Sources/_SwiftFormatInstructionCounter/include/module.modulemap @@ -0,0 +1,3 @@ +module _SwiftFormatInstructionCounter { + header "InstructionsExecuted.h" +} diff --git a/Sources/_SwiftFormatInstructionCounter/src/InstructionsExecuted.c b/Sources/_SwiftFormatInstructionCounter/src/InstructionsExecuted.c new file mode 100644 index 000000000..4df6fa632 --- /dev/null +++ b/Sources/_SwiftFormatInstructionCounter/src/InstructionsExecuted.c @@ -0,0 +1,38 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +#if __APPLE__ +#include +#if TARGET_OS_MAC && !TARGET_OS_IPHONE +#define TARGET_IS_MACOS 1 +#endif +#endif + +#include "InstructionsExecuted.h" + +#ifdef TARGET_IS_MACOS +#include +#include +#include + +uint64_t getInstructionsExecuted() { + struct rusage_info_v4 ru; + if (proc_pid_rusage(getpid(), RUSAGE_INFO_V4, (rusage_info_t *)&ru) == 0) { + return ru.ri_instructions; + } + return 0; +} +#else +uint64_t getInstructionsExecuted() { + return 0; +} +#endif diff --git a/Sources/_SwiftFormatTestSupport/Configuration+Testing.swift b/Sources/_SwiftFormatTestSupport/Configuration+Testing.swift new file mode 100644 index 000000000..a3c593726 --- /dev/null +++ b/Sources/_SwiftFormatTestSupport/Configuration+Testing.swift @@ -0,0 +1,47 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import SwiftFormat + +extension Configuration { + /// The default configuration to be used during unit tests. + /// + /// This configuration is separate from `Configuration.init()` so that that configuration can be + /// replaced without breaking tests that implicitly rely on it. Unfortunately, since this is in a + /// different module than where `Configuration` is defined, we can't make this an initializer that + /// would enforce that every field of `Configuration` is initialized here (we're forced to + /// delegate to another initializer first, which defeats the purpose). So, users adding new + /// configuration settings should be sure to supply a default here for testing, otherwise they + /// will be implicitly relying on the real default. + public static var forTesting: Configuration { + var config = Configuration() + config.rules = Configuration.defaultRuleEnablements + config.maximumBlankLines = 1 + config.lineLength = 100 + config.tabWidth = 8 + config.indentation = .spaces(2) + config.respectsExistingLineBreaks = true + config.lineBreakBeforeControlFlowKeywords = false + config.lineBreakBeforeEachArgument = false + config.lineBreakBeforeEachGenericRequirement = false + config.prioritizeKeepingFunctionOutputTogether = false + config.indentConditionalCompilationBlocks = true + config.lineBreakAroundMultilineExpressionChainComponents = false + config.fileScopedDeclarationPrivacy = FileScopedDeclarationPrivacyConfiguration() + config.indentSwitchCaseLabels = false + config.spacesAroundRangeFormationOperators = false + config.noAssignmentInExpressions = NoAssignmentInExpressionsConfiguration() + config.multiElementCollectionTrailingCommas = true + config.indentBlankLines = false + return config + } +} diff --git a/Sources/_SwiftFormatTestSupport/DiagnosingTestCase.swift b/Sources/_SwiftFormatTestSupport/DiagnosingTestCase.swift new file mode 100644 index 000000000..cb4e07267 --- /dev/null +++ b/Sources/_SwiftFormatTestSupport/DiagnosingTestCase.swift @@ -0,0 +1,266 @@ +import SwiftFormat +@_spi(Rules) @_spi(Testing) import SwiftFormat +import SwiftSyntax +import XCTest + +/// DiagnosingTestCase is an XCTestCase subclass meant to inject diagnostic-specific testing +/// routines into specific formatting test cases. +open class DiagnosingTestCase: XCTestCase { + /// Creates and returns a new `Context` from the given syntax tree and configuration. + /// + /// The returned context is configured with the given finding consumer to record findings emitted + /// during the tests, so that they can be asserted later using the `assertFindings` method. + @_spi(Testing) + public func makeContext( + sourceFileSyntax: SourceFileSyntax, + configuration: Configuration? = nil, + selection: Selection, + findingConsumer: @escaping (Finding) -> Void + ) -> Context { + let context = Context( + configuration: configuration ?? Configuration(), + operatorTable: .standardOperators, + findingConsumer: findingConsumer, + fileURL: URL(fileURLWithPath: "/tmp/test.swift"), + selection: selection, + sourceFileSyntax: sourceFileSyntax, + ruleNameCache: ruleNameCache + ) + return context + } + + /// Asserts that the given list of findings matches a set of specs. + @_spi(Testing) + public final func assertFindings( + expected specs: [FindingSpec], + markerLocations: [String: Int], + emittedFindings: [Finding], + context: Context, + file: StaticString = #file, + line: UInt = #line + ) { + var emittedFindings = emittedFindings + + // Check for a finding that matches each spec, removing it from the array if found. + for spec in specs { + assertAndRemoveFinding( + findingSpec: spec, + markerLocations: markerLocations, + emittedFindings: &emittedFindings, + context: context, + file: file, + line: line + ) + } + + // Emit test failures for any findings that did not have matches. + for finding in emittedFindings { + let locationString: String + if let location = finding.location { + locationString = "line:col \(location.line):\(location.column)" + } else { + locationString = "no location provided" + } + XCTFail( + "Unexpected finding '\(finding.message)' was emitted (\(locationString))", + file: file, + line: line + ) + } + } + + private func assertAndRemoveFinding( + findingSpec: FindingSpec, + markerLocations: [String: Int], + emittedFindings: inout [Finding], + context: Context, + file: StaticString = #file, + line: UInt = #line + ) { + guard let utf8Offset = markerLocations[findingSpec.marker] else { + XCTFail("Marker '\(findingSpec.marker)' was not found in the input", file: file, line: line) + return + } + + let markerLocation = + context.sourceLocationConverter.location(for: AbsolutePosition(utf8Offset: utf8Offset)) + + // Find a finding that has the expected line/column location, ignoring the text. + // FIXME: We do this to provide a better error message if the finding is in the right place but + // doesn't have the right message, but this also introduces an order-sensitivity among the + // specs. Fix this if it becomes an issue. + let maybeIndex = emittedFindings.firstIndex { + markerLocation.line == $0.location?.line && markerLocation.column == $0.location?.column + } + guard let index = maybeIndex else { + XCTFail( + """ + Finding '\(findingSpec.message)' was not emitted at marker '\(findingSpec.marker)' \ + (line:col \(markerLocation.line):\(markerLocation.column), offset \(utf8Offset)) + """, + file: file, + line: line + ) + return + } + + // Verify that the finding text also matches what we expect. + let matchedFinding = emittedFindings.remove(at: index) + XCTAssertEqual( + matchedFinding.message.text, + findingSpec.message, + """ + Finding emitted at marker '\(findingSpec.marker)' \ + (line:col \(markerLocation.line):\(markerLocation.column), offset \(utf8Offset)) \ + had the wrong message + """, + file: file, + line: line + ) + + // Assert that a note exists for each of the expected nodes in the finding. + var emittedNotes = matchedFinding.notes + for noteSpec in findingSpec.notes { + assertAndRemoveNote( + noteSpec: noteSpec, + markerLocations: markerLocations, + emittedNotes: &emittedNotes, + context: context, + file: file, + line: line + ) + } + + // Emit test failures for any notes that weren't specified. + for note in emittedNotes { + let locationString: String + if let location = note.location { + locationString = "line:col \(location.line):\(location.column)" + } else { + locationString = "no location provided" + } + XCTFail( + "Unexpected note '\(note.message)' was emitted (\(locationString))", + file: file, + line: line + ) + } + } + + private func assertAndRemoveNote( + noteSpec: NoteSpec, + markerLocations: [String: Int], + emittedNotes: inout [Finding.Note], + context: Context, + file: StaticString = #file, + line: UInt = #line + ) { + guard let utf8Offset = markerLocations[noteSpec.marker] else { + XCTFail("Marker '\(noteSpec.marker)' was not found in the input", file: file, line: line) + return + } + + let markerLocation = + context.sourceLocationConverter.location(for: AbsolutePosition(utf8Offset: utf8Offset)) + + // FIXME: We do this to provide a better error message if the note is in the right place but + // doesn't have the right message, but this also introduces an order-sensitivity among the + // specs. Fix this if it becomes an issue. + let maybeIndex = emittedNotes.firstIndex { + markerLocation.line == $0.location?.line && markerLocation.column == $0.location?.column + } + guard let index = maybeIndex else { + XCTFail( + """ + Note '\(noteSpec.message)' was not emitted at marker '\(noteSpec.marker)' \ + (line:col \(markerLocation.line):\(markerLocation.column), offset \(utf8Offset)) + """, + file: file, + line: line + ) + return + } + + // Verify that the note text also matches what we expect. + let matchedNote = emittedNotes.remove(at: index) + XCTAssertEqual( + matchedNote.message.text, + noteSpec.message, + """ + Note emitted at marker '\(noteSpec.marker)' \ + (line:col \(markerLocation.line):\(markerLocation.column), offset \(utf8Offset)) \ + had the wrong message + """, + file: file, + line: line + ) + } + + /// Asserts that the two strings are equal, providing Unix `diff`-style output if they are not. + /// + /// - Parameters: + /// - actual: The actual string. + /// - expected: The expected string. + /// - message: An optional description of the failure. + /// - file: The file in which failure occurred. Defaults to the file name of the test case in + /// which this function was called. + /// - line: The line number on which failure occurred. Defaults to the line number on which this + /// function was called. + public final func assertStringsEqualWithDiff( + _ actual: String, + _ expected: String, + _ message: String = "", + file: StaticString = #file, + line: UInt = #line + ) { + // Use `CollectionDifference` on supported platforms to get `diff`-like line-based output. On + // older platforms, fall back to simple string comparison. + if #available(macOS 10.15, *) { + let actualLines = actual.components(separatedBy: .newlines) + let expectedLines = expected.components(separatedBy: .newlines) + + let difference = actualLines.difference(from: expectedLines) + if difference.isEmpty { return } + + var result = "" + + var insertions = [Int: String]() + var removals = [Int: String]() + + for change in difference { + switch change { + case .insert(let offset, let element, _): + insertions[offset] = element + case .remove(let offset, let element, _): + removals[offset] = element + } + } + + var expectedLine = 0 + var actualLine = 0 + + while expectedLine < expectedLines.count || actualLine < actualLines.count { + if let removal = removals[expectedLine] { + result += "-\(removal)\n" + expectedLine += 1 + } else if let insertion = insertions[actualLine] { + result += "+\(insertion)\n" + actualLine += 1 + } else { + result += " \(expectedLines[expectedLine])\n" + expectedLine += 1 + actualLine += 1 + } + } + + let failureMessage = "Actual output (+) differed from expected output (-):\n\(result)" + let fullMessage = message.isEmpty ? failureMessage : "\(message) - \(failureMessage)" + XCTFail(fullMessage, file: file, line: line) + } else { + // Fall back to simple string comparison on platforms that don't support CollectionDifference. + let failureMessage = "Actual output differed from expected output:" + let fullMessage = message.isEmpty ? failureMessage : "\(message) - \(failureMessage)" + XCTAssertEqual(actual, expected, fullMessage, file: file, line: line) + } + } +} diff --git a/Sources/_SwiftFormatTestSupport/FindingSpec.swift b/Sources/_SwiftFormatTestSupport/FindingSpec.swift new file mode 100644 index 000000000..e9751ede4 --- /dev/null +++ b/Sources/_SwiftFormatTestSupport/FindingSpec.swift @@ -0,0 +1,45 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +/// A description of a `Finding` that can be asserted during tests. +public struct FindingSpec { + /// The marker that identifies the finding. + public var marker: String + + /// The message text associated with the finding. + public var message: String + + /// A description of a `Note` that should be associated with this finding. + public var notes: [NoteSpec] + + /// Creates a new `FindingSpec` with the given values. + public init(_ marker: String = "1️⃣", message: String, notes: [NoteSpec] = []) { + self.marker = marker + self.message = message + self.notes = notes + } +} + +/// A description of a `Note` that can be asserted during tests. +public struct NoteSpec { + /// The marker that identifies the note. + public var marker: String + + /// The message text associated with the note. + public var message: String + + /// Creates a new `NoteSpec` with the given values. + public init(_ marker: String, message: String) { + self.marker = marker + self.message = message + } +} diff --git a/Sources/_SwiftFormatTestSupport/MarkedText.swift b/Sources/_SwiftFormatTestSupport/MarkedText.swift new file mode 100644 index 000000000..9d63a4812 --- /dev/null +++ b/Sources/_SwiftFormatTestSupport/MarkedText.swift @@ -0,0 +1,96 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import SwiftFormat +import SwiftSyntax + +/// Encapsulates the locations of emoji markers extracted from source text. +public struct MarkedText { + /// A mapping from marker names to the UTF-8 offset where the marker was found in the string. + public let markers: [String: Int] + + /// The text with all markers removed. + public let textWithoutMarkers: String + + /// If the marked text contains "⏩" and "⏪", they're used to create a selection + public var selection: Selection + + /// Creates a new `MarkedText` value by extracting emoji markers from the given text. + public init(textWithMarkers markedText: String) { + var text = "" + var markers = [String: Int]() + var lastIndex = markedText.startIndex + var offsets = [Range]() + var lastRangeStart = 0 + for marker in findMarkedRanges(in: markedText) { + text += markedText[lastIndex.. +} + +private func findMarkedRanges(in text: String) -> [Marker] { + var markers = [Marker]() + while let marker = nextMarkedRange(in: text, from: markers.last?.range.upperBound ?? text.startIndex) { + markers.append(marker) + } + return markers +} + +private func nextMarkedRange(in text: String, from index: String.Index) -> Marker? { + guard let start = text[index...].firstIndex(where: { $0.isMarkerEmoji }) else { + return nil + } + + let end = text.index(after: start) + let markerRange = start.. SourceFileSyntax { + var source = source + return source.withUTF8 { sourceBytes in + parse( + source: sourceBytes, + experimentalFeatures: experimentalFeatures + ) + } + } +} diff --git a/Sources/generate-pipeline/FileGenerator.swift b/Sources/generate-swift-format/FileGenerator.swift similarity index 86% rename from Sources/generate-pipeline/FileGenerator.swift rename to Sources/generate-swift-format/FileGenerator.swift index bd4c8b32a..c4ba1f553 100644 --- a/Sources/generate-pipeline/FileGenerator.swift +++ b/Sources/generate-swift-format/FileGenerator.swift @@ -19,6 +19,10 @@ protocol FileGenerator { func write(into handle: FileHandle) throws } +private struct FailedToCreateFileError: Error { + let url: URL +} + extension FileGenerator { /// Generates a file at the given URL, overwriting it if it already exists. func generateFile(at url: URL) throws { @@ -27,7 +31,9 @@ extension FileGenerator { try fm.removeItem(at: url) } - fm.createFile(atPath: url.path, contents: nil, attributes: nil) + if !fm.createFile(atPath: url.path, contents: nil, attributes: nil) { + throw FailedToCreateFileError(url: url) + } let handle = try FileHandle(forWritingTo: url) defer { handle.closeFile() } @@ -35,7 +41,7 @@ extension FileGenerator { } } -extension FileHandle: TextOutputStream { +extension FileHandle { /// Writes the provided string as data to a file output stream. public func write(_ string: String) { guard let data = string.data(using: .utf8) else { return } diff --git a/Sources/generate-pipeline/PipelineGenerator.swift b/Sources/generate-swift-format/PipelineGenerator.swift similarity index 77% rename from Sources/generate-pipeline/PipelineGenerator.swift rename to Sources/generate-swift-format/PipelineGenerator.swift index e0455f583..151231c68 100644 --- a/Sources/generate-pipeline/PipelineGenerator.swift +++ b/Sources/generate-swift-format/PipelineGenerator.swift @@ -38,10 +38,8 @@ final class PipelineGenerator: FileGenerator { // //===----------------------------------------------------------------------===// - // This file is automatically generated with generate-pipeline. Do Not Edit! + // This file is automatically generated with generate-swift-format. Do not edit! - import SwiftFormatCore - import SwiftFormatRules import SwiftSyntax /// A syntax visitor that delegates to individual rules for linting. @@ -56,6 +54,10 @@ final class PipelineGenerator: FileGenerator { /// class type. var ruleCache = [ObjectIdentifier: Rule]() + /// Rules present in this dictionary skip visiting children until they leave the + /// syntax node stored as their value + var shouldSkipChildren = [ObjectIdentifier: SyntaxProtocol]() + /// Creates a new lint pipeline. init(context: Context) { self.context = context @@ -71,14 +73,16 @@ final class PipelineGenerator: FileGenerator { override func visit(_ node: \(nodeType)) -> SyntaxVisitorContinueKind { - """) + """ + ) for ruleName in lintRules.sorted() { handle.write( """ visitIfEnabled(\(ruleName).visit, for: node) - """) + """ + ) } handle.write( @@ -86,7 +90,29 @@ final class PipelineGenerator: FileGenerator { return .visitChildren } - """) + """ + ) + + handle.write( + """ + override func visitPost(_ node: \(nodeType)) { + + """ + ) + for ruleName in lintRules.sorted() { + handle.write( + """ + onVisitPost(rule: \(ruleName).self, for: node) + + """ + ) + } + handle.write( + """ + } + + """ + ) } handle.write( @@ -95,7 +121,7 @@ final class PipelineGenerator: FileGenerator { extension FormatPipeline { - func visit(_ node: Syntax) -> Syntax { + func rewrite(_ node: Syntax) -> Syntax { var node = node """ @@ -104,9 +130,10 @@ final class PipelineGenerator: FileGenerator { for ruleName in ruleCollector.allFormatters.map({ $0.typeName }).sorted() { handle.write( """ - node = \(ruleName)(context: context).visit(node) + node = \(ruleName)(context: context).rewrite(node) - """) + """ + ) } handle.write( @@ -115,6 +142,7 @@ final class PipelineGenerator: FileGenerator { } } - """) + """ + ) } } diff --git a/Sources/generate-pipeline/RuleCollector.swift b/Sources/generate-swift-format/RuleCollector.swift similarity index 80% rename from Sources/generate-pipeline/RuleCollector.swift rename to Sources/generate-swift-format/RuleCollector.swift index 7c23a93d3..f39dc66e0 100644 --- a/Sources/generate-pipeline/RuleCollector.swift +++ b/Sources/generate-swift-format/RuleCollector.swift @@ -11,9 +11,9 @@ //===----------------------------------------------------------------------===// import Foundation -import SwiftFormatCore -import SwiftSyntax +@_spi(Rules) import SwiftFormat import SwiftParser +import SwiftSyntax /// Collects information about rules in the formatter code base. final class RuleCollector { @@ -22,6 +22,10 @@ final class RuleCollector { /// The type name of the rule. let typeName: String + /// The description of the rule, extracted from the rule class or struct DocC comment + /// with `DocumentationCommentText(extractedFrom:)` + let description: String? + /// The syntax node types visited by the rule type. let visitedNodes: [String] @@ -83,16 +87,17 @@ final class RuleCollector { /// Determine the rule kind for the declaration in the given statement, if any. private func detectedRule(at statement: CodeBlockItemSyntax) -> DetectedRule? { let typeName: String - let members: MemberDeclListSyntax - let maybeInheritanceClause: TypeInheritanceClauseSyntax? + let members: MemberBlockItemListSyntax + let maybeInheritanceClause: InheritanceClauseSyntax? + let description = DocumentationCommentText(extractedFrom: statement.item.leadingTrivia) if let classDecl = statement.item.as(ClassDeclSyntax.self) { - typeName = classDecl.identifier.text - members = classDecl.members.members + typeName = classDecl.name.text + members = classDecl.memberBlock.members maybeInheritanceClause = classDecl.inheritanceClause } else if let structDecl = statement.item.as(StructDeclSyntax.self) { - typeName = structDecl.identifier.text - members = structDecl.members.members + typeName = structDecl.name.text + members = structDecl.memberBlock.members maybeInheritanceClause = structDecl.inheritanceClause } else { return nil @@ -104,8 +109,8 @@ final class RuleCollector { } // Scan through the inheritance clause to find one of the protocols/types we're interested in. - for inheritance in inheritanceClause.inheritedTypeCollection { - guard let identifier = inheritance.typeName.as(SimpleTypeIdentifierSyntax.self) else { + for inheritance in inheritanceClause.inheritedTypes { + guard let identifier = inheritance.type.as(IdentifierTypeSyntax.self) else { continue } @@ -124,9 +129,9 @@ final class RuleCollector { var visitedNodes = [String]() for member in members { guard let function = member.decl.as(FunctionDeclSyntax.self) else { continue } - guard function.identifier.text == "visit" else { continue } - let params = function.signature.input.parameterList - guard let firstType = params.firstAndOnly?.type?.as(SimpleTypeIdentifierSyntax.self) else { + guard function.name.text == "visit" else { continue } + let params = function.signature.parameterClause.parameters + guard let firstType = params.firstAndOnly?.type.as(IdentifierTypeSyntax.self) else { continue } visitedNodes.append(firstType.name.text) @@ -135,12 +140,16 @@ final class RuleCollector { /// Ignore it if it doesn't have any; there's no point in putting no-op rules in the pipeline. /// Otherwise, return it (we don't need to look at the rest of the inheritances). guard !visitedNodes.isEmpty else { return nil } - guard let ruleType = _typeByName("SwiftFormatRules.\(typeName)") as? Rule.Type else { + guard let ruleType = _typeByName("SwiftFormat.\(typeName)") as? Rule.Type else { preconditionFailure("Failed to find type for rule named \(typeName)") } return DetectedRule( - typeName: typeName, visitedNodes: visitedNodes, canFormat: canFormat, - isOptIn: ruleType.isOptIn) + typeName: typeName, + description: description?.text, + visitedNodes: visitedNodes, + canFormat: canFormat, + isOptIn: ruleType.isOptIn + ) } return nil diff --git a/Sources/generate-swift-format/RuleDocumentationGenerator.swift b/Sources/generate-swift-format/RuleDocumentationGenerator.swift new file mode 100644 index 000000000..eb2375f26 --- /dev/null +++ b/Sources/generate-swift-format/RuleDocumentationGenerator.swift @@ -0,0 +1,73 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Foundation +import SwiftFormat + +/// Generates the markdown file with extended documenation on the available rules. +final class RuleDocumentationGenerator: FileGenerator { + + /// The rules collected by scanning the formatter source code. + let ruleCollector: RuleCollector + + /// Creates a new rule registry generator. + init(ruleCollector: RuleCollector) { + self.ruleCollector = ruleCollector + } + + func write(into handle: FileHandle) throws { + handle.write( + """ + + + # `swift-format` Lint and Format Rules + + Use the rules below in the `rules` block of your `.swift-format` + configuration file, as described in + [Configuration](Documentation/Configuration.md). All of these rules can be + applied in the linter, but only some of them can format your source code + automatically. + + Here's the list of available rules: + + + """ + ) + + for detectedRule in ruleCollector.allLinters.sorted(by: { $0.typeName < $1.typeName }) { + handle.write( + """ + - [\(detectedRule.typeName)](#\(detectedRule.typeName)) + + """ + ) + } + + for detectedRule in ruleCollector.allLinters.sorted(by: { $0.typeName < $1.typeName }) { + handle.write( + """ + + ### \(detectedRule.typeName) + + \(detectedRule.description ?? "") + \(ruleFormatSupportDescription(for: detectedRule)) + + """ + ) + } + } + + private func ruleFormatSupportDescription(for rule: RuleCollector.DetectedRule) -> String { + return rule.canFormat + ? "`\(rule.typeName)` rule can format your code automatically." : "`\(rule.typeName)` is a linter-only rule." + } +} diff --git a/Sources/generate-pipeline/RuleNameCacheGenerator.swift b/Sources/generate-swift-format/RuleNameCacheGenerator.swift similarity index 94% rename from Sources/generate-pipeline/RuleNameCacheGenerator.swift rename to Sources/generate-swift-format/RuleNameCacheGenerator.swift index 6c7e3a729..b6be4ff96 100644 --- a/Sources/generate-pipeline/RuleNameCacheGenerator.swift +++ b/Sources/generate-swift-format/RuleNameCacheGenerator.swift @@ -38,9 +38,10 @@ final class RuleNameCacheGenerator: FileGenerator { // //===----------------------------------------------------------------------===// - // This file is automatically generated with generate-pipeline. Do Not Edit! + // This file is automatically generated with generate-swift-format. Do not edit! /// By default, the `Rule.ruleName` should be the name of the implementing rule type. + @_spi(Testing) public let ruleNameCache: [ObjectIdentifier: String] = [ """ @@ -52,4 +53,3 @@ final class RuleNameCacheGenerator: FileGenerator { handle.write("]\n") } } - diff --git a/Sources/generate-pipeline/RuleRegistryGenerator.swift b/Sources/generate-swift-format/RuleRegistryGenerator.swift similarity index 90% rename from Sources/generate-pipeline/RuleRegistryGenerator.swift rename to Sources/generate-swift-format/RuleRegistryGenerator.swift index 7a5644202..3994f5b3f 100644 --- a/Sources/generate-pipeline/RuleRegistryGenerator.swift +++ b/Sources/generate-swift-format/RuleRegistryGenerator.swift @@ -38,10 +38,10 @@ final class RuleRegistryGenerator: FileGenerator { // //===----------------------------------------------------------------------===// - // This file is automatically generated with generate-pipeline. Do Not Edit! + // This file is automatically generated with generate-swift-format. Do not edit! - enum RuleRegistry { - static let rules: [String: Bool] = [ + @_spi(Internal) public enum RuleRegistry { + public static let rules: [String: Bool] = [ """ ) diff --git a/Sources/generate-pipeline/Syntax+Convenience.swift b/Sources/generate-swift-format/Syntax+Convenience.swift similarity index 100% rename from Sources/generate-pipeline/Syntax+Convenience.swift rename to Sources/generate-swift-format/Syntax+Convenience.swift diff --git a/Sources/generate-pipeline/main.swift b/Sources/generate-swift-format/main.swift similarity index 63% rename from Sources/generate-pipeline/main.swift rename to Sources/generate-swift-format/main.swift index afc469ad9..ea40bcd1b 100644 --- a/Sources/generate-pipeline/main.swift +++ b/Sources/generate-swift-format/main.swift @@ -16,18 +16,33 @@ import SwiftSyntax let sourcesDirectory = URL(fileURLWithPath: #file) .deletingLastPathComponent() .deletingLastPathComponent() -let rulesDirectory = sourcesDirectory.appendingPathComponent("SwiftFormatRules") -let pipelineFile = sourcesDirectory +let rulesDirectory = + sourcesDirectory .appendingPathComponent("SwiftFormat") + .appendingPathComponent("Rules") +let pipelineFile = + sourcesDirectory + .appendingPathComponent("SwiftFormat") + .appendingPathComponent("Core") .appendingPathComponent("Pipelines+Generated.swift") -let ruleRegistryFile = sourcesDirectory - .appendingPathComponent("SwiftFormatConfiguration") +let ruleRegistryFile = + sourcesDirectory + .appendingPathComponent("SwiftFormat") + .appendingPathComponent("Core") .appendingPathComponent("RuleRegistry+Generated.swift") -let ruleNameCacheFile = sourcesDirectory - .appendingPathComponent("SwiftFormatRules") +let ruleNameCacheFile = + sourcesDirectory + .appendingPathComponent("SwiftFormat") + .appendingPathComponent("Core") .appendingPathComponent("RuleNameCache+Generated.swift") +let ruleDocumentationFile = + sourcesDirectory + .appendingPathComponent("..") + .appendingPathComponent("Documentation") + .appendingPathComponent("RuleDocumentation.md") + var ruleCollector = RuleCollector() try ruleCollector.collect(from: rulesDirectory) @@ -42,3 +57,8 @@ try registryGenerator.generateFile(at: ruleRegistryFile) // Generate the rule name cache. let ruleNameCacheGenerator = RuleNameCacheGenerator(ruleCollector: ruleCollector) try ruleNameCacheGenerator.generateFile(at: ruleNameCacheFile) + +// Generate the Documentation/RuleDocumentation.md file with rule descriptions. +// This uses DocC comments from rule implementations. +let ruleDocumentationGenerator = RuleDocumentationGenerator(ruleCollector: ruleCollector) +try ruleDocumentationGenerator.generateFile(at: ruleDocumentationFile) diff --git a/Sources/swift-format/CMakeLists.txt b/Sources/swift-format/CMakeLists.txt new file mode 100644 index 000000000..9ae9603e1 --- /dev/null +++ b/Sources/swift-format/CMakeLists.txt @@ -0,0 +1,36 @@ +#[[ +This source file is part of the swift-format open source project + +Copyright (c) 2024 Apple Inc. and the swift-format project authors +Licensed under Apache License v2.0 with Runtime Library Exception + +See https://swift.org/LICENSE.txt for license information +#]] + +add_executable(swift-format + PrintVersion.swift + SwiftFormatCommand.swift + VersionOptions.swift + Frontend/ConfigurationLoader.swift + Frontend/FormatFrontend.swift + Frontend/Frontend.swift + Frontend/LintFrontend.swift + Subcommands/DumpConfiguration.swift + Subcommands/Format.swift + Subcommands/Lint.swift + Subcommands/LintFormatOptions.swift + Subcommands/PerformanceMeasurement.swift + Utilities/Diagnostic.swift + Utilities/DiagnosticsEngine.swift + Utilities/FileHandleTextOutputStream.swift + Utilities/FormatError.swift + Utilities/StderrDiagnosticPrinter.swift + Utilities/TTY.swift) +target_link_libraries(swift-format PRIVATE + _SwiftFormatInstructionCounter + ArgumentParser + SwiftFormat + SwiftParser + SwiftSyntax) + +_install_target(swift-format) diff --git a/Sources/swift-format/Frontend/ConfigurationLoader.swift b/Sources/swift-format/Frontend/ConfigurationLoader.swift index 6948ac553..57d9bb63b 100644 --- a/Sources/swift-format/Frontend/ConfigurationLoader.swift +++ b/Sources/swift-format/Frontend/ConfigurationLoader.swift @@ -11,7 +11,7 @@ //===----------------------------------------------------------------------===// import Foundation -import SwiftFormatConfiguration +import SwiftFormat /// Loads formatter configurations, caching them in memory so that multiple operations in the same /// directory do not repeatedly hit the file system. @@ -19,13 +19,13 @@ struct ConfigurationLoader { /// The cache of previously loaded configurations. private var cache = [String: Configuration]() - /// Returns the configuration found by searching in the directory (and ancestor directories) - /// containing the given `.swift` source file. + /// Returns the configuration found by walking up the file tree from `url`. + /// This function works for both files and directories. /// /// If no configuration file was found during the search, this method returns nil. /// /// - Throws: If a configuration file was found but an error occurred loading it. - mutating func configuration(forSwiftFileAt url: URL) throws -> Configuration? { + mutating func configuration(forPath url: URL) throws -> Configuration? { guard let configurationFileURL = Configuration.url(forConfigurationFileApplyingTo: url) else { return nil diff --git a/Sources/swift-format/Frontend/FormatFrontend.swift b/Sources/swift-format/Frontend/FormatFrontend.swift index 996b1a924..23d127719 100644 --- a/Sources/swift-format/Frontend/FormatFrontend.swift +++ b/Sources/swift-format/Frontend/FormatFrontend.swift @@ -2,7 +2,7 @@ // // This source file is part of the Swift.org open source project // -// Copyright (c) 2014 - 2020 Apple Inc. and the Swift project authors +// Copyright (c) 2014 - 2024 Apple Inc. and the Swift project authors // Licensed under Apache License v2.0 with Runtime Library Exception // // See https://swift.org/LICENSE.txt for license information @@ -13,7 +13,6 @@ import Foundation import SwiftDiagnostics import SwiftFormat -import SwiftFormatConfiguration import SwiftSyntax /// The frontend for formatting operations. @@ -36,11 +35,13 @@ class FormatFrontend: Frontend { let url = fileToProcess.url guard let source = fileToProcess.sourceText else { diagnosticsEngine.emitError( - "Unable to format \(url.relativePath): file is not readable or does not exist.") + "Unable to format \(url.relativePath): file is not readable or does not exist." + ) return } - let diagnosticHandler: (Diagnostic, SourceLocation) -> () = { (diagnostic, location) in + let diagnosticHandler: (SwiftDiagnostics.Diagnostic, SourceLocation) -> () = { + (diagnostic, location) in guard !self.lintFormatOptions.ignoreUnparsableFiles else { // No diagnostics should be emitted in this mode. return @@ -54,8 +55,11 @@ class FormatFrontend: Frontend { try formatter.format( source: source, assumingFileURL: url, + selection: fileToProcess.selection, + experimentalFeatures: Set(lintFormatOptions.experimentalFeatures), to: &buffer, - parsingDiagnosticHandler: diagnosticHandler) + parsingDiagnosticHandler: diagnosticHandler + ) if buffer != source { let bufferData = buffer.data(using: .utf8)! // Conversion to UTF-8 cannot fail @@ -65,13 +69,12 @@ class FormatFrontend: Frontend { try formatter.format( source: source, assumingFileURL: url, + selection: fileToProcess.selection, + experimentalFeatures: Set(lintFormatOptions.experimentalFeatures), to: &stdoutStream, - parsingDiagnosticHandler: diagnosticHandler) + parsingDiagnosticHandler: diagnosticHandler + ) } - } catch SwiftFormatError.fileNotReadable { - diagnosticsEngine.emitError( - "Unable to format \(url.relativePath): file is not readable or does not exist.") - return } catch SwiftFormatError.fileContainsInvalidSyntax { guard !lintFormatOptions.ignoreUnparsableFiles else { guard !inPlace else { @@ -81,10 +84,10 @@ class FormatFrontend: Frontend { stdoutStream.write(source) return } - // Otherwise, relevant diagnostics about the problematic nodes have been emitted. - return + // Otherwise, relevant diagnostics about the problematic nodes have already been emitted; we + // don't need to print anything else. } catch { - diagnosticsEngine.emitError("Unable to format \(url.relativePath): \(error)") + diagnosticsEngine.emitError("Unable to format \(url.relativePath): \(error.localizedDescription).") } } } diff --git a/Sources/swift-format/Frontend/Frontend.swift b/Sources/swift-format/Frontend/Frontend.swift index fa9611fac..b0e262a94 100644 --- a/Sources/swift-format/Frontend/Frontend.swift +++ b/Sources/swift-format/Frontend/Frontend.swift @@ -2,7 +2,7 @@ // // This source file is part of the Swift.org open source project // -// Copyright (c) 2014 - 2020 Apple Inc. and the Swift project authors +// Copyright (c) 2014 - 2024 Apple Inc. and the Swift project authors // Licensed under Apache License v2.0 with Runtime Library Exception // // See https://swift.org/LICENSE.txt for license information @@ -11,10 +11,9 @@ //===----------------------------------------------------------------------===// import Foundation -import SwiftFormat -import SwiftFormatConfiguration -import SwiftSyntax +@_spi(Internal) import SwiftFormat import SwiftParser +import SwiftSyntax class Frontend { /// Represents a file to be processed by the frontend and any file-specific options associated @@ -36,6 +35,9 @@ class Frontend { /// The configuration that should applied for this file. let configuration: Configuration + /// the selected ranges to process + let selection: Selection + /// Returns the string contents of the file. /// /// The contents of the file are assumed to be UTF-8 encoded. If there is an error decoding the @@ -46,10 +48,16 @@ class Frontend { return String(data: sourceData, encoding: .utf8) }() - init(fileHandle: FileHandle, url: URL, configuration: Configuration) { + init( + fileHandle: FileHandle, + url: URL, + configuration: Configuration, + selection: Selection = .infinite + ) { self.fileHandle = fileHandle self.url = url self.configuration = configuration + self.selection = selection } } @@ -57,7 +65,7 @@ class Frontend { final let diagnosticPrinter: StderrDiagnosticPrinter /// The diagnostic engine to which warnings and errors will be emitted. - final let diagnosticsEngine: UnifiedDiagnosticsEngine + final let diagnosticsEngine: DiagnosticsEngine /// Options that apply during formatting or linting. final let lintFormatOptions: LintFormatOptions @@ -81,19 +89,37 @@ class Frontend { self.lintFormatOptions = lintFormatOptions self.diagnosticPrinter = StderrDiagnosticPrinter( - colorMode: lintFormatOptions.colorDiagnostics.map { $0 ? .on : .off } ?? .auto) + colorMode: lintFormatOptions.colorDiagnostics.map { $0 ? .on : .off } ?? .auto + ) self.diagnosticsEngine = - UnifiedDiagnosticsEngine(diagnosticsHandlers: [diagnosticPrinter.printDiagnostic]) + DiagnosticsEngine(diagnosticsHandlers: [diagnosticPrinter.printDiagnostic]) } /// Runs the linter or formatter over the inputs. final func run() { - if lintFormatOptions.paths.isEmpty { + if lintFormatOptions.paths == ["-"] { + processStandardInput() + } else if lintFormatOptions.paths.isEmpty { + diagnosticsEngine.emitWarning( + """ + Running swift-format without input paths is deprecated and will be removed in the future. + + Please update your invocation to do either of the following: + + - Pass `-` to read from stdin (e.g., `cat MyFile.swift | swift-format -`). + - Pass one or more paths to Swift source files or directories containing + Swift source files. When passing directories, make sure to include the + `--recursive` flag. + + For more information, use the `--help` option. + """ + ) processStandardInput() } else { processURLs( lintFormatOptions.paths.map(URL.init(fileURLWithPath:)), - parallel: lintFormatOptions.parallel) + parallel: lintFormatOptions.parallel + ) } } @@ -109,9 +135,13 @@ class Frontend { /// Processes source content from standard input. private func processStandardInput() { - guard let configuration = configuration( - at: lintFormatOptions.configurationPath.map(URL.init(fileURLWithPath:)), - orInferredFromSwiftFileAt: nil) + let assumedUrl = lintFormatOptions.assumeFilename.map(URL.init(fileURLWithPath:)) + + guard + let configuration = configuration( + fromPathOrString: lintFormatOptions.configuration, + orInferredFromSwiftFileAt: assumedUrl + ) else { // Already diagnosed in the called method. return @@ -119,8 +149,10 @@ class Frontend { let fileToProcess = FileToProcess( fileHandle: FileHandle.standardInput, - url: URL(fileURLWithPath: lintFormatOptions.assumeFilename ?? ""), - configuration: configuration) + url: assumedUrl ?? URL(fileURLWithPath: ""), + configuration: configuration, + selection: Selection(offsetRanges: lintFormatOptions.offsets) + ) processFile(fileToProcess) } @@ -128,15 +160,21 @@ class Frontend { private func processURLs(_ urls: [URL], parallel: Bool) { precondition( !urls.isEmpty, - "processURLs(_:) should only be called when 'urls' is non-empty.") + "processURLs(_:) should only be called when 'urls' is non-empty." + ) if parallel { - let filesToProcess = FileIterator(urls: urls).compactMap(openAndPrepareFile) + let filesToProcess = + FileIterator(urls: urls, followSymlinks: lintFormatOptions.followSymlinks) + .compactMap(openAndPrepareFile) DispatchQueue.concurrentPerform(iterations: filesToProcess.count) { index in processFile(filesToProcess[index]) } } else { - FileIterator(urls: urls).lazy.compactMap(openAndPrepareFile).forEach(processFile) + FileIterator(urls: urls, followSymlinks: lintFormatOptions.followSymlinks) + .lazy + .compactMap(openAndPrepareFile) + .forEach(processFile) } } @@ -145,44 +183,68 @@ class Frontend { private func openAndPrepareFile(at url: URL) -> FileToProcess? { guard let sourceFile = try? FileHandle(forReadingFrom: url) else { diagnosticsEngine.emitError( - "Unable to open \(url.relativePath): file is not readable or does not exist") + "Unable to open \(url.relativePath): file is not readable or does not exist" + ) return nil } guard let configuration = configuration( - at: lintFormatOptions.configurationPath.map(URL.init(fileURLWithPath:)), - orInferredFromSwiftFileAt: url) + fromPathOrString: lintFormatOptions.configuration, + orInferredFromSwiftFileAt: url + ) else { // Already diagnosed in the called method. return nil } - return FileToProcess(fileHandle: sourceFile, url: url, configuration: configuration) + return FileToProcess( + fileHandle: sourceFile, + url: url, + configuration: configuration, + selection: Selection(offsetRanges: lintFormatOptions.offsets) + ) } /// Returns the configuration that applies to the given `.swift` source file, when an explicit /// configuration path is also perhaps provided. /// + /// This method also checks for unrecognized rules within the configuration. + /// /// - Parameters: - /// - configurationFilePath: The path to a configuration file that will be loaded, or `nil` to - /// try to infer it from `swiftFilePath`. + /// - pathOrString: A string containing either the path to a configuration file that will be + /// loaded, JSON configuration data directly, or `nil` to try to infer it from + /// `swiftFilePath`. /// - swiftFilePath: The path to a `.swift` file, which will be used to infer the path to the /// configuration file if `configurationFilePath` is nil. - /// - Returns: If successful, the returned configuration is the one loaded from - /// `configurationFilePath` if it was provided, or by searching in paths inferred by - /// `swiftFilePath` if one exists, or the default configuration otherwise. If an error occurred - /// when reading the configuration, a diagnostic is emitted and `nil` is returned. + /// + /// - Returns: If successful, the returned configuration is the one loaded from `pathOrString` if + /// it was provided, or by searching in paths inferred by `swiftFilePath` if one exists, or the + /// default configuration otherwise. If an error occurred when reading the configuration, a + /// diagnostic is emitted and `nil` is returned. If neither `pathOrString` nor `swiftFilePath` + /// were provided, a default `Configuration()` will be returned. private func configuration( - at configurationFileURL: URL?, + fromPathOrString pathOrString: String?, orInferredFromSwiftFileAt swiftFileURL: URL? ) -> Configuration? { - // If an explicit configuration file path was given, try to load it and fail if it cannot be - // loaded. (Do not try to fall back to a path inferred from the source file path.) - if let configurationFileURL = configurationFileURL { + if let pathOrString = pathOrString { + // If an explicit configuration file path was given, try to load it and fail if it cannot be + // loaded. (Do not try to fall back to a path inferred from the source file path.) + let configurationFileURL = URL(fileURLWithPath: pathOrString) do { - return try configurationLoader.configuration(at: configurationFileURL) + let configuration = try configurationLoader.configuration(at: configurationFileURL) + self.checkForUnrecognizedRules(in: configuration) + return configuration } catch { + // If we failed to load this from the path, try interpreting the string as configuration + // data itself because the user might have written something like `--configuration '{...}'`, + let data = pathOrString.data(using: .utf8)! + if let configuration = try? Configuration(data: data) { + return configuration + } + + // Fail if the configuration flag was neither a valid file path nor valid configuration + // data. diagnosticsEngine.emitError("Unable to read configuration: \(error.localizedDescription)") return nil } @@ -192,20 +254,50 @@ class Frontend { // then try to load the configuration by inferring it based on the source file path. if let swiftFileURL = swiftFileURL { do { - if let configuration = try configurationLoader.configuration(forSwiftFileAt: swiftFileURL) { + if let configuration = try configurationLoader.configuration(forPath: swiftFileURL) { + self.checkForUnrecognizedRules(in: configuration) return configuration } // Fall through to the default return at the end of the function. } catch { diagnosticsEngine.emitError( - "Unable to read configuration for \(swiftFileURL.path): \(error.localizedDescription)") + "Unable to read configuration for \(swiftFileURL.path): \(error.localizedDescription)" + ) + return nil + } + } else { + // If reading from stdin and no explicit configuration file was given, + // walk up the file tree from the cwd to find a config. + + let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + // Definitely a Swift file. Definitely not a directory. Shhhhhh. + do { + if let configuration = try configurationLoader.configuration(forPath: cwd) { + self.checkForUnrecognizedRules(in: configuration) + return configuration + } + } catch { + diagnosticsEngine.emitError( + "Unable to read configuration for \(cwd): \(error.localizedDescription)" + ) return nil } } - // If neither path was given (for example, formatting standard input with no assumed filename) - // or if there was no configuration found by inferring it from the source file path, return the - // default configuration. + // An explicit configuration has not been given, and one cannot be found. + // Return the default configuration. return Configuration() } + + /// Checks if all the rules in the given configuration are supported by the registry. + /// If there are any rules that are not supported, they are emitted as a warning. + private func checkForUnrecognizedRules(in configuration: Configuration) { + // If any rules in the decoded configuration are not supported by the registry, + // emit them into the diagnosticsEngine as warnings. + // That way they will be printed out, but we'll continue execution on the valid rules. + let invalidRules = configuration.rules.filter { !RuleRegistry.rules.keys.contains($0.key) } + for rule in invalidRules { + diagnosticsEngine.emitWarning("Configuration contains an unrecognized rule: \(rule.key)", location: nil) + } + } } diff --git a/Sources/swift-format/Frontend/LintFrontend.swift b/Sources/swift-format/Frontend/LintFrontend.swift index 789960177..c231266a7 100644 --- a/Sources/swift-format/Frontend/LintFrontend.swift +++ b/Sources/swift-format/Frontend/LintFrontend.swift @@ -13,48 +13,46 @@ import Foundation import SwiftDiagnostics import SwiftFormat -import SwiftFormatConfiguration import SwiftSyntax /// The frontend for linting operations. class LintFrontend: Frontend { override func processFile(_ fileToProcess: FileToProcess) { let linter = SwiftLinter( - configuration: fileToProcess.configuration, findingConsumer: diagnosticsEngine.consumeFinding) + configuration: fileToProcess.configuration, + findingConsumer: diagnosticsEngine.consumeFinding + ) linter.debugOptions = debugOptions let url = fileToProcess.url guard let source = fileToProcess.sourceText else { diagnosticsEngine.emitError( - "Unable to lint \(url.relativePath): file is not readable or does not exist.") + "Unable to lint \(url.relativePath): file is not readable or does not exist." + ) return } do { try linter.lint( source: source, - assumingFileURL: url) { (diagnostic, location) in - guard !self.lintFormatOptions.ignoreUnparsableFiles else { - // No diagnostics should be emitted in this mode. - return - } - self.diagnosticsEngine.consumeParserDiagnostic(diagnostic, location) + assumingFileURL: url, + experimentalFeatures: Set(lintFormatOptions.experimentalFeatures) + ) { (diagnostic, location) in + guard !self.lintFormatOptions.ignoreUnparsableFiles else { + // No diagnostics should be emitted in this mode. + return + } + self.diagnosticsEngine.consumeParserDiagnostic(diagnostic, location) } - - } catch SwiftFormatError.fileNotReadable { - diagnosticsEngine.emitError( - "Unable to lint \(url.relativePath): file is not readable or does not exist.") - return } catch SwiftFormatError.fileContainsInvalidSyntax { guard !lintFormatOptions.ignoreUnparsableFiles else { // The caller wants to silently ignore this error. return } - // Otherwise, relevant diagnostics about the problematic nodes have been emitted. - return + // Otherwise, relevant diagnostics about the problematic nodes have already been emitted; we + // don't need to print anything else. } catch { - diagnosticsEngine.emitError("Unable to lint \(url.relativePath): \(error)") - return + diagnosticsEngine.emitError("Unable to lint \(url.relativePath): \(error.localizedDescription).") } } } diff --git a/Sources/swift-format/PrintVersion.swift b/Sources/swift-format/PrintVersion.swift new file mode 100644 index 000000000..15b90e48a --- /dev/null +++ b/Sources/swift-format/PrintVersion.swift @@ -0,0 +1,16 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +func printVersionInformation() { + // TODO: Automate updates to this somehow. + print("main") +} diff --git a/Sources/swift-format/Subcommands/DumpConfiguration.swift b/Sources/swift-format/Subcommands/DumpConfiguration.swift index b32b9c8fe..ff41c8554 100644 --- a/Sources/swift-format/Subcommands/DumpConfiguration.swift +++ b/Sources/swift-format/Subcommands/DumpConfiguration.swift @@ -12,13 +12,14 @@ import ArgumentParser import Foundation -import SwiftFormatConfiguration +import SwiftFormat extension SwiftFormatCommand { /// Dumps the tool's default configuration in JSON format to standard output. struct DumpConfiguration: ParsableCommand { static var configuration = CommandConfiguration( - abstract: "Dump the default configuration in JSON format to standard output") + abstract: "Dump the default configuration in JSON format to standard output" + ) func run() throws { let configuration = Configuration() @@ -34,7 +35,8 @@ extension SwiftFormatCommand { // This should never happen, but let's make sure we fail more gracefully than crashing, just // in case. throw FormatError( - message: "Could not dump the default configuration: the JSON was not valid UTF-8") + message: "Could not dump the default configuration: the JSON was not valid UTF-8" + ) } print(jsonString) } catch { diff --git a/Sources/swift-format/Subcommands/Format.swift b/Sources/swift-format/Subcommands/Format.swift index 1a7f89d23..42c2da165 100644 --- a/Sources/swift-format/Subcommands/Format.swift +++ b/Sources/swift-format/Subcommands/Format.swift @@ -17,19 +17,24 @@ extension SwiftFormatCommand { struct Format: ParsableCommand { static var configuration = CommandConfiguration( abstract: "Format Swift source code", - discussion: "When no files are specified, it expects the source from standard input.") + discussion: "When no files are specified, it expects the source from standard input." + ) /// Whether or not to format the Swift file in-place. /// /// If specified, the current file is overwritten when formatting. @Flag( name: .shortAndLong, - help: "Overwrite the current file when formatting.") + help: "Overwrite the current file when formatting." + ) var inPlace: Bool = false @OptionGroup() var formatOptions: LintFormatOptions + @OptionGroup(visibility: .hidden) + var performanceMeasurementOptions: PerformanceMeasurementsOptions + func validate() throws { if inPlace && formatOptions.paths.isEmpty { throw ValidationError("'--in-place' is only valid when formatting files") @@ -37,9 +42,11 @@ extension SwiftFormatCommand { } func run() throws { - let frontend = FormatFrontend(lintFormatOptions: formatOptions, inPlace: inPlace) - frontend.run() - if frontend.diagnosticsEngine.hasErrors { throw ExitCode.failure } + try performanceMeasurementOptions.printingInstructionCountIfRequested() { + let frontend = FormatFrontend(lintFormatOptions: formatOptions, inPlace: inPlace) + frontend.run() + if frontend.diagnosticsEngine.hasErrors { throw ExitCode.failure } + } } } } diff --git a/Sources/swift-format/Subcommands/Lint.swift b/Sources/swift-format/Subcommands/Lint.swift index 46f28f7c3..3002c5912 100644 --- a/Sources/swift-format/Subcommands/Lint.swift +++ b/Sources/swift-format/Subcommands/Lint.swift @@ -17,23 +17,29 @@ extension SwiftFormatCommand { struct Lint: ParsableCommand { static var configuration = CommandConfiguration( abstract: "Diagnose style issues in Swift source code", - discussion: "When no files are specified, it expects the source from standard input.") + discussion: "When no files are specified, it expects the source from standard input." + ) @OptionGroup() var lintOptions: LintFormatOptions - + @Flag( name: .shortAndLong, help: "Fail on warnings." ) var strict: Bool = false + @OptionGroup(visibility: .hidden) + var performanceMeasurementOptions: PerformanceMeasurementsOptions + func run() throws { - let frontend = LintFrontend(lintFormatOptions: lintOptions) - frontend.run() + try performanceMeasurementOptions.printingInstructionCountIfRequested { + let frontend = LintFrontend(lintFormatOptions: lintOptions) + frontend.run() - if frontend.diagnosticsEngine.hasErrors || strict && frontend.diagnosticsEngine.hasWarnings { - throw ExitCode.failure + if frontend.diagnosticsEngine.hasErrors || strict && frontend.diagnosticsEngine.hasWarnings { + throw ExitCode.failure + } } } } diff --git a/Sources/swift-format/Subcommands/LintFormatOptions.swift b/Sources/swift-format/Subcommands/LintFormatOptions.swift index c0569b405..737de42fc 100644 --- a/Sources/swift-format/Subcommands/LintFormatOptions.swift +++ b/Sources/swift-format/Subcommands/LintFormatOptions.swift @@ -2,7 +2,7 @@ // // This source file is part of the Swift.org open source project // -// Copyright (c) 2014 - 2020 Apple Inc. and the Swift project authors +// Copyright (c) 2014 - 2024 Apple Inc. and the Swift project authors // Licensed under Apache License v2.0 with Runtime Library Exception // // See https://swift.org/LICENSE.txt for license information @@ -20,8 +20,24 @@ struct LintFormatOptions: ParsableArguments { /// If not specified, the default configuration will be used. @Option( name: .customLong("configuration"), - help: "The path to a JSON file containing the configuration of the linter/formatter.") - var configurationPath: String? + help: """ + The path to a JSON file containing the configuration of the linter/formatter or a JSON \ + string containing the configuration directly. + """ + ) + var configuration: String? + + /// A list of comma-separated "start:end" pairs specifying UTF-8 offsets of the ranges to format. + /// + /// If not specified, the whole file will be formatted. + @Option( + name: .long, + help: """ + A "start:end" pair specifying UTF-8 offsets of the range to format. Multiple ranges can be + formatted by specifying several --offsets arguments. + """ + ) + var offsets: [Range] = [] /// The filename for the source code when reading from standard input, to include in diagnostic /// messages. @@ -36,23 +52,27 @@ struct LintFormatOptions: ParsableArguments { /// If set, we recursively run on all ".swift" files in any provided directories. @Flag( name: .shortAndLong, - help: "Recursively run on '.swift' files in any provided directories.") + help: "Recursively run on '.swift' files in any provided directories." + ) var recursive: Bool = false /// Whether unparsable files, due to syntax errors or unrecognized syntax, should be ignored or /// treated as containing an error. When ignored, unparsable files are output verbatim in format /// mode and no diagnostics are raised in lint mode. When not ignored, unparsable files raise a /// diagnostic in both format and lint mode. - @Flag(help: """ - Ignores unparsable files, disabling all diagnostics and formatting for files that contain \ - invalid syntax. - """) + @Flag( + help: """ + Ignores unparsable files, disabling all diagnostics and formatting for files that contain \ + invalid syntax. + """ + ) var ignoreUnparsableFiles: Bool = false /// Whether or not to run the formatter/linter in parallel. @Flag( name: .shortAndLong, - help: "Process files in parallel, simultaneously across multiple cores.") + help: "Process files in parallel, simultaneously across multiple cores." + ) var parallel: Bool = false /// Whether colors should be used in diagnostics printed to standard error. @@ -65,11 +85,30 @@ struct LintFormatOptions: ParsableArguments { Enables or disables color diagnostics when printing to standard error. The default behavior \ if this flag is omitted is to use colors if standard error is connected to a terminal, and \ to not use colors otherwise. - """) + """ + ) var colorDiagnostics: Bool? + /// Whether symlinks should be followed. + @Flag( + help: """ + Follow symbolic links passed on the command line, or found during directory traversal when \ + using `-r/--recursive`. + """ + ) + var followSymlinks: Bool = false + + @Option( + name: .customLong("enable-experimental-feature"), + help: """ + The name of an experimental swift-syntax parser feature that should be enabled by \ + swift-format. Multiple features can be enabled by specifying this flag multiple times. + """ + ) + var experimentalFeatures: [String] = [] + /// The list of paths to Swift source files that should be formatted or linted. - @Argument(help: "Zero or more input filenames.") + @Argument(help: "Zero or more input filenames. Use `-` for stdin.") var paths: [String] = [] @Flag(help: .hidden) var debugDisablePrettyPrint: Bool = false @@ -84,6 +123,10 @@ struct LintFormatOptions: ParsableArguments { throw ValidationError("'--assume-filename' is only valid when reading from stdin") } + if !offsets.isEmpty && paths.count > 1 { + throw ValidationError("'--offsets' is only valid when processing a single file") + } + if !paths.isEmpty && !recursive { for path in paths { var isDir: ObjCBool = false @@ -99,3 +142,20 @@ struct LintFormatOptions: ParsableArguments { } } } + +extension Range { + public init?(argument: String) { + let pair = argument.components(separatedBy: ":") + if pair.count == 2, let start = Int(pair[0]), let end = Int(pair[1]), start <= end { + self = start..=6) +extension Range: @retroactive ExpressibleByArgument {} +#else +extension Range: ExpressibleByArgument {} +#endif diff --git a/Sources/swift-format/Subcommands/PerformanceMeasurement.swift b/Sources/swift-format/Subcommands/PerformanceMeasurement.swift new file mode 100644 index 000000000..a230aa052 --- /dev/null +++ b/Sources/swift-format/Subcommands/PerformanceMeasurement.swift @@ -0,0 +1,33 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import ArgumentParser +import _SwiftFormatInstructionCounter + +struct PerformanceMeasurementsOptions: ParsableArguments { + @Flag(help: "Measure number of instructions executed by swift-format") + var measureInstructions = false + + /// If `measureInstructions` is set, execute `body` and print the number of instructions + /// executed by it. Otherwise, just execute `body` + func printingInstructionCountIfRequested(_ body: () throws -> T) rethrows -> T { + if !measureInstructions { + return try body() + } else { + let startInstructions = getInstructionsExecuted() + defer { + print("Instructions executed: \(getInstructionsExecuted() - startInstructions)") + } + return try body() + } + } +} diff --git a/Sources/swift-format/main.swift b/Sources/swift-format/SwiftFormatCommand.swift similarity index 97% rename from Sources/swift-format/main.swift rename to Sources/swift-format/SwiftFormatCommand.swift index 4d0a2deaf..5b814a159 100644 --- a/Sources/swift-format/main.swift +++ b/Sources/swift-format/SwiftFormatCommand.swift @@ -14,6 +14,7 @@ import ArgumentParser /// Collects the command line options that were passed to `swift-format` and dispatches to the /// appropriate subcommand. +@main struct SwiftFormatCommand: ParsableCommand { static var configuration = CommandConfiguration( commandName: "swift-format", @@ -29,5 +30,3 @@ struct SwiftFormatCommand: ParsableCommand { @OptionGroup() var versionOptions: VersionOptions } - -SwiftFormatCommand.main() diff --git a/Sources/swift-format/Utilities/Diagnostic.swift b/Sources/swift-format/Utilities/Diagnostic.swift new file mode 100644 index 000000000..636190046 --- /dev/null +++ b/Sources/swift-format/Utilities/Diagnostic.swift @@ -0,0 +1,80 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import SwiftFormat +import SwiftSyntax + +/// Diagnostic data that retains the separation of a finding category (if present) from the rest of +/// the message, allowing diagnostic printers that want to print those values separately to do so. +struct Diagnostic { + /// The severity of the diagnostic. + enum Severity { + case note + case warning + case error + } + + /// Represents the location of a diagnostic. + struct Location { + /// The file path associated with the diagnostic. + var file: String + + /// The 1-based line number where the diagnostic occurred. + var line: Int + + /// The 1-based column number where the diagnostic occurred. + var column: Int + + /// Creates a new diagnostic location from the given source location. + init(_ sourceLocation: SourceLocation) { + self.file = sourceLocation.file + self.line = sourceLocation.line + self.column = sourceLocation.column + } + + /// Creates a new diagnostic location with the given finding location. + init(_ findingLocation: Finding.Location) { + self.file = findingLocation.file + self.line = findingLocation.line + self.column = findingLocation.column + } + } + + /// The severity of the diagnostic. + var severity: Severity + + /// The location where the diagnostic occurred, if known. + var location: Location? + + /// The category of the diagnostic, if any. + var category: String? + + /// The message text associated with the diagnostic. + var message: String + + var description: String { + if let category = category { + return "[\(category)] \(message)" + } else { + return message + } + } + + /// Creates a new diagnostic with the given severity, location, optional category, and + /// message. + init(severity: Severity, location: Location?, category: String? = nil, message: String) { + self.severity = severity + self.location = location + self.category = category + self.message = message + } +} diff --git a/Sources/swift-format/Utilities/DiagnosticsEngine.swift b/Sources/swift-format/Utilities/DiagnosticsEngine.swift new file mode 100644 index 000000000..220a2a23c --- /dev/null +++ b/Sources/swift-format/Utilities/DiagnosticsEngine.swift @@ -0,0 +1,151 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import SwiftDiagnostics +import SwiftFormat +import SwiftSyntax + +/// Unifies the handling of findings from the linter, parsing errors from the syntax parser, and +/// generic errors from the frontend so that they are emitted in a uniform fashion. +final class DiagnosticsEngine { + /// The handler functions that will be called to process diagnostics that are emitted. + private let handlers: [(Diagnostic) -> Void] + + /// A Boolean value indicating whether any errors were emitted by the diagnostics engine. + private(set) var hasErrors: Bool + + /// A Boolean value indicating whether any warnings were emitted by the diagnostics engine. + private(set) var hasWarnings: Bool + + /// Creates a new diagnostics engine with the given diagnostic handlers. + /// + /// - Parameter diagnosticsHandlers: An array of functions, each of which takes a `Diagnostic` as + /// its sole argument and returns `Void`. The functions are called whenever a diagnostic is + /// received by the engine. + init(diagnosticsHandlers: [(Diagnostic) -> Void]) { + self.handlers = diagnosticsHandlers + self.hasErrors = false + self.hasWarnings = false + } + + /// Emits the diagnostic by passing it to the registered handlers, and tracks whether it was an + /// error or warning diagnostic. + private func emit(_ diagnostic: Diagnostic) { + switch diagnostic.severity { + case .error: self.hasErrors = true + case .warning: self.hasWarnings = true + default: break + } + + for handler in handlers { + handler(diagnostic) + } + } + + /// Emits a generic error message. + /// + /// - Parameters: + /// - message: The message associated with the error. + /// - location: The location in the source code associated with the error, or nil if there is no + /// location associated with the error. + func emitError(_ message: String, location: SourceLocation? = nil) { + emit( + Diagnostic( + severity: .error, + location: location.map(Diagnostic.Location.init), + message: message + ) + ) + } + + /// Emits a generic warning message. + /// + /// - Parameters: + /// - message: The message associated with the error. + /// - location: The location in the source code associated with the error, or nil if there is no + /// location associated with the error. + func emitWarning(_ message: String, location: SourceLocation? = nil) { + emit( + Diagnostic( + severity: .warning, + location: location.map(Diagnostic.Location.init), + message: message + ) + ) + } + + /// Emits a finding from the linter and any of its associated notes as diagnostics. + /// + /// - Parameter finding: The finding that should be emitted. + func consumeFinding(_ finding: Finding) { + emit(diagnosticMessage(for: finding)) + + for note in finding.notes { + emit( + Diagnostic( + severity: .note, + location: note.location.map(Diagnostic.Location.init), + message: "\(note.message)" + ) + ) + } + } + + /// Emits a diagnostic from the syntax parser and any of its associated notes. + /// + /// - Parameter diagnostic: The syntax parser diagnostic that should be emitted. + func consumeParserDiagnostic( + _ diagnostic: SwiftDiagnostics.Diagnostic, + _ location: SourceLocation + ) { + emit(diagnosticMessage(for: diagnostic.diagMessage, at: location)) + } + + /// Converts a diagnostic message from the syntax parser into a diagnostic message that can be + /// used by the `TSCBasic` diagnostics engine and returns it. + private func diagnosticMessage( + for message: SwiftDiagnostics.DiagnosticMessage, + at location: SourceLocation + ) -> Diagnostic { + let severity: Diagnostic.Severity + switch message.severity { + case .error: severity = .error + case .warning: severity = .warning + case .note: severity = .note + case .remark: severity = .note // should we model this? + } + return Diagnostic( + severity: severity, + location: Diagnostic.Location(location), + category: nil, + message: message.message + ) + } + + /// Converts a lint finding into a diagnostic message that can be used by the `TSCBasic` + /// diagnostics engine and returns it. + private func diagnosticMessage(for finding: Finding) -> Diagnostic { + let severity: Diagnostic.Severity + switch finding.severity { + case .error: severity = .error + case .warning: severity = .warning + case .refactoring: severity = .warning + case .convention: severity = .warning + } + return Diagnostic( + severity: severity, + location: finding.location.map(Diagnostic.Location.init), + category: "\(finding.category)", + message: "\(finding.message.text)" + ) + } +} diff --git a/Sources/swift-format/Utilities/FileIterator.swift b/Sources/swift-format/Utilities/FileIterator.swift deleted file mode 100644 index 20b513a01..000000000 --- a/Sources/swift-format/Utilities/FileIterator.swift +++ /dev/null @@ -1,109 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift.org open source project -// -// Copyright (c) 2014 - 2019 Apple Inc. and the Swift project authors -// Licensed under Apache License v2.0 with Runtime Library Exception -// -// See https://swift.org/LICENSE.txt for license information -// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors -// -//===----------------------------------------------------------------------===// - -import Foundation - -/// Iterator for looping over lists of files and directories. Directories are automatically -/// traversed recursively, and we check for files with a ".swift" extension. -struct FileIterator: Sequence, IteratorProtocol { - - /// List of file and directory URLs to iterate over. - let urls: [URL] - - /// Iterator for the list of URLs. - var urlIterator: Array.Iterator - - /// Iterator for recursing through directories. - var dirIterator: FileManager.DirectoryEnumerator? = nil - - /// The current working directory of the process, which is used to relativize URLs of files found - /// during iteration. - let workingDirectory = URL(fileURLWithPath: ".") - - /// Keep track of the current directory we're recursing through. - var currentDirectory = URL(fileURLWithPath: "") - - /// Keep track of files we have visited to prevent duplicates. - var visited: Set = [] - - /// The file extension to check for when recursing through directories. - let fileSuffix = ".swift" - - /// Create a new file iterator over the given list of file URLs. - /// - /// The given URLs may be files or directories. If they are directories, the iterator will recurse - /// into them. - init(urls: [URL]) { - self.urls = urls - self.urlIterator = self.urls.makeIterator() - } - - /// Iterate through the "paths" list, and emit the file paths in it. If we encounter a directory, - /// recurse through it and emit .swift file paths. - mutating func next() -> URL? { - var output: URL? = nil - while output == nil { - // Check if we're recursing through a directory. - if dirIterator != nil { - output = nextInDirectory() - } else { - guard let next = urlIterator.next() else { return nil } - var isDir: ObjCBool = false - if FileManager.default.fileExists(atPath: next.path, isDirectory: &isDir), isDir.boolValue { - dirIterator = FileManager.default.enumerator(at: next, includingPropertiesForKeys: nil) - currentDirectory = next - } else { - // We'll get here if the path is a file, or if it doesn't exist. In the latter case, - // return the path anyway; we'll turn the error we get when we try to open the file into - // an appropriate diagnostic instead of trying to handle it here. - output = next - } - } - if let out = output, visited.contains(out.absoluteURL.standardized.path) { - output = nil - } - } - if let out = output { - visited.insert(out.absoluteURL.standardized.path) - } - return output - } - - /// Use the FileManager API to recurse through directories and emit .swift file paths. - private mutating func nextInDirectory() -> URL? { - var output: URL? = nil - while output == nil { - if let item = dirIterator?.nextObject() as? URL { - if item.lastPathComponent.hasSuffix(fileSuffix) { - var isDir: ObjCBool = false - if FileManager.default.fileExists(atPath: item.path, isDirectory: &isDir) - && !isDir.boolValue - { - // We can't use the `.producesRelativePathURLs` enumeration option because it isn't - // supported yet on Linux, so we need to relativize the URL ourselves. - let relativePath = - item.path.hasPrefix(workingDirectory.path) - ? String(item.path.dropFirst(workingDirectory.path.count + 1)) - : item.path - output = - URL(fileURLWithPath: relativePath, isDirectory: false, relativeTo: workingDirectory) - } - } - } else { break } - } - // If we've exhausted the files in the directory recursion, unset the directory iterator. - if output == nil { - dirIterator = nil - } - return output - } -} diff --git a/Sources/swift-format/Utilities/FormatError.swift b/Sources/swift-format/Utilities/FormatError.swift index 5b325cc57..b922038ee 100644 --- a/Sources/swift-format/Utilities/FormatError.swift +++ b/Sources/swift-format/Utilities/FormatError.swift @@ -15,10 +15,9 @@ import Foundation struct FormatError: LocalizedError { var message: String var errorDescription: String? { message } - + static var exitWithDiagnosticErrors: FormatError { // The diagnostics engine has already printed errors to stderr. FormatError(message: "") } } - diff --git a/Sources/swift-format/Utilities/StderrDiagnosticPrinter.swift b/Sources/swift-format/Utilities/StderrDiagnosticPrinter.swift index f6452be82..f7730f00c 100644 --- a/Sources/swift-format/Utilities/StderrDiagnosticPrinter.swift +++ b/Sources/swift-format/Utilities/StderrDiagnosticPrinter.swift @@ -12,7 +12,6 @@ import Dispatch import Foundation -import TSCBasic /// Manages printing of diagnostics to standard error. final class StderrDiagnosticPrinter { @@ -49,11 +48,7 @@ final class StderrDiagnosticPrinter { init(colorMode: ColorMode) { switch colorMode { case .auto: - if let stream = stderrStream.stream as? LocalFileOutputByteStream { - useColors = TerminalController.isTTY(stream) - } else { - useColors = false - } + useColors = isTTY(FileHandle.standardError) case .off: useColors = false case .on: @@ -62,25 +57,32 @@ final class StderrDiagnosticPrinter { } /// Prints a diagnostic to standard error. - func printDiagnostic(_ diagnostic: TSCBasic.Diagnostic) { + func printDiagnostic(_ diagnostic: Diagnostic) { printQueue.sync { let stderr = FileHandleTextOutputStream(FileHandle.standardError) - stderr.write("\(ansiSGR(.boldWhite))\(diagnostic.location): ") + stderr.write("\(ansiSGR(.boldWhite))\(description(of: diagnostic.location)): ") - switch diagnostic.behavior { + switch diagnostic.severity { case .error: stderr.write("\(ansiSGR(.boldRed))error: ") case .warning: stderr.write("\(ansiSGR(.boldMagenta))warning: ") case .note: stderr.write("\(ansiSGR(.boldGray))note: ") - case .remark, .ignored: break } - let data = diagnostic.data as! UnifiedDiagnosticData - if let category = data.category { + if let category = diagnostic.category { stderr.write("\(ansiSGR(.boldYellow))[\(category)] ") } - stderr.write("\(ansiSGR(.boldWhite))\(data.message)\(ansiSGR(.reset))\n") + stderr.write("\(ansiSGR(.boldWhite))\(diagnostic.message)\(ansiSGR(.reset))\n") + } + } + + /// Returns a string representation of the given diagnostic location, or a fallback string if the + /// location was not known. + private func description(of location: Diagnostic.Location?) -> String { + if let location = location { + return "\(location.file):\(location.line):\(location.column)" } + return "" } /// Returns the complete ANSI sequence used to enable the given SGR if colors are enabled in the diff --git a/Sources/swift-format/Utilities/TTY.swift b/Sources/swift-format/Utilities/TTY.swift new file mode 100644 index 000000000..3097b1611 --- /dev/null +++ b/Sources/swift-format/Utilities/TTY.swift @@ -0,0 +1,29 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Foundation + +/// Returns a value indicating whether or not the stream is a TTY. +func isTTY(_ fileHandle: FileHandle) -> Bool { + // The implementation of this function is adapted from `TerminalController.swift` in + // swift-tools-support-core. + #if os(Windows) + // The TSC implementation of this function only returns `.file` or `.dumb` for Windows, + // neither of which is a TTY. + return false + #else + if ProcessInfo.processInfo.environment["TERM"] == "dumb" { + return false + } + return isatty(fileHandle.fileDescriptor) != 0 + #endif +} diff --git a/Sources/swift-format/Utilities/UnifiedDiagnosticsEngine.swift b/Sources/swift-format/Utilities/UnifiedDiagnosticsEngine.swift deleted file mode 100644 index de6963f58..000000000 --- a/Sources/swift-format/Utilities/UnifiedDiagnosticsEngine.swift +++ /dev/null @@ -1,151 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift.org open source project -// -// Copyright (c) 2014 - 2021 Apple Inc. and the Swift project authors -// Licensed under Apache License v2.0 with Runtime Library Exception -// -// See https://swift.org/LICENSE.txt for license information -// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors -// -//===----------------------------------------------------------------------===// - -import SwiftFormatCore -import SwiftSyntax -import SwiftDiagnostics -import TSCBasic - -/// Diagnostic data that retains the separation of a finding category (if present) from the rest of -/// the message, allowing diagnostic printers that want to print those values separately to do so. -struct UnifiedDiagnosticData: DiagnosticData { - /// The category of the diagnostic, if any. - var category: String? - - /// The message text associated with the diagnostic. - var message: String - - var description: String { - if let category = category { - return "[\(category)] \(message)" - } else { - return message - } - } - - /// Creates a new unified diagnostic with the given optional category and message. - init(category: String? = nil, message: String) { - self.category = category - self.message = message - } -} - -/// Unifies the handling of findings from the linter, parsing errors from the syntax parser, and -/// generic errors from the frontend so that they are treated uniformly by the underlying -/// diagnostics engine from the `swift-tools-support-core` package. -final class UnifiedDiagnosticsEngine { - /// Represents a location from either the linter or the syntax parser and supports converting it - /// to a string representation for printing. - private enum UnifiedLocation: DiagnosticLocation { - /// A location received from the swift parser. - case parserLocation(SourceLocation) - - /// A location received from the linter. - case findingLocation(Finding.Location) - - var description: String { - switch self { - case .parserLocation(let location): - // SwiftSyntax's old diagnostic printer also force-unwrapped these, so we assume that they - // will always be present if the location itself is non-nil. - return "\(location.file!):\(location.line!):\(location.column!)" - case .findingLocation(let location): - return "\(location.file):\(location.line):\(location.column)" - } - } - } - - /// The underlying diagnostics engine. - private let diagnosticsEngine: DiagnosticsEngine - - /// A Boolean value indicating whether any errors were emitted by the diagnostics engine. - var hasErrors: Bool { diagnosticsEngine.hasErrors } - - /// A Boolean value indicating whether any warnings were emitted by the diagnostics engine. - var hasWarnings: Bool { - diagnosticsEngine.diagnostics.contains { $0.behavior == .warning } - } - - /// Creates a new unified diagnostics engine with the given diagnostic handlers. - /// - /// - Parameter diagnosticsHandlers: An array of functions, each of which takes a `Diagnostic` as - /// its sole argument and returns `Void`. The functions are called whenever a diagnostic is - /// received by the engine. - init(diagnosticsHandlers: [DiagnosticsEngine.DiagnosticsHandler]) { - self.diagnosticsEngine = DiagnosticsEngine(handlers: diagnosticsHandlers) - } - - /// Emits a generic error message. - /// - /// - Parameters: - /// - message: The message associated with the error. - /// - location: The location in the source code associated with the error, or nil if there is no - /// location associated with the error. - func emitError(_ message: String, location: SourceLocation? = nil) { - diagnosticsEngine.emit( - .error(UnifiedDiagnosticData(message: message)), - location: location.map(UnifiedLocation.parserLocation)) - } - - /// Emits a finding from the linter and any of its associated notes as diagnostics. - /// - /// - Parameter finding: The finding that should be emitted. - func consumeFinding(_ finding: Finding) { - diagnosticsEngine.emit( - diagnosticMessage(for: finding), - location: finding.location.map(UnifiedLocation.findingLocation)) - - for note in finding.notes { - diagnosticsEngine.emit( - .note(UnifiedDiagnosticData(message: "\(note.message)")), - location: note.location.map(UnifiedLocation.findingLocation)) - } - } - - /// Emits a diagnostic from the syntax parser and any of its associated notes. - /// - /// - Parameter diagnostic: The syntax parser diagnostic that should be emitted. - func consumeParserDiagnostic( - _ diagnostic: SwiftDiagnostics.Diagnostic, - _ location: SourceLocation - ) { - diagnosticsEngine.emit( - diagnosticMessage(for: diagnostic.diagMessage), - location: UnifiedLocation.parserLocation(location)) - } - - /// Converts a diagnostic message from the syntax parser into a diagnostic message that can be - /// used by the `TSCBasic` diagnostics engine and returns it. - private func diagnosticMessage(for message: SwiftDiagnostics.DiagnosticMessage) - -> TSCBasic.Diagnostic.Message - { - let data = UnifiedDiagnosticData(category: nil, message: message.message) - - switch message.severity { - case .error: return .error(data) - case .warning: return .warning(data) - case .note: return .note(data) - } - } - - /// Converts a lint finding into a diagnostic message that can be used by the `TSCBasic` - /// diagnostics engine and returns it. - private func diagnosticMessage(for finding: Finding) -> TSCBasic.Diagnostic.Message { - let data = - UnifiedDiagnosticData(category: "\(finding.category)", message: "\(finding.message.text)") - - switch finding.severity { - case .error: return .error(data) - case .warning: return .warning(data) - } - } -} diff --git a/Sources/swift-format/VersionOptions.swift b/Sources/swift-format/VersionOptions.swift index 677e89fdd..21afa50b8 100644 --- a/Sources/swift-format/VersionOptions.swift +++ b/Sources/swift-format/VersionOptions.swift @@ -19,8 +19,7 @@ struct VersionOptions: ParsableArguments { func validate() throws { if version { - // TODO: Automate updates to this somehow. - print("508.0.1") + printVersionInformation() throw ExitCode.success } } diff --git a/Tests/SwiftFormatConfigurationTests/ConfigurationTests.swift b/Tests/SwiftFormatConfigurationTests/ConfigurationTests.swift deleted file mode 100644 index 9d50ec388..000000000 --- a/Tests/SwiftFormatConfigurationTests/ConfigurationTests.swift +++ /dev/null @@ -1,20 +0,0 @@ -import SwiftFormatConfiguration -import XCTest - -final class ConfigurationTests: XCTestCase { - func testDefaultConfigurationIsSameAsEmptyDecode() { - // Since we don't use the synthesized `init(from: Decoder)` and allow fields - // to be missing, we provide defaults there as well as in the property - // declarations themselves. This test ensures that creating a default- - // initialized `Configuration` is identical to decoding one from an empty - // JSON input, which verifies that those defaults are always in sync. - let defaultInitConfig = Configuration() - - let emptyDictionaryData = "{}\n".data(using: .utf8)! - let jsonDecoder = JSONDecoder() - let emptyJSONConfig = - try! jsonDecoder.decode(Configuration.self, from: emptyDictionaryData) - - XCTAssertEqual(defaultInitConfig, emptyJSONConfig) - } -} diff --git a/Tests/SwiftFormatPerformanceTests/WhitespaceLinterPerformanceTests.swift b/Tests/SwiftFormatPerformanceTests/WhitespaceLinterPerformanceTests.swift index 7ca3ecde8..b50b23aca 100644 --- a/Tests/SwiftFormatPerformanceTests/WhitespaceLinterPerformanceTests.swift +++ b/Tests/SwiftFormatPerformanceTests/WhitespaceLinterPerformanceTests.swift @@ -1,8 +1,8 @@ -import SwiftFormatTestSupport -import SwiftFormatWhitespaceLinter -import SwiftSyntax +@_spi(Testing) import SwiftFormat import SwiftParser +import SwiftSyntax import XCTest +@_spi(Testing) import _SwiftFormatTestSupport final class WhitespaceLinterPerformanceTests: DiagnosingTestCase { func testWhitespaceLinterPerformance() { @@ -57,7 +57,11 @@ final class WhitespaceLinterPerformanceTests: DiagnosingTestCase { /// - expected: The formatted text. private func performWhitespaceLint(input: String, expected: String) { let sourceFileSyntax = Parser.parse(source: input) - let context = makeContext(sourceFileSyntax: sourceFileSyntax) + let context = makeContext( + sourceFileSyntax: sourceFileSyntax, + selection: .infinite, + findingConsumer: { _ in } + ) let linter = WhitespaceLinter(user: input, formatted: expected, context: context) linter.lint() } diff --git a/Tests/SwiftFormatPrettyPrintTests/ArrayDeclTests.swift b/Tests/SwiftFormatPrettyPrintTests/ArrayDeclTests.swift deleted file mode 100644 index c0e8d6576..000000000 --- a/Tests/SwiftFormatPrettyPrintTests/ArrayDeclTests.swift +++ /dev/null @@ -1,214 +0,0 @@ -import SwiftFormatPrettyPrint -import SwiftSyntax - -final class ArrayDeclTests: PrettyPrintTestCase { - func testBasicArrays() { - let input = - """ - let a = [ ] - let a = [ - ] - let a = [ - // Comment - ] - let a = [1, 2, 3,] - let a: [Bool] = [false, true, true, false] - let a = [11111111, 2222222, 33333333, 4444444] - let a: [String] = ["One", "Two", "Three", "Four"] - let a: [String] = ["One", "Two", "Three", "Four", "Five", "Six", "Seven"] - let a: [String] = ["One", "Two", "Three", "Four", "Five", "Six", "Seven",] - let a: [String] = [ - "One", "Two", "Three", "Four", "Five", - "Six", "Seven", "Eight", - ] - let a = [11111111, 2222222, 33333333, 444444] - """ - - let expected = - """ - let a = [] - let a = [] - let a = [ - // Comment - ] - let a = [1, 2, 3] - let a: [Bool] = [false, true, true, false] - let a = [ - 11111111, 2222222, 33333333, 4444444, - ] - let a: [String] = [ - "One", "Two", "Three", "Four", - ] - let a: [String] = [ - "One", "Two", "Three", "Four", "Five", - "Six", "Seven", - ] - let a: [String] = [ - "One", "Two", "Three", "Four", "Five", - "Six", "Seven", - ] - let a: [String] = [ - "One", "Two", "Three", "Four", "Five", - "Six", "Seven", "Eight", - ] - - """ - // Ideally, this array would be left on 1 line without a trailing comma. We don't know if the - // comma is required when calculating the length of array elements, so the comma's length is - // always added to last element and that 1 character causes the newlines inside of the array. - + """ - let a = [ - 11111111, 2222222, 33333333, 444444, - ] - - """ - - assertPrettyPrintEqual(input: input, expected: expected, linelength: 45) - } - - func testArrayOfFunctions() { - let input = - """ - let A = [(Int, Double) -> Bool]() - let A = [(Int, Double) throws -> Bool]() - """ - - let expected = - """ - let A = [(Int, Double) -> Bool]() - let A = [(Int, Double) throws -> Bool]() - - """ - - assertPrettyPrintEqual(input: input, expected: expected, linelength: 45) - } - - func testNoTrailingCommasInTypes() { - let input = - """ - let a = [SomeSuperMegaLongTypeName]() - """ - - let expected = - """ - let a = [ - SomeSuperMegaLongTypeName - ]() - - """ - - assertPrettyPrintEqual(input: input, expected: expected, linelength: 30) - } - - func testWhitespaceOnlyDoesNotChangeTrailingComma() { - let input = - """ - let a = [ - "String", - ] - let a = [1, 2, 3,] - let a: [String] = [ - "One", "Two", "Three", "Four", "Five", - "Six", "Seven", "Eight" - ] - """ - - assertPrettyPrintEqual( - input: input, expected: input + "\n", linelength: 45, whitespaceOnly: true) - } - - func testTrailingCommaDiagnostics() { - let input = - """ - let a = [1, 2, 3,] - let a: [String] = [ - "One", "Two", "Three", "Four", "Five", - "Six", "Seven", "Eight" - ] - """ - - assertPrettyPrintEqual( - input: input, expected: input + "\n", linelength: 45, whitespaceOnly: true) - - XCTAssertDiagnosed(.removeTrailingComma, line: 1, column: 17) - XCTAssertDiagnosed(.addTrailingComma, line: 4, column: 26) - } - - func testGroupsTrailingComma() { - let input = - """ - let a = [ - condition ? firstOption : secondOption, - bar(), - ] - """ - - let expected = - """ - let a = [ - condition - ? firstOption - : secondOption, - bar(), - ] - - """ - - assertPrettyPrintEqual(input: input, expected: expected, linelength: 32) - } - - func testInnerElementBreakingFromComma() { - let input = - """ - let a = [("abc", "def", "xyz"),("this ", "string", "is long"),] - let a = [("abc", "def", "xyz"),("this ", "string", "is long")] - let a = [("this ", "string", "is long"),] - let a = [("this ", "string", "is long")] - let a = ["this ", "string", "is longer",] - let a = [("this", "str"), ("is", "lng")] - a = [("az", "by"), ("cf", "de")] - """ - - let expected = - """ - let a = [ - ("abc", "def", "xyz"), - ( - "this ", "string", "is long" - ), - ] - let a = [ - ("abc", "def", "xyz"), - ( - "this ", "string", "is long" - ), - ] - let a = [ - ("this ", "string", "is long") - ] - let a = [ - ("this ", "string", "is long") - ] - let a = [ - "this ", "string", - "is longer", - ] - let a = [ - ("this", "str"), - ("is", "lng"), - ] - - """ - // Ideally, this array would be left on 1 line without a trailing comma. We don't know if the - // comma is required when calculating the length of array elements, so the comma's length is - // always added to last element and that 1 character causes the newlines inside of the array. - + """ - a = [ - ("az", "by"), ("cf", "de"), - ] - - """ - - assertPrettyPrintEqual(input: input, expected: expected, linelength: 32) - } -} diff --git a/Tests/SwiftFormatPrettyPrintTests/KeyPathExprTests.swift b/Tests/SwiftFormatPrettyPrintTests/KeyPathExprTests.swift deleted file mode 100644 index 77a41f146..000000000 --- a/Tests/SwiftFormatPrettyPrintTests/KeyPathExprTests.swift +++ /dev/null @@ -1,98 +0,0 @@ -// TODO: Add more tests and figure out how we want to wrap keypaths. Right now, they just get -// printed without breaks. -final class KeyPathExprTests: PrettyPrintTestCase { - func testSimple() { - let input = - #""" - let x = \.foo - let y = \.foo.bar - let z = a.map(\.foo.bar) - """# - - let expected = - #""" - let x = \.foo - let y = \.foo.bar - let z = a.map(\.foo.bar) - - """# - - assertPrettyPrintEqual(input: input, expected: expected, linelength: 80) - } - - func testWithType() { - let input = - #""" - let x = \Type.foo - let y = \Type.foo.bar - let z = a.map(\Type.foo.bar) - """# - - let expected = - #""" - let x = \Type.foo - let y = \Type.foo.bar - let z = a.map(\Type.foo.bar) - - """# - - assertPrettyPrintEqual(input: input, expected: expected, linelength: 80) - } - - func testOptionalUnwrap() { - let input = - #""" - let x = \.foo? - let y = \.foo!.bar - let z = a.map(\.foo!.bar) - """# - - let expected = - #""" - let x = \.foo? - let y = \.foo!.bar - let z = a.map(\.foo!.bar) - - """# - - assertPrettyPrintEqual(input: input, expected: expected, linelength: 80) - } - - func testSubscript() { - let input = - #""" - let x = \.foo[0] - let y = \.foo[0].bar - let z = a.map(\.foo[0].bar) - """# - - let expected = - #""" - let x = \.foo[0] - let y = \.foo[0].bar - let z = a.map(\.foo[0].bar) - - """# - - assertPrettyPrintEqual(input: input, expected: expected, linelength: 80) - } - - func testImplicitSelfUnwrap() { - let input = - #""" - //let x = \.?.foo - //let y = \.?.foo.bar - let z = a.map(\.?.foo.bar) - """# - - let expected = - #""" - //let x = \.?.foo - //let y = \.?.foo.bar - let z = a.map(\.?.foo.bar) - - """# - - assertPrettyPrintEqual(input: input, expected: expected, linelength: 80) - } -} diff --git a/Tests/SwiftFormatPrettyPrintTests/PrettyPrintTestCase.swift b/Tests/SwiftFormatPrettyPrintTests/PrettyPrintTestCase.swift deleted file mode 100644 index d8f498075..000000000 --- a/Tests/SwiftFormatPrettyPrintTests/PrettyPrintTestCase.swift +++ /dev/null @@ -1,81 +0,0 @@ -import SwiftFormatConfiguration -import SwiftFormatCore -import SwiftFormatPrettyPrint -import SwiftFormatTestSupport -import SwiftOperators -import SwiftSyntax -import SwiftParser -import XCTest - -class PrettyPrintTestCase: DiagnosingTestCase { - /// Asserts that the input string, when pretty printed, is equal to the expected string. - /// - /// - Parameters: - /// - input: The input text to pretty print. - /// - expected: The expected pretty-printed output. - /// - linelength: The maximum allowed line length of the output. - /// - configuration: The formatter configuration. - /// - whitespaceOnly: If true, the pretty printer should only apply whitespace changes and omit - /// changes that insert or remove non-whitespace characters (like trailing commas). - /// - file: The file in which failure occurred. Defaults to the file name of the test case in - /// which this function was called. - /// - line: The line number on which failure occurred. Defaults to the line number on which this - /// function was called. - final func assertPrettyPrintEqual( - input: String, - expected: String, - linelength: Int, - configuration: Configuration = Configuration(), - whitespaceOnly: Bool = false, - file: StaticString = #file, - line: UInt = #line - ) { - var configuration = configuration - configuration.lineLength = linelength - - // Assert that the input, when formatted, is what we expected. - if let formatted = prettyPrintedSource( - input, configuration: configuration, whitespaceOnly: whitespaceOnly) - { - XCTAssertStringsEqualWithDiff( - formatted, expected, - "Pretty-printed result was not what was expected", - file: file, line: line) - - // Idempotency check: Running the formatter multiple times should not change the outcome. - // Assert that running the formatter again on the previous result keeps it the same. - stopTrackingDiagnostics() - if let reformatted = prettyPrintedSource( - formatted, configuration: configuration, whitespaceOnly: whitespaceOnly) - { - XCTAssertStringsEqualWithDiff( - reformatted, formatted, "Pretty printer is not idempotent", file: file, line: line) - } - } - } - - /// Returns the given source code reformatted with the pretty printer. - /// - /// - Parameters: - /// - source: The source text to pretty print. - /// - configuration: The formatter configuration. - /// - whitespaceOnly: If true, the pretty printer should only apply whitespace changes and omit - /// changes that insert or remove non-whitespace characters (like trailing commas). - /// - Returns: The pretty-printed text, or nil if an error occurred and a test failure was logged. - private func prettyPrintedSource( - _ source: String, configuration: Configuration, whitespaceOnly: Bool - ) -> String? { - // Ignore folding errors for unrecognized operators so that we fallback to a reasonable default. - let sourceFileSyntax = - restoringLegacyTriviaBehavior( - OperatorTable.standardOperators.foldAll(Parser.parse(source: source)) { _ in } - .as(SourceFileSyntax.self)!) - let context = makeContext(sourceFileSyntax: sourceFileSyntax, configuration: configuration) - let printer = PrettyPrinter( - context: context, - node: Syntax(sourceFileSyntax), - printTokenStream: false, - whitespaceOnly: whitespaceOnly) - return printer.prettyPrint() - } -} diff --git a/Tests/SwiftFormatPrettyPrintTests/StringTests.swift b/Tests/SwiftFormatPrettyPrintTests/StringTests.swift deleted file mode 100644 index b8a1d4678..000000000 --- a/Tests/SwiftFormatPrettyPrintTests/StringTests.swift +++ /dev/null @@ -1,180 +0,0 @@ -final class StringTests: PrettyPrintTestCase { - func testStrings() { - let input = - """ - let a = "abc" - myFun("Some string \\(a + b)") - let b = "A really long string that should not wrap" - let c = "A really long string with \\(a + b) some expressions \\(c + d)" - """ - - let expected = - """ - let a = "abc" - myFun("Some string \\(a + b)") - let b = - "A really long string that should not wrap" - let c = - "A really long string with \\(a + b) some expressions \\(c + d)" - - """ - - assertPrettyPrintEqual(input: input, expected: expected, linelength: 35) - } - - func testMultilineStringOpenQuotesDoNotWrapIfStringIsVeryLong() { - let input = - #""" - let someString = """ - this string's total - length will be longer - than the column limit - even though none of - its individual lines - are. - """ - """# - - assertPrettyPrintEqual(input: input, expected: input + "\n", linelength: 30) - } - - func testMultilineStringIsReindentedCorrectly() { - let input = - #""" - functionCall(longArgument, anotherLongArgument, """ - some multi- - line string - """) - """# - - let expected = - #""" - functionCall( - longArgument, - anotherLongArgument, - """ - some multi- - line string - """) - - """# - - assertPrettyPrintEqual(input: input, expected: expected, linelength: 25) - } - - func testMultilineStringInterpolations() { - let input = - #""" - let x = """ - \(1) 2 3 - 4 \(5) 6 - 7 8 \(9) - """ - """# - - assertPrettyPrintEqual(input: input, expected: input + "\n", linelength: 25) - } - - func testMultilineRawString() { - let input = - ##""" - let x = #""" - """who would - ever do this""" - """# - """## - - assertPrettyPrintEqual(input: input, expected: input + "\n", linelength: 25) - } - - func testMultilineRawStringOpenQuotesWrap() { - let input = - #""" - let aLongVariableName = """ - some - multi- - line - string - """ - """# - - let expected = - #""" - let aLongVariableName = - """ - some - multi- - line - string - """ - - """# - - assertPrettyPrintEqual(input: input, expected: expected, linelength: 25) - } - - func testMultilineStringAutocorrectMisalignedLines() { - let input = - #""" - let x = """ - the - second - line is - wrong - """ - """# - - let expected = - #""" - let x = """ - the - second - line is - wrong - """ - - """# - - assertPrettyPrintEqual(input: input, expected: expected, linelength: 25) - } - - func testMultilineStringKeepsBlankLines() { - // This test not only ensures that the blank lines are retained in the first place, but that - // the newlines are mandatory and not collapsed to the maximum number allowed by the formatter - // configuration. - let input = - #""" - let x = """ - - - there should be - - - - - gaps all around here - - - """ - """# - - let expected = - #""" - let x = """ - - - there should be - - - - - gaps all around here - - - """ - - """# - - assertPrettyPrintEqual(input: input, expected: expected, linelength: 25) - } -} diff --git a/Tests/SwiftFormatPrettyPrintTests/UnknownNodeTests.swift b/Tests/SwiftFormatPrettyPrintTests/UnknownNodeTests.swift deleted file mode 100644 index 51f725cab..000000000 --- a/Tests/SwiftFormatPrettyPrintTests/UnknownNodeTests.swift +++ /dev/null @@ -1,93 +0,0 @@ -import XCTest - -/// Tests for unknown/malformed nodes that ensure that they are handled as verbatim text so that -/// their internal tokens do not get squashed together. -final class UnknownNodeTests: PrettyPrintTestCase { - func testUnknownDecl() throws { - throw XCTSkip("This is no longer an unknown declaration") - - let input = - """ - struct MyStruct where { - let a = 123 - } - """ - - assertPrettyPrintEqual(input: input, expected: input + "\n", linelength: 45) - } - - func testUnknownExpr() throws { - throw XCTSkip("This is no longer an unknown expression") - - let input = - """ - (foo where bar) - """ - - assertPrettyPrintEqual(input: input, expected: input + "\n", linelength: 45) - } - - func testUnknownPattern() throws { - let input = - """ - if case * ! = x { - bar() - } - """ - - let expected = - """ - if case - * ! = x - { - bar() - } - - """ - - assertPrettyPrintEqual(input: input, expected: expected, linelength: 45) - } - - func testUnknownStmt() throws { - throw XCTSkip("This is no longer an unknown statement") - - let input = - """ - if foo where { - } - """ - - assertPrettyPrintEqual(input: input, expected: input + "\n", linelength: 45) - } - - func testUnknownType() throws { - // This one loses the space after the colon because the break would normally be inserted before - // the first token in the type name. - let input = - """ - let x: where - """ - - let expected = - """ - let x:where - - """ - - assertPrettyPrintEqual(input: input, expected: expected, linelength: 45) - } - - func testNonEmptyTokenList() throws { - // The C++ parse modeled as a non-empty list of unparsed tokens. The Swift - // parser sees through this and treats it as an attribute with a missing - // name and some unexpected text after `foo!` in the arguments. - throw XCTSkip("This is no longer a non-empty token list") - - let input = - """ - @(foo ! @ # bar) - """ - - assertPrettyPrintEqual(input: input, expected: input + "\n", linelength: 45) - } -} diff --git a/Tests/SwiftFormatRulesTests/AllPublicDeclarationsHaveDocumentationTests.swift b/Tests/SwiftFormatRulesTests/AllPublicDeclarationsHaveDocumentationTests.swift deleted file mode 100644 index 4db69d3cb..000000000 --- a/Tests/SwiftFormatRulesTests/AllPublicDeclarationsHaveDocumentationTests.swift +++ /dev/null @@ -1,31 +0,0 @@ -import SwiftFormatRules - -final class AllPublicDeclarationsHaveDocumentationTests: LintOrFormatRuleTestCase { - func testPublicDeclsWithoutDocs() { - let input = - """ - public func lightswitchRave() { - } - - public var isSblounskched: Int { - return 0 - } - - /// Everybody to the limit. - public func fhqwhgads() { - } - - /** - * Determines if an email was delorted. - */ - public var isDelorted: Bool { - return false - } - """ - performLint(AllPublicDeclarationsHaveDocumentation.self, input: input) - XCTAssertDiagnosed(.declRequiresComment("lightswitchRave()")) - XCTAssertDiagnosed(.declRequiresComment("isSblounskched")) - XCTAssertNotDiagnosed(.declRequiresComment("fhqwhgads()")) - XCTAssertNotDiagnosed(.declRequiresComment("isDelorted")) - } -} diff --git a/Tests/SwiftFormatRulesTests/AlwaysUseLowerCamelCaseTests.swift b/Tests/SwiftFormatRulesTests/AlwaysUseLowerCamelCaseTests.swift deleted file mode 100644 index 2f8080745..000000000 --- a/Tests/SwiftFormatRulesTests/AlwaysUseLowerCamelCaseTests.swift +++ /dev/null @@ -1,261 +0,0 @@ -import SwiftFormatRules - -final class AlwaysUseLowerCamelCaseTests: LintOrFormatRuleTestCase { - override func setUp() { - super.setUp() - shouldCheckForUnassertedDiagnostics = true - } - - func testInvalidVariableCasing() { - let input = - """ - let Test = 1 - var foo = 2 - var bad_name = 20 - var _okayName = 20 - struct Foo { - func FooFunc() {} - } - class UnitTests: XCTestCase { - func test_HappyPath_Through_GoodCode() {} - } - enum FooBarCases { - case UpperCamelCase - case lowerCamelCase - } - if let Baz = foo { } - guard let foo = [1, 2, 3, 4].first(where: { BadName -> Bool in - let TerribleName = BadName - return TerribleName != 0 - }) else { return } - var fooVar = [1, 2, 3, 4].first(where: { BadNameInFooVar -> Bool in - let TerribleNameInFooVar = BadName - return TerribleName != 0 - }) - var abc = array.first(where: { (CParam1, _ CParam2: Type, cparam3) -> Bool in return true }) - func wellNamedFunc(_ BadFuncArg1: Int, BadFuncArgLabel goodFuncArg: String) { - var PoorlyNamedVar = 0 - } - """ - performLint(AlwaysUseLowerCamelCase.self, input: input) - XCTAssertDiagnosed( - .nameMustBeLowerCamelCase("Test", description: "constant"), line: 1, column: 5) - XCTAssertNotDiagnosed(.nameMustBeLowerCamelCase("foo", description: "variable")) - XCTAssertDiagnosed( - .nameMustBeLowerCamelCase("bad_name", description: "variable"), line: 3, column: 5) - XCTAssertNotDiagnosed(.nameMustBeLowerCamelCase("_okayName", description: "variable")) - XCTAssertNotDiagnosed(.nameMustBeLowerCamelCase("Foo", description: "struct")) - XCTAssertDiagnosed( - .nameMustBeLowerCamelCase("FooFunc", description: "function"), line: 6, column: 8) - XCTAssertDiagnosed( - .nameMustBeLowerCamelCase("test_HappyPath_Through_GoodCode", description: "function"), - line: 9, column: 8) - XCTAssertDiagnosed( - .nameMustBeLowerCamelCase("UpperCamelCase", description: "enum case"), line: 12, column: 8) - XCTAssertDiagnosed( - .nameMustBeLowerCamelCase("Baz", description: "constant"), line: 15, column: 8) - XCTAssertDiagnosed( - .nameMustBeLowerCamelCase("BadName", description: "closure parameter"), line: 16, column: 45) - XCTAssertDiagnosed( - .nameMustBeLowerCamelCase("TerribleName", description: "constant"), line: 17, column: 7) - XCTAssertDiagnosed( - .nameMustBeLowerCamelCase("BadNameInFooVar", description: "closure parameter"), - line: 20, column: 42) - XCTAssertDiagnosed( - .nameMustBeLowerCamelCase("TerribleNameInFooVar", description: "constant"), - line: 21, column: 7) - XCTAssertDiagnosed( - .nameMustBeLowerCamelCase("CParam1", description: "closure parameter"), line: 24, column: 33) - XCTAssertDiagnosed( - .nameMustBeLowerCamelCase("CParam2", description: "closure parameter"), line: 24, column: 44) - XCTAssertDiagnosed( - .nameMustBeLowerCamelCase("BadFuncArg1", description: "function parameter"), - line: 25, column: 22) - XCTAssertDiagnosed( - .nameMustBeLowerCamelCase("BadFuncArgLabel", description: "argument label"), - line: 25, column: 40) - XCTAssertDiagnosed( - .nameMustBeLowerCamelCase("PoorlyNamedVar", description: "variable"), line: 26, column: 7) - } - - func testIgnoresUnderscoresInTestNames() { - let input = - """ - import XCTest - - let Test = 1 - class UnitTests: XCTestCase { - static let My_Constant_Value = 0 - func test_HappyPath_Through_GoodCode() {} - private func FooFunc() {} - private func helperFunc_For_HappyPath_Setup() {} - private func testLikeMethod_With_Underscores(_ arg1: ParamType) {} - private func testLikeMethod_With_Underscores2() -> ReturnType {} - func test_HappyPath_Through_GoodCode_ReturnsVoid() -> Void {} - func test_HappyPath_Through_GoodCode_ReturnsShortVoid() -> () {} - func test_HappyPath_Through_GoodCode_Throws() throws {} - } - """ - performLint(AlwaysUseLowerCamelCase.self, input: input) - XCTAssertDiagnosed( - .nameMustBeLowerCamelCase("Test", description: "constant"), line: 3, column: 5) - XCTAssertDiagnosed( - .nameMustBeLowerCamelCase("My_Constant_Value", description: "constant"), line: 5, column: 14) - XCTAssertNotDiagnosed( - .nameMustBeLowerCamelCase("test_HappyPath_Through_GoodCode", description: "function")) - XCTAssertDiagnosed( - .nameMustBeLowerCamelCase("FooFunc", description: "function"), line: 7, column: 16) - XCTAssertDiagnosed( - .nameMustBeLowerCamelCase("helperFunc_For_HappyPath_Setup", description: "function"), - line: 8, column: 16) - XCTAssertDiagnosed( - .nameMustBeLowerCamelCase("testLikeMethod_With_Underscores", description: "function"), - line: 9, column: 16) - XCTAssertDiagnosed( - .nameMustBeLowerCamelCase("testLikeMethod_With_Underscores2", description: "function"), - line: 10, column: 16) - XCTAssertNotDiagnosed( - .nameMustBeLowerCamelCase( - "test_HappyPath_Through_GoodCode_ReturnsVoid", description: "function")) - XCTAssertNotDiagnosed( - .nameMustBeLowerCamelCase( - "test_HappyPath_Through_GoodCode_ReturnsShortVoid", description: "function")) - XCTAssertNotDiagnosed( - .nameMustBeLowerCamelCase("test_HappyPath_Through_GoodCode_Throws", description: "function")) - } - - func testIgnoresUnderscoresInTestNamesWhenImportedConditionally() { - let input = - """ - #if SOME_FEATURE_FLAG - import XCTest - - let Test = 1 - class UnitTests: XCTestCase { - static let My_Constant_Value = 0 - func test_HappyPath_Through_GoodCode() {} - private func FooFunc() {} - private func helperFunc_For_HappyPath_Setup() {} - private func testLikeMethod_With_Underscores(_ arg1: ParamType) {} - private func testLikeMethod_With_Underscores2() -> ReturnType {} - func test_HappyPath_Through_GoodCode_ReturnsVoid() -> Void {} - func test_HappyPath_Through_GoodCode_ReturnsShortVoid() -> () {} - func test_HappyPath_Through_GoodCode_Throws() throws {} - } - #endif - """ - performLint(AlwaysUseLowerCamelCase.self, input: input) - XCTAssertDiagnosed( - .nameMustBeLowerCamelCase("Test", description: "constant"), line: 4, column: 7) - XCTAssertDiagnosed( - .nameMustBeLowerCamelCase("My_Constant_Value", description: "constant"), line: 6, column: 16) - XCTAssertNotDiagnosed( - .nameMustBeLowerCamelCase("test_HappyPath_Through_GoodCode", description: "function")) - XCTAssertDiagnosed( - .nameMustBeLowerCamelCase("FooFunc", description: "function"), line: 8, column: 18) - XCTAssertDiagnosed( - .nameMustBeLowerCamelCase("helperFunc_For_HappyPath_Setup", description: "function"), - line: 9, column: 18) - XCTAssertDiagnosed( - .nameMustBeLowerCamelCase("testLikeMethod_With_Underscores", description: "function"), - line: 10, column: 18) - XCTAssertDiagnosed( - .nameMustBeLowerCamelCase("testLikeMethod_With_Underscores2", description: "function"), - line: 11, column: 18) - XCTAssertNotDiagnosed( - .nameMustBeLowerCamelCase( - "test_HappyPath_Through_GoodCode_ReturnsVoid", description: "function")) - XCTAssertNotDiagnosed( - .nameMustBeLowerCamelCase( - "test_HappyPath_Through_GoodCode_ReturnsShortVoid", description: "function")) - XCTAssertNotDiagnosed( - .nameMustBeLowerCamelCase("test_HappyPath_Through_GoodCode_Throws", description: "function")) - } - - func testIgnoresUnderscoresInConditionalTestNames() { - let input = - """ - import XCTest - - let Test = 1 - class UnitTests: XCTestCase { - #if SOME_FEATURE_FLAG - static let My_Constant_Value = 0 - func test_HappyPath_Through_GoodCode() {} - private func FooFunc() {} - private func helperFunc_For_HappyPath_Setup() {} - private func testLikeMethod_With_Underscores(_ arg1: ParamType) {} - private func testLikeMethod_With_Underscores2() -> ReturnType {} - func test_HappyPath_Through_GoodCode_ReturnsVoid() -> Void {} - func test_HappyPath_Through_GoodCode_ReturnsShortVoid() -> () {} - func test_HappyPath_Through_GoodCode_Throws() throws {} - #else - func testBadMethod_HasNonVoidReturn() -> ReturnType {} - func testGoodMethod_HasVoidReturn() {} - #if SOME_OTHER_FEATURE_FLAG - func testBadMethod_HasNonVoidReturn2() -> ReturnType {} - func testGoodMethod_HasVoidReturn2() {} - #endif - #endif - } - #endif - """ - performLint(AlwaysUseLowerCamelCase.self, input: input) - XCTAssertDiagnosed( - .nameMustBeLowerCamelCase("Test", description: "constant"), line: 3, column: 5) - XCTAssertDiagnosed( - .nameMustBeLowerCamelCase("My_Constant_Value", description: "constant"), line: 6, column: 16) - XCTAssertNotDiagnosed( - .nameMustBeLowerCamelCase("test_HappyPath_Through_GoodCode", description: "function")) - XCTAssertDiagnosed( - .nameMustBeLowerCamelCase("FooFunc", description: "function"), line: 8, column: 18) - XCTAssertDiagnosed( - .nameMustBeLowerCamelCase("helperFunc_For_HappyPath_Setup", description: "function"), - line: 9, column: 18) - XCTAssertDiagnosed( - .nameMustBeLowerCamelCase("testLikeMethod_With_Underscores", description: "function"), - line: 10, column: 18) - XCTAssertDiagnosed( - .nameMustBeLowerCamelCase("testLikeMethod_With_Underscores2", description: "function"), - line: 11, column: 18) - XCTAssertNotDiagnosed( - .nameMustBeLowerCamelCase( - "test_HappyPath_Through_GoodCode_ReturnsVoid", description: "function")) - XCTAssertNotDiagnosed( - .nameMustBeLowerCamelCase( - "test_HappyPath_Through_GoodCode_ReturnsShortVoid", description: "function")) - XCTAssertNotDiagnosed( - .nameMustBeLowerCamelCase("test_HappyPath_Through_GoodCode_Throws", description: "function")) - XCTAssertDiagnosed( - .nameMustBeLowerCamelCase("testBadMethod_HasNonVoidReturn", description: "function"), - line: 16, column: 10) - XCTAssertNotDiagnosed( - .nameMustBeLowerCamelCase("testGoodMethod_HasVoidReturn", description: "function")) - XCTAssertDiagnosed( - .nameMustBeLowerCamelCase("testBadMethod_HasNonVoidReturn2", description: "function"), - line: 19, column: 12) - XCTAssertNotDiagnosed( - .nameMustBeLowerCamelCase("testGoodMethod_HasVoidReturn2", description: "function")) - } - - func testIgnoresFunctionOverrides() { - let input = - """ - class ParentClass { - var poorly_named_variable: Int = 5 - func poorly_named_method() {} - } - - class ChildClass: ParentClass { - override var poorly_named_variable: Int = 5 - override func poorly_named_method() {} - } - """ - - performLint(AlwaysUseLowerCamelCase.self, input: input) - XCTAssertDiagnosed( - .nameMustBeLowerCamelCase("poorly_named_variable", description: "variable"), line: 2, column: 7) - XCTAssertDiagnosed( - .nameMustBeLowerCamelCase("poorly_named_method", description: "function"), line: 3, column: 8) - } -} diff --git a/Tests/SwiftFormatRulesTests/AmbiguousTrailingClosureOverloadTests.swift b/Tests/SwiftFormatRulesTests/AmbiguousTrailingClosureOverloadTests.swift deleted file mode 100644 index f758509d7..000000000 --- a/Tests/SwiftFormatRulesTests/AmbiguousTrailingClosureOverloadTests.swift +++ /dev/null @@ -1,47 +0,0 @@ -import SwiftFormatRules - -final class AmbiguousTrailingClosureOverloadTests: LintOrFormatRuleTestCase { - func testAmbiguousOverloads() { - performLint( - AmbiguousTrailingClosureOverload.self, - input: """ - func strong(mad: () -> Int) {} - func strong(bad: (Bool) -> Bool) {} - func strong(sad: (String) -> Bool) {} - - class A { - static func the(cheat: (Int) -> Void) {} - class func the(sneak: (Int) -> Void) {} - func the(kingOfTown: () -> Void) {} - func the(cheatCommandos: (Bool) -> Void) {} - func the(brothersStrong: (String) -> Void) {} - } - - struct B { - func hom(estar: () -> Int) {} - func hom(sar: () -> Bool) {} - - static func baleeted(_ f: () -> Void) {} - func baleeted(_ f: () -> Void) {} - } - """ - ) - - XCTAssertDiagnosed(.ambiguousTrailingClosureOverload("strong(mad:)")) - XCTAssertDiagnosed(.otherAmbiguousOverloadHere("strong(bad:)")) - XCTAssertDiagnosed(.otherAmbiguousOverloadHere("strong(sad:)")) - - XCTAssertDiagnosed(.ambiguousTrailingClosureOverload("the(cheat:)")) - XCTAssertDiagnosed(.otherAmbiguousOverloadHere("the(sneak:)")) - - XCTAssertDiagnosed(.ambiguousTrailingClosureOverload("the(kingOfTown:)")) - XCTAssertDiagnosed(.otherAmbiguousOverloadHere("the(cheatCommandos:)")) - XCTAssertDiagnosed(.otherAmbiguousOverloadHere("the(brothersStrong:)")) - - XCTAssertDiagnosed(.ambiguousTrailingClosureOverload("hom(estar:)")) - XCTAssertDiagnosed(.otherAmbiguousOverloadHere("hom(sar:)")) - - XCTAssertNotDiagnosed(.ambiguousTrailingClosureOverload("baleeted(_:)")) - XCTAssertNotDiagnosed(.otherAmbiguousOverloadHere("baleeted(_:)")) - } -} diff --git a/Tests/SwiftFormatRulesTests/BeginDocumentationCommentWithOneLineSummaryTests.swift b/Tests/SwiftFormatRulesTests/BeginDocumentationCommentWithOneLineSummaryTests.swift deleted file mode 100644 index 9187eb947..000000000 --- a/Tests/SwiftFormatRulesTests/BeginDocumentationCommentWithOneLineSummaryTests.swift +++ /dev/null @@ -1,148 +0,0 @@ -import SwiftFormatRules - -final class BeginDocumentationCommentWithOneLineSummaryTests: LintOrFormatRuleTestCase { - override func setUp() { - // Reset this to false by default. Specific tests may override it. - BeginDocumentationCommentWithOneLineSummary._forcesFallbackModeForTesting = false - super.setUp() - } - - func testDocLineCommentsWithoutOneSentenceSummary() { - let input = - """ - /// Returns a bottle of Dr Pepper from the vending machine. - public func drPepper(from vendingMachine: VendingMachine) -> Soda {} - - /// Contains a comment as description that needs a sentence - /// of two lines of code. - public var twoLinesForOneSentence = "test" - - /// The background color of the view. - var backgroundColor: UIColor - - /// Returns the sum of the numbers. - /// - /// - Parameter numbers: The numbers to sum. - /// - Returns: The sum of the numbers. - func sum(_ numbers: [Int]) -> Int { - // ... - } - - /// This docline should not succeed. - /// There are two sentences without a blank line between them. - struct Test {} - - /// This docline should not succeed. There are two sentences. - public enum Token { case comma, semicolon, identifier } - - /// Should fail because it doesn't have a period - public class testNoPeriod {} - """ - performLint(BeginDocumentationCommentWithOneLineSummary.self, input: input) - XCTAssertDiagnosed(.addBlankLineAfterFirstSentence("This docline should not succeed.")) - XCTAssertDiagnosed(.addBlankLineAfterFirstSentence("This docline should not succeed.")) - XCTAssertDiagnosed(.terminateSentenceWithPeriod("Should fail because it doesn't have a period")) - - XCTAssertNotDiagnosed(.addBlankLineAfterFirstSentence( - "Returns a bottle of Dr Pepper from the vending machine.")) - XCTAssertNotDiagnosed(.addBlankLineAfterFirstSentence( - "Contains a comment as description that needs a sentence of two lines of code.")) - XCTAssertNotDiagnosed(.addBlankLineAfterFirstSentence("The background color of the view.")) - XCTAssertNotDiagnosed(.addBlankLineAfterFirstSentence("Returns the sum of the numbers.")) - } - - func testBlockLineCommentsWithoutOneSentenceSummary() { - let input = - """ - /** - * Returns the numeric value. - * - * - Parameters: - * - digit: The Unicode scalar whose numeric value should be returned. - * - radix: The radix, between 2 and 36, used to compute the numeric value. - * - Returns: The numeric value of the scalar.*/ - func numericValue(of digit: UnicodeScalar, radix: Int = 10) -> Int {} - - /** - * This block comment contains a sentence summary - * of two lines of code. - */ - public var twoLinesForOneSentence = "test" - - /** - * This block comment should not succeed, struct. - * There are two sentences without a blank line between them. - */ - struct TestStruct {} - - /** - This block comment should not succeed, class. - Add a blank comment after the first line. - */ - public class TestClass {} - /** This block comment should not succeed, enum. There are two sentences. */ - public enum testEnum {} - /** Should fail because it doesn't have a period */ - public class testNoPeriod {} - """ - performLint(BeginDocumentationCommentWithOneLineSummary.self, input: input) - XCTAssertDiagnosed(.addBlankLineAfterFirstSentence("This block comment should not succeed, struct.")) - XCTAssertDiagnosed(.addBlankLineAfterFirstSentence("This block comment should not succeed, class.")) - XCTAssertDiagnosed(.addBlankLineAfterFirstSentence("This block comment should not succeed, enum.")) - XCTAssertDiagnosed(.terminateSentenceWithPeriod("Should fail because it doesn't have a period")) - - XCTAssertNotDiagnosed(.addBlankLineAfterFirstSentence("Returns the numeric value.")) - XCTAssertNotDiagnosed(.addBlankLineAfterFirstSentence( - "This block comment contains a sentence summary of two lines of code.")) - } - - func testApproximationsOnMacOS() { - #if os(macOS) - // Let macOS also verify that the fallback mode works, which gives us signal about whether it - // will also succeed on Linux (where the linguistic APIs are not currently available). - BeginDocumentationCommentWithOneLineSummary._forcesFallbackModeForTesting = true - - let input = - """ - /// Returns a bottle of Dr Pepper from the vending machine. - public func drPepper(from vendingMachine: VendingMachine) -> Soda {} - - /// Contains a comment as description that needs a sentence - /// of two lines of code. - public var twoLinesForOneSentence = "test" - - /// The background color of the view. - var backgroundColor: UIColor - - /// Returns the sum of the numbers. - /// - /// - Parameter numbers: The numbers to sum. - /// - Returns: The sum of the numbers. - func sum(_ numbers: [Int]) -> Int { - // ... - } - - /// This docline should not succeed. - /// There are two sentences without a blank line between them. - struct Test {} - - /// This docline should not succeed. There are two sentences. - public enum Token { case comma, semicolon, identifier } - - /// Should fail because it doesn't have a period - public class testNoPeriod {} - """ - performLint(BeginDocumentationCommentWithOneLineSummary.self, input: input) - XCTAssertDiagnosed(.addBlankLineAfterFirstSentence("This docline should not succeed.")) - XCTAssertDiagnosed(.addBlankLineAfterFirstSentence("This docline should not succeed.")) - XCTAssertDiagnosed(.terminateSentenceWithPeriod("Should fail because it doesn't have a period")) - - XCTAssertNotDiagnosed(.addBlankLineAfterFirstSentence( - "Returns a bottle of Dr Pepper from the vending machine.")) - XCTAssertNotDiagnosed(.addBlankLineAfterFirstSentence( - "Contains a comment as description that needs a sentence of two lines of code.")) - XCTAssertNotDiagnosed(.addBlankLineAfterFirstSentence("The background color of the view.")) - XCTAssertNotDiagnosed(.addBlankLineAfterFirstSentence("Returns the sum of the numbers.")) - #endif - } -} diff --git a/Tests/SwiftFormatRulesTests/DoNotUseSemicolonsTests.swift b/Tests/SwiftFormatRulesTests/DoNotUseSemicolonsTests.swift deleted file mode 100644 index 7768c5192..000000000 --- a/Tests/SwiftFormatRulesTests/DoNotUseSemicolonsTests.swift +++ /dev/null @@ -1,129 +0,0 @@ -import SwiftFormatRules - -final class DoNotUseSemicolonsTests: LintOrFormatRuleTestCase { - func testSemicolonUse() { - XCTAssertFormatting( - DoNotUseSemicolons.self, - input: """ - print("hello"); print("goodbye"); - print("3") - """, - expected: """ - print("hello") - print("goodbye") - print("3") - """) - } - - func testSemicolonsInNestedStatements() { - XCTAssertFormatting( - DoNotUseSemicolons.self, - input: """ - guard let someVar = Optional(items.filter ({ a in foo(a); return true; })) else { - items.forEach { a in foo(a); }; return; - } - """, - // The formatting in the expected output is unappealing, but that is fixed by the pretty - // printer and isn't a concern for the format rule. - expected: """ - guard let someVar = Optional(items.filter ({ a in foo(a) - return true})) else { - items.forEach { a in foo(a)} - return - } - """) - } - - func testSemicolonsInMemberLists() { - XCTAssertFormatting( - DoNotUseSemicolons.self, - input: """ - struct Foo { - func foo() { - code() - }; - - let someVar = 5;let someOtherVar = 6; - } - """, - expected: """ - struct Foo { - func foo() { - code() - } - - let someVar = 5 - let someOtherVar = 6 - } - """) - } - - func testNewlinesAfterSemicolons() { - XCTAssertFormatting( - DoNotUseSemicolons.self, - input: """ - print("hello"); - /// This is a doc comment for printing "goodbye". - print("goodbye"); - - /// This is a doc comment for printing "3". - print("3"); - - print("4"); /** Inline comment. */ print("5"); - - print("6"); // This is an important statement. - print("7"); - """, - expected: """ - print("hello") - /// This is a doc comment for printing "goodbye". - print("goodbye") - - /// This is a doc comment for printing "3". - print("3") - - print("4") - /** Inline comment. */ print("5") - - print("6")// This is an important statement. - print("7") - """) - } - - func testSemicolonsSeparatingDoWhile() { - XCTAssertFormatting( - DoNotUseSemicolons.self, - input: """ - do { f() }; - while someCondition { g() } - - do { - f() - }; - - // Comment and whitespace separating blocks. - while someCondition { - g() - } - - do { f() }; - for _ in 0..<10 { g() } - """, - expected: """ - do { f() }; - while someCondition { g() } - - do { - f() - }; - - // Comment and whitespace separating blocks. - while someCondition { - g() - } - - do { f() } - for _ in 0..<10 { g() } - """) - } -} diff --git a/Tests/SwiftFormatRulesTests/DontRepeatTypeInStaticPropertiesTests.swift b/Tests/SwiftFormatRulesTests/DontRepeatTypeInStaticPropertiesTests.swift deleted file mode 100644 index 0a9cb1ce1..000000000 --- a/Tests/SwiftFormatRulesTests/DontRepeatTypeInStaticPropertiesTests.swift +++ /dev/null @@ -1,79 +0,0 @@ -import SwiftFormatRules - -final class DontRepeatTypeInStaticPropertiesTests: LintOrFormatRuleTestCase { - func testRepetitiveProperties() { - let input = - """ - public class UIColor { - static let redColor: UIColor - public class var blueColor: UIColor - var yellowColor: UIColor - static let green: UIColor - public class var purple: UIColor - } - enum Sandwich { - static let bolognaSandwich: Sandwich - static var hamSandwich: Sandwich - static var turkey: Sandwich - } - protocol RANDPerson { - var oldPerson: Person - static let youngPerson: Person - } - struct TVGame { - static var basketballGame: TVGame - static var baseballGame: TVGame - static let soccer: TVGame - let hockey: TVGame - } - extension URLSession { - class var sharedSession: URLSession - } - """ - - performLint(DontRepeatTypeInStaticProperties.self, input: input) - XCTAssertDiagnosed(.removeTypeFromName(name: "redColor", type: "Color")) - XCTAssertDiagnosed(.removeTypeFromName(name: "blueColor", type: "Color")) - XCTAssertNotDiagnosed(.removeTypeFromName(name: "yellowColor", type: "Color")) - XCTAssertNotDiagnosed(.removeTypeFromName(name: "green", type: "Color")) - XCTAssertNotDiagnosed(.removeTypeFromName(name: "purple", type: "Color")) - - XCTAssertDiagnosed(.removeTypeFromName(name: "bolognaSandwich", type: "Sandwich")) - XCTAssertDiagnosed(.removeTypeFromName(name: "hamSandwich", type: "Sandwich")) - XCTAssertNotDiagnosed(.removeTypeFromName(name: "turkey", type: "Sandwich")) - - XCTAssertNotDiagnosed(.removeTypeFromName(name: "oldPerson", type: "Person")) - XCTAssertDiagnosed(.removeTypeFromName(name: "youngPerson", type: "Person")) - - XCTAssertDiagnosed(.removeTypeFromName(name: "basketballGame", type: "Game")) - XCTAssertDiagnosed(.removeTypeFromName(name: "baseballGame", type: "Game")) - XCTAssertNotDiagnosed(.removeTypeFromName(name: "soccer", type: "Game")) - XCTAssertNotDiagnosed(.removeTypeFromName(name: "hockey", type: "Game")) - - XCTAssertDiagnosed(.removeTypeFromName(name: "sharedSession", type: "Session")) - } - - func testSR11123() { - let input = - """ - extension A { - static let b = C() - } - """ - - performLint(DontRepeatTypeInStaticProperties.self, input: input) - XCTAssertNotDiagnosed(.removeTypeFromName(name: "b", type: "A")) - } - - func testDottedExtendedType() { - let input = - """ - extension Dotted.Thing { - static let defaultThing: Dotted.Thing - } - """ - - performLint(DontRepeatTypeInStaticProperties.self, input: input) - XCTAssertDiagnosed(.removeTypeFromName(name: "defaultThing", type: "Thing")) - } -} diff --git a/Tests/SwiftFormatRulesTests/FileScopedDeclarationPrivacyTests.swift b/Tests/SwiftFormatRulesTests/FileScopedDeclarationPrivacyTests.swift deleted file mode 100644 index 8baaebecc..000000000 --- a/Tests/SwiftFormatRulesTests/FileScopedDeclarationPrivacyTests.swift +++ /dev/null @@ -1,239 +0,0 @@ -import SwiftFormatConfiguration -import SwiftFormatCore -import SwiftFormatRules -import SwiftSyntax - -private typealias TestConfiguration = ( - original: String, - desired: FileScopedDeclarationPrivacyConfiguration.AccessLevel, - expected: String -) - -/// Test configurations for file-scoped declarations, which should be changed if the access level -/// does not match the desired level in the formatter configuration. -private let changingTestConfigurations: [TestConfiguration] = [ - (original: "private", desired: .fileprivate, expected: "fileprivate"), - (original: "private", desired: .private, expected: "private"), - (original: "fileprivate", desired: .fileprivate, expected: "fileprivate"), - (original: "fileprivate", desired: .private, expected: "private"), -] - -/// Test configurations for declarations that should not have their access level changed; extensions -/// and nested declarations (i.e., not at file scope). -private let unchangingTestConfigurations: [TestConfiguration] = [ - (original: "private", desired: .fileprivate, expected: "private"), - (original: "private", desired: .private, expected: "private"), - (original: "fileprivate", desired: .fileprivate, expected: "fileprivate"), - (original: "fileprivate", desired: .private, expected: "fileprivate"), -] - -final class FileScopedDeclarationPrivacyTests: LintOrFormatRuleTestCase { - func testFileScopeDecls() { - runWithMultipleConfigurations( - source: """ - $access$ class Foo {} - $access$ struct Foo {} - $access$ enum Foo {} - $access$ protocol Foo {} - $access$ typealias Foo = Bar - $access$ func foo() {} - $access$ var foo: Bar - """, - testConfigurations: changingTestConfigurations - ) { assertDiagnosticWasEmittedOrNot in - assertDiagnosticWasEmittedOrNot(1, 1) - assertDiagnosticWasEmittedOrNot(2, 1) - assertDiagnosticWasEmittedOrNot(3, 1) - assertDiagnosticWasEmittedOrNot(4, 1) - assertDiagnosticWasEmittedOrNot(5, 1) - assertDiagnosticWasEmittedOrNot(6, 1) - assertDiagnosticWasEmittedOrNot(7, 1) - } - } - - func testFileScopeExtensionsAreNotChanged() { - runWithMultipleConfigurations( - source: """ - $access$ extension Foo {} - """, - testConfigurations: unchangingTestConfigurations - ) { assertDiagnosticWasEmittedOrNot in - assertDiagnosticWasEmittedOrNot(1, 1) - } - } - - func testNonFileScopeDeclsAreNotChanged() { - runWithMultipleConfigurations( - source: """ - enum Namespace { - $access$ class Foo {} - $access$ struct Foo {} - $access$ enum Foo {} - $access$ typealias Foo = Bar - $access$ func foo() {} - $access$ var foo: Bar - } - """, - testConfigurations: unchangingTestConfigurations - ) { assertDiagnosticWasEmittedOrNot in - assertDiagnosticWasEmittedOrNot(1, 1) - assertDiagnosticWasEmittedOrNot(2, 1) - assertDiagnosticWasEmittedOrNot(3, 1) - assertDiagnosticWasEmittedOrNot(4, 1) - assertDiagnosticWasEmittedOrNot(5, 1) - assertDiagnosticWasEmittedOrNot(6, 1) - assertDiagnosticWasEmittedOrNot(7, 1) - } - } - - func testFileScopeDeclsInsideConditionals() { - runWithMultipleConfigurations( - source: """ - #if FOO - $access$ class Foo {} - $access$ struct Foo {} - $access$ enum Foo {} - $access$ protocol Foo {} - $access$ typealias Foo = Bar - $access$ func foo() {} - $access$ var foo: Bar - #elseif BAR - $access$ class Foo {} - $access$ struct Foo {} - $access$ enum Foo {} - $access$ protocol Foo {} - $access$ typealias Foo = Bar - $access$ func foo() {} - $access$ var foo: Bar - #else - $access$ class Foo {} - $access$ struct Foo {} - $access$ enum Foo {} - $access$ protocol Foo {} - $access$ typealias Foo = Bar - $access$ func foo() {} - $access$ var foo: Bar - #endif - """, - testConfigurations: changingTestConfigurations - ) { assertDiagnosticWasEmittedOrNot in - assertDiagnosticWasEmittedOrNot(2, 3) - assertDiagnosticWasEmittedOrNot(3, 3) - assertDiagnosticWasEmittedOrNot(4, 3) - assertDiagnosticWasEmittedOrNot(5, 3) - assertDiagnosticWasEmittedOrNot(6, 3) - assertDiagnosticWasEmittedOrNot(7, 3) - assertDiagnosticWasEmittedOrNot(8, 3) - assertDiagnosticWasEmittedOrNot(10, 3) - assertDiagnosticWasEmittedOrNot(11, 3) - assertDiagnosticWasEmittedOrNot(12, 3) - assertDiagnosticWasEmittedOrNot(13, 3) - assertDiagnosticWasEmittedOrNot(14, 3) - assertDiagnosticWasEmittedOrNot(15, 3) - assertDiagnosticWasEmittedOrNot(16, 3) - assertDiagnosticWasEmittedOrNot(18, 3) - assertDiagnosticWasEmittedOrNot(19, 3) - assertDiagnosticWasEmittedOrNot(20, 3) - assertDiagnosticWasEmittedOrNot(21, 3) - assertDiagnosticWasEmittedOrNot(22, 3) - assertDiagnosticWasEmittedOrNot(23, 3) - assertDiagnosticWasEmittedOrNot(24, 3) - } - } - - func testFileScopeDeclsInsideNestedConditionals() { - runWithMultipleConfigurations( - source: """ - #if FOO - #if BAR - $access$ class Foo {} - $access$ struct Foo {} - $access$ enum Foo {} - $access$ protocol Foo {} - $access$ typealias Foo = Bar - $access$ func foo() {} - $access$ var foo: Bar - #endif - #endif - """, - testConfigurations: changingTestConfigurations - ) { assertDiagnosticWasEmittedOrNot in - assertDiagnosticWasEmittedOrNot(3, 5) - assertDiagnosticWasEmittedOrNot(4, 5) - assertDiagnosticWasEmittedOrNot(5, 5) - assertDiagnosticWasEmittedOrNot(6, 5) - assertDiagnosticWasEmittedOrNot(7, 5) - assertDiagnosticWasEmittedOrNot(8, 5) - assertDiagnosticWasEmittedOrNot(9, 5) - } - } - - func testLeadingTriviaIsPreserved() { - runWithMultipleConfigurations( - source: """ - /// Some doc comment - $access$ class Foo {} - - @objc /* comment */ $access$ class Bar {} - """, - testConfigurations: changingTestConfigurations - ) { assertDiagnosticWasEmittedOrNot in - assertDiagnosticWasEmittedOrNot(2, 1) - assertDiagnosticWasEmittedOrNot(4, 21) - } - } - - func testModifierDetailIsPreserved() { - runWithMultipleConfigurations( - source: """ - public $access$(set) var foo: Int - """, - testConfigurations: changingTestConfigurations - ) { assertDiagnosticWasEmittedOrNot in - assertDiagnosticWasEmittedOrNot(1, 8) - } - } - - /// Runs a test for this rule in multiple configurations. - private func runWithMultipleConfigurations( - source: String, - testConfigurations: [TestConfiguration], - file: StaticString = #file, - line: UInt = #line, - completion: ((Int, Int) -> Void) -> Void - ) { - for testConfig in testConfigurations { - var configuration = Configuration() - configuration.fileScopedDeclarationPrivacy.accessLevel = testConfig.desired - - let substitutedInput = source.replacingOccurrences(of: "$access$", with: testConfig.original) - let substitutedExpected = - source.replacingOccurrences(of: "$access$", with: testConfig.expected) - - XCTAssertFormatting( - FileScopedDeclarationPrivacy.self, - input: substitutedInput, - expected: substitutedExpected, - checkForUnassertedDiagnostics: true, - configuration: configuration, - file: file, - line: line) - - let message: Finding.Message = - testConfig.desired == .private - ? .replaceFileprivateWithPrivate - : .replacePrivateWithFileprivate - - if testConfig.original == testConfig.expected { - completion { _, _ in - XCTAssertNotDiagnosed(message, file: file, line: line) - } - } else { - completion { diagnosticLine, diagnosticColumn in - XCTAssertDiagnosed( - message, line: diagnosticLine, column: diagnosticColumn, file: file, line: line) - } - } - } - } -} diff --git a/Tests/SwiftFormatRulesTests/FullyIndirectEnumTests.swift b/Tests/SwiftFormatRulesTests/FullyIndirectEnumTests.swift deleted file mode 100644 index 9d24ed7a9..000000000 --- a/Tests/SwiftFormatRulesTests/FullyIndirectEnumTests.swift +++ /dev/null @@ -1,62 +0,0 @@ -import SwiftFormatRules - -class FullyIndirectEnumTests: LintOrFormatRuleTestCase { - func testAllIndirectCases() { - XCTAssertFormatting( - FullyIndirectEnum.self, - input: """ - // Comment 1 - public enum DependencyGraphNode { - internal indirect case userDefined(dependencies: [DependencyGraphNode]) - // Comment 2 - indirect case synthesized(dependencies: [DependencyGraphNode]) - indirect case other(dependencies: [DependencyGraphNode]) - var x: Int - } - """, - expected: """ - // Comment 1 - public indirect enum DependencyGraphNode { - internal case userDefined(dependencies: [DependencyGraphNode]) - // Comment 2 - case synthesized(dependencies: [DependencyGraphNode]) - case other(dependencies: [DependencyGraphNode]) - var x: Int - } - """) - } - - func testNotAllIndirectCases() { - let input = """ - public enum CompassPoint { - case north - indirect case south - case east - case west - } - """ - XCTAssertFormatting(FullyIndirectEnum.self, input: input, expected: input) - } - - func testAlreadyIndirectEnum() { - let input = """ - indirect enum CompassPoint { - case north - case south - case east - case west - } - """ - XCTAssertFormatting(FullyIndirectEnum.self, input: input, expected: input) - } - - func testCaselessEnum() { - let input = """ - public enum Constants { - public static let foo = 5 - public static let bar = "bar" - } - """ - XCTAssertFormatting(FullyIndirectEnum.self, input: input, expected: input) - } -} diff --git a/Tests/SwiftFormatRulesTests/GroupNumericLiteralsTests.swift b/Tests/SwiftFormatRulesTests/GroupNumericLiteralsTests.swift deleted file mode 100644 index 27e63842a..000000000 --- a/Tests/SwiftFormatRulesTests/GroupNumericLiteralsTests.swift +++ /dev/null @@ -1,57 +0,0 @@ -import SwiftFormatRules - -final class GroupNumericLiteralsTests: LintOrFormatRuleTestCase { - func testNumericGrouping() { - XCTAssertFormatting( - GroupNumericLiterals.self, - input: """ - let a = 9876543210 - let b = 1234 - let c = 0x34950309233 - let d = -0x34242 - let e = 0b10010010101 - let f = 0b101 - let g = 11_15_1999 - let h = 0o21743 - let i = -53096828347 - let j = 0000123 - let k = 0x00000012 - let l = 0x0000012 - let m = 0b00010010101 - let n = [ - 0xff00ff00, // comment - 0x00ff00ff, // comment - ] - """, - expected: """ - let a = 9_876_543_210 - let b = 1234 - let c = 0x349_5030_9233 - let d = -0x34242 - let e = 0b100_10010101 - let f = 0b101 - let g = 11_15_1999 - let h = 0o21743 - let i = -53_096_828_347 - let j = 0_000_123 - let k = 0x0000_0012 - let l = 0x0000012 - let m = 0b000_10010101 - let n = [ - 0xff00_ff00, // comment - 0x00ff_00ff, // comment - ] - """) - XCTAssertDiagnosed(.groupNumericLiteral(every: 3)) - XCTAssertDiagnosed(.groupNumericLiteral(every: 3)) - XCTAssertDiagnosed(.groupNumericLiteral(every: 3)) - XCTAssertNotDiagnosed(.groupNumericLiteral(every: 3)) - XCTAssertDiagnosed(.groupNumericLiteral(every: 4)) - XCTAssertDiagnosed(.groupNumericLiteral(every: 4)) - XCTAssertDiagnosed(.groupNumericLiteral(every: 8)) - XCTAssertDiagnosed(.groupNumericLiteral(every: 8)) - XCTAssertNotDiagnosed(.groupNumericLiteral(every: 8)) - XCTAssertDiagnosed(.groupNumericLiteral(every: 4)) - XCTAssertDiagnosed(.groupNumericLiteral(every: 4)) - } -} diff --git a/Tests/SwiftFormatRulesTests/IdentifiersMustBeASCIITests.swift b/Tests/SwiftFormatRulesTests/IdentifiersMustBeASCIITests.swift deleted file mode 100644 index 95ff62cf1..000000000 --- a/Tests/SwiftFormatRulesTests/IdentifiersMustBeASCIITests.swift +++ /dev/null @@ -1,19 +0,0 @@ -import SwiftFormatRules - -final class IdentifiersMustBeASCIITests: LintOrFormatRuleTestCase { - func testInvalidIdentifiers() { - let input = - """ - let Te$t = 1 - var fo😎o = 2 - let Δx = newX - previousX - var 🤩😆 = 20 - """ - performLint(IdentifiersMustBeASCII.self, input: input) - XCTAssertDiagnosed(.nonASCIICharsNotAllowed(["😎"],"fo😎o")) - // TODO: It would be nice to allow Δ (among other mathematically meaningful symbols) without - // a lot of special cases; investigate this. - XCTAssertDiagnosed(.nonASCIICharsNotAllowed(["Δ"],"Δx")) - XCTAssertDiagnosed(.nonASCIICharsNotAllowed(["🤩", "😆"], "🤩😆")) - } -} diff --git a/Tests/SwiftFormatRulesTests/ImportsXCTestVisitorTests.swift b/Tests/SwiftFormatRulesTests/ImportsXCTestVisitorTests.swift deleted file mode 100644 index b133678ea..000000000 --- a/Tests/SwiftFormatRulesTests/ImportsXCTestVisitorTests.swift +++ /dev/null @@ -1,57 +0,0 @@ -import SwiftFormatCore -import SwiftFormatRules -import SwiftFormatTestSupport -import SwiftParser -import XCTest - -class ImportsXCTestVisitorTests: DiagnosingTestCase { - func testDoesNotImportXCTest() throws { - XCTAssertEqual( - try makeContextAndSetImportsXCTest(source: """ - import Foundation - """), - .doesNotImportXCTest - ) - } - - func testImportsXCTest() throws { - XCTAssertEqual( - try makeContextAndSetImportsXCTest(source: """ - import Foundation - import XCTest - """), - .importsXCTest - ) - } - - func testImportsSpecificXCTestDecl() throws { - XCTAssertEqual( - try makeContextAndSetImportsXCTest(source: """ - import Foundation - import class XCTest.XCTestCase - """), - .importsXCTest - ) - } - - func testImportsXCTestInsideConditional() throws { - XCTAssertEqual( - try makeContextAndSetImportsXCTest(source: """ - import Foundation - #if SOME_FEATURE_FLAG - import XCTest - #endif - """), - .importsXCTest - ) - } - - /// Parses the given source, makes a new `Context`, then populates and returns its `XCTest` - /// import state. - private func makeContextAndSetImportsXCTest(source: String) throws -> Context.XCTestImportState { - let sourceFile = Parser.parse(source: source) - let context = makeContext(sourceFileSyntax: sourceFile) - setImportsXCTest(context: context, sourceFile: sourceFile) - return context.importsXCTest - } -} diff --git a/Tests/SwiftFormatRulesTests/LintOrFormatRuleTestCase.swift b/Tests/SwiftFormatRulesTests/LintOrFormatRuleTestCase.swift deleted file mode 100644 index 66ae212ce..000000000 --- a/Tests/SwiftFormatRulesTests/LintOrFormatRuleTestCase.swift +++ /dev/null @@ -1,76 +0,0 @@ -import SwiftFormatConfiguration -import SwiftFormatCore -import SwiftFormatTestSupport -import SwiftOperators -import SwiftParser -import SwiftSyntax -import XCTest - -class LintOrFormatRuleTestCase: DiagnosingTestCase { - /// Performs a lint using the provided linter rule on the provided input. - /// - /// - Parameters: - /// - type: The metatype of the lint rule you wish to perform. - /// - input: The input code. - /// - file: The file the test resides in (defaults to the current caller's file) - /// - line: The line the test resides in (defaults to the current caller's line) - final func performLint( - _ type: LintRule.Type, - input: String, - file: StaticString = #file, - line: UInt = #line - ) { - let sourceFileSyntax = try! restoringLegacyTriviaBehavior( - OperatorTable.standardOperators.foldAll(Parser.parse(source: input)) - .as(SourceFileSyntax.self)!) - - // Force the rule to be enabled while we test it. - var configuration = Configuration() - configuration.rules[type.ruleName] = true - let context = makeContext(sourceFileSyntax: sourceFileSyntax, configuration: configuration) - - // If we're linting, then indicate that we want to fail for unasserted diagnostics when the test - // is torn down. - shouldCheckForUnassertedDiagnostics = true - - let linter = type.init(context: context) - linter.walk(sourceFileSyntax) - } - - /// Asserts that the result of applying a formatter to the provided input code yields the output. - /// - /// This method should be called by each test of each rule. - /// - /// - Parameters: - /// - formatType: The metatype of the format rule you wish to apply. - /// - input: The unformatted input code. - /// - expected: The expected result of formatting the input code. - /// - checkForUnassertedDiagnostics: Fail the test if there are any unasserted linter - /// diagnostics. - /// - configuration: The configuration to use when formatting (or nil to use the default). - /// - file: The file the test resides in (defaults to the current caller's file) - /// - line: The line the test resides in (defaults to the current caller's line) - final func XCTAssertFormatting( - _ formatType: SyntaxFormatRule.Type, - input: String, - expected: String, - checkForUnassertedDiagnostics: Bool = false, - configuration: Configuration? = nil, - file: StaticString = #file, - line: UInt = #line - ) { - let sourceFileSyntax = try! restoringLegacyTriviaBehavior( - OperatorTable.standardOperators.foldAll(Parser.parse(source: input)) - .as(SourceFileSyntax.self)!) - - // Force the rule to be enabled while we test it. - var configuration = configuration ?? Configuration() - configuration.rules[formatType.ruleName] = true - let context = makeContext(sourceFileSyntax: sourceFileSyntax, configuration: configuration) - - shouldCheckForUnassertedDiagnostics = checkForUnassertedDiagnostics - let formatter = formatType.init(context: context) - let actual = formatter.visit(sourceFileSyntax) - XCTAssertStringsEqualWithDiff(actual.description, expected, file: file, line: line) - } -} diff --git a/Tests/SwiftFormatRulesTests/NeverForceUnwrapTests.swift b/Tests/SwiftFormatRulesTests/NeverForceUnwrapTests.swift deleted file mode 100644 index 076ea6610..000000000 --- a/Tests/SwiftFormatRulesTests/NeverForceUnwrapTests.swift +++ /dev/null @@ -1,39 +0,0 @@ -import SwiftFormatRules - -final class NeverForceUnwrapTests: LintOrFormatRuleTestCase { - func testUnsafeUnwrap() { - let input = - """ - func someFunc() -> Int { - var a = getInt() - var b = a as! Int - let c = (someValue())! - let d = String(a)! - let regex = try! NSRegularExpression(pattern: "a*b+c?") - let e = /*comment about stuff*/ [1: a, 2: b, 3: c][4]! - var f = a as! /*comment about this type*/ FooBarType - return a! - } - """ - performLint(NeverForceUnwrap.self, input: input) - XCTAssertDiagnosed(.doNotForceCast(name: "Int"), line: 3, column: 11) - XCTAssertDiagnosed(.doNotForceUnwrap(name: "(someValue())"), line: 4, column: 11) - XCTAssertDiagnosed(.doNotForceUnwrap(name: "String(a)"), line: 5, column: 11) - XCTAssertNotDiagnosed(.doNotForceCast(name: "try")) - XCTAssertNotDiagnosed(.doNotForceUnwrap(name: "try")) - XCTAssertDiagnosed(.doNotForceUnwrap(name: "[1: a, 2: b, 3: c][4]"), line: 7, column: 35) - XCTAssertDiagnosed(.doNotForceCast(name: "FooBarType"), line: 8, column: 11) - XCTAssertDiagnosed(.doNotForceUnwrap(name: "a"), line: 9, column: 10) - } - - func testIgnoreTestCode() { - let input = - """ - import XCTest - - var b = a as! Int - """ - performLint(NeverUseImplicitlyUnwrappedOptionals.self, input: input) - XCTAssertNotDiagnosed(.doNotForceCast(name: "Int")) - } -} diff --git a/Tests/SwiftFormatRulesTests/NeverUseForceTryTests.swift b/Tests/SwiftFormatRulesTests/NeverUseForceTryTests.swift deleted file mode 100644 index 646182a95..000000000 --- a/Tests/SwiftFormatRulesTests/NeverUseForceTryTests.swift +++ /dev/null @@ -1,36 +0,0 @@ -import SwiftFormatRules - -final class NeverUseForceTryTests: LintOrFormatRuleTestCase { - func testInvalidTryExpression() { - let input = - """ - let document = try! Document(path: "important.data") - let document = try Document(path: "important.data") - let x = try! someThrowingFunction() - let x = try? someThrowingFunction( - try! someThrowingFunction() - ) - let x = try someThrowingFunction( - try! someThrowingFunction() - ) - if let data = try? fetchDataFromDisk() { return data } - """ - performLint(NeverUseForceTry.self, input: input) - XCTAssertDiagnosed(.doNotForceTry) - XCTAssertDiagnosed(.doNotForceTry) - XCTAssertDiagnosed(.doNotForceTry) - XCTAssertDiagnosed(.doNotForceTry) - XCTAssertNotDiagnosed(.doNotForceTry) - } - - func testAllowForceTryInTestCode() { - let input = - """ - import XCTest - - let document = try! Document(path: "important.data") - """ - performLint(NeverUseForceTry.self, input: input) - XCTAssertNotDiagnosed(.doNotForceTry) - } -} diff --git a/Tests/SwiftFormatRulesTests/NeverUseImplicitlyUnwrappedOptionalsTests.swift b/Tests/SwiftFormatRulesTests/NeverUseImplicitlyUnwrappedOptionalsTests.swift deleted file mode 100644 index 369f1c8fe..000000000 --- a/Tests/SwiftFormatRulesTests/NeverUseImplicitlyUnwrappedOptionalsTests.swift +++ /dev/null @@ -1,35 +0,0 @@ -import SwiftFormatRules - -final class NeverUseImplicitlyUnwrappedOptionalsTests: LintOrFormatRuleTestCase { - func testInvalidVariableUnwrapping() { - let input = - """ - import Core - import Foundation - import SwiftSyntax - - var foo: Int? - var s: String! - var f: /*this is a Foo*/Foo! - var c, d, e: Float - @IBOutlet var button: UIButton! - """ - performLint(NeverUseImplicitlyUnwrappedOptionals.self, input: input) - XCTAssertNotDiagnosed(.doNotUseImplicitUnwrapping(identifier: "Int")) - XCTAssertDiagnosed(.doNotUseImplicitUnwrapping(identifier: "String")) - XCTAssertDiagnosed(.doNotUseImplicitUnwrapping(identifier: "Foo")) - XCTAssertNotDiagnosed(.doNotUseImplicitUnwrapping(identifier: "Float")) - XCTAssertNotDiagnosed(.doNotUseImplicitUnwrapping(identifier: "UIButton")) - } - - func testIgnoreTestCode() { - let input = - """ - import XCTest - - var s: String! - """ - performLint(NeverUseImplicitlyUnwrappedOptionals.self, input: input) - XCTAssertNotDiagnosed(.doNotUseImplicitUnwrapping(identifier: "String")) - } -} diff --git a/Tests/SwiftFormatRulesTests/NoAccessLevelOnExtensionDeclarationTests.swift b/Tests/SwiftFormatRulesTests/NoAccessLevelOnExtensionDeclarationTests.swift deleted file mode 100644 index 03a782d1e..000000000 --- a/Tests/SwiftFormatRulesTests/NoAccessLevelOnExtensionDeclarationTests.swift +++ /dev/null @@ -1,245 +0,0 @@ -import SwiftFormatRules - -final class NoAccessLevelOnExtensionDeclarationTests: LintOrFormatRuleTestCase { - func testExtensionDeclarationAccessLevel() { - XCTAssertFormatting( - NoAccessLevelOnExtensionDeclaration.self, - input: """ - public extension Foo { - var x: Bool - // Comment 1 - internal var y: Bool - // Comment 2 - static var z: Bool - // Comment 3 - static func someFunc() {} - init() {} - subscript(index: Int) -> Element {} - protocol SomeProtocol {} - class SomeClass {} - struct SomeStruct {} - enum SomeEnum {} - typealias Foo = Bar - } - internal extension Bar { - var a: Int - var b: Int - } - """, - expected: """ - extension Foo { - public var x: Bool - // Comment 1 - internal var y: Bool - // Comment 2 - public static var z: Bool - // Comment 3 - public static func someFunc() {} - public init() {} - public subscript(index: Int) -> Element {} - public protocol SomeProtocol {} - public class SomeClass {} - public struct SomeStruct {} - public enum SomeEnum {} - public typealias Foo = Bar - } - extension Bar { - var a: Int - var b: Int - } - """ - ) - } - - func testPreservesCommentOnRemovedModifier() { - XCTAssertFormatting( - NoAccessLevelOnExtensionDeclaration.self, - input: """ - /// This doc comment should stick around. - public extension Foo { - func f() {} - // This should not change. - func g() {} - } - - /// So should this one. - internal extension Foo { - func f() {} - // This should not change. - func g() {} - } - """, - expected: """ - /// This doc comment should stick around. - extension Foo { - public func f() {} - // This should not change. - public func g() {} - } - - /// So should this one. - extension Foo { - func f() {} - // This should not change. - func g() {} - } - """ - ) - } - - func testPrivateIsEffectivelyFileprivate() { - XCTAssertFormatting( - NoAccessLevelOnExtensionDeclaration.self, - input: """ - private extension Foo { - func f() {} - } - """, - expected: """ - extension Foo { - fileprivate func f() {} - } - """ - ) - } - - func testExtensionWithAnnotation() { - XCTAssertFormatting( - NoAccessLevelOnExtensionDeclaration.self, - input: - """ - /// This extension has a comment. - @objc public extension Foo { - } - """, - expected: - """ - /// This extension has a comment. - @objc extension Foo { - } - """ - ) - } - - func testPreservesInlineAnnotationsBeforeAddedAccessLevelModifiers() { - XCTAssertFormatting( - NoAccessLevelOnExtensionDeclaration.self, - input: """ - /// This extension has a comment. - public extension Foo { - /// This property has a doc comment. - @objc var x: Bool { get { return true }} - // This property has a developer comment. - @objc static var z: Bool { get { return false }} - /// This static function has a doc comment. - @objc static func someStaticFunc() {} - @objc init(with foo: Foo) {} - @objc func someOtherFunc() {} - @objc protocol SomeProtocol {} - @objc class SomeClass : NSObject {} - @objc associatedtype SomeType - @objc enum SomeEnum : Int { - case SomeInt = 32 - } - } - """, - expected: """ - /// This extension has a comment. - extension Foo { - /// This property has a doc comment. - @objc public var x: Bool { get { return true }} - // This property has a developer comment. - @objc public static var z: Bool { get { return false }} - /// This static function has a doc comment. - @objc public static func someStaticFunc() {} - @objc public init(with foo: Foo) {} - @objc public func someOtherFunc() {} - @objc public protocol SomeProtocol {} - @objc public class SomeClass : NSObject {} - @objc public associatedtype SomeType - @objc public enum SomeEnum : Int { - case SomeInt = 32 - } - } - """ - ) - } - - func testPreservesMultiLineAnnotationsBeforeAddedAccessLevelModifiers() { - XCTAssertFormatting( - NoAccessLevelOnExtensionDeclaration.self, - input: """ - /// This extension has a comment. - public extension Foo { - /// This property has a doc comment. - @available(iOS 13, *) - var x: Bool { get { return true }} - // This property has a developer comment. - @available(iOS 13, *) - static var z: Bool { get { return false }} - // This static function has a developer comment. - @objc(someStaticFunction) - static func someStaticFunc() {} - @objc(initWithFoo:) - init(with foo: Foo) {} - @objc - func someOtherFunc() {} - @objc - protocol SomeProtocol {} - @objc - class SomeClass : NSObject {} - @available(iOS 13, *) - associatedtype SomeType - @objc - enum SomeEnum : Int { - case SomeInt = 32 - } - - // This is a doc comment for a multi-argument method. - @objc( - doSomethingThatIsVeryComplicatedWithThisFoo: - forGoodMeasureUsingThisBar: - andApplyingThisBaz: - ) - public func doSomething(_ foo : Foo, bar : Bar, baz : Baz) {} - } - """, - expected: """ - /// This extension has a comment. - extension Foo { - /// This property has a doc comment. - @available(iOS 13, *) - public var x: Bool { get { return true }} - // This property has a developer comment. - @available(iOS 13, *) - public static var z: Bool { get { return false }} - // This static function has a developer comment. - @objc(someStaticFunction) - public static func someStaticFunc() {} - @objc(initWithFoo:) - public init(with foo: Foo) {} - @objc - public func someOtherFunc() {} - @objc - public protocol SomeProtocol {} - @objc - public class SomeClass : NSObject {} - @available(iOS 13, *) - public associatedtype SomeType - @objc - public enum SomeEnum : Int { - case SomeInt = 32 - } - - // This is a doc comment for a multi-argument method. - @objc( - doSomethingThatIsVeryComplicatedWithThisFoo: - forGoodMeasureUsingThisBar: - andApplyingThisBaz: - ) - public func doSomething(_ foo : Foo, bar : Bar, baz : Baz) {} - } - """ - ) - } -} diff --git a/Tests/SwiftFormatRulesTests/NoAssignmentInExpressionsTests.swift b/Tests/SwiftFormatRulesTests/NoAssignmentInExpressionsTests.swift deleted file mode 100644 index 238c2f92f..000000000 --- a/Tests/SwiftFormatRulesTests/NoAssignmentInExpressionsTests.swift +++ /dev/null @@ -1,164 +0,0 @@ -import SwiftFormatRules - -final class NoAssignmentInExpressionsTests: LintOrFormatRuleTestCase { - func testAssignmentInExpressionContextIsDiagnosed() { - XCTAssertFormatting( - NoAssignmentInExpressions.self, - input: """ - foo(bar, baz = quux, a + b) - """, - expected: """ - foo(bar, baz = quux, a + b) - """ - ) - XCTAssertDiagnosed(.moveAssignmentToOwnStatement, line: 1, column: 10) - // Make sure no other expressions were diagnosed. - XCTAssertNotDiagnosed(.moveAssignmentToOwnStatement) - } - - func testReturnStatementWithoutExpressionIsUnchanged() { - XCTAssertFormatting( - NoAssignmentInExpressions.self, - input: """ - func foo() { - return - } - """, - expected: """ - func foo() { - return - } - """ - ) - XCTAssertNotDiagnosed(.moveAssignmentToOwnStatement) - } - - func testReturnStatementWithNonAssignmentExpressionIsUnchanged() { - XCTAssertFormatting( - NoAssignmentInExpressions.self, - input: """ - func foo() { - return a + b - } - """, - expected: """ - func foo() { - return a + b - } - """ - ) - XCTAssertNotDiagnosed(.moveAssignmentToOwnStatement) - } - - func testReturnStatementWithSimpleAssignmentExpressionIsExpanded() { - // For this and similar tests below, we don't try to match the leading indentation in the new - // `return` statement; the pretty-printer will fix it up. - XCTAssertFormatting( - NoAssignmentInExpressions.self, - input: """ - func foo() { - return a = b - } - """, - expected: """ - func foo() { - a = b - return - } - """ - ) - XCTAssertDiagnosed(.moveAssignmentToOwnStatement, line: 2, column: 10) - } - - func testReturnStatementWithCompoundAssignmentExpressionIsExpanded() { - XCTAssertFormatting( - NoAssignmentInExpressions.self, - input: """ - func foo() { - return a += b - } - """, - expected: """ - func foo() { - a += b - return - } - """ - ) - XCTAssertDiagnosed(.moveAssignmentToOwnStatement, line: 2, column: 10) - } - - func testReturnStatementWithAssignmentDealsWithLeadingLineCommentSensibly() { - XCTAssertFormatting( - NoAssignmentInExpressions.self, - input: """ - func foo() { - // some comment - return a = b - } - """, - expected: """ - func foo() { - // some comment - a = b - return - } - """ - ) - XCTAssertDiagnosed(.moveAssignmentToOwnStatement, line: 3, column: 10) - } - - func testReturnStatementWithAssignmentDealsWithTrailingLineCommentSensibly() { - XCTAssertFormatting( - NoAssignmentInExpressions.self, - input: """ - func foo() { - return a = b // some comment - } - """, - expected: """ - func foo() { - a = b - return // some comment - } - """ - ) - XCTAssertDiagnosed(.moveAssignmentToOwnStatement, line: 2, column: 10) - } - - func testReturnStatementWithAssignmentDealsWithTrailingBlockCommentSensibly() { - XCTAssertFormatting( - NoAssignmentInExpressions.self, - input: """ - func foo() { - return a = b /* some comment */ - } - """, - expected: """ - func foo() { - a = b - return /* some comment */ - } - """ - ) - XCTAssertDiagnosed(.moveAssignmentToOwnStatement, line: 2, column: 10) - } - - func testReturnStatementWithAssignmentDealsWithNestedBlockCommentSensibly() { - XCTAssertFormatting( - NoAssignmentInExpressions.self, - input: """ - func foo() { - return /* some comment */ a = b - } - """, - expected: """ - func foo() { - /* some comment */ a = b - return - } - """ - ) - XCTAssertDiagnosed(.moveAssignmentToOwnStatement, line: 2, column: 29) - } -} diff --git a/Tests/SwiftFormatRulesTests/NoBlockCommentsTests.swift b/Tests/SwiftFormatRulesTests/NoBlockCommentsTests.swift deleted file mode 100644 index f00f8fd5f..000000000 --- a/Tests/SwiftFormatRulesTests/NoBlockCommentsTests.swift +++ /dev/null @@ -1,34 +0,0 @@ -import SwiftFormatRules - -final class NoBlockCommentsTests: LintOrFormatRuleTestCase { - func testDiagnoseBlockComments() { - let input = - """ - /* - Lorem ipsum dolor sit amet, at nonumes adipisci sea, natum - offendit vis ex. Audiam legendos expetenda ei quo, nonumes - - msensibus eloquentiam ex vix. - */ - let a = /*ff*/10 /*ff*/ + 10 - var b = 0/*Block Comment inline with code*/ - - /* - - Block Comment - */ - let c = a + b - /* This is the end - of a file - - */ - """ - performLint(NoBlockComments.self, input: input) - XCTAssertDiagnosed(.avoidBlockComment) - XCTAssertDiagnosed(.avoidBlockComment) - XCTAssertDiagnosed(.avoidBlockComment) - XCTAssertDiagnosed(.avoidBlockComment) - XCTAssertDiagnosed(.avoidBlockComment) - XCTAssertDiagnosed(.avoidBlockComment) - } -} diff --git a/Tests/SwiftFormatRulesTests/NoEmptyTrailingClosureParenthesesTests.swift b/Tests/SwiftFormatRulesTests/NoEmptyTrailingClosureParenthesesTests.swift deleted file mode 100644 index c4aef650a..000000000 --- a/Tests/SwiftFormatRulesTests/NoEmptyTrailingClosureParenthesesTests.swift +++ /dev/null @@ -1,88 +0,0 @@ -import SwiftFormatRules - -final class NoEmptyTrailingClosureParenthesesTests: LintOrFormatRuleTestCase { - func testInvalidEmptyParenTrailingClosure() { - XCTAssertFormatting( - NoEmptyTrailingClosureParentheses.self, - input: """ - func greetEnthusiastically(_ nameProvider: () -> String) { - // ... - } - func greetApathetically(_ nameProvider: () -> String) { - // ... - } - greetEnthusiastically() { "John" } - greetApathetically { "not John" } - func myfunc(cls: MyClass) { - cls.myClosure { $0 } - } - func myfunc(cls: MyClass) { - cls.myBadClosure() { $0 } - } - DispatchQueue.main.async() { - greetEnthusiastically() { "John" } - DispatchQueue.main.async() { - greetEnthusiastically() { "Willis" } - } - } - DispatchQueue.global.async(inGroup: blah) { - DispatchQueue.main.async() { - greetEnthusiastically() { "Willis" } - } - DispatchQueue.main.async { - greetEnthusiastically() { "Willis" } - } - } - foo(bar() { baz })() { blah } - """, - expected: """ - func greetEnthusiastically(_ nameProvider: () -> String) { - // ... - } - func greetApathetically(_ nameProvider: () -> String) { - // ... - } - greetEnthusiastically { "John" } - greetApathetically { "not John" } - func myfunc(cls: MyClass) { - cls.myClosure { $0 } - } - func myfunc(cls: MyClass) { - cls.myBadClosure { $0 } - } - DispatchQueue.main.async { - greetEnthusiastically { "John" } - DispatchQueue.main.async { - greetEnthusiastically { "Willis" } - } - } - DispatchQueue.global.async(inGroup: blah) { - DispatchQueue.main.async { - greetEnthusiastically { "Willis" } - } - DispatchQueue.main.async { - greetEnthusiastically { "Willis" } - } - } - foo(bar { baz }) { blah } - """, - checkForUnassertedDiagnostics: true) - XCTAssertDiagnosed( - .removeEmptyTrailingParentheses(name: "greetEnthusiastically"), line: 7, column: 1) - XCTAssertDiagnosed(.removeEmptyTrailingParentheses(name: "myBadClosure"), line: 13, column: 3) - XCTAssertNotDiagnosed(.removeEmptyTrailingParentheses(name: "myClosure")) - XCTAssertDiagnosed(.removeEmptyTrailingParentheses(name: "async"), line: 15, column: 1) - XCTAssertDiagnosed( - .removeEmptyTrailingParentheses(name: "greetEnthusiastically"), line: 16, column: 3) - XCTAssertDiagnosed(.removeEmptyTrailingParentheses(name: "async"), line: 17, column: 3) - XCTAssertDiagnosed( - .removeEmptyTrailingParentheses(name: "greetEnthusiastically"), line: 18, column: 5) - XCTAssertDiagnosed(.removeEmptyTrailingParentheses(name: "async"), line: 22, column: 3) - XCTAssertDiagnosed( - .removeEmptyTrailingParentheses(name: "greetEnthusiastically"), line: 23, column: 5) - XCTAssertDiagnosed( - .removeEmptyTrailingParentheses(name: "greetEnthusiastically"), line: 26, column: 5) - XCTAssertDiagnosed(.removeEmptyTrailingParentheses(name: ")"), line: 29, column: 1) - XCTAssertDiagnosed(.removeEmptyTrailingParentheses(name: "bar"), line: 29, column: 5) - } -} diff --git a/Tests/SwiftFormatRulesTests/NoLabelsInCasePatternsTests.swift b/Tests/SwiftFormatRulesTests/NoLabelsInCasePatternsTests.swift deleted file mode 100644 index f756513d4..000000000 --- a/Tests/SwiftFormatRulesTests/NoLabelsInCasePatternsTests.swift +++ /dev/null @@ -1,32 +0,0 @@ -import SwiftFormatRules - -final class NoLabelsInCasePatternsTests: LintOrFormatRuleTestCase { - func testRedundantCaseLabels() { - XCTAssertFormatting( - NoLabelsInCasePatterns.self, - input: """ - switch treeNode { - case .root(let data): - break - case .subtree(left: let /*hello*/left, right: let right): - break - case .leaf(element: let element): - break - } - """, - expected: """ - switch treeNode { - case .root(let data): - break - case .subtree(let /*hello*/left, let right): - break - case .leaf(let element): - break - } - """) - XCTAssertNotDiagnosed(.removeRedundantLabel(name: "data")) - XCTAssertDiagnosed(.removeRedundantLabel(name: "left")) - XCTAssertDiagnosed(.removeRedundantLabel(name: "right")) - XCTAssertDiagnosed(.removeRedundantLabel(name: "element")) - } -} diff --git a/Tests/SwiftFormatRulesTests/NoLeadingUnderscoresTests.swift b/Tests/SwiftFormatRulesTests/NoLeadingUnderscoresTests.swift deleted file mode 100644 index 3df6f4f43..000000000 --- a/Tests/SwiftFormatRulesTests/NoLeadingUnderscoresTests.swift +++ /dev/null @@ -1,164 +0,0 @@ -import SwiftFormatRules - -final class NoLeadingUnderscoresTests: LintOrFormatRuleTestCase { - func testVars() { - let input = """ - let _foo = foo - var good_name = 20 - var _badName, okayName, _wor_sEName = 20 - """ - performLint(NoLeadingUnderscores.self, input: input) - - XCTAssertDiagnosed(.doNotStartWithUnderscore(identifier: "_foo")) - XCTAssertNotDiagnosed(.doNotStartWithUnderscore(identifier: "good_name")) - XCTAssertDiagnosed(.doNotStartWithUnderscore(identifier: "_badName")) - XCTAssertNotDiagnosed(.doNotStartWithUnderscore(identifier: "okayName")) - XCTAssertDiagnosed(.doNotStartWithUnderscore(identifier: "_wor_sEName")) - } - - func testClasses() { - let input = """ - class Foo { let _foo = foo } - class _Bar {} - """ - performLint(NoLeadingUnderscores.self, input: input) - - XCTAssertNotDiagnosed(.doNotStartWithUnderscore(identifier: "Foo")) - XCTAssertDiagnosed(.doNotStartWithUnderscore(identifier: "_foo")) - XCTAssertDiagnosed(.doNotStartWithUnderscore(identifier: "_Bar")) - } - - func testEnums() { - let input = """ - enum Foo { - case _case1 - case case2, _case3 - case caseWithAssociatedValues(_value: Int, otherValue: String) - let _foo = foo - } - enum _Bar {} - """ - performLint(NoLeadingUnderscores.self, input: input) - - XCTAssertNotDiagnosed(.doNotStartWithUnderscore(identifier: "Foo")) - XCTAssertDiagnosed(.doNotStartWithUnderscore(identifier: "_case1")) - XCTAssertNotDiagnosed(.doNotStartWithUnderscore(identifier: "case2")) - XCTAssertDiagnosed(.doNotStartWithUnderscore(identifier: "_case3")) - XCTAssertNotDiagnosed(.doNotStartWithUnderscore(identifier: "caseWithAssociatedValues")) - XCTAssertDiagnosed(.doNotStartWithUnderscore(identifier: "_value")) - XCTAssertNotDiagnosed(.doNotStartWithUnderscore(identifier: "otherValue")) - XCTAssertDiagnosed(.doNotStartWithUnderscore(identifier: "_foo")) - XCTAssertDiagnosed(.doNotStartWithUnderscore(identifier: "_Bar")) - } - - func testProtocols() { - let input = """ - protocol Foo { - associatedtype _Quux - associatedtype Florb - var _foo: Int { get set } - } - protocol _Bar {} - """ - performLint(NoLeadingUnderscores.self, input: input) - - XCTAssertNotDiagnosed(.doNotStartWithUnderscore(identifier: "Foo")) - XCTAssertDiagnosed(.doNotStartWithUnderscore(identifier: "_foo")) - XCTAssertDiagnosed(.doNotStartWithUnderscore(identifier: "_Bar")) - XCTAssertDiagnosed(.doNotStartWithUnderscore(identifier: "_Quux")) - XCTAssertNotDiagnosed(.doNotStartWithUnderscore(identifier: "Florb")) - } - - func testStructs() { - let input = """ - struct Foo { let _foo = foo } - struct _Bar {} - """ - performLint(NoLeadingUnderscores.self, input: input) - - XCTAssertNotDiagnosed(.doNotStartWithUnderscore(identifier: "Foo")) - XCTAssertDiagnosed(.doNotStartWithUnderscore(identifier: "_foo")) - XCTAssertDiagnosed(.doNotStartWithUnderscore(identifier: "_Bar")) - } - - func testFunctions() { - let input = """ - func _foo(_ ok: Int, _notOK: Int, _ok _butNotThisOne: Int) {} - func bar() {} - """ - performLint(NoLeadingUnderscores.self, input: input) - - XCTAssertDiagnosed(.doNotStartWithUnderscore(identifier: "_foo")) - XCTAssertNotDiagnosed(.doNotStartWithUnderscore(identifier: "T1")) - XCTAssertDiagnosed(.doNotStartWithUnderscore(identifier: "_T2")) - XCTAssertNotDiagnosed(.doNotStartWithUnderscore(identifier: "_")) - XCTAssertNotDiagnosed(.doNotStartWithUnderscore(identifier: "ok")) - XCTAssertDiagnosed(.doNotStartWithUnderscore(identifier: "_notOK")) - XCTAssertNotDiagnosed(.doNotStartWithUnderscore(identifier: "_ok")) - XCTAssertDiagnosed(.doNotStartWithUnderscore(identifier: "_butNotThisOne")) - XCTAssertNotDiagnosed(.doNotStartWithUnderscore(identifier: "bar")) - } - - func testInitializerArguments() { - let input = """ - struct X { - init(_ ok: Int, _notOK: Int, _ok _butNotThisOne: Int) {} - } - """ - performLint(NoLeadingUnderscores.self, input: input) - - XCTAssertNotDiagnosed(.doNotStartWithUnderscore(identifier: "T1")) - XCTAssertDiagnosed(.doNotStartWithUnderscore(identifier: "_T2")) - XCTAssertNotDiagnosed(.doNotStartWithUnderscore(identifier: "_")) - XCTAssertNotDiagnosed(.doNotStartWithUnderscore(identifier: "ok")) - XCTAssertDiagnosed(.doNotStartWithUnderscore(identifier: "_notOK")) - XCTAssertNotDiagnosed(.doNotStartWithUnderscore(identifier: "_ok")) - XCTAssertDiagnosed(.doNotStartWithUnderscore(identifier: "_butNotThisOne")) - } - - func testPrecedenceGroups() { - let input = """ - precedencegroup FooPrecedence { - associativity: left - higherThan: BarPrecedence - } - precedencegroup _FooPrecedence { - associativity: left - higherThan: BarPrecedence - } - infix operator <> : _BazPrecedence - """ - performLint(NoLeadingUnderscores.self, input: input) - - XCTAssertNotDiagnosed(.doNotStartWithUnderscore(identifier: "FooPrecedence")) - XCTAssertNotDiagnosed(.doNotStartWithUnderscore(identifier: "BarPrecedence")) - XCTAssertDiagnosed(.doNotStartWithUnderscore(identifier: "_FooPrecedence")) - XCTAssertNotDiagnosed(.doNotStartWithUnderscore(identifier: "_BazPrecedence")) - } - - func testTypealiases() { - let input = """ - typealias Foo = _Foo - typealias _Bar = Bar - """ - performLint(NoLeadingUnderscores.self, input: input) - - XCTAssertNotDiagnosed(.doNotStartWithUnderscore(identifier: "Foo")) - XCTAssertNotDiagnosed(.doNotStartWithUnderscore(identifier: "_Foo")) - XCTAssertDiagnosed(.doNotStartWithUnderscore(identifier: "_Bar")) - XCTAssertNotDiagnosed(.doNotStartWithUnderscore(identifier: "Bar")) - } - - func testIdentifiersAreIgnoredAtUsage() { - let input = """ - let x = _y + _z - _foo(_bar) - """ - performLint(NoLeadingUnderscores.self, input: input) - - XCTAssertNotDiagnosed(.doNotStartWithUnderscore(identifier: "_y")) - XCTAssertNotDiagnosed(.doNotStartWithUnderscore(identifier: "_z")) - XCTAssertNotDiagnosed(.doNotStartWithUnderscore(identifier: "_foo")) - XCTAssertNotDiagnosed(.doNotStartWithUnderscore(identifier: "_bar")) - } -} diff --git a/Tests/SwiftFormatRulesTests/NoParensAroundConditionsTests.swift b/Tests/SwiftFormatRulesTests/NoParensAroundConditionsTests.swift deleted file mode 100644 index 8b0b53493..000000000 --- a/Tests/SwiftFormatRulesTests/NoParensAroundConditionsTests.swift +++ /dev/null @@ -1,138 +0,0 @@ -import SwiftFormatRules - -final class NoParensAroundConditionsTests: LintOrFormatRuleTestCase { - func testParensAroundConditions() { - XCTAssertFormatting( - NoParensAroundConditions.self, - input: """ - if (x) {} - while (x) {} - guard (x), (y), (x == 3) else {} - if (foo { x }) {} - repeat {} while(x) - switch (4) { default: break } - """, - expected: """ - if x {} - while x {} - guard x, y, x == 3 else {} - if (foo { x }) {} - repeat {} while x - switch 4 { default: break } - """) - } - - func testParensAroundNestedParenthesizedStatements() { - XCTAssertFormatting( - NoParensAroundConditions.self, - input: """ - switch (a) { - case 1: - switch (b) { - default: break - } - } - if (x) { - if (y) { - } else if (z) { - } else { - } - } else if (w) { - } - while (x) { - while (y) {} - } - guard (x), (y), (x == 3) else { - guard (a), (b), (c == x) else { - return - } - return - } - repeat { - repeat { - } while (y) - } while(x) - if (foo.someCall({ if (x) {} })) {} - """, - expected: """ - switch a { - case 1: - switch b { - default: break - } - } - if x { - if y { - } else if z { - } else { - } - } else if w { - } - while x { - while y {} - } - guard x, y, x == 3 else { - guard a, b, c == x else { - return - } - return - } - repeat { - repeat { - } while y - } while x - if foo.someCall({ if x {} }) {} - """) - } - - func testParensAroundNestedUnparenthesizedStatements() { - XCTAssertFormatting( - NoParensAroundConditions.self, - input: """ - switch b { - case 2: - switch (d) { - default: break - } - } - if x { - if (y) { - } else if (z) { - } else { - } - } else if (w) { - } - while x { - while (y) {} - } - repeat { - repeat { - } while (y) - } while x - if foo.someCall({ if (x) {} }) {} - """, - expected: """ - switch b { - case 2: - switch d { - default: break - } - } - if x { - if y { - } else if z { - } else { - } - } else if w { - } - while x { - while y {} - } - repeat { - repeat { - } while y - } while x - if foo.someCall({ if x {} }) {} - """) - } -} diff --git a/Tests/SwiftFormatRulesTests/NoVoidReturnOnFunctionSignatureTests.swift b/Tests/SwiftFormatRulesTests/NoVoidReturnOnFunctionSignatureTests.swift deleted file mode 100644 index 953b5b5e2..000000000 --- a/Tests/SwiftFormatRulesTests/NoVoidReturnOnFunctionSignatureTests.swift +++ /dev/null @@ -1,34 +0,0 @@ -import SwiftFormatRules - -final class NoVoidReturnOnFunctionSignatureTests: LintOrFormatRuleTestCase { - func testVoidReturns() { - XCTAssertFormatting( - NoVoidReturnOnFunctionSignature.self, - input: """ - func foo() -> () { - } - - func test() -> Void{ - } - - func x() -> Int { return 2 } - - let x = { () -> Void in - print("Hello, world!") - } - """, - expected: """ - func foo() { - } - - func test() { - } - - func x() -> Int { return 2 } - - let x = { () -> Void in - print("Hello, world!") - } - """) - } -} diff --git a/Tests/SwiftFormatRulesTests/OneCasePerLineTests.swift b/Tests/SwiftFormatRulesTests/OneCasePerLineTests.swift deleted file mode 100644 index d89cd6c72..000000000 --- a/Tests/SwiftFormatRulesTests/OneCasePerLineTests.swift +++ /dev/null @@ -1,112 +0,0 @@ -import SwiftFormatRules - -final class OneCasePerLineTests: LintOrFormatRuleTestCase { - - // The inconsistent leading whitespace in the expected text is intentional. This transform does - // not attempt to preserve leading indentation since the pretty printer will correct it when - // running the full formatter. - - func testInvalidCasesOnLine() { - XCTAssertFormatting( - OneCasePerLine.self, - input: - """ - public enum Token { - case arrow - case comma, identifier(String), semicolon, stringSegment(String) - case period - case ifKeyword(String), forKeyword(String) - indirect case guardKeyword, elseKeyword, contextualKeyword(String) - var x: Bool - case leftParen, rightParen = ")", leftBrace, rightBrace = "}" - } - """, - expected: - """ - public enum Token { - case arrow - case comma - case identifier(String) - case semicolon - case stringSegment(String) - case period - case ifKeyword(String) - case forKeyword(String) - indirect case guardKeyword, elseKeyword - indirect case contextualKeyword(String) - var x: Bool - case leftParen - case rightParen = ")" - case leftBrace - case rightBrace = "}" - } - """) - } - - func testElementOrderIsPreserved() { - XCTAssertFormatting( - OneCasePerLine.self, - input: - """ - enum Foo: Int { - case a = 0, b, c, d - } - """, - expected: - """ - enum Foo: Int { - case a = 0 - case b, c, d - } - """) - } - - func testCommentsAreNotRepeated() { - XCTAssertFormatting( - OneCasePerLine.self, - input: - """ - enum Foo: Int { - /// This should only be above `a`. - case a = 0, b, c, d - // This should only be above `e`. - case e, f = 100 - } - """, - expected: - """ - enum Foo: Int { - /// This should only be above `a`. - case a = 0 - case b, c, d - // This should only be above `e`. - case e - case f = 100 - } - """) - } - - func testAttributesArePropagated() { - XCTAssertFormatting( - OneCasePerLine.self, - input: - """ - enum Foo { - @someAttr case a(String), b, c, d - case e, f(Int) - @anotherAttr case g, h(Float) - } - """, - expected: - """ - enum Foo { - @someAttr case a(String) - @someAttr case b, c, d - case e - case f(Int) - @anotherAttr case g - @anotherAttr case h(Float) - } - """) - } -} diff --git a/Tests/SwiftFormatRulesTests/OneVariableDeclarationPerLineTests.swift b/Tests/SwiftFormatRulesTests/OneVariableDeclarationPerLineTests.swift deleted file mode 100644 index fb6af3bb3..000000000 --- a/Tests/SwiftFormatRulesTests/OneVariableDeclarationPerLineTests.swift +++ /dev/null @@ -1,232 +0,0 @@ -import SwiftFormatRules - -final class OneVariableDeclarationPerLineTests: LintOrFormatRuleTestCase { - func testMultipleVariableBindings() { - XCTAssertFormatting( - OneVariableDeclarationPerLine.self, - input: - """ - var a = 0, b = 2, (c, d) = (0, "h") - let e = 0, f = 2, (g, h) = (0, "h") - var x: Int { return 3 } - let a, b, c: Int - var j: Int, k: String, l: Float - """, - expected: - """ - var a = 0 - var b = 2 - var (c, d) = (0, "h") - let e = 0 - let f = 2 - let (g, h) = (0, "h") - var x: Int { return 3 } - let a: Int - let b: Int - let c: Int - var j: Int - var k: String - var l: Float - """, - checkForUnassertedDiagnostics: true - ) - XCTAssertDiagnosed(.onlyOneVariableDeclaration, line: 1, column: 1) - XCTAssertDiagnosed(.onlyOneVariableDeclaration, line: 2, column: 1) - XCTAssertDiagnosed(.onlyOneVariableDeclaration, line: 4, column: 1) - XCTAssertDiagnosed(.onlyOneVariableDeclaration, line: 5, column: 1) - } - - func testNestedVariableBindings() { - XCTAssertFormatting( - OneVariableDeclarationPerLine.self, - input: - """ - var x: Int = { - let y = 5, z = 10 - return z - }() - - func foo() { - let x = 4, y = 10 - } - - var x: Int { - let y = 5, z = 10 - return z - } - - var a: String = "foo" { - didSet { - let b, c: Bool - } - } - - let - a: Int = { - let p = 10, q = 20 - return p * q - }(), - b: Int = { - var s: Int, t: Double - return 20 - }() - """, - expected: - """ - var x: Int = { - let y = 5 - let z = 10 - return z - }() - - func foo() { - let x = 4 - let y = 10 - } - - var x: Int { - let y = 5 - let z = 10 - return z - } - - var a: String = "foo" { - didSet { - let b: Bool - let c: Bool - } - } - - let - a: Int = { - let p = 10 - let q = 20 - return p * q - }() - let - b: Int = { - var s: Int - var t: Double - return 20 - }() - """, - checkForUnassertedDiagnostics: true - ) - XCTAssertDiagnosed(.onlyOneVariableDeclaration, line: 2, column: 3) - XCTAssertDiagnosed(.onlyOneVariableDeclaration, line: 7, column: 3) - XCTAssertDiagnosed(.onlyOneVariableDeclaration, line: 11, column: 3) - XCTAssertDiagnosed(.onlyOneVariableDeclaration, line: 17, column: 5) - XCTAssertDiagnosed(.onlyOneVariableDeclaration, line: 21, column: 1) - XCTAssertDiagnosed(.onlyOneVariableDeclaration, line: 23, column: 5) - XCTAssertDiagnosed(.onlyOneVariableDeclaration, line: 27, column: 5) - } - - func testMixedInitializedAndTypedBindings() { - XCTAssertFormatting( - OneVariableDeclarationPerLine.self, - input: - """ - var a = 5, b: String - let c: Int, d = "d", e = "e", f: Double - """, - expected: - """ - var a = 5 - var b: String - let c: Int - let d = "d" - let e = "e" - let f: Double - """, - checkForUnassertedDiagnostics: true - ) - XCTAssertDiagnosed(.onlyOneVariableDeclaration, line: 1, column: 1) - XCTAssertDiagnosed(.onlyOneVariableDeclaration, line: 2, column: 1) - } - - func testCommentPrecedingDeclIsNotRepeated() { - XCTAssertFormatting( - OneVariableDeclarationPerLine.self, - input: - """ - // Comment - let a, b, c: Int - """, - expected: - """ - // Comment - let a: Int - let b: Int - let c: Int - """, - checkForUnassertedDiagnostics: true - ) - XCTAssertDiagnosed(.onlyOneVariableDeclaration, line: 2, column: 1) - } - - func testCommentsPrecedingBindingsAreKept() { - XCTAssertFormatting( - OneVariableDeclarationPerLine.self, - input: - """ - let /* a */ a, /* b */ b, /* c */ c: Int - """, - expected: - """ - let /* a */ a: Int - let /* b */ b: Int - let /* c */ c: Int - """, - checkForUnassertedDiagnostics: true - ) - XCTAssertDiagnosed(.onlyOneVariableDeclaration, line: 1, column: 1) - } - - func testInvalidBindingsAreNotDestroyed() { - XCTAssertFormatting( - OneVariableDeclarationPerLine.self, - input: - """ - let a, b, c = 5 - let d, e - let f, g, h: Int = 5 - let a: Int, b, c = 5, d, e: Int - """, - expected: - """ - let a, b, c = 5 - let d, e - let f, g, h: Int = 5 - let a: Int - let b, c = 5 - let d: Int - let e: Int - """, - checkForUnassertedDiagnostics: true - ) - XCTAssertDiagnosed(.onlyOneVariableDeclaration, line: 1, column: 1) - XCTAssertDiagnosed(.onlyOneVariableDeclaration, line: 2, column: 1) - XCTAssertDiagnosed(.onlyOneVariableDeclaration, line: 3, column: 1) - XCTAssertDiagnosed(.onlyOneVariableDeclaration, line: 4, column: 1) - } - - func testMultipleBindingsWithAccessorsAreCorrected() { - // Swift parses multiple bindings with accessors but forbids them at a later - // stage. That means that if the individual bindings would be correct in - // isolation then we can correct them, which is kind of nice. - XCTAssertFormatting( - OneVariableDeclarationPerLine.self, - input: - """ - var x: Int { return 10 }, y = "foo" { didSet { print("changed") } } - """, - expected: - """ - var x: Int { return 10 } - var y = "foo" { didSet { print("changed") } } - """, - checkForUnassertedDiagnostics: true - ) - XCTAssertDiagnosed(.onlyOneVariableDeclaration, line: 1, column: 1) - } -} diff --git a/Tests/SwiftFormatRulesTests/OnlyOneTrailingClosureArgumentTests.swift b/Tests/SwiftFormatRulesTests/OnlyOneTrailingClosureArgumentTests.swift deleted file mode 100644 index 812dfef9f..000000000 --- a/Tests/SwiftFormatRulesTests/OnlyOneTrailingClosureArgumentTests.swift +++ /dev/null @@ -1,20 +0,0 @@ -import SwiftFormatRules - -final class OnlyOneTrailingClosureArgumentTests: LintOrFormatRuleTestCase { - func testInvalidTrailingClosureCall() { - let input = - """ - callWithBoth(someClosure: {}) { - // ... - } - callWithClosure(someClosure: {}) - callWithTrailingClosure { - // ... - } - """ - performLint(OnlyOneTrailingClosureArgument.self, input: input) - XCTAssertDiagnosed(.removeTrailingClosure) - XCTAssertNotDiagnosed(.removeTrailingClosure) - XCTAssertNotDiagnosed(.removeTrailingClosure) - } -} diff --git a/Tests/SwiftFormatRulesTests/OrderedImportsTests.swift b/Tests/SwiftFormatRulesTests/OrderedImportsTests.swift deleted file mode 100644 index b3d2f2527..000000000 --- a/Tests/SwiftFormatRulesTests/OrderedImportsTests.swift +++ /dev/null @@ -1,644 +0,0 @@ -import SwiftFormatRules - -final class OrderedImportsTests: LintOrFormatRuleTestCase { - func testInvalidImportsOrder() { - let input = - """ - import Foundation - // Starts Imports - import Core - - - // Comment with new lines - import UIKit - - @testable import SwiftFormatRules - import enum Darwin.D.isatty - // Starts Test - @testable import MyModuleUnderTest - // Starts Ind - import func Darwin.C.isatty - - let a = 3 - import SwiftSyntax - """ - - let expected = - """ - // Starts Imports - import Core - import Foundation - import SwiftSyntax - // Comment with new lines - import UIKit - - // Starts Ind - import func Darwin.C.isatty - import enum Darwin.D.isatty - - // Starts Test - @testable import MyModuleUnderTest - @testable import SwiftFormatRules - - let a = 3 - """ - - XCTAssertFormatting( - OrderedImports.self, input: input, expected: expected, checkForUnassertedDiagnostics: true - ) - - XCTAssertDiagnosed(.sortImports) // import Core - XCTAssertDiagnosed(.sortImports) // import func Darwin.C.isatty - XCTAssertDiagnosed(.sortImports) // @testable import MyModuleUnderTest - - // import SwiftSyntax - XCTAssertDiagnosed(.placeAtTopOfFile) - XCTAssertDiagnosed(.groupImports(before: .regularImport, after: .declImport)) - XCTAssertDiagnosed(.groupImports(before: .regularImport, after: .testableImport)) - - // import func Darwin.C.isatty - XCTAssertDiagnosed(.groupImports(before: .declImport, after: .testableImport)) - - // import enum Darwin.D.isatty - XCTAssertDiagnosed(.groupImports(before: .declImport, after: .testableImport)) - } - - func testImportsOrderWithoutModuleType() { - let input = - """ - @testable import SwiftFormatRules - import func Darwin.D.isatty - @testable import MyModuleUnderTest - import func Darwin.C.isatty - - let a = 3 - """ - - let expected = - """ - import func Darwin.C.isatty - import func Darwin.D.isatty - - @testable import MyModuleUnderTest - @testable import SwiftFormatRules - - let a = 3 - """ - - XCTAssertFormatting( - OrderedImports.self, input: input, expected: expected, checkForUnassertedDiagnostics: true - ) - - // import func Darwin.D.isatty - XCTAssertDiagnosed(.groupImports(before: .declImport, after: .testableImport)) - - // import func Darwin.C.isatty - XCTAssertDiagnosed(.groupImports(before: .declImport, after: .testableImport)) - XCTAssertDiagnosed(.sortImports) - - // @testable import MyModuleUnderTest - XCTAssertDiagnosed(.sortImports) - } - - func testImportsOrderWithDocComment() { - let input = - """ - /// Test imports with comments. - /// - /// Comments at the top of the file - /// should be preserved. - - // Line comment for import - // Foundation. - import Foundation - // Line comment for Core - import Core - import UIKit - - let a = 3 - """ - - let expected = - """ - /// Test imports with comments. - /// - /// Comments at the top of the file - /// should be preserved. - - // Line comment for Core - import Core - // Line comment for import - // Foundation. - import Foundation - import UIKit - - let a = 3 - """ - - XCTAssertFormatting( - OrderedImports.self, input: input, expected: expected, checkForUnassertedDiagnostics: true - ) - - // import Core - XCTAssertDiagnosed(.sortImports) - } - - func testValidOrderedImport() { - let input = - """ - import CoreLocation - import MyThirdPartyModule - import SpriteKit - import UIKit - - import func Darwin.C.isatty - - @testable import MyModuleUnderTest - """ - - let expected = - """ - import CoreLocation - import MyThirdPartyModule - import SpriteKit - import UIKit - - import func Darwin.C.isatty - - @testable import MyModuleUnderTest - """ - - XCTAssertFormatting( - OrderedImports.self, input: input, expected: expected, checkForUnassertedDiagnostics: true - ) - - // Should not raise any linter errors. - } - - func testSeparatedFileHeader() { - let input = - """ - // This is part of the file header. - - // So is this. - - // Top comment - import Bimport - import Aimport - - struct MyStruct { - // do stuff - } - - import HoistMe - """ - - let expected = - """ - // This is part of the file header. - - // So is this. - - import Aimport - // Top comment - import Bimport - import HoistMe - - struct MyStruct { - // do stuff - } - """ - - XCTAssertFormatting( - OrderedImports.self, input: input, expected: expected, checkForUnassertedDiagnostics: true - ) - - // import Aimport - XCTAssertDiagnosed(.sortImports) - - // import HoistMe - XCTAssertDiagnosed(.placeAtTopOfFile) - } - - func testNonHeaderComment() { - let input = - """ - // Top comment - import Bimport - import Aimport - - let A = 123 - """ - - let expected = - """ - import Aimport - // Top comment - import Bimport - - let A = 123 - """ - - XCTAssertFormatting( - OrderedImports.self, input: input, expected: expected, checkForUnassertedDiagnostics: true - ) - - // import Aimport - XCTAssertDiagnosed(.sortImports) - } - - func testMultipleCodeBlocksPerLine() { - let input = - """ - import A;import Z;import D;import C; - foo();bar();baz();quxxe(); - """ - - let expected = - """ - import A; - import C; - import D; - import Z; - - foo();bar();baz();quxxe(); - """ - - XCTAssertFormatting(OrderedImports.self, input: input, expected: expected) - } - - func testMultipleCodeBlocksWithImportsPerLine() { - let input = - """ - import A;import Z;import D;import C;foo();bar();baz();quxxe(); - """ - - let expected = - """ - import A; - import C; - import D; - import Z; - - foo();bar();baz();quxxe(); - """ - - XCTAssertFormatting(OrderedImports.self, input: input, expected: expected) - } - - func testDisableOrderedImports() { - let input = - """ - import C - import B - // swift-format-ignore: OrderedImports - import A - let a = 123 - import func Darwin.C.isatty - - // swift-format-ignore - import a - """ - - let expected = - """ - import B - import C - - // swift-format-ignore: OrderedImports - import A - - import func Darwin.C.isatty - - let a = 123 - - // swift-format-ignore - import a - """ - - XCTAssertFormatting( - OrderedImports.self, input: input, expected: expected, checkForUnassertedDiagnostics: true - ) - - XCTAssertDiagnosed(.sortImports, line: 2, column: 1) - XCTAssertDiagnosed(.placeAtTopOfFile, line: 6, column: 1) - } - - func testDisableOrderedImportsMovingComments() { - let input = - """ - import C // Trailing comment about C - import B - // Comment about ignored A - // swift-format-ignore: OrderedImports - import A // trailing comment about ignored A - // Comment about Z - import Z - import D - // swift-format-ignore - // Comment about testable testA - @testable import testA - @testable import testZ // trailing comment about testZ - @testable import testC - // swift-format-ignore - @testable import testB - // Comment about Bar - import enum Bar - - let a = 2 - """ - - let expected = - """ - import B - import C // Trailing comment about C - - // Comment about ignored A - // swift-format-ignore: OrderedImports - import A // trailing comment about ignored A - - import D - // Comment about Z - import Z - - // swift-format-ignore - // Comment about testable testA - @testable import testA - - @testable import testC - @testable import testZ // trailing comment about testZ - - // swift-format-ignore - @testable import testB - - // Comment about Bar - import enum Bar - - let a = 2 - """ - - XCTAssertFormatting( - OrderedImports.self, input: input, expected: expected, checkForUnassertedDiagnostics: true - ) - - XCTAssertDiagnosed(.sortImports, line: 2, column: 1) - XCTAssertDiagnosed(.sortImports, line: 8, column: 1) - XCTAssertDiagnosed(.sortImports, line: 13, column: 1) - } - - func testEmptyFile() { - XCTAssertFormatting( - OrderedImports.self, input: "", expected: "", checkForUnassertedDiagnostics: true - ) - XCTAssertFormatting( - OrderedImports.self, input: "// test", expected: "// test", - checkForUnassertedDiagnostics: true - ) - } - - func testImportsContainingNewlines() { - let input = - """ - import - zeta - import Zeta - import - Alpha - import Beta - """ - - let expected = - """ - import - Alpha - import Beta - import Zeta - import - zeta - """ - - XCTAssertFormatting(OrderedImports.self, input: input, expected: expected) - } - - func testRemovesDuplicateImports() { - let input = - """ - import CoreLocation - import UIKit - import CoreLocation - import ZeeFramework - bar() - """ - - let expected = - """ - import CoreLocation - import UIKit - import ZeeFramework - - bar() - """ - - XCTAssertFormatting( - OrderedImports.self, input: input, expected: expected, checkForUnassertedDiagnostics: true) - XCTAssertDiagnosed(.removeDuplicateImport, line: 3, column: 1) - } - - func testDuplicateCommentedImports() { - let input = - """ - import AppKit - // CoreLocation is necessary to get location stuff. - import CoreLocation // This import must stay. - // UIKit does UI Stuff? - import UIKit - // This is the second CoreLocation import. - import CoreLocation // The 2nd CL import has a comment here too. - // Comment about ZeeFramework. - import ZeeFramework - import foo - // Second comment about ZeeFramework. - import ZeeFramework // This one has a trailing comment too. - foo() - """ - - let expected = - """ - import AppKit - // CoreLocation is necessary to get location stuff. - import CoreLocation // This import must stay. - // This is the second CoreLocation import. - import CoreLocation // The 2nd CL import has a comment here too. - // UIKit does UI Stuff? - import UIKit - // Comment about ZeeFramework. - // Second comment about ZeeFramework. - import ZeeFramework // This one has a trailing comment too. - import foo - - foo() - """ - - XCTAssertFormatting( - OrderedImports.self, input: input, expected: expected, checkForUnassertedDiagnostics: true) - - // Even though this import is technically also not sorted, that won't matter if the import is - // removed so there should only be a warning to remove it. - XCTAssertDiagnosed(.removeDuplicateImport, line: 7, column: 1) - XCTAssertDiagnosed(.removeDuplicateImport, line: 12, column: 1) - } - - func testDuplicateIgnoredImports() { - let input = - """ - import AppKit - // swift-format-ignore - import CoreLocation - // Second CoreLocation import here. - import CoreLocation - // Comment about ZeeFramework. - import ZeeFramework - // swift-format-ignore - import ZeeFramework // trailing comment - foo() - """ - - let expected = - """ - import AppKit - - // swift-format-ignore - import CoreLocation - - // Second CoreLocation import here. - import CoreLocation - // Comment about ZeeFramework. - import ZeeFramework - - // swift-format-ignore - import ZeeFramework // trailing comment - - foo() - """ - - XCTAssertFormatting( - OrderedImports.self, input: input, expected: expected, checkForUnassertedDiagnostics: true) - } - - func testDuplicateAttributedImports() { - let input = - """ - // imports an enum - import enum Darwin.D.isatty - // this is a dup - import enum Darwin.D.isatty - import foo - import a - @testable import foo - // exported import of bar - @_exported import bar - @_implementationOnly import bar - import bar - // second import of foo - import foo - baz() - """ - - let expected = - """ - import a - // exported import of bar - @_exported import bar - @_implementationOnly import bar - import bar - // second import of foo - import foo - - // imports an enum - // this is a dup - import enum Darwin.D.isatty - - @testable import foo - - baz() - """ - - XCTAssertFormatting(OrderedImports.self, input: input, expected: expected) - } - - func testConditionalImports() { - let input = - """ - import Zebras - import Apples - #if canImport(Darwin) - import Darwin - #elseif canImport(Glibc) - import Glibc - #endif - import Aardvarks - - foo() - bar() - baz() - """ - - let expected = - """ - import Aardvarks - import Apples - import Zebras - - #if canImport(Darwin) - import Darwin - #elseif canImport(Glibc) - import Glibc - #endif - - foo() - bar() - baz() - """ - - XCTAssertFormatting(OrderedImports.self, input: input, expected: expected) - } - - func testIgnoredConditionalImports() { - let input = - """ - import Zebras - import Apples - #if canImport(Darwin) - import Darwin - #elseif canImport(Glibc) - import Glibc - #endif - // swift-format-ignore - import Aardvarks - - foo() - bar() - baz() - """ - - let expected = - """ - import Apples - import Zebras - - #if canImport(Darwin) - import Darwin - #elseif canImport(Glibc) - import Glibc - #endif - // swift-format-ignore - import Aardvarks - - foo() - bar() - baz() - """ - - XCTAssertFormatting(OrderedImports.self, input: input, expected: expected) - } -} diff --git a/Tests/SwiftFormatRulesTests/ReturnVoidInsteadOfEmptyTupleTests.swift b/Tests/SwiftFormatRulesTests/ReturnVoidInsteadOfEmptyTupleTests.swift deleted file mode 100644 index 1bd020d5d..000000000 --- a/Tests/SwiftFormatRulesTests/ReturnVoidInsteadOfEmptyTupleTests.swift +++ /dev/null @@ -1,162 +0,0 @@ -import SwiftFormatRules - -final class ReturnVoidInsteadOfEmptyTupleTests: LintOrFormatRuleTestCase { - func testBasic() { - XCTAssertFormatting( - ReturnVoidInsteadOfEmptyTuple.self, - input: - """ - let callback: () -> () - typealias x = Int -> () - func y() -> Int -> () { return } - func z(d: Bool -> ()) {} - """, - expected: - """ - let callback: () -> Void - typealias x = Int -> Void - func y() -> Int -> Void { return } - func z(d: Bool -> Void) {} - """, - checkForUnassertedDiagnostics: true - ) - XCTAssertDiagnosed(.returnVoid, line: 1, column: 21) - XCTAssertDiagnosed(.returnVoid, line: 2, column: 22) - XCTAssertDiagnosed(.returnVoid, line: 3, column: 20) - XCTAssertDiagnosed(.returnVoid, line: 4, column: 19) - } - - func testNestedFunctionTypes() { - XCTAssertFormatting( - ReturnVoidInsteadOfEmptyTuple.self, - input: - """ - typealias Nested1 = (() -> ()) -> Int - typealias Nested2 = (() -> ()) -> () - typealias Nested3 = Int -> (() -> ()) - """, - expected: - """ - typealias Nested1 = (() -> Void) -> Int - typealias Nested2 = (() -> Void) -> Void - typealias Nested3 = Int -> (() -> Void) - """, - checkForUnassertedDiagnostics: true - ) - XCTAssertDiagnosed(.returnVoid, line: 1, column: 28) - XCTAssertDiagnosed(.returnVoid, line: 2, column: 28) - XCTAssertDiagnosed(.returnVoid, line: 2, column: 35) - XCTAssertDiagnosed(.returnVoid, line: 3, column: 35) - } - - func testClosureSignatures() { - XCTAssertFormatting( - ReturnVoidInsteadOfEmptyTuple.self, - input: - """ - callWithTrailingClosure(arg) { arg -> () in body } - callWithTrailingClosure(arg) { arg -> () in - nestedCallWithTrailingClosure(arg) { arg -> () in - body - } - } - callWithTrailingClosure(arg) { (arg: () -> ()) -> Int in body } - callWithTrailingClosure(arg) { (arg: () -> ()) -> () in body } - """, - expected: - """ - callWithTrailingClosure(arg) { arg -> Void in body } - callWithTrailingClosure(arg) { arg -> Void in - nestedCallWithTrailingClosure(arg) { arg -> Void in - body - } - } - callWithTrailingClosure(arg) { (arg: () -> Void) -> Int in body } - callWithTrailingClosure(arg) { (arg: () -> Void) -> Void in body } - """, - checkForUnassertedDiagnostics: true - ) - XCTAssertDiagnosed(.returnVoid, line: 1, column: 39) - XCTAssertDiagnosed(.returnVoid, line: 2, column: 39) - XCTAssertDiagnosed(.returnVoid, line: 3, column: 47) - XCTAssertDiagnosed(.returnVoid, line: 7, column: 44) - XCTAssertDiagnosed(.returnVoid, line: 8, column: 44) - XCTAssertDiagnosed(.returnVoid, line: 8, column: 51) - } - - func testTriviaPreservation() { - XCTAssertFormatting( - ReturnVoidInsteadOfEmptyTuple.self, - input: - """ - let callback: () -> /*foo*/()/*bar*/ - let callback: ((Int) -> /*foo*/ () /*bar*/) -> () - """, - expected: - """ - let callback: () -> /*foo*/Void/*bar*/ - let callback: ((Int) -> /*foo*/ Void /*bar*/) -> Void - """, - checkForUnassertedDiagnostics: true - ) - XCTAssertDiagnosed(.returnVoid, line: 1, column: 28) - XCTAssertDiagnosed(.returnVoid, line: 2, column: 37) - XCTAssertDiagnosed(.returnVoid, line: 2, column: 54) - } - - func testEmptyTupleWithInternalCommentsIsDiagnosedButNotReplaced() { - XCTAssertFormatting( - ReturnVoidInsteadOfEmptyTuple.self, - input: - """ - let callback: () -> ( ) - let callback: () -> (\t) - let callback: () -> ( - ) - let callback: () -> ( /* please don't change me! */ ) - let callback: () -> ( /** please don't change me! */ ) - let callback: () -> ( - // don't change me either! - ) - let callback: () -> ( - /// don't change me either! - ) - let callback: () -> (\u{feff}) - - let callback: (() -> ()) -> ( /* please don't change me! */ ) - callWithTrailingClosure(arg) { (arg: () -> ()) -> ( /* no change */ ) in body } - """, - expected: - """ - let callback: () -> Void - let callback: () -> Void - let callback: () -> Void - let callback: () -> ( /* please don't change me! */ ) - let callback: () -> ( /** please don't change me! */ ) - let callback: () -> ( - // don't change me either! - ) - let callback: () -> ( - /// don't change me either! - ) - let callback: () -> (\u{feff}) - - let callback: (() -> Void) -> ( /* please don't change me! */ ) - callWithTrailingClosure(arg) { (arg: () -> Void) -> ( /* no change */ ) in body } - """, - checkForUnassertedDiagnostics: true - ) - XCTAssertDiagnosed(.returnVoid, line: 1, column: 21) - XCTAssertDiagnosed(.returnVoid, line: 2, column: 21) - XCTAssertDiagnosed(.returnVoid, line: 3, column: 21) - XCTAssertDiagnosed(.returnVoid, line: 5, column: 21) - XCTAssertDiagnosed(.returnVoid, line: 6, column: 21) - XCTAssertDiagnosed(.returnVoid, line: 7, column: 21) - XCTAssertDiagnosed(.returnVoid, line: 10, column: 21) - XCTAssertDiagnosed(.returnVoid, line: 13, column: 21) - XCTAssertDiagnosed(.returnVoid, line: 15, column: 22) - XCTAssertDiagnosed(.returnVoid, line: 15, column: 29) - XCTAssertDiagnosed(.returnVoid, line: 16, column: 44) - XCTAssertDiagnosed(.returnVoid, line: 16, column: 51) - } -} diff --git a/Tests/SwiftFormatRulesTests/UseLetInEveryBoundCaseVariableTests.swift b/Tests/SwiftFormatRulesTests/UseLetInEveryBoundCaseVariableTests.swift deleted file mode 100644 index b491d4e69..000000000 --- a/Tests/SwiftFormatRulesTests/UseLetInEveryBoundCaseVariableTests.swift +++ /dev/null @@ -1,115 +0,0 @@ -import SwiftFormatRules - -final class UseLetInEveryBoundCaseVariableTests: LintOrFormatRuleTestCase { - override func setUp() { - super.setUp() - self.shouldCheckForUnassertedDiagnostics = true - } - - func testSwitchCase() { - let input = - """ - switch DataPoint.labeled("hello", 100) { - case let .labeled(label, value): break - case .labeled(label, let value): break - case .labeled(let label, let value): break - case let .labeled(label, value)?: break - case let .labeled(label, value)!: break - case let .labeled(label, value)??: break - case let (label, value): break - case let x as SomeType: break - } - """ - performLint(UseLetInEveryBoundCaseVariable.self, input: input) - - XCTAssertDiagnosed(.useLetInBoundCaseVariables, line: 2, column: 6) - XCTAssertDiagnosed(.useLetInBoundCaseVariables, line: 5, column: 6) - XCTAssertDiagnosed(.useLetInBoundCaseVariables, line: 6, column: 6) - XCTAssertDiagnosed(.useLetInBoundCaseVariables, line: 7, column: 6) - XCTAssertDiagnosed(.useLetInBoundCaseVariables, line: 8, column: 6) - } - - func testIfCase() { - let input = - """ - if case let .labeled(label, value) = DataPoint.labeled("hello", 100) {} - if case .labeled(label, let value) = DataPoint.labeled("hello", 100) {} - if case .labeled(let label, let value) = DataPoint.labeled("hello", 100) {} - if case let .labeled(label, value)? = DataPoint.labeled("hello", 100) {} - if case let .labeled(label, value)! = DataPoint.labeled("hello", 100) {} - if case let .labeled(label, value)?? = DataPoint.labeled("hello", 100) {} - if case let (label, value) = DataPoint.labeled("hello", 100) {} - if case let x as SomeType = someValue {} - """ - performLint(UseLetInEveryBoundCaseVariable.self, input: input) - - XCTAssertDiagnosed(.useLetInBoundCaseVariables, line: 1, column: 9) - XCTAssertDiagnosed(.useLetInBoundCaseVariables, line: 4, column: 9) - XCTAssertDiagnosed(.useLetInBoundCaseVariables, line: 5, column: 9) - XCTAssertDiagnosed(.useLetInBoundCaseVariables, line: 6, column: 9) - XCTAssertDiagnosed(.useLetInBoundCaseVariables, line: 7, column: 9) - } - - func testGuardCase() { - let input = - """ - guard case let .labeled(label, value) = DataPoint.labeled("hello", 100) else {} - guard case .labeled(label, let value) = DataPoint.labeled("hello", 100) else {} - guard case .labeled(let label, let value) = DataPoint.labeled("hello", 100) else {} - guard case let .labeled(label, value)? = DataPoint.labeled("hello", 100) else {} - guard case let .labeled(label, value)! = DataPoint.labeled("hello", 100) else {} - guard case let .labeled(label, value)?? = DataPoint.labeled("hello", 100) else {} - guard case let (label, value) = DataPoint.labeled("hello", 100) else {} - guard case let x as SomeType = someValue else {} - """ - performLint(UseLetInEveryBoundCaseVariable.self, input: input) - - XCTAssertDiagnosed(.useLetInBoundCaseVariables, line: 1, column: 12) - XCTAssertDiagnosed(.useLetInBoundCaseVariables, line: 4, column: 12) - XCTAssertDiagnosed(.useLetInBoundCaseVariables, line: 5, column: 12) - XCTAssertDiagnosed(.useLetInBoundCaseVariables, line: 6, column: 12) - XCTAssertDiagnosed(.useLetInBoundCaseVariables, line: 7, column: 12) - } - - func testForCase() { - let input = - """ - for case let .labeled(label, value) in dataPoints {} - for case .labeled(label, let value) in dataPoints {} - for case .labeled(let label, let value) in dataPoints {} - for case let .labeled(label, value)? in dataPoints {} - for case let .labeled(label, value)! in dataPoints {} - for case let .labeled(label, value)?? in dataPoints {} - for case let (label, value) in dataPoints {} - for case let x as SomeType in {} - """ - performLint(UseLetInEveryBoundCaseVariable.self, input: input) - - XCTAssertDiagnosed(.useLetInBoundCaseVariables, line: 1, column: 10) - XCTAssertDiagnosed(.useLetInBoundCaseVariables, line: 4, column: 10) - XCTAssertDiagnosed(.useLetInBoundCaseVariables, line: 5, column: 10) - XCTAssertDiagnosed(.useLetInBoundCaseVariables, line: 6, column: 10) - XCTAssertDiagnosed(.useLetInBoundCaseVariables, line: 7, column: 10) - } - - func testWhileCase() { - let input = - """ - while case let .labeled(label, value) = iter.next() {} - while case .labeled(label, let value) = iter.next() {} - while case .labeled(let label, let value) = iter.next() {} - while case let .labeled(label, value)? = iter.next() {} - while case let .labeled(label, value)! = iter.next() {} - while case let .labeled(label, value)?? = iter.next() {} - while case let (label, value) = iter.next() {} - while case let x as SomeType = iter.next() {} - """ - performLint(UseLetInEveryBoundCaseVariable.self, input: input) - - XCTAssertDiagnosed(.useLetInBoundCaseVariables, line: 1, column: 12) - XCTAssertDiagnosed(.useLetInBoundCaseVariables, line: 4, column: 12) - XCTAssertDiagnosed(.useLetInBoundCaseVariables, line: 5, column: 12) - XCTAssertDiagnosed(.useLetInBoundCaseVariables, line: 6, column: 12) - XCTAssertDiagnosed(.useLetInBoundCaseVariables, line: 7, column: 12) - } -} diff --git a/Tests/SwiftFormatRulesTests/UseShorthandTypeNamesTests.swift b/Tests/SwiftFormatRulesTests/UseShorthandTypeNamesTests.swift deleted file mode 100644 index bdcd4ac5a..000000000 --- a/Tests/SwiftFormatRulesTests/UseShorthandTypeNamesTests.swift +++ /dev/null @@ -1,416 +0,0 @@ -import SwiftFormatRules - -final class UseShorthandTypeNamesTests: LintOrFormatRuleTestCase { - func testNamesInTypeContextsAreShortened() { - XCTAssertFormatting( - UseShorthandTypeNames.self, - input: - """ - var a: Array = [] - var b: Dictionary = [:] - var c: Optional = nil - """, - expected: - """ - var a: [Int] = [] - var b: [String: Int] = [:] - var c: Foo? = nil - """) - - XCTAssertDiagnosed(.useTypeShorthand(type: "Array")) - XCTAssertDiagnosed(.useTypeShorthand(type: "Dictionary")) - XCTAssertDiagnosed(.useTypeShorthand(type: "Optional")) - } - - func testNestedNamesInTypeContextsAreShortened() { - XCTAssertFormatting( - UseShorthandTypeNames.self, - input: - """ - var a: Array> - var b: Array<[Int]> - var c: [Array] - - var a: Dictionary, Int> - var b: Dictionary> - var c: Dictionary, Dictionary> - var d: Dictionary<[String: Int], Int> - var e: Dictionary - var f: Dictionary<[String: Int], [String: Int]> - var g: [Dictionary: Int] - var h: [String: Dictionary] - var i: [Dictionary: Dictionary] - - let a: Optional> - let b: Optional> - let c: Optional> - let d: Array? - let e: Dictionary? - let f: Optional? - let g: Optional - - var a: Array> - var b: Dictionary, Optional> - var c: Array - var d: Dictionary - """, - expected: - """ - var a: [[Int]] - var b: [[Int]] - var c: [[Int]] - - var a: [[String: Int]: Int] - var b: [String: [String: Int]] - var c: [[String: Int]: [String: Int]] - var d: [[String: Int]: Int] - var e: [String: [String: Int]] - var f: [[String: Int]: [String: Int]] - var g: [[String: Int]: Int] - var h: [String: [String: Int]] - var i: [[String: Int]: [String: Int]] - - let a: [Int]? - let b: [String: Int]? - let c: Int?? - let d: [Int]? - let e: [String: Int]? - let f: Int?? - let g: Int?? - - var a: [Int?] - var b: [String?: Int?] - var c: [Int?] - var d: [String?: Int?] - """) - } - - func testNamesInNonMemberAccessExpressionContextsAreShortened() { - XCTAssertFormatting( - UseShorthandTypeNames.self, - input: - """ - var a = Array() - var b = Dictionary() - var c = Optional(from: decoder) - """, - expected: - """ - var a = [Int]() - var b = [String: Int]() - var c = String?(from: decoder) - """) - - XCTAssertDiagnosed(.useTypeShorthand(type: "Array")) - XCTAssertDiagnosed(.useTypeShorthand(type: "Dictionary")) - XCTAssertDiagnosed(.useTypeShorthand(type: "Optional")) - } - - func testNestedNamesInNonMemberAccessExpressionContextsAreShortened() { - XCTAssertFormatting( - UseShorthandTypeNames.self, - input: - """ - var a = Array>() - var b = Array<[Int]>() - var c = [Array]() - - var a = Dictionary, Int>() - var b = Dictionary>() - var c = Dictionary, Dictionary>() - var d = Dictionary<[String: Int], Int>() - var e = Dictionary() - var f = Dictionary<[String: Int], [String: Int]>() - var g = [Dictionary: Int]() - var h = [String: Dictionary]() - var i = [Dictionary: Dictionary]() - - var a = Optional>(from: decoder) - var b = Optional>(from: decoder) - var c = Optional>(from: decoder) - var d = Array?(from: decoder) - var e = Dictionary?(from: decoder) - var f = Optional?(from: decoder) - var g = Optional(from: decoder) - - var a = Array>() - var b = Dictionary, Optional>() - var c = Array() - var d = Dictionary() - """, - expected: - """ - var a = [[Int]]() - var b = [[Int]]() - var c = [[Int]]() - - var a = [[String: Int]: Int]() - var b = [String: [String: Int]]() - var c = [[String: Int]: [String: Int]]() - var d = [[String: Int]: Int]() - var e = [String: [String: Int]]() - var f = [[String: Int]: [String: Int]]() - var g = [[String: Int]: Int]() - var h = [String: [String: Int]]() - var i = [[String: Int]: [String: Int]]() - - var a = [Int]?(from: decoder) - var b = [String: Int]?(from: decoder) - var c = Int??(from: decoder) - var d = [Int]?(from: decoder) - var e = [String: Int]?(from: decoder) - var f = Int??(from: decoder) - var g = Int??(from: decoder) - - var a = [Int?]() - var b = [String?: Int?]() - var c = [Int?]() - var d = [String?: Int?]() - """) - } - - func testTypesWithMemberAccessesAreNotShortened() { - XCTAssertFormatting( - UseShorthandTypeNames.self, - input: - """ - var a: Array.Index = Array.Index() - var b: Dictionary.Index = Dictionary.Index() - var c: Array>.Index = Array>.Index() - var d: Dictionary, Array>.Index = Dictionary, Array>.Index() - var e: Array.Index> = Array.Index>() - - var f: Foo>.Bar = Foo>.Bar() - var g: Foo.Index>.Bar = Foo.Index>.Bar() - var h: Foo.Bar> = Foo.Bar>() - var i: Foo.Bar.Index> = Foo.Bar.Index>() - - var j: Optional>.Publisher = Optional>.Publisher() - var k: Optional>.Publisher = Optional>.Publisher() - var l: Optional>.Publisher = Optional>.Publisher() - """, - expected: - """ - var a: Array.Index = Array.Index() - var b: Dictionary.Index = Dictionary.Index() - var c: Array.Index = Array.Index() - var d: Dictionary.Index = Dictionary.Index() - var e: [Array.Index] = [Array.Index]() - - var f: Foo<[Int]>.Bar = Foo<[Int]>.Bar() - var g: Foo.Index>.Bar = Foo.Index>.Bar() - var h: Foo.Bar<[Int]> = Foo.Bar<[Int]>() - var i: Foo.Bar.Index> = Foo.Bar.Index>() - - var j: Optional<[Int]>.Publisher = Optional<[Int]>.Publisher() - var k: Optional<[String: Int]>.Publisher = Optional<[String: Int]>.Publisher() - var l: Optional.Publisher = Optional.Publisher() - """) - } - - func testFunctionTypesAreOnlyWrappedWhenShortenedAsOptionals() { - // Some of these examples are questionable since function types aren't hashable and thus not - // valid dictionary keys, nor are they codable, but syntactically they're fine. - XCTAssertFormatting( - UseShorthandTypeNames.self, - input: - """ - var a: Array<(Foo) -> Bar> = Array<(Foo) -> Bar>() - var b: Dictionary<(Foo) -> Bar, (Foo) -> Bar> = Dictionary<(Foo) -> Bar, (Foo) -> Bar>() - var c: Optional<(Foo) -> Bar> = Optional<(Foo) -> Bar>(from: decoder) - var d: Optional<((Foo) -> Bar)> = Optional<((Foo) -> Bar)>(from: decoder) - """, - expected: - """ - var a: [(Foo) -> Bar] = [(Foo) -> Bar]() - var b: [(Foo) -> Bar: (Foo) -> Bar] = [(Foo) -> Bar: (Foo) -> Bar]() - var c: ((Foo) -> Bar)? = ((Foo) -> Bar)?(from: decoder) - var d: ((Foo) -> Bar)? = ((Foo) -> Bar)?(from: decoder) - """) - } - - func testTypesWithEmptyTupleAsGenericArgumentAreNotShortenedInExpressionContexts() { - // The Swift parser will treat `()` encountered in an expression context as the void *value*, - // not the type. This extends outwards to shorthand syntax, where `()?` would be treated as an - // attempt to optional-unwrap the tuple (which is not valid), `[()]` would be an array literal - // containing the empty tuple, and `[(): ()]` would be a dictionary literal mapping the empty - // tuple to the empty tuple. Because of this, we cannot permit the empty tuple type to appear - // directly inside an expression context. In type contexts, however, it's fine. - XCTAssertFormatting( - UseShorthandTypeNames.self, - input: - """ - var a: Optional<()> = Optional<()>(from: decoder) - var b: Array<()> = Array<()>() - var c: Dictionary<(), ()> = Dictionary<(), ()>() - var d: Array<(Optional<()>) -> Optional<()>> = Array<(Optional<()>) -> Optional<()>>() - """, - expected: - """ - var a: ()? = Optional<()>(from: decoder) - var b: [()] = Array<()>() - var c: [(): ()] = Dictionary<(), ()>() - var d: [(()?) -> ()?] = Array<(()?) -> ()?>() - """) - } - - func testPreservesNestedGenericsForUnshortenedTypes() { - // Regression test for a bug that discarded the generic argument list of a nested type when - // shortening something like `Array>` to `[Range]` (instead of `[Range]`. - XCTAssertFormatting( - UseShorthandTypeNames.self, - input: - """ - var a: Array> = Array>() - var b: Dictionary, Range> = Dictionary, Range>() - var c: Optional> = Optional>(from: decoder) - """, - expected: - """ - var a: [Range] = [Range]() - var b: [Range: Range] = [Range: Range]() - var c: Range? = Range?(from: decoder) - """) - } - - func testTypesWithIncorrectNumbersOfGenericArgumentsAreNotChanged() { - XCTAssertFormatting( - UseShorthandTypeNames.self, - input: - """ - var a: Array, Bar> = Array, Bar>() - var b: Dictionary> = Dictionary>() - var c: Optional, Bar> = Optional, Bar>(from: decoder) - """, - expected: - """ - var a: Array<[Foo], Bar> = Array<[Foo], Bar>() - var b: Dictionary<[Foo: Bar]> = Dictionary<[Foo: Bar]>() - var c: Optional = Optional(from: decoder) - """) - } - - func testModuleQualifiedNamesAreNotShortened() { - XCTAssertFormatting( - UseShorthandTypeNames.self, - input: - """ - var a: Swift.Array> = Swift.Array>() - var b: Swift.Dictionary, Dictionary> = Swift.Dictionary, Dictionary>() - var c: Swift.Optional> = Swift.Optional>(from: decoder) - """, - expected: - """ - var a: Swift.Array<[Foo]> = Swift.Array<[Foo]>() - var b: Swift.Dictionary<[Foo: Bar], [Foo: Bar]> = Swift.Dictionary<[Foo: Bar], [Foo: Bar]>() - var c: Swift.Optional = Swift.Optional(from: decoder) - """) - } - - func testTypesWeDoNotCareAboutAreUnchanged() { - XCTAssertFormatting( - UseShorthandTypeNames.self, - input: - """ - var a: Larry = Larry() - var b: Pictionary = Pictionary() - var c: Sectional = Sectional(from: warehouse) - """, - expected: - """ - var a: Larry = Larry() - var b: Pictionary = Pictionary() - var c: Sectional = Sectional(from: warehouse) - """) - } - - func testOptionalStoredVarsWithoutInitializersAreNotChangedUnlessImmutable() { - XCTAssertFormatting( - UseShorthandTypeNames.self, - input: - """ - var a: Optional - var b: Optional { - didSet {} - } - let c: Optional - """, - expected: - """ - var a: Optional - var b: Optional { - didSet {} - } - let c: Int? - """) - } - - func testOptionalComputedVarsAreChanged() { - XCTAssertFormatting( - UseShorthandTypeNames.self, - input: - """ - var a: Optional { nil } - var b: Optional { - get { 0 } - } - var c: Optional { - _read {} - } - var d: Optional { - unsafeAddress {} - } - """, - expected: - """ - var a: Int? { nil } - var b: Int? { - get { 0 } - } - var c: Int? { - _read {} - } - var d: Int? { - unsafeAddress {} - } - """) - } - - func testOptionalStoredVarsWithInitializersAreChanged() { - XCTAssertFormatting( - UseShorthandTypeNames.self, - input: - """ - var a: Optional = nil - var b: Optional = nil { - didSet {} - } - let c: Optional = nil - """, - expected: - """ - var a: Int? = nil - var b: Int? = nil { - didSet {} - } - let c: Int? = nil - """) - } - - func testOptionalsNestedInOtherTypesInStoredVarsAreStillChanged() { - XCTAssertFormatting( - UseShorthandTypeNames.self, - input: - """ - var c: Generic> - var d: [Optional] - var e: [String: Optional] - """, - expected: - """ - var c: Generic - var d: [Int?] - var e: [String: Int?] - """) - } -} diff --git a/Tests/SwiftFormatRulesTests/UseSingleLinePropertyGetterTests.swift b/Tests/SwiftFormatRulesTests/UseSingleLinePropertyGetterTests.swift deleted file mode 100644 index e20a093fb..000000000 --- a/Tests/SwiftFormatRulesTests/UseSingleLinePropertyGetterTests.swift +++ /dev/null @@ -1,66 +0,0 @@ -import SwiftFormatRules - -final class UseSingleLinePropertyGetterTests: LintOrFormatRuleTestCase { - func testMultiLinePropertyGetter() { - XCTAssertFormatting( - UseSingleLinePropertyGetter.self, - input: """ - var g: Int { return 4 } - var h: Int { - get { - return 4 - } - } - var i: Int { - get { return 0 } - set { print("no set, only get") } - } - var j: Int { - mutating get { return 0 } - } - var k: Int { - get async { - return 4 - } - } - var l: Int { - get throws { - return 4 - } - } - var m: Int { - get async throws { - return 4 - } - } - """, - expected: """ - var g: Int { return 4 } - var h: Int { - return 4 - } - var i: Int { - get { return 0 } - set { print("no set, only get") } - } - var j: Int { - mutating get { return 0 } - } - var k: Int { - get async { - return 4 - } - } - var l: Int { - get throws { - return 4 - } - } - var m: Int { - get async throws { - return 4 - } - } - """) - } -} diff --git a/Tests/SwiftFormatRulesTests/UseTripleSlashForDocumentationCommentsTests.swift b/Tests/SwiftFormatRulesTests/UseTripleSlashForDocumentationCommentsTests.swift deleted file mode 100644 index bba730a97..000000000 --- a/Tests/SwiftFormatRulesTests/UseTripleSlashForDocumentationCommentsTests.swift +++ /dev/null @@ -1,148 +0,0 @@ -import SwiftFormatRules - -final class UseTripleSlashForDocumentationCommentsTests: LintOrFormatRuleTestCase { - func testRemoveDocBlockComments() { - XCTAssertFormatting( - UseTripleSlashForDocumentationComments.self, - input: """ - /** - * This comment should not be converted. - */ - - /** - * Returns a docLineComment. - * - * - Parameters: - * - withOutStar: Indicates if the comment start with a star. - * - Returns: docLineComment. - */ - func foo(withOutStar: Bool) {} - """, - expected: """ - /** - * This comment should not be converted. - */ - - /// Returns a docLineComment. - /// - /// - Parameters: - /// - withOutStar: Indicates if the comment start with a star. - /// - Returns: docLineComment. - func foo(withOutStar: Bool) {} - """) - } - - func testRemoveDocBlockCommentsWithoutStars() { - XCTAssertFormatting( - UseTripleSlashForDocumentationComments.self, - input: """ - /** - Returns a docLineComment. - - - Parameters: - - withStar: Indicates if the comment start with a star. - - Returns: docLineComment. - */ - public var test = 1 - """, - expected: """ - /// Returns a docLineComment. - /// - /// - Parameters: - /// - withStar: Indicates if the comment start with a star. - /// - Returns: docLineComment. - public var test = 1 - """) - } - - func testMultipleTypesOfDocComments() { - XCTAssertFormatting( - UseTripleSlashForDocumentationComments.self, - input: """ - /** - * This is my preamble. It could be important. - * This comment stays as-is. - */ - - /// This decl has a comment. - /// The comment is multiple lines long. - public class AClazz { - } - """, - expected: """ - /** - * This is my preamble. It could be important. - * This comment stays as-is. - */ - - /// This decl has a comment. - /// The comment is multiple lines long. - public class AClazz { - } - """) - } - - func testMultipleDocLineComments() { - XCTAssertFormatting( - UseTripleSlashForDocumentationComments.self, - input: """ - /// This is my preamble. It could be important. - /// This comment stays as-is. - /// - - /// This decl has a comment. - /// The comment is multiple lines long. - public class AClazz { - } - """, - expected: """ - /// This is my preamble. It could be important. - /// This comment stays as-is. - /// - - /// This decl has a comment. - /// The comment is multiple lines long. - public class AClazz { - } - """) - } - - func testManyDocComments() { - XCTAssertFormatting( - UseTripleSlashForDocumentationComments.self, - input: """ - /** - * This is my preamble. It could be important. - * This comment stays as-is. - */ - - /// This is a doc-line comment! - - /** This is a fairly short doc-block comment. */ - - /// Why are there so many comments? - /// Who knows! But there are loads. - - /** AClazz is a class with good name. */ - public class AClazz { - } - """, - expected: """ - /** - * This is my preamble. It could be important. - * This comment stays as-is. - */ - - /// This is a doc-line comment! - - /** This is a fairly short doc-block comment. */ - - /// Why are there so many comments? - /// Who knows! But there are loads. - - /// AClazz is a class with good name. - public class AClazz { - } - """) - } -} diff --git a/Tests/SwiftFormatRulesTests/UseWhereClausesInForLoopsTests.swift b/Tests/SwiftFormatRulesTests/UseWhereClausesInForLoopsTests.swift deleted file mode 100644 index ac60a3c81..000000000 --- a/Tests/SwiftFormatRulesTests/UseWhereClausesInForLoopsTests.swift +++ /dev/null @@ -1,89 +0,0 @@ -import SwiftFormatRules - -final class UseWhereClausesInForLoopsTests: LintOrFormatRuleTestCase { - func testForLoopWhereClauses() { - XCTAssertFormatting( - UseWhereClausesInForLoops.self, - input: """ - for i in [0, 1, 2, 3] { - if i > 30 { - print(i) - } - } - - for i in [0, 1, 2, 3] { - if i > 30 { - print(i) - } else { - print(i) - } - } - - for i in [0, 1, 2, 3] { - if i > 30 { - print(i) - } else if i > 40 { - print(i) - } - } - - for i in [0, 1, 2, 3] { - if i > 30 { - print(i) - } - print(i) - } - - for i in [0, 1, 2, 3] { - if let x = (2 as Int?) { - print(i) - } - } - - for i in [0, 1, 2, 3] { - guard i > 30 else { - continue - } - print(i) - } - """, - expected: """ - for i in [0, 1, 2, 3] where i > 30 { - print(i) - } - - for i in [0, 1, 2, 3] { - if i > 30 { - print(i) - } else { - print(i) - } - } - - for i in [0, 1, 2, 3] { - if i > 30 { - print(i) - } else if i > 40 { - print(i) - } - } - - for i in [0, 1, 2, 3] { - if i > 30 { - print(i) - } - print(i) - } - - for i in [0, 1, 2, 3] { - if let x = (2 as Int?) { - print(i) - } - } - - for i in [0, 1, 2, 3] where i > 30 { - print(i) - } - """) - } -} diff --git a/Tests/SwiftFormatRulesTests/ValidateDocumentationCommentsTests.swift b/Tests/SwiftFormatRulesTests/ValidateDocumentationCommentsTests.swift deleted file mode 100644 index 4aafb32e2..000000000 --- a/Tests/SwiftFormatRulesTests/ValidateDocumentationCommentsTests.swift +++ /dev/null @@ -1,312 +0,0 @@ -import SwiftFormatRules - -final class ValidateDocumentationCommentsTests: LintOrFormatRuleTestCase { - override func setUp() { - super.setUp() - shouldCheckForUnassertedDiagnostics = true - } - - func testParameterDocumentation() { - let input = - """ - /// Uses 'Parameters' when it only has one parameter. - /// - /// - Parameters singular: singular description. - /// - Returns: A string containing the contents of a - /// description - func testPluralParamDesc(singular: String) -> Bool {} - - /// Uses 'Parameter' with a list of parameters. - /// - /// - Parameter - /// - command: The command to execute in the shell environment. - /// - stdin: The string to use as standard input. - /// - Returns: A string containing the contents of the invoked process's - /// standard output. - func execute(command: String, stdin: String) -> String { - // ... - } - - /// Returns the output generated by executing a command with the given string - /// used as standard input. - /// - /// - Parameter command: The command to execute in the shell environment. - /// - Parameter stdin: The string to use as standard input. - /// - Returns: A string containing the contents of the invoked process's - /// standard output. - func testInvalidParameterDesc(command: String, stdin: String) -> String {} - """ - performLint(ValidateDocumentationComments.self, input: input) - XCTAssertDiagnosed(.useSingularParameter, line: 6, column: 1) - XCTAssertDiagnosed(.usePluralParameters, line: 15, column: 1) - XCTAssertDiagnosed(.usePluralParameters, line: 26, column: 1) - } - - func testParametersName() { - let input = - """ - /// Parameters dont match. - /// - /// - Parameters: - /// - sum: The sum of all numbers. - /// - avg: The average of all numbers. - /// - Returns: The sum of sum and avg. - func sum(avg: Int, sum: Int) -> Int {} - - /// Missing one parameter documentation. - /// - /// - Parameters: - /// - p1: Parameter 1. - /// - p2: Parameter 2. - /// - Returns: an integer. - func foo(p1: Int, p2: Int, p3: Int) -> Int {} - """ - performLint(ValidateDocumentationComments.self, input: input) - XCTAssertDiagnosed(.parametersDontMatch(funcName: "sum"), line: 7, column: 1) - XCTAssertDiagnosed(.parametersDontMatch(funcName: "foo"), line: 15, column: 1) - } - - func testThrowsDocumentation() { - let input = - """ - /// One sentence summary. - /// - /// - Parameters: - /// - p1: Parameter 1. - /// - p2: Parameter 2. - /// - p3: Parameter 3. - /// - Throws: an error. - func doesNotThrow(p1: Int, p2: Int, p3: Int) {} - - /// One sentence summary. - /// - /// - Parameters: - /// - p1: Parameter 1. - /// - p2: Parameter 2. - /// - p3: Parameter 3. - func doesThrow(p1: Int, p2: Int, p3: Int) throws {} - - /// One sentence summary. - /// - /// - Parameter p1: Parameter 1. - /// - Throws: doesn't really throw, just rethrows - func doesRethrow(p1: (() throws -> ())) rethrows {} - """ - performLint(ValidateDocumentationComments.self, input: input) - XCTAssertDiagnosed(.removeThrowsComment(funcName: "doesNotThrow"), line: 8, column: 1) - XCTAssertDiagnosed(.documentErrorsThrown(funcName: "doesThrow"), line: 16, column: 43) - XCTAssertDiagnosed(.removeThrowsComment(funcName: "doesRethrow"), line: 22, column: 41) - } - - func testReturnDocumentation() { - let input = - """ - /// One sentence summary. - /// - /// - Parameters: - /// - p1: Parameter 1. - /// - p2: Parameter 2. - /// - p3: Parameter 3. - /// - Returns: an integer. - func noReturn(p1: Int, p2: Int, p3: Int) {} - - /// One sentence summary. - /// - /// - Parameters: - /// - p1: Parameter 1. - /// - p2: Parameter 2. - /// - p3: Parameter 3. - func foo(p1: Int, p2: Int, p3: Int) -> Int {} - - /// One sentence summary. - /// - /// - Parameters: - /// - p1: Parameter 1. - /// - p2: Parameter 2. - /// - p3: Parameter 3. - func neverReturns(p1: Int, p2: Int, p3: Int) -> Never {} - - /// One sentence summary. - /// - /// - Parameters: - /// - p1: Parameter 1. - /// - p2: Parameter 2. - /// - p3: Parameter 3. - /// - Returns: Never returns. - func documentedNeverReturns(p1: Int, p2: Int, p3: Int) -> Never {} - """ - performLint(ValidateDocumentationComments.self, input: input) - XCTAssertDiagnosed(.removeReturnComment(funcName: "noReturn"), line: 8, column: 1) - XCTAssertDiagnosed(.documentReturnValue(funcName: "foo"), line: 16, column: 37) - XCTAssertNotDiagnosed(.documentReturnValue(funcName: "neverReturns")) - XCTAssertNotDiagnosed(.removeReturnComment(funcName: "documentedNeverReturns")) - } - - func testValidDocumentation() { - let input = - """ - /// Returns the output generated by executing a command. - /// - /// - Parameter command: The command to execute in the shell environment. - /// - Returns: A string containing the contents of the invoked process's - /// standard output. - func singularParam(command: String) -> String { - // ... - } - - /// Returns the output generated by executing a command with the given string - /// used as standard input. - /// - /// - Parameters: - /// - command: The command to execute in the shell environment. - /// - stdin: The string to use as standard input. - /// - Throws: An error, possibly. - /// - Returns: A string containing the contents of the invoked process's - /// standard output. - func pluralParam(command: String, stdin: String) throws -> String { - // ... - } - - /// One sentence summary. - /// - /// - Parameter p1: Parameter 1. - func rethrower(p1: (() throws -> ())) rethrows { - // ... - } - - /// Parameter(s) and Returns tags may be omitted only if the single-sentence - /// brief summary fully describes the meaning of those items and including the - /// tags would only repeat what has already been said - func omittedFunc(p1: Int) - """ - performLint(ValidateDocumentationComments.self, input: input) - XCTAssertNotDiagnosed(.useSingularParameter) - XCTAssertNotDiagnosed(.usePluralParameters) - - XCTAssertNotDiagnosed(.documentReturnValue(funcName: "singularParam")) - XCTAssertNotDiagnosed(.removeReturnComment(funcName: "singularParam")) - XCTAssertNotDiagnosed(.parametersDontMatch(funcName: "singularParam")) - - XCTAssertNotDiagnosed(.documentReturnValue(funcName: "pluralParam")) - XCTAssertNotDiagnosed(.removeReturnComment(funcName: "pluralParam")) - XCTAssertNotDiagnosed(.parametersDontMatch(funcName: "pluralParam")) - XCTAssertNotDiagnosed(.documentErrorsThrown(funcName: "pluralParam")) - XCTAssertNotDiagnosed(.removeThrowsComment(funcName: "pluralParam")) - - XCTAssertNotDiagnosed(.documentErrorsThrown(funcName: "pluralParam")) - XCTAssertNotDiagnosed(.removeThrowsComment(funcName: "pluralParam")) - - XCTAssertNotDiagnosed(.documentReturnValue(funcName: "omittedFunc")) - XCTAssertNotDiagnosed(.removeReturnComment(funcName: "omittedFunc")) - XCTAssertNotDiagnosed(.parametersDontMatch(funcName: "omittedFunc")) - } - - func testSeparateLabelAndIdentifier() { - let input = - """ - /// Returns the output generated by executing a command. - /// - /// - Parameter command: The command to execute in the shell environment. - /// - Returns: A string containing the contents of the invoked process's - /// standard output. - func incorrectParam(label commando: String) -> String { - // ... - } - - /// Returns the output generated by executing a command. - /// - /// - Parameter command: The command to execute in the shell environment. - /// - Returns: A string containing the contents of the invoked process's - /// standard output. - func singularParam(label command: String) -> String { - // ... - } - - /// Returns the output generated by executing a command with the given string - /// used as standard input. - /// - /// - Parameters: - /// - command: The command to execute in the shell environment. - /// - stdin: The string to use as standard input. - /// - Returns: A string containing the contents of the invoked process's - /// standard output. - func pluralParam(label command: String, label2 stdin: String) -> String { - // ... - } - """ - performLint(ValidateDocumentationComments.self, input: input) - XCTAssertNotDiagnosed(.useSingularParameter) - XCTAssertNotDiagnosed(.usePluralParameters) - - XCTAssertDiagnosed(.parametersDontMatch(funcName: "incorrectParam"), line: 6, column: 1) - - XCTAssertNotDiagnosed(.documentReturnValue(funcName: "singularParam")) - XCTAssertNotDiagnosed(.removeReturnComment(funcName: "singularParam")) - XCTAssertNotDiagnosed(.parametersDontMatch(funcName: "singularParam")) - - XCTAssertNotDiagnosed(.documentReturnValue(funcName: "pluralParam")) - XCTAssertNotDiagnosed(.removeReturnComment(funcName: "pluralParam")) - XCTAssertNotDiagnosed(.parametersDontMatch(funcName: "pluralParam")) - } - - func testInitializer() { - let input = - """ - struct SomeType { - /// Brief summary. - /// - /// - Parameter command: The command to execute in the shell environment. - /// - Returns: Shouldn't be here. - init(label commando: String) { - // ... - } - - /// Brief summary. - /// - /// - Parameter command: The command to execute in the shell environment. - init(label command: String) { - // ... - } - - /// Brief summary. - /// - /// - Parameters: - /// - command: The command to execute in the shell environment. - /// - stdin: The string to use as standard input. - init(label command: String, label2 stdin: String) { - // ... - } - - /// Brief summary. - /// - /// - Parameters: - /// - command: The command to execute in the shell environment. - /// - stdin: The string to use as standard input. - /// - Throws: An error. - init(label command: String, label2 stdin: String) throws { - // ... - } - } - """ - performLint(ValidateDocumentationComments.self, input: input) - XCTAssertNotDiagnosed(.useSingularParameter) - XCTAssertNotDiagnosed(.usePluralParameters) - - XCTAssertDiagnosed(.parametersDontMatch(funcName: "init"), line: 6, column: 3) - XCTAssertDiagnosed(.removeReturnComment(funcName: "init"), line: 6, column: 3) - - XCTAssertNotDiagnosed(.documentReturnValue(funcName: "init")) - XCTAssertNotDiagnosed(.removeReturnComment(funcName: "init")) - XCTAssertNotDiagnosed(.parametersDontMatch(funcName: "init")) - - XCTAssertNotDiagnosed(.documentReturnValue(funcName: "init")) - XCTAssertNotDiagnosed(.removeReturnComment(funcName: "init")) - XCTAssertNotDiagnosed(.parametersDontMatch(funcName: "init")) - - XCTAssertNotDiagnosed(.documentReturnValue(funcName: "init")) - XCTAssertNotDiagnosed(.removeReturnComment(funcName: "init")) - XCTAssertNotDiagnosed(.parametersDontMatch(funcName: "init")) - XCTAssertNotDiagnosed(.documentErrorsThrown(funcName: "init")) - XCTAssertNotDiagnosed(.removeThrowsComment(funcName: "init")) - } -} diff --git a/Tests/SwiftFormatTests/API/ConfigurationTests.swift b/Tests/SwiftFormatTests/API/ConfigurationTests.swift new file mode 100644 index 000000000..86a9d8dd2 --- /dev/null +++ b/Tests/SwiftFormatTests/API/ConfigurationTests.swift @@ -0,0 +1,56 @@ +import SwiftFormat +import XCTest + +final class ConfigurationTests: XCTestCase { + func testDefaultConfigurationIsSameAsEmptyDecode() { + // Since we don't use the synthesized `init(from: Decoder)` and allow fields + // to be missing, we provide defaults there as well as in the property + // declarations themselves. This test ensures that creating a default- + // initialized `Configuration` is identical to decoding one from an empty + // JSON input, which verifies that those defaults are always in sync. + let defaultInitConfig = Configuration() + + let emptyDictionaryData = "{}\n".data(using: .utf8)! + let jsonDecoder = JSONDecoder() + let emptyJSONConfig = + try! jsonDecoder.decode(Configuration.self, from: emptyDictionaryData) + + XCTAssertEqual(defaultInitConfig, emptyJSONConfig) + } + + func testMissingConfigurationFile() throws { + #if os(Windows) + #if compiler(<6.0.2) + try XCTSkipIf(true, "Requires https://github.com/swiftlang/swift-foundation/pull/983") + #endif + let path = #"C:\test.swift"# + #else + let path = "/test.swift" + #endif + XCTAssertNil(Configuration.url(forConfigurationFileApplyingTo: URL(fileURLWithPath: path))) + } + + func testMissingConfigurationFileInSubdirectory() throws { + #if os(Windows) + #if compiler(<6.0.2) + try XCTSkipIf(true, "Requires https://github.com/swiftlang/swift-foundation/pull/983") + #endif + let path = #"C:\whatever\test.swift"# + #else + let path = "/whatever/test.swift" + #endif + XCTAssertNil(Configuration.url(forConfigurationFileApplyingTo: URL(fileURLWithPath: path))) + } + + func testMissingConfigurationFileMountedDirectory() throws { + #if os(Windows) + #if compiler(<6.0.2) + try XCTSkipIf(true, "Requires https://github.com/swiftlang/swift-foundation/pull/983") + #endif + #else + try XCTSkipIf(true, #"\\ file mounts are only a concept on Windows"#) + #endif + let path = #"\\mount\test.swift"# + XCTAssertNil(Configuration.url(forConfigurationFileApplyingTo: URL(fileURLWithPath: path))) + } +} diff --git a/Tests/SwiftFormatTests/Core/DocumentationCommentTests.swift b/Tests/SwiftFormatTests/Core/DocumentationCommentTests.swift new file mode 100644 index 000000000..55af641ef --- /dev/null +++ b/Tests/SwiftFormatTests/Core/DocumentationCommentTests.swift @@ -0,0 +1,300 @@ +import Markdown +@_spi(Testing) import SwiftFormat +import SwiftSyntax +import SwiftSyntaxBuilder +import XCTest + +final class DocumentationCommentTests: XCTestCase { + func testBriefSummaryOnly() throws { + let decl: DeclSyntax = """ + /// A brief summary. + func f() {} + """ + let comment = try XCTUnwrap(DocumentationComment(extractedFrom: decl)) + XCTAssertEqual( + try XCTUnwrap(comment.briefSummary).debugDescription(), + """ + Paragraph + └─ Text "A brief summary." + """ + ) + XCTAssertTrue(comment.bodyNodes.isEmpty) + XCTAssertNil(comment.parameterLayout) + XCTAssertTrue(comment.parameters.isEmpty) + XCTAssertNil(comment.returns) + XCTAssertNil(comment.throws) + } + + func testBriefSummaryAndAdditionalParagraphs() throws { + let decl: DeclSyntax = """ + /// A brief summary. + /// + /// Some detail. + /// + /// More detail. + func f() {} + """ + let comment = try XCTUnwrap(DocumentationComment(extractedFrom: decl)) + XCTAssertEqual( + comment.briefSummary?.debugDescription(), + """ + Paragraph + └─ Text "A brief summary." + """ + ) + XCTAssertEqual( + comment.bodyNodes.map { $0.debugDescription() }, + [ + """ + Paragraph + └─ Text "Some detail." + """, + """ + Paragraph + └─ Text "More detail." + """, + ] + ) + XCTAssertNil(comment.parameterLayout) + XCTAssertTrue(comment.parameters.isEmpty) + XCTAssertNil(comment.returns) + XCTAssertNil(comment.throws) + } + + func testParameterOutline() throws { + let decl: DeclSyntax = """ + /// - Parameters: + /// - x: A value. + /// - y: Another value. + func f(x: Int, y: Int) {} + """ + let comment = try XCTUnwrap(DocumentationComment(extractedFrom: decl)) + XCTAssertNil(comment.briefSummary) + XCTAssertTrue(comment.bodyNodes.isEmpty) + XCTAssertEqual(comment.parameterLayout, .outline) + XCTAssertEqual(comment.parameters.count, 2) + XCTAssertEqual(comment.parameters[0].name, "x") + XCTAssertEqual( + comment.parameters[0].comment.briefSummary?.debugDescription(), + """ + Paragraph + └─ Text " A value." + """ + ) + XCTAssertEqual(comment.parameters[1].name, "y") + XCTAssertEqual( + comment.parameters[1].comment.briefSummary?.debugDescription(), + """ + Paragraph + └─ Text " Another value." + """ + ) + XCTAssertNil(comment.returns) + XCTAssertNil(comment.throws) + } + + func testSeparatedParameters() throws { + let decl: DeclSyntax = """ + /// - Parameter x: A value. + /// - Parameter y: Another value. + func f(x: Int, y: Int) {} + """ + let comment = try XCTUnwrap(DocumentationComment(extractedFrom: decl)) + XCTAssertNil(comment.briefSummary) + XCTAssertTrue(comment.bodyNodes.isEmpty) + XCTAssertEqual(comment.parameterLayout, .separated) + XCTAssertEqual(comment.parameters.count, 2) + XCTAssertEqual(comment.parameters[0].name, "x") + XCTAssertEqual( + comment.parameters[0].comment.briefSummary?.debugDescription(), + """ + Paragraph + └─ Text " A value." + """ + ) + XCTAssertEqual(comment.parameters[1].name, "y") + XCTAssertEqual( + comment.parameters[1].comment.briefSummary?.debugDescription(), + """ + Paragraph + └─ Text " Another value." + """ + ) + XCTAssertNil(comment.returns) + XCTAssertNil(comment.throws) + } + + func testMalformedTagsGoIntoBodyNodes() throws { + let decl: DeclSyntax = """ + /// - Parameter: A value. + /// - Parameter y Another value. + /// - Parmeter z: Another value. + /// - Parameter *x*: Another value. + /// - Return: A value. + /// - Throw: An error. + func f(x: Int, y: Int) {} + """ + let comment = try XCTUnwrap(DocumentationComment(extractedFrom: decl)) + XCTAssertEqual(comment.bodyNodes.count, 1) + XCTAssertEqual( + comment.bodyNodes[0].debugDescription(), + """ + UnorderedList + ├─ ListItem + │ └─ Paragraph + │ └─ Text "Parameter: A value." + ├─ ListItem + │ └─ Paragraph + │ └─ Text "Parameter y Another value." + ├─ ListItem + │ └─ Paragraph + │ └─ Text "Parmeter z: Another value." + ├─ ListItem + │ └─ Paragraph + │ ├─ Text "Parameter " + │ ├─ Emphasis + │ │ └─ Text "x" + │ └─ Text ": Another value." + ├─ ListItem + │ └─ Paragraph + │ └─ Text "Return: A value." + └─ ListItem + └─ Paragraph + └─ Text "Throw: An error." + """ + ) + XCTAssertNil(comment.parameterLayout) + XCTAssertTrue(comment.parameters.isEmpty) + } + + func testReturnsField() throws { + let decl: DeclSyntax = """ + /// - Returns: A value. + func f() {} + """ + let comment = try XCTUnwrap(DocumentationComment(extractedFrom: decl)) + XCTAssertNil(comment.briefSummary) + XCTAssertTrue(comment.bodyNodes.isEmpty) + XCTAssertNil(comment.parameterLayout) + XCTAssertTrue(comment.parameters.isEmpty) + + let returnsField = try XCTUnwrap(comment.returns) + XCTAssertEqual( + returnsField.debugDescription(), + """ + Paragraph + └─ Text " A value." + """ + ) + XCTAssertNil(comment.throws) + } + + func testThrowsField() throws { + let decl: DeclSyntax = """ + /// - Throws: An error. + func f() {} + """ + let comment = try XCTUnwrap(DocumentationComment(extractedFrom: decl)) + XCTAssertNil(comment.briefSummary) + XCTAssertTrue(comment.bodyNodes.isEmpty) + XCTAssertNil(comment.parameterLayout) + XCTAssertTrue(comment.parameters.isEmpty) + XCTAssertNil(comment.returns) + + let throwsField = try XCTUnwrap(comment.throws) + XCTAssertEqual( + throwsField.debugDescription(), + """ + Paragraph + └─ Text " An error." + """ + ) + } + + func testUnrecognizedFieldsGoIntoBodyNodes() throws { + let decl: DeclSyntax = """ + /// - Blahblah: Blah. + /// - Return: A value. + /// - Throw: An error. + func f() {} + """ + let comment = try XCTUnwrap(DocumentationComment(extractedFrom: decl)) + XCTAssertNil(comment.briefSummary) + XCTAssertEqual( + comment.bodyNodes.map { $0.debugDescription() }, + [ + """ + UnorderedList + ├─ ListItem + │ └─ Paragraph + │ └─ Text "Blahblah: Blah." + ├─ ListItem + │ └─ Paragraph + │ └─ Text "Return: A value." + └─ ListItem + └─ Paragraph + └─ Text "Throw: An error." + """ + ] + ) + XCTAssertNil(comment.parameterLayout) + XCTAssertTrue(comment.parameters.isEmpty) + XCTAssertNil(comment.returns) + XCTAssertNil(comment.throws) + } + + func testNestedCommentInParameter() throws { + let decl: DeclSyntax = """ + /// - Parameters: + /// - g: A function. + /// - Parameter x: A value. + /// - Parameter y: Another value. + /// - Returns: A result. + func f(g: (x: Int, y: Int) -> Int) {} + """ + let comment = try XCTUnwrap(DocumentationComment(extractedFrom: decl)) + XCTAssertNil(comment.briefSummary) + XCTAssertTrue(comment.bodyNodes.isEmpty) + XCTAssertEqual(comment.parameterLayout, .outline) + XCTAssertEqual(comment.parameters.count, 1) + XCTAssertEqual(comment.parameters[0].name, "g") + XCTAssertNil(comment.returns) + XCTAssertNil(comment.throws) + + let paramComment = comment.parameters[0].comment + XCTAssertEqual( + paramComment.briefSummary?.debugDescription(), + """ + Paragraph + └─ Text " A function." + """ + ) + XCTAssertTrue(paramComment.bodyNodes.isEmpty) + XCTAssertEqual(paramComment.parameterLayout, .separated) + XCTAssertEqual(paramComment.parameters.count, 2) + XCTAssertEqual(paramComment.parameters[0].name, "x") + XCTAssertEqual( + paramComment.parameters[0].comment.briefSummary?.debugDescription(), + """ + Paragraph + └─ Text " A value." + """ + ) + XCTAssertEqual(paramComment.parameters[1].name, "y") + XCTAssertEqual( + paramComment.parameters[1].comment.briefSummary?.debugDescription(), + """ + Paragraph + └─ Text " Another value." + """ + ) + XCTAssertEqual( + paramComment.returns?.debugDescription(), + """ + Paragraph + └─ Text " A result." + """ + ) + XCTAssertNil(paramComment.throws) + } +} diff --git a/Tests/SwiftFormatTests/Core/DocumentationCommentTextTests.swift b/Tests/SwiftFormatTests/Core/DocumentationCommentTextTests.swift new file mode 100644 index 000000000..a7a16e70d --- /dev/null +++ b/Tests/SwiftFormatTests/Core/DocumentationCommentTextTests.swift @@ -0,0 +1,185 @@ +@_spi(Testing) import SwiftFormat +import SwiftSyntax +import SwiftSyntaxBuilder +import XCTest + +final class DocumentationCommentTextTests: XCTestCase { + func testSimpleDocLineComment() throws { + let decl: DeclSyntax = """ + /// A simple doc comment. + func f() {} + """ + let commentText = try XCTUnwrap(DocumentationCommentText(extractedFrom: decl.leadingTrivia)) + XCTAssertEqual(commentText.introducer, .line) + XCTAssertEqual( + commentText.text, + """ + A simple doc comment. + + """ + ) + } + + func testOneLineDocBlockComment() throws { + let decl: DeclSyntax = """ + /** A simple doc comment. */ + func f() {} + """ + let commentText = try XCTUnwrap(DocumentationCommentText(extractedFrom: decl.leadingTrivia)) + XCTAssertEqual(commentText.introducer, .block) + XCTAssertEqual( + commentText.text, + """ + A simple doc comment.\u{0020} + + """ + ) + } + + func testDocBlockCommentWithASCIIArt() throws { + let decl: DeclSyntax = """ + /** + * A simple doc comment. + */ + func f() {} + """ + let commentText = try XCTUnwrap(DocumentationCommentText(extractedFrom: decl.leadingTrivia)) + XCTAssertEqual(commentText.introducer, .block) + XCTAssertEqual( + commentText.text, + """ + A simple doc comment. + + """ + ) + } + + func testIndentedDocBlockCommentWithASCIIArt() throws { + let decl: DeclSyntax = """ + /** + * A simple doc comment. + */ + func f() {} + """ + let commentText = try XCTUnwrap(DocumentationCommentText(extractedFrom: decl.leadingTrivia)) + XCTAssertEqual(commentText.introducer, .block) + XCTAssertEqual( + commentText.text, + """ + A simple doc comment. + + """ + ) + } + + func testDocBlockCommentWithoutASCIIArt() throws { + let decl: DeclSyntax = """ + /** + A simple doc comment. + */ + func f() {} + """ + let commentText = try XCTUnwrap(DocumentationCommentText(extractedFrom: decl.leadingTrivia)) + XCTAssertEqual(commentText.introducer, .block) + XCTAssertEqual( + commentText.text, + """ + A simple doc comment. + + """ + ) + } + + func testMultilineDocLineComment() throws { + let decl: DeclSyntax = """ + /// A doc comment. + /// + /// This is a longer paragraph, + /// containing more detail. + /// + /// - Parameter x: A parameter. + /// - Returns: A value. + func f(x: Int) -> Int {} + """ + let commentText = try XCTUnwrap(DocumentationCommentText(extractedFrom: decl.leadingTrivia)) + XCTAssertEqual(commentText.introducer, .line) + XCTAssertEqual( + commentText.text, + """ + A doc comment. + + This is a longer paragraph, + containing more detail. + + - Parameter x: A parameter. + - Returns: A value. + + """ + ) + } + + func testDocLineCommentStopsAtBlankLine() throws { + let decl: DeclSyntax = """ + /// This should not be part of the comment. + + /// A doc comment. + func f(x: Int) -> Int {} + """ + let commentText = try XCTUnwrap(DocumentationCommentText(extractedFrom: decl.leadingTrivia)) + XCTAssertEqual(commentText.introducer, .line) + XCTAssertEqual( + commentText.text, + """ + A doc comment. + + """ + ) + } + + func testDocBlockCommentStopsAtBlankLine() throws { + let decl: DeclSyntax = """ + /** This should not be part of the comment. */ + + /** + * This is part of the comment. + */ + /** so is this */ + func f(x: Int) -> Int {} + """ + let commentText = try XCTUnwrap(DocumentationCommentText(extractedFrom: decl.leadingTrivia)) + XCTAssertEqual(commentText.introducer, .block) + XCTAssertEqual( + commentText.text, + """ + This is part of the comment. + so is this\u{0020} + + """ + ) + } + + func testDocCommentHasMixedIntroducers() throws { + let decl: DeclSyntax = """ + /// This is part of the comment. + /** This is too. */ + func f(x: Int) -> Int {} + """ + let commentText = try XCTUnwrap(DocumentationCommentText(extractedFrom: decl.leadingTrivia)) + XCTAssertEqual(commentText.introducer, .mixed) + XCTAssertEqual( + commentText.text, + """ + This is part of the comment. + This is too.\u{0020} + + """ + ) + } + + func testNilIfNoComment() throws { + let decl: DeclSyntax = """ + func f(x: Int) -> Int {} + """ + XCTAssertNil(DocumentationCommentText(extractedFrom: decl.leadingTrivia)) + } +} diff --git a/Tests/SwiftFormatCoreTests/RuleMaskTests.swift b/Tests/SwiftFormatTests/Core/RuleMaskTests.swift similarity index 98% rename from Tests/SwiftFormatCoreTests/RuleMaskTests.swift rename to Tests/SwiftFormatTests/Core/RuleMaskTests.swift index b4303b850..4e46ac635 100644 --- a/Tests/SwiftFormatCoreTests/RuleMaskTests.swift +++ b/Tests/SwiftFormatTests/Core/RuleMaskTests.swift @@ -1,6 +1,6 @@ -import SwiftFormatCore -import SwiftSyntax +@_spi(Testing) import SwiftFormat import SwiftParser +import SwiftSyntax import XCTest final class RuleMaskTests: XCTestCase { @@ -11,8 +11,8 @@ final class RuleMaskTests: XCTestCase { private func createMask(sourceText: String) -> RuleMask { let fileURL = URL(fileURLWithPath: "/tmp/test.swift") - converter = SourceLocationConverter(file: fileURL.path, source: sourceText) let syntax = Parser.parse(source: sourceText) + converter = SourceLocationConverter(fileName: fileURL.path, tree: syntax) return RuleMask(syntaxNode: Syntax(syntax), sourceLocationConverter: converter) } diff --git a/Tests/SwiftFormatPrettyPrintTests/AccessorTests.swift b/Tests/SwiftFormatTests/PrettyPrint/AccessorTests.swift similarity index 100% rename from Tests/SwiftFormatPrettyPrintTests/AccessorTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/AccessorTests.swift diff --git a/Tests/SwiftFormatTests/PrettyPrint/ArrayDeclTests.swift b/Tests/SwiftFormatTests/PrettyPrint/ArrayDeclTests.swift new file mode 100644 index 000000000..389ce0853 --- /dev/null +++ b/Tests/SwiftFormatTests/PrettyPrint/ArrayDeclTests.swift @@ -0,0 +1,293 @@ +import SwiftFormat +import SwiftSyntax +import _SwiftFormatTestSupport + +final class ArrayDeclTests: PrettyPrintTestCase { + func testBasicArrays() { + let input = + """ + let a = [ ] + let a = [ + ] + let a = [ + // Comment + ] + let a = [1, 2, 3,] + let a: [Bool] = [false, true, true, false] + let a = [11111111, 2222222, 33333333, 4444444] + let a: [String] = ["One", "Two", "Three", "Four"] + let a: [String] = ["One", "Two", "Three", "Four", "Five", "Six", "Seven"] + let a: [String] = ["One", "Two", "Three", "Four", "Five", "Six", "Seven",] + let a: [String] = [ + "One", "Two", "Three", "Four", "Five", + "Six", "Seven", "Eight", + ] + let a = [11111111, 2222222, 33333333, 444444] + """ + + let expected = + """ + let a = [] + let a = [] + let a = [ + // Comment + ] + let a = [1, 2, 3] + let a: [Bool] = [false, true, true, false] + let a = [ + 11111111, 2222222, 33333333, 4444444, + ] + let a: [String] = [ + "One", "Two", "Three", "Four", + ] + let a: [String] = [ + "One", "Two", "Three", "Four", "Five", + "Six", "Seven", + ] + let a: [String] = [ + "One", "Two", "Three", "Four", "Five", + "Six", "Seven", + ] + let a: [String] = [ + "One", "Two", "Three", "Four", "Five", + "Six", "Seven", "Eight", + ] + + """ + // Ideally, this array would be left on 1 line without a trailing comma. We don't know if the + // comma is required when calculating the length of array elements, so the comma's length is + // always added to last element and that 1 character causes the newlines inside of the array. + + """ + let a = [ + 11111111, 2222222, 33333333, 444444, + ] + + """ + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 45) + } + + func testArrayOfFunctions() { + let input = + """ + let A = [(Int, Double) -> Bool]() + let A = [(Int, Double) async -> Bool]() + let A = [(Int, Double) throws -> Bool]() + let A = [(Int, Double) async throws -> Bool]() + """ + + let expected46 = + """ + let A = [(Int, Double) -> Bool]() + let A = [(Int, Double) async -> Bool]() + let A = [(Int, Double) throws -> Bool]() + let A = [(Int, Double) async throws -> Bool]() + + """ + assertPrettyPrintEqual(input: input, expected: expected46, linelength: 46) + + let expected43 = + """ + let A = [(Int, Double) -> Bool]() + let A = [(Int, Double) async -> Bool]() + let A = [(Int, Double) throws -> Bool]() + let A = [ + (Int, Double) async throws -> Bool + ]() + + """ + assertPrettyPrintEqual(input: input, expected: expected43, linelength: 43) + + let expected35 = + """ + let A = [(Int, Double) -> Bool]() + let A = [ + (Int, Double) async -> Bool + ]() + let A = [ + (Int, Double) throws -> Bool + ]() + let A = [ + (Int, Double) async throws + -> Bool + ]() + + """ + assertPrettyPrintEqual(input: input, expected: expected35, linelength: 35) + + let expected27 = + """ + let A = [ + (Int, Double) -> Bool + ]() + let A = [ + (Int, Double) async + -> Bool + ]() + let A = [ + (Int, Double) throws + -> Bool + ]() + let A = [ + (Int, Double) + async throws -> Bool + ]() + + """ + assertPrettyPrintEqual(input: input, expected: expected27, linelength: 27) + } + + func testNoTrailingCommasInTypes() { + let input = + """ + let a = [SomeSuperMegaLongTypeName]() + """ + + let expected = + """ + let a = [ + SomeSuperMegaLongTypeName + ]() + + """ + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 30) + } + + func testWhitespaceOnlyDoesNotChangeTrailingComma() { + assertPrettyPrintEqual( + input: """ + let a = [ + "String"1️⃣, + ] + let a = [1, 2, 32️⃣,] + let a: [String] = [ + "One", "Two", "Three", "Four", "Five", + "Six", "Seven", "Eight"3️⃣ + ] + """, + expected: """ + let a = [ + "String", + ] + let a = [1, 2, 3,] + let a: [String] = [ + "One", "Two", "Three", "Four", "Five", + "Six", "Seven", "Eight" + ] + + """, + linelength: 45, + whitespaceOnly: true, + findings: [ + FindingSpec("1️⃣", message: "remove trailing comma from the last element in single line collection literal"), + FindingSpec("2️⃣", message: "remove trailing comma from the last element in single line collection literal"), + FindingSpec("3️⃣", message: "add trailing comma to the last element in multiline collection literal"), + ] + ) + } + + func testTrailingCommaDiagnostics() { + assertPrettyPrintEqual( + input: """ + let a = [1, 2, 31️⃣,] + let a: [String] = [ + "One", "Two", "Three", "Four", "Five", + "Six", "Seven", "Eight"2️⃣ + ] + """, + expected: """ + let a = [1, 2, 3,] + let a: [String] = [ + "One", "Two", "Three", "Four", "Five", + "Six", "Seven", "Eight" + ] + + """, + linelength: 45, + whitespaceOnly: true, + findings: [ + FindingSpec("1️⃣", message: "remove trailing comma from the last element in single line collection literal"), + FindingSpec("2️⃣", message: "add trailing comma to the last element in multiline collection literal"), + ] + ) + } + + func testGroupsTrailingComma() { + let input = + """ + let a = [ + condition ? firstOption : secondOption, + bar(), + ] + """ + + let expected = + """ + let a = [ + condition + ? firstOption + : secondOption, + bar(), + ] + + """ + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 32) + } + + func testInnerElementBreakingFromComma() { + let input = + """ + let a = [("abc", "def", "xyz"),("this ", "string", "is long"),] + let a = [("abc", "def", "xyz"),("this ", "string", "is long")] + let a = [("this ", "string", "is long"),] + let a = [("this ", "string", "is long")] + let a = ["this ", "string", "is longer",] + let a = [("this", "str"), ("is", "lng")] + a = [("az", "by"), ("cf", "de")] + """ + + let expected = + """ + let a = [ + ("abc", "def", "xyz"), + ( + "this ", "string", "is long" + ), + ] + let a = [ + ("abc", "def", "xyz"), + ( + "this ", "string", "is long" + ), + ] + let a = [ + ("this ", "string", "is long") + ] + let a = [ + ("this ", "string", "is long") + ] + let a = [ + "this ", "string", + "is longer", + ] + let a = [ + ("this", "str"), + ("is", "lng"), + ] + + """ + // Ideally, this array would be left on 1 line without a trailing comma. We don't know if the + // comma is required when calculating the length of array elements, so the comma's length is + // always added to last element and that 1 character causes the newlines inside of the array. + + """ + a = [ + ("az", "by"), ("cf", "de"), + ] + + """ + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 32) + } +} diff --git a/Tests/SwiftFormatPrettyPrintTests/AsExprTests.swift b/Tests/SwiftFormatTests/PrettyPrint/AsExprTests.swift similarity index 100% rename from Tests/SwiftFormatPrettyPrintTests/AsExprTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/AsExprTests.swift diff --git a/Tests/SwiftFormatPrettyPrintTests/AssignmentExprTests.swift b/Tests/SwiftFormatTests/PrettyPrint/AssignmentExprTests.swift similarity index 54% rename from Tests/SwiftFormatPrettyPrintTests/AssignmentExprTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/AssignmentExprTests.swift index a7c735f56..2ab1b1d92 100644 --- a/Tests/SwiftFormatPrettyPrintTests/AssignmentExprTests.swift +++ b/Tests/SwiftFormatTests/PrettyPrint/AssignmentExprTests.swift @@ -1,4 +1,4 @@ -import SwiftFormatConfiguration +import SwiftFormat final class AssignmentExprTests: PrettyPrintTestCase { func testBasicAssignmentExprs() { @@ -47,93 +47,101 @@ final class AssignmentExprTests: PrettyPrintTestCase { func testAssignmentOperatorFromSequenceWithFunctionCalls() { let input = - """ - result = firstOp + secondOp + someOpFetchingFunc(foo, bar: bar, baz: baz) - result = someOpFetchingFunc(foo, bar: bar, baz: baz) - result += someOpFetchingFunc(foo, bar: bar, baz: baz) - result = someOpFetchingFunc(foo, bar: bar, baz: baz) + someOtherOperand + andAThirdOneForReasons - result = firstOp + secondOp + thirdOp + someOpFetchingFunc(foo, bar, baz) + nextOp + lastOp - result += firstOp + secondOp + thirdOp + someOpFetchingFunc(foo, bar, baz) + nextOp + lastOp - """ - - let expectedWithArgBinPacking = - """ - result = - firstOp + secondOp - + someOpFetchingFunc( - foo, bar: bar, baz: baz) - result = someOpFetchingFunc( + """ + result = firstOp + secondOp + someOpFetchingFunc(foo, bar: bar, baz: baz) + result = someOpFetchingFunc(foo, bar: bar, baz: baz) + result += someOpFetchingFunc(foo, bar: bar, baz: baz) + result = someOpFetchingFunc(foo, bar: bar, baz: baz) + someOtherOperand + andAThirdOneForReasons + result = firstOp + secondOp + thirdOp + someOpFetchingFunc(foo, bar, baz) + nextOp + lastOp + result += firstOp + secondOp + thirdOp + someOpFetchingFunc(foo, bar, baz) + nextOp + lastOp + """ + + let expectedWithArgBinPacking = + """ + result = + firstOp + secondOp + + someOpFetchingFunc( foo, bar: bar, baz: baz) - result += someOpFetchingFunc( + result = someOpFetchingFunc( + foo, bar: bar, baz: baz) + result += someOpFetchingFunc( + foo, bar: bar, baz: baz) + result = + someOpFetchingFunc( foo, bar: bar, baz: baz) - result = - someOpFetchingFunc( - foo, bar: bar, baz: baz) - + someOtherOperand - + andAThirdOneForReasons - result = - firstOp + secondOp + thirdOp - + someOpFetchingFunc( - foo, bar, baz) + nextOp - + lastOp - result += - firstOp + secondOp + thirdOp - + someOpFetchingFunc( - foo, bar, baz) + nextOp - + lastOp - - """ - - var config = Configuration() - config.lineBreakBeforeEachArgument = false - assertPrettyPrintEqual( - input: input, expected: expectedWithArgBinPacking, linelength: 35, configuration: config) - - let expectedWithBreakBeforeEachArg = - """ - result = - firstOp + secondOp - + someOpFetchingFunc( - foo, - bar: bar, - baz: baz - ) - result = someOpFetchingFunc( + + someOtherOperand + + andAThirdOneForReasons + result = + firstOp + secondOp + thirdOp + + someOpFetchingFunc( + foo, bar, baz) + nextOp + + lastOp + result += + firstOp + secondOp + thirdOp + + someOpFetchingFunc( + foo, bar, baz) + nextOp + + lastOp + + """ + + var config = Configuration.forTesting + config.lineBreakBeforeEachArgument = false + assertPrettyPrintEqual( + input: input, + expected: expectedWithArgBinPacking, + linelength: 35, + configuration: config + ) + + let expectedWithBreakBeforeEachArg = + """ + result = + firstOp + secondOp + + someOpFetchingFunc( foo, bar: bar, baz: baz ) - result += someOpFetchingFunc( + result = someOpFetchingFunc( + foo, + bar: bar, + baz: baz + ) + result += someOpFetchingFunc( + foo, + bar: bar, + baz: baz + ) + result = + someOpFetchingFunc( foo, bar: bar, baz: baz - ) - result = - someOpFetchingFunc( - foo, - bar: bar, - baz: baz - ) + someOtherOperand - + andAThirdOneForReasons - result = - firstOp + secondOp + thirdOp - + someOpFetchingFunc( - foo, - bar, - baz - ) + nextOp + lastOp - result += - firstOp + secondOp + thirdOp - + someOpFetchingFunc( - foo, - bar, - baz - ) + nextOp + lastOp - - """ - config.lineBreakBeforeEachArgument = true - assertPrettyPrintEqual( - input: input, expected: expectedWithBreakBeforeEachArg, linelength: 35, configuration: config) + ) + someOtherOperand + + andAThirdOneForReasons + result = + firstOp + secondOp + thirdOp + + someOpFetchingFunc( + foo, + bar, + baz + ) + nextOp + lastOp + result += + firstOp + secondOp + thirdOp + + someOpFetchingFunc( + foo, + bar, + baz + ) + nextOp + lastOp + + """ + config.lineBreakBeforeEachArgument = true + assertPrettyPrintEqual( + input: input, + expected: expectedWithBreakBeforeEachArg, + linelength: 35, + configuration: config + ) } func testAssignmentPatternBindingFromSequenceWithFunctionCalls() { @@ -166,10 +174,14 @@ final class AssignmentExprTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachArgument = false assertPrettyPrintEqual( - input: input, expected: expectedWithArgBinPacking, linelength: 35, configuration: config) + input: input, + expected: expectedWithArgBinPacking, + linelength: 35, + configuration: config + ) let expectedWithBreakBeforeEachArg = """ @@ -203,6 +215,10 @@ final class AssignmentExprTests: PrettyPrintTestCase { """ config.lineBreakBeforeEachArgument = true assertPrettyPrintEqual( - input: input, expected: expectedWithBreakBeforeEachArg, linelength: 35, configuration: config) + input: input, + expected: expectedWithBreakBeforeEachArg, + linelength: 35, + configuration: config + ) } } diff --git a/Tests/SwiftFormatPrettyPrintTests/AttributeTests.swift b/Tests/SwiftFormatTests/PrettyPrint/AttributeTests.swift similarity index 50% rename from Tests/SwiftFormatPrettyPrintTests/AttributeTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/AttributeTests.swift index 9dd036ea7..3776b10f7 100644 --- a/Tests/SwiftFormatPrettyPrintTests/AttributeTests.swift +++ b/Tests/SwiftFormatTests/PrettyPrint/AttributeTests.swift @@ -1,4 +1,4 @@ -import SwiftFormatConfiguration +import SwiftFormat final class AttributeTests: PrettyPrintTestCase { func testAttributeParamSpacing() { @@ -26,6 +26,40 @@ final class AttributeTests: PrettyPrintTestCase { assertPrettyPrintEqual(input: input, expected: expected, linelength: 60) } + func testAttributeParamSpacingInOriginallyDefinedIn() { + let input = + """ + @_originallyDefinedIn( module :"SwiftUI" , iOS 10.0 ) + func f() {} + """ + + let expected = + """ + @_originallyDefinedIn(module: "SwiftUI", iOS 10.0) + func f() {} + + """ + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 60) + } + + func testAttributeParamSpacingInDocVisibility() { + let input = + """ + @_documentation( visibility :private ) + func f() {} + """ + + let expected = + """ + @_documentation(visibility: private) + func f() {} + + """ + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 60) + } + func testAttributeBinPackedWrapping() { let input = """ @@ -90,10 +124,14 @@ final class AttributeTests: PrettyPrintTestCase { """ - var configuration = Configuration() + var configuration = Configuration.forTesting configuration.lineBreakBeforeEachArgument = true assertPrettyPrintEqual( - input: input, expected: expected, linelength: 32, configuration: configuration) + input: input, + expected: expected, + linelength: 32, + configuration: configuration + ) } func testAttributeFormattingRespectsDiscretionaryLineBreaks() { @@ -166,10 +204,14 @@ final class AttributeTests: PrettyPrintTestCase { """ - var configuration = Configuration() + var configuration = Configuration.forTesting configuration.lineBreakBeforeEachArgument = true assertPrettyPrintEqual( - input: input, expected: expected, linelength: 40, configuration: configuration) + input: input, + expected: expected, + linelength: 40, + configuration: configuration + ) } func testObjCBinPackedAttributes() { @@ -206,43 +248,47 @@ final class AttributeTests: PrettyPrintTestCase { } func testObjCAttributesPerLineBreaking() { - let input = - """ - @objc func f() {} - @objc(foo:bar:baz) - func f() {} - @objc(thisMethodHasAVeryLongName:foo:bar:) - func f() {} - @objc(thisMethodHasAVeryLongName:andThisArgumentHasANameToo:soDoesThisOne:bar:) - func f() {} - """ - - let expected = - """ - @objc func f() {} - @objc(foo:bar:baz) - func f() {} - @objc( - thisMethodHasAVeryLongName: - foo: - bar: - ) - func f() {} - @objc( - thisMethodHasAVeryLongName: - andThisArgumentHasANameToo: - soDoesThisOne: - bar: - ) - func f() {} - - """ - - var configuration = Configuration() - configuration.lineBreakBeforeEachArgument = true - assertPrettyPrintEqual( - input: input, expected: expected, linelength: 40, configuration: configuration) - } + let input = + """ + @objc func f() {} + @objc(foo:bar:baz) + func f() {} + @objc(thisMethodHasAVeryLongName:foo:bar:) + func f() {} + @objc(thisMethodHasAVeryLongName:andThisArgumentHasANameToo:soDoesThisOne:bar:) + func f() {} + """ + + let expected = + """ + @objc func f() {} + @objc(foo:bar:baz) + func f() {} + @objc( + thisMethodHasAVeryLongName: + foo: + bar: + ) + func f() {} + @objc( + thisMethodHasAVeryLongName: + andThisArgumentHasANameToo: + soDoesThisOne: + bar: + ) + func f() {} + + """ + + var configuration = Configuration.forTesting + configuration.lineBreakBeforeEachArgument = true + assertPrettyPrintEqual( + input: input, + expected: expected, + linelength: 40, + configuration: configuration + ) + } func testObjCAttributesDiscretionaryLineBreaking() { // The discretionary newlines in the 3rd function declaration are invalid, because new lines @@ -316,9 +362,6 @@ final class AttributeTests: PrettyPrintTestCase { } func testPropertyWrappers() { - // Property wrappers are `CustomAttributeSyntax` nodes (not `AttributeSyntax`) and their - // arguments are `TupleExprElementListSyntax` (like regular function call argument lists), so - // make sure that those are formatted properly. let input = """ struct X { @@ -359,4 +402,213 @@ final class AttributeTests: PrettyPrintTestCase { assertPrettyPrintEqual(input: input, expected: expected, linelength: 32) } + + func testMultilineStringLiteralInCustomAttribute() { + let input = + #""" + @CustomAttribute(message: """ + This is a + multiline + string + """) + public func f() {} + """# + + let expected = + #""" + @CustomAttribute( + message: """ + This is a + multiline + string + """) + public func f() {} + + """# + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 100) + } + + func testMultilineStringLiteralInAvailableAttribute() { + let input = + #""" + @available(*, deprecated, message: """ + This is a + multiline + string + """) + public func f() {} + """# + + let expected = + #""" + @available( + *, deprecated, + message: """ + This is a + multiline + string + """ + ) + public func f() {} + + """# + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 100) + } + + func testAttributeParamSpacingInExpose() { + let input = + """ + @_expose( wasm , "foo" ) + func f() {} + + @_expose( Cxx , "bar") + func b() {} + + """ + + let expected = + """ + @_expose(wasm, "foo") + func f() {} + + @_expose(Cxx, "bar") + func b() {} + + """ + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 100) + } + + func testLineBreakBetweenDeclarationAttributes() { + let input = + """ + @_spi(Private) @_spi(InviteOnly) import SwiftFormat + + @available(iOS 14.0, *) @available(macOS 11.0, *) + public protocol P { + @available(iOS 16.0, *) @available(macOS 14.0, *) + #if DEBUG + @available(tvOS 17.0, *) @available(watchOS 10.0, *) + #endif + @available(visionOS 1.0, *) + associatedtype ID + } + + @available(iOS 14.0, *) @available(macOS 11.0, *) + public enum Dimension { + case x + case y + @available(iOS 17.0, *) @available(visionOS 1.0, *) + case z + } + + @available(iOS 16.0, *) @available(macOS 14.0, *) + @available(tvOS 16.0, *) @frozen + struct X { + @available(iOS 17.0, *) @available(macOS 15.0, *) + typealias ID = UUID + + @available(iOS 17.0, *) @available(macOS 15.0, *) + var callMe: @MainActor @Sendable () -> Void + + @available(iOS 17.0, *) @available(macOS 15.0, *) + @MainActor @discardableResult + func f(@_inheritActorContext body: @MainActor @Sendable () -> Void) {} + + @available(iOS 17.0, *) @available(macOS 15.0, *) @MainActor + var foo: Foo { + get { Foo() } + @available(iOS, obsoleted: 17.0) @available(macOS 15.0, obsoleted: 15.0) + set { fatalError() } + } + } + """ + + let expected = + """ + @_spi(Private) @_spi(InviteOnly) import SwiftFormat + + @available(iOS 14.0, *) + @available(macOS 11.0, *) + public protocol P { + @available(iOS 16.0, *) + @available(macOS 14.0, *) + #if DEBUG + @available(tvOS 17.0, *) + @available(watchOS 10.0, *) + #endif + @available(visionOS 1.0, *) + associatedtype ID + } + + @available(iOS 14.0, *) + @available(macOS 11.0, *) + public enum Dimension { + case x + case y + @available(iOS 17.0, *) + @available(visionOS 1.0, *) + case z + } + + @available(iOS 16.0, *) + @available(macOS 14.0, *) + @available(tvOS 16.0, *) + @frozen + struct X { + @available(iOS 17.0, *) + @available(macOS 15.0, *) + typealias ID = UUID + + @available(iOS 17.0, *) + @available(macOS 15.0, *) + var callMe: @MainActor @Sendable () -> Void + + @available(iOS 17.0, *) + @available(macOS 15.0, *) + @MainActor + @discardableResult + func f(@_inheritActorContext body: @MainActor @Sendable () -> Void) {} + + @available(iOS 17.0, *) + @available(macOS 15.0, *) + @MainActor + var foo: Foo { + get { Foo() } + @available(iOS, obsoleted: 17.0) + @available(macOS 15.0, obsoleted: 15.0) + set { fatalError() } + } + } + + """ + var configuration = Configuration.forTesting + configuration.lineBreakBetweenDeclarationAttributes = true + assertPrettyPrintEqual(input: input, expected: expected, linelength: 80, configuration: configuration) + } + + func testAttributesStartWithPoundIf() { + let input = + """ + #if os(macOS) + @available(macOS, unavailable) + @_spi(Foo) + #endif + public let myVar = "Test" + + """ + let expected = + """ + #if os(macOS) + @available(macOS, unavailable) + @_spi(Foo) + #endif + public let myVar = "Test" + + """ + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 45) + } } diff --git a/Tests/SwiftFormatPrettyPrintTests/AvailabilityConditionTests.swift b/Tests/SwiftFormatTests/PrettyPrint/AvailabilityConditionTests.swift similarity index 100% rename from Tests/SwiftFormatPrettyPrintTests/AvailabilityConditionTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/AvailabilityConditionTests.swift diff --git a/Tests/SwiftFormatPrettyPrintTests/AwaitExprTests.swift b/Tests/SwiftFormatTests/PrettyPrint/AwaitExprTests.swift similarity index 99% rename from Tests/SwiftFormatPrettyPrintTests/AwaitExprTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/AwaitExprTests.swift index 5730f7f49..1c890e42d 100644 --- a/Tests/SwiftFormatPrettyPrintTests/AwaitExprTests.swift +++ b/Tests/SwiftFormatTests/PrettyPrint/AwaitExprTests.swift @@ -1,4 +1,4 @@ -import SwiftFormatConfiguration +import SwiftFormat final class AwaitExprTests: PrettyPrintTestCase { func testBasicAwaits() { diff --git a/Tests/SwiftFormatTests/PrettyPrint/BackDeployAttributeTests.swift b/Tests/SwiftFormatTests/PrettyPrint/BackDeployAttributeTests.swift new file mode 100644 index 000000000..5deef12bf --- /dev/null +++ b/Tests/SwiftFormatTests/PrettyPrint/BackDeployAttributeTests.swift @@ -0,0 +1,53 @@ +final class BackDeployAttributeTests: PrettyPrintTestCase { + func testSpacingAndWrapping() { + let input = + """ + @backDeployed(before:iOS 17) + public func hello() {} + + @backDeployed(before:iOS 17,macOS 14) + public func hello() {} + + @backDeployed(before:iOS 17,macOS 14,tvOS 17) + public func hello() {} + """ + + let expected80 = + """ + @backDeployed(before: iOS 17) + public func hello() {} + + @backDeployed(before: iOS 17, macOS 14) + public func hello() {} + + @backDeployed(before: iOS 17, macOS 14, tvOS 17) + public func hello() {} + + """ + + assertPrettyPrintEqual(input: input, expected: expected80, linelength: 80) + + let expected28 = + """ + @backDeployed( + before: iOS 17 + ) + public func hello() {} + + @backDeployed( + before: iOS 17, macOS 14 + ) + public func hello() {} + + @backDeployed( + before: + iOS 17, macOS 14, + tvOS 17 + ) + public func hello() {} + + """ + + assertPrettyPrintEqual(input: input, expected: expected28, linelength: 28) + } +} diff --git a/Tests/SwiftFormatPrettyPrintTests/BacktickTests.swift b/Tests/SwiftFormatTests/PrettyPrint/BacktickTests.swift similarity index 100% rename from Tests/SwiftFormatPrettyPrintTests/BacktickTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/BacktickTests.swift diff --git a/Tests/SwiftFormatPrettyPrintTests/BinaryOperatorExprTests.swift b/Tests/SwiftFormatTests/PrettyPrint/BinaryOperatorExprTests.swift similarity index 93% rename from Tests/SwiftFormatPrettyPrintTests/BinaryOperatorExprTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/BinaryOperatorExprTests.swift index 1a97cd76e..1d2bd6492 100644 --- a/Tests/SwiftFormatPrettyPrintTests/BinaryOperatorExprTests.swift +++ b/Tests/SwiftFormatTests/PrettyPrint/BinaryOperatorExprTests.swift @@ -1,4 +1,4 @@ -import SwiftFormatConfiguration +import SwiftFormat final class BinaryOperatorExprTests: PrettyPrintTestCase { func testNonRangeFormationOperatorsAreSurroundedByBreaks() { @@ -50,10 +50,14 @@ final class BinaryOperatorExprTests: PrettyPrintTestCase { """ - var configuration = Configuration() + var configuration = Configuration.forTesting configuration.spacesAroundRangeFormationOperators = false assertPrettyPrintEqual( - input: input, expected: expected, linelength: 80, configuration: configuration) + input: input, + expected: expected, + linelength: 80, + configuration: configuration + ) } func testRangeFormationOperatorCompaction_spacesAroundRangeFormation() { @@ -78,10 +82,14 @@ final class BinaryOperatorExprTests: PrettyPrintTestCase { """ - var configuration = Configuration() + var configuration = Configuration.forTesting configuration.spacesAroundRangeFormationOperators = true assertPrettyPrintEqual( - input: input, expected: expected, linelength: 80, configuration: configuration) + input: input, + expected: expected, + linelength: 80, + configuration: configuration + ) } func testRangeFormationOperatorsAreNotCompactedWhenFollowingAPostfixOperator() { diff --git a/Tests/SwiftFormatPrettyPrintTests/ClassDeclTests.swift b/Tests/SwiftFormatTests/PrettyPrint/ClassDeclTests.swift similarity index 97% rename from Tests/SwiftFormatPrettyPrintTests/ClassDeclTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/ClassDeclTests.swift index 4d9dc76c7..985d4db66 100644 --- a/Tests/SwiftFormatPrettyPrintTests/ClassDeclTests.swift +++ b/Tests/SwiftFormatTests/PrettyPrint/ClassDeclTests.swift @@ -1,4 +1,4 @@ -import SwiftFormatConfiguration +import SwiftFormat final class ClassDeclTests: PrettyPrintTestCase { func testBasicClassDeclarations() { @@ -79,7 +79,7 @@ final class ClassDeclTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachArgument = true assertPrettyPrintEqual(input: input, expected: expected, linelength: 30, configuration: config) } @@ -120,7 +120,7 @@ final class ClassDeclTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachArgument = false assertPrettyPrintEqual(input: input, expected: expected, linelength: 30, configuration: config) } @@ -228,7 +228,7 @@ final class ClassDeclTests: PrettyPrintTestCase { func testClassWhereClause_lineBreakAfterGenericWhereClause() { let input = - """ + """ class MyClass where S: Collection { let A: Int let B: Double @@ -244,7 +244,7 @@ final class ClassDeclTests: PrettyPrintTestCase { """ let expected = - """ + """ class MyClass where S: Collection { let A: Int let B: Double @@ -267,7 +267,7 @@ final class ClassDeclTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachGenericRequirement = true assertPrettyPrintEqual(input: input, expected: expected, linelength: 60, configuration: config) } @@ -304,7 +304,7 @@ final class ClassDeclTests: PrettyPrintTestCase { func testClassWhereClauseWithInheritance_lineBreakAfterGenericWhereClause() { let input = - """ + """ class MyClass: SuperOne where S: Collection { let A: Int let B: Double @@ -320,7 +320,7 @@ final class ClassDeclTests: PrettyPrintTestCase { """ let expected = - """ + """ class MyClass: SuperOne where S: Collection { let A: Int let B: Double @@ -343,7 +343,7 @@ final class ClassDeclTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachGenericRequirement = true assertPrettyPrintEqual(input: input, expected: expected, linelength: 60, configuration: config) } @@ -424,14 +424,14 @@ final class ClassDeclTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachArgument = false assertPrettyPrintEqual(input: input, expected: expected, linelength: 50, configuration: config) } func testClassFullWrap_lineBreakAfterGenericWhereClause() { let input = - """ + """ public class MyContainer: MyContainerSuperclass, MyContainerProtocol, SomeoneElsesContainerProtocol, SomeFrameworkContainerProtocol where BaseCollection: Collection, BaseCollection: P, BaseCollection.Element: Equatable, BaseCollection.Element: SomeOtherProtocol { let A: Int let B: Double @@ -440,7 +440,7 @@ final class ClassDeclTests: PrettyPrintTestCase { let expected = - """ + """ public class MyContainer< BaseCollection, SecondCollection >: MyContainerSuperclass, MyContainerProtocol, @@ -458,7 +458,7 @@ final class ClassDeclTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachArgument = false config.lineBreakBeforeEachGenericRequirement = true assertPrettyPrintEqual(input: input, expected: expected, linelength: 50, configuration: config) diff --git a/Tests/SwiftFormatPrettyPrintTests/ClosureExprTests.swift b/Tests/SwiftFormatTests/PrettyPrint/ClosureExprTests.swift similarity index 95% rename from Tests/SwiftFormatPrettyPrintTests/ClosureExprTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/ClosureExprTests.swift index 5518c5a96..eb437c253 100644 --- a/Tests/SwiftFormatPrettyPrintTests/ClosureExprTests.swift +++ b/Tests/SwiftFormatTests/PrettyPrint/ClosureExprTests.swift @@ -1,4 +1,4 @@ -import SwiftFormatConfiguration +import SwiftFormat final class ClosureExprTests: PrettyPrintTestCase { func testBasicFunctionClosures_noPackArguments() { @@ -65,7 +65,7 @@ final class ClosureExprTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachArgument = true assertPrettyPrintEqual(input: input, expected: expected, linelength: 42, configuration: config) } @@ -117,7 +117,7 @@ final class ClosureExprTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachArgument = false assertPrettyPrintEqual(input: input, expected: expected, linelength: 42, configuration: config) } @@ -151,7 +151,7 @@ final class ClosureExprTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachArgument = false assertPrettyPrintEqual(input: input, expected: expected, linelength: 40, configuration: config) } @@ -189,7 +189,7 @@ final class ClosureExprTests: PrettyPrintTestCase { func testClosuresWithIfs() { let input = - """ + """ let a = afunc() { if condition1 { return true @@ -209,7 +209,7 @@ final class ClosureExprTests: PrettyPrintTestCase { """ let expected = - """ + """ let a = afunc() { if condition1 { return true @@ -479,10 +479,14 @@ final class ClosureExprTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.prioritizeKeepingFunctionOutputTogether = true assertPrettyPrintEqual( - input: input, expected: expectedKeepingOutputTogether, linelength: 50, configuration: config) + input: input, + expected: expectedKeepingOutputTogether, + linelength: 50, + configuration: config + ) } func testClosureSignatureAttributes() { @@ -516,4 +520,24 @@ final class ClosureExprTests: PrettyPrintTestCase { assertPrettyPrintEqual(input: input, expected: expected, linelength: 40) } + + func testClosureWithSignatureAndMultipleStatements() { + let input = + """ + { a in a + 1 + a + 2 + } + """ + + let expected = + """ + { a in + a + 1 + a + 2 + } + + """ + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 40) + } } diff --git a/Tests/SwiftFormatTests/PrettyPrint/CommaTests.swift b/Tests/SwiftFormatTests/PrettyPrint/CommaTests.swift new file mode 100644 index 000000000..c3c7766e5 --- /dev/null +++ b/Tests/SwiftFormatTests/PrettyPrint/CommaTests.swift @@ -0,0 +1,385 @@ +import SwiftFormat + +final class CommaTests: PrettyPrintTestCase { + func testArrayCommasAbsentEnabled() { + let input = + """ + let MyCollection = [ + 1, + 2, + 3 + ] + + """ + + let expected = + """ + let MyCollection = [ + 1, + 2, + 3, + ] + + """ + + var configuration = Configuration.forTesting + configuration.multiElementCollectionTrailingCommas = true + assertPrettyPrintEqual(input: input, expected: expected, linelength: 20, configuration: configuration) + } + + func testArrayCommasAbsentDisabled() { + let input = + """ + let MyCollection = [ + 1, + 2, + 3 + ] + + """ + + let expected = + """ + let MyCollection = [ + 1, + 2, + 3 + ] + + """ + + var configuration = Configuration.forTesting + configuration.multiElementCollectionTrailingCommas = false + assertPrettyPrintEqual(input: input, expected: expected, linelength: 20, configuration: configuration) + } + + func testArrayCommasPresentEnabled() { + let input = + """ + let MyCollection = [ + 1, + 2, + 3, + ] + + """ + + let expected = + """ + let MyCollection = [ + 1, + 2, + 3, + ] + + """ + + var configuration = Configuration.forTesting + configuration.multiElementCollectionTrailingCommas = true + assertPrettyPrintEqual(input: input, expected: expected, linelength: 20, configuration: configuration) + } + + func testArrayCommasPresentDisabled() { + let input = + """ + let MyCollection = [ + 1, + 2, + 3, + ] + + """ + + let expected = + """ + let MyCollection = [ + 1, + 2, + 3 + ] + + """ + + var configuration = Configuration.forTesting + configuration.multiElementCollectionTrailingCommas = false + assertPrettyPrintEqual(input: input, expected: expected, linelength: 20, configuration: configuration) + } + + func testArraySingleLineCommasPresentEnabled() { + let input = + """ + let MyCollection = [1, 2, 3,] + + """ + + // no effect expected + let expected = + """ + let MyCollection = [1, 2, 3] + + """ + + var configuration = Configuration.forTesting + configuration.multiElementCollectionTrailingCommas = true + assertPrettyPrintEqual(input: input, expected: expected, linelength: 40, configuration: configuration) + } + + func testArraySingleLineCommasPresentDisabled() { + let input = + """ + let MyCollection = [1, 2, 3,] + + """ + + // no effect expected + let expected = + """ + let MyCollection = [1, 2, 3] + + """ + + var configuration = Configuration.forTesting + configuration.multiElementCollectionTrailingCommas = false + assertPrettyPrintEqual(input: input, expected: expected, linelength: 40, configuration: configuration) + } + + func testArrayWithCommentCommasPresentEnabled() { + let input = + """ + let MyCollection = [ + 1, + 2 // some comment + ] + + """ + + let expected = + """ + let MyCollection = [ + 1, + 2, // some comment + ] + + """ + + var configuration = Configuration.forTesting + configuration.multiElementCollectionTrailingCommas = true + assertPrettyPrintEqual(input: input, expected: expected, linelength: 40, configuration: configuration) + } + + func testArrayWithCommentCommasPresentDisabled() { + let input = + """ + let MyCollection = [ + 1, + 2 // some comment + ] + + """ + + let expected = + """ + let MyCollection = [ + 1, + 2 // some comment + ] + + """ + + var configuration = Configuration.forTesting + configuration.multiElementCollectionTrailingCommas = false + assertPrettyPrintEqual(input: input, expected: expected, linelength: 40, configuration: configuration) + } + + func testArrayWithTernaryOperatorAndCommentCommasPresentEnabled() { + let input = + """ + let MyCollection = [ + 1, + true ? 1 : 2 // some comment + ] + + """ + + let expected = + """ + let MyCollection = [ + 1, + true ? 1 : 2, // some comment + ] + + """ + + var configuration = Configuration.forTesting + configuration.multiElementCollectionTrailingCommas = true + assertPrettyPrintEqual(input: input, expected: expected, linelength: 40, configuration: configuration) + } + + func testArrayWithTernaryOperatorAndCommentCommasPresentDisabled() { + let input = + """ + let MyCollection = [ + 1, + true ? 1 : 2 // some comment + ] + + """ + + let expected = + """ + let MyCollection = [ + 1, + true ? 1 : 2 // some comment + ] + + """ + + var configuration = Configuration.forTesting + configuration.multiElementCollectionTrailingCommas = false + assertPrettyPrintEqual(input: input, expected: expected, linelength: 40, configuration: configuration) + } + + func testDictionaryCommasAbsentEnabled() { + let input = + """ + let MyCollection = [ + "a": 1, + "b": 2, + "c": 3 + ] + + """ + + let expected = + """ + let MyCollection = [ + "a": 1, + "b": 2, + "c": 3, + ] + + """ + + var configuration = Configuration.forTesting + configuration.multiElementCollectionTrailingCommas = true + assertPrettyPrintEqual(input: input, expected: expected, linelength: 20, configuration: configuration) + } + + func testDictionaryCommasAbsentDisabled() { + let input = + """ + let MyCollection = [ + "a": 1, + "b": 2, + "c": 3 + ] + + """ + + let expected = + """ + let MyCollection = [ + "a": 1, + "b": 2, + "c": 3 + ] + + """ + + var configuration = Configuration.forTesting + configuration.multiElementCollectionTrailingCommas = false + assertPrettyPrintEqual(input: input, expected: expected, linelength: 20, configuration: configuration) + } + + func testDictionaryCommasPresentEnabled() { + let input = + """ + let MyCollection = [ + "a": 1, + "b": 2, + "c": 3, + ] + + """ + + let expected = + """ + let MyCollection = [ + "a": 1, + "b": 2, + "c": 3, + ] + + """ + + var configuration = Configuration.forTesting + configuration.multiElementCollectionTrailingCommas = true + assertPrettyPrintEqual(input: input, expected: expected, linelength: 20, configuration: configuration) + } + + func testDictionaryCommasPresentDisabled() { + let input = + """ + let MyCollection = [ + "a": 1, + "b": 2, + "c": 3, + ] + + """ + + let expected = + """ + let MyCollection = [ + "a": 1, + "b": 2, + "c": 3 + ] + + """ + + var configuration = Configuration.forTesting + configuration.multiElementCollectionTrailingCommas = false + assertPrettyPrintEqual(input: input, expected: expected, linelength: 20, configuration: configuration) + } + + func testDictionarySingleLineCommasPresentDisabled() { + let input = + """ + let MyCollection = ["a": 1, "b": 2, "c": 3,] + + """ + + let expected = + """ + let MyCollection = [ + "a": 1, "b": 2, "c": 3, + ] + + """ + + var configuration = Configuration.forTesting + configuration.multiElementCollectionTrailingCommas = true + assertPrettyPrintEqual(input: input, expected: expected, linelength: 40, configuration: configuration) + } + + func testDictionarySingleLineCommasPresentEnabled() { + let input = + """ + let MyCollection = ["a": 1, "b": 2, "c": 3,] + + """ + + let expected = + """ + let MyCollection = [ + "a": 1, "b": 2, "c": 3 + ] + + """ + + var configuration = Configuration.forTesting + configuration.multiElementCollectionTrailingCommas = false + assertPrettyPrintEqual(input: input, expected: expected, linelength: 40, configuration: configuration) + } +} diff --git a/Tests/SwiftFormatPrettyPrintTests/CommentTests.swift b/Tests/SwiftFormatTests/PrettyPrint/CommentTests.swift similarity index 61% rename from Tests/SwiftFormatPrettyPrintTests/CommentTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/CommentTests.swift index c197c5d69..b32342748 100644 --- a/Tests/SwiftFormatPrettyPrintTests/CommentTests.swift +++ b/Tests/SwiftFormatTests/PrettyPrint/CommentTests.swift @@ -1,3 +1,6 @@ +import SwiftFormat +import _SwiftFormatTestSupport + final class CommentTests: PrettyPrintTestCase { func testDocumentationComments() { let input = @@ -81,6 +84,8 @@ final class CommentTests: PrettyPrintTestCase { func testLineComments() { let input = """ + // Line Comment0 + // Line Comment1 // Line Comment2 let a = 123 @@ -91,6 +96,7 @@ final class CommentTests: PrettyPrintTestCase { // Comment 4 let reallyLongVariableName = 123 // This comment should not wrap + // and should not combine with this comment func MyFun() { // just a comment @@ -133,6 +139,8 @@ final class CommentTests: PrettyPrintTestCase { let expected = """ + // Line Comment0 + // Line Comment1 // Line Comment2 let a = 123 @@ -143,6 +151,7 @@ final class CommentTests: PrettyPrintTestCase { // Comment 4 let reallyLongVariableName = 123 // This comment should not wrap + // and should not combine with this comment func MyFun() { // just a comment @@ -191,6 +200,152 @@ final class CommentTests: PrettyPrintTestCase { assertPrettyPrintEqual(input: input, expected: expected, linelength: 45) } + func testLineCommentsWithCustomLeadingSpaces() { + let pairs: [(String, String)] = [ + ( + """ + // Line Comment0 + + // Line Comment1 + // Line Comment2 + let a = 123 + let b = "456" // End of line comment + let c = "More content" + + """, + """ + // Line Comment0 + + // Line Comment1 + // Line Comment2 + let a = 123 + let b = "456" // End of line comment + let c = "More content" + + """ + ), + ( + """ + // Comment 3 + // Comment 4 + + let reallyLongVariableName = 123 // This comment should not wrap + // and should not combine with this comment + + func MyFun() { + // just a comment + } + """, + """ + // Comment 3 + // Comment 4 + + let reallyLongVariableName = 123 // This comment should not wrap + // and should not combine with this comment + + func MyFun() { + // just a comment + } + + """ + ), + ( + """ + func MyFun() { + // Comment 1 + // Comment 2 + let a = 123 + + let b = 456 // Comment 3 + } + + func MyFun() { + let c = 789 // Comment 4 + // Comment 5 + } + """, + """ + func MyFun() { + // Comment 1 + // Comment 2 + let a = 123 + + let b = 456 // Comment 3 + } + + func MyFun() { + let c = 789 // Comment 4 + // Comment 5 + } + + """ + ), + ( + """ + let a = myfun(123 // Cmt 7 + ) + let a = myfun(var1: 123 // Cmt 7 + ) + + guard condition else { return // Cmt 6 + } + + switch myvar { + case .one, .two, // three + .four: + dostuff() + default: () + } + + """, + """ + let a = myfun( + 123 // Cmt 7 + ) + let a = myfun( + var1: 123 // Cmt 7 + ) + + guard condition else { + return // Cmt 6 + } + + switch myvar { + case .one, .two, // three + .four: + dostuff() + default: () + } + + """ + ), + ( + """ + let a = 123 + // comment + b + c + + let d = 123 + // Trailing Comment + """, + """ + let a = + 123 // comment + + b + c + + let d = 123 + // Trailing Comment + + """ + ), + ] + + var config = Configuration.forTesting + config.spacesBeforeEndOfLineComments = 3 + for (input, expected) in pairs { + assertPrettyPrintEqual(input: input, expected: expected, linelength: 45, configuration: config) + } + } + func testContainerLineComments() { let input = """ @@ -206,6 +361,13 @@ final class CommentTests: PrettyPrintTestCase { let c = [123, 456 // small comment ] + // Multiline comment + let d = [123, + // comment line 1 + // comment line 2 + 456 + ] + /* Array comment */ let a = [456, /* small comment */ 789] @@ -234,6 +396,14 @@ final class CommentTests: PrettyPrintTestCase { 123, 456, // small comment ] + // Multiline comment + let d = [ + 123, + // comment line 1 + // comment line 2 + 456, + ] + /* Array comment */ let a = [ 456, /* small comment */ @@ -251,6 +421,82 @@ final class CommentTests: PrettyPrintTestCase { assertPrettyPrintEqual(input: input, expected: expected, linelength: 80) } + func testContainerLineCommentsWithCustomLeadingSpaces() { + let input = + """ + // Array comment + let a = [456, // small comment + 789] + + // Dictionary comment + let b = ["abc": 456, // small comment + "def": 789] + + // Trailing comment + let c = [123, 456 // small comment + ] + + // Multiline comment + let d = [123, + // comment line 1 + // comment line 2 + 456 + ] + + /* Array comment */ + let a = [456, /* small comment */ + 789] + + /* Dictionary comment */ + let b = ["abc": 456, /* small comment */ + "def": 789] + """ + + let expected = + """ + // Array comment + let a = [ + 456, // small comment + 789, + ] + + // Dictionary comment + let b = [ + "abc": 456, // small comment + "def": 789, + ] + + // Trailing comment + let c = [ + 123, 456, // small comment + ] + + // Multiline comment + let d = [ + 123, + // comment line 1 + // comment line 2 + 456, + ] + + /* Array comment */ + let a = [ + 456, /* small comment */ + 789, + ] + + /* Dictionary comment */ + let b = [ + "abc": 456, /* small comment */ + "def": 789, + ] + + """ + var config = Configuration.forTesting + config.spacesBeforeEndOfLineComments = 1 + assertPrettyPrintEqual(input: input, expected: expected, linelength: 80, configuration: config) + } + func testDocumentationBlockComments() { let input = """ @@ -337,14 +583,14 @@ final class CommentTests: PrettyPrintTestCase { func testBlockComments() { let input = """ - /* Line Comment1 */ + /* Line Comment1 */ /* Line Comment2 */ let a = 123 let b = "456" /* End of line comment */ let c = "More content" - /* Comment 3 - Comment 4 */ + /* Comment 3 + Comment 4 */ let reallyLongVariableName = 123 /* This comment should wrap */ @@ -357,7 +603,9 @@ final class CommentTests: PrettyPrintTestCase { } let d = 123 - /* Trailing Comment */ + /* Trailing Comment */ + /* Trailing + Block Comment */ """ let expected = @@ -387,6 +635,8 @@ final class CommentTests: PrettyPrintTestCase { let d = 123 /* Trailing Comment */ + /* Trailing + Block Comment */ """ @@ -408,7 +658,7 @@ final class CommentTests: PrettyPrintTestCase { case quux } """ - + let expected = """ struct Foo { @@ -424,7 +674,7 @@ final class CommentTests: PrettyPrintTestCase { } """ - + assertPrettyPrintEqual(input: input, expected: expected, linelength: 100) } @@ -592,27 +842,75 @@ final class CommentTests: PrettyPrintTestCase { assertPrettyPrintEqual(input: input, expected: expected, linelength: 100) } + func testOperatorOnNewLineWithTrailingLineComment() { + let input = + """ + if next + && // final is important + // second line about final + final + { + } + """ + + let expected = + """ + if next + // final is important + // second line about final + && final + { + } + + """ + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 100) + } + + func testOperatorOnSameLineWithTrailingLineComment() { + let input = + """ + if next && // final is important + // second line about final + final + { + } + """ + + let expected = + """ + if next // final is important + // second line about final + && final + { + } + + """ + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 100) + } + func testCommentsInIfStatements() { let input = - """ - if foo.bar && false && // comment about foo.bar - baz && // comment about baz - // comment about next - next - && // other is important - // second line about other - other && - // comment about final on a new line - final - { - } - if foo.bar && foo.baz - && // comment about the next line - // another comment line - next.line - { - } - """ + """ + if foo.bar && false && // comment about foo.bar + baz && // comment about baz + // comment about next + next + && // other is important + // second line about other + other && + // comment about final on a new line + final + { + } + if foo.bar && foo.baz + && // comment about the next line + // another comment line + next.line + { + } + """ let expected = """ @@ -682,4 +980,106 @@ final class CommentTests: PrettyPrintTestCase { assertPrettyPrintEqual(input: input, expected: expected, linelength: 60) } + + func testDiagnoseMoveEndOfLineComment() { + assertPrettyPrintEqual( + input: """ + import veryveryverylongmodulenameherebecauseitistypical // special sentinel comment + + func fooBarBazRunningOutOfIdeas() { 1️⃣// comment that needs to move + if foo { // comment is fine + } + } + + """, + expected: """ + import veryveryverylongmodulenameherebecauseitistypical // special sentinel comment + + func fooBarBazRunningOutOfIdeas() { // comment that needs to move + if foo { // comment is fine + } + } + + """, + linelength: 45, + whitespaceOnly: true, + findings: [ + FindingSpec("1️⃣", message: "move end-of-line comment that exceeds the line length") + ] + ) + } + + // Tests that "end of line" comments are flagged only when they exceed the configured line length. + func testDiagnoseMoveEndOfLineCommentAroundBoundary() { + assertPrettyPrintEqual( + input: """ + x // 789 + x // 7890 + x 1️⃣// 78901 + + """, + expected: """ + x // 789 + x // 7890 + x // 78901 + + """, + linelength: 10, + whitespaceOnly: true, + findings: [ + FindingSpec("1️⃣", message: "move end-of-line comment that exceeds the line length") + ] + ) + } + + func testLineWithDocLineComment() { + // none of these should be merged if/when there is comment formatting + let input = + """ + /// Doc line comment + // Line comment + /// Doc line comment + // Line comment + + // Another line comment + + """ + assertPrettyPrintEqual(input: input, expected: input, linelength: 80) + } + + func testNonmergeableComments() { + // none of these should be merged if/when there is comment formatting + let input = + """ + let x = 1 // end of line comment + // + + let y = // eol comment + 1 // another + + 2 // and another + + """ + + assertPrettyPrintEqual(input: input, expected: input, linelength: 80) + } + + func testMergeableComments() { + // these examples should be merged and formatted if/when there is comment formatting + let input = + """ + let z = + // one comment + // and another comment + 1 + 2 + + let w = [1, 2, 3] + .foo() + // this comment + // could be merged with this one + .bar() + + """ + + assertPrettyPrintEqual(input: input, expected: input, linelength: 80) + } } diff --git a/Tests/SwiftFormatPrettyPrintTests/ConstrainedSugarTypeTests.swift b/Tests/SwiftFormatTests/PrettyPrint/ConstrainedSugarTypeTests.swift similarity index 100% rename from Tests/SwiftFormatPrettyPrintTests/ConstrainedSugarTypeTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/ConstrainedSugarTypeTests.swift diff --git a/Tests/SwiftFormatTests/PrettyPrint/ConsumeExprTests.swift b/Tests/SwiftFormatTests/PrettyPrint/ConsumeExprTests.swift new file mode 100644 index 000000000..dfe64d414 --- /dev/null +++ b/Tests/SwiftFormatTests/PrettyPrint/ConsumeExprTests.swift @@ -0,0 +1,15 @@ +final class ConsumeExprTests: PrettyPrintTestCase { + func testConsume() { + assertPrettyPrintEqual( + input: """ + let x = consume y + """, + expected: """ + let x = + consume y + + """, + linelength: 16 + ) + } +} diff --git a/Tests/SwiftFormatTests/PrettyPrint/CopyExprSyntax.swift b/Tests/SwiftFormatTests/PrettyPrint/CopyExprSyntax.swift new file mode 100644 index 000000000..5b6a65e95 --- /dev/null +++ b/Tests/SwiftFormatTests/PrettyPrint/CopyExprSyntax.swift @@ -0,0 +1,15 @@ +final class CopyExprTests: PrettyPrintTestCase { + func testCopy() { + assertPrettyPrintEqual( + input: """ + let x = copy y + """, + expected: """ + let x = + copy y + + """, + linelength: 13 + ) + } +} diff --git a/Tests/SwiftFormatPrettyPrintTests/DeclNameArgumentTests.swift b/Tests/SwiftFormatTests/PrettyPrint/DeclNameArgumentTests.swift similarity index 97% rename from Tests/SwiftFormatPrettyPrintTests/DeclNameArgumentTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/DeclNameArgumentTests.swift index 289b65042..0d02ae3a5 100644 --- a/Tests/SwiftFormatPrettyPrintTests/DeclNameArgumentTests.swift +++ b/Tests/SwiftFormatTests/PrettyPrint/DeclNameArgumentTests.swift @@ -1,4 +1,4 @@ -import SwiftFormatConfiguration +import SwiftFormat final class DeclNameArgumentTests: PrettyPrintTestCase { func testSelectors_noPackArguments() { @@ -41,7 +41,7 @@ final class DeclNameArgumentTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachArgument = true assertPrettyPrintEqual(input: input, expected: expected, linelength: 40, configuration: config) } @@ -114,7 +114,7 @@ final class DeclNameArgumentTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachArgument = true assertPrettyPrintEqual(input: input, expected: expected, linelength: 40, configuration: config) } @@ -148,4 +148,3 @@ final class DeclNameArgumentTests: PrettyPrintTestCase { assertPrettyPrintEqual(input: input, expected: expected, linelength: 40) } } - diff --git a/Tests/SwiftFormatPrettyPrintTests/DeinitializerDeclTests.swift b/Tests/SwiftFormatTests/PrettyPrint/DeinitializerDeclTests.swift similarity index 99% rename from Tests/SwiftFormatPrettyPrintTests/DeinitializerDeclTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/DeinitializerDeclTests.swift index fc4d3fc36..4c219eb6b 100644 --- a/Tests/SwiftFormatPrettyPrintTests/DeinitializerDeclTests.swift +++ b/Tests/SwiftFormatTests/PrettyPrint/DeinitializerDeclTests.swift @@ -83,7 +83,7 @@ final class DeinitializerDeclTests: PrettyPrintTestCase { } """ assertPrettyPrintEqual(input: input, expected: input + "\n", linelength: 50) - + let wrapped = """ class X { // diff --git a/Tests/SwiftFormatPrettyPrintTests/DictionaryDeclTests.swift b/Tests/SwiftFormatTests/PrettyPrint/DictionaryDeclTests.swift similarity index 66% rename from Tests/SwiftFormatPrettyPrintTests/DictionaryDeclTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/DictionaryDeclTests.swift index 67bd96c5f..f903b9018 100644 --- a/Tests/SwiftFormatPrettyPrintTests/DictionaryDeclTests.swift +++ b/Tests/SwiftFormatTests/PrettyPrint/DictionaryDeclTests.swift @@ -1,5 +1,6 @@ -import SwiftFormatPrettyPrint +import SwiftFormat import SwiftSyntax +import _SwiftFormatTestSupport final class DictionaryDeclTests: PrettyPrintTestCase { func testBasicDictionaries() { @@ -58,16 +59,16 @@ final class DictionaryDeclTests: PrettyPrintTestCase { ] """ - // Ideally, this dictionary would be left on 1 line without a trailing comma. We don't know if - // the comma is required when calculating the length of elements, so the comma's length is - // always added to last element and that 1 character causes the newlines inside of the - // dictionary. - + """ - let a = [ - 10000: "abc", 20000: "def", 30000: "ghi", - ] + // Ideally, this dictionary would be left on 1 line without a trailing comma. We don't know if + // the comma is required when calculating the length of elements, so the comma's length is + // always added to last element and that 1 character causes the newlines inside of the + // dictionary. + + """ + let a = [ + 10000: "abc", 20000: "def", 30000: "ghi", + ] - """ + """ assertPrettyPrintEqual(input: input, expected: expected, linelength: 50) } @@ -90,37 +91,62 @@ final class DictionaryDeclTests: PrettyPrintTestCase { } func testWhitespaceOnlyDoesNotChangeTrailingComma() { - let input = - """ - let a = [ - 1: "a", - ] - let a = [1: "a", 2: "b", 3: "c",] - let a: [Int: String] = [ - 1: "a", 2: "b", 3: "c", 4: "d", 5: "e", 6: "f", - 7: "g", 8: "i" - ] - """ - assertPrettyPrintEqual( - input: input, expected: input + "\n", linelength: 50, whitespaceOnly: true) - } + input: """ + let a = [ + 1: "a"1️⃣, + ] + let a = [1: "a", 2: "b", 3: "c"2️⃣,] + let a: [Int: String] = [ + 1: "a", 2: "b", 3: "c", 4: "d", 5: "e", 6: "f", + 7: "g", 8: "i"3️⃣ + ] + """, + expected: """ + let a = [ + 1: "a", + ] + let a = [1: "a", 2: "b", 3: "c",] + let a: [Int: String] = [ + 1: "a", 2: "b", 3: "c", 4: "d", 5: "e", 6: "f", + 7: "g", 8: "i" + ] - func testTrailingCommaDiagnostics() { - let input = - """ - let a = [1: "a", 2: "b", 3: "c",] - let a: [Int: String] = [ - 1: "a", 2: "b", 3: "c", 4: "d", 5: "e", 6: "f", - 7: "g", 8: "i" + """, + linelength: 50, + whitespaceOnly: true, + findings: [ + FindingSpec("1️⃣", message: "remove trailing comma from the last element in single line collection literal"), + FindingSpec("2️⃣", message: "remove trailing comma from the last element in single line collection literal"), + FindingSpec("3️⃣", message: "add trailing comma to the last element in multiline collection literal"), ] - """ + ) + } + func testTrailingCommaDiagnostics() { assertPrettyPrintEqual( - input: input, expected: input + "\n", linelength: 50, whitespaceOnly: true) + input: """ + let a = [1: "a", 2: "b", 3: "c"1️⃣,] + let a: [Int: String] = [ + 1: "a", 2: "b", 3: "c", 4: "d", 5: "e", 6: "f", + 7: "g", 8: "i"2️⃣ + ] + """, + expected: """ + let a = [1: "a", 2: "b", 3: "c",] + let a: [Int: String] = [ + 1: "a", 2: "b", 3: "c", 4: "d", 5: "e", 6: "f", + 7: "g", 8: "i" + ] - XCTAssertDiagnosed(.removeTrailingComma, line: 1, column: 32) - XCTAssertDiagnosed(.addTrailingComma, line: 4, column: 17) + """, + linelength: 50, + whitespaceOnly: true, + findings: [ + FindingSpec("1️⃣", message: "remove trailing comma from the last element in single line collection literal"), + FindingSpec("2️⃣", message: "add trailing comma to the last element in multiline collection literal"), + ] + ) } func testDiscretionaryNewlineAfterColon() { @@ -251,16 +277,16 @@ final class DictionaryDeclTests: PrettyPrintTestCase { ] """ - // Ideally, this dictionary would be left on 1 line without a trailing comma. We don't know if - // the comma is required when calculating the length of elements, so the comma's length is - // always added to last element and that 1 character causes the newlines inside of the - // dictionary. - + """ - a = [ - k1: ("ab", "z"), k2: ("bc", "y"), - ] + // Ideally, this dictionary would be left on 1 line without a trailing comma. We don't know if + // the comma is required when calculating the length of elements, so the comma's length is + // always added to last element and that 1 character causes the newlines inside of the + // dictionary. + + """ + a = [ + k1: ("ab", "z"), k2: ("bc", "y"), + ] - """ + """ assertPrettyPrintEqual(input: input, expected: expected, linelength: 38) } diff --git a/Tests/SwiftFormatPrettyPrintTests/DifferentiationAttributeTests.swift b/Tests/SwiftFormatTests/PrettyPrint/DifferentiationAttributeTests.swift similarity index 57% rename from Tests/SwiftFormatPrettyPrintTests/DifferentiationAttributeTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/DifferentiationAttributeTests.swift index 5e3cf2b58..3c14742f9 100644 --- a/Tests/SwiftFormatPrettyPrintTests/DifferentiationAttributeTests.swift +++ b/Tests/SwiftFormatTests/PrettyPrint/DifferentiationAttributeTests.swift @@ -96,84 +96,80 @@ final class DifferentiationAttributeTests: PrettyPrintTestCase { } func testDerivative() { - #if HAS_DERIVATIVE_REGISTRATION_ATTRIBUTE - let input = - """ - @derivative(of: foo) - func deriv() {} + let input = + """ + @derivative(of: foo) + func deriv() {} - @derivative(of: foo, wrt: x) - func deriv(_ x: T) {} + @derivative(of: foo, wrt: x) + func deriv(_ x: T) {} - @derivative(of: foobar, wrt: x) - func deriv(_ x: T) {} + @derivative(of: foobar, wrt: x) + func deriv(_ x: T) {} - @derivative(of: foobarbaz, wrt: theVariableNamedX) - func deriv(_ theVariableNamedX: T) {} - """ + @derivative(of: foobarbaz, wrt: theVariableNamedX) + func deriv(_ theVariableNamedX: T) {} + """ - let expected = - """ - @derivative(of: foo) - func deriv() {} + let expected = + """ + @derivative(of: foo) + func deriv() {} - @derivative(of: foo, wrt: x) - func deriv(_ x: T) {} + @derivative(of: foo, wrt: x) + func deriv(_ x: T) {} - @derivative( - of: foobar, wrt: x - ) - func deriv(_ x: T) {} + @derivative( + of: foobar, wrt: x + ) + func deriv(_ x: T) {} - @derivative( - of: foobarbaz, - wrt: theVariableNamedX - ) - func deriv( - _ theVariableNamedX: T - ) {} + @derivative( + of: foobarbaz, + wrt: theVariableNamedX + ) + func deriv( + _ theVariableNamedX: T + ) {} - """ + """ - assertPrettyPrintEqual(input: input, expected: expected, linelength: 28) - #endif + assertPrettyPrintEqual(input: input, expected: expected, linelength: 28) } func testTranspose() { - #if HAS_DERIVATIVE_REGISTRATION_ATTRIBUTE - let input = - """ - @transpose(of: foo, wrt: 0) - func trans(_ v: T) {} - - @transpose(of: foobar, wrt: 0) - func trans(_ v: T) {} - - @transpose(of: someReallyLongName, wrt: 0) - func trans(_ theVariableNamedV: T) {} - """ - - let expected = - """ - @transpose(of: foo, wrt: 0) - func trans(_ v: T) {} - - @transpose( - of: foobar, wrt: 0 - ) - func trans(_ v: T) {} + let input = + """ + @transpose(of: foo, wrt: 0) + func trans(_ v: T) {} - @transpose( - of: someReallyLongName, - wrt: 0 - ) - func trans( - _ theVariableNamedV: T - ) {} + @transpose(of: foobar, wrt: 0) + func trans(_ v: T) {} - """ + @transpose(of: someReallyLongName, wrt: 0) + func trans(_ theVariableNamedV: T) {} + """ + + let expected = + """ + @transpose(of: foo, wrt: 0) + func trans(_ v: T) {} + + @transpose( + of: foobar, wrt: 0 + ) + func trans(_ v: T) {} + + @transpose( + of: someReallyLongName, + wrt: 0 + ) + func trans( + _ theVariableNamedV: T + ) {} + + """ - assertPrettyPrintEqual(input: input, expected: expected, linelength: 27) - #endif + assertPrettyPrintEqual(input: input, expected: expected, linelength: 27) } } diff --git a/Tests/SwiftFormatTests/PrettyPrint/DiscardStmtTests.swift b/Tests/SwiftFormatTests/PrettyPrint/DiscardStmtTests.swift new file mode 100644 index 000000000..172f04ea4 --- /dev/null +++ b/Tests/SwiftFormatTests/PrettyPrint/DiscardStmtTests.swift @@ -0,0 +1,14 @@ +final class DiscardStmtTests: PrettyPrintTestCase { + func testDiscard() { + assertPrettyPrintEqual( + input: """ + discard self + """, + expected: """ + discard self + + """, + linelength: 9 + ) + } +} diff --git a/Tests/SwiftFormatPrettyPrintTests/DoStmtTests.swift b/Tests/SwiftFormatTests/PrettyPrint/DoStmtTests.swift similarity index 68% rename from Tests/SwiftFormatPrettyPrintTests/DoStmtTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/DoStmtTests.swift index f3c458869..2bda1fc27 100644 --- a/Tests/SwiftFormatPrettyPrintTests/DoStmtTests.swift +++ b/Tests/SwiftFormatTests/PrettyPrint/DoStmtTests.swift @@ -1,4 +1,4 @@ -import SwiftFormatConfiguration +import SwiftFormat final class DoStmtTests: PrettyPrintTestCase { func testBasicDoStmt() { @@ -57,5 +57,37 @@ final class DoStmtTests: PrettyPrintTestCase { """ assertPrettyPrintEqual(input: input, expected: expected, linelength: 45) } -} + func testDoTypedThrowsStmt() { + let input = + """ + do throws(FooError) { + foo() + } + """ + + assertPrettyPrintEqual( + input: input, + expected: + """ + do + throws(FooError) { + foo() + } + + """, + linelength: 18 + ) + assertPrettyPrintEqual( + input: input, + expected: + """ + do throws(FooError) { + foo() + } + + """, + linelength: 25 + ) + } +} diff --git a/Tests/SwiftFormatPrettyPrintTests/EnumDeclTests.swift b/Tests/SwiftFormatTests/PrettyPrint/EnumDeclTests.swift similarity index 94% rename from Tests/SwiftFormatPrettyPrintTests/EnumDeclTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/EnumDeclTests.swift index bf015c132..d51fbeae4 100644 --- a/Tests/SwiftFormatPrettyPrintTests/EnumDeclTests.swift +++ b/Tests/SwiftFormatTests/PrettyPrint/EnumDeclTests.swift @@ -1,4 +1,4 @@ -import SwiftFormatConfiguration +import SwiftFormat final class EnumDeclTests: PrettyPrintTestCase { func testBasicEnumDeclarations() { @@ -79,7 +79,7 @@ final class EnumDeclTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachArgument = true assertPrettyPrintEqual(input: input, expected: expected, linelength: 30, configuration: config) } @@ -120,7 +120,7 @@ final class EnumDeclTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachArgument = false assertPrettyPrintEqual(input: input, expected: expected, linelength: 31, configuration: config) } @@ -198,7 +198,7 @@ final class EnumDeclTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachArgument = false assertPrettyPrintEqual(input: input, expected: expected, linelength: 30, configuration: config) } @@ -285,7 +285,7 @@ final class EnumDeclTests: PrettyPrintTestCase { func testEnumWhereClause_lineBreakBeforeEachGenericRequirement() { let input = - """ + """ enum MyEnum where S: Collection { case firstCase let B: Double @@ -301,7 +301,7 @@ final class EnumDeclTests: PrettyPrintTestCase { """ let expected = - """ + """ enum MyEnum where S: Collection { case firstCase let B: Double @@ -324,7 +324,7 @@ final class EnumDeclTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachGenericRequirement = true assertPrettyPrintEqual(input: input, expected: expected, linelength: 60, configuration: config) } @@ -373,7 +373,7 @@ final class EnumDeclTests: PrettyPrintTestCase { func testEnumWhereClauseWithInheritance_lineBreakBeforeEachGenericRequirement() { let input = - """ + """ enum MyEnum: ProtoOne where S: Collection { case firstCase let B: Double @@ -389,7 +389,7 @@ final class EnumDeclTests: PrettyPrintTestCase { """ let expected = - """ + """ enum MyEnum: ProtoOne where S: Collection { case firstCase let B: Double @@ -413,7 +413,7 @@ final class EnumDeclTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachGenericRequirement = true assertPrettyPrintEqual(input: input, expected: expected, linelength: 60, configuration: config) } @@ -494,14 +494,14 @@ final class EnumDeclTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachArgument = false assertPrettyPrintEqual(input: input, expected: expected, linelength: 50, configuration: config) } func testEnumFullWrap_lineBreakBeforeEachGenericRequirement() { let input = - """ + """ public enum MyEnum: MyContainerProtocolOne, MyContainerProtocolTwo, SomeoneElsesContainerProtocol, SomeFrameworkContainerProtocol where BaseCollection: Collection, BaseCollection: P, BaseCollection.Element: Equatable, BaseCollection.Element: SomeOtherProtocol { case firstCase let B: Double @@ -510,7 +510,7 @@ final class EnumDeclTests: PrettyPrintTestCase { let expected = - """ + """ public enum MyEnum< BaseCollection, SecondCollection >: MyContainerProtocolOne, MyContainerProtocolTwo, @@ -528,7 +528,7 @@ final class EnumDeclTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachArgument = false config.lineBreakBeforeEachGenericRequirement = true assertPrettyPrintEqual(input: input, expected: expected, linelength: 50, configuration: config) @@ -559,4 +559,16 @@ final class EnumDeclTests: PrettyPrintTestCase { let input = "enum Foo { var bar: Int }" assertPrettyPrintEqual(input: input, expected: input + "\n", linelength: 50) } + + func testEnumWithPrioritizeKeepingFunctionOutputTogetherFlag() { + let input = """ + enum Error { + case alreadyOpen(Int) + } + + """ + var config = Configuration.forTesting + config.prioritizeKeepingFunctionOutputTogether = true + assertPrettyPrintEqual(input: input, expected: input, linelength: 50, configuration: config) + } } diff --git a/Tests/SwiftFormatPrettyPrintTests/ExtensionDeclTests.swift b/Tests/SwiftFormatTests/PrettyPrint/ExtensionDeclTests.swift similarity index 97% rename from Tests/SwiftFormatPrettyPrintTests/ExtensionDeclTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/ExtensionDeclTests.swift index 632ba07c1..d9b2bbe62 100644 --- a/Tests/SwiftFormatPrettyPrintTests/ExtensionDeclTests.swift +++ b/Tests/SwiftFormatTests/PrettyPrint/ExtensionDeclTests.swift @@ -1,4 +1,4 @@ -import SwiftFormatConfiguration +import SwiftFormat final class ExtensionDeclTests: PrettyPrintTestCase { func testBasicExtensionDeclarations() { @@ -122,7 +122,7 @@ final class ExtensionDeclTests: PrettyPrintTestCase { func testExtensionWhereClause_lineBreakBeforeEachGenericRequirement() { let input = - """ + """ extension MyExtension where S: Collection { let A: Int let B: Double @@ -138,7 +138,7 @@ final class ExtensionDeclTests: PrettyPrintTestCase { """ let expected = - """ + """ extension MyExtension where S: Collection { let A: Int let B: Double @@ -161,7 +161,7 @@ final class ExtensionDeclTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachGenericRequirement = true assertPrettyPrintEqual(input: input, expected: expected, linelength: 70, configuration: config) } @@ -210,7 +210,7 @@ final class ExtensionDeclTests: PrettyPrintTestCase { func testExtensionWhereClauseWithInheritance_lineBreakBeforeEachGenericRequirement() { let input = - """ + """ extension MyExtension: ProtoOne where S: Collection { let A: Int let B: Double @@ -226,7 +226,7 @@ final class ExtensionDeclTests: PrettyPrintTestCase { """ let expected = - """ + """ extension MyExtension: ProtoOne where S: Collection { let A: Int let B: Double @@ -249,7 +249,7 @@ final class ExtensionDeclTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachGenericRequirement = true assertPrettyPrintEqual(input: input, expected: expected, linelength: 70, configuration: config) } @@ -336,7 +336,7 @@ final class ExtensionDeclTests: PrettyPrintTestCase { func testExtensionFullWrap_lineBreakBeforeEachGenericRequirement() { let input = - """ + """ public extension MyContainer: MyContainerProtocolOne, MyContainerProtocolTwo, SomeoneElsesContainerProtocol, SomeFrameworkContainerProtocol where BaseCollection: Collection, BaseCollection: P, BaseCollection.Element: Equatable, BaseCollection.Element: SomeOtherProtocol { let A: Int let B: Double @@ -345,7 +345,7 @@ final class ExtensionDeclTests: PrettyPrintTestCase { let expected = - """ + """ public extension MyContainer: MyContainerProtocolOne, MyContainerProtocolTwo, SomeoneElsesContainerProtocol, @@ -362,7 +362,7 @@ final class ExtensionDeclTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachGenericRequirement = true assertPrettyPrintEqual(input: input, expected: expected, linelength: 50, configuration: config) } diff --git a/Tests/SwiftFormatPrettyPrintTests/ForInStmtTests.swift b/Tests/SwiftFormatTests/PrettyPrint/ForInStmtTests.swift similarity index 100% rename from Tests/SwiftFormatPrettyPrintTests/ForInStmtTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/ForInStmtTests.swift diff --git a/Tests/SwiftFormatPrettyPrintTests/FunctionCallTests.swift b/Tests/SwiftFormatTests/PrettyPrint/FunctionCallTests.swift similarity index 98% rename from Tests/SwiftFormatPrettyPrintTests/FunctionCallTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/FunctionCallTests.swift index c4978ae1d..7f2d10e7e 100644 --- a/Tests/SwiftFormatPrettyPrintTests/FunctionCallTests.swift +++ b/Tests/SwiftFormatTests/PrettyPrint/FunctionCallTests.swift @@ -1,4 +1,4 @@ -import SwiftFormatConfiguration +import SwiftFormat final class FunctionCallTests: PrettyPrintTestCase { func testBasicFunctionCalls_noPackArguments() { @@ -54,7 +54,7 @@ final class FunctionCallTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachArgument = true assertPrettyPrintEqual(input: input, expected: expected, linelength: 45, configuration: config) } @@ -92,7 +92,7 @@ final class FunctionCallTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachArgument = false assertPrettyPrintEqual(input: input, expected: expected, linelength: 45, configuration: config) } @@ -127,7 +127,7 @@ final class FunctionCallTests: PrettyPrintTestCase { ) """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachArgument = false assertPrettyPrintEqual(input: input, expected: expected, linelength: 45, configuration: config) } diff --git a/Tests/SwiftFormatPrettyPrintTests/FunctionDeclTests.swift b/Tests/SwiftFormatTests/PrettyPrint/FunctionDeclTests.swift similarity index 89% rename from Tests/SwiftFormatPrettyPrintTests/FunctionDeclTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/FunctionDeclTests.swift index 34cdb236a..743f11dca 100644 --- a/Tests/SwiftFormatPrettyPrintTests/FunctionDeclTests.swift +++ b/Tests/SwiftFormatTests/PrettyPrint/FunctionDeclTests.swift @@ -1,4 +1,4 @@ -import SwiftFormatConfiguration +import SwiftFormat final class FunctionDeclTests: PrettyPrintTestCase { func testBasicFunctionDeclarations_noPackArguments() { @@ -41,7 +41,7 @@ final class FunctionDeclTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachArgument = true assertPrettyPrintEqual(input: input, expected: expected, linelength: 50, configuration: config) } @@ -84,7 +84,7 @@ final class FunctionDeclTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachArgument = false assertPrettyPrintEqual(input: input, expected: expected, linelength: 50, configuration: config) } @@ -147,7 +147,7 @@ final class FunctionDeclTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachArgument = false assertPrettyPrintEqual(input: input, expected: expected, linelength: 50, configuration: config) } @@ -192,7 +192,7 @@ final class FunctionDeclTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachArgument = false assertPrettyPrintEqual(input: input, expected: expected, linelength: 50, configuration: config) } @@ -239,7 +239,7 @@ final class FunctionDeclTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachArgument = true assertPrettyPrintEqual(input: input, expected: expected, linelength: 40, configuration: config) } @@ -285,7 +285,7 @@ final class FunctionDeclTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachArgument = false assertPrettyPrintEqual(input: input, expected: expected, linelength: 40, configuration: config) } @@ -352,14 +352,14 @@ final class FunctionDeclTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachArgument = false assertPrettyPrintEqual(input: input, expected: expected, linelength: 50, configuration: config) } func testFunctionWhereClause_lineBreakBeforeEachGenericRequirement() { let input = - """ + """ public func index( of element: Element, in collection: Elements ) -> Elements.Index? where Elements.Element == Element { @@ -385,7 +385,7 @@ final class FunctionDeclTests: PrettyPrintTestCase { """ let expected = - """ + """ public func index( of element: Element, in collection: Elements ) -> Elements.Index? @@ -421,7 +421,7 @@ final class FunctionDeclTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachArgument = false config.lineBreakBeforeEachGenericRequirement = true assertPrettyPrintEqual(input: input, expected: expected, linelength: 50, configuration: config) @@ -447,7 +447,6 @@ final class FunctionDeclTests: PrettyPrintTestCase { } """ - let expected = """ func myFun() { @@ -556,69 +555,69 @@ final class FunctionDeclTests: PrettyPrintTestCase { func testFunctionFullWrap() { let input = - """ - @discardableResult @objc - public func index(of element: Element, in collection: Elements) -> Elements.Index? where Element: Foo, Element: Bar, Elements.Element == Element { - let a = 123 - let b = "abc" - } - """ + """ + @discardableResult @objc + public func index(of element: Element, in collection: Elements) -> Elements.Index? where Element: Foo, Element: Bar, Elements.Element == Element { + let a = 123 + let b = "abc" + } + """ let expected = - """ - @discardableResult @objc - public func index< - Elements: Collection, - Element - >( - of element: Element, - in collection: Elements - ) -> Elements.Index? - where - Element: Foo, Element: Bar, - Elements.Element == Element - { - let a = 123 - let b = "abc" - } - - """ + """ + @discardableResult @objc + public func index< + Elements: Collection, + Element + >( + of element: Element, + in collection: Elements + ) -> Elements.Index? + where + Element: Foo, Element: Bar, + Elements.Element == Element + { + let a = 123 + let b = "abc" + } + + """ assertPrettyPrintEqual(input: input, expected: expected, linelength: 30) } func testFunctionFullWrap_lineBreakBeforeEachGenericRequirement() { let input = - """ - @discardableResult @objc - public func index(of element: Element, in collection: Elements) -> Elements.Index? where Element: Foo, Element: Bar, Elements.Element == Element { - let a = 123 - let b = "abc" - } - """ + """ + @discardableResult @objc + public func index(of element: Element, in collection: Elements) -> Elements.Index? where Element: Foo, Element: Bar, Elements.Element == Element { + let a = 123 + let b = "abc" + } + """ let expected = - """ - @discardableResult @objc - public func index< - Elements: Collection, - Element - >( - of element: Element, - in collection: Elements - ) -> Elements.Index? - where - Element: Foo, - Element: Bar, - Elements.Element == Element - { - let a = 123 - let b = "abc" - } - - """ - - var config = Configuration() + """ + @discardableResult @objc + public func index< + Elements: Collection, + Element + >( + of element: Element, + in collection: Elements + ) -> Elements.Index? + where + Element: Foo, + Element: Bar, + Elements.Element == Element + { + let a = 123 + let b = "abc" + } + + """ + + var config = Configuration.forTesting config.lineBreakBeforeEachGenericRequirement = true assertPrettyPrintEqual(input: input, expected: expected, linelength: 30, configuration: config) } @@ -626,7 +625,7 @@ final class FunctionDeclTests: PrettyPrintTestCase { func testEmptyFunction() { let input = "func foo() {}" assertPrettyPrintEqual(input: input, expected: input + "\n", linelength: 50) - + let wrapped = """ func foo() { } @@ -703,7 +702,7 @@ final class FunctionDeclTests: PrettyPrintTestCase { assertPrettyPrintEqual(input: input, expected: expected, linelength: 23) expected = - """ + """ func name(_ x: Int) throws -> R @@ -721,7 +720,7 @@ final class FunctionDeclTests: PrettyPrintTestCase { func testBreaksBeforeOrInsideOutput_prioritizingKeepingOutputTogether() { let input = - """ + """ func name(_ x: Int) throws -> R func name(_ x: Int) throws -> R { @@ -731,7 +730,7 @@ final class FunctionDeclTests: PrettyPrintTestCase { """ var expected = - """ + """ func name( _ x: Int ) throws -> R @@ -744,24 +743,24 @@ final class FunctionDeclTests: PrettyPrintTestCase { } """ - var config = Configuration() + var config = Configuration.forTesting config.prioritizeKeepingFunctionOutputTogether = true assertPrettyPrintEqual(input: input, expected: expected, linelength: 23, configuration: config) expected = - """ - func name( - _ x: Int - ) throws -> R - - func name( - _ x: Int - ) throws -> R { - statement - statement - } + """ + func name( + _ x: Int + ) throws -> R + + func name( + _ x: Int + ) throws -> R { + statement + statement + } - """ + """ assertPrettyPrintEqual(input: input, expected: expected, linelength: 30, configuration: config) assertPrettyPrintEqual(input: input, expected: expected, linelength: 33, configuration: config) } @@ -801,7 +800,7 @@ final class FunctionDeclTests: PrettyPrintTestCase { func testBreaksBeforeOrInsideOutputWithAttributes_prioritizingKeepingOutputTogether() { let input = - """ + """ @objc @discardableResult func name(_ x: Int) throws -> R @@ -813,7 +812,7 @@ final class FunctionDeclTests: PrettyPrintTestCase { """ let expected = - """ + """ @objc @discardableResult func name( @@ -830,7 +829,7 @@ final class FunctionDeclTests: PrettyPrintTestCase { } """ - var config = Configuration() + var config = Configuration.forTesting config.prioritizeKeepingFunctionOutputTogether = true assertPrettyPrintEqual(input: input, expected: expected, linelength: 23, configuration: config) } @@ -894,7 +893,7 @@ final class FunctionDeclTests: PrettyPrintTestCase { func testBreaksBeforeOrInsideOutputWithWhereClause_prioritizingKeepingOutputTogether() { var input = - """ + """ func name(_ x: Int) throws -> R where Foo == Bar func name(_ x: Int) throws -> R where Foo == Bar { @@ -904,7 +903,7 @@ final class FunctionDeclTests: PrettyPrintTestCase { """ var expected = - """ + """ func name( _ x: Int ) throws -> R @@ -919,39 +918,39 @@ final class FunctionDeclTests: PrettyPrintTestCase { } """ - var config = Configuration() + var config = Configuration.forTesting config.prioritizeKeepingFunctionOutputTogether = true assertPrettyPrintEqual(input: input, expected: expected, linelength: 23, configuration: config) input = - """ - func name(_ x: Int) throws -> R where Fooooooo == Barrrrr - - func name(_ x: Int) throws -> R where Fooooooo == Barrrrr { - statement - statement - } - """ + """ + func name(_ x: Int) throws -> R where Fooooooo == Barrrrr - expected = - """ - func name( - _ x: Int - ) throws -> R - where - Fooooooo == Barrrrr - - func name( - _ x: Int - ) throws -> R - where - Fooooooo == Barrrrr - { + func name(_ x: Int) throws -> R where Fooooooo == Barrrrr { statement statement - } + } + """ + + expected = + """ + func name( + _ x: Int + ) throws -> R + where + Fooooooo == Barrrrr - """ + func name( + _ x: Int + ) throws -> R + where + Fooooooo == Barrrrr + { + statement + statement + } + + """ assertPrettyPrintEqual(input: input, expected: expected, linelength: 23, configuration: config) } @@ -980,7 +979,7 @@ final class FunctionDeclTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachArgument = false assertPrettyPrintEqual(input: input, expected: expected, linelength: 35, configuration: config) } @@ -1016,24 +1015,24 @@ final class FunctionDeclTests: PrettyPrintTestCase { func testDoesNotCollapseFunctionParameterAttributes() { let input = - """ - func foo(@ViewBuilder bar: () -> View) { - bar() - } + """ + func foo(@ViewBuilder bar: () -> View) { + bar() + } - """ + """ assertPrettyPrintEqual(input: input, expected: input, linelength: 60) } func testDoesNotCollapseStackedFunctionParameterAttributes() { let input = - """ - func foo(@FakeAttr @ViewBuilder bar: () -> View) { - bar() - } + """ + func foo(@FakeAttr @ViewBuilder bar: () -> View) { + bar() + } - """ + """ assertPrettyPrintEqual(input: input, expected: input, linelength: 80) } @@ -1177,7 +1176,7 @@ final class FunctionDeclTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachArgument = false assertPrettyPrintEqual(input: input, expected: expected, linelength: 49, configuration: config) } @@ -1222,7 +1221,7 @@ final class FunctionDeclTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachArgument = false assertPrettyPrintEqual(input: input, expected: expected, linelength: 49, configuration: config) } diff --git a/Tests/SwiftFormatPrettyPrintTests/FunctionTypeTests.swift b/Tests/SwiftFormatTests/PrettyPrint/FunctionTypeTests.swift similarity index 53% rename from Tests/SwiftFormatPrettyPrintTests/FunctionTypeTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/FunctionTypeTests.swift index d3e78ee4c..8ee6370ee 100644 --- a/Tests/SwiftFormatPrettyPrintTests/FunctionTypeTests.swift +++ b/Tests/SwiftFormatTests/PrettyPrint/FunctionTypeTests.swift @@ -60,6 +60,127 @@ final class FunctionTypeTests: PrettyPrintTestCase { assertPrettyPrintEqual(input: input, expected: expected, linelength: 60) } + func testFunctionTypeAsync() { + let input = + """ + func f(g: (_ somevalue: Int) async -> String?) { + let a = 123 + let b = "abc" + } + func f(g: (currentLevel: Int) async -> String?) { + let a = 123 + let b = "abc" + } + func f(g: (currentLevel: inout Int) async -> String?) { + let a = 123 + let b = "abc" + } + func f(g: (variable1: Int, variable2: Double, variable3: Bool) async -> Double) { + let a = 123 + let b = "abc" + } + func f(g: (variable1: Int, variable2: Double, variable3: Bool, variable4: String) async -> Double) { + let a = 123 + let b = "abc" + } + """ + + let expected = + """ + func f(g: (_ somevalue: Int) async -> String?) { + let a = 123 + let b = "abc" + } + func f(g: (currentLevel: Int) async -> String?) { + let a = 123 + let b = "abc" + } + func f(g: (currentLevel: inout Int) async -> String?) { + let a = 123 + let b = "abc" + } + func f( + g: (variable1: Int, variable2: Double, variable3: Bool) async -> + Double + ) { + let a = 123 + let b = "abc" + } + func f( + g: ( + variable1: Int, variable2: Double, variable3: Bool, + variable4: String + ) async -> Double + ) { + let a = 123 + let b = "abc" + } + + """ + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 66) + } + + func testFunctionTypeAsyncThrows() { + let input = + """ + func f(g: (_ somevalue: Int) async throws -> String?) { + let a = 123 + let b = "abc" + } + func f(g: (currentLevel: Int) async throws -> String?) { + let a = 123 + let b = "abc" + } + func f(g: (currentLevel: inout Int) async throws -> String?) { + let a = 123 + let b = "abc" + } + func f(g: (variable1: Int, variable2: Double, variable3: Bool) async throws -> Double) { + let a = 123 + let b = "abc" + } + func f(g: (variable1: Int, variable2: Double, variable3: Bool, variable4: String) async throws -> Double) { + let a = 123 + let b = "abc" + } + """ + + let expected = + """ + func f(g: (_ somevalue: Int) async throws -> String?) { + let a = 123 + let b = "abc" + } + func f(g: (currentLevel: Int) async throws -> String?) { + let a = 123 + let b = "abc" + } + func f(g: (currentLevel: inout Int) async throws -> String?) { + let a = 123 + let b = "abc" + } + func f( + g: (variable1: Int, variable2: Double, variable3: Bool) async throws -> + Double + ) { + let a = 123 + let b = "abc" + } + func f( + g: ( + variable1: Int, variable2: Double, variable3: Bool, variable4: String + ) async throws -> Double + ) { + let a = 123 + let b = "abc" + } + + """ + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 73) + } + func testFunctionTypeThrows() { let input = """ @@ -84,7 +205,7 @@ final class FunctionTypeTests: PrettyPrintTestCase { let b = "abc" } """ - + let expected = """ func f(g: (_ somevalue: Int) throws -> String?) { @@ -117,7 +238,7 @@ final class FunctionTypeTests: PrettyPrintTestCase { } """ - + assertPrettyPrintEqual(input: input, expected: expected, linelength: 67) } diff --git a/Tests/SwiftFormatPrettyPrintTests/GarbageTextTests.swift b/Tests/SwiftFormatTests/PrettyPrint/GarbageTextTests.swift similarity index 100% rename from Tests/SwiftFormatPrettyPrintTests/GarbageTextTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/GarbageTextTests.swift diff --git a/Tests/SwiftFormatPrettyPrintTests/GuardStmtTests.swift b/Tests/SwiftFormatTests/PrettyPrint/GuardStmtTests.swift similarity index 100% rename from Tests/SwiftFormatPrettyPrintTests/GuardStmtTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/GuardStmtTests.swift diff --git a/Tests/SwiftFormatPrettyPrintTests/IfConfigTests.swift b/Tests/SwiftFormatTests/PrettyPrint/IfConfigTests.swift similarity index 62% rename from Tests/SwiftFormatPrettyPrintTests/IfConfigTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/IfConfigTests.swift index 9547816f0..1b725a94b 100644 --- a/Tests/SwiftFormatPrettyPrintTests/IfConfigTests.swift +++ b/Tests/SwiftFormatTests/PrettyPrint/IfConfigTests.swift @@ -1,4 +1,4 @@ -import SwiftFormatConfiguration +import SwiftFormat final class IfConfigTests: PrettyPrintTestCase { func testBasicIfConfig() { @@ -112,7 +112,7 @@ final class IfConfigTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.indentConditionalCompilationBlocks = false assertPrettyPrintEqual(input: input, expected: expected, linelength: 45, configuration: config) } @@ -163,25 +163,25 @@ final class IfConfigTests: PrettyPrintTestCase { func testInvalidDiscretionaryLineBreaksRemoved() { let input = - """ - #if (canImport(SwiftUI) && - !(os(iOS) && - arch(arm)) && - ((canImport(AppKit) || - canImport(UIKit)) && !os(watchOS))) - conditionalFunc(foo, bar, baz) - #endif - """ - - let expected = - """ - #if (canImport(SwiftUI) && !(os(iOS) && arch(arm)) && ((canImport(AppKit) || canImport(UIKit)) && !os(watchOS))) - conditionalFunc(foo, bar, baz) - #endif - - """ - - assertPrettyPrintEqual(input: input, expected: expected, linelength: 40) + """ + #if (canImport(SwiftUI) && + !(os(iOS) && + arch(arm)) && + ((canImport(AppKit) || + canImport(UIKit)) && !os(watchOS))) + conditionalFunc(foo, bar, baz) + #endif + """ + + let expected = + """ + #if (canImport(SwiftUI) && !(os(iOS) && arch(arm)) && ((canImport(AppKit) || canImport(UIKit)) && !os(watchOS))) + conditionalFunc(foo, bar, baz) + #endif + + """ + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 40) } func testValidDiscretionaryLineBreaksRetained() { @@ -247,10 +247,10 @@ final class IfConfigTests: PrettyPrintTestCase { """ VStack { Text("something") - #if os(iOS) - .iOSSpecificModifier() - #endif - .commonModifier() + #if os(iOS) + .iOSSpecificModifier() + #endif + .commonModifier() } """ @@ -277,13 +277,13 @@ final class IfConfigTests: PrettyPrintTestCase { """ VStack { Text("something") - #if os(iOS) - .iOSSpecificModifier() - .anotherModifier() - .anotherAnotherModifier() - #endif - .commonModifier() - .anotherCommonModifier() + #if os(iOS) + .iOSSpecificModifier() + .anotherModifier() + .anotherAnotherModifier() + #endif + .commonModifier() + .anotherCommonModifier() } """ @@ -299,6 +299,8 @@ final class IfConfigTests: PrettyPrintTestCase { #if os(iOS) || os(watchOS) #if os(iOS) .iOSModifier() + #elseif os(tvOS) + .tvOSModifier() #else .watchOSModifier() #endif @@ -311,14 +313,16 @@ final class IfConfigTests: PrettyPrintTestCase { """ VStack { Text("something") - #if os(iOS) || os(watchOS) - #if os(iOS) - .iOSModifier() - #else - .watchOSModifier() + #if os(iOS) || os(watchOS) + #if os(iOS) + .iOSModifier() + #elseif os(tvOS) + .tvOSModifier() + #else + .watchOSModifier() + #endif + .iOSAndWatchOSModifier() #endif - .iOSAndWatchOSModifier() - #endif } """ @@ -326,7 +330,6 @@ final class IfConfigTests: PrettyPrintTestCase { assertPrettyPrintEqual(input: input, expected: expected, linelength: 45) } - func testPostfixPoundIfAfterVariables() { let input = """ @@ -343,10 +346,10 @@ final class IfConfigTests: PrettyPrintTestCase { """ VStack { textView - #if os(iOS) - .iOSSpecificModifier() - #endif - .commonModifier() + #if os(iOS) + .iOSSpecificModifier() + #endif + .commonModifier() } """ @@ -390,4 +393,168 @@ final class IfConfigTests: PrettyPrintTestCase { assertPrettyPrintEqual(input: input, expected: expected, linelength: 45) } + + func testPostfixPoundIfBetweenOtherModifiers() { + let input = + """ + EmptyView() + .padding([.vertical]) + #if os(iOS) + .iOSSpecificModifier() + .anotherIOSSpecificModifier() + #endif + .commonModifier() + """ + + let expected = + """ + EmptyView() + .padding([.vertical]) + #if os(iOS) + .iOSSpecificModifier() + .anotherIOSSpecificModifier() + #endif + .commonModifier() + + """ + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 45) + } + + func testPostfixPoundIfWithTypeInModifier() { + let input = + """ + EmptyView() + .padding([.vertical]) + #if os(iOS) + .iOSSpecificModifier( + SpecificType() + .onChanged { _ in + // do things + } + .onEnded { _ in + // do things + } + ) + #endif + """ + + let expected = + """ + EmptyView() + .padding([.vertical]) + #if os(iOS) + .iOSSpecificModifier( + SpecificType() + .onChanged { _ in + // do things + } + .onEnded { _ in + // do things + } + ) + #endif + + """ + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 45) + } + + func testPostfixPoundIfNotIndentedIfClosingParenOnOwnLine() { + let input = + """ + SomeFunction( + foo, + bar + ) + #if os(iOS) + .iOSSpecificModifier() + #endif + .commonModifier() + """ + + let expected = + """ + SomeFunction( + foo, + bar + ) + #if os(iOS) + .iOSSpecificModifier() + #endif + .commonModifier() + + """ + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 45) + } + + func testPostfixPoundIfForcesPrecedingClosingParenOntoNewLine() { + let input = + """ + SomeFunction( + foo, + bar) + #if os(iOS) + .iOSSpecificModifier() + #endif + .commonModifier() + """ + + let expected = + """ + SomeFunction( + foo, + bar + ) + #if os(iOS) + .iOSSpecificModifier() + #endif + .commonModifier() + + """ + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 45) + } + + func testPostfixPoundIfInParameterList() { + let input = + """ + print( + 32 + #if true + .foo + #endif + , 22 + ) + + """ + assertPrettyPrintEqual(input: input, expected: input, linelength: 45) + } + + func testNestedPoundIfInSwitchStatement() { + let input = + """ + switch self { + #if os(iOS) || os(tvOS) || os(watchOS) + case .a: + return 40 + #if os(iOS) || os(tvOS) + case .e: + return 30 + #endif + #if os(iOS) + case .g: + return 2 + #endif + #endif + default: + return nil + } + + """ + var configuration = Configuration.forTesting + configuration.indentConditionalCompilationBlocks = false + assertPrettyPrintEqual(input: input, expected: input, linelength: 45, configuration: configuration) + } } diff --git a/Tests/SwiftFormatPrettyPrintTests/IfStmtTests.swift b/Tests/SwiftFormatTests/PrettyPrint/IfStmtTests.swift similarity index 87% rename from Tests/SwiftFormatPrettyPrintTests/IfStmtTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/IfStmtTests.swift index 51fad551e..cddaad53c 100644 --- a/Tests/SwiftFormatPrettyPrintTests/IfStmtTests.swift +++ b/Tests/SwiftFormatTests/PrettyPrint/IfStmtTests.swift @@ -1,4 +1,4 @@ -import SwiftFormatConfiguration +import SwiftFormat import XCTest final class IfStmtTests: PrettyPrintTestCase { @@ -148,11 +148,113 @@ final class IfStmtTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeControlFlowKeywords = true assertPrettyPrintEqual(input: input, expected: expected, linelength: 20, configuration: config) } + func testIfExpression1() { + let input = + """ + func foo() -> Int { + if var1 < var2 { + 23 + } + else if d < e { + 24 + } + else { + 0 + } + } + """ + + let expected = + """ + func foo() -> Int { + if var1 < var2 { + 23 + } else if d < e { + 24 + } else { + 0 + } + } + + """ + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 23) + } + + func testIfExpression2() { + let input = + """ + func foo() -> Int { + let x = if var1 < var2 { + 23 + } + else if d < e { + 24 + } + else { + 0 + } + return x + } + """ + + let expected = + """ + func foo() -> Int { + let x = + if var1 < var2 { + 23 + } else if d < e { + 24 + } else { + 0 + } + return x + } + + """ + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 26) + } + + func testIfExpression3() { + let input = + """ + let x = if a { b } else { c } + xyzab = if a { b } else { c } + """ + assertPrettyPrintEqual(input: input, expected: input + "\n", linelength: 80) + + let expected28 = + """ + let x = + if a { b } else { c } + xyzab = + if a { b } else { c } + + """ + assertPrettyPrintEqual(input: input, expected: expected28, linelength: 28) + + let expected22 = + """ + let x = + if a { b } else { + c + } + xyzab = + if a { b } else { + c + } + + """ + assertPrettyPrintEqual(input: input, expected: expected22, linelength: 22) + } + func testMatchingPatternConditions() { let input = """ diff --git a/Tests/SwiftFormatPrettyPrintTests/IgnoreNodeTests.swift b/Tests/SwiftFormatTests/PrettyPrint/IgnoreNodeTests.swift similarity index 92% rename from Tests/SwiftFormatPrettyPrintTests/IgnoreNodeTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/IgnoreNodeTests.swift index 2537623ba..46fa3f467 100644 --- a/Tests/SwiftFormatPrettyPrintTests/IgnoreNodeTests.swift +++ b/Tests/SwiftFormatTests/PrettyPrint/IgnoreNodeTests.swift @@ -1,5 +1,5 @@ final class IgnoreNodeTests: PrettyPrintTestCase { - func atestIgnoreCodeBlockListItems() { + func testIgnoreCodeBlockListItems() { let input = """ x = 4 + 5 // This comment stays here. @@ -227,7 +227,7 @@ final class IgnoreNodeTests: PrettyPrintTestCase { """ - assertPrettyPrintEqual(input: input, expected: expected, linelength: 50) + assertPrettyPrintEqual(input: input, expected: expected, linelength: 50) } func testValidComment() { @@ -318,36 +318,18 @@ final class IgnoreNodeTests: PrettyPrintTestCase { { var bazzle = 0 } """ + assertPrettyPrintEqual(input: input, expected: input, linelength: 50) + } - let expected = + func testIgnoreWholeFileDoesNotTouchWhitespace() { + let input = """ // swift-format-ignore-file - import Zoo - import Aoo - import foo - - struct Foo { - private var baz: Bool { - return foo + - bar + // poorly placed comment - false - } - - var a = true // line comment - // aligned line comment - var b = false // correct trailing comment - - var c = 0 + - 1 - + (2 + 3) - } - - class Bar - { - var bazzle = 0 } + /// foo bar + \u{0020} + // baz """ - - assertPrettyPrintEqual(input: input, expected: expected, linelength: 50) + assertPrettyPrintEqual(input: input, expected: input, linelength: 100) } func testIgnoreWholeFileInNestedNode() { diff --git a/Tests/SwiftFormatPrettyPrintTests/ImportTests.swift b/Tests/SwiftFormatTests/PrettyPrint/ImportTests.swift similarity index 83% rename from Tests/SwiftFormatPrettyPrintTests/ImportTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/ImportTests.swift index befa073b5..326f36718 100644 --- a/Tests/SwiftFormatPrettyPrintTests/ImportTests.swift +++ b/Tests/SwiftFormatTests/PrettyPrint/ImportTests.swift @@ -7,6 +7,12 @@ final class ImportTests: PrettyPrintTestCase { import class MyModule.MyClass import struct MyModule.MyStruct @testable import testModule + + @_spi( + STP + ) + @testable + import testModule """ let expected = @@ -17,6 +23,8 @@ final class ImportTests: PrettyPrintTestCase { import struct MyModule.MyStruct @testable import testModule + @_spi(STP) @testable import testModule + """ // Imports should not wrap diff --git a/Tests/SwiftFormatTests/PrettyPrint/IndentBlankLinesTests.swift b/Tests/SwiftFormatTests/PrettyPrint/IndentBlankLinesTests.swift new file mode 100644 index 000000000..31f8a1190 --- /dev/null +++ b/Tests/SwiftFormatTests/PrettyPrint/IndentBlankLinesTests.swift @@ -0,0 +1,294 @@ +import SwiftFormat + +final class IndentBlankLinesTests: PrettyPrintTestCase { + func testIndentBlankLinesEnabled() { + let input = + """ + class A { + func foo() -> Int { + return 1 + } + \u{0020}\u{0020} + func bar() -> Int { + return 2 + } + } + """ + + let expected = + """ + class A { + func foo() -> Int { + return 1 + } + \u{0020}\u{0020} + func bar() -> Int { + return 2 + } + } + + """ + var config = Configuration.forTesting + config.indentBlankLines = true + assertPrettyPrintEqual(input: input, expected: expected, linelength: 80, configuration: config) + } + + func testIndentBlankLinesDisabled() { + let input = + """ + class A { + func foo() -> Int { + return 1 + } + \u{0020}\u{0020} + func bar() -> Int { + return 2 + } + } + """ + + let expected = + """ + class A { + func foo() -> Int { + return 1 + } + + func bar() -> Int { + return 2 + } + } + + """ + var config = Configuration.forTesting + config.indentBlankLines = false + assertPrettyPrintEqual(input: input, expected: expected, linelength: 80, configuration: config) + } + + func testLineWithMoreWhitespacesThanIndentation() { + let input = + """ + class A { + func foo() -> Int { + return 1 + } + \u{0020}\u{0020}\u{0020}\u{0020}\u{0020} + func bar() -> Int { + return 2 + } + } + """ + + let expected = + """ + class A { + func foo() -> Int { + return 1 + } + \u{0020}\u{0020} + func bar() -> Int { + return 2 + } + } + + """ + var config = Configuration.forTesting + config.indentBlankLines = true + assertPrettyPrintEqual(input: input, expected: expected, linelength: 80, configuration: config) + } + + func testLineWithFewerWhitespacesThanIndentation() { + let input = + """ + class A { + func foo() -> Int { + return 1 + } + \u{0020} + func bar() -> Int { + return 2 + } + } + """ + + let expected = + """ + class A { + func foo() -> Int { + return 1 + } + \u{0020}\u{0020} + func bar() -> Int { + return 2 + } + } + + """ + var config = Configuration.forTesting + config.indentBlankLines = true + assertPrettyPrintEqual(input: input, expected: expected, linelength: 80, configuration: config) + } + + func testLineWithoutWhitespace() { + let input = + """ + class A { + func foo() -> Int { + return 1 + } + + func bar() -> Int { + return 2 + } + } + """ + + let expected = + """ + class A { + func foo() -> Int { + return 1 + } + \u{0020}\u{0020} + func bar() -> Int { + return 2 + } + } + + """ + var config = Configuration.forTesting + config.indentBlankLines = true + assertPrettyPrintEqual(input: input, expected: expected, linelength: 80, configuration: config) + } + + func testConsecutiveLinesWithMoreWhitespacesThanIndentation() { + let input = + """ + class A { + func foo() -> Int { + return 1 + } + \u{0020}\u{0020}\u{0020}\u{0020}\u{0020} + \u{0020}\u{0020}\u{0020}\u{0020} + func bar() -> Int { + return 2 + } + } + """ + + let expected = + """ + class A { + func foo() -> Int { + return 1 + } + \u{0020}\u{0020} + func bar() -> Int { + return 2 + } + } + + """ + var config = Configuration.forTesting + config.indentBlankLines = true + assertPrettyPrintEqual(input: input, expected: expected, linelength: 80, configuration: config) + } + + func testConsecutiveLinesWithFewerWhitespacesThanIndentation() { + let input = + """ + class A { + func foo() -> Int { + return 1 + } + \u{0020} + + func bar() -> Int { + return 2 + } + } + """ + + let expected = + """ + class A { + func foo() -> Int { + return 1 + } + \u{0020}\u{0020} + func bar() -> Int { + return 2 + } + } + + """ + var config = Configuration.forTesting + config.indentBlankLines = true + assertPrettyPrintEqual(input: input, expected: expected, linelength: 80, configuration: config) + } + + func testConsecutiveLinesWithoutWhitespace() { + let input = + """ + class A { + func foo() -> Int { + return 1 + } + + + func bar() -> Int { + return 2 + } + } + """ + + let expected = + """ + class A { + func foo() -> Int { + return 1 + } + \u{0020}\u{0020} + func bar() -> Int { + return 2 + } + } + + """ + var config = Configuration.forTesting + config.indentBlankLines = true + assertPrettyPrintEqual(input: input, expected: expected, linelength: 80, configuration: config) + } + + func testExpressionsWithUnnecessaryWhitespaces() { + let input = + """ + class A { + func foo() -> Int { + return 1 + } + \u{0020}\u{0020} + func bar() -> Int { + return 2 + } + } + """ + + let expected = + """ + class A { + func foo() -> Int { + return 1 + } + \u{0020}\u{0020} + func bar() -> Int { + return 2 + } + } + + """ + var config = Configuration.forTesting + config.indentBlankLines = true + assertPrettyPrintEqual(input: input, expected: expected, linelength: 80, configuration: config) + } +} diff --git a/Tests/SwiftFormatPrettyPrintTests/InitializerDeclTests.swift b/Tests/SwiftFormatTests/PrettyPrint/InitializerDeclTests.swift similarity index 72% rename from Tests/SwiftFormatPrettyPrintTests/InitializerDeclTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/InitializerDeclTests.swift index 0ca0d75d1..6c15ff53f 100644 --- a/Tests/SwiftFormatPrettyPrintTests/InitializerDeclTests.swift +++ b/Tests/SwiftFormatTests/PrettyPrint/InitializerDeclTests.swift @@ -1,4 +1,4 @@ -import SwiftFormatConfiguration +import SwiftFormat final class InitializerDeclTests: PrettyPrintTestCase { func testBasicInitializerDeclarations_noPackArguments() { @@ -41,7 +41,7 @@ final class InitializerDeclTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachArgument = true assertPrettyPrintEqual(input: input, expected: expected, linelength: 50, configuration: config) } @@ -85,14 +85,14 @@ final class InitializerDeclTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachArgument = false assertPrettyPrintEqual(input: input, expected: expected, linelength: 50, configuration: config) } func testInitializerOptionality() { let input = - """ + """ struct Struct { init? (var1: Int, var2: Double) { print("Hello World") @@ -106,9 +106,9 @@ final class InitializerDeclTests: PrettyPrintTestCase { init!() { let a = "AAAA BBBB CCCC DDDD EEEE FFFF" } } """ - + let expected = - """ + """ struct Struct { init?(var1: Int, var2: Double) { print("Hello World") @@ -128,8 +128,8 @@ final class InitializerDeclTests: PrettyPrintTestCase { } """ - - var config = Configuration() + + var config = Configuration.forTesting config.lineBreakBeforeEachArgument = false assertPrettyPrintEqual(input: input, expected: expected, linelength: 50, configuration: config) } @@ -163,133 +163,133 @@ final class InitializerDeclTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachArgument = false assertPrettyPrintEqual(input: input, expected: expected, linelength: 50, configuration: config) } func testInitializerGenericParameters() { let input = - """ - struct Struct { - init(var1: S, var2: T) { - let a = 123 - print("Hello World") - } - init(var1: ReallyLongTypeName, var2: TypeName) { - let a = 123 - let b = 456 + """ + struct Struct { + init(var1: S, var2: T) { + let a = 123 + print("Hello World") + } + init(var1: ReallyLongTypeName, var2: TypeName) { + let a = 123 + let b = 456 + } } - } - """ + """ let expected = - """ - struct Struct { - init(var1: S, var2: T) { - let a = 123 - print("Hello World") - } - init< - ReallyLongTypeName: Conform, - TypeName - >( - var1: ReallyLongTypeName, - var2: TypeName - ) { - let a = 123 - let b = 456 + """ + struct Struct { + init(var1: S, var2: T) { + let a = 123 + print("Hello World") + } + init< + ReallyLongTypeName: Conform, + TypeName + >( + var1: ReallyLongTypeName, + var2: TypeName + ) { + let a = 123 + let b = 456 + } } - } - """ + """ assertPrettyPrintEqual(input: input, expected: expected, linelength: 40) } func testInitializerWhereClause() { let input = - """ - struct Struct { - public init(element: Element, in collection: Elements) where Elements.Element == Element { - let a = 123 - let b = "abc" - } - public init(element: Element, in collection: Elements) where Elements.Element == Element, Element: P, Element: Equatable { - let a = 123 - let b = "abc" + """ + struct Struct { + public init(element: Element, in collection: Elements) where Elements.Element == Element { + let a = 123 + let b = "abc" + } + public init(element: Element, in collection: Elements) where Elements.Element == Element, Element: P, Element: Equatable { + let a = 123 + let b = "abc" + } } - } - """ + """ let expected = - """ - struct Struct { - public init( - element: Element, in collection: Elements - ) where Elements.Element == Element { - let a = 123 - let b = "abc" - } - public init( - element: Element, in collection: Elements - ) - where - Elements.Element == Element, Element: P, - Element: Equatable - { - let a = 123 - let b = "abc" + """ + struct Struct { + public init( + element: Element, in collection: Elements + ) where Elements.Element == Element { + let a = 123 + let b = "abc" + } + public init( + element: Element, in collection: Elements + ) + where + Elements.Element == Element, Element: P, + Element: Equatable + { + let a = 123 + let b = "abc" + } } - } - """ + """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachArgument = false assertPrettyPrintEqual(input: input, expected: expected, linelength: 50, configuration: config) } func testInitializerWhereClause_lineBreakBeforeEachGenericRequirement() { let input = - """ - struct Struct { - public init(element: Element, in collection: Elements) where Elements.Element == Element { - let a = 123 - let b = "abc" - } - public init(element: Element, in collection: Elements) where Elements.Element == Element, Element: P, Element: Equatable { - let a = 123 - let b = "abc" + """ + struct Struct { + public init(element: Element, in collection: Elements) where Elements.Element == Element { + let a = 123 + let b = "abc" + } + public init(element: Element, in collection: Elements) where Elements.Element == Element, Element: P, Element: Equatable { + let a = 123 + let b = "abc" + } } - } - """ + """ let expected = - """ - struct Struct { - public init( - element: Element, in collection: Elements - ) where Elements.Element == Element { - let a = 123 - let b = "abc" - } - public init( - element: Element, in collection: Elements - ) - where - Elements.Element == Element, - Element: P, - Element: Equatable - { - let a = 123 - let b = "abc" + """ + struct Struct { + public init( + element: Element, in collection: Elements + ) where Elements.Element == Element { + let a = 123 + let b = "abc" + } + public init( + element: Element, in collection: Elements + ) + where + Elements.Element == Element, + Element: P, + Element: Equatable + { + let a = 123 + let b = "abc" + } } - } - """ + """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachArgument = false config.lineBreakBeforeEachGenericRequirement = true assertPrettyPrintEqual(input: input, expected: expected, linelength: 50, configuration: config) @@ -369,14 +369,14 @@ final class InitializerDeclTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachArgument = false assertPrettyPrintEqual(input: input, expected: expected, linelength: 40, configuration: config) } func testInitializerFullWrap_lineBreakBeforeEachGenericRequirement() { let input = - """ + """ struct Struct { @objc @inlinable public init(element: Element, in collection: Elements) where Elements.Element == Element, Element: Equatable, Element: P { let a = 123 @@ -386,7 +386,7 @@ final class InitializerDeclTests: PrettyPrintTestCase { """ let expected = - """ + """ struct Struct { @objc @inlinable public init< @@ -407,7 +407,7 @@ final class InitializerDeclTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachArgument = false config.lineBreakBeforeEachGenericRequirement = true assertPrettyPrintEqual(input: input, expected: expected, linelength: 40, configuration: config) @@ -422,7 +422,7 @@ final class InitializerDeclTests: PrettyPrintTestCase { } """ assertPrettyPrintEqual(input: input, expected: input + "\n", linelength: 50) - + let wrapped = """ struct X { // diff --git a/Tests/SwiftFormatTests/PrettyPrint/KeyPathExprTests.swift b/Tests/SwiftFormatTests/PrettyPrint/KeyPathExprTests.swift new file mode 100644 index 000000000..8573d4be2 --- /dev/null +++ b/Tests/SwiftFormatTests/PrettyPrint/KeyPathExprTests.swift @@ -0,0 +1,168 @@ +final class KeyPathExprTests: PrettyPrintTestCase { + func testSimple() { + let input = + #""" + let x = \.foo + let y = \.foo.bar + let z = a.map(\.foo.bar) + """# + + let expected = + #""" + let x = \.foo + let y = \.foo.bar + let z = a.map(\.foo.bar) + + """# + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 80) + } + + func testWithType() { + let input = + #""" + let x = \Type.foo + let y = \Type.foo.bar + let z = a.map(\Type.foo.bar) + """# + + let expected = + #""" + let x = \Type.foo + let y = \Type.foo.bar + let z = a.map(\Type.foo.bar) + + """# + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 80) + } + + func testOptionalUnwrap() { + let input = + #""" + let x = \.foo? + let y = \.foo!.bar + let z = a.map(\.foo!.bar) + """# + + let expected80 = + #""" + let x = \.foo? + let y = \.foo!.bar + let z = a.map(\.foo!.bar) + + """# + + assertPrettyPrintEqual(input: input, expected: expected80, linelength: 80) + + let expected11 = + #""" + let x = + \.foo? + let y = + \.foo! + .bar + let z = + a.map( + \.foo! + .bar) + + """# + + assertPrettyPrintEqual(input: input, expected: expected11, linelength: 11) + } + + func testSubscript() { + let input = + #""" + let x = \.foo[0] + let y = \.foo[0].bar + let z = a.map(\.foo[0].bar) + """# + + let expected = + #""" + let x = \.foo[0] + let y = \.foo[0].bar + let z = a.map(\.foo[0].bar) + + """# + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 80) + } + + func testImplicitSelfUnwrap() { + let input = + #""" + let x = \.?.foo + let y = \.?.foo.bar + let z = a.map(\.?.foo.bar) + """# + + let expected80 = + #""" + let x = \.?.foo + let y = \.?.foo.bar + let z = a.map(\.?.foo.bar) + + """# + + assertPrettyPrintEqual(input: input, expected: expected80, linelength: 80) + + let expected11 = + #""" + let x = + \.?.foo + let y = + \.?.foo + .bar + let z = + a.map( + \.?.foo + .bar) + + """# + + assertPrettyPrintEqual(input: input, expected: expected11, linelength: 11) + } + + func testWrapping() { + let input = + #""" + let x = \ReallyLongType.reallyLongProperty.anotherLongProperty + let x = \.reeeeallyLongProperty.anotherLongProperty + let x = \.longProperty.a.b.c[really + long + expression] + let x = \.longProperty.a.b.c[really + long + expression].anotherLongProperty + let x = \.longProperty.a.b.c[label:really + long + expression].anotherLongProperty + """# + + let expected = + #""" + let x = + \ReallyLongType + .reallyLongProperty + .anotherLongProperty + let x = + \.reeeeallyLongProperty + .anotherLongProperty + let x = + \.longProperty.a.b.c[ + really + long + + expression] + let x = + \.longProperty.a.b.c[ + really + long + + expression + ].anotherLongProperty + let x = + \.longProperty.a.b.c[ + label: really + + long + + expression + ].anotherLongProperty + + """# + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 23) + } +} diff --git a/Tests/SwiftFormatTests/PrettyPrint/LineNumbersTests.swift b/Tests/SwiftFormatTests/PrettyPrint/LineNumbersTests.swift new file mode 100644 index 000000000..c377e7cad --- /dev/null +++ b/Tests/SwiftFormatTests/PrettyPrint/LineNumbersTests.swift @@ -0,0 +1,118 @@ +import SwiftFormat +import _SwiftFormatTestSupport + +final class LineNumbersTests: PrettyPrintTestCase { + func testLineNumbers() { + let input = + """ + final class A { + @Test func b() throws { + doSomethingInAFunctionWithAVeryLongName() 1️⃣// Here we have a very long comment that should not be here because it is far too long + } + } + """ + + let expected = + """ + final class A { + @Test func b() throws { + doSomethingInAFunctionWithAVeryLongName() // Here we have a very long comment that should not be here because it is far too long + } + } + + """ + + assertPrettyPrintEqual( + input: input, + expected: expected, + linelength: 120, + whitespaceOnly: true, + findings: [ + FindingSpec("1️⃣", message: "move end-of-line comment that exceeds the line length") + ] + ) + } + + func testLineNumbersWithComments() { + let input = + """ + // Copyright (C) 2024 My Coorp. All rights reserved. + // + // This document is the property of My Coorp. + // It is considered confidential and proprietary. + // + // This document may not be reproduced or transmitted in any form, + // in whole or in part, without the express written permission of + // My Coorp. + + final class A { + @Test func b() throws { + doSomethingInAFunctionWithAVeryLongName() 1️⃣// Here we have a very long comment that should not be here because it is far too long + } + } + """ + + let expected = + """ + // Copyright (C) 2024 My Coorp. All rights reserved. + // + // This document is the property of My Coorp. + // It is considered confidential and proprietary. + // + // This document may not be reproduced or transmitted in any form, + // in whole or in part, without the express written permission of + // My Coorp. + + final class A { + @Test func b() throws { + doSomethingInAFunctionWithAVeryLongName() // Here we have a very long comment that should not be here because it is far too long + } + } + + """ + + assertPrettyPrintEqual( + input: input, + expected: expected, + linelength: 120, + whitespaceOnly: true, + findings: [ + FindingSpec("1️⃣", message: "move end-of-line comment that exceeds the line length") + ] + ) + } + + func testCharacterVsCodepoint() { + let input = + """ + let fo = 1 // 🤥 + + """ + + assertPrettyPrintEqual( + input: input, + expected: input, + linelength: 16, + whitespaceOnly: true, + findings: [] + ) + } + + func testCharacterVsCodepointMultiline() { + let input = + #""" + /// This is a multiline + /// comment that is in 🤥 + /// fact perfectly sized + + """# + + assertPrettyPrintEqual( + input: input, + expected: input, + linelength: 25, + whitespaceOnly: true, + findings: [] + ) + } +} diff --git a/Tests/SwiftFormatTests/PrettyPrint/MacroCallTests.swift b/Tests/SwiftFormatTests/PrettyPrint/MacroCallTests.swift new file mode 100644 index 000000000..0da0718ce --- /dev/null +++ b/Tests/SwiftFormatTests/PrettyPrint/MacroCallTests.swift @@ -0,0 +1,129 @@ +import SwiftFormat + +final class MacroCallTests: PrettyPrintTestCase { + func testNoWhiteSpaceAfterMacroWithoutTrailingClosure() { + let input = + """ + func myFunction() { + print("Currently running \\(#function)") + } + + """ + + let expected = + """ + func myFunction() { + print("Currently running \\(#function)") + } + + """ + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 50) + } + + func testKeepWhiteSpaceBeforeTrailingClosure() { + let input = + """ + #Preview {} + #Preview("MyPreview") { + MyView() + } + let p = #Predicate { $0 == 0 } + """ + + let expected = + """ + #Preview {} + #Preview("MyPreview") { + MyView() + } + let p = #Predicate { $0 == 0 } + + """ + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 40) + } + + func testInsertWhiteSpaceBeforeTrailingClosure() { + let input = + """ + #Preview{} + #Preview("MyPreview"){ + MyView() + } + let p = #Predicate{ $0 == 0 } + """ + + let expected = + """ + #Preview {} + #Preview("MyPreview") { + MyView() + } + let p = #Predicate { $0 == 0 } + + """ + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 40) + } + + func testDiscretionaryLineBreakBeforeTrailingClosure() { + let input = + """ + #Preview("MyPreview") + { + MyView() + } + #Preview( + "MyPreview", traits: .landscapeLeft + ) + { + MyView() + } + #Preview("MyPreview", traits: .landscapeLeft, .sizeThatFitsLayout) + { + MyView() + } + #Preview("MyPreview", traits: .landscapeLeft) { + MyView() + } + """ + + let expected = + """ + #Preview("MyPreview") { + MyView() + } + #Preview( + "MyPreview", traits: .landscapeLeft + ) { + MyView() + } + #Preview( + "MyPreview", traits: .landscapeLeft, + .sizeThatFitsLayout + ) { + MyView() + } + #Preview("MyPreview", traits: .landscapeLeft) + { + MyView() + } + + """ + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 45) + } + + func testMacroDeclWithAttributesAndArguments() { + let input = """ + @nonsenseAttribute + @available(iOS 17.0, *) + #Preview("Name") { + EmptyView() + } + + """ + assertPrettyPrintEqual(input: input, expected: input, linelength: 45) + } +} diff --git a/Tests/SwiftFormatTests/PrettyPrint/MacroDeclTests.swift b/Tests/SwiftFormatTests/PrettyPrint/MacroDeclTests.swift new file mode 100644 index 000000000..2182b9dc4 --- /dev/null +++ b/Tests/SwiftFormatTests/PrettyPrint/MacroDeclTests.swift @@ -0,0 +1,397 @@ +import SwiftFormat + +final class MacroDeclTests: PrettyPrintTestCase { + func testBasicMacroDeclarations_noPackArguments() { + let input = + """ + macro myFun(var1: Int, var2: Double) = #externalMacro(module: "Foo", type: "Bar") + macro reallyLongName(var1: Int, var2: Double, var3: Bool) = #externalMacro(module: "Foo", type: "Bar") + macro myFun() = #externalMacro(module: "Foo", type: "Bar") + """ + + let expected = + """ + macro myFun(var1: Int, var2: Double) = + #externalMacro(module: "Foo", type: "Bar") + macro reallyLongName( + var1: Int, + var2: Double, + var3: Bool + ) = #externalMacro(module: "Foo", type: "Bar") + macro myFun() = #externalMacro(module: "Foo", type: "Bar") + + """ + + var config = Configuration.forTesting + config.lineBreakBeforeEachArgument = true + assertPrettyPrintEqual(input: input, expected: expected, linelength: 58, configuration: config) + } + + func testBasicMacroDeclarations_packArguments() { + let input = + """ + macro myFun(var1: Int, var2: Double) = #externalMacro(module: "Foo", type: "Bar") + macro reallyLongName(var1: Int, var2: Double, var3: Bool) = #externalMacro(module: "Foo", type: "Bar") + macro myFun() = #externalMacro(module: "Foo", type: "Bar") + """ + + let expected = + """ + macro myFun(var1: Int, var2: Double) = + #externalMacro(module: "Foo", type: "Bar") + macro reallyLongName( + var1: Int, var2: Double, var3: Bool + ) = #externalMacro(module: "Foo", type: "Bar") + macro myFun() = #externalMacro(module: "Foo", type: "Bar") + + """ + + var config = Configuration.forTesting + config.lineBreakBeforeEachArgument = false + assertPrettyPrintEqual(input: input, expected: expected, linelength: 58, configuration: config) + } + + func testMacroDeclReturns() { + let input = + """ + macro myFun(var1: Int, var2: Double) -> Double = #externalMacro(module: "Foo", type: "Bar") + macro reallyLongName(var1: Int, var2: Double, var3: Bool) -> Double = #externalMacro(module: "Foo", type: "Bar") + macro reallyReallyLongName(var1: Int, var2: Double, var3: Bool) -> Double = #externalMacro(module: "Foo", type: "Bar") + macro tupleFunc() -> (one: Int, two: Double, three: Bool, four: String) = #externalMacro(module: "Foo", type: "Bar") + macro memberTypeReallyReallyLongNameFunc() -> Type.InnerMember = #externalMacro(module: "Foo", type: "Bar") + macro tupleMembersReallyLongNameFunc() -> (Type.Inner, Type2.Inner2) = #externalMacro(module: "Foo", type: "Bar") + """ + + let expected = + """ + macro myFun(var1: Int, var2: Double) -> Double = + #externalMacro(module: "Foo", type: "Bar") + macro reallyLongName(var1: Int, var2: Double, var3: Bool) + -> Double = #externalMacro(module: "Foo", type: "Bar") + macro reallyReallyLongName( + var1: Int, var2: Double, var3: Bool + ) -> Double = #externalMacro(module: "Foo", type: "Bar") + macro tupleFunc() -> ( + one: Int, two: Double, three: Bool, four: String + ) = #externalMacro(module: "Foo", type: "Bar") + macro memberTypeReallyReallyLongNameFunc() + -> Type.InnerMember = + #externalMacro(module: "Foo", type: "Bar") + macro tupleMembersReallyLongNameFunc() -> ( + Type.Inner, Type2.Inner2 + ) = #externalMacro(module: "Foo", type: "Bar") + + """ + + var config = Configuration.forTesting + config.lineBreakBeforeEachArgument = false + assertPrettyPrintEqual(input: input, expected: expected, linelength: 58, configuration: config) + } + + func testMacroGenericParameters_noPackArguments() { + let input = + """ + macro myFun(var1: S, var2: T) = #externalMacro(module: "Foo", type: "Bar") + macro myFun(var1: S) = #externalMacro(module: "Foo", type: "Bar") + macro longerNameFun(var1: ReallyLongTypeName, var2: TypeName) = #externalMacro(module: "Foo", type: "Bar") + """ + + let expected = + """ + macro myFun(var1: S, var2: T) = + #externalMacro(module: "Foo", type: "Bar") + macro myFun(var1: S) = + #externalMacro(module: "Foo", type: "Bar") + macro longerNameFun< + ReallyLongTypeName: Conform, + TypeName + >( + var1: ReallyLongTypeName, + var2: TypeName + ) = + #externalMacro(module: "Foo", type: "Bar") + + """ + + var config = Configuration.forTesting + config.lineBreakBeforeEachArgument = true + assertPrettyPrintEqual(input: input, expected: expected, linelength: 44, configuration: config) + } + + func testMacroGenericParameters_packArguments() { + let input = + """ + macro myFun(var1: S, var2: T) = #externalMacro(module: "Foo", type: "Bar") + macro myFun(var1: S) = #externalMacro(module: "Foo", type: "Bar") + macro longerNameFun(var1: ReallyLongTypeName, var2: TypeName) = #externalMacro(module: "Foo", type: "Bar") + """ + + let expected = + """ + macro myFun(var1: S, var2: T) = + #externalMacro(module: "Foo", type: "Bar") + macro myFun(var1: S) = + #externalMacro(module: "Foo", type: "Bar") + macro longerNameFun< + ReallyLongTypeName: Conform, TypeName + >( + var1: ReallyLongTypeName, var2: TypeName + ) = + #externalMacro(module: "Foo", type: "Bar") + + """ + + var config = Configuration.forTesting + config.lineBreakBeforeEachArgument = false + assertPrettyPrintEqual(input: input, expected: expected, linelength: 44, configuration: config) + } + + func testMacroWhereClause() { + let input = + """ + macro index( + of element: Element, in collection: Elements + ) -> Elements.Index? = #externalMacro(module: "Foo", type: "Bar") where Elements.Element == Element + + macro index( + of element: Element, + in collection: Elements + ) -> Elements.Index? = #externalMacro(module: "Foo", type: "Bar") where Elements.Element == Element, Element: Equatable + + macro index( + of element: Element, + in collection: Elements + ) -> Elements.Index? = #externalMacro(module: "Foo", type: "Bar") where Elements.Element == Element, Element: Equatable, Element: ReallyLongProtocolName + """ + + let expected = + """ + macro index( + of element: Element, in collection: Elements + ) -> Elements.Index? = + #externalMacro(module: "Foo", type: "Bar") + where Elements.Element == Element + + macro index( + of element: Element, + in collection: Elements + ) -> Elements.Index? = + #externalMacro(module: "Foo", type: "Bar") + where + Elements.Element == Element, Element: Equatable + + macro index( + of element: Element, + in collection: Elements + ) -> Elements.Index? = + #externalMacro(module: "Foo", type: "Bar") + where + Elements.Element == Element, Element: Equatable, + Element: ReallyLongProtocolName + + """ + + var config = Configuration.forTesting + config.lineBreakBeforeEachArgument = false + assertPrettyPrintEqual(input: input, expected: expected, linelength: 51, configuration: config) + } + + func testMacroWhereClause_lineBreakBeforeEachGenericRequirement() { + let input = + """ + public macro index( + of element: Element, in collection: Elements + ) -> Elements.Index? = #externalMacro(module: "Foo", type: "Bar") where Elements.Element == Element + + public macro index( + of element: Element, + in collection: Elements + ) -> Elements.Index? = #externalMacro(module: "Foo", type: "Bar") where Elements.Element == Element, Element: Equatable + + public macro index( + of element: Element, + in collection: Elements + ) -> Elements.Index? = #externalMacro(module: "Foo", type: "Bar") where Elements.Element == Element, Element: Equatable, Element: ReallyLongProtocolName + """ + + let expected = + """ + public macro index( + of element: Element, in collection: Elements + ) -> Elements.Index? = + #externalMacro(module: "Foo", type: "Bar") + where Elements.Element == Element + + public macro index( + of element: Element, + in collection: Elements + ) -> Elements.Index? = + #externalMacro(module: "Foo", type: "Bar") + where + Elements.Element == Element, + Element: Equatable + + public macro index( + of element: Element, + in collection: Elements + ) -> Elements.Index? = + #externalMacro(module: "Foo", type: "Bar") + where + Elements.Element == Element, + Element: Equatable, + Element: ReallyLongProtocolName + + """ + + var config = Configuration.forTesting + config.lineBreakBeforeEachArgument = false + config.lineBreakBeforeEachGenericRequirement = true + assertPrettyPrintEqual(input: input, expected: expected, linelength: 50, configuration: config) + } + + func testMacroAttributes() { + let input = + """ + @attached(accessor) public macro MyFun() = #externalMacro(module: "Foo", type: "Bar") + @attached(accessor) @attached(memberAttribute) public macro MyFun() = #externalMacro(module: "Foo", type: "Bar") + @attached(accessor) @attached(member, names: named(_storage)) public macro MyFun() = #externalMacro(module: "Foo", type: "Bar") + @attached(accessor) + @attached(member, names: named(_storage)) + public macro MyFun() = #externalMacro(module: "Foo", type: "Bar") + """ + + let expected = + """ + @attached(accessor) public macro MyFun() = + #externalMacro(module: "Foo", type: "Bar") + @attached(accessor) @attached(memberAttribute) public macro MyFun() = + #externalMacro(module: "Foo", type: "Bar") + @attached(accessor) @attached(member, names: named(_storage)) + public macro MyFun() = #externalMacro(module: "Foo", type: "Bar") + @attached(accessor) + @attached(member, names: named(_storage)) + public macro MyFun() = #externalMacro(module: "Foo", type: "Bar") + + """ + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 69) + } + + func testMacroDeclWithoutDefinition() { + let input = + """ + macro myFun() + macro myFun(arg1: Int) + macro myFun() -> Int + macro myFun(arg1: Int) + macro myFun(arg1: Int) where T: S + """ + + let expected = + """ + macro myFun() + macro myFun(arg1: Int) + macro myFun() -> Int + macro myFun(arg1: Int) + macro myFun(arg1: Int) where T: S + + """ + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 50) + } + + func testBreaksBeforeOrInsideOutput() { + let input = + """ + macro name(_ x: Int) -> R + """ + + let expected = + """ + macro name(_ x: Int) + -> R + + """ + assertPrettyPrintEqual(input: input, expected: expected, linelength: 23) + assertPrettyPrintEqual(input: input, expected: expected, linelength: 24) + assertPrettyPrintEqual(input: input, expected: expected, linelength: 27) + } + + func testBreaksBeforeOrInsideOutput_prioritizingKeepingOutputTogether() { + let input = + """ + macro name(_ x: Int) -> R + """ + + let expected = + """ + macro name( + _ x: Int + ) -> R + + """ + var config = Configuration.forTesting + config.prioritizeKeepingFunctionOutputTogether = true + assertPrettyPrintEqual(input: input, expected: expected, linelength: 23, configuration: config) + assertPrettyPrintEqual(input: input, expected: expected, linelength: 24, configuration: config) + assertPrettyPrintEqual(input: input, expected: expected, linelength: 27, configuration: config) + } + + func testBreaksBeforeOrInsideOutputWithAttributes() { + let input = + """ + @attached(member) @attached(memberAttribute) + macro name(_ x: Int) -> R + """ + + let expected = + """ + @attached(member) + @attached(memberAttribute) + macro name(_ x: Int) + -> R + + """ + assertPrettyPrintEqual(input: input, expected: expected, linelength: 26) + } + + func testBreaksBeforeOrInsideOutputWithAttributes_prioritizingKeepingOutputTogether() { + let input = + """ + @attached(member) @attached(memberAttribute) + macro name(_ x: Int) -> R + """ + + let expected = + """ + @attached(member) + @attached(memberAttribute) + macro name( + _ x: Int + ) -> R + + """ + var config = Configuration.forTesting + config.prioritizeKeepingFunctionOutputTogether = true + assertPrettyPrintEqual(input: input, expected: expected, linelength: 26, configuration: config) + } + + func testDoesNotBreakInsideEmptyParens() { + // If the macro name is so long that the parentheses of a no-argument parameter list would + // be pushed past the margin, don't break inside them. + let input = + """ + macro fooBarBaz() + + """ + + let expected = + """ + macro + fooBarBaz() + + """ + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 16) + } +} diff --git a/Tests/SwiftFormatPrettyPrintTests/MemberAccessExprTests.swift b/Tests/SwiftFormatTests/PrettyPrint/MemberAccessExprTests.swift similarity index 88% rename from Tests/SwiftFormatPrettyPrintTests/MemberAccessExprTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/MemberAccessExprTests.swift index f1539bba2..8ab588c04 100644 --- a/Tests/SwiftFormatPrettyPrintTests/MemberAccessExprTests.swift +++ b/Tests/SwiftFormatTests/PrettyPrint/MemberAccessExprTests.swift @@ -1,4 +1,4 @@ -import SwiftFormatConfiguration +import SwiftFormat final class MemberAccessExprTests: PrettyPrintTestCase { func testMemberAccess() { @@ -118,11 +118,14 @@ final class MemberAccessExprTests: PrettyPrintTestCase { """ - var configuration = Configuration() + var configuration = Configuration.forTesting configuration.lineBreakAroundMultilineExpressionChainComponents = true assertPrettyPrintEqual( - input: input, expected: expectedWithForcedBreaks, linelength: 20, - configuration: configuration) + input: input, + expected: expectedWithForcedBreaks, + linelength: 20, + configuration: configuration + ) } func testContinuationRestorationAfterGroup() { @@ -235,11 +238,14 @@ final class MemberAccessExprTests: PrettyPrintTestCase { """ - var configuration = Configuration() + var configuration = Configuration.forTesting configuration.lineBreakAroundMultilineExpressionChainComponents = true assertPrettyPrintEqual( - input: input, expected: expectedWithForcedBreaking, linelength: 35, - configuration: configuration) + input: input, + expected: expectedWithForcedBreaking, + linelength: 35, + configuration: configuration + ) } func testMemberItemClosureChaining() { @@ -329,33 +335,36 @@ final class MemberAccessExprTests: PrettyPrintTestCase { """ - var configuration = Configuration() + var configuration = Configuration.forTesting configuration.lineBreakAroundMultilineExpressionChainComponents = true assertPrettyPrintEqual( - input: input, expected: expectedWithForcedBreaks, linelength: 50, - configuration: configuration) + input: input, + expected: expectedWithForcedBreaks, + linelength: 50, + configuration: configuration + ) } func testChainedTrailingClosureMethods() { let input = """ - var button = View.Button { Text("ABC") }.action { presentAction() }.background(.red).text(.blue).text(.red).font(.appleSans) - var button = View.Button { - // comment #0 - Text("ABC") - }.action { presentAction() }.background(.red).text(.blue).text(.red).font(.appleSans) - var button = View.Button { Text("ABC") } - .action { presentAction() }.background(.red).text(.blue) .text(.red).font(.appleSans) - var button = View.Button { Text("ABC") } - .action { - // comment #1 - presentAction() // comment #2 - }.background(.red).text(.blue) .text(.red).font(.appleSans) /* trailing comment */ - var button = View.Button { Text("ABC") }.action { presentAction() }.background(.red).text(.blue).text(.red).font(.appleSans).foo { - abc in - return abc.foo.bar - } - """ + var button = View.Button { Text("ABC") }.action { presentAction() }.background(.red).text(.blue).text(.red).font(.appleSans) + var button = View.Button { + // comment #0 + Text("ABC") + }.action { presentAction() }.background(.red).text(.blue).text(.red).font(.appleSans) + var button = View.Button { Text("ABC") } + .action { presentAction() }.background(.red).text(.blue) .text(.red).font(.appleSans) + var button = View.Button { Text("ABC") } + .action { + // comment #1 + presentAction() // comment #2 + }.background(.red).text(.blue) .text(.red).font(.appleSans) /* trailing comment */ + var button = View.Button { Text("ABC") }.action { presentAction() }.background(.red).text(.blue).text(.red).font(.appleSans).foo { + abc in + return abc.foo.bar + } + """ let expectedNoForcedBreaks = """ @@ -422,11 +431,14 @@ final class MemberAccessExprTests: PrettyPrintTestCase { """ - var configuration = Configuration() + var configuration = Configuration.forTesting configuration.lineBreakAroundMultilineExpressionChainComponents = true assertPrettyPrintEqual( - input: input, expected: expectedWithForcedBreaks, linelength: 50, - configuration: configuration) + input: input, + expected: expectedWithForcedBreaks, + linelength: 50, + configuration: configuration + ) } func testChainedSubscriptExprs() { @@ -511,10 +523,13 @@ final class MemberAccessExprTests: PrettyPrintTestCase { """ - var configuration = Configuration() + var configuration = Configuration.forTesting configuration.lineBreakAroundMultilineExpressionChainComponents = true assertPrettyPrintEqual( - input: input, expected: expectedWithForcedBreaks, linelength: 50, - configuration: configuration) + input: input, + expected: expectedWithForcedBreaks, + linelength: 50, + configuration: configuration + ) } } diff --git a/Tests/SwiftFormatPrettyPrintTests/MemberTypeIdentifierTests.swift b/Tests/SwiftFormatTests/PrettyPrint/MemberTypeIdentifierTests.swift similarity index 100% rename from Tests/SwiftFormatPrettyPrintTests/MemberTypeIdentifierTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/MemberTypeIdentifierTests.swift diff --git a/Tests/SwiftFormatPrettyPrintTests/NewlineTests.swift b/Tests/SwiftFormatTests/PrettyPrint/NewlineTests.swift similarity index 100% rename from Tests/SwiftFormatPrettyPrintTests/NewlineTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/NewlineTests.swift diff --git a/Tests/SwiftFormatPrettyPrintTests/ObjectLiteralExprTests.swift b/Tests/SwiftFormatTests/PrettyPrint/ObjectLiteralExprTests.swift similarity index 94% rename from Tests/SwiftFormatPrettyPrintTests/ObjectLiteralExprTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/ObjectLiteralExprTests.swift index 407fbf518..c735320b4 100644 --- a/Tests/SwiftFormatPrettyPrintTests/ObjectLiteralExprTests.swift +++ b/Tests/SwiftFormatTests/PrettyPrint/ObjectLiteralExprTests.swift @@ -1,4 +1,4 @@ -import SwiftFormatConfiguration +import SwiftFormat final class ObjectLiteralExprTests: PrettyPrintTestCase { func testColorLiteral_noPackArguments() { @@ -27,7 +27,7 @@ final class ObjectLiteralExprTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachArgument = true assertPrettyPrintEqual(input: input, expected: expected, linelength: 25, configuration: config) } @@ -53,7 +53,7 @@ final class ObjectLiteralExprTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachArgument = false assertPrettyPrintEqual(input: input, expected: expected, linelength: 25, configuration: config) } @@ -80,7 +80,7 @@ final class ObjectLiteralExprTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachArgument = true assertPrettyPrintEqual(input: input, expected: expected, linelength: 38, configuration: config) } @@ -106,7 +106,7 @@ final class ObjectLiteralExprTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachArgument = false assertPrettyPrintEqual(input: input, expected: expected, linelength: 38, configuration: config) } diff --git a/Tests/SwiftFormatPrettyPrintTests/OperatorDeclTests.swift b/Tests/SwiftFormatTests/PrettyPrint/OperatorDeclTests.swift similarity index 98% rename from Tests/SwiftFormatPrettyPrintTests/OperatorDeclTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/OperatorDeclTests.swift index 8272d4526..6849fbed2 100644 --- a/Tests/SwiftFormatPrettyPrintTests/OperatorDeclTests.swift +++ b/Tests/SwiftFormatTests/PrettyPrint/OperatorDeclTests.swift @@ -1,4 +1,4 @@ -import SwiftFormatConfiguration +import SwiftFormat final class OperatorDeclTests: PrettyPrintTestCase { func testOperatorDecl() { diff --git a/Tests/SwiftFormatTests/PrettyPrint/ParameterPackTests.swift b/Tests/SwiftFormatTests/PrettyPrint/ParameterPackTests.swift new file mode 100644 index 000000000..0e666e041 --- /dev/null +++ b/Tests/SwiftFormatTests/PrettyPrint/ParameterPackTests.swift @@ -0,0 +1,49 @@ +final class ParameterPackTests: PrettyPrintTestCase { + func testGenericPackArgument() { + assertPrettyPrintEqual( + input: """ + func someFunction() {} + struct SomeStruct {} + """, + expected: """ + func someFunction< + each P + >() {} + struct SomeStruct< + each P + > {} + + """, + linelength: 22 + ) + } + + func testPackExpansionsAndElements() { + assertPrettyPrintEqual( + input: """ + repeat checkNilness(of: each value) + """, + expected: """ + repeat checkNilness( + of: each value) + + """, + linelength: 25 + ) + + assertPrettyPrintEqual( + input: """ + repeat f(of: each v) + """, + expected: """ + repeat + f( + of: + each v + ) + + """, + linelength: 7 + ) + } +} diff --git a/Tests/SwiftFormatPrettyPrintTests/ParenthesizedExprTests.swift b/Tests/SwiftFormatTests/PrettyPrint/ParenthesizedExprTests.swift similarity index 100% rename from Tests/SwiftFormatPrettyPrintTests/ParenthesizedExprTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/ParenthesizedExprTests.swift diff --git a/Tests/SwiftFormatPrettyPrintTests/PatternBindingTests.swift b/Tests/SwiftFormatTests/PrettyPrint/PatternBindingTests.swift similarity index 97% rename from Tests/SwiftFormatPrettyPrintTests/PatternBindingTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/PatternBindingTests.swift index 264156c31..608825b96 100644 --- a/Tests/SwiftFormatPrettyPrintTests/PatternBindingTests.swift +++ b/Tests/SwiftFormatTests/PrettyPrint/PatternBindingTests.swift @@ -1,4 +1,4 @@ -import SwiftFormatConfiguration +import SwiftFormat final class PatternBindingTests: PrettyPrintTestCase { func testBindingIncludingTypeAnnotation() { diff --git a/Tests/SwiftFormatTests/PrettyPrint/PrettyPrintTestCase.swift b/Tests/SwiftFormatTests/PrettyPrint/PrettyPrintTestCase.swift new file mode 100644 index 000000000..375e5fd7e --- /dev/null +++ b/Tests/SwiftFormatTests/PrettyPrint/PrettyPrintTestCase.swift @@ -0,0 +1,136 @@ +import SwiftFormat +@_spi(Rules) @_spi(Testing) import SwiftFormat +import SwiftOperators +@_spi(ExperimentalLanguageFeatures) import SwiftParser +import SwiftSyntax +import XCTest +@_spi(Testing) import _SwiftFormatTestSupport + +class PrettyPrintTestCase: DiagnosingTestCase { + /// Asserts that the input string, when pretty printed, is equal to the expected string. + /// + /// - Parameters: + /// - input: The input text to pretty print. + /// - expected: The expected pretty-printed output. + /// - linelength: The maximum allowed line length of the output. + /// - configuration: The formatter configuration. + /// - whitespaceOnly: If true, the pretty printer should only apply whitespace changes and omit + /// changes that insert or remove non-whitespace characters (like trailing commas). + /// - findings: A list of `FindingSpec` values that describe the findings that are expected to + /// be emitted. These are currently only checked if `whitespaceOnly` is true. + /// - experimentalFeatures: The set of experimental features that should be enabled in the + /// parser. + /// - file: The file in which failure occurred. Defaults to the file name of the test case in + /// which this function was called. + /// - line: The line number on which failure occurred. Defaults to the line number on which this + /// function was called. + final func assertPrettyPrintEqual( + input: String, + expected: String, + linelength: Int, + configuration: Configuration = Configuration.forTesting, + whitespaceOnly: Bool = false, + findings: [FindingSpec] = [], + experimentalFeatures: Parser.ExperimentalFeatures = [], + file: StaticString = #file, + line: UInt = #line + ) { + var configuration = configuration + configuration.lineLength = linelength + + let markedInput = MarkedText(textWithMarkers: input) + var emittedFindings = [Finding]() + + // Assert that the input, when formatted, is what we expected. + let (formatted, context) = prettyPrintedSource( + markedInput.textWithoutMarkers, + configuration: configuration, + selection: markedInput.selection, + whitespaceOnly: whitespaceOnly, + experimentalFeatures: experimentalFeatures, + findingConsumer: { emittedFindings.append($0) } + ) + assertStringsEqualWithDiff( + formatted, + expected, + "Pretty-printed result was not what was expected", + file: file, + line: line + ) + + // FIXME: It would be nice to check findings when whitespaceOnly == false, but their locations + // are wrong. + if whitespaceOnly { + assertFindings( + expected: findings, + markerLocations: markedInput.markers, + emittedFindings: emittedFindings, + context: context, + file: file, + line: line + ) + } + + // Idempotency check: Running the formatter multiple times should not change the outcome. + // Assert that running the formatter again on the previous result keeps it the same. + // But if we have ranges, they aren't going to be valid for the formatted text. + if case .infinite = markedInput.selection { + let (reformatted, _) = prettyPrintedSource( + formatted, + configuration: configuration, + selection: markedInput.selection, + whitespaceOnly: whitespaceOnly, + experimentalFeatures: experimentalFeatures, + findingConsumer: { _ in } // Ignore findings during the idempotence check. + ) + assertStringsEqualWithDiff( + reformatted, + formatted, + "Pretty printer is not idempotent", + file: file, + line: line + ) + } + } + + /// Returns the given source code reformatted with the pretty printer. + /// + /// - Parameters: + /// - source: The source text to pretty print. + /// - configuration: The formatter configuration. + /// - whitespaceOnly: If true, the pretty printer should only apply whitespace changes and omit + /// changes that insert or remove non-whitespace characters (like trailing commas). + /// - experimentalFeatures: The set of experimental features that should be enabled in the + /// parser. + /// - findingConsumer: A function called for each finding that is emitted by the pretty printer. + /// - Returns: The pretty-printed text, or nil if an error occurred and a test failure was logged. + private func prettyPrintedSource( + _ source: String, + configuration: Configuration, + selection: Selection, + whitespaceOnly: Bool, + experimentalFeatures: Parser.ExperimentalFeatures = [], + findingConsumer: @escaping (Finding) -> Void + ) -> (String, Context) { + // Ignore folding errors for unrecognized operators so that we fallback to a reasonable default. + let sourceFileSyntax = + OperatorTable.standardOperators.foldAll( + Parser.parse(source: source, experimentalFeatures: experimentalFeatures) + ) { _ in } + .as(SourceFileSyntax.self)! + let context = makeContext( + sourceFileSyntax: sourceFileSyntax, + configuration: configuration, + selection: selection, + findingConsumer: findingConsumer + ) + let printer = PrettyPrinter( + context: context, + source: source, + node: Syntax(sourceFileSyntax), + printTokenStream: false, + whitespaceOnly: whitespaceOnly + ) + return (printer.prettyPrint(), context) + } +} diff --git a/Tests/SwiftFormatPrettyPrintTests/ProtocolDeclTests.swift b/Tests/SwiftFormatTests/PrettyPrint/ProtocolDeclTests.swift similarity index 98% rename from Tests/SwiftFormatPrettyPrintTests/ProtocolDeclTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/ProtocolDeclTests.swift index f8b029cde..2bad29363 100644 --- a/Tests/SwiftFormatPrettyPrintTests/ProtocolDeclTests.swift +++ b/Tests/SwiftFormatTests/PrettyPrint/ProtocolDeclTests.swift @@ -1,4 +1,4 @@ -import SwiftFormatConfiguration +import SwiftFormat final class ProtocolDeclTests: PrettyPrintTestCase { func testBasicProtocolDeclarations() { @@ -89,7 +89,6 @@ final class ProtocolDeclTests: PrettyPrintTestCase { assertPrettyPrintEqual(input: input, expected: expected, linelength: 50) } - func testProtocolAttributes() { let input = """ @@ -152,7 +151,7 @@ final class ProtocolDeclTests: PrettyPrintTestCase { func doStuff(firstArg: Foo, second second: Bar, third third: Baz) -> Output } """ - + let expected = """ protocol MyProtocol { @@ -177,7 +176,7 @@ final class ProtocolDeclTests: PrettyPrintTestCase { } """ - + assertPrettyPrintEqual(input: input, expected: expected, linelength: 30) } @@ -189,7 +188,7 @@ final class ProtocolDeclTests: PrettyPrintTestCase { init(reallyLongLabel: Int, anotherLongLabel: Bool) } """ - + let expected = """ protocol MyProtocol { @@ -200,7 +199,7 @@ final class ProtocolDeclTests: PrettyPrintTestCase { } """ - + assertPrettyPrintEqual(input: input, expected: expected, linelength: 30) } @@ -298,7 +297,7 @@ final class ProtocolDeclTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachArgument = true assertPrettyPrintEqual(input: input, expected: expected, linelength: 30, configuration: config) } @@ -339,7 +338,7 @@ final class ProtocolDeclTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachArgument = false assertPrettyPrintEqual(input: input, expected: expected, linelength: 30, configuration: config) } diff --git a/Tests/SwiftFormatPrettyPrintTests/RepeatStmtTests.swift b/Tests/SwiftFormatTests/PrettyPrint/RepeatStmtTests.swift similarity index 98% rename from Tests/SwiftFormatPrettyPrintTests/RepeatStmtTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/RepeatStmtTests.swift index 474083baf..037c26f31 100644 --- a/Tests/SwiftFormatPrettyPrintTests/RepeatStmtTests.swift +++ b/Tests/SwiftFormatTests/PrettyPrint/RepeatStmtTests.swift @@ -1,4 +1,4 @@ -import SwiftFormatConfiguration +import SwiftFormat final class RepeatStmtTests: PrettyPrintTestCase { func testBasicRepeatTests_noBreakBeforeWhile() { @@ -107,7 +107,7 @@ final class RepeatStmtTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeControlFlowKeywords = true assertPrettyPrintEqual(input: input, expected: expected, linelength: 25, configuration: config) } diff --git a/Tests/SwiftFormatPrettyPrintTests/RespectsExistingLineBreaksTests.swift b/Tests/SwiftFormatTests/PrettyPrint/RespectsExistingLineBreaksTests.swift similarity index 81% rename from Tests/SwiftFormatPrettyPrintTests/RespectsExistingLineBreaksTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/RespectsExistingLineBreaksTests.swift index 2dd5e9e1d..fadbc59fe 100644 --- a/Tests/SwiftFormatPrettyPrintTests/RespectsExistingLineBreaksTests.swift +++ b/Tests/SwiftFormatTests/PrettyPrint/RespectsExistingLineBreaksTests.swift @@ -1,6 +1,6 @@ -import SwiftFormatConfiguration +import SwiftFormat -/// Sanity checks and regression tests for the `respectsExistingLineBreaks` configuration setting +/// Basic checks and regression tests for the `respectsExistingLineBreaks` configuration setting /// in both true and false states. final class RespectsExistingLineBreaksTests: PrettyPrintTestCase { func testExpressions() { @@ -24,8 +24,11 @@ final class RespectsExistingLineBreaksTests: PrettyPrintTestCase { """ assertPrettyPrintEqual( - input: input, expected: expectedRespecting, linelength: 12, - configuration: configuration(respectingExistingLineBreaks: true)) + input: input, + expected: expectedRespecting, + linelength: 12, + configuration: configuration(respectingExistingLineBreaks: true) + ) let expectedNotRespecting = """ @@ -36,8 +39,11 @@ final class RespectsExistingLineBreaksTests: PrettyPrintTestCase { """ assertPrettyPrintEqual( - input: input, expected: expectedNotRespecting, linelength: 25, - configuration: configuration(respectingExistingLineBreaks: false)) + input: input, + expected: expectedNotRespecting, + linelength: 25, + configuration: configuration(respectingExistingLineBreaks: false) + ) } func testCodeBlocksAndMemberDecls() { @@ -82,8 +88,11 @@ final class RespectsExistingLineBreaksTests: PrettyPrintTestCase { // No changes expected when respecting existing newlines. assertPrettyPrintEqual( - input: input, expected: input + "\n", linelength: 80, - configuration: configuration(respectingExistingLineBreaks: true)) + input: input, + expected: input + "\n", + linelength: 80, + configuration: configuration(respectingExistingLineBreaks: true) + ) let expectedNotRespecting = """ @@ -110,8 +119,11 @@ final class RespectsExistingLineBreaksTests: PrettyPrintTestCase { """ assertPrettyPrintEqual( - input: input, expected: expectedNotRespecting, linelength: 80, - configuration: configuration(respectingExistingLineBreaks: false)) + input: input, + expected: expectedNotRespecting, + linelength: 80, + configuration: configuration(respectingExistingLineBreaks: false) + ) } func testSemicolons() { @@ -130,8 +142,11 @@ final class RespectsExistingLineBreaksTests: PrettyPrintTestCase { // the same line if they were originally like that and likewise preserve newlines after // semicolons if present. assertPrettyPrintEqual( - input: input, expected: input + "\n", linelength: 80, - configuration: configuration(respectingExistingLineBreaks: true)) + input: input, + expected: input + "\n", + linelength: 80, + configuration: configuration(respectingExistingLineBreaks: true) + ) let expectedNotRespecting = """ @@ -150,8 +165,11 @@ final class RespectsExistingLineBreaksTests: PrettyPrintTestCase { // When not respecting newlines every semicolon-delimited statement or declaration should end up // on its own line. assertPrettyPrintEqual( - input: input, expected: expectedNotRespecting, linelength: 80, - configuration: configuration(respectingExistingLineBreaks: false)) + input: input, + expected: expectedNotRespecting, + linelength: 80, + configuration: configuration(respectingExistingLineBreaks: false) + ) } func testInvalidBreaksAreAlwaysRejected() { @@ -176,8 +194,11 @@ final class RespectsExistingLineBreaksTests: PrettyPrintTestCase { """ assertPrettyPrintEqual( - input: input, expected: expectedRespecting, linelength: 80, - configuration: configuration(respectingExistingLineBreaks: true)) + input: input, + expected: expectedRespecting, + linelength: 80, + configuration: configuration(respectingExistingLineBreaks: true) + ) let expectedNotRespecting = """ @@ -186,14 +207,17 @@ final class RespectsExistingLineBreaksTests: PrettyPrintTestCase { """ assertPrettyPrintEqual( - input: input, expected: expectedNotRespecting, linelength: 80, - configuration: configuration(respectingExistingLineBreaks: false)) + input: input, + expected: expectedNotRespecting, + linelength: 80, + configuration: configuration(respectingExistingLineBreaks: false) + ) } /// Creates a new configuration with the given value for `respectsExistingLineBreaks` and default /// values for everything else. private func configuration(respectingExistingLineBreaks: Bool) -> Configuration { - var config = Configuration() + var config = Configuration.forTesting config.respectsExistingLineBreaks = respectingExistingLineBreaks return config } diff --git a/Tests/SwiftFormatTests/PrettyPrint/SelectionTests.swift b/Tests/SwiftFormatTests/PrettyPrint/SelectionTests.swift new file mode 100644 index 000000000..fb95b2a6e --- /dev/null +++ b/Tests/SwiftFormatTests/PrettyPrint/SelectionTests.swift @@ -0,0 +1,395 @@ +import SwiftFormat +import XCTest + +final class SelectionTests: PrettyPrintTestCase { + func testSelectAll() { + let input = + """ + ⏩func foo() { + if let SomeReallyLongVar = Some.More.Stuff(), let a = myfunc() { + // do stuff + } + }⏪ + """ + + let expected = + """ + func foo() { + if let SomeReallyLongVar = Some.More.Stuff(), let a = myfunc() { + // do stuff + } + } + """ + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 80) + } + + func testSelectComment() { + let input = + """ + func foo() { + if let SomeReallyLongVar = Some.More.Stuff(), let a = myfunc() { + ⏩// do stuff⏪ + } + } + """ + + let expected = + """ + func foo() { + if let SomeReallyLongVar = Some.More.Stuff(), let a = myfunc() { + // do stuff + } + } + """ + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 80) + } + + func testInsertionPointBeforeComment() { + let input = + """ + func foo() { + if let SomeReallyLongVar = Some.More.Stuff(), let a = myfunc() { + ⏩⏪// do stuff + } + } + """ + + let expected = + """ + func foo() { + if let SomeReallyLongVar = Some.More.Stuff(), let a = myfunc() { + // do stuff + } + } + """ + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 80) + } + + func testSpacesInline() { + let input = + """ + func foo() { + if let SomeReallyLongVar ⏩ = ⏪Some.More.Stuff(), let a = myfunc() { + // do stuff + } + } + """ + + let expected = + """ + func foo() { + if let SomeReallyLongVar = Some.More.Stuff(), let a = myfunc() { + // do stuff + } + } + """ + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 80) + } + + func testSpacesFullLine() { + let input = + """ + func foo() { + ⏩if let SomeReallyLongVar = Some.More.Stuff(), let a = myfunc() {⏪ + // do stuff + } + } + """ + + let expected = + """ + func foo() { + if let SomeReallyLongVar = Some.More.Stuff(), let a = myfunc() { + // do stuff + } + } + """ + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 80) + } + + func testWrapInline() { + let input = + """ + func foo() { + if let SomeReallyLongVar = ⏩Some.More.Stuff(), let a = myfunc()⏪ { + // do stuff + } + } + """ + + let expected = + """ + func foo() { + if let SomeReallyLongVar = Some.More + .Stuff(), let a = myfunc() { + // do stuff + } + } + """ + + // The line length ends on the last paren of .Stuff() + assertPrettyPrintEqual(input: input, expected: expected, linelength: 44) + } + + func testCommentsOnly() { + let input = + """ + func foo() { + if let SomeReallyLongVar = Some.More.Stuff(), let a = myfunc() { + ⏩// do stuff + // do more stuff⏪ + var i = 0 + } + } + """ + + let expected = + """ + func foo() { + if let SomeReallyLongVar = Some.More.Stuff(), let a = myfunc() { + // do stuff + // do more stuff + var i = 0 + } + } + """ + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 80) + } + + func testVarOnly() { + let input = + """ + func foo() { + if let SomeReallyLongVar = Some.More.Stuff(), let a = myfunc() { + // do stuff + // do more stuff + ⏩⏪var i = 0 + } + } + """ + + let expected = + """ + func foo() { + if let SomeReallyLongVar = Some.More.Stuff(), let a = myfunc() { + // do stuff + // do more stuff + var i = 0 + } + } + """ + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 80) + } + + func testSingleLineFunc() { + let input = + """ + func foo() ⏩{}⏪ + """ + + let expected = + """ + func foo() {} + """ + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 80) + } + + func testSingleLineFunc2() { + let input = + """ + func foo() /**/ ⏩{}⏪ + """ + + let expected = + """ + func foo() /**/ {} + """ + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 80) + } + + func testSimpleFunc() { + let input = + """ + func foo() /**/ + ⏩{}⏪ + """ + + let expected = + """ + func foo() /**/ + {} + """ + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 80) + } + + // MARK: - multiple selection ranges + func testFirstCommentAndVar() { + let input = + """ + func foo() { + if let SomeReallyLongVar = Some.More.Stuff(), let a = myfunc() { + ⏩⏪// do stuff + // do more stuff + ⏩⏪var i = 0 + } + } + """ + + let expected = + """ + func foo() { + if let SomeReallyLongVar = Some.More.Stuff(), let a = myfunc() { + // do stuff + // do more stuff + var i = 0 + } + } + """ + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 80) + } + + // from AccessorTests (but with some Selection ranges) + func testBasicAccessors() { + let input = + """ + ⏩struct MyStruct { + var memberValue: Int + var someValue: Int { get { return memberValue + 2 } set(newValue) { memberValue = newValue } } + }⏪ + struct MyStruct { + var memberValue: Int + var someValue: Int { @objc get { return memberValue + 2 } @objc(isEnabled) set(newValue) { memberValue = newValue } } + } + struct MyStruct { + var memberValue: Int + var memberValue2: Int + var someValue: Int { + get { + let A = 123 + return A + } + set(newValue) { + memberValue = newValue && otherValue + ⏩memberValue2 = newValue / 2 && andableValue⏪ + } + } + } + struct MyStruct { + var memberValue: Int + var SomeValue: Int { return 123 } + var AnotherValue: Double { + let out = 1.23 + return out + } + } + """ + + let expected = + """ + struct MyStruct { + var memberValue: Int + var someValue: Int { + get { return memberValue + 2 } + set(newValue) { memberValue = newValue } + } + } + struct MyStruct { + var memberValue: Int + var someValue: Int { @objc get { return memberValue + 2 } @objc(isEnabled) set(newValue) { memberValue = newValue } } + } + struct MyStruct { + var memberValue: Int + var memberValue2: Int + var someValue: Int { + get { + let A = 123 + return A + } + set(newValue) { + memberValue = newValue && otherValue + memberValue2 = + newValue / 2 && andableValue + } + } + } + struct MyStruct { + var memberValue: Int + var SomeValue: Int { return 123 } + var AnotherValue: Double { + let out = 1.23 + return out + } + } + """ + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 45) + } + + // from CommentTests (but with some Selection ranges) + func testContainerLineComments() { + let input = + """ + // Array comment + let a = [⏩4⏪56, // small comment + 789] + + // Dictionary comment + let b = ["abc": ⏩456, // small comment + "def": 789]⏪ + + // Trailing comment + let c = [123, 456 // small comment + ] + + ⏩/* Array comment */ + let a = [456, /* small comment */ + 789] + + /* Dictionary comment */ + let b = ["abc": 456, /* small comment */ + "def": 789]⏪ + """ + + let expected = + """ + // Array comment + let a = [ + 456, // small comment + 789] + + // Dictionary comment + let b = ["abc": 456, // small comment + "def": 789, + ] + + // Trailing comment + let c = [123, 456 // small comment + ] + + /* Array comment */ + let a = [ + 456, /* small comment */ + 789, + ] + + /* Dictionary comment */ + let b = [ + "abc": 456, /* small comment */ + "def": 789, + ] + """ + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 80) + } +} diff --git a/Tests/SwiftFormatPrettyPrintTests/SemicolonTests.swift b/Tests/SwiftFormatTests/PrettyPrint/SemicolonTests.swift similarity index 71% rename from Tests/SwiftFormatPrettyPrintTests/SemicolonTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/SemicolonTests.swift index 80b746152..68f0420f8 100644 --- a/Tests/SwiftFormatPrettyPrintTests/SemicolonTests.swift +++ b/Tests/SwiftFormatTests/PrettyPrint/SemicolonTests.swift @@ -12,20 +12,20 @@ final class SemiColonTypeTests: PrettyPrintTestCase { assertPrettyPrintEqual(input: input, expected: input + "\n", linelength: 50) } - + func testNoSemicolon() { let input = """ - var foo = false - guard !foo else { return } - defer { foo = true } + var foo = false + guard !foo else { return } + defer { foo = true } - struct Foo { - var foo = false - var bar = true - var baz = false - } - """ + struct Foo { + var foo = false + var bar = true + var baz = false + } + """ assertPrettyPrintEqual(input: input, expected: input + "\n", linelength: 50) } diff --git a/Tests/SwiftFormatTests/PrettyPrint/StringTests.swift b/Tests/SwiftFormatTests/PrettyPrint/StringTests.swift new file mode 100644 index 000000000..d97d3e0a0 --- /dev/null +++ b/Tests/SwiftFormatTests/PrettyPrint/StringTests.swift @@ -0,0 +1,730 @@ +@_spi(Rules) @_spi(Testing) import SwiftFormat + +final class StringTests: PrettyPrintTestCase { + func testStrings() { + let input = + """ + let a = "abc" + myFun("Some string \\(a + b)") + let b = "A really long string that should not wrap" + let c = "A really long string with \\(a + b) some expressions \\(c + d)" + """ + + let expected = + """ + let a = "abc" + myFun("Some string \\(a + b)") + let b = + "A really long string that should not wrap" + let c = + "A really long string with \\(a + b) some expressions \\(c + d)" + + """ + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 35) + } + + func testLongMultilinestringIsWrapped() { + let input = + #""" + let someString = """ + this string's total lengths will be longer than the column limit even though its individual lines are as well, whoops. + """ + """# + + let expected = + #""" + let someString = """ + this string's total \ + lengths will be longer \ + than the column limit even \ + though its individual \ + lines are as well, whoops. + """ + + """# + + var config = Configuration() + config.reflowMultilineStringLiterals = .onlyLinesOverLength + assertPrettyPrintEqual( + input: input, + expected: expected, + linelength: 30, + configuration: config + ) + } + + func testMultilineStringIsNotReformattedWithIgnore() { + let input = + #""" + let someString = + // swift-format-ignore + """ + lines \ + are \ + short. + """ + """# + + let expected = + #""" + let someString = + // swift-format-ignore + """ + lines \ + are \ + short. + """ + + """# + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 30) + } + + func testMultilineStringIsNotReformattedWithReflowDisabled() { + let input = + #""" + let someString = + """ + lines \ + are \ + short. + """ + """# + + let expected = + #""" + let someString = + """ + lines \ + are \ + short. + """ + + """# + + var config = Configuration() + config.reflowMultilineStringLiterals = .onlyLinesOverLength + assertPrettyPrintEqual(input: input, expected: expected, linelength: 30, configuration: config) + } + + func testMultilineStringWithInterpolations() { + let input = + #""" + if true { + guard let opt else { + functionCall(""" + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec rutrum libero \(2) \(testVariable) ids risus placerat imperdiet. Praesent fringilla vel nisi sed fermentum. In vitae purus feugiat, euismod nulla in, rhoncus leo. Suspendisse feugiat sapien lobortis facilisis malesuada. Aliquam feugiat suscipit accumsan. Praesent tempus fermentum est, vel blandit mi pretium a. Proin in posuere sapien. Nunc tincidunt efficitur ante id fermentum. + """) + } + } + """# + + let expected = + #""" + if true { + guard let opt else { + functionCall( + """ + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec rutrum libero \(2) \ + \(testVariable) ids risus placerat imperdiet. Praesent fringilla vel nisi sed fermentum. In \ + vitae purus feugiat, euismod nulla in, rhoncus leo. Suspendisse feugiat sapien lobortis \ + facilisis malesuada. Aliquam feugiat suscipit accumsan. Praesent tempus fermentum est, vel \ + blandit mi pretium a. Proin in posuere sapien. Nunc tincidunt efficitur ante id fermentum. + """) + } + } + + """# + + var config = Configuration() + config.reflowMultilineStringLiterals = .onlyLinesOverLength + assertPrettyPrintEqual(input: input, expected: expected, linelength: 100, configuration: config) + } + + func testMutlilineStringsRespectsHardLineBreaks() { + let input = + #""" + """ + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec rutrum libero ids risus placerat imperdiet. Praesent fringilla vel nisi sed fermentum. In vitae purus feugiat, euismod nulla in, rhoncus leo. + Suspendisse feugiat sapien lobortis facilisis malesuada. Aliquam feugiat suscipit accumsan. Praesent tempus fermentum est, vel blandit mi pretium a. Proin in posuere sapien. Nunc tincidunt efficitur ante id fermentum. + """ + """# + + let expected = + #""" + """ + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec rutrum libero ids risus placerat \ + imperdiet. Praesent fringilla vel nisi sed fermentum. In vitae purus feugiat, euismod nulla in, \ + rhoncus leo. + Suspendisse feugiat sapien lobortis facilisis malesuada. Aliquam feugiat suscipit accumsan. \ + Praesent tempus fermentum est, vel blandit mi pretium a. Proin in posuere sapien. Nunc tincidunt \ + efficitur ante id fermentum. + """ + + """# + + var config = Configuration() + config.reflowMultilineStringLiterals = .onlyLinesOverLength + assertPrettyPrintEqual(input: input, expected: expected, linelength: 100, configuration: config) + } + + func testMultilineStringsWrapAroundInterpolations() { + let input = + #""" + """ + An interpolation should be treated as a single "word" and can't be broken up \(aLongVariableName + anotherLongVariableName), so no line breaks should be available within the expr. + """ + """# + + let expected = + #""" + """ + An interpolation should be treated as a single "word" and can't be broken up \ + \(aLongVariableName + anotherLongVariableName), so no line breaks should be available within the \ + expr. + """ + + """# + + var config = Configuration() + config.reflowMultilineStringLiterals = .onlyLinesOverLength + assertPrettyPrintEqual(input: input, expected: expected, linelength: 100, configuration: config) + } + + func testMultilineStringOpenQuotesDoNotWrapIfStringIsVeryLong() { + let input = + #""" + let someString = """ + this string's total + length will be longer + than the column limit + even though none of + its individual lines + are. + """ + """# + + assertPrettyPrintEqual(input: input, expected: input + "\n", linelength: 30) + } + + func testMultilineStringWithAssignmentOperatorInsteadOfPatternBinding() { + let input = + #""" + someString = """ + this string's total + length will be longer + than the column limit + even though none of + its individual lines + are. + """ + """# + + assertPrettyPrintEqual(input: input, expected: input + "\n", linelength: 30) + } + + func testMultilineStringUnlabeledArgumentIsReindentedCorrectly() { + let input = + #""" + functionCall(longArgument, anotherLongArgument, """ + some multi- + line string + """) + """# + + let expected = + #""" + functionCall( + longArgument, + anotherLongArgument, + """ + some multi- + line string + """) + + """# + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 25) + } + + func testMultilineStringLabeledArgumentIsReindentedCorrectly() { + let input = + #""" + functionCall(longArgument: x, anotherLongArgument: y, longLabel: """ + some multi- + line string + """) + """# + + let expected = + #""" + functionCall( + longArgument: x, + anotherLongArgument: y, + longLabel: """ + some multi- + line string + """) + + """# + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 25) + } + + func testMultilineStringWithWordLongerThanLineLength() { + let input = + #""" + """ + there isn't an opportunity to break up this long url: https://www.cool-math-games.org/games/id?=01913310-b7c3-77d8-898e-300ccd451ea8 + """ + """# + let expected = + #""" + """ + there isn't an opportunity to break up this long url: \ + https://www.cool-math-games.org/games/id?=01913310-b7c3-77d8-898e-300ccd451ea8 + """ + + """# + + var config = Configuration() + config.reflowMultilineStringLiterals = .onlyLinesOverLength + assertPrettyPrintEqual(input: input, expected: expected, linelength: 70, configuration: config) + } + + func testMultilineStringInterpolations() { + let input = + #""" + let x = """ + \(1) 2 3 + 4 \(5) 6 + 7 8 \(9) + """ + """# + + assertPrettyPrintEqual(input: input, expected: input + "\n", linelength: 25) + } + + func testMultilineRawString() { + let input = + ##""" + let x = #""" + """who would + ever do this""" + """# + """## + + assertPrettyPrintEqual(input: input, expected: input + "\n", linelength: 25) + } + + func testMultilineRawStringOpenQuotesWrap() { + let input = + #""" + let aLongVariableName = """ + some + multi- + line + string + """ + """# + + let expected = + #""" + let aLongVariableName = + """ + some + multi- + line + string + """ + + """# + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 25) + } + + func testMultilineStringAutocorrectMisalignedLines() { + let input = + #""" + let x = """ + the + second + line is + wrong + """ + """# + + let expected = + #""" + let x = """ + the + second + line is + wrong + """ + + """# + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 25) + } + + func testMultilineStringKeepsBlankLines() { + // This test not only ensures that the blank lines are retained in the first place, but that + // the newlines are mandatory and not collapsed to the maximum number allowed by the formatter + // configuration. + let input = + #""" + let x = """ + + + there should be + + + + + gaps all around here + + + """ + """# + + let expected = + #""" + let x = """ + + + there should be + + + + + gaps all around here + + + """ + + """# + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 25) + } + + func testMultilineStringReflowsTrailingBackslashes() { + let input = + #""" + let x = """ + there should be \ + backslashes at \ + the end of \ + every line \ + except this one + """ + """# + + let expected = + #""" + let x = """ + there should be \ + backslashes at \ + the end of every \ + line except this \ + one + """ + + """# + + var config = Configuration.forTesting + config.reflowMultilineStringLiterals = .always + assertPrettyPrintEqual(input: input, expected: expected, linelength: 20, configuration: config) + } + + func testRawMultilineStringIsNotFormatted() { + let input = + ##""" + #""" + this is a long line that is not broken. + """# + """## + let expected = + ##""" + #""" + this is a long line that is not broken. + """# + + """## + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 10) + } + + func testMultilineStringIsNotFormattedWithNeverReflowBehavior() { + let input = + #""" + """ + this is a long line that is not broken. + """ + """# + let expected = + #""" + """ + this is a long line that is not broken. + """ + + """# + + var config = Configuration.forTesting + config.reflowMultilineStringLiterals = .never + assertPrettyPrintEqual(input: input, expected: expected, linelength: 10, configuration: config) + } + + func testMultilineStringInParenthesizedExpression() { + let input = + #""" + let x = (""" + this is a + multiline string + """) + """# + + let expected = + #""" + let x = + (""" + this is a + multiline string + """) + + """# + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 20) + } + + func testMultilineStringAfterStatementKeyword() { + let input = + #""" + return """ + this is a + multiline string + """ + return """ + this is a + multiline string + """ + "hello" + """# + + let expected = + #""" + return """ + this is a + multiline string + """ + return """ + this is a + multiline string + """ + "hello" + + """# + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 20) + } + + func testMultilineStringsInExpressionWithNarrowMargins() { + let input = + #""" + x = """ + abcdefg + hijklmn + """ + """ + abcde + hijkl + """ + """# + + let expected = + #""" + x = """ + abcdefg + hijklmn + """ + + """ + abcde + hijkl + """ + + """# + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 9) + } + + func testMultilineStringsInExpression() { + let input = + #""" + let x = """ + this is a + multiline string + """ + """ + this is more + multiline string + """ + """# + + let expected = + #""" + let x = """ + this is a + multiline string + """ + """ + this is more + multiline string + """ + + """# + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 20) + } + + func testLeadingMultilineStringsInOtherExpressions() { + // The stacked indentation behavior needs to drill down into different node types to find the + // leftmost multiline string literal. This makes sure that we cover various cases. + let input = + #""" + let bytes = """ + { + "key": "value" + } + """.utf8.count + let json = """ + { + "key": "value" + } + """.data(using: .utf8) + let slice = """ + { + "key": "value" + } + """[...] + let forceUnwrap = """ + { + "key": "value" + } + """! + let optionalChaining = """ + { + "key": "value" + } + """? + let postfix = """ + { + "key": "value" + } + """^*^ + let prefix = +""" + { + "key": "value" + } + """ + let postfixIf = """ + { + "key": "value" + } + """ + #if FLAG + .someMethod + #endif + + // Like the infix operator cases, cast operations force the string's open quotes to wrap. + // This could be considered consistent if you look at it through the right lens. Let's make + // sure to test it so that we can see if the behavior ever changes accidentally. + let cast = + """ + { + "key": "value" + } + """ as NSString + let typecheck = + """ + { + "key": "value" + } + """ is NSString + """# + assertPrettyPrintEqual(input: input, expected: input + "\n", linelength: 100) + } + + func testMultilineStringsAsEnumRawValues() { + let input = #""" + enum E: String { + case x = """ + blah blah + """ + } + """# + assertPrettyPrintEqual(input: input, expected: input + "\n", linelength: 100) + } + + func testMultilineStringsNestedInAnotherWrappingContext() { + let input = + #""" + guard + let x = """ + blah + blah + """.data(using: .utf8) else { + print(x) + } + """# + + let expected = + #""" + guard + let x = """ + blah + blah + """.data(using: .utf8) + else { + print(x) + } + + """# + assertPrettyPrintEqual(input: input, expected: expected, linelength: 100) + } + + func testEmptyMultilineStrings() { + let input = + ##""" + let x = """ + """ + let y = + """ + """ + let x = #""" + """# + let y = + #""" + """# + """## + + assertPrettyPrintEqual(input: input, expected: input + "\n", linelength: 20) + } + + func testOnlyBlankLinesMultilineStrings() { + let input = + ##""" + let x = """ + + """ + let y = + """ + + """ + let x = #""" + + """# + let y = + #""" + + """# + """## + + assertPrettyPrintEqual(input: input, expected: input + "\n", linelength: 20) + } +} diff --git a/Tests/SwiftFormatPrettyPrintTests/StructDeclTests.swift b/Tests/SwiftFormatTests/PrettyPrint/StructDeclTests.swift similarity index 97% rename from Tests/SwiftFormatPrettyPrintTests/StructDeclTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/StructDeclTests.swift index 900842ec3..b05e203df 100644 --- a/Tests/SwiftFormatPrettyPrintTests/StructDeclTests.swift +++ b/Tests/SwiftFormatTests/PrettyPrint/StructDeclTests.swift @@ -1,4 +1,4 @@ -import SwiftFormatConfiguration +import SwiftFormat final class StructDeclTests: PrettyPrintTestCase { func testBasicStructDeclarations() { @@ -79,7 +79,7 @@ final class StructDeclTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachArgument = true assertPrettyPrintEqual(input: input, expected: expected, linelength: 30, configuration: config) } @@ -120,7 +120,7 @@ final class StructDeclTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachArgument = false assertPrettyPrintEqual(input: input, expected: expected, linelength: 30, configuration: config) } @@ -208,7 +208,7 @@ final class StructDeclTests: PrettyPrintTestCase { func testStructWhereClause_lineBreakBeforeEachGenericRequirement() { let input = - """ + """ struct MyStruct where S: Collection { let A: Int let B: Double @@ -224,7 +224,7 @@ final class StructDeclTests: PrettyPrintTestCase { """ let expected = - """ + """ struct MyStruct where S: Collection { let A: Int let B: Double @@ -246,7 +246,7 @@ final class StructDeclTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachGenericRequirement = true assertPrettyPrintEqual(input: input, expected: expected, linelength: 60, configuration: config) } @@ -295,7 +295,7 @@ final class StructDeclTests: PrettyPrintTestCase { func testStructWhereClauseWithInheritance_lineBreakBeforeEachGenericRequirement() { let input = - """ + """ struct MyStruct: ProtoOne where S: Collection { let A: Int let B: Double @@ -311,7 +311,7 @@ final class StructDeclTests: PrettyPrintTestCase { """ let expected = - """ + """ struct MyStruct: ProtoOne where S: Collection { let A: Int let B: Double @@ -334,7 +334,7 @@ final class StructDeclTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachGenericRequirement = true assertPrettyPrintEqual(input: input, expected: expected, linelength: 60, configuration: config) } @@ -414,7 +414,7 @@ final class StructDeclTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachArgument = false assertPrettyPrintEqual(input: input, expected: expected, linelength: 50, configuration: config) } @@ -448,7 +448,7 @@ final class StructDeclTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachArgument = false config.lineBreakBeforeEachGenericRequirement = true assertPrettyPrintEqual(input: input, expected: expected, linelength: 50, configuration: config) diff --git a/Tests/SwiftFormatPrettyPrintTests/SubscriptDeclTests.swift b/Tests/SwiftFormatTests/PrettyPrint/SubscriptDeclTests.swift similarity index 80% rename from Tests/SwiftFormatPrettyPrintTests/SubscriptDeclTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/SubscriptDeclTests.swift index 383082e76..f7dae1118 100644 --- a/Tests/SwiftFormatPrettyPrintTests/SubscriptDeclTests.swift +++ b/Tests/SwiftFormatTests/PrettyPrint/SubscriptDeclTests.swift @@ -1,4 +1,4 @@ -import SwiftFormatConfiguration +import SwiftFormat final class SubscriptDeclTests: PrettyPrintTestCase { func testBasicSubscriptDeclarations() { @@ -78,7 +78,7 @@ final class SubscriptDeclTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachArgument = true assertPrettyPrintEqual(input: input, expected: expected, linelength: 50, configuration: config) } @@ -120,7 +120,7 @@ final class SubscriptDeclTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachArgument = false assertPrettyPrintEqual(input: input, expected: expected, linelength: 50, configuration: config) } @@ -159,14 +159,14 @@ final class SubscriptDeclTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachArgument = false assertPrettyPrintEqual(input: input, expected: expected, linelength: 50, configuration: config) } func testSubscriptGenericWhere_lineBreakBeforeEachGenericRequirement() { let input = - """ + """ struct MyStruct { subscript(var1: Element, var2: Elements) -> Double where Elements.Element == Element { return 1.23 @@ -178,7 +178,7 @@ final class SubscriptDeclTests: PrettyPrintTestCase { """ let expected = - """ + """ struct MyStruct { subscript( var1: Element, var2: Elements @@ -199,7 +199,7 @@ final class SubscriptDeclTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeEachArgument = false config.lineBreakBeforeEachGenericRequirement = true assertPrettyPrintEqual(input: input, expected: expected, linelength: 50, configuration: config) @@ -262,7 +262,7 @@ final class SubscriptDeclTests: PrettyPrintTestCase { func testBreaksBeforeOrInsideOutput() { let input = - """ + """ protocol MyProtocol { subscript(index: Int) -> R } @@ -276,7 +276,7 @@ final class SubscriptDeclTests: PrettyPrintTestCase { """ var expected = - """ + """ protocol MyProtocol { subscript(index: Int) -> R @@ -295,29 +295,29 @@ final class SubscriptDeclTests: PrettyPrintTestCase { assertPrettyPrintEqual(input: input, expected: expected, linelength: 26) expected = - """ - protocol MyProtocol { - subscript(index: Int) - -> R - } - - struct MyStruct { - subscript(index: Int) - -> R - { - statement - statement - } - } - - """ + """ + protocol MyProtocol { + subscript(index: Int) + -> R + } + + struct MyStruct { + subscript(index: Int) + -> R + { + statement + statement + } + } + + """ assertPrettyPrintEqual(input: input, expected: expected, linelength: 27) assertPrettyPrintEqual(input: input, expected: expected, linelength: 30) } func testBreaksBeforeOrInsideOutput_prioritizingKeepingOutputTogether() { let input = - """ + """ protocol MyProtocol { subscript(index: Int) -> R } @@ -331,7 +331,7 @@ final class SubscriptDeclTests: PrettyPrintTestCase { """ var expected = - """ + """ protocol MyProtocol { subscript( index: Int @@ -348,131 +348,131 @@ final class SubscriptDeclTests: PrettyPrintTestCase { } """ - var config = Configuration() + var config = Configuration.forTesting config.prioritizeKeepingFunctionOutputTogether = true assertPrettyPrintEqual(input: input, expected: expected, linelength: 26, configuration: config) expected = - """ - protocol MyProtocol { - subscript( - index: Int - ) -> R - } - - struct MyStruct { - subscript( - index: Int - ) -> R { - statement - statement - } - } - - """ + """ + protocol MyProtocol { + subscript( + index: Int + ) -> R + } + + struct MyStruct { + subscript( + index: Int + ) -> R { + statement + statement + } + } + + """ assertPrettyPrintEqual(input: input, expected: expected, linelength: 27, configuration: config) assertPrettyPrintEqual(input: input, expected: expected, linelength: 30, configuration: config) } func testSubscriptFullWrap() { let input = - """ - struct MyStruct { - @discardableResult @objc - subscript(var1: Element, var2: ManyElements) -> ManyElements.Index? where Element: Foo, Element: Bar, ManyElements.Element == Element { - get { - let out = vals[var1][var2] - return out - } - set(newValue) { - let tmp = compute(newValue) - vals[var1][var2] = tmp + """ + struct MyStruct { + @discardableResult @objc + subscript(var1: Element, var2: ManyElements) -> ManyElements.Index? where Element: Foo, Element: Bar, ManyElements.Element == Element { + get { + let out = vals[var1][var2] + return out + } + set(newValue) { + let tmp = compute(newValue) + vals[var1][var2] = tmp + } } } - } - """ + """ let expected = - """ - struct MyStruct { - @discardableResult @objc - subscript< - ManyElements: Collection, - Element - >( - var1: Element, - var2: ManyElements - ) -> ManyElements.Index? - where - Element: Foo, Element: Bar, - ManyElements.Element - == Element - { - get { - let out = vals[var1][var2] - return out - } - set(newValue) { - let tmp = compute(newValue) - vals[var1][var2] = tmp - } - } - } - - """ + """ + struct MyStruct { + @discardableResult @objc + subscript< + ManyElements: Collection, + Element + >( + var1: Element, + var2: ManyElements + ) -> ManyElements.Index? + where + Element: Foo, Element: Bar, + ManyElements.Element + == Element + { + get { + let out = vals[var1][var2] + return out + } + set(newValue) { + let tmp = compute(newValue) + vals[var1][var2] = tmp + } + } + } + + """ assertPrettyPrintEqual(input: input, expected: expected, linelength: 34) } func testSubscriptFullWrap_lineBreakBeforeEachGenericRequirement() { let input = - """ - struct MyStruct { - @discardableResult @objc - subscript(var1: Element, var2: ManyElements) -> ManyElements.Index? where Element: Foo, Element: Bar, ManyElements.Element == Element { - get { - let out = vals[var1][var2] - return out - } - set(newValue) { - let tmp = compute(newValue) - vals[var1][var2] = tmp + """ + struct MyStruct { + @discardableResult @objc + subscript(var1: Element, var2: ManyElements) -> ManyElements.Index? where Element: Foo, Element: Bar, ManyElements.Element == Element { + get { + let out = vals[var1][var2] + return out + } + set(newValue) { + let tmp = compute(newValue) + vals[var1][var2] = tmp + } } } - } - """ + """ let expected = - """ - struct MyStruct { - @discardableResult @objc - subscript< - ManyElements: Collection, - Element - >( - var1: Element, - var2: ManyElements - ) -> ManyElements.Index? - where - Element: Foo, - Element: Bar, - ManyElements.Element - == Element - { - get { - let out = vals[var1][var2] - return out - } - set(newValue) { - let tmp = compute(newValue) - vals[var1][var2] = tmp - } - } - } - - """ - - var config = Configuration() + """ + struct MyStruct { + @discardableResult @objc + subscript< + ManyElements: Collection, + Element + >( + var1: Element, + var2: ManyElements + ) -> ManyElements.Index? + where + Element: Foo, + Element: Bar, + ManyElements.Element + == Element + { + get { + let out = vals[var1][var2] + return out + } + set(newValue) { + let tmp = compute(newValue) + vals[var1][var2] = tmp + } + } + } + + """ + + var config = Configuration.forTesting config.lineBreakBeforeEachGenericRequirement = true assertPrettyPrintEqual(input: input, expected: expected, linelength: 34, configuration: config) } @@ -486,7 +486,7 @@ final class SubscriptDeclTests: PrettyPrintTestCase { } """ assertPrettyPrintEqual(input: input, expected: input + "\n", linelength: 50) - + let wrapped = """ struct X { // diff --git a/Tests/SwiftFormatPrettyPrintTests/SubscriptExprTests.swift b/Tests/SwiftFormatTests/PrettyPrint/SubscriptExprTests.swift similarity index 99% rename from Tests/SwiftFormatPrettyPrintTests/SubscriptExprTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/SubscriptExprTests.swift index b832120df..fe9bc36cf 100644 --- a/Tests/SwiftFormatPrettyPrintTests/SubscriptExprTests.swift +++ b/Tests/SwiftFormatTests/PrettyPrint/SubscriptExprTests.swift @@ -65,14 +65,14 @@ final class SubscriptExprTests: PrettyPrintTestCase { func testSubscriptSettersWithTrailingClosures() { let input = - """ + """ myCollection[index] { $0 < $1 } = someValue myCollection[label: index] { arg1, arg2 in foo() } = someValue myCollection[index, default: someDefaultValue] { arg1, arg2 in foo() } = someValue """ let expected = - """ + """ myCollection[index] { $0 < $1 } = someValue myCollection[label: index] { arg1, arg2 in foo() diff --git a/Tests/SwiftFormatPrettyPrintTests/SwitchCaseIndentConfigTests.swift b/Tests/SwiftFormatTests/PrettyPrint/SwitchCaseIndentConfigTests.swift similarity index 96% rename from Tests/SwiftFormatPrettyPrintTests/SwitchCaseIndentConfigTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/SwitchCaseIndentConfigTests.swift index 5a1942675..b39e36e54 100644 --- a/Tests/SwiftFormatPrettyPrintTests/SwitchCaseIndentConfigTests.swift +++ b/Tests/SwiftFormatTests/PrettyPrint/SwitchCaseIndentConfigTests.swift @@ -1,4 +1,4 @@ -import SwiftFormatConfiguration +import SwiftFormat /// Tests the `indentSwitchCaseLabels` config option final class SwitchCaseIndentConfigTests: PrettyPrintTestCase { @@ -31,7 +31,7 @@ final class SwitchCaseIndentConfigTests: PrettyPrintTestCase { let expected = input - var config = Configuration() + var config = Configuration.forTesting config.indentSwitchCaseLabels = false assertPrettyPrintEqual(input: input, expected: expected, linelength: 35, configuration: config) @@ -86,7 +86,7 @@ final class SwitchCaseIndentConfigTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.indentSwitchCaseLabels = false assertPrettyPrintEqual(input: input, expected: expected, linelength: 35, configuration: config) @@ -141,7 +141,7 @@ final class SwitchCaseIndentConfigTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.indentSwitchCaseLabels = true assertPrettyPrintEqual(input: input, expected: expected, linelength: 35, configuration: config) @@ -175,7 +175,7 @@ final class SwitchCaseIndentConfigTests: PrettyPrintTestCase { let expected = input - var config = Configuration() + var config = Configuration.forTesting config.indentSwitchCaseLabels = true assertPrettyPrintEqual(input: input, expected: expected, linelength: 35, configuration: config) @@ -230,7 +230,7 @@ final class SwitchCaseIndentConfigTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.indentSwitchCaseLabels = false assertPrettyPrintEqual(input: input, expected: expected, linelength: 35, configuration: config) @@ -285,7 +285,7 @@ final class SwitchCaseIndentConfigTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.indentSwitchCaseLabels = true assertPrettyPrintEqual(input: input, expected: expected, linelength: 35, configuration: config) @@ -340,7 +340,7 @@ final class SwitchCaseIndentConfigTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.indentSwitchCaseLabels = true assertPrettyPrintEqual(input: input, expected: expected, linelength: 35, configuration: config) @@ -371,7 +371,7 @@ final class SwitchCaseIndentConfigTests: PrettyPrintTestCase { let expected = input - var config = Configuration() + var config = Configuration.forTesting config.indentSwitchCaseLabels = true assertPrettyPrintEqual(input: input, expected: expected, linelength: 80, configuration: config) diff --git a/Tests/SwiftFormatPrettyPrintTests/SwitchStmtTests.swift b/Tests/SwiftFormatTests/PrettyPrint/SwitchStmtTests.swift similarity index 78% rename from Tests/SwiftFormatPrettyPrintTests/SwitchStmtTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/SwitchStmtTests.swift index 365e8ce36..4c02f4a3f 100644 --- a/Tests/SwiftFormatPrettyPrintTests/SwitchStmtTests.swift +++ b/Tests/SwiftFormatTests/PrettyPrint/SwitchStmtTests.swift @@ -1,4 +1,4 @@ -import SwiftFormatConfiguration +import SwiftFormat final class SwitchStmtTests: PrettyPrintTestCase { func testBasicSwitch() { @@ -78,6 +78,43 @@ final class SwitchStmtTests: PrettyPrintTestCase { assertPrettyPrintEqual(input: input, expected: expected, linelength: 35) } + func testSwitchEmptyCases() { + let input = + """ + switch a { + case b: + default: + print("Not b") + } + + switch a { + case b: + // Comment but no statements + default: + print("Not b") + } + """ + + let expected = + """ + switch a { + case b: + default: + print("Not b") + } + + switch a { + case b: + // Comment but no statements + default: + print("Not b") + } + + """ + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 35) + } + func testSwitchCompoundCases() { let input = """ @@ -205,6 +242,98 @@ final class SwitchStmtTests: PrettyPrintTestCase { assertPrettyPrintEqual(input: input, expected: expected, linelength: 45) } + func testSwitchExpression1() { + let input = + """ + func foo() -> Int { + switch value1 + value2 + value3 + value4 { + case "a": + 0 + case "b": + 1 + default: + 2 + } + } + """ + + let expected = + """ + func foo() -> Int { + switch value1 + value2 + value3 + + value4 + { + case "a": + 0 + case "b": + 1 + default: + 2 + } + } + + """ + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 35) + } + + func testSwitchExpression2() { + let input = + """ + func foo() -> Int { + let x = switch value1 + value2 + value3 + value4 { + case "a": + 0 + case "b": + 1 + default: + 2 + } + return x + } + """ + + let expected = + """ + func foo() -> Int { + let x = + switch value1 + value2 + value3 + value4 { + case "a": + 0 + case "b": + 1 + default: + 2 + } + return x + } + + """ + + assertPrettyPrintEqual(input: input, expected: expected, linelength: 46) + + let expected43 = + """ + func foo() -> Int { + let x = + switch value1 + value2 + value3 + + value4 + { + case "a": + 0 + case "b": + 1 + default: + 2 + } + return x + } + + """ + + assertPrettyPrintEqual(input: input, expected: expected43, linelength: 43) + } + func testUnknownDefault() { let input = """ @@ -422,9 +551,13 @@ final class SwitchStmtTests: PrettyPrintTestCase { """ - var configuration = Configuration() + var configuration = Configuration.forTesting configuration.indentSwitchCaseLabels = true assertPrettyPrintEqual( - input: input, expected: expected, linelength: 40, configuration: configuration) + input: input, + expected: expected, + linelength: 40, + configuration: configuration + ) } } diff --git a/Tests/SwiftFormatPrettyPrintTests/TernaryExprTests.swift b/Tests/SwiftFormatTests/PrettyPrint/TernaryExprTests.swift similarity index 100% rename from Tests/SwiftFormatPrettyPrintTests/TernaryExprTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/TernaryExprTests.swift diff --git a/Tests/SwiftFormatPrettyPrintTests/TryCatchTests.swift b/Tests/SwiftFormatTests/PrettyPrint/TryCatchTests.swift similarity index 98% rename from Tests/SwiftFormatPrettyPrintTests/TryCatchTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/TryCatchTests.swift index 152cff650..b92a03338 100644 --- a/Tests/SwiftFormatPrettyPrintTests/TryCatchTests.swift +++ b/Tests/SwiftFormatTests/PrettyPrint/TryCatchTests.swift @@ -1,4 +1,4 @@ -import SwiftFormatConfiguration +import SwiftFormat final class TryCatchTests: PrettyPrintTestCase { func testBasicTries() { @@ -105,7 +105,7 @@ final class TryCatchTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeControlFlowKeywords = true assertPrettyPrintEqual(input: input, expected: expected, linelength: 40, configuration: config) } @@ -156,7 +156,7 @@ final class TryCatchTests: PrettyPrintTestCase { """ - var config = Configuration() + var config = Configuration.forTesting config.lineBreakBeforeControlFlowKeywords = true assertPrettyPrintEqual(input: input, expected: expected, linelength: 42, configuration: config) } diff --git a/Tests/SwiftFormatPrettyPrintTests/TupleDeclTests.swift b/Tests/SwiftFormatTests/PrettyPrint/TupleDeclTests.swift similarity index 100% rename from Tests/SwiftFormatPrettyPrintTests/TupleDeclTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/TupleDeclTests.swift diff --git a/Tests/SwiftFormatPrettyPrintTests/TypeAliasTests.swift b/Tests/SwiftFormatTests/PrettyPrint/TypeAliasTests.swift similarity index 100% rename from Tests/SwiftFormatPrettyPrintTests/TypeAliasTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/TypeAliasTests.swift diff --git a/Tests/SwiftFormatTests/PrettyPrint/ValueGenericsTests.swift b/Tests/SwiftFormatTests/PrettyPrint/ValueGenericsTests.swift new file mode 100644 index 000000000..54dec83ab --- /dev/null +++ b/Tests/SwiftFormatTests/PrettyPrint/ValueGenericsTests.swift @@ -0,0 +1,46 @@ +@_spi(ExperimentalLanguageFeatures) import SwiftParser + +final class ValueGenericsTests: PrettyPrintTestCase { + func testValueGenericDeclaration() { + let input = "struct Foo { static let bar = n }" + let expected = """ + struct Foo< + let n: Int + > { + static let bar = n + } + + """ + assertPrettyPrintEqual( + input: input, + expected: expected, + linelength: 20, + experimentalFeatures: [.valueGenerics] + ) + } + + func testValueGenericTypeUsage() { + let input = + """ + let v1: Vector<100, Int> + let v2 = Vector<100, Int>() + """ + let expected = """ + let v1: + Vector< + 100, Int + > + let v2 = + Vector< + 100, Int + >() + + """ + assertPrettyPrintEqual( + input: input, + expected: expected, + linelength: 15, + experimentalFeatures: [.valueGenerics] + ) + } +} diff --git a/Tests/SwiftFormatPrettyPrintTests/VariableDeclTests.swift b/Tests/SwiftFormatTests/PrettyPrint/VariableDeclTests.swift similarity index 100% rename from Tests/SwiftFormatPrettyPrintTests/VariableDeclTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/VariableDeclTests.swift diff --git a/Tests/SwiftFormatPrettyPrintTests/WhileStmtTests.swift b/Tests/SwiftFormatTests/PrettyPrint/WhileStmtTests.swift similarity index 100% rename from Tests/SwiftFormatPrettyPrintTests/WhileStmtTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/WhileStmtTests.swift diff --git a/Tests/SwiftFormatTests/PrettyPrint/WhitespaceLintTests.swift b/Tests/SwiftFormatTests/PrettyPrint/WhitespaceLintTests.swift new file mode 100644 index 000000000..7fa561816 --- /dev/null +++ b/Tests/SwiftFormatTests/PrettyPrint/WhitespaceLintTests.swift @@ -0,0 +1,246 @@ +import SwiftFormat +import _SwiftFormatTestSupport + +// A note about these tests: `WhitespaceLinter` *only* emits findings; it does not do any +// reformatting. Therefore, in these tests the "expected" source code is the desired string that the +// linter is diffing against. +final class WhitespaceLintTests: WhitespaceTestCase { + func testSpacing() { + assertWhitespaceLint( + input: """ + let a1️⃣ : Int = 123 + let b =2️⃣456 + + """, + expected: """ + let a: Int = 123 + let b = 456 + + """, + findings: [ + FindingSpec("1️⃣", message: "remove 1 space"), + FindingSpec("2️⃣", message: "add 1 space"), + ] + ) + } + + func testTabSpacing() { + assertWhitespaceLint( + input: """ + let a1️⃣\t: Int = 123 + + """, + expected: """ + let a: Int = 123 + + """, + findings: [ + FindingSpec("1️⃣", message: "use spaces for spacing") + ] + ) + } + + func testSpaceIndentation() { + assertWhitespaceLint( + input: """ + 1️⃣ let a = 123 + 2️⃣let b = 456 + 3️⃣ let c = "abc" + 4️⃣\tlet d = 111 + + """, + expected: """ + let a = 123 + let b = 456 + let c = "abc" + let d = 111 + + """, + findings: [ + FindingSpec("1️⃣", message: "remove all leading whitespace"), + FindingSpec("2️⃣", message: "replace leading whitespace with 4 spaces"), + FindingSpec("3️⃣", message: "remove all leading whitespace"), + FindingSpec("4️⃣", message: "replace leading whitespace with 2 spaces"), + ] + ) + } + + func testTabIndentation() { + assertWhitespaceLint( + input: """ + 1️⃣\t\tlet a = 123 + 2️⃣let b = 456 + 3️⃣ let c = "abc" + 4️⃣ let d = 111 + + """, + expected: """ + let a = 123 + \tlet b = 456 + let c = "abc" + \t\tlet d = 111 + + """, + findings: [ + FindingSpec("1️⃣", message: "remove all leading whitespace"), + FindingSpec("2️⃣", message: "replace leading whitespace with 1 tab"), + FindingSpec("3️⃣", message: "remove all leading whitespace"), + FindingSpec("4️⃣", message: "replace leading whitespace with 2 tabs"), + ] + ) + } + + func testHeterogeneousIndentation() { + assertWhitespaceLint( + input: """ + 1️⃣\t\t \t let a = 123 + 2️⃣let b = 456 + 3️⃣ let c = "abc" + 4️⃣ \tlet d = 111 + 5️⃣\t let e = 111 + + """, + expected: """ + let a = 123 + \t \t let b = 456 + let c = "abc" + let d = 111 + \tlet e = 111 + + """, + findings: [ + FindingSpec("1️⃣", message: "replace leading whitespace with 2 spaces"), + FindingSpec("2️⃣", message: "replace leading whitespace with 1 tab, 2 spaces, 1 tab, 1 space"), + FindingSpec("3️⃣", message: "remove all leading whitespace"), + FindingSpec("4️⃣", message: "replace leading whitespace with 2 spaces"), + FindingSpec("5️⃣", message: "replace leading whitespace with 1 space, 1 tab"), + ] + ) + } + + func testTrailingWhitespace() { + assertWhitespaceLint( + input: """ + let a = 1231️⃣\u{20}\u{20} + let b = "abc"2️⃣\u{20} + let c = "def" + 3️⃣\u{20}\u{20} + let d = 4564️⃣\u{20}\u{20}\u{20} + + """, + expected: """ + let a = 123 + let b = "abc" + let c = "def" + + let d = 456 + + """, + findings: [ + FindingSpec("1️⃣", message: "remove trailing whitespace"), + FindingSpec("2️⃣", message: "remove trailing whitespace"), + FindingSpec("3️⃣", message: "remove trailing whitespace"), + FindingSpec("4️⃣", message: "remove trailing whitespace"), + ] + ) + } + + func testAddLines() { + assertWhitespaceLint( + input: """ + let a = 1231️⃣ + let b = "abc" + func myfun() {2️⃣ return3️⃣ } + + """, + expected: """ + let a = 123 + + let b = "abc" + func myfun() { + return + } + + """, + findings: [ + FindingSpec("1️⃣", message: "add 1 line break"), + FindingSpec("2️⃣", message: "add 1 line break"), + FindingSpec("3️⃣", message: "add 1 line break"), + ] + ) + } + + func testRemoveLines() { + assertWhitespaceLint( + input: """ + let a = 1231️⃣ + + let b = "abc"2️⃣ + 3️⃣ + + let c = 456 + func myFun() {4️⃣ + return someValue5️⃣ + } + + """, + expected: """ + let a = 123 + let b = "abc" + let c = 456 + func myFun() { return someValue } + + """, + findings: [ + FindingSpec("1️⃣", message: "remove line break"), + FindingSpec("2️⃣", message: "remove line break"), + FindingSpec("3️⃣", message: "remove line break"), + FindingSpec("4️⃣", message: "remove line break"), + FindingSpec("5️⃣", message: "remove line break"), + ] + ) + } + + func testLineLength() { + assertWhitespaceLint( + input: """ + 1️⃣func myFunc(longVar1: Bool, longVar2: Bool, longVar3: Bool, longVar4: Bool) { + // do stuff + } + + 2️⃣func myFunc(longVar1: Bool, longVar2: Bool, + longVar3: Bool, + longVar4: Bool3️⃣) { + // do stuff + } + + """, + expected: """ + func myFunc( + longVar1: Bool, + longVar2: Bool, + longVar3: Bool, + longVar4: Bool + ) { + // do stuff + } + + func myFunc( + longVar1: Bool, + longVar2: Bool, + longVar3: Bool, + longVar4: Bool + ) { + // do stuff + } + + """, + linelength: 30, + findings: [ + FindingSpec("1️⃣", message: "line is too long"), + FindingSpec("2️⃣", message: "line is too long"), + FindingSpec("3️⃣", message: "add 1 line break"), + ] + ) + } +} diff --git a/Tests/SwiftFormatTests/PrettyPrint/WhitespaceTestCase.swift b/Tests/SwiftFormatTests/PrettyPrint/WhitespaceTestCase.swift new file mode 100644 index 000000000..4a707e779 --- /dev/null +++ b/Tests/SwiftFormatTests/PrettyPrint/WhitespaceTestCase.swift @@ -0,0 +1,60 @@ +import SwiftFormat +@_spi(Testing) import SwiftFormat +import SwiftParser +import SwiftSyntax +import XCTest +@_spi(Testing) import _SwiftFormatTestSupport + +class WhitespaceTestCase: DiagnosingTestCase { + /// Perform whitespace linting by comparing the input text from the user with the expected + /// formatted text. + /// + /// - Parameters: + /// - input: The user's input text. + /// - expected: The formatted text. + /// - linelength: The maximum allowed line length of the output. + /// - findings: A list of `FindingSpec` values that describe the findings that are expected to + /// be emitted. + /// - file: The file the test resides in (defaults to the current caller's file). + /// - line: The line the test resides in (defaults to the current caller's line). + final func assertWhitespaceLint( + input: String, + expected: String, + linelength: Int? = nil, + findings: [FindingSpec], + file: StaticString = #file, + line: UInt = #line + ) { + let markedText = MarkedText(textWithMarkers: input) + + let sourceFileSyntax = Parser.parse(source: markedText.textWithoutMarkers) + var configuration = Configuration.forTesting + if let linelength = linelength { + configuration.lineLength = linelength + } + + var emittedFindings = [Finding]() + + let context = makeContext( + sourceFileSyntax: sourceFileSyntax, + configuration: configuration, + selection: .infinite, + findingConsumer: { emittedFindings.append($0) } + ) + let linter = WhitespaceLinter( + user: markedText.textWithoutMarkers, + formatted: expected, + context: context + ) + linter.lint() + + assertFindings( + expected: findings, + markerLocations: markedText.markers, + emittedFindings: emittedFindings, + context: context, + file: file, + line: line + ) + } +} diff --git a/Tests/SwiftFormatPrettyPrintTests/YieldStmtTests.swift b/Tests/SwiftFormatTests/PrettyPrint/YieldStmtTests.swift similarity index 100% rename from Tests/SwiftFormatPrettyPrintTests/YieldStmtTests.swift rename to Tests/SwiftFormatTests/PrettyPrint/YieldStmtTests.swift diff --git a/Tests/SwiftFormatTests/Rules/AllPublicDeclarationsHaveDocumentationTests.swift b/Tests/SwiftFormatTests/Rules/AllPublicDeclarationsHaveDocumentationTests.swift new file mode 100644 index 000000000..b3fabac22 --- /dev/null +++ b/Tests/SwiftFormatTests/Rules/AllPublicDeclarationsHaveDocumentationTests.swift @@ -0,0 +1,189 @@ +@_spi(Rules) import SwiftFormat +import _SwiftFormatTestSupport + +final class AllPublicDeclarationsHaveDocumentationTests: LintOrFormatRuleTestCase { + func testPublicDeclsWithoutDocs() { + assertLint( + AllPublicDeclarationsHaveDocumentation.self, + """ + 1️⃣public func lightswitchRave() {} + /// Comment. + public func lightswitchRave() {} + func lightswitchRave() {} + + 2️⃣public var isSblounskched: Int { return 0 } + /// Comment. + public var isSblounskched: Int { return 0 } + var isSblounskched: Int { return 0 } + + 3️⃣public struct Foo {} + /// Comment. + public struct Foo {} + struct Foo {} + + 4️⃣public actor Bar {} + /// Comment. + public actor Bar {} + actor Bar {} + + 5️⃣public class Baz {} + /// Comment. + public class Baz {} + class Baz {} + + 6️⃣public enum Qux {} + /// Comment. + public enum Qux {} + enum Qux {} + + 7️⃣public typealias MyType = Int + /// Comment. + public typealias MyType = Int + typealias MyType = Int + + /** + * Determines if an email was delorted. + */ + public var isDelorted: Bool { + return false + } + """, + findings: [ + FindingSpec("1️⃣", message: "add a documentation comment for 'lightswitchRave()'"), + FindingSpec("2️⃣", message: "add a documentation comment for 'isSblounskched'"), + FindingSpec("3️⃣", message: "add a documentation comment for 'Foo'"), + FindingSpec("4️⃣", message: "add a documentation comment for 'Bar'"), + FindingSpec("5️⃣", message: "add a documentation comment for 'Baz'"), + FindingSpec("6️⃣", message: "add a documentation comment for 'Qux'"), + FindingSpec("7️⃣", message: "add a documentation comment for 'MyType'"), + ] + ) + } + + func testNestedDecls() { + assertLint( + AllPublicDeclarationsHaveDocumentation.self, + """ + /// Comment. + public struct MyContainer { + 1️⃣public func lightswitchRave() {} + /// Comment. + public func lightswitchRave() {} + func lightswitchRave() {} + + 2️⃣public var isSblounskched: Int { return 0 } + /// Comment. + public var isSblounskched: Int { return 0 } + var isSblounskched: Int { return 0 } + + 3️⃣public struct Foo {} + /// Comment. + public struct Foo {} + struct Foo {} + + 4️⃣public actor Bar {} + /// Comment. + public actor Bar {} + actor Bar {} + + 5️⃣public class Baz {} + /// Comment. + public class Baz {} + class Baz {} + + 6️⃣public enum Qux {} + /// Comment. + public enum Qux {} + enum Qux {} + + 7️⃣public typealias MyType = Int + /// Comment. + public typealias MyType = Int + typealias MyType = Int + + } + """, + findings: [ + FindingSpec("1️⃣", message: "add a documentation comment for 'lightswitchRave()'"), + FindingSpec("2️⃣", message: "add a documentation comment for 'isSblounskched'"), + FindingSpec("3️⃣", message: "add a documentation comment for 'Foo'"), + FindingSpec("4️⃣", message: "add a documentation comment for 'Bar'"), + FindingSpec("5️⃣", message: "add a documentation comment for 'Baz'"), + FindingSpec("6️⃣", message: "add a documentation comment for 'Qux'"), + FindingSpec("7️⃣", message: "add a documentation comment for 'MyType'"), + ] + ) + } + + func testNestedInStruct() { + assertLint( + AllPublicDeclarationsHaveDocumentation.self, + """ + /// Comment. + public struct MyContainer { + 1️⃣public typealias MyType = Int + /// Comment. + public typealias MyType = Int + typealias MyType = Int + } + """, + findings: [ + FindingSpec("1️⃣", message: "add a documentation comment for 'MyType'") + ] + ) + } + + func testNestedInClass() { + assertLint( + AllPublicDeclarationsHaveDocumentation.self, + """ + /// Comment. + public class MyContainer { + 1️⃣public typealias MyType = Int + /// Comment. + public typealias MyType = Int + typealias MyType = Int + } + """, + findings: [ + FindingSpec("1️⃣", message: "add a documentation comment for 'MyType'") + ] + ) + } + + func testNestedInEnum() { + assertLint( + AllPublicDeclarationsHaveDocumentation.self, + """ + /// Comment. + public enum MyContainer { + 1️⃣public typealias MyType = Int + /// Comment. + public typealias MyType = Int + typealias MyType = Int + } + """, + findings: [ + FindingSpec("1️⃣", message: "add a documentation comment for 'MyType'") + ] + ) + } + + func testNestedInActor() { + assertLint( + AllPublicDeclarationsHaveDocumentation.self, + """ + /// Comment. + public actor MyContainer { + 1️⃣public typealias MyType = Int + /// Comment. + public typealias MyType = Int + typealias MyType = Int + } + """, + findings: [ + FindingSpec("1️⃣", message: "add a documentation comment for 'MyType'") + ] + ) + } +} diff --git a/Tests/SwiftFormatTests/Rules/AlwaysUseLiteralForEmptyCollectionInitTests.swift b/Tests/SwiftFormatTests/Rules/AlwaysUseLiteralForEmptyCollectionInitTests.swift new file mode 100644 index 000000000..acb3535da --- /dev/null +++ b/Tests/SwiftFormatTests/Rules/AlwaysUseLiteralForEmptyCollectionInitTests.swift @@ -0,0 +1,172 @@ +@_spi(Rules) import SwiftFormat +import _SwiftFormatTestSupport + +final class AlwaysUseLiteralForEmptyCollectionInitTests: LintOrFormatRuleTestCase { + func testArray() { + assertFormatting( + AlwaysUseLiteralForEmptyCollectionInit.self, + input: """ + public struct Test { + var value1 = 1️⃣[Int]() + + func test(v: [Double] = 2️⃣[Double]()) { + let _ = 3️⃣[String]() + } + } + + var _: [Category] = 4️⃣[Category]() + let _ = 5️⃣[(Int, Array)]() + let _: [(String, Int, Float)] = 6️⃣[(String, Int, Float)]() + + let _ = [(1, 2, String)]() + + class TestSubscript { + subscript(_: [A] = 7️⃣[A](), x: [(Int, B)] = 8️⃣[(Int, B)]()) { + } + } + + // All of the examples in this block could be re-written to use leading-dot syntax: `.init(...)` + do { + let _ = [Int](repeating: 0, count: 10) + let _: [Int] = [Int](repeating: 0, count: 10) + + func testDefault(_ x: [String] = [String](repeating: "a", count: 42)) { + } + + class TestSubscript { + subscript(_: Int = 42, x: [(Int, B)] = [(Int, B)](repeating: (0, B()), count: 1)) { + } + } + } + """, + expected: """ + public struct Test { + var value1: [Int] = [] + + func test(v: [Double] = []) { + let _: [String] = [] + } + } + + var _: [Category] = [] + let _: [(Int, Array)] = [] + let _: [(String, Int, Float)] = [] + + let _ = [(1, 2, String)]() + + class TestSubscript { + subscript(_: [A] = [], x: [(Int, B)] = []) { + } + } + + // All of the examples in this block could be re-written to use leading-dot syntax: `.init(...)` + do { + let _ = [Int](repeating: 0, count: 10) + let _: [Int] = [Int](repeating: 0, count: 10) + + func testDefault(_ x: [String] = [String](repeating: "a", count: 42)) { + } + + class TestSubscript { + subscript(_: Int = 42, x: [(Int, B)] = [(Int, B)](repeating: (0, B()), count: 1)) { + } + } + } + """, + findings: [ + FindingSpec("1️⃣", message: "replace '[Int]()' with ': [Int] = []'"), + FindingSpec("2️⃣", message: "replace '[Double]()' with '[]'"), + FindingSpec("3️⃣", message: "replace '[String]()' with ': [String] = []'"), + FindingSpec("4️⃣", message: "replace '[Category]()' with '[]'"), + FindingSpec("5️⃣", message: "replace '[(Int, Array)]()' with ': [(Int, Array)] = []'"), + FindingSpec("6️⃣", message: "replace '[(String, Int, Float)]()' with '[]'"), + FindingSpec("7️⃣", message: "replace '[A]()' with '[]'"), + FindingSpec("8️⃣", message: "replace '[(Int, B)]()' with '[]'"), + ] + ) + } + + func testDictionary() { + assertFormatting( + AlwaysUseLiteralForEmptyCollectionInit.self, + input: """ + public struct Test { + var value1 = 1️⃣[Int: String]() + + func test(v: [Double: Int] = 2️⃣[Double: Int]()) { + let _ = 3️⃣[String: Int]() + } + } + + var _: [Category: String] = 4️⃣[Category: String]() + let _ = 5️⃣[(Int, Array): Int]() + let _: [String: (String, Int, Float)] = 6️⃣[String: (String, Int, Float)]() + + let _ = [String: (1, 2, String)]() + + class TestSubscript { + subscript(_: [A: Int] = 7️⃣[A: Int](), x: [(Int, B): String] = 8️⃣[(Int, B): String]()) { + } + } + + // All of the examples in this block could be re-written to use leading-dot syntax: `.init(...)` + do { + let _ = [String: Int](minimumCapacity: 42) + let _: [String: Int] = [String: Int](minimumCapacity: 42) + + func testDefault(_ x: [Int: String] = [String](minimumCapacity: 1)) { + } + + class TestSubscript { + subscript(_: Int = 42, x: [String: (Int, B)] = [String: (Int, B)](minimumCapacity: 2)) { + } + } + } + """, + expected: """ + public struct Test { + var value1: [Int: String] = [:] + + func test(v: [Double: Int] = [:]) { + let _: [String: Int] = [:] + } + } + + var _: [Category: String] = [:] + let _: [(Int, Array): Int] = [:] + let _: [String: (String, Int, Float)] = [:] + + let _ = [String: (1, 2, String)]() + + class TestSubscript { + subscript(_: [A: Int] = [:], x: [(Int, B): String] = [:]) { + } + } + + // All of the examples in this block could be re-written to use leading-dot syntax: `.init(...)` + do { + let _ = [String: Int](minimumCapacity: 42) + let _: [String: Int] = [String: Int](minimumCapacity: 42) + + func testDefault(_ x: [Int: String] = [String](minimumCapacity: 1)) { + } + + class TestSubscript { + subscript(_: Int = 42, x: [String: (Int, B)] = [String: (Int, B)](minimumCapacity: 2)) { + } + } + } + """, + findings: [ + FindingSpec("1️⃣", message: "replace '[Int: String]()' with ': [Int: String] = [:]'"), + FindingSpec("2️⃣", message: "replace '[Double: Int]()' with '[:]'"), + FindingSpec("3️⃣", message: "replace '[String: Int]()' with ': [String: Int] = [:]'"), + FindingSpec("4️⃣", message: "replace '[Category: String]()' with '[:]'"), + FindingSpec("5️⃣", message: "replace '[(Int, Array): Int]()' with ': [(Int, Array): Int] = [:]'"), + FindingSpec("6️⃣", message: "replace '[String: (String, Int, Float)]()' with '[:]'"), + FindingSpec("7️⃣", message: "replace '[A: Int]()' with '[:]'"), + FindingSpec("8️⃣", message: "replace '[(Int, B): String]()' with '[:]'"), + ] + ) + } +} diff --git a/Tests/SwiftFormatTests/Rules/AlwaysUseLowerCamelCaseTests.swift b/Tests/SwiftFormatTests/Rules/AlwaysUseLowerCamelCaseTests.swift new file mode 100644 index 000000000..5d8ffe3db --- /dev/null +++ b/Tests/SwiftFormatTests/Rules/AlwaysUseLowerCamelCaseTests.swift @@ -0,0 +1,238 @@ +@_spi(Rules) import SwiftFormat +import _SwiftFormatTestSupport + +final class AlwaysUseLowerCamelCaseTests: LintOrFormatRuleTestCase { + func testInvalidVariableCasing() { + assertLint( + AlwaysUseLowerCamelCase.self, + """ + let 1️⃣Test = 1 + var foo = 2 + var 2️⃣bad_name = 20 + var _okayName = 20 + if let 3️⃣Baz = foo { } + """, + findings: [ + FindingSpec("1️⃣", message: "rename the constant 'Test' using lowerCamelCase"), + FindingSpec("2️⃣", message: "rename the variable 'bad_name' using lowerCamelCase"), + FindingSpec("3️⃣", message: "rename the constant 'Baz' using lowerCamelCase"), + ] + ) + } + + func testInvalidFunctionCasing() { + assertLint( + AlwaysUseLowerCamelCase.self, + """ + struct Foo { + func 1️⃣FooFunc() {} + } + class UnitTests: XCTestCase { + // This is flagged because XCTest is not imported. + func 2️⃣test_HappyPath_Through_GoodCode() {} + } + func wellNamedFunc(_ 3️⃣BadFuncArg1: Int, 4️⃣BadFuncArgLabel goodFuncArg: String) { + var 5️⃣PoorlyNamedVar = 0 + } + """, + findings: [ + FindingSpec("1️⃣", message: "rename the function 'FooFunc' using lowerCamelCase"), + FindingSpec("2️⃣", message: "rename the function 'test_HappyPath_Through_GoodCode' using lowerCamelCase"), + FindingSpec("3️⃣", message: "rename the function parameter 'BadFuncArg1' using lowerCamelCase"), + FindingSpec("4️⃣", message: "rename the argument label 'BadFuncArgLabel' using lowerCamelCase"), + FindingSpec("5️⃣", message: "rename the variable 'PoorlyNamedVar' using lowerCamelCase"), + ] + ) + + } + + func testInvalidEnumCaseCasing() { + assertLint( + AlwaysUseLowerCamelCase.self, + """ + enum FooBarCases { + case 1️⃣UpperCamelCase + case lowerCamelCase + } + """, + findings: [ + FindingSpec("1️⃣", message: "rename the enum case 'UpperCamelCase' using lowerCamelCase") + ] + ) + + } + + func testInvalidClosureCasing() { + assertLint( + AlwaysUseLowerCamelCase.self, + """ + var fooVar = [1, 2, 3, 4].first(where: { 1️⃣BadNameInFooVar -> Bool in + let 2️⃣TerribleNameInFooVar = BadName + return TerribleName != 0 + }) + var abc = array.first(where: { (3️⃣CParam1, _ 4️⃣CParam2: Type, cparam3) -> Bool in return true }) + guard let foo = [1, 2, 3, 4].first(where: { 5️⃣BadName -> Bool in + let 6️⃣TerribleName = BadName + return TerribleName != 0 + }) else { return } + """, + findings: [ + FindingSpec("1️⃣", message: "rename the closure parameter 'BadNameInFooVar' using lowerCamelCase"), + FindingSpec("2️⃣", message: "rename the constant 'TerribleNameInFooVar' using lowerCamelCase"), + FindingSpec("3️⃣", message: "rename the closure parameter 'CParam1' using lowerCamelCase"), + FindingSpec("4️⃣", message: "rename the closure parameter 'CParam2' using lowerCamelCase"), + FindingSpec("5️⃣", message: "rename the closure parameter 'BadName' using lowerCamelCase"), + FindingSpec("6️⃣", message: "rename the constant 'TerribleName' using lowerCamelCase"), + ] + ) + } + + func testIgnoresUnderscoresInTestNames() { + assertLint( + AlwaysUseLowerCamelCase.self, + """ + import XCTest + + let 1️⃣Test = 1 + class UnitTests: XCTestCase { + static let 2️⃣My_Constant_Value = 0 + func test_HappyPath_Through_GoodCode() {} + private func 3️⃣FooFunc() {} + private func 4️⃣helperFunc_For_HappyPath_Setup() {} + private func 5️⃣testLikeMethod_With_Underscores(_ arg1: ParamType) {} + private func 6️⃣testLikeMethod_With_Underscores2() -> ReturnType {} + func test_HappyPath_Through_GoodCode_ReturnsVoid() -> Void {} + func test_HappyPath_Through_GoodCode_ReturnsShortVoid() -> () {} + func test_HappyPath_Through_GoodCode_Throws() throws {} + } + """, + findings: [ + FindingSpec("1️⃣", message: "rename the constant 'Test' using lowerCamelCase"), + FindingSpec("2️⃣", message: "rename the constant 'My_Constant_Value' using lowerCamelCase"), + FindingSpec("3️⃣", message: "rename the function 'FooFunc' using lowerCamelCase"), + FindingSpec("4️⃣", message: "rename the function 'helperFunc_For_HappyPath_Setup' using lowerCamelCase"), + FindingSpec("5️⃣", message: "rename the function 'testLikeMethod_With_Underscores' using lowerCamelCase"), + FindingSpec("6️⃣", message: "rename the function 'testLikeMethod_With_Underscores2' using lowerCamelCase"), + ] + ) + } + + func testIgnoresUnderscoresInTestNamesWhenImportedConditionally() { + assertLint( + AlwaysUseLowerCamelCase.self, + """ + #if SOME_FEATURE_FLAG + import XCTest + + let 1️⃣Test = 1 + class UnitTests: XCTestCase { + static let 2️⃣My_Constant_Value = 0 + func test_HappyPath_Through_GoodCode() {} + private func 3️⃣FooFunc() {} + private func 4️⃣helperFunc_For_HappyPath_Setup() {} + private func 5️⃣testLikeMethod_With_Underscores(_ arg1: ParamType) {} + private func 6️⃣testLikeMethod_With_Underscores2() -> ReturnType {} + func test_HappyPath_Through_GoodCode_ReturnsVoid() -> Void {} + func test_HappyPath_Through_GoodCode_ReturnsShortVoid() -> () {} + func test_HappyPath_Through_GoodCode_Throws() throws {} + } + #endif + """, + findings: [ + FindingSpec("1️⃣", message: "rename the constant 'Test' using lowerCamelCase"), + FindingSpec("2️⃣", message: "rename the constant 'My_Constant_Value' using lowerCamelCase"), + FindingSpec("3️⃣", message: "rename the function 'FooFunc' using lowerCamelCase"), + FindingSpec("4️⃣", message: "rename the function 'helperFunc_For_HappyPath_Setup' using lowerCamelCase"), + FindingSpec("5️⃣", message: "rename the function 'testLikeMethod_With_Underscores' using lowerCamelCase"), + FindingSpec("6️⃣", message: "rename the function 'testLikeMethod_With_Underscores2' using lowerCamelCase"), + ] + ) + } + + func testIgnoresUnderscoresInConditionalTestNames() { + assertLint( + AlwaysUseLowerCamelCase.self, + """ + import XCTest + + class UnitTests: XCTestCase { + #if SOME_FEATURE_FLAG + static let 1️⃣My_Constant_Value = 0 + func test_HappyPath_Through_GoodCode() {} + private func 2️⃣FooFunc() {} + private func 3️⃣helperFunc_For_HappyPath_Setup() {} + private func 4️⃣testLikeMethod_With_Underscores(_ arg1: ParamType) {} + private func 5️⃣testLikeMethod_With_Underscores2() -> ReturnType {} + func test_HappyPath_Through_GoodCode_ReturnsVoid() -> Void {} + func test_HappyPath_Through_GoodCode_ReturnsShortVoid() -> () {} + func test_HappyPath_Through_GoodCode_Throws() throws {} + #else + func 6️⃣testBadMethod_HasNonVoidReturn() -> ReturnType {} + func testGoodMethod_HasVoidReturn() {} + #if SOME_OTHER_FEATURE_FLAG + func 7️⃣testBadMethod_HasNonVoidReturn2() -> ReturnType {} + func testGoodMethod_HasVoidReturn2() {} + #endif + #endif + } + #endif + """, + findings: [ + FindingSpec("1️⃣", message: "rename the constant 'My_Constant_Value' using lowerCamelCase"), + FindingSpec("2️⃣", message: "rename the function 'FooFunc' using lowerCamelCase"), + FindingSpec("3️⃣", message: "rename the function 'helperFunc_For_HappyPath_Setup' using lowerCamelCase"), + FindingSpec("4️⃣", message: "rename the function 'testLikeMethod_With_Underscores' using lowerCamelCase"), + FindingSpec("5️⃣", message: "rename the function 'testLikeMethod_With_Underscores2' using lowerCamelCase"), + FindingSpec("6️⃣", message: "rename the function 'testBadMethod_HasNonVoidReturn' using lowerCamelCase"), + FindingSpec("7️⃣", message: "rename the function 'testBadMethod_HasNonVoidReturn2' using lowerCamelCase"), + ] + ) + } + + func testIgnoresFunctionOverrides() { + assertLint( + AlwaysUseLowerCamelCase.self, + """ + class ParentClass { + var 1️⃣poorly_named_variable: Int = 5 + func 2️⃣poorly_named_method() {} + } + + class ChildClass: ParentClass { + override var poorly_named_variable: Int = 5 + override func poorly_named_method() {} + } + """, + findings: [ + FindingSpec("1️⃣", message: "rename the variable 'poorly_named_variable' using lowerCamelCase"), + FindingSpec("2️⃣", message: "rename the function 'poorly_named_method' using lowerCamelCase"), + ] + ) + } + + func testIgnoresFunctionsWithTestAttributes() { + assertLint( + AlwaysUseLowerCamelCase.self, + """ + @Test + func function_With_Test_Attribute() {} + @Testing.Test("Description for test functions", + .tags(.testTag)) + func function_With_Test_Attribute_And_Args() {} + func 1️⃣function_Without_Test_Attribute() {} + @objc + func 2️⃣function_With_Non_Test_Attribute() {} + @Foo.Test + func 3️⃣function_With_Test_Attribute_From_Foo_Module() {} + """, + findings: [ + FindingSpec("1️⃣", message: "rename the function 'function_Without_Test_Attribute' using lowerCamelCase"), + FindingSpec("2️⃣", message: "rename the function 'function_With_Non_Test_Attribute' using lowerCamelCase"), + FindingSpec( + "3️⃣", + message: "rename the function 'function_With_Test_Attribute_From_Foo_Module' using lowerCamelCase" + ), + ] + ) + } +} diff --git a/Tests/SwiftFormatTests/Rules/AmbiguousTrailingClosureOverloadTests.swift b/Tests/SwiftFormatTests/Rules/AmbiguousTrailingClosureOverloadTests.swift new file mode 100644 index 000000000..185fe92f4 --- /dev/null +++ b/Tests/SwiftFormatTests/Rules/AmbiguousTrailingClosureOverloadTests.swift @@ -0,0 +1,63 @@ +@_spi(Rules) import SwiftFormat +import _SwiftFormatTestSupport + +final class AmbiguousTrailingClosureOverloadTests: LintOrFormatRuleTestCase { + func testAmbiguousOverloads() { + assertLint( + AmbiguousTrailingClosureOverload.self, + """ + func 1️⃣strong(mad: () -> Int) {} + func 2️⃣strong(bad: (Bool) -> Bool) {} + func 3️⃣strong(sad: (String) -> Bool) {} + + class A { + static func 4️⃣the(cheat: (Int) -> Void) {} + class func 5️⃣the(sneak: (Int) -> Void) {} + func 6️⃣the(kingOfTown: () -> Void) {} + func 7️⃣the(cheatCommandos: (Bool) -> Void) {} + func 8️⃣the(brothersStrong: (String) -> Void) {} + } + + struct B { + func 9️⃣hom(estar: () -> Int) {} + func 🔟hom(sar: () -> Bool) {} + + static func baleeted(_ f: () -> Void) {} + func baleeted(_ f: () -> Void) {} + } + """, + findings: [ + FindingSpec( + "1️⃣", + message: "rename 'strong(mad:)' so it is no longer ambiguous when called with a trailing closure", + notes: [ + NoteSpec("2️⃣", message: "ambiguous overload 'strong(bad:)' is here"), + NoteSpec("3️⃣", message: "ambiguous overload 'strong(sad:)' is here"), + ] + ), + FindingSpec( + "4️⃣", + message: "rename 'the(cheat:)' so it is no longer ambiguous when called with a trailing closure", + notes: [ + NoteSpec("5️⃣", message: "ambiguous overload 'the(sneak:)' is here") + ] + ), + FindingSpec( + "6️⃣", + message: "rename 'the(kingOfTown:)' so it is no longer ambiguous when called with a trailing closure", + notes: [ + NoteSpec("7️⃣", message: "ambiguous overload 'the(cheatCommandos:)' is here"), + NoteSpec("8️⃣", message: "ambiguous overload 'the(brothersStrong:)' is here"), + ] + ), + FindingSpec( + "9️⃣", + message: "rename 'hom(estar:)' so it is no longer ambiguous when called with a trailing closure", + notes: [ + NoteSpec("🔟", message: "ambiguous overload 'hom(sar:)' is here") + ] + ), + ] + ) + } +} diff --git a/Tests/SwiftFormatTests/Rules/AvoidRetroactiveConformancesTests.swift b/Tests/SwiftFormatTests/Rules/AvoidRetroactiveConformancesTests.swift new file mode 100644 index 000000000..4e6d45239 --- /dev/null +++ b/Tests/SwiftFormatTests/Rules/AvoidRetroactiveConformancesTests.swift @@ -0,0 +1,16 @@ +@_spi(Rules) import SwiftFormat +import _SwiftFormatTestSupport + +final class AvoidRetroactiveConformancesTests: LintOrFormatRuleTestCase { + func testRetroactiveConformanceIsDiagnosed() { + assertLint( + AvoidRetroactiveConformances.self, + """ + extension Int: 1️⃣@retroactive Identifiable {} + """, + findings: [ + FindingSpec("1️⃣", message: "do not declare retroactive conformances") + ] + ) + } +} diff --git a/Tests/SwiftFormatTests/Rules/BeginDocumentationCommentWithOneLineSummaryTests.swift b/Tests/SwiftFormatTests/Rules/BeginDocumentationCommentWithOneLineSummaryTests.swift new file mode 100644 index 000000000..a297a1b3a --- /dev/null +++ b/Tests/SwiftFormatTests/Rules/BeginDocumentationCommentWithOneLineSummaryTests.swift @@ -0,0 +1,254 @@ +@_spi(Rules) import SwiftFormat +import _SwiftFormatTestSupport + +// FIXME: We should place the diagnostic somewhere in the comment, not on the declaration. +final class BeginDocumentationCommentWithOneLineSummaryTests: LintOrFormatRuleTestCase { + override func setUp() { + // Reset this to false by default. Specific tests may override it. + BeginDocumentationCommentWithOneLineSummary._forcesFallbackModeForTesting = false + super.setUp() + } + + func testDocLineCommentsWithoutOneSentenceSummary() { + assertLint( + BeginDocumentationCommentWithOneLineSummary.self, + """ + /// Returns a bottle of Dr Pepper from the vending machine. + public func drPepper(from vendingMachine: VendingMachine) -> Soda {} + + /// Contains a comment as description that needs a sentence + /// of two lines of code. + public var twoLinesForOneSentence = "test" + + /// The background color of the view. + var backgroundColor: UIColor + + /// Returns the sum of the numbers. + /// + /// - Parameter numbers: The numbers to sum. + /// - Returns: The sum of the numbers. + func sum(_ numbers: [Int]) -> Int { + // ... + } + + /// This docline should not succeed. + /// There are two sentences without a blank line between them. + 1️⃣struct Test {} + + /// This docline should not succeed. There are two sentences. + 2️⃣public enum Token { case comma, semicolon, identifier } + + /// Should fail because it doesn't have a period + 3️⃣public class testNoPeriod {} + """, + findings: [ + FindingSpec("1️⃣", message: #"add a blank comment line after this sentence: "This docline should not succeed.""#), + FindingSpec("2️⃣", message: #"add a blank comment line after this sentence: "This docline should not succeed.""#), + FindingSpec( + "3️⃣", + message: #"terminate this sentence with a period: "Should fail because it doesn't have a period""# + ), + ] + ) + } + + func testBlockLineCommentsWithoutOneSentenceSummary() { + assertLint( + BeginDocumentationCommentWithOneLineSummary.self, + """ + /** + * Returns the numeric value. + * + * - Parameters: + * - digit: The Unicode scalar whose numeric value should be returned. + * - radix: The radix, between 2 and 36, used to compute the numeric value. + * - Returns: The numeric value of the scalar.*/ + func numericValue(of digit: UnicodeScalar, radix: Int = 10) -> Int {} + + /** + * This block comment contains a sentence summary + * of two lines of code. + */ + public var twoLinesForOneSentence = "test" + + /** + * This block comment should not succeed, struct. + * There are two sentences without a blank line between them. + */ + 1️⃣struct TestStruct {} + + /** + This block comment should not succeed, class. + Add a blank comment after the first line. + */ + 2️⃣public class TestClass {} + /** This block comment should not succeed, enum. There are two sentences. */ + 3️⃣public enum testEnum {} + /** Should fail because it doesn't have a period */ + 4️⃣public class testNoPeriod {} + """, + findings: [ + FindingSpec( + "1️⃣", + message: #"add a blank comment line after this sentence: "This block comment should not succeed, struct.""# + ), + FindingSpec( + "2️⃣", + message: #"add a blank comment line after this sentence: "This block comment should not succeed, class.""# + ), + FindingSpec( + "3️⃣", + message: #"add a blank comment line after this sentence: "This block comment should not succeed, enum.""# + ), + FindingSpec( + "4️⃣", + message: #"terminate this sentence with a period: "Should fail because it doesn't have a period""# + ), + ] + ) + } + + func testApproximationsOnMacOS() { + #if os(macOS) + // Let macOS also verify that the fallback mode works, which gives us signal about whether it + // will also succeed on Linux (where the linguistic APIs are not currently available). + BeginDocumentationCommentWithOneLineSummary._forcesFallbackModeForTesting = true + + assertLint( + BeginDocumentationCommentWithOneLineSummary.self, + """ + /// Returns a bottle of Dr Pepper from the vending machine. + public func drPepper(from vendingMachine: VendingMachine) -> Soda {} + + /// Contains a comment as description that needs a sentence + /// of two lines of code. + public var twoLinesForOneSentence = "test" + + /// The background color of the view. + var backgroundColor: UIColor + + /// Returns the sum of the numbers. + /// + /// - Parameter numbers: The numbers to sum. + /// - Returns: The sum of the numbers. + func sum(_ numbers: [Int]) -> Int { + // ... + } + + /// This docline should not succeed. + /// There are two sentences without a blank line between them. + 1️⃣struct Test {} + + /// This docline should not succeed. There are two sentences. + 2️⃣public enum Token { case comma, semicolon, identifier } + + /// Should fail because it doesn't have a period + 3️⃣public class testNoPeriod {} + """, + findings: [ + FindingSpec("1️⃣", message: #"add a blank comment line after this sentence: "This docline should not succeed.""#), + FindingSpec("2️⃣", message: #"add a blank comment line after this sentence: "This docline should not succeed.""#), + FindingSpec( + "3️⃣", + message: #"terminate this sentence with a period: "Should fail because it doesn't have a period""# + ), + ] + ) + #endif + } + + func testSentenceTerminationInsideQuotes() { + assertLint( + BeginDocumentationCommentWithOneLineSummary.self, + """ + /// Creates an instance with the same raw value as `x` failing iff `x.kind != Subject.kind`. + struct TestBackTick {} + + /// A set of `Diagnostic` that can answer the question ‘was there an error?’ in O(1). + struct TestSingleSmartQuotes {} + + /// A set of `Diagnostic` that can answer the question 'was there an error?' in O(1). + struct TestSingleStraightQuotes {} + + /// A set of `Diagnostic` that can answer the question “was there an error?” in O(1). + struct TestDoubleSmartQuotes {} + + /// A set of `Diagnostic` that can answer the question "was there an error?" in O(1). + struct TestDoubleStraightQuotes {} + + /// A set of `Diagnostic` that can answer the question “was there + /// an error?” in O(1). + struct TestTwoLinesDoubleSmartQuotes {} + + /// A set of `Diagnostic` that can answer the question "was there + /// an error?" in O(1). + struct TestTwoLinesDoubleStraightQuotes {} + """ + ) + } + + func testNestedInsideStruct() { + assertLint( + BeginDocumentationCommentWithOneLineSummary.self, + """ + struct MyContainer { + /// This docline should not succeed. + /// There are two sentences without a blank line between them. + 1️⃣struct Test {} + } + """, + findings: [ + FindingSpec("1️⃣", message: #"add a blank comment line after this sentence: "This docline should not succeed.""#) + ] + ) + } + + func testNestedInsideEnum() { + assertLint( + BeginDocumentationCommentWithOneLineSummary.self, + """ + enum MyContainer { + /// This docline should not succeed. + /// There are two sentences without a blank line between them. + 1️⃣struct Test {} + } + """, + findings: [ + FindingSpec("1️⃣", message: #"add a blank comment line after this sentence: "This docline should not succeed.""#) + ] + ) + } + + func testNestedInsideClass() { + assertLint( + BeginDocumentationCommentWithOneLineSummary.self, + """ + class MyContainer { + /// This docline should not succeed. + /// There are two sentences without a blank line between them. + 1️⃣struct Test {} + } + """, + findings: [ + FindingSpec("1️⃣", message: #"add a blank comment line after this sentence: "This docline should not succeed.""#) + ] + ) + } + + func testNestedInsideActor() { + assertLint( + BeginDocumentationCommentWithOneLineSummary.self, + """ + actor MyContainer { + /// This docline should not succeed. + /// There are two sentences without a blank line between them. + 1️⃣struct Test {} + } + """, + findings: [ + FindingSpec("1️⃣", message: #"add a blank comment line after this sentence: "This docline should not succeed.""#) + ] + ) + } + +} diff --git a/Tests/SwiftFormatTests/Rules/DoNotUseSemicolonsTests.swift b/Tests/SwiftFormatTests/Rules/DoNotUseSemicolonsTests.swift new file mode 100644 index 000000000..3804701ae --- /dev/null +++ b/Tests/SwiftFormatTests/Rules/DoNotUseSemicolonsTests.swift @@ -0,0 +1,228 @@ +@_spi(Rules) import SwiftFormat +import _SwiftFormatTestSupport + +final class DoNotUseSemicolonsTests: LintOrFormatRuleTestCase { + func testSemicolonUse() { + assertFormatting( + DoNotUseSemicolons.self, + input: """ + print("hello")1️⃣; print("goodbye")2️⃣; + print("3") + """, + expected: """ + print("hello") + print("goodbye") + print("3") + """, + findings: [ + FindingSpec("1️⃣", message: "remove ';' and move the next statement to a new line"), + FindingSpec("2️⃣", message: "remove ';'"), + ] + ) + } + + func testSemicolonsInNestedStatements() { + assertFormatting( + DoNotUseSemicolons.self, + input: """ + guard let someVar = Optional(items.filter ({ a in foo(a)1️⃣; return true2️⃣; })) else { + items.forEach { a in foo(a)3️⃣; }4️⃣; return5️⃣; + } + """, + // The formatting in the expected output is unappealing, but that is fixed by the pretty + // printer and isn't a concern for the format rule. + expected: """ + guard let someVar = Optional(items.filter ({ a in foo(a) + return true })) else { + items.forEach { a in foo(a) } + return + } + """, + findings: [ + FindingSpec("1️⃣", message: "remove ';' and move the next statement to a new line"), + FindingSpec("2️⃣", message: "remove ';'"), + FindingSpec("3️⃣", message: "remove ';'"), + FindingSpec("4️⃣", message: "remove ';' and move the next statement to a new line"), + FindingSpec("5️⃣", message: "remove ';'"), + ] + ) + } + + func testSemicolonsInMemberLists() { + assertFormatting( + DoNotUseSemicolons.self, + input: """ + struct Foo { + func foo() { + code() + }1️⃣; + + let someVar = 52️⃣;let someOtherVar = 63️⃣; + } + """, + expected: """ + struct Foo { + func foo() { + code() + } + + let someVar = 5 + let someOtherVar = 6 + } + """, + findings: [ + FindingSpec("1️⃣", message: "remove ';'"), + FindingSpec("2️⃣", message: "remove ';' and move the next statement to a new line"), + FindingSpec("3️⃣", message: "remove ';'"), + ] + ) + } + + func testNewlinesAfterSemicolons() { + assertFormatting( + DoNotUseSemicolons.self, + input: """ + print("hello")1️⃣; + /// This is a doc comment for printing "goodbye". + print("goodbye")2️⃣; + + /// This is a doc comment for printing "3". + print("3")3️⃣; + + print("4")4️⃣; /** Inline comment. */ print("5")5️⃣; + + print("6")6️⃣; // This is an important statement. + print("7")7️⃣; + """, + expected: """ + print("hello") + /// This is a doc comment for printing "goodbye". + print("goodbye") + + /// This is a doc comment for printing "3". + print("3") + + print("4") + /** Inline comment. */ print("5") + + print("6") // This is an important statement. + print("7") + """, + findings: [ + FindingSpec("1️⃣", message: "remove ';'"), + FindingSpec("2️⃣", message: "remove ';'"), + FindingSpec("3️⃣", message: "remove ';'"), + FindingSpec("4️⃣", message: "remove ';' and move the next statement to a new line"), + FindingSpec("5️⃣", message: "remove ';'"), + FindingSpec("6️⃣", message: "remove ';'"), + FindingSpec("7️⃣", message: "remove ';'"), + ] + ) + } + + func testBlockCommentAtEndOfBlock() { + assertFormatting( + DoNotUseSemicolons.self, + input: """ + print("hello")1️⃣; /* block comment */ + """, + expected: """ + print("hello") /* block comment */ + """, + findings: [ + FindingSpec("1️⃣", message: "remove ';'") + ] + ) + + assertFormatting( + DoNotUseSemicolons.self, + input: """ + if x { + print("hello")1️⃣; /* block comment */ + } + """, + expected: """ + if x { + print("hello") /* block comment */ + } + """, + findings: [ + FindingSpec("1️⃣", message: "remove ';'") + ] + ) + } + + func testBlockCommentAfterSemicolonPrecedingOtherStatement() { + assertFormatting( + DoNotUseSemicolons.self, + input: """ + print("hello")1️⃣; /* block comment */ print("world") + """, + expected: """ + print("hello") + /* block comment */ print("world") + """, + findings: [ + FindingSpec("1️⃣", message: "remove ';' and move the next statement to a new line") + ] + ) + + assertFormatting( + DoNotUseSemicolons.self, + input: """ + if x { + print("hello")1️⃣; /* block comment */ + } + """, + expected: """ + if x { + print("hello") /* block comment */ + } + """, + findings: [ + FindingSpec("1️⃣", message: "remove ';'") + ] + ) + } + + func testSemicolonsSeparatingDoWhile() { + assertFormatting( + DoNotUseSemicolons.self, + input: """ + do { f() }; + while someCondition { g() } + + do { + f() + }; + + // Comment and whitespace separating blocks. + while someCondition { + g() + } + + do { f() }1️⃣; + for _ in 0..<10 { g() } + """, + expected: """ + do { f() }; + while someCondition { g() } + + do { + f() + }; + + // Comment and whitespace separating blocks. + while someCondition { + g() + } + + do { f() } + for _ in 0..<10 { g() } + """, + findings: [ + FindingSpec("1️⃣", message: "remove ';'") + ] + ) + } +} diff --git a/Tests/SwiftFormatTests/Rules/DontRepeatTypeInStaticPropertiesTests.swift b/Tests/SwiftFormatTests/Rules/DontRepeatTypeInStaticPropertiesTests.swift new file mode 100644 index 000000000..2eb12dd82 --- /dev/null +++ b/Tests/SwiftFormatTests/Rules/DontRepeatTypeInStaticPropertiesTests.swift @@ -0,0 +1,94 @@ +@_spi(Rules) import SwiftFormat +import _SwiftFormatTestSupport + +final class DontRepeatTypeInStaticPropertiesTests: LintOrFormatRuleTestCase { + func testRepetitiveProperties() { + assertLint( + DontRepeatTypeInStaticProperties.self, + """ + public class UIColor { + static let 1️⃣redColor: UIColor + public class var 2️⃣blueColor: UIColor + var yellowColor: UIColor + static let green: UIColor + public class var purple: UIColor + } + enum Sandwich { + static let 3️⃣bolognaSandwich: Sandwich + static var 4️⃣hamSandwich: Sandwich + static var turkey: Sandwich + } + protocol RANDPerson { + var oldPerson: Person + static let 5️⃣youngPerson: Person + } + struct TVGame { + static var 6️⃣basketballGame: TVGame + static var 7️⃣baseballGame: TVGame + static let soccer: TVGame + let hockey: TVGame + } + extension URLSession { + class var 8️⃣sharedSession: URLSession + } + public actor Cookie { + static let 9️⃣chocolateChipCookie: Cookie + } + """, + findings: [ + FindingSpec("1️⃣", message: "remove the suffix 'Color' from the name of the variable 'redColor'"), + FindingSpec("2️⃣", message: "remove the suffix 'Color' from the name of the variable 'blueColor'"), + FindingSpec("3️⃣", message: "remove the suffix 'Sandwich' from the name of the variable 'bolognaSandwich'"), + FindingSpec("4️⃣", message: "remove the suffix 'Sandwich' from the name of the variable 'hamSandwich'"), + FindingSpec("5️⃣", message: "remove the suffix 'Person' from the name of the variable 'youngPerson'"), + FindingSpec("6️⃣", message: "remove the suffix 'Game' from the name of the variable 'basketballGame'"), + FindingSpec("7️⃣", message: "remove the suffix 'Game' from the name of the variable 'baseballGame'"), + FindingSpec("8️⃣", message: "remove the suffix 'Session' from the name of the variable 'sharedSession'"), + FindingSpec("9️⃣", message: "remove the suffix 'Cookie' from the name of the variable 'chocolateChipCookie'"), + ] + ) + } + + func testDoNotDiagnoseUnrelatedType() { + assertLint( + DontRepeatTypeInStaticProperties.self, + """ + extension A { + static let b = C() + } + """, + findings: [] + ) + } + + func testDottedExtendedType() { + assertLint( + DontRepeatTypeInStaticProperties.self, + """ + extension Dotted.Thing { + static let 1️⃣defaultThing: Dotted.Thing + } + """, + findings: [ + FindingSpec("1️⃣", message: "remove the suffix 'Thing' from the name of the variable 'defaultThing'") + ] + ) + } + + func testIgnoreSingleDecl() { + assertLint( + DontRepeatTypeInStaticProperties.self, + """ + struct Foo { + // swift-format-ignore: DontRepeatTypeInStaticProperties + static let defaultFoo: Int + static let 1️⃣alternateFoo: Int + } + """, + findings: [ + FindingSpec("1️⃣", message: "remove the suffix 'Foo' from the name of the variable 'alternateFoo'") + ] + ) + } + +} diff --git a/Tests/SwiftFormatTests/Rules/FileScopedDeclarationPrivacyTests.swift b/Tests/SwiftFormatTests/Rules/FileScopedDeclarationPrivacyTests.swift new file mode 100644 index 000000000..88e425ac6 --- /dev/null +++ b/Tests/SwiftFormatTests/Rules/FileScopedDeclarationPrivacyTests.swift @@ -0,0 +1,201 @@ +import SwiftFormat +@_spi(Rules) import SwiftFormat +import SwiftSyntax +import _SwiftFormatTestSupport + +private typealias TestConfiguration = ( + original: String, + desired: FileScopedDeclarationPrivacyConfiguration.AccessLevel, + expected: String +) + +/// Test configurations for file-scoped declarations, which should be changed if the access level +/// does not match the desired level in the formatter configuration. +private let changingTestConfigurations: [TestConfiguration] = [ + (original: "private", desired: .fileprivate, expected: "fileprivate"), + (original: "private", desired: .private, expected: "private"), + (original: "fileprivate", desired: .fileprivate, expected: "fileprivate"), + (original: "fileprivate", desired: .private, expected: "private"), +] + +/// Test configurations for declarations that should not have their access level changed; extensions +/// and nested declarations (i.e., not at file scope). +private let unchangingTestConfigurations: [TestConfiguration] = [ + (original: "private", desired: .fileprivate, expected: "private"), + (original: "private", desired: .private, expected: "private"), + (original: "fileprivate", desired: .fileprivate, expected: "fileprivate"), + (original: "fileprivate", desired: .private, expected: "fileprivate"), +] + +final class FileScopedDeclarationPrivacyTests: LintOrFormatRuleTestCase { + func testFileScopeDecls() { + runWithMultipleConfigurations( + source: """ + 1️⃣$access$ class Foo {} + 2️⃣$access$ struct Foo {} + 3️⃣$access$ enum Foo {} + 4️⃣$access$ protocol Foo {} + 5️⃣$access$ typealias Foo = Bar + 6️⃣$access$ func foo() {} + 7️⃣$access$ var foo: Bar + """, + testConfigurations: changingTestConfigurations + ) { original, expected in + [ + FindingSpec("1️⃣", message: "replace '\(original)' with '\(expected)' on file-scoped declarations"), + FindingSpec("2️⃣", message: "replace '\(original)' with '\(expected)' on file-scoped declarations"), + FindingSpec("3️⃣", message: "replace '\(original)' with '\(expected)' on file-scoped declarations"), + FindingSpec("4️⃣", message: "replace '\(original)' with '\(expected)' on file-scoped declarations"), + FindingSpec("5️⃣", message: "replace '\(original)' with '\(expected)' on file-scoped declarations"), + FindingSpec("6️⃣", message: "replace '\(original)' with '\(expected)' on file-scoped declarations"), + FindingSpec("7️⃣", message: "replace '\(original)' with '\(expected)' on file-scoped declarations"), + ] + } + } + + func testFileScopeExtensionsAreNotChanged() { + runWithMultipleConfigurations( + source: """ + $access$ extension Foo {} + """, + testConfigurations: unchangingTestConfigurations + ) { _, _ in [] } + } + + func testNonFileScopeDeclsAreNotChanged() { + runWithMultipleConfigurations( + source: """ + enum Namespace { + $access$ class Foo {} + $access$ struct Foo {} + $access$ enum Foo {} + $access$ typealias Foo = Bar + $access$ func foo() {} + $access$ var foo: Bar + } + """, + testConfigurations: unchangingTestConfigurations + ) { _, _ in [] } + } + + func testFileScopeDeclsInsideConditionals() { + runWithMultipleConfigurations( + source: """ + #if FOO + 1️⃣$access$ class Foo {} + #elseif BAR + 2️⃣$access$ class Foo {} + #else + 3️⃣$access$ class Foo {} + #endif + """, + testConfigurations: changingTestConfigurations + ) { original, expected in + [ + FindingSpec("1️⃣", message: "replace '\(original)' with '\(expected)' on file-scoped declarations"), + FindingSpec("2️⃣", message: "replace '\(original)' with '\(expected)' on file-scoped declarations"), + FindingSpec("3️⃣", message: "replace '\(original)' with '\(expected)' on file-scoped declarations"), + ] + } + } + + func testFileScopeDeclsInsideNestedConditionals() { + runWithMultipleConfigurations( + source: """ + #if FOO + #if BAR + 1️⃣$access$ class Foo {} + 2️⃣$access$ struct Foo {} + 3️⃣$access$ enum Foo {} + 4️⃣$access$ protocol Foo {} + 5️⃣$access$ typealias Foo = Bar + 6️⃣$access$ func foo() {} + 7️⃣$access$ var foo: Bar + #endif + #endif + """, + testConfigurations: changingTestConfigurations + ) { original, expected in + [ + FindingSpec("1️⃣", message: "replace '\(original)' with '\(expected)' on file-scoped declarations"), + FindingSpec("2️⃣", message: "replace '\(original)' with '\(expected)' on file-scoped declarations"), + FindingSpec("3️⃣", message: "replace '\(original)' with '\(expected)' on file-scoped declarations"), + FindingSpec("4️⃣", message: "replace '\(original)' with '\(expected)' on file-scoped declarations"), + FindingSpec("5️⃣", message: "replace '\(original)' with '\(expected)' on file-scoped declarations"), + FindingSpec("6️⃣", message: "replace '\(original)' with '\(expected)' on file-scoped declarations"), + FindingSpec("7️⃣", message: "replace '\(original)' with '\(expected)' on file-scoped declarations"), + ] + } + } + + func testLeadingTriviaIsPreserved() { + runWithMultipleConfigurations( + source: """ + /// Some doc comment + 1️⃣$access$ class Foo {} + + @objc /* comment */ 2️⃣$access$ class Bar {} + """, + testConfigurations: changingTestConfigurations + ) { original, expected in + [ + FindingSpec("1️⃣", message: "replace '\(original)' with '\(expected)' on file-scoped declarations"), + FindingSpec("2️⃣", message: "replace '\(original)' with '\(expected)' on file-scoped declarations"), + ] + } + } + + func testModifierDetailIsPreserved() { + runWithMultipleConfigurations( + source: """ + public 1️⃣$access$(set) var foo: Int + """, + testConfigurations: changingTestConfigurations + ) { original, expected in + [ + FindingSpec("1️⃣", message: "replace '\(original)' with '\(expected)' on file-scoped declarations") + ] + } + } + + /// Runs a test for this rule in multiple configurations. + private func runWithMultipleConfigurations( + source: String, + testConfigurations: [TestConfiguration], + file: StaticString = #file, + line: UInt = #line, + findingsProvider: (String, String) -> [FindingSpec] + ) { + for testConfig in testConfigurations { + var configuration = Configuration.forTesting + configuration.fileScopedDeclarationPrivacy.accessLevel = testConfig.desired + + let substitutedInput = source.replacingOccurrences(of: "$access$", with: testConfig.original) + + let markedSource = MarkedText(textWithMarkers: source) + let substitutedExpected = markedSource.textWithoutMarkers.replacingOccurrences( + of: "$access$", + with: testConfig.expected + ) + + // Only use the findings if the output was expected to change. If it didn't change, then the + // rule wouldn't have emitted anything. + let findingSpecs: [FindingSpec] + if testConfig.original == testConfig.expected { + findingSpecs = [] + } else { + findingSpecs = findingsProvider(testConfig.original, testConfig.expected) + } + + assertFormatting( + FileScopedDeclarationPrivacy.self, + input: substitutedInput, + expected: substitutedExpected, + findings: findingSpecs, + configuration: configuration, + file: file, + line: line + ) + } + } +} diff --git a/Tests/SwiftFormatTests/Rules/FullyIndirectEnumTests.swift b/Tests/SwiftFormatTests/Rules/FullyIndirectEnumTests.swift new file mode 100644 index 000000000..8b9a7d985 --- /dev/null +++ b/Tests/SwiftFormatTests/Rules/FullyIndirectEnumTests.swift @@ -0,0 +1,112 @@ +@_spi(Rules) import SwiftFormat +import _SwiftFormatTestSupport + +class FullyIndirectEnumTests: LintOrFormatRuleTestCase { + func testAllIndirectCases() { + assertFormatting( + FullyIndirectEnum.self, + input: """ + // Comment 1 + public 1️⃣enum DependencyGraphNode { + internal 2️⃣indirect case userDefined(dependencies: [DependencyGraphNode]) + // Comment 2 + 3️⃣indirect case synthesized(dependencies: [DependencyGraphNode]) + 4️⃣indirect case other(dependencies: [DependencyGraphNode]) + var x: Int + } + """, + expected: """ + // Comment 1 + public indirect enum DependencyGraphNode { + internal case userDefined(dependencies: [DependencyGraphNode]) + // Comment 2 + case synthesized(dependencies: [DependencyGraphNode]) + case other(dependencies: [DependencyGraphNode]) + var x: Int + } + """, + findings: [ + FindingSpec( + "1️⃣", + message: "declare enum 'DependencyGraphNode' itself as indirect when all cases are indirect", + notes: [ + NoteSpec("2️⃣", message: "remove 'indirect' here"), + NoteSpec("3️⃣", message: "remove 'indirect' here"), + NoteSpec("4️⃣", message: "remove 'indirect' here"), + ] + ) + ] + ) + } + + func testAllIndirectCasesWithAttributes() { + assertFormatting( + FullyIndirectEnum.self, + input: """ + // Comment 1 + public 1️⃣enum DependencyGraphNode { + @someAttr internal 2️⃣indirect case userDefined(dependencies: [DependencyGraphNode]) + // Comment 2 + @someAttr 3️⃣indirect case synthesized(dependencies: [DependencyGraphNode]) + @someAttr 4️⃣indirect case other(dependencies: [DependencyGraphNode]) + var x: Int + } + """, + expected: """ + // Comment 1 + public indirect enum DependencyGraphNode { + @someAttr internal case userDefined(dependencies: [DependencyGraphNode]) + // Comment 2 + @someAttr case synthesized(dependencies: [DependencyGraphNode]) + @someAttr case other(dependencies: [DependencyGraphNode]) + var x: Int + } + """, + findings: [ + FindingSpec( + "1️⃣", + message: "declare enum 'DependencyGraphNode' itself as indirect when all cases are indirect", + notes: [ + NoteSpec("2️⃣", message: "remove 'indirect' here"), + NoteSpec("3️⃣", message: "remove 'indirect' here"), + NoteSpec("4️⃣", message: "remove 'indirect' here"), + ] + ) + ] + ) + } + + func testNotAllIndirectCases() { + let input = """ + public enum CompassPoint { + case north + indirect case south + case east + case west + } + """ + assertFormatting(FullyIndirectEnum.self, input: input, expected: input, findings: []) + } + + func testAlreadyIndirectEnum() { + let input = """ + indirect enum CompassPoint { + case north + case south + case east + case west + } + """ + assertFormatting(FullyIndirectEnum.self, input: input, expected: input, findings: []) + } + + func testCaselessEnum() { + let input = """ + public enum Constants { + public static let foo = 5 + public static let bar = "bar" + } + """ + assertFormatting(FullyIndirectEnum.self, input: input, expected: input, findings: []) + } +} diff --git a/Tests/SwiftFormatTests/Rules/GroupNumericLiteralsTests.swift b/Tests/SwiftFormatTests/Rules/GroupNumericLiteralsTests.swift new file mode 100644 index 000000000..abb8c148b --- /dev/null +++ b/Tests/SwiftFormatTests/Rules/GroupNumericLiteralsTests.swift @@ -0,0 +1,59 @@ +@_spi(Rules) import SwiftFormat +import _SwiftFormatTestSupport + +final class GroupNumericLiteralsTests: LintOrFormatRuleTestCase { + func testNumericGrouping() { + assertFormatting( + GroupNumericLiterals.self, + input: """ + let a = 1️⃣9876543210 + let b = 1234 + let c = 2️⃣0x34950309233 + let d = -0x34242 + let e = 3️⃣0b10010010101 + let f = 0b101 + let g = 11_15_1999 + let h = 0o21743 + let i = -4️⃣53096828347 + let j = 5️⃣0000123 + let k = 6️⃣0x00000012 + let l = 0x0000012 + let m = 7️⃣0b00010010101 + let n = [ + 8️⃣0xff00ff00, // comment + 9️⃣0x00ff00ff, // comment + ] + """, + expected: """ + let a = 9_876_543_210 + let b = 1234 + let c = 0x349_5030_9233 + let d = -0x34242 + let e = 0b100_10010101 + let f = 0b101 + let g = 11_15_1999 + let h = 0o21743 + let i = -53_096_828_347 + let j = 0_000_123 + let k = 0x0000_0012 + let l = 0x0000012 + let m = 0b000_10010101 + let n = [ + 0xff00_ff00, // comment + 0x00ff_00ff, // comment + ] + """, + findings: [ + FindingSpec("1️⃣", message: "group every 3 digits in this decimal literal using a '_' separator"), + FindingSpec("2️⃣", message: "group every 4 digits in this hexadecimal literal using a '_' separator"), + FindingSpec("3️⃣", message: "group every 8 digits in this binary literal using a '_' separator"), + FindingSpec("4️⃣", message: "group every 3 digits in this decimal literal using a '_' separator"), + FindingSpec("5️⃣", message: "group every 3 digits in this decimal literal using a '_' separator"), + FindingSpec("6️⃣", message: "group every 4 digits in this hexadecimal literal using a '_' separator"), + FindingSpec("7️⃣", message: "group every 8 digits in this binary literal using a '_' separator"), + FindingSpec("8️⃣", message: "group every 4 digits in this hexadecimal literal using a '_' separator"), + FindingSpec("9️⃣", message: "group every 4 digits in this hexadecimal literal using a '_' separator"), + ] + ) + } +} diff --git a/Tests/SwiftFormatTests/Rules/IdentifiersMustBeASCIITests.swift b/Tests/SwiftFormatTests/Rules/IdentifiersMustBeASCIITests.swift new file mode 100644 index 000000000..6c4545dba --- /dev/null +++ b/Tests/SwiftFormatTests/Rules/IdentifiersMustBeASCIITests.swift @@ -0,0 +1,23 @@ +@_spi(Rules) import SwiftFormat +import _SwiftFormatTestSupport + +final class IdentifiersMustBeASCIITests: LintOrFormatRuleTestCase { + func testInvalidIdentifiers() { + assertLint( + IdentifiersMustBeASCII.self, + """ + let Te$t = 1 + var 1️⃣fo😎o = 2 + let 2️⃣Δx = newX - previousX + var 3️⃣🤩😆 = 20 + """, + findings: [ + FindingSpec("1️⃣", message: "remove non-ASCII characters from 'fo😎o': 😎"), + // TODO: It would be nice to allow Δ (among other mathematically meaningful symbols) without + // a lot of special cases; investigate this. + FindingSpec("2️⃣", message: "remove non-ASCII characters from 'Δx': Δ"), + FindingSpec("3️⃣", message: "remove non-ASCII characters from '🤩😆': 🤩, 😆"), + ] + ) + } +} diff --git a/Tests/SwiftFormatTests/Rules/ImportsXCTestVisitorTests.swift b/Tests/SwiftFormatTests/Rules/ImportsXCTestVisitorTests.swift new file mode 100644 index 000000000..f4d8cb26f --- /dev/null +++ b/Tests/SwiftFormatTests/Rules/ImportsXCTestVisitorTests.swift @@ -0,0 +1,71 @@ +import SwiftFormat +@_spi(Rules) @_spi(Testing) import SwiftFormat +import SwiftParser +import XCTest + +class ImportsXCTestVisitorTests: XCTestCase { + func testDoesNotImportXCTest() throws { + XCTAssertEqual( + try makeContextAndSetImportsXCTest( + source: """ + import Foundation + """ + ), + .doesNotImportXCTest + ) + } + + func testImportsXCTest() throws { + XCTAssertEqual( + try makeContextAndSetImportsXCTest( + source: """ + import Foundation + import XCTest + """ + ), + .importsXCTest + ) + } + + func testImportsSpecificXCTestDecl() throws { + XCTAssertEqual( + try makeContextAndSetImportsXCTest( + source: """ + import Foundation + import class XCTest.XCTestCase + """ + ), + .importsXCTest + ) + } + + func testImportsXCTestInsideConditional() throws { + XCTAssertEqual( + try makeContextAndSetImportsXCTest( + source: """ + import Foundation + #if SOME_FEATURE_FLAG + import XCTest + #endif + """ + ), + .importsXCTest + ) + } + + /// Parses the given source, makes a new `Context`, then populates and returns its `XCTest` + /// import state. + private func makeContextAndSetImportsXCTest(source: String) throws -> Context.XCTestImportState { + let sourceFile = Parser.parse(source: source) + let context = Context( + configuration: Configuration(), + operatorTable: .standardOperators, + findingConsumer: { _ in }, + fileURL: URL(fileURLWithPath: "/tmp/test.swift"), + sourceFileSyntax: sourceFile, + ruleNameCache: ruleNameCache + ) + setImportsXCTest(context: context, sourceFile: sourceFile) + return context.importsXCTest + } +} diff --git a/Tests/SwiftFormatTests/Rules/LintOrFormatRuleTestCase.swift b/Tests/SwiftFormatTests/Rules/LintOrFormatRuleTestCase.swift new file mode 100644 index 000000000..dc69fbef6 --- /dev/null +++ b/Tests/SwiftFormatTests/Rules/LintOrFormatRuleTestCase.swift @@ -0,0 +1,210 @@ +import SwiftFormat +@_spi(Rules) @_spi(Testing) import SwiftFormat +import SwiftOperators +@_spi(ExperimentalLanguageFeatures) import SwiftParser +import SwiftSyntax +import XCTest +@_spi(Testing) import _SwiftFormatTestSupport + +class LintOrFormatRuleTestCase: DiagnosingTestCase { + /// Performs a lint using the provided linter rule on the provided input and asserts that the + /// emitted findings are correct. + /// + /// - Parameters: + /// - type: The metatype of the lint rule you wish to perform. + /// - markedSource: The input source code, which may include emoji markers at the locations + /// where findings are expected to be emitted. + /// - findings: A list of `FindingSpec` values that describe the findings that are expected to + /// be emitted. + /// - experimentalFeatures: The set of experimental features that should be enabled in the + /// parser. + /// - file: The file the test resides in (defaults to the current caller's file). + /// - line: The line the test resides in (defaults to the current caller's line). + final func assertLint( + _ type: LintRule.Type, + _ markedSource: String, + findings: [FindingSpec] = [], + experimentalFeatures: Parser.ExperimentalFeatures = [], + file: StaticString = #file, + line: UInt = #line + ) { + let markedText = MarkedText(textWithMarkers: markedSource) + let unmarkedSource = markedText.textWithoutMarkers + let tree = Parser.parse(source: unmarkedSource, experimentalFeatures: experimentalFeatures) + let sourceFileSyntax = + try! OperatorTable.standardOperators.foldAll(tree).as(SourceFileSyntax.self)! + + var emittedFindings = [Finding]() + + // Force the rule to be enabled while we test it. + var configuration = Configuration.forTesting + configuration.rules[type.ruleName] = true + let context = makeContext( + sourceFileSyntax: sourceFileSyntax, + configuration: configuration, + selection: .infinite, + findingConsumer: { emittedFindings.append($0) } + ) + + var emittedPipelineFindings = [Finding]() + // Disable default rules, so only select rule runs in pipeline + configuration.rules = [type.ruleName: true] + let pipeline = SwiftLinter( + configuration: configuration, + findingConsumer: { emittedPipelineFindings.append($0) } + ) + pipeline.debugOptions.insert(.disablePrettyPrint) + try! pipeline.lint( + syntax: sourceFileSyntax, + source: unmarkedSource, + operatorTable: OperatorTable.standardOperators, + assumingFileURL: URL(fileURLWithPath: file.description) + ) + + // Check that pipeline produces the expected findings + assertFindings( + expected: findings, + markerLocations: markedText.markers, + emittedFindings: emittedPipelineFindings, + context: context, + file: file, + line: line + ) + } + + /// Asserts that the result of applying a formatter to the provided input code yields the output. + /// + /// This method should be called by each test of each rule. + /// + /// - Parameters: + /// - formatType: The metatype of the format rule you wish to apply. + /// - input: The unformatted input code. + /// - expected: The expected result of formatting the input code. + /// - findings: A list of `FindingSpec` values that describe the findings that are expected to + /// be emitted. + /// - configuration: The configuration to use when formatting (or nil to use the default). + /// - experimentalFeatures: The set of experimental features that should be enabled in the + /// parser. + /// - file: The file the test resides in (defaults to the current caller's file) + /// - line: The line the test resides in (defaults to the current caller's line) + final func assertFormatting( + _ formatType: SyntaxFormatRule.Type, + input: String, + expected: String, + findings: [FindingSpec] = [], + configuration: Configuration? = nil, + experimentalFeatures: Parser.ExperimentalFeatures = [], + file: StaticString = #file, + line: UInt = #line + ) { + let markedInput = MarkedText(textWithMarkers: input) + let originalSource: String = markedInput.textWithoutMarkers + let tree = Parser.parse(source: originalSource, experimentalFeatures: experimentalFeatures) + let sourceFileSyntax = + try! OperatorTable.standardOperators.foldAll(tree).as(SourceFileSyntax.self)! + + var emittedFindings = [Finding]() + + // Force the rule to be enabled while we test it. + var configuration = configuration ?? Configuration.forTesting + configuration.rules[formatType.ruleName] = true + let context = makeContext( + sourceFileSyntax: sourceFileSyntax, + configuration: configuration, + selection: .infinite, + findingConsumer: { emittedFindings.append($0) } + ) + + let formatter = formatType.init(context: context) + let actual = formatter.visit(sourceFileSyntax) + assertStringsEqualWithDiff("\(actual)", expected, file: file, line: line) + + assertFindings( + expected: findings, + markerLocations: markedInput.markers, + emittedFindings: emittedFindings, + context: context, + file: file, + line: line + ) + + // Verify that the pretty printer can consume the transformed tree (e.g., it does not contain + // any unfolded `SequenceExpr`s). Then do a whitespace-insensitive comparison of the two trees + // to verify that the format rule didn't transform the tree in such a way that it caused the + // pretty-printer to drop important information (the most likely case is a format rule + // misplacing trivia in a way that the pretty-printer isn't able to handle). + let prettyPrintedSource = PrettyPrinter( + context: context, + source: originalSource, + node: Syntax(actual), + printTokenStream: false, + whitespaceOnly: false + ).prettyPrint() + let prettyPrintedTree = Parser.parse(source: prettyPrintedSource, experimentalFeatures: experimentalFeatures) + XCTAssertEqual( + whitespaceInsensitiveText(of: actual), + whitespaceInsensitiveText(of: prettyPrintedTree), + "After pretty-printing and removing fluid whitespace, the files did not match", + file: file, + line: line + ) + + var emittedPipelineFindings = [Finding]() + // Disable default rules, so only select rule runs in pipeline + configuration.rules = [formatType.ruleName: true] + let pipeline = SwiftFormatter( + configuration: configuration, + findingConsumer: { emittedPipelineFindings.append($0) } + ) + pipeline.debugOptions.insert(.disablePrettyPrint) + var pipelineActual = "" + try! pipeline.format( + syntax: sourceFileSyntax, + source: originalSource, + operatorTable: OperatorTable.standardOperators, + assumingFileURL: nil, + selection: .infinite, + to: &pipelineActual + ) + assertStringsEqualWithDiff(pipelineActual, expected) + assertFindings( + expected: findings, + markerLocations: markedInput.markers, + emittedFindings: emittedPipelineFindings, + context: context, + file: file, + line: line + ) + } +} + +/// Returns a string containing a whitespace-insensitive representation of the given source file. +private func whitespaceInsensitiveText(of file: SourceFileSyntax) -> String { + var result = "" + for token in file.tokens(viewMode: .sourceAccurate) { + appendNonspaceTrivia(token.leadingTrivia, to: &result) + result.append(token.text) + appendNonspaceTrivia(token.trailingTrivia, to: &result) + } + return result +} + +/// Appends any non-whitespace trivia pieces from the given trivia collection to the output string. +private func appendNonspaceTrivia(_ trivia: Trivia, to string: inout String) { + for piece in trivia { + switch piece { + case .carriageReturnLineFeeds, .carriageReturns, .formfeeds, .newlines, .spaces, .tabs: + break + case .lineComment(let comment), .docLineComment(let comment): + // A tree transforming rule might leave whitespace at the end of a line comment, which the + // pretty printer will remove, so we should ignore that. + if let lastNonWhitespaceIndex = comment.lastIndex(where: { !$0.isWhitespace }) { + string.append(contentsOf: comment[...lastNonWhitespaceIndex]) + } else { + string.append(comment) + } + default: + piece.write(to: &string) + } + } +} diff --git a/Tests/SwiftFormatTests/Rules/NeverForceUnwrapTests.swift b/Tests/SwiftFormatTests/Rules/NeverForceUnwrapTests.swift new file mode 100644 index 000000000..9d4d61f4b --- /dev/null +++ b/Tests/SwiftFormatTests/Rules/NeverForceUnwrapTests.swift @@ -0,0 +1,61 @@ +@_spi(Rules) import SwiftFormat +import _SwiftFormatTestSupport + +final class NeverForceUnwrapTests: LintOrFormatRuleTestCase { + func testUnsafeUnwrap() { + assertLint( + NeverForceUnwrap.self, + """ + func someFunc() -> Int { + var a = getInt() + var b = 1️⃣a as! Int + let c = 2️⃣(someValue())! + let d = 3️⃣String(a)! + let regex = try! NSRegularExpression(pattern: "a*b+c?") + let e = /*comment about stuff*/ 4️⃣[1: a, 2: b, 3: c][4]! + var f = 5️⃣a as! /*comment about this type*/ FooBarType + return 6️⃣a! + } + """, + findings: [ + FindingSpec("1️⃣", message: "do not force cast to 'Int'"), + FindingSpec("2️⃣", message: "do not force unwrap '(someValue())'"), + FindingSpec("3️⃣", message: "do not force unwrap 'String(a)'"), + FindingSpec("4️⃣", message: "do not force unwrap '[1: a, 2: b, 3: c][4]'"), + FindingSpec("5️⃣", message: "do not force cast to 'FooBarType'"), + FindingSpec("6️⃣", message: "do not force unwrap 'a'"), + ] + ) + } + + func testIgnoreTestCode() { + assertLint( + NeverForceUnwrap.self, + """ + import XCTest + + var b = a as! Int + """, + findings: [] + ) + } + + func testIgnoreTestAttributeFunction() { + assertLint( + NeverForceUnwrap.self, + """ + @Test + func testSomeFunc() { + var b = a as! Int + } + @Test + func testAnotherFunc() { + func nestedFunc() { + let c = someValue()! + } + } + """, + findings: [] + ) + } +} diff --git a/Tests/SwiftFormatTests/Rules/NeverUseForceTryTests.swift b/Tests/SwiftFormatTests/Rules/NeverUseForceTryTests.swift new file mode 100644 index 000000000..907264222 --- /dev/null +++ b/Tests/SwiftFormatTests/Rules/NeverUseForceTryTests.swift @@ -0,0 +1,56 @@ +@_spi(Rules) import SwiftFormat +import _SwiftFormatTestSupport + +final class NeverUseForceTryTests: LintOrFormatRuleTestCase { + func testInvalidTryExpression() { + assertLint( + NeverUseForceTry.self, + """ + let document = 1️⃣try! Document(path: "important.data") + let document = try Document(path: "important.data") + let x = 2️⃣try! someThrowingFunction() + let x = try? someThrowingFunction( + 3️⃣try! someThrowingFunction() + ) + let x = try someThrowingFunction( + 4️⃣try! someThrowingFunction() + ) + if let data = try? fetchDataFromDisk() { return data } + """, + findings: [ + FindingSpec("1️⃣", message: "do not use force try"), + FindingSpec("2️⃣", message: "do not use force try"), + FindingSpec("3️⃣", message: "do not use force try"), + FindingSpec("4️⃣", message: "do not use force try"), + ] + ) + } + + func testAllowForceTryInTestCode() { + assertLint( + NeverUseForceTry.self, + """ + import XCTest + + let document = try! Document(path: "important.data") + """, + findings: [] + ) + } + + func testAllowForceTryInTestAttributeFunction() { + assertLint( + NeverUseForceTry.self, + """ + @Test + func testSomeFunc() { + let document = try! Document(path: "important.data") + func nestedFunc() { + let x = try! someThrowingFunction() + } + } + """, + findings: [] + ) + } +} diff --git a/Tests/SwiftFormatTests/Rules/NeverUseImplicitlyUnwrappedOptionalsTests.swift b/Tests/SwiftFormatTests/Rules/NeverUseImplicitlyUnwrappedOptionalsTests.swift new file mode 100644 index 000000000..2de8f4ee3 --- /dev/null +++ b/Tests/SwiftFormatTests/Rules/NeverUseImplicitlyUnwrappedOptionalsTests.swift @@ -0,0 +1,53 @@ +@_spi(Rules) import SwiftFormat +import _SwiftFormatTestSupport + +final class NeverUseImplicitlyUnwrappedOptionalsTests: LintOrFormatRuleTestCase { + func testInvalidVariableUnwrapping() { + assertLint( + NeverUseImplicitlyUnwrappedOptionals.self, + """ + import Core + import Foundation + import SwiftSyntax + + var foo: Int? + var s: 1️⃣String! + var f: /*this is a Foo*/2️⃣Foo! + var c, d, e: Float + @IBOutlet var button: UIButton! + """, + findings: [ + FindingSpec("1️⃣", message: "use 'String' or 'String?' instead of 'String!'"), + FindingSpec("2️⃣", message: "use 'Foo' or 'Foo?' instead of 'Foo!'"), + ] + ) + } + + func testIgnoreTestCode() { + assertLint( + NeverUseImplicitlyUnwrappedOptionals.self, + """ + import XCTest + + var s: String! + """, + findings: [] + ) + } + + func testIgnoreTestAttrinuteFunction() { + assertLint( + NeverUseImplicitlyUnwrappedOptionals.self, + """ + @Test + func testSomeFunc() { + var s: String! + func nestedFunc() { + var f: Foo! + } + } + """, + findings: [] + ) + } +} diff --git a/Tests/SwiftFormatTests/Rules/NoAccessLevelOnExtensionDeclarationTests.swift b/Tests/SwiftFormatTests/Rules/NoAccessLevelOnExtensionDeclarationTests.swift new file mode 100644 index 000000000..6735b8861 --- /dev/null +++ b/Tests/SwiftFormatTests/Rules/NoAccessLevelOnExtensionDeclarationTests.swift @@ -0,0 +1,347 @@ +@_spi(Rules) import SwiftFormat +import _SwiftFormatTestSupport + +final class NoAccessLevelOnExtensionDeclarationTests: LintOrFormatRuleTestCase { + func testExtensionDeclarationAccessLevel() { + assertFormatting( + NoAccessLevelOnExtensionDeclaration.self, + input: """ + 1️⃣public extension Foo { + 2️⃣var x: Bool + // Comment 1 + internal var y: Bool + // Comment 2 + 3️⃣static var z: Bool + // Comment 3 + 4️⃣static func someFunc() {} + 5️⃣init() {} + 6️⃣subscript(index: Int) -> Element {} + 7️⃣class SomeClass {} + 8️⃣struct SomeStruct {} + 9️⃣enum SomeEnum {} + 🔟typealias Foo = Bar + } + """, + expected: """ + extension Foo { + public var x: Bool + // Comment 1 + internal var y: Bool + // Comment 2 + public static var z: Bool + // Comment 3 + public static func someFunc() {} + public init() {} + public subscript(index: Int) -> Element {} + public class SomeClass {} + public struct SomeStruct {} + public enum SomeEnum {} + public typealias Foo = Bar + } + """, + findings: [ + FindingSpec( + "1️⃣", + message: "move this 'public' access modifier to precede each member inside this extension", + notes: [ + NoteSpec("2️⃣", message: "add 'public' access modifier to this declaration"), + NoteSpec("3️⃣", message: "add 'public' access modifier to this declaration"), + NoteSpec("4️⃣", message: "add 'public' access modifier to this declaration"), + NoteSpec("5️⃣", message: "add 'public' access modifier to this declaration"), + NoteSpec("6️⃣", message: "add 'public' access modifier to this declaration"), + NoteSpec("7️⃣", message: "add 'public' access modifier to this declaration"), + NoteSpec("8️⃣", message: "add 'public' access modifier to this declaration"), + NoteSpec("9️⃣", message: "add 'public' access modifier to this declaration"), + NoteSpec("🔟", message: "add 'public' access modifier to this declaration"), + ] + ) + ] + ) + } + + func testRemoveRedundantInternal() { + assertFormatting( + NoAccessLevelOnExtensionDeclaration.self, + input: """ + 1️⃣internal extension Bar { + var a: Int + var b: Int + } + """, + expected: """ + extension Bar { + var a: Int + var b: Int + } + """, + findings: [ + FindingSpec("1️⃣", message: "remove this redundant 'internal' access modifier from this extension") + ] + ) + } + + func testPreservesCommentOnRemovedModifier() { + assertFormatting( + NoAccessLevelOnExtensionDeclaration.self, + input: """ + /// This doc comment should stick around. + 1️⃣public extension Foo { + 3️⃣func f() {} + // This should not change. + 4️⃣func g() {} + } + + /// So should this one. + 2️⃣internal extension Foo { + func f() {} + // This should not change. + func g() {} + } + """, + expected: """ + /// This doc comment should stick around. + extension Foo { + public func f() {} + // This should not change. + public func g() {} + } + + /// So should this one. + extension Foo { + func f() {} + // This should not change. + func g() {} + } + """, + findings: [ + FindingSpec( + "1️⃣", + message: "move this 'public' access modifier to precede each member inside this extension", + notes: [ + NoteSpec("3️⃣", message: "add 'public' access modifier to this declaration"), + NoteSpec("4️⃣", message: "add 'public' access modifier to this declaration"), + ] + ), + FindingSpec("2️⃣", message: "remove this redundant 'internal' access modifier from this extension"), + ] + ) + } + + func testPackageAccessLevel() { + assertFormatting( + NoAccessLevelOnExtensionDeclaration.self, + input: """ + 1️⃣package extension Foo { + 2️⃣func f() {} + } + """, + expected: """ + extension Foo { + package func f() {} + } + """, + findings: [ + FindingSpec( + "1️⃣", + message: "move this 'package' access modifier to precede each member inside this extension", + notes: [ + NoteSpec("2️⃣", message: "add 'package' access modifier to this declaration") + ] + ) + ] + ) + } + + func testPrivateIsEffectivelyFileprivate() { + assertFormatting( + NoAccessLevelOnExtensionDeclaration.self, + input: """ + 1️⃣private extension Foo { + 2️⃣func f() {} + } + """, + expected: """ + extension Foo { + fileprivate func f() {} + } + """, + findings: [ + FindingSpec( + "1️⃣", + message: + "remove this 'private' access modifier and declare each member inside this extension as 'fileprivate'", + notes: [ + NoteSpec("2️⃣", message: "add 'fileprivate' access modifier to this declaration") + ] + ) + ] + ) + } + + func testExtensionWithAnnotation() { + assertFormatting( + NoAccessLevelOnExtensionDeclaration.self, + input: """ + /// This extension has a comment. + @objc 1️⃣public extension Foo { + } + """, + expected: """ + /// This extension has a comment. + @objc extension Foo { + } + """, + findings: [ + FindingSpec("1️⃣", message: "move this 'public' access modifier to precede each member inside this extension") + ] + ) + } + + func testPreservesInlineAnnotationsBeforeAddedAccessLevelModifiers() { + assertFormatting( + NoAccessLevelOnExtensionDeclaration.self, + input: """ + /// This extension has a comment. + 1️⃣public extension Foo { + /// This property has a doc comment. + 2️⃣@objc var x: Bool { get { return true }} + // This property has a developer comment. + 3️⃣@objc static var z: Bool { get { return false }} + /// This static function has a doc comment. + 4️⃣@objc static func someStaticFunc() {} + 5️⃣@objc init(with foo: Foo) {} + 6️⃣@objc func someOtherFunc() {} + 7️⃣@objc class SomeClass : NSObject {} + 8️⃣@objc typealias SomeType = SomeOtherType + 9️⃣@objc enum SomeEnum : Int { + case SomeInt = 32 + } + } + """, + expected: """ + /// This extension has a comment. + extension Foo { + /// This property has a doc comment. + @objc public var x: Bool { get { return true }} + // This property has a developer comment. + @objc public static var z: Bool { get { return false }} + /// This static function has a doc comment. + @objc public static func someStaticFunc() {} + @objc public init(with foo: Foo) {} + @objc public func someOtherFunc() {} + @objc public class SomeClass : NSObject {} + @objc public typealias SomeType = SomeOtherType + @objc public enum SomeEnum : Int { + case SomeInt = 32 + } + } + """, + findings: [ + FindingSpec( + "1️⃣", + message: "move this 'public' access modifier to precede each member inside this extension", + notes: [ + NoteSpec("2️⃣", message: "add 'public' access modifier to this declaration"), + NoteSpec("3️⃣", message: "add 'public' access modifier to this declaration"), + NoteSpec("4️⃣", message: "add 'public' access modifier to this declaration"), + NoteSpec("5️⃣", message: "add 'public' access modifier to this declaration"), + NoteSpec("6️⃣", message: "add 'public' access modifier to this declaration"), + NoteSpec("7️⃣", message: "add 'public' access modifier to this declaration"), + NoteSpec("8️⃣", message: "add 'public' access modifier to this declaration"), + NoteSpec("9️⃣", message: "add 'public' access modifier to this declaration"), + ] + ) + ] + ) + } + + func testPreservesMultiLineAnnotationsBeforeAddedAccessLevelModifiers() { + assertFormatting( + NoAccessLevelOnExtensionDeclaration.self, + input: """ + /// This extension has a comment. + 1️⃣public extension Foo { + /// This property has a doc comment. + 2️⃣@available(iOS 13, *) + var x: Bool { get { return true }} + // This property has a developer comment. + 3️⃣@available(iOS 13, *) + static var z: Bool { get { return false }} + // This static function has a developer comment. + 4️⃣@objc(someStaticFunction) + static func someStaticFunc() {} + 5️⃣@objc(initWithFoo:) + init(with foo: Foo) {} + 6️⃣@objc + func someOtherFunc() {} + 7️⃣@objc + class SomeClass : NSObject {} + 8️⃣@available(iOS 13, *) + typealias SomeType = SomeOtherType + 9️⃣@objc + enum SomeEnum : Int { + case SomeInt = 32 + } + + // This is a doc comment for a multi-argument method. + @objc( + doSomethingThatIsVeryComplicatedWithThisFoo: + forGoodMeasureUsingThisBar: + andApplyingThisBaz: + ) + public func doSomething(_ foo : Foo, bar : Bar, baz : Baz) {} + } + """, + expected: """ + /// This extension has a comment. + extension Foo { + /// This property has a doc comment. + @available(iOS 13, *) + public var x: Bool { get { return true }} + // This property has a developer comment. + @available(iOS 13, *) + public static var z: Bool { get { return false }} + // This static function has a developer comment. + @objc(someStaticFunction) + public static func someStaticFunc() {} + @objc(initWithFoo:) + public init(with foo: Foo) {} + @objc + public func someOtherFunc() {} + @objc + public class SomeClass : NSObject {} + @available(iOS 13, *) + public typealias SomeType = SomeOtherType + @objc + public enum SomeEnum : Int { + case SomeInt = 32 + } + + // This is a doc comment for a multi-argument method. + @objc( + doSomethingThatIsVeryComplicatedWithThisFoo: + forGoodMeasureUsingThisBar: + andApplyingThisBaz: + ) + public func doSomething(_ foo : Foo, bar : Bar, baz : Baz) {} + } + """, + findings: [ + FindingSpec( + "1️⃣", + message: "move this 'public' access modifier to precede each member inside this extension", + notes: [ + NoteSpec("2️⃣", message: "add 'public' access modifier to this declaration"), + NoteSpec("3️⃣", message: "add 'public' access modifier to this declaration"), + NoteSpec("4️⃣", message: "add 'public' access modifier to this declaration"), + NoteSpec("5️⃣", message: "add 'public' access modifier to this declaration"), + NoteSpec("6️⃣", message: "add 'public' access modifier to this declaration"), + NoteSpec("7️⃣", message: "add 'public' access modifier to this declaration"), + NoteSpec("8️⃣", message: "add 'public' access modifier to this declaration"), + NoteSpec("9️⃣", message: "add 'public' access modifier to this declaration"), + ] + ) + ] + ) + } +} diff --git a/Tests/SwiftFormatTests/Rules/NoAssignmentInExpressionsTests.swift b/Tests/SwiftFormatTests/Rules/NoAssignmentInExpressionsTests.swift new file mode 100644 index 000000000..9ac022653 --- /dev/null +++ b/Tests/SwiftFormatTests/Rules/NoAssignmentInExpressionsTests.swift @@ -0,0 +1,227 @@ +@_spi(Rules) import SwiftFormat +import _SwiftFormatTestSupport + +final class NoAssignmentInExpressionsTests: LintOrFormatRuleTestCase { + func testAssignmentInExpressionContextIsDiagnosed() { + assertFormatting( + NoAssignmentInExpressions.self, + input: """ + foo(bar, 1️⃣baz = quux, a + b) + """, + expected: """ + foo(bar, baz = quux, a + b) + """, + findings: [ + FindingSpec("1️⃣", message: "move this assignment expression into its own statement") + ] + ) + } + + func testReturnStatementWithoutExpressionIsUnchanged() { + assertFormatting( + NoAssignmentInExpressions.self, + input: """ + func foo() { + return + } + """, + expected: """ + func foo() { + return + } + """, + findings: [] + ) + } + + func testReturnStatementWithNonAssignmentExpressionIsUnchanged() { + assertFormatting( + NoAssignmentInExpressions.self, + input: """ + func foo() { + return a + b + } + """, + expected: """ + func foo() { + return a + b + } + """, + findings: [] + ) + } + + func testReturnStatementWithSimpleAssignmentExpressionIsExpanded() { + // For this and similar tests below, we don't try to match the leading indentation in the new + // `return` statement; the pretty-printer will fix it up. + assertFormatting( + NoAssignmentInExpressions.self, + input: """ + func foo() { + return 1️⃣a = b + } + """, + expected: """ + func foo() { + a = b + return + } + """, + findings: [ + FindingSpec("1️⃣", message: "move this assignment expression into its own statement") + ] + ) + } + + func testReturnStatementWithCompoundAssignmentExpressionIsExpanded() { + assertFormatting( + NoAssignmentInExpressions.self, + input: """ + func foo() { + return 1️⃣a += b + } + """, + expected: """ + func foo() { + a += b + return + } + """, + findings: [ + FindingSpec("1️⃣", message: "move this assignment expression into its own statement") + ] + ) + } + + func testReturnStatementWithAssignmentDealsWithLeadingLineCommentSensibly() { + assertFormatting( + NoAssignmentInExpressions.self, + input: """ + func foo() { + // some comment + return 1️⃣a = b + } + """, + expected: """ + func foo() { + // some comment + a = b + return + } + """, + findings: [ + FindingSpec("1️⃣", message: "move this assignment expression into its own statement") + ] + ) + } + + func testReturnStatementWithAssignmentDealsWithTrailingLineCommentSensibly() { + assertFormatting( + NoAssignmentInExpressions.self, + input: """ + func foo() { + return 1️⃣a = b // some comment + } + """, + expected: """ + func foo() { + a = b + return // some comment + } + """, + findings: [ + FindingSpec("1️⃣", message: "move this assignment expression into its own statement") + ] + ) + } + + func testReturnStatementWithAssignmentDealsWithTrailingBlockCommentSensibly() { + assertFormatting( + NoAssignmentInExpressions.self, + input: """ + func foo() { + return 1️⃣a = b /* some comment */ + } + """, + expected: """ + func foo() { + a = b + return /* some comment */ + } + """, + findings: [ + FindingSpec("1️⃣", message: "move this assignment expression into its own statement") + ] + ) + } + + func testReturnStatementWithAssignmentDealsWithNestedBlockCommentSensibly() { + assertFormatting( + NoAssignmentInExpressions.self, + input: """ + func foo() { + return /* some comment */ 1️⃣a = b + } + """, + expected: """ + func foo() { + /* some comment */ a = b + return + } + """, + findings: [ + FindingSpec("1️⃣", message: "move this assignment expression into its own statement") + ] + ) + } + + func testTryAndAwaitAssignmentExpressionsAreUnchanged() { + assertFormatting( + NoAssignmentInExpressions.self, + input: """ + func foo() { + try a.b = c + await a.b = c + } + """, + expected: """ + func foo() { + try a.b = c + await a.b = c + } + """, + findings: [] + ) + } + + func testAssignmentExpressionsInAllowedFunctions() { + assertFormatting( + NoAssignmentInExpressions.self, + input: """ + // These should not diagnose. + XCTAssertNoThrow(a = try b()) + XCTAssertNoThrow { a = try b() } + XCTAssertNoThrow({ a = try b() }) + someRegularFunction({ a = b }) + someRegularFunction { a = b } + + // This should be diagnosed. + someRegularFunction(1️⃣a = b) + """, + expected: """ + // These should not diagnose. + XCTAssertNoThrow(a = try b()) + XCTAssertNoThrow { a = try b() } + XCTAssertNoThrow({ a = try b() }) + someRegularFunction({ a = b }) + someRegularFunction { a = b } + + // This should be diagnosed. + someRegularFunction(a = b) + """, + findings: [ + FindingSpec("1️⃣", message: "move this assignment expression into its own statement") + ] + ) + } +} diff --git a/Tests/SwiftFormatTests/Rules/NoBlockCommentsTests.swift b/Tests/SwiftFormatTests/Rules/NoBlockCommentsTests.swift new file mode 100644 index 000000000..afd82e297 --- /dev/null +++ b/Tests/SwiftFormatTests/Rules/NoBlockCommentsTests.swift @@ -0,0 +1,38 @@ +@_spi(Rules) import SwiftFormat +import _SwiftFormatTestSupport + +final class NoBlockCommentsTests: LintOrFormatRuleTestCase { + func testDiagnoseBlockComments() { + assertLint( + NoBlockComments.self, + """ + 1️⃣/* + Lorem ipsum dolor sit amet, at nonumes adipisci sea, natum + offendit vis ex. Audiam legendos expetenda ei quo, nonumes + + msensibus eloquentiam ex vix. + */ + let a = 2️⃣/*ff*/10 3️⃣/*ff*/ + 10 + var b = 04️⃣/*Block Comment inline with code*/ + + 5️⃣/* + + Block Comment + */ + let c = a + b + 6️⃣/* This is the end + of a file + + */ + """, + findings: [ + FindingSpec("1️⃣", message: "replace this block comment with line comments"), + FindingSpec("2️⃣", message: "replace this block comment with line comments"), + FindingSpec("3️⃣", message: "replace this block comment with line comments"), + FindingSpec("4️⃣", message: "replace this block comment with line comments"), + FindingSpec("5️⃣", message: "replace this block comment with line comments"), + FindingSpec("6️⃣", message: "replace this block comment with line comments"), + ] + ) + } +} diff --git a/Tests/SwiftFormatRulesTests/NoCasesWithOnlyFallthroughTests.swift b/Tests/SwiftFormatTests/Rules/NoCasesWithOnlyFallthroughTests.swift similarity index 54% rename from Tests/SwiftFormatRulesTests/NoCasesWithOnlyFallthroughTests.swift rename to Tests/SwiftFormatTests/Rules/NoCasesWithOnlyFallthroughTests.swift index 0d8b18448..66e7da94a 100644 --- a/Tests/SwiftFormatRulesTests/NoCasesWithOnlyFallthroughTests.swift +++ b/Tests/SwiftFormatTests/Rules/NoCasesWithOnlyFallthroughTests.swift @@ -1,32 +1,33 @@ -import SwiftFormatRules +@_spi(Rules) import SwiftFormat +import _SwiftFormatTestSupport final class NoCasesWithOnlyFallthroughTests: LintOrFormatRuleTestCase { func testFallthroughCases() { - XCTAssertFormatting( + assertFormatting( NoCasesWithOnlyFallthrough.self, input: """ switch numbers { case 1: print("one") - case 2: fallthrough - case 3: fallthrough + 1️⃣case 2: fallthrough + 2️⃣case 3: fallthrough case 4: print("two to four") - case 5: fallthrough + 3️⃣case 5: fallthrough case 7: print("five or seven") default: break } switch letters { - case "a": fallthrough - case "b", "c": fallthrough + 4️⃣case "a": fallthrough + 5️⃣case "b", "c": fallthrough case "d": print("abcd") case "e": print("e") - case "f": fallthrough + 6️⃣case "f": fallthrough case "z": print("fz") default: break } switch tokens { case .comma: print(",") - case .rightBrace: fallthrough - case .leftBrace: fallthrough + 7️⃣case .rightBrace: fallthrough + 8️⃣case .leftBrace: fallthrough case .braces: print("{}") case .period: print(".") case .empty: fallthrough @@ -54,26 +55,27 @@ final class NoCasesWithOnlyFallthroughTests: LintOrFormatRuleTestCase { default: break } """, - checkForUnassertedDiagnostics: true) - - XCTAssertDiagnosed(.collapseCase(name: "2"), line: 3, column: 1) - XCTAssertDiagnosed(.collapseCase(name: "3"), line: 4, column: 1) - XCTAssertDiagnosed(.collapseCase(name: "5"), line: 6, column: 1) - XCTAssertDiagnosed(.collapseCase(name: "\"a\""), line: 11, column: 1) - XCTAssertDiagnosed(.collapseCase(name: "\"b\", \"c\""), line: 12, column: 1) - XCTAssertDiagnosed(.collapseCase(name: "\"f\""), line: 15, column: 1) - XCTAssertDiagnosed(.collapseCase(name: ".rightBrace"), line: 21, column: 1) - XCTAssertDiagnosed(.collapseCase(name: ".leftBrace"), line: 22, column: 1) + findings: [ + FindingSpec("1️⃣", message: "combine this fallthrough-only 'case' and the following 'case' into a single 'case'"), + FindingSpec("2️⃣", message: "combine this fallthrough-only 'case' and the following 'case' into a single 'case'"), + FindingSpec("3️⃣", message: "combine this fallthrough-only 'case' and the following 'case' into a single 'case'"), + FindingSpec("4️⃣", message: "combine this fallthrough-only 'case' and the following 'case' into a single 'case'"), + FindingSpec("5️⃣", message: "combine this fallthrough-only 'case' and the following 'case' into a single 'case'"), + FindingSpec("6️⃣", message: "combine this fallthrough-only 'case' and the following 'case' into a single 'case'"), + FindingSpec("7️⃣", message: "combine this fallthrough-only 'case' and the following 'case' into a single 'case'"), + FindingSpec("8️⃣", message: "combine this fallthrough-only 'case' and the following 'case' into a single 'case'"), + ] + ) } func testFallthroughCasesWithCommentsAreNotCombined() { - XCTAssertFormatting( + assertFormatting( NoCasesWithOnlyFallthrough.self, input: """ switch numbers { case 1: return 0 // This return has an inline comment. - case 2: fallthrough + 1️⃣case 2: fallthrough // This case is commented so it should stay. case 3: fallthrough @@ -81,7 +83,7 @@ final class NoCasesWithOnlyFallthroughTests: LintOrFormatRuleTestCase { // This fallthrough is commented so it should stay. fallthrough case 5: fallthrough // This fallthrough is relevant. - case 6: + 2️⃣case 6: fallthrough // This case has a descriptive comment. case 7: print("got here") @@ -102,23 +104,24 @@ final class NoCasesWithOnlyFallthroughTests: LintOrFormatRuleTestCase { case 6, 7: print("got here") } """, - checkForUnassertedDiagnostics: true) - - XCTAssertDiagnosed(.collapseCase(name: "2"), line: 4, column: 1) - XCTAssertDiagnosed(.collapseCase(name: "6"), line: 12, column: 1) + findings: [ + FindingSpec("1️⃣", message: "combine this fallthrough-only 'case' and the following 'case' into a single 'case'"), + FindingSpec("2️⃣", message: "combine this fallthrough-only 'case' and the following 'case' into a single 'case'"), + ] + ) } func testCommentsAroundCombinedCasesStayInPlace() { - XCTAssertFormatting( + assertFormatting( NoCasesWithOnlyFallthrough.self, input: """ switch numbers { case 5: return 42 // This return is important. - case 6: fallthrough + 1️⃣case 6: fallthrough // This case has an important comment. case 7: print("6 to 7") - case 8: fallthrough + 2️⃣case 8: fallthrough // This case has an extra leading newline for emphasis. case 9: print("8 to 9") @@ -135,27 +138,28 @@ final class NoCasesWithOnlyFallthroughTests: LintOrFormatRuleTestCase { case 8, 9: print("8 to 9") } """, - checkForUnassertedDiagnostics: true) - - XCTAssertDiagnosed(.collapseCase(name: "6"), line: 4, column: 1) - XCTAssertDiagnosed(.collapseCase(name: "8"), line: 7, column: 1) + findings: [ + FindingSpec("1️⃣", message: "combine this fallthrough-only 'case' and the following 'case' into a single 'case'"), + FindingSpec("2️⃣", message: "combine this fallthrough-only 'case' and the following 'case' into a single 'case'"), + ] + ) } func testNestedSwitches() { - XCTAssertFormatting( + assertFormatting( NoCasesWithOnlyFallthrough.self, input: """ switch x { - case 1: fallthrough - case 2: fallthrough + 1️⃣case 1: fallthrough + 2️⃣case 2: fallthrough case 3: switch y { - case 1: fallthrough + 3️⃣case 1: fallthrough case 2: print(2) } case 4: switch y { - case 1: fallthrough + 4️⃣case 1: fallthrough case 2: print(2) } } @@ -172,27 +176,27 @@ final class NoCasesWithOnlyFallthroughTests: LintOrFormatRuleTestCase { } } """, - checkForUnassertedDiagnostics: true) - - XCTAssertDiagnosed(.collapseCase(name: "1"), line: 2, column: 1) - XCTAssertDiagnosed(.collapseCase(name: "2"), line: 3, column: 1) - // TODO: Column 9 seems wrong here; it should be 3. Look into this. - XCTAssertDiagnosed(.collapseCase(name: "1"), line: 6, column: 9) - XCTAssertDiagnosed(.collapseCase(name: "1"), line: 11, column: 3) + findings: [ + FindingSpec("1️⃣", message: "combine this fallthrough-only 'case' and the following 'case' into a single 'case'"), + FindingSpec("2️⃣", message: "combine this fallthrough-only 'case' and the following 'case' into a single 'case'"), + FindingSpec("3️⃣", message: "combine this fallthrough-only 'case' and the following 'case' into a single 'case'"), + FindingSpec("4️⃣", message: "combine this fallthrough-only 'case' and the following 'case' into a single 'case'"), + ] + ) } func testCasesInsideConditionalCompilationBlock() { - XCTAssertFormatting( + assertFormatting( NoCasesWithOnlyFallthrough.self, input: """ switch x { case 1: fallthrough #if FOO - case 2: fallthrough + 1️⃣case 2: fallthrough case 3: print(3) case 4: print(4) #endif - case 5: fallthrough + 2️⃣case 5: fallthrough case 6: print(6) #if BAR #if BAZ @@ -222,29 +226,30 @@ final class NoCasesWithOnlyFallthroughTests: LintOrFormatRuleTestCase { case 10: print(10) } """, - checkForUnassertedDiagnostics: true) - - XCTAssertDiagnosed(.collapseCase(name: "2"), line: 4, column: 1) - XCTAssertDiagnosed(.collapseCase(name: "5"), line: 8, column: 1) + findings: [ + FindingSpec("1️⃣", message: "combine this fallthrough-only 'case' and the following 'case' into a single 'case'"), + FindingSpec("2️⃣", message: "combine this fallthrough-only 'case' and the following 'case' into a single 'case'"), + ] + ) } func testCasesWithWhereClauses() { // As noted in the rule implementation, the formatted result should include a newline before any // case items that have `where` clauses if they follow any case items that do not, to avoid // compiler warnings. This is handled by the pretty printer, not this rule. - XCTAssertFormatting( + assertFormatting( NoCasesWithOnlyFallthrough.self, input: """ switch x { - case 1 where y < 0: fallthrough - case 2 where y == 0: fallthrough - case 3 where y < 0: fallthrough + 1️⃣case 1 where y < 0: fallthrough + 2️⃣case 2 where y == 0: fallthrough + 3️⃣case 3 where y < 0: fallthrough case 4 where y != 0: print(4) - case 5: fallthrough - case 6: fallthrough - case 7: fallthrough - case 8: fallthrough - case 9: fallthrough + 4️⃣case 5: fallthrough + 5️⃣case 6: fallthrough + 6️⃣case 7: fallthrough + 7️⃣case 8: fallthrough + 8️⃣case 9: fallthrough case 10 where y == 0: print(10) default: print("?") } @@ -256,31 +261,32 @@ final class NoCasesWithOnlyFallthroughTests: LintOrFormatRuleTestCase { default: print("?") } """, - checkForUnassertedDiagnostics: true) - - XCTAssertDiagnosed(.collapseCase(name: "1 where y < 0"), line: 2, column: 1) - XCTAssertDiagnosed(.collapseCase(name: "2 where y == 0"), line: 3, column: 1) - XCTAssertDiagnosed(.collapseCase(name: "3 where y < 0"), line: 4, column: 1) - XCTAssertDiagnosed(.collapseCase(name: "5"), line: 6, column: 1) - XCTAssertDiagnosed(.collapseCase(name: "6"), line: 7, column: 1) - XCTAssertDiagnosed(.collapseCase(name: "7"), line: 8, column: 1) - XCTAssertDiagnosed(.collapseCase(name: "8"), line: 9, column: 1) - XCTAssertDiagnosed(.collapseCase(name: "9"), line: 10, column: 1) + findings: [ + FindingSpec("1️⃣", message: "combine this fallthrough-only 'case' and the following 'case' into a single 'case'"), + FindingSpec("2️⃣", message: "combine this fallthrough-only 'case' and the following 'case' into a single 'case'"), + FindingSpec("3️⃣", message: "combine this fallthrough-only 'case' and the following 'case' into a single 'case'"), + FindingSpec("4️⃣", message: "combine this fallthrough-only 'case' and the following 'case' into a single 'case'"), + FindingSpec("5️⃣", message: "combine this fallthrough-only 'case' and the following 'case' into a single 'case'"), + FindingSpec("6️⃣", message: "combine this fallthrough-only 'case' and the following 'case' into a single 'case'"), + FindingSpec("7️⃣", message: "combine this fallthrough-only 'case' and the following 'case' into a single 'case'"), + FindingSpec("8️⃣", message: "combine this fallthrough-only 'case' and the following 'case' into a single 'case'"), + ] + ) } func testCasesWithValueBindingsAreNotMerged() { - XCTAssertFormatting( + assertFormatting( NoCasesWithOnlyFallthrough.self, input: """ switch x { - case .a: fallthrough + 1️⃣case .a: fallthrough case .b: fallthrough case .c(let x): fallthrough case .d(let y): fallthrough - case .e: fallthrough + 2️⃣case .e: fallthrough case .f: fallthrough case (let g, let h): fallthrough - case .i: fallthrough + 3️⃣case .i: fallthrough case .j?: fallthrough case let k as K: fallthrough case .l: break @@ -298,19 +304,20 @@ final class NoCasesWithOnlyFallthroughTests: LintOrFormatRuleTestCase { case .l: break } """, - checkForUnassertedDiagnostics: true) - - XCTAssertDiagnosed(.collapseCase(name: ".a"), line: 2, column: 1) - XCTAssertDiagnosed(.collapseCase(name: ".e"), line: 6, column: 1) - XCTAssertDiagnosed(.collapseCase(name: ".i"), line: 9, column: 1) + findings: [ + FindingSpec("1️⃣", message: "combine this fallthrough-only 'case' and the following 'case' into a single 'case'"), + FindingSpec("2️⃣", message: "combine this fallthrough-only 'case' and the following 'case' into a single 'case'"), + FindingSpec("3️⃣", message: "combine this fallthrough-only 'case' and the following 'case' into a single 'case'"), + ] + ) } func testFallthroughOnlyCasesAreNotMergedWithDefault() { - XCTAssertFormatting( + assertFormatting( NoCasesWithOnlyFallthrough.self, input: """ switch x { - case .a: fallthrough + 1️⃣case .a: fallthrough case .b: fallthrough default: print("got here") } @@ -321,17 +328,18 @@ final class NoCasesWithOnlyFallthroughTests: LintOrFormatRuleTestCase { default: print("got here") } """, - checkForUnassertedDiagnostics: true) - - XCTAssertDiagnosed(.collapseCase(name: ".a"), line: 2, column: 1) + findings: [ + FindingSpec("1️⃣", message: "combine this fallthrough-only 'case' and the following 'case' into a single 'case'") + ] + ) } func testFallthroughOnlyCasesAreNotMergedWithUnknownDefault() { - XCTAssertFormatting( + assertFormatting( NoCasesWithOnlyFallthrough.self, input: """ switch x { - case .a: fallthrough + 1️⃣case .a: fallthrough case .b: fallthrough @unknown default: print("got here") } @@ -342,8 +350,9 @@ final class NoCasesWithOnlyFallthroughTests: LintOrFormatRuleTestCase { @unknown default: print("got here") } """, - checkForUnassertedDiagnostics: true) - - XCTAssertDiagnosed(.collapseCase(name: ".a"), line: 2, column: 1) + findings: [ + FindingSpec("1️⃣", message: "combine this fallthrough-only 'case' and the following 'case' into a single 'case'") + ] + ) } } diff --git a/Tests/SwiftFormatTests/Rules/NoEmptyLinesOpeningClosingBracesTests.swift b/Tests/SwiftFormatTests/Rules/NoEmptyLinesOpeningClosingBracesTests.swift new file mode 100644 index 000000000..bb555739a --- /dev/null +++ b/Tests/SwiftFormatTests/Rules/NoEmptyLinesOpeningClosingBracesTests.swift @@ -0,0 +1,195 @@ +@_spi(Rules) import SwiftFormat +import _SwiftFormatTestSupport + +final class NoEmptyLinesOpeningClosingBracesTests: LintOrFormatRuleTestCase { + func testNoEmptyLinesOpeningClosingBracesInCodeBlock() { + assertFormatting( + NoEmptyLinesOpeningClosingBraces.self, + input: """ + func f() {1️⃣ + + // + return + + + 2️⃣} + """, + expected: """ + func f() { + // + return + } + """, + findings: [ + FindingSpec("1️⃣", message: "remove empty line after '{'"), + FindingSpec("2️⃣", message: "remove empty lines before '}'"), + ] + ) + } + + func testNoEmptyLinesOpeningClosingBracesInMemberBlock() { + assertFormatting( + NoEmptyLinesOpeningClosingBraces.self, + input: """ + struct {1️⃣ + + let x: Int + + let y: Int + + 2️⃣} + """, + expected: """ + struct { + let x: Int + + let y: Int + } + """, + findings: [ + FindingSpec("1️⃣", message: "remove empty line after '{'"), + FindingSpec("2️⃣", message: "remove empty line before '}'"), + ] + ) + } + + func testNoEmptyLinesOpeningClosingBracesInAccessorBlock() { + assertFormatting( + NoEmptyLinesOpeningClosingBraces.self, + input: """ + var x: Int {1️⃣ + + // + return _x + + 2️⃣} + + var y: Int {3️⃣ + + get 5️⃣{ + + // + return _y + + 6️⃣ } + + set 7️⃣{ + + // + _x = newValue + + 8️⃣ } + + 4️⃣} + """, + expected: """ + var x: Int { + // + return _x + } + + var y: Int { + get { + // + return _y + } + + set { + // + _x = newValue + } + } + """, + findings: [ + FindingSpec("1️⃣", message: "remove empty line after '{'"), + FindingSpec("2️⃣", message: "remove empty line before '}'"), + FindingSpec("3️⃣", message: "remove empty line after '{'"), + FindingSpec("4️⃣", message: "remove empty line before '}'"), + FindingSpec("5️⃣", message: "remove empty line after '{'"), + FindingSpec("6️⃣", message: "remove empty line before '}'"), + FindingSpec("7️⃣", message: "remove empty line after '{'"), + FindingSpec("8️⃣", message: "remove empty line before '}'"), + ] + ) + } + + func testNoEmptyLinesOpeningClosingBracesInClosureExpr() { + assertFormatting( + NoEmptyLinesOpeningClosingBraces.self, + input: """ + let closure = {1️⃣ + + // + return + + 2️⃣} + """, + expected: """ + let closure = { + // + return + } + """, + findings: [ + FindingSpec("1️⃣", message: "remove empty line after '{'"), + FindingSpec("2️⃣", message: "remove empty line before '}'"), + ] + ) + } + + func testNoEmptyLinesOpeningClosingBracesInFunctionBeginningAndEndingWithComment() { + assertFormatting( + NoEmptyLinesOpeningClosingBraces.self, + input: """ + func myFunc() { + // Some comment here + + // Do a thing + var x = doAThing() + + // Do a thing + + var y = doAThing() + + // Some other comment here + } + """, + expected: """ + func myFunc() { + // Some comment here + + // Do a thing + var x = doAThing() + + // Do a thing + + var y = doAThing() + + // Some other comment here + } + """ + ) + } + + func testNoEmptyLinesOpeningClosingBracesInFunctionWithEmptyLinesOnly() { + assertFormatting( + NoEmptyLinesOpeningClosingBraces.self, + input: """ + func myFunc() { + + + + + + 1️⃣} + """, + expected: """ + func myFunc() { + } + """, + findings: [ + FindingSpec("1️⃣", message: "remove empty lines before '}'") + ] + ) + } +} diff --git a/Tests/SwiftFormatTests/Rules/NoEmptyTrailingClosureParenthesesTests.swift b/Tests/SwiftFormatTests/Rules/NoEmptyTrailingClosureParenthesesTests.swift new file mode 100644 index 000000000..0e11de115 --- /dev/null +++ b/Tests/SwiftFormatTests/Rules/NoEmptyTrailingClosureParenthesesTests.swift @@ -0,0 +1,110 @@ +@_spi(Rules) import SwiftFormat +import _SwiftFormatTestSupport + +final class NoEmptyTrailingClosureParenthesesTests: LintOrFormatRuleTestCase { + func testInvalidEmptyParenTrailingClosure() { + assertFormatting( + NoEmptyTrailingClosureParentheses.self, + input: """ + func greetEnthusiastically(_ nameProvider: () -> String) { + // ... + } + func greetApathetically(_ nameProvider: () -> String) { + // ... + } + greetEnthusiastically0️⃣() { "John" } + greetApathetically { "not John" } + func myfunc(cls: MyClass) { + cls.myClosure { $0 } + } + func myfunc(cls: MyClass) { + cls.myBadClosure1️⃣() { $0 } + } + DispatchQueue.main.async2️⃣() { + greetEnthusiastically3️⃣() { "John" } + DispatchQueue.main.async4️⃣() { + greetEnthusiastically5️⃣() { "Willis" } + } + } + DispatchQueue.global.async(inGroup: blah) { + DispatchQueue.main.async6️⃣() { + greetEnthusiastically7️⃣() { "Willis" } + } + DispatchQueue.main.async { + greetEnthusiastically8️⃣() { "Willis" } + } + } + foo(bar🔟() { baz })9️⃣() { blah } + """, + expected: """ + func greetEnthusiastically(_ nameProvider: () -> String) { + // ... + } + func greetApathetically(_ nameProvider: () -> String) { + // ... + } + greetEnthusiastically { "John" } + greetApathetically { "not John" } + func myfunc(cls: MyClass) { + cls.myClosure { $0 } + } + func myfunc(cls: MyClass) { + cls.myBadClosure { $0 } + } + DispatchQueue.main.async { + greetEnthusiastically { "John" } + DispatchQueue.main.async { + greetEnthusiastically { "Willis" } + } + } + DispatchQueue.global.async(inGroup: blah) { + DispatchQueue.main.async { + greetEnthusiastically { "Willis" } + } + DispatchQueue.main.async { + greetEnthusiastically { "Willis" } + } + } + foo(bar { baz }) { blah } + """, + findings: [ + FindingSpec("0️⃣", message: "remove the empty parentheses following 'greetEnthusiastically'"), + FindingSpec("1️⃣", message: "remove the empty parentheses following 'myBadClosure'"), + FindingSpec("2️⃣", message: "remove the empty parentheses following 'async'"), + FindingSpec("3️⃣", message: "remove the empty parentheses following 'greetEnthusiastically'"), + FindingSpec("4️⃣", message: "remove the empty parentheses following 'async'"), + FindingSpec("5️⃣", message: "remove the empty parentheses following 'greetEnthusiastically'"), + FindingSpec("6️⃣", message: "remove the empty parentheses following 'async'"), + FindingSpec("7️⃣", message: "remove the empty parentheses following 'greetEnthusiastically'"), + FindingSpec("8️⃣", message: "remove the empty parentheses following 'greetEnthusiastically'"), + FindingSpec("9️⃣", message: "remove the empty parentheses following ')'"), + FindingSpec("🔟", message: "remove the empty parentheses following 'bar'"), + ] + ) + } + + func testDoNotRemoveParensContainingOnlyComments() { + assertFormatting( + NoEmptyTrailingClosureParentheses.self, + input: """ + greetEnthusiastically(/*oldArg: x*/) { "John" } + greetEnthusiastically( + /*oldArg: x*/ + ) { "John" } + greetEnthusiastically( + // oldArg: x + ) { "John" } + """, + expected: """ + greetEnthusiastically(/*oldArg: x*/) { "John" } + greetEnthusiastically( + /*oldArg: x*/ + ) { "John" } + greetEnthusiastically( + // oldArg: x + ) { "John" } + """, + findings: [] + ) + } +} diff --git a/Tests/SwiftFormatTests/Rules/NoLabelsInCasePatternsTests.swift b/Tests/SwiftFormatTests/Rules/NoLabelsInCasePatternsTests.swift new file mode 100644 index 000000000..ab95fb2ec --- /dev/null +++ b/Tests/SwiftFormatTests/Rules/NoLabelsInCasePatternsTests.swift @@ -0,0 +1,35 @@ +@_spi(Rules) import SwiftFormat +import _SwiftFormatTestSupport + +final class NoLabelsInCasePatternsTests: LintOrFormatRuleTestCase { + func testRedundantCaseLabels() { + assertFormatting( + NoLabelsInCasePatterns.self, + input: """ + switch treeNode { + case .root(let data): + break + case .subtree(1️⃣left: let /*hello*/left, 2️⃣right: let right): + break + case .leaf(3️⃣element: let element): + break + } + """, + expected: """ + switch treeNode { + case .root(let data): + break + case .subtree(let /*hello*/left, let right): + break + case .leaf(let element): + break + } + """, + findings: [ + FindingSpec("1️⃣", message: "remove the label 'left' from this 'case' pattern"), + FindingSpec("2️⃣", message: "remove the label 'right' from this 'case' pattern"), + FindingSpec("3️⃣", message: "remove the label 'element' from this 'case' pattern"), + ] + ) + } +} diff --git a/Tests/SwiftFormatTests/Rules/NoLeadingUnderscoresTests.swift b/Tests/SwiftFormatTests/Rules/NoLeadingUnderscoresTests.swift new file mode 100644 index 000000000..8339bed9a --- /dev/null +++ b/Tests/SwiftFormatTests/Rules/NoLeadingUnderscoresTests.swift @@ -0,0 +1,165 @@ +@_spi(Rules) import SwiftFormat +import _SwiftFormatTestSupport + +final class NoLeadingUnderscoresTests: LintOrFormatRuleTestCase { + func testVars() { + assertLint( + NoLeadingUnderscores.self, + """ + let 1️⃣_foo = foo + var good_name = 20 + var 2️⃣_badName, okayName, 3️⃣_wor_sEName = 20 + """, + findings: [ + FindingSpec("1️⃣", message: "remove the leading '_' from the name '_foo'"), + FindingSpec("2️⃣", message: "remove the leading '_' from the name '_badName'"), + FindingSpec("3️⃣", message: "remove the leading '_' from the name '_wor_sEName'"), + ] + ) + } + + func testClasses() { + assertLint( + NoLeadingUnderscores.self, + """ + class Foo { let 1️⃣_foo = foo } + class 2️⃣_Bar {} + """, + findings: [ + FindingSpec("1️⃣", message: "remove the leading '_' from the name '_foo'"), + FindingSpec("2️⃣", message: "remove the leading '_' from the name '_Bar'"), + ] + ) + } + + func testEnums() { + assertLint( + NoLeadingUnderscores.self, + """ + enum Foo { + case 1️⃣_case1 + case case2, 2️⃣_case3 + case caseWithAssociatedValues(3️⃣_value: Int, otherValue: String) + let 4️⃣_foo = foo + } + enum 5️⃣_Bar {} + """, + findings: [ + FindingSpec("1️⃣", message: "remove the leading '_' from the name '_case1'"), + FindingSpec("2️⃣", message: "remove the leading '_' from the name '_case3'"), + FindingSpec("3️⃣", message: "remove the leading '_' from the name '_value'"), + FindingSpec("4️⃣", message: "remove the leading '_' from the name '_foo'"), + FindingSpec("5️⃣", message: "remove the leading '_' from the name '_Bar'"), + ] + ) + } + + func testProtocols() { + assertLint( + NoLeadingUnderscores.self, + """ + protocol Foo { + associatedtype 1️⃣_Quux + associatedtype Florb + var 2️⃣_foo: Int { get set } + } + protocol 3️⃣_Bar {} + """, + findings: [ + FindingSpec("1️⃣", message: "remove the leading '_' from the name '_Quux'"), + FindingSpec("2️⃣", message: "remove the leading '_' from the name '_foo'"), + FindingSpec("3️⃣", message: "remove the leading '_' from the name '_Bar'"), + ] + ) + } + + func testStructs() { + assertLint( + NoLeadingUnderscores.self, + """ + struct Foo { let 1️⃣_foo = foo } + struct 2️⃣_Bar {} + """, + findings: [ + FindingSpec("1️⃣", message: "remove the leading '_' from the name '_foo'"), + FindingSpec("2️⃣", message: "remove the leading '_' from the name '_Bar'"), + ] + ) + } + + func testFunctions() { + assertLint( + NoLeadingUnderscores.self, + """ + func 1️⃣_foo(_ ok: Int, 3️⃣_notOK: Int, _ok 4️⃣_butNotThisOne: Int) {} + func bar() {} + """, + findings: [ + FindingSpec("1️⃣", message: "remove the leading '_' from the name '_foo'"), + FindingSpec("2️⃣", message: "remove the leading '_' from the name '_T2'"), + FindingSpec("3️⃣", message: "remove the leading '_' from the name '_notOK'"), + FindingSpec("4️⃣", message: "remove the leading '_' from the name '_butNotThisOne'"), + ] + ) + } + + func testInitializerArguments() { + assertLint( + NoLeadingUnderscores.self, + """ + struct X { + init(_ ok: Int, 2️⃣_notOK: Int, _ok 3️⃣_butNotThisOne: Int) {} + } + """, + findings: [ + FindingSpec("1️⃣", message: "remove the leading '_' from the name '_T2'"), + FindingSpec("2️⃣", message: "remove the leading '_' from the name '_notOK'"), + FindingSpec("3️⃣", message: "remove the leading '_' from the name '_butNotThisOne'"), + ] + ) + } + + func testPrecedenceGroups() { + assertLint( + NoLeadingUnderscores.self, + """ + precedencegroup FooPrecedence { + associativity: left + higherThan: BarPrecedence + } + precedencegroup 1️⃣_FooPrecedence { + associativity: left + higherThan: BarPrecedence + } + infix operator <> : _BazPrecedence + """, + findings: [ + FindingSpec("1️⃣", message: "remove the leading '_' from the name '_FooPrecedence'") + ] + ) + } + + func testTypealiases() { + assertLint( + NoLeadingUnderscores.self, + """ + typealias Foo = _Foo + typealias 1️⃣_Bar = Bar + """, + findings: [ + FindingSpec("1️⃣", message: "remove the leading '_' from the name '_Bar'") + ] + ) + } + + func testIdentifiersAreIgnoredAtUsage() { + assertLint( + NoLeadingUnderscores.self, + """ + let x = _y + _z + _foo(_bar) + """, + findings: [] + ) + } +} diff --git a/Tests/SwiftFormatTests/Rules/NoParensAroundConditionsTests.swift b/Tests/SwiftFormatTests/Rules/NoParensAroundConditionsTests.swift new file mode 100644 index 000000000..7925b57cf --- /dev/null +++ b/Tests/SwiftFormatTests/Rules/NoParensAroundConditionsTests.swift @@ -0,0 +1,338 @@ +@_spi(Rules) import SwiftFormat +import _SwiftFormatTestSupport + +final class NoParensAroundConditionsTests: LintOrFormatRuleTestCase { + func testParensAroundConditions() { + assertFormatting( + NoParensAroundConditions.self, + input: """ + if 1️⃣(x) {} + while 2️⃣(x) {} + guard 3️⃣(x), 4️⃣(y), 5️⃣(x == 3) else {} + if (foo { x }) {} + repeat {} while6️⃣(x) + switch 7️⃣(4) { default: break } + """, + expected: """ + if x {} + while x {} + guard x, y, x == 3 else {} + if (foo { x }) {} + repeat {} while x + switch 4 { default: break } + """, + findings: [ + FindingSpec("1️⃣", message: "remove the parentheses around this expression"), + FindingSpec("2️⃣", message: "remove the parentheses around this expression"), + FindingSpec("3️⃣", message: "remove the parentheses around this expression"), + FindingSpec("4️⃣", message: "remove the parentheses around this expression"), + FindingSpec("5️⃣", message: "remove the parentheses around this expression"), + FindingSpec("6️⃣", message: "remove the parentheses around this expression"), + FindingSpec("7️⃣", message: "remove the parentheses around this expression"), + ] + ) + } + + func testParensAroundNestedParenthesizedStatements() { + assertFormatting( + NoParensAroundConditions.self, + input: """ + switch 1️⃣(a) { + case 1: + switch 2️⃣(b) { + default: break + } + } + if 3️⃣(x) { + if 4️⃣(y) { + } else if 5️⃣(z) { + } else { + } + } else if 6️⃣(w) { + } + """, + expected: """ + switch a { + case 1: + switch b { + default: break + } + } + if x { + if y { + } else if z { + } else { + } + } else if w { + } + """, + findings: [ + FindingSpec("1️⃣", message: "remove the parentheses around this expression"), + FindingSpec("2️⃣", message: "remove the parentheses around this expression"), + FindingSpec("3️⃣", message: "remove the parentheses around this expression"), + FindingSpec("4️⃣", message: "remove the parentheses around this expression"), + FindingSpec("5️⃣", message: "remove the parentheses around this expression"), + FindingSpec("6️⃣", message: "remove the parentheses around this expression"), + ] + ) + + assertFormatting( + NoParensAroundConditions.self, + input: """ + while 1️⃣(x) { + while 2️⃣(y) {} + } + guard 3️⃣(x), 4️⃣(y), 5️⃣(x == 3) else { + guard 6️⃣(a), 7️⃣(b), 8️⃣(c == x) else { + return + } + return + } + repeat { + repeat { + } while 9️⃣(y) + } while🔟(x) + if 0️⃣(foo.someCall({ if ℹ️(x) {} })) {} + """, + expected: """ + while x { + while y {} + } + guard x, y, x == 3 else { + guard a, b, c == x else { + return + } + return + } + repeat { + repeat { + } while y + } while x + if foo.someCall({ if x {} }) {} + """, + findings: [ + FindingSpec("1️⃣", message: "remove the parentheses around this expression"), + FindingSpec("2️⃣", message: "remove the parentheses around this expression"), + FindingSpec("3️⃣", message: "remove the parentheses around this expression"), + FindingSpec("4️⃣", message: "remove the parentheses around this expression"), + FindingSpec("5️⃣", message: "remove the parentheses around this expression"), + FindingSpec("6️⃣", message: "remove the parentheses around this expression"), + FindingSpec("7️⃣", message: "remove the parentheses around this expression"), + FindingSpec("8️⃣", message: "remove the parentheses around this expression"), + FindingSpec("9️⃣", message: "remove the parentheses around this expression"), + FindingSpec("🔟", message: "remove the parentheses around this expression"), + FindingSpec("0️⃣", message: "remove the parentheses around this expression"), + FindingSpec("ℹ️", message: "remove the parentheses around this expression"), + ] + ) + } + + func testParensAroundNestedUnparenthesizedStatements() { + assertFormatting( + NoParensAroundConditions.self, + input: """ + switch b { + case 2: + switch 1️⃣(d) { + default: break + } + } + if x { + if 2️⃣(y) { + } else if 3️⃣(z) { + } else { + } + } else if 4️⃣(w) { + } + while x { + while 5️⃣(y) {} + } + repeat { + repeat { + } while 6️⃣(y) + } while x + if foo.someCall({ if 7️⃣(x) {} }) {} + """, + expected: """ + switch b { + case 2: + switch d { + default: break + } + } + if x { + if y { + } else if z { + } else { + } + } else if w { + } + while x { + while y {} + } + repeat { + repeat { + } while y + } while x + if foo.someCall({ if x {} }) {} + """, + findings: [ + FindingSpec("1️⃣", message: "remove the parentheses around this expression"), + FindingSpec("2️⃣", message: "remove the parentheses around this expression"), + FindingSpec("3️⃣", message: "remove the parentheses around this expression"), + FindingSpec("4️⃣", message: "remove the parentheses around this expression"), + FindingSpec("5️⃣", message: "remove the parentheses around this expression"), + FindingSpec("6️⃣", message: "remove the parentheses around this expression"), + FindingSpec("7️⃣", message: "remove the parentheses around this expression"), + ] + ) + } + + func testParensAroundIfAndSwitchExprs() { + assertFormatting( + NoParensAroundConditions.self, + input: """ + let x = if 1️⃣(x) {} + let y = switch 2️⃣(4) { default: break } + func foo() { + return if 3️⃣(x) {} + } + func bar() { + return switch 4️⃣(4) { default: break } + } + """, + expected: """ + let x = if x {} + let y = switch 4 { default: break } + func foo() { + return if x {} + } + func bar() { + return switch 4 { default: break } + } + """, + findings: [ + FindingSpec("1️⃣", message: "remove the parentheses around this expression"), + FindingSpec("2️⃣", message: "remove the parentheses around this expression"), + FindingSpec("3️⃣", message: "remove the parentheses around this expression"), + FindingSpec("4️⃣", message: "remove the parentheses around this expression"), + ] + ) + } + + func testParensAroundAmbiguousConditions() { + assertFormatting( + NoParensAroundConditions.self, + input: """ + if ({ true }()) {} + if (functionWithTrailingClosure { 5 }) {} + """, + expected: """ + if ({ true }()) {} + if (functionWithTrailingClosure { 5 }) {} + """, + findings: [] + ) + } + + func testKeywordAlwaysHasTrailingSpace() { + assertFormatting( + NoParensAroundConditions.self, + input: """ + if1️⃣(x) {} + while2️⃣(x) {} + guard3️⃣(x),4️⃣(y),5️⃣(x == 3) else {} + repeat {} while6️⃣(x) + switch7️⃣(4) { default: break } + """, + expected: """ + if x {} + while x {} + guard x,y,x == 3 else {} + repeat {} while x + switch 4 { default: break } + """, + findings: [ + FindingSpec("1️⃣", message: "remove the parentheses around this expression"), + FindingSpec("2️⃣", message: "remove the parentheses around this expression"), + FindingSpec("3️⃣", message: "remove the parentheses around this expression"), + FindingSpec("4️⃣", message: "remove the parentheses around this expression"), + FindingSpec("5️⃣", message: "remove the parentheses around this expression"), + FindingSpec("6️⃣", message: "remove the parentheses around this expression"), + FindingSpec("7️⃣", message: "remove the parentheses around this expression"), + ] + ) + } + + func testBlockCommentsBeforeConditionArePreserved() { + assertFormatting( + NoParensAroundConditions.self, + input: """ + if/*foo*/1️⃣(x) {} + while/*foo*/2️⃣(x) {} + guard/*foo*/3️⃣(x), /*foo*/4️⃣(y), /*foo*/5️⃣(x == 3) else {} + repeat {} while/*foo*/6️⃣(x) + switch/*foo*/7️⃣(4) { default: break } + """, + expected: """ + if/*foo*/x {} + while/*foo*/x {} + guard/*foo*/x, /*foo*/y, /*foo*/x == 3 else {} + repeat {} while/*foo*/x + switch/*foo*/4 { default: break } + """, + findings: [ + FindingSpec("1️⃣", message: "remove the parentheses around this expression"), + FindingSpec("2️⃣", message: "remove the parentheses around this expression"), + FindingSpec("3️⃣", message: "remove the parentheses around this expression"), + FindingSpec("4️⃣", message: "remove the parentheses around this expression"), + FindingSpec("5️⃣", message: "remove the parentheses around this expression"), + FindingSpec("6️⃣", message: "remove the parentheses around this expression"), + FindingSpec("7️⃣", message: "remove the parentheses around this expression"), + ] + ) + } + + func testCommentsAfterKeywordArePreserved() { + assertFormatting( + NoParensAroundConditions.self, + input: """ + if /*foo*/ // bar + 1️⃣(x) {} + while /*foo*/ // bar + 2️⃣(x) {} + guard /*foo*/ // bar + 3️⃣(x), /*foo*/ // bar + 4️⃣(y), /*foo*/ // bar + 5️⃣(x == 3) else {} + repeat {} while /*foo*/ // bar + 6️⃣(x) + switch /*foo*/ // bar + 7️⃣(4) { default: break } + """, + expected: """ + if /*foo*/ // bar + x {} + while /*foo*/ // bar + x {} + guard /*foo*/ // bar + x, /*foo*/ // bar + y, /*foo*/ // bar + x == 3 else {} + repeat {} while /*foo*/ // bar + x + switch /*foo*/ // bar + 4 { default: break } + """, + findings: [ + FindingSpec("1️⃣", message: "remove the parentheses around this expression"), + FindingSpec("2️⃣", message: "remove the parentheses around this expression"), + FindingSpec("3️⃣", message: "remove the parentheses around this expression"), + FindingSpec("4️⃣", message: "remove the parentheses around this expression"), + FindingSpec("5️⃣", message: "remove the parentheses around this expression"), + FindingSpec("6️⃣", message: "remove the parentheses around this expression"), + FindingSpec("7️⃣", message: "remove the parentheses around this expression"), + ] + ) + } +} diff --git a/Tests/SwiftFormatTests/Rules/NoPlaygroundLiteralsTests.swift b/Tests/SwiftFormatTests/Rules/NoPlaygroundLiteralsTests.swift new file mode 100644 index 000000000..3cc205fde --- /dev/null +++ b/Tests/SwiftFormatTests/Rules/NoPlaygroundLiteralsTests.swift @@ -0,0 +1,79 @@ +@_spi(Rules) import SwiftFormat +import _SwiftFormatTestSupport + +final class NoPlaygroundLiteralsTests: LintOrFormatRuleTestCase { + func testColorLiterals() { + assertLint( + NoPlaygroundLiterals.self, + """ + _ = 1️⃣#colorLiteral(red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0) + _ = #otherMacro(color: 2️⃣#colorLiteral(red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0)) + _ = #otherMacro { 3️⃣#colorLiteral(red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0) } + + // Ignore invalid expansions. + _ = #colorLiteral(1.0, 0.0, 0.0, 1.0) + _ = #colorLiteral(r: 1.0, g: 0.0, b: 0.0, a: 1.0) + _ = #colorLiteral(red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0) { trailingClosure() } + _ = #colorLiteral(red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0) + """, + findings: [ + FindingSpec("1️⃣", message: "replace '#colorLiteral' with a call to an initializer on 'NSColor' or 'UIColor'"), + FindingSpec("2️⃣", message: "replace '#colorLiteral' with a call to an initializer on 'NSColor' or 'UIColor'"), + FindingSpec("3️⃣", message: "replace '#colorLiteral' with a call to an initializer on 'NSColor' or 'UIColor'"), + ] + ) + } + + func testFileLiterals() { + assertLint( + NoPlaygroundLiterals.self, + """ + _ = 1️⃣#fileLiteral(resourceName: "secrets.json") + _ = #otherMacro(url: 2️⃣#fileLiteral(resourceName: "secrets.json")) + _ = #otherMacro { 3️⃣#fileLiteral(resourceName: "secrets.json") } + + // Ignore invalid expansions. + _ = #fileLiteral("secrets.json") + _ = #fileLiteral(name: "secrets.json") + _ = #fileLiteral(resourceName: "secrets.json") { trailingClosure() } + _ = #fileLiteral(resourceName: "secrets.json") + """, + findings: [ + FindingSpec( + "1️⃣", + message: "replace '#fileLiteral' with a call to a method such as 'Bundle.url(forResource:withExtension:)'" + ), + FindingSpec( + "2️⃣", + message: "replace '#fileLiteral' with a call to a method such as 'Bundle.url(forResource:withExtension:)'" + ), + FindingSpec( + "3️⃣", + message: "replace '#fileLiteral' with a call to a method such as 'Bundle.url(forResource:withExtension:)'" + ), + ] + ) + } + + func testImageLiterals() { + assertLint( + NoPlaygroundLiterals.self, + """ + _ = 1️⃣#imageLiteral(resourceName: "image.png") + _ = #otherMacro(url: 2️⃣#imageLiteral(resourceName: "image.png")) + _ = #otherMacro { 3️⃣#imageLiteral(resourceName: "image.png") } + + // Ignore invalid expansions. + _ = #imageLiteral("image.png") + _ = #imageLiteral(name: "image.pngn") + _ = #imageLiteral(resourceName: "image.png") { trailingClosure() } + _ = #imageLiteral(resourceName: "image.png") + """, + findings: [ + FindingSpec("1️⃣", message: "replace '#imageLiteral' with a call to an initializer on 'NSImage' or 'UIImage'"), + FindingSpec("2️⃣", message: "replace '#imageLiteral' with a call to an initializer on 'NSImage' or 'UIImage'"), + FindingSpec("3️⃣", message: "replace '#imageLiteral' with a call to an initializer on 'NSImage' or 'UIImage'"), + ] + ) + } +} diff --git a/Tests/SwiftFormatTests/Rules/NoVoidReturnOnFunctionSignatureTests.swift b/Tests/SwiftFormatTests/Rules/NoVoidReturnOnFunctionSignatureTests.swift new file mode 100644 index 000000000..20247a289 --- /dev/null +++ b/Tests/SwiftFormatTests/Rules/NoVoidReturnOnFunctionSignatureTests.swift @@ -0,0 +1,40 @@ +@_spi(Rules) import SwiftFormat +import _SwiftFormatTestSupport + +final class NoVoidReturnOnFunctionSignatureTests: LintOrFormatRuleTestCase { + func testVoidReturns() { + assertFormatting( + NoVoidReturnOnFunctionSignature.self, + input: """ + func foo() -> 1️⃣() { + } + + func test() -> 2️⃣Void{ + } + + func x() -> Int { return 2 } + + let x = { () -> Void in + print("Hello, world!") + } + """, + expected: """ + func foo() { + } + + func test() { + } + + func x() -> Int { return 2 } + + let x = { () -> Void in + print("Hello, world!") + } + """, + findings: [ + FindingSpec("1️⃣", message: "remove the explicit return type '()' from this function"), + FindingSpec("2️⃣", message: "remove the explicit return type 'Void' from this function"), + ] + ) + } +} diff --git a/Tests/SwiftFormatTests/Rules/OmitReturnsTests.swift b/Tests/SwiftFormatTests/Rules/OmitReturnsTests.swift new file mode 100644 index 000000000..ae0d84188 --- /dev/null +++ b/Tests/SwiftFormatTests/Rules/OmitReturnsTests.swift @@ -0,0 +1,122 @@ +@_spi(Rules) import SwiftFormat +import _SwiftFormatTestSupport + +final class OmitReturnsTests: LintOrFormatRuleTestCase { + func testOmitReturnInFunction() { + assertFormatting( + OmitExplicitReturns.self, + input: """ + func test() -> Bool { + 1️⃣return false + } + """, + expected: """ + func test() -> Bool { + false + } + """, + findings: [ + FindingSpec("1️⃣", message: "'return' can be omitted because body consists of a single expression") + ] + ) + } + + func testOmitReturnInClosure() { + assertFormatting( + OmitExplicitReturns.self, + input: """ + vals.filter { + 1️⃣return $0.count == 1 + } + """, + expected: """ + vals.filter { + $0.count == 1 + } + """, + findings: [ + FindingSpec("1️⃣", message: "'return' can be omitted because body consists of a single expression") + ] + ) + } + + func testOmitReturnInSubscript() { + assertFormatting( + OmitExplicitReturns.self, + input: """ + struct Test { + subscript(x: Int) -> Bool { + 1️⃣return false + } + } + + struct Test { + subscript(x: Int) -> Bool { + get { + 2️⃣return false + } + set { } + } + } + """, + expected: """ + struct Test { + subscript(x: Int) -> Bool { + false + } + } + + struct Test { + subscript(x: Int) -> Bool { + get { + false + } + set { } + } + } + """, + findings: [ + FindingSpec("1️⃣", message: "'return' can be omitted because body consists of a single expression"), + FindingSpec("2️⃣", message: "'return' can be omitted because body consists of a single expression"), + ] + ) + } + + func testOmitReturnInComputedVars() { + assertFormatting( + OmitExplicitReturns.self, + input: """ + var x: Int { + 1️⃣return 42 + } + + struct Test { + var x: Int { + get { + 2️⃣return 42 + } + set { } + } + } + """, + expected: """ + var x: Int { + 42 + } + + struct Test { + var x: Int { + get { + 42 + } + set { } + } + } + """, + findings: [ + FindingSpec("1️⃣", message: "'return' can be omitted because body consists of a single expression"), + FindingSpec("2️⃣", message: "'return' can be omitted because body consists of a single expression"), + ] + ) + } +} diff --git a/Tests/SwiftFormatTests/Rules/OneCasePerLineTests.swift b/Tests/SwiftFormatTests/Rules/OneCasePerLineTests.swift new file mode 100644 index 000000000..23c809a8d --- /dev/null +++ b/Tests/SwiftFormatTests/Rules/OneCasePerLineTests.swift @@ -0,0 +1,130 @@ +@_spi(Rules) import SwiftFormat +import _SwiftFormatTestSupport + +final class OneCasePerLineTests: LintOrFormatRuleTestCase { + + // The inconsistent leading whitespace in the expected text is intentional. This transform does + // not attempt to preserve leading indentation since the pretty printer will correct it when + // running the full formatter. + + func testInvalidCasesOnLine() { + assertFormatting( + OneCasePerLine.self, + input: """ + public enum Token { + case arrow + case comma, 1️⃣identifier(String), semicolon, 2️⃣stringSegment(String) + case period + case 3️⃣ifKeyword(String), 4️⃣forKeyword(String) + indirect case guardKeyword, elseKeyword, 5️⃣contextualKeyword(String) + var x: Bool + case leftParen, 6️⃣rightParen = ")", leftBrace, 7️⃣rightBrace = "}" + } + """, + expected: """ + public enum Token { + case arrow + case comma + case identifier(String) + case semicolon + case stringSegment(String) + case period + case ifKeyword(String) + case forKeyword(String) + indirect case guardKeyword, elseKeyword + indirect case contextualKeyword(String) + var x: Bool + case leftParen + case rightParen = ")" + case leftBrace + case rightBrace = "}" + } + """, + findings: [ + FindingSpec("1️⃣", message: "move 'identifier' to its own 'case' declaration"), + FindingSpec("2️⃣", message: "move 'stringSegment' to its own 'case' declaration"), + FindingSpec("3️⃣", message: "move 'ifKeyword' to its own 'case' declaration"), + FindingSpec("4️⃣", message: "move 'forKeyword' to its own 'case' declaration"), + FindingSpec("5️⃣", message: "move 'contextualKeyword' to its own 'case' declaration"), + FindingSpec("6️⃣", message: "move 'rightParen' to its own 'case' declaration"), + FindingSpec("7️⃣", message: "move 'rightBrace' to its own 'case' declaration"), + ] + ) + } + + func testElementOrderIsPreserved() { + assertFormatting( + OneCasePerLine.self, + input: """ + enum Foo: Int { + case 1️⃣a = 0, b, c, d + } + """, + expected: """ + enum Foo: Int { + case a = 0 + case b, c, d + } + """, + findings: [ + FindingSpec("1️⃣", message: "move 'a' to its own 'case' declaration") + ] + ) + } + + func testCommentsAreNotRepeated() { + assertFormatting( + OneCasePerLine.self, + input: """ + enum Foo: Int { + /// This should only be above `a`. + case 1️⃣a = 0, b, c, d + // This should only be above `e`. + case e, 2️⃣f = 100 + } + """, + expected: """ + enum Foo: Int { + /// This should only be above `a`. + case a = 0 + case b, c, d + // This should only be above `e`. + case e + case f = 100 + } + """, + findings: [ + FindingSpec("1️⃣", message: "move 'a' to its own 'case' declaration"), + FindingSpec("2️⃣", message: "move 'f' to its own 'case' declaration"), + ] + ) + } + + func testAttributesArePropagated() { + assertFormatting( + OneCasePerLine.self, + input: """ + enum Foo { + @someAttr case 1️⃣a(String), b, c, d + case e, 2️⃣f(Int) + @anotherAttr case g, 3️⃣h(Float) + } + """, + expected: """ + enum Foo { + @someAttr case a(String) + @someAttr case b, c, d + case e + case f(Int) + @anotherAttr case g + @anotherAttr case h(Float) + } + """, + findings: [ + FindingSpec("1️⃣", message: "move 'a' to its own 'case' declaration"), + FindingSpec("2️⃣", message: "move 'f' to its own 'case' declaration"), + FindingSpec("3️⃣", message: "move 'h' to its own 'case' declaration"), + ] + ) + } +} diff --git a/Tests/SwiftFormatTests/Rules/OneVariableDeclarationPerLineTests.swift b/Tests/SwiftFormatTests/Rules/OneVariableDeclarationPerLineTests.swift new file mode 100644 index 000000000..3b84e467d --- /dev/null +++ b/Tests/SwiftFormatTests/Rules/OneVariableDeclarationPerLineTests.swift @@ -0,0 +1,226 @@ +@_spi(Rules) import SwiftFormat +import _SwiftFormatTestSupport + +final class OneVariableDeclarationPerLineTests: LintOrFormatRuleTestCase { + func testMultipleVariableBindings() { + assertFormatting( + OneVariableDeclarationPerLine.self, + input: """ + 1️⃣var a = 0, b = 2, (c, d) = (0, "h") + 2️⃣let e = 0, f = 2, (g, h) = (0, "h") + var x: Int { return 3 } + 3️⃣let a, b, c: Int + 4️⃣var j: Int, k: String, l: Float + """, + expected: """ + var a = 0 + var b = 2 + var (c, d) = (0, "h") + let e = 0 + let f = 2 + let (g, h) = (0, "h") + var x: Int { return 3 } + let a: Int + let b: Int + let c: Int + var j: Int + var k: String + var l: Float + """, + findings: [ + FindingSpec("1️⃣", message: "split this variable declaration to introduce only one variable per 'var'"), + FindingSpec("2️⃣", message: "split this variable declaration to introduce only one variable per 'let'"), + FindingSpec("3️⃣", message: "split this variable declaration to introduce only one variable per 'let'"), + FindingSpec("4️⃣", message: "split this variable declaration to introduce only one variable per 'var'"), + ] + ) + } + + func testNestedVariableBindings() { + assertFormatting( + OneVariableDeclarationPerLine.self, + input: """ + var x: Int = { + 1️⃣let y = 5, z = 10 + return z + }() + + func foo() { + 2️⃣let x = 4, y = 10 + } + + var x: Int { + 3️⃣let y = 5, z = 10 + return z + } + + var a: String = "foo" { + didSet { + 4️⃣let b, c: Bool + } + } + + 5️⃣let + a: Int = { + 6️⃣let p = 10, q = 20 + return p * q + }(), + b: Int = { + 7️⃣var s: Int, t: Double + return 20 + }() + """, + expected: """ + var x: Int = { + let y = 5 + let z = 10 + return z + }() + + func foo() { + let x = 4 + let y = 10 + } + + var x: Int { + let y = 5 + let z = 10 + return z + } + + var a: String = "foo" { + didSet { + let b: Bool + let c: Bool + } + } + + let + a: Int = { + let p = 10 + let q = 20 + return p * q + }() + let + b: Int = { + var s: Int + var t: Double + return 20 + }() + """, + findings: [ + FindingSpec("1️⃣", message: "split this variable declaration to introduce only one variable per 'let'"), + FindingSpec("2️⃣", message: "split this variable declaration to introduce only one variable per 'let'"), + FindingSpec("3️⃣", message: "split this variable declaration to introduce only one variable per 'let'"), + FindingSpec("4️⃣", message: "split this variable declaration to introduce only one variable per 'let'"), + FindingSpec("5️⃣", message: "split this variable declaration to introduce only one variable per 'let'"), + FindingSpec("6️⃣", message: "split this variable declaration to introduce only one variable per 'let'"), + FindingSpec("7️⃣", message: "split this variable declaration to introduce only one variable per 'var'"), + ] + ) + } + + func testMixedInitializedAndTypedBindings() { + assertFormatting( + OneVariableDeclarationPerLine.self, + input: """ + 1️⃣var a = 5, b: String + 2️⃣let c: Int, d = "d", e = "e", f: Double + """, + expected: """ + var a = 5 + var b: String + let c: Int + let d = "d" + let e = "e" + let f: Double + """, + findings: [ + FindingSpec("1️⃣", message: "split this variable declaration to introduce only one variable per 'var'"), + FindingSpec("2️⃣", message: "split this variable declaration to introduce only one variable per 'let'"), + ] + ) + } + + func testCommentPrecedingDeclIsNotRepeated() { + assertFormatting( + OneVariableDeclarationPerLine.self, + input: """ + // Comment + 1️⃣let a, b, c: Int + """, + expected: """ + // Comment + let a: Int + let b: Int + let c: Int + """, + findings: [ + FindingSpec("1️⃣", message: "split this variable declaration to introduce only one variable per 'let'") + ] + ) + } + + func testCommentsPrecedingBindingsAreKept() { + assertFormatting( + OneVariableDeclarationPerLine.self, + input: """ + 1️⃣let /* a */ a, /* b */ b, /* c */ c: Int + """, + expected: """ + let /* a */ a: Int + let /* b */ b: Int + let /* c */ c: Int + """, + findings: [ + FindingSpec("1️⃣", message: "split this variable declaration to introduce only one variable per 'let'") + ] + ) + } + + func testInvalidBindingsAreNotDestroyed() { + assertFormatting( + OneVariableDeclarationPerLine.self, + input: """ + 1️⃣let a, b, c = 5 + 2️⃣let d, e + 3️⃣let f, g, h: Int = 5 + 4️⃣let a: Int, b, c = 5, d, e: Int + """, + expected: """ + let a, b, c = 5 + let d, e + let f, g, h: Int = 5 + let a: Int + let b, c = 5 + let d: Int + let e: Int + """, + findings: [ + FindingSpec("1️⃣", message: "split this variable declaration to introduce only one variable per 'let'"), + FindingSpec("2️⃣", message: "split this variable declaration to introduce only one variable per 'let'"), + FindingSpec("3️⃣", message: "split this variable declaration to introduce only one variable per 'let'"), + FindingSpec("4️⃣", message: "split this variable declaration to introduce only one variable per 'let'"), + ] + ) + } + + func testMultipleBindingsWithAccessorsAreCorrected() { + // Swift parses multiple bindings with accessors but forbids them at a later + // stage. That means that if the individual bindings would be correct in + // isolation then we can correct them, which is kind of nice. + assertFormatting( + OneVariableDeclarationPerLine.self, + input: """ + 1️⃣var x: Int { return 10 }, y = "foo" { didSet { print("changed") } } + """, + expected: """ + var x: Int { return 10 } + var y = "foo" { didSet { print("changed") } } + """, + findings: [ + FindingSpec("1️⃣", message: "split this variable declaration to introduce only one variable per 'var'") + ] + ) + } +} diff --git a/Tests/SwiftFormatTests/Rules/OnlyOneTrailingClosureArgumentTests.swift b/Tests/SwiftFormatTests/Rules/OnlyOneTrailingClosureArgumentTests.swift new file mode 100644 index 000000000..d18d03061 --- /dev/null +++ b/Tests/SwiftFormatTests/Rules/OnlyOneTrailingClosureArgumentTests.swift @@ -0,0 +1,25 @@ +@_spi(Rules) import SwiftFormat +import _SwiftFormatTestSupport + +final class OnlyOneTrailingClosureArgumentTests: LintOrFormatRuleTestCase { + func testInvalidTrailingClosureCall() { + assertLint( + OnlyOneTrailingClosureArgument.self, + """ + 1️⃣callWithBoth(someClosure: {}) { + // ... + } + callWithClosure(someClosure: {}) + callWithTrailingClosure { + // ... + } + """, + findings: [ + FindingSpec( + "1️⃣", + message: "revise this function call to avoid using both closure arguments and a trailing closure" + ) + ] + ) + } +} diff --git a/Tests/SwiftFormatTests/Rules/OrderedImportsTests.swift b/Tests/SwiftFormatTests/Rules/OrderedImportsTests.swift new file mode 100644 index 000000000..73d33aa77 --- /dev/null +++ b/Tests/SwiftFormatTests/Rules/OrderedImportsTests.swift @@ -0,0 +1,654 @@ +@_spi(Rules) import SwiftFormat +import _SwiftFormatTestSupport + +final class OrderedImportsTests: LintOrFormatRuleTestCase { + func testInvalidImportsOrder() { + assertFormatting( + OrderedImports.self, + input: """ + import Foundation + // Starts Imports + 1️⃣import Core + + + // Comment with new lines + import UIKit + + @testable import SwiftFormat + 8️⃣import enum Darwin.D.isatty + // Starts Test + 3️⃣@testable import MyModuleUnderTest + // Starts Ind + 2️⃣7️⃣import func Darwin.C.isatty + + let a = 3 + 4️⃣5️⃣6️⃣import SwiftSyntax + """, + expected: """ + // Starts Imports + import Core + import Foundation + import SwiftSyntax + // Comment with new lines + import UIKit + + // Starts Ind + import func Darwin.C.isatty + import enum Darwin.D.isatty + + // Starts Test + @testable import MyModuleUnderTest + @testable import SwiftFormat + + let a = 3 + """, + findings: [ + FindingSpec("1️⃣", message: "sort import statements lexicographically"), + FindingSpec("2️⃣", message: "place declaration imports before testable imports"), + FindingSpec("3️⃣", message: "sort import statements lexicographically"), + FindingSpec("4️⃣", message: "place imports at the top of the file"), + FindingSpec("5️⃣", message: "place regular imports before testable imports"), + FindingSpec("6️⃣", message: "place regular imports before declaration imports"), + FindingSpec("7️⃣", message: "sort import statements lexicographically"), + FindingSpec("8️⃣", message: "place declaration imports before testable imports"), + ] + ) + } + + func testImportsOrderWithoutModuleType() { + assertFormatting( + OrderedImports.self, + input: """ + @testable import SwiftFormat + 1️⃣import func Darwin.D.isatty + 4️⃣@testable import MyModuleUnderTest + 2️⃣3️⃣import func Darwin.C.isatty + + let a = 3 + """, + expected: """ + import func Darwin.C.isatty + import func Darwin.D.isatty + + @testable import MyModuleUnderTest + @testable import SwiftFormat + + let a = 3 + """, + findings: [ + FindingSpec("1️⃣", message: "place declaration imports before testable imports"), + FindingSpec("2️⃣", message: "place declaration imports before testable imports"), + FindingSpec("3️⃣", message: "sort import statements lexicographically"), + FindingSpec("4️⃣", message: "sort import statements lexicographically"), + ] + ) + } + + func testImportsOrderWithDocComment() { + assertFormatting( + OrderedImports.self, + input: """ + /// Test imports with comments. + /// + /// Comments at the top of the file + /// should be preserved. + + // Line comment for import + // Foundation. + import Foundation + // Line comment for Core + 1️⃣import Core + import UIKit + + let a = 3 + """, + expected: """ + /// Test imports with comments. + /// + /// Comments at the top of the file + /// should be preserved. + + // Line comment for Core + import Core + // Line comment for import + // Foundation. + import Foundation + import UIKit + + let a = 3 + """, + findings: [ + FindingSpec("1️⃣", message: "sort import statements lexicographically") + ] + ) + } + + func testValidOrderedImport() { + assertFormatting( + OrderedImports.self, + input: """ + import CoreLocation + import MyThirdPartyModule + import SpriteKit + import UIKit + + import func Darwin.C.isatty + + @testable import MyModuleUnderTest + """, + expected: """ + import CoreLocation + import MyThirdPartyModule + import SpriteKit + import UIKit + + import func Darwin.C.isatty + + @testable import MyModuleUnderTest + """, + findings: [] + ) + } + + func testSeparatedFileHeader() { + assertFormatting( + OrderedImports.self, + input: """ + // This is part of the file header. + + // So is this. + + // Top comment + import Bimport + 1️⃣import Aimport + + struct MyStruct { + // do stuff + } + + 2️⃣import HoistMe + """, + expected: """ + // This is part of the file header. + + // So is this. + + import Aimport + // Top comment + import Bimport + import HoistMe + + struct MyStruct { + // do stuff + } + """, + findings: [ + FindingSpec("1️⃣", message: "sort import statements lexicographically"), + FindingSpec("2️⃣", message: "place imports at the top of the file"), + ] + ) + } + + func testNonHeaderComment() { + let input = + """ + // Top comment + import Bimport + 1️⃣import Aimport + + let A = 123 + """ + + let expected = + """ + import Aimport + // Top comment + import Bimport + + let A = 123 + """ + + assertFormatting( + OrderedImports.self, + input: input, + expected: expected, + findings: [ + FindingSpec("1️⃣", message: "sort import statements lexicographically") + ] + ) + } + + func testMultipleCodeBlocksPerLine() { + assertFormatting( + OrderedImports.self, + input: """ + import A;import Z;1️⃣import D;import C; + foo();bar();baz();quxxe(); + """, + expected: """ + import A; + import C; + import D; + import Z; + + foo();bar();baz();quxxe(); + """, + findings: [ + FindingSpec("1️⃣", message: "sort import statements lexicographically") + ] + ) + } + + func testMultipleCodeBlocksWithImportsPerLine() { + assertFormatting( + OrderedImports.self, + input: """ + import A;import Z;1️⃣import D;import C;foo();bar();baz();quxxe(); + """, + expected: """ + import A; + import C; + import D; + import Z; + + foo();bar();baz();quxxe(); + """, + findings: [ + FindingSpec("1️⃣", message: "sort import statements lexicographically") + ] + ) + } + + func testDisableOrderedImports() { + assertFormatting( + OrderedImports.self, + input: """ + import C + 1️⃣import B + // swift-format-ignore: OrderedImports + import A + let a = 123 + 2️⃣import func Darwin.C.isatty + + // swift-format-ignore + import a + """, + expected: """ + import B + import C + + // swift-format-ignore: OrderedImports + import A + + import func Darwin.C.isatty + + let a = 123 + + // swift-format-ignore + import a + """, + findings: [ + FindingSpec("1️⃣", message: "sort import statements lexicographically"), + FindingSpec("2️⃣", message: "place imports at the top of the file"), + ] + ) + } + + func testDisableOrderedImportsMovingComments() { + assertFormatting( + OrderedImports.self, + input: """ + import C // Trailing comment about C + 1️⃣import B + // Comment about ignored A + // swift-format-ignore: OrderedImports + import A // trailing comment about ignored A + // Comment about Z + import Z + 2️⃣import D + // swift-format-ignore + // Comment about testable testA + @testable import testA + @testable import testZ // trailing comment about testZ + 3️⃣@testable import testC + // swift-format-ignore + @testable import testB + // Comment about Bar + import enum Bar + + let a = 2 + """, + expected: """ + import B + import C // Trailing comment about C + + // Comment about ignored A + // swift-format-ignore: OrderedImports + import A // trailing comment about ignored A + + import D + // Comment about Z + import Z + + // swift-format-ignore + // Comment about testable testA + @testable import testA + + @testable import testC + @testable import testZ // trailing comment about testZ + + // swift-format-ignore + @testable import testB + + // Comment about Bar + import enum Bar + + let a = 2 + """, + findings: [ + FindingSpec("1️⃣", message: "sort import statements lexicographically"), + FindingSpec("2️⃣", message: "sort import statements lexicographically"), + FindingSpec("3️⃣", message: "sort import statements lexicographically"), + ] + ) + } + + func testEmptyFile() { + assertFormatting( + OrderedImports.self, + input: "", + expected: "", + findings: [] + ) + + assertFormatting( + OrderedImports.self, + input: "// test", + expected: "// test", + findings: [] + ) + } + + func testImportsContainingNewlines() { + assertFormatting( + OrderedImports.self, + input: """ + import + zeta + 1️⃣import Zeta + import + Alpha + import Beta + """, + expected: """ + import + Alpha + import Beta + import Zeta + import + zeta + """, + findings: [ + FindingSpec("1️⃣", message: "sort import statements lexicographically") + ] + ) + } + + func testRemovesDuplicateImports() { + assertFormatting( + OrderedImports.self, + input: """ + import CoreLocation + import UIKit + 1️⃣import CoreLocation + import ZeeFramework + bar() + """, + expected: """ + import CoreLocation + import UIKit + import ZeeFramework + + bar() + """, + findings: [ + FindingSpec("1️⃣", message: "remove this duplicate import") + ] + ) + } + + func testDuplicateCommentedImports() { + // Verify that we diagnose redundant imports if they have comments, but don't remove them. + assertFormatting( + OrderedImports.self, + input: """ + import AppKit + // CoreLocation is necessary to get location stuff. + import CoreLocation // This import must stay. + // UIKit does UI Stuff? + import UIKit + // This is the second CoreLocation import. + 1️⃣import CoreLocation // The 2nd CL import has a comment here too. + // Comment about ZeeFramework. + import ZeeFramework + import foo + // Second comment about ZeeFramework. + 2️⃣import ZeeFramework // This one has a trailing comment too. + foo() + """, + expected: """ + import AppKit + // CoreLocation is necessary to get location stuff. + import CoreLocation // This import must stay. + // This is the second CoreLocation import. + import CoreLocation // The 2nd CL import has a comment here too. + // UIKit does UI Stuff? + import UIKit + // Comment about ZeeFramework. + // Second comment about ZeeFramework. + import ZeeFramework // This one has a trailing comment too. + import foo + + foo() + """, + findings: [ + // Even though this import is technically also not sorted, that won't matter if the import + // is removed so there should only be a warning to remove it. + FindingSpec("1️⃣", message: "remove this duplicate import"), + FindingSpec("2️⃣", message: "remove this duplicate import"), + ] + ) + } + + func testDuplicateIgnoredImports() { + assertFormatting( + OrderedImports.self, + input: """ + import AppKit + // swift-format-ignore + import CoreLocation + // Second CoreLocation import here. + import CoreLocation + // Comment about ZeeFramework. + import ZeeFramework + // swift-format-ignore + import ZeeFramework // trailing comment + foo() + """, + expected: """ + import AppKit + + // swift-format-ignore + import CoreLocation + + // Second CoreLocation import here. + import CoreLocation + // Comment about ZeeFramework. + import ZeeFramework + + // swift-format-ignore + import ZeeFramework // trailing comment + + foo() + """, + findings: [] + ) + } + + func testDuplicateAttributedImports() { + assertFormatting( + OrderedImports.self, + input: """ + // exported import of bar + @_exported import bar + @_implementationOnly import bar + import bar + import foo + // second import of foo + 1️⃣import foo + + // imports an enum + import enum Darwin.D.isatty + // this is a dup + 2️⃣import enum Darwin.D.isatty + + @testable import foo + + baz() + """, + expected: """ + // exported import of bar + @_exported import bar + @_implementationOnly import bar + import bar + // second import of foo + import foo + + // imports an enum + // this is a dup + import enum Darwin.D.isatty + + @testable import foo + + baz() + """, + findings: [ + FindingSpec("1️⃣", message: "remove this duplicate import"), + FindingSpec("2️⃣", message: "remove this duplicate import"), + ] + ) + } + + func testConditionalImports() { + assertFormatting( + OrderedImports.self, + input: """ + import Zebras + 1️⃣import Apples + #if canImport(Darwin) + import Darwin + #elseif canImport(Glibc) + import Glibc + #endif + 2️⃣import Aardvarks + + foo() + bar() + baz() + """, + expected: """ + import Aardvarks + import Apples + import Zebras + + #if canImport(Darwin) + import Darwin + #elseif canImport(Glibc) + import Glibc + #endif + + foo() + bar() + baz() + """, + findings: [ + FindingSpec("1️⃣", message: "sort import statements lexicographically"), + FindingSpec("2️⃣", message: "place imports at the top of the file"), + ] + ) + } + + func testIgnoredConditionalImports() { + assertFormatting( + OrderedImports.self, + input: """ + import Zebras + 1️⃣import Apples + #if canImport(Darwin) + import Darwin + #elseif canImport(Glibc) + import Glibc + #endif + // swift-format-ignore + import Aardvarks + + foo() + bar() + baz() + """, + expected: """ + import Apples + import Zebras + + #if canImport(Darwin) + import Darwin + #elseif canImport(Glibc) + import Glibc + #endif + // swift-format-ignore + import Aardvarks + + foo() + bar() + baz() + """, + findings: [ + FindingSpec("1️⃣", message: "sort import statements lexicographically") + ] + ) + } + + func testTrailingCommentsOnTopLevelCodeItems() { + assertFormatting( + OrderedImports.self, + input: """ + import Zebras + 1️⃣import Apples + #if canImport(Darwin) + import Darwin + #elseif canImport(Glibc) + import Glibc + #endif // canImport(Darwin) + + foo() // calls the foo + bar() // calls the bar + """, + expected: """ + import Apples + import Zebras + + #if canImport(Darwin) + import Darwin + #elseif canImport(Glibc) + import Glibc + #endif // canImport(Darwin) + + foo() // calls the foo + bar() // calls the bar + """, + findings: [ + FindingSpec("1️⃣", message: "sort import statements lexicographically") + ] + ) + } +} diff --git a/Tests/SwiftFormatTests/Rules/ReplaceForEachWithForLoopTests.swift b/Tests/SwiftFormatTests/Rules/ReplaceForEachWithForLoopTests.swift new file mode 100644 index 000000000..6f225b249 --- /dev/null +++ b/Tests/SwiftFormatTests/Rules/ReplaceForEachWithForLoopTests.swift @@ -0,0 +1,32 @@ +@_spi(Rules) import SwiftFormat +import _SwiftFormatTestSupport + +final class ReplaceForEachWithForLoopTests: LintOrFormatRuleTestCase { + func test() { + assertLint( + ReplaceForEachWithForLoop.self, + """ + values.1️⃣forEach { $0 * 2 } + values.map { $0 }.2️⃣forEach { print($0) } + values.forEach(callback) + values.forEach { $0 }.chained() + values.forEach({ $0 }).chained() + values.3️⃣forEach { + let arg = $0 + return arg + 1 + } + values.forEach { + let arg = $0 + return arg + 1 + } other: { + 42 + } + """, + findings: [ + FindingSpec("1️⃣", message: "replace use of '.forEach { ... }' with for-in loop"), + FindingSpec("2️⃣", message: "replace use of '.forEach { ... }' with for-in loop"), + FindingSpec("3️⃣", message: "replace use of '.forEach { ... }' with for-in loop"), + ] + ) + } +} diff --git a/Tests/SwiftFormatTests/Rules/ReturnVoidInsteadOfEmptyTupleTests.swift b/Tests/SwiftFormatTests/Rules/ReturnVoidInsteadOfEmptyTupleTests.swift new file mode 100644 index 000000000..17e76607f --- /dev/null +++ b/Tests/SwiftFormatTests/Rules/ReturnVoidInsteadOfEmptyTupleTests.swift @@ -0,0 +1,158 @@ +@_spi(Rules) import SwiftFormat +import _SwiftFormatTestSupport + +final class ReturnVoidInsteadOfEmptyTupleTests: LintOrFormatRuleTestCase { + func testBasic() { + assertFormatting( + ReturnVoidInsteadOfEmptyTuple.self, + input: """ + let callback: () -> 1️⃣() + typealias x = Int -> 2️⃣() + func y() -> Int -> 3️⃣() { return } + func z(d: Bool -> 4️⃣()) {} + """, + expected: """ + let callback: () -> Void + typealias x = Int -> Void + func y() -> Int -> Void { return } + func z(d: Bool -> Void) {} + """, + findings: [ + FindingSpec("1️⃣", message: "replace '()' with 'Void'"), + FindingSpec("2️⃣", message: "replace '()' with 'Void'"), + FindingSpec("3️⃣", message: "replace '()' with 'Void'"), + FindingSpec("4️⃣", message: "replace '()' with 'Void'"), + ] + ) + } + + func testNestedFunctionTypes() { + assertFormatting( + ReturnVoidInsteadOfEmptyTuple.self, + input: """ + typealias Nested1 = (() -> 1️⃣()) -> Int + typealias Nested2 = (() -> 2️⃣()) -> 3️⃣() + typealias Nested3 = Int -> (() -> 4️⃣()) + """, + expected: """ + typealias Nested1 = (() -> Void) -> Int + typealias Nested2 = (() -> Void) -> Void + typealias Nested3 = Int -> (() -> Void) + """, + findings: [ + FindingSpec("1️⃣", message: "replace '()' with 'Void'"), + FindingSpec("2️⃣", message: "replace '()' with 'Void'"), + FindingSpec("3️⃣", message: "replace '()' with 'Void'"), + FindingSpec("4️⃣", message: "replace '()' with 'Void'"), + ] + ) + } + + func testClosureSignatures() { + assertFormatting( + ReturnVoidInsteadOfEmptyTuple.self, + input: """ + callWithTrailingClosure(arg) { arg -> 1️⃣() in body } + callWithTrailingClosure(arg) { arg -> 2️⃣() in + nestedCallWithTrailingClosure(arg) { arg -> 3️⃣() in + body + } + } + callWithTrailingClosure(arg) { (arg: () -> 4️⃣()) -> Int in body } + callWithTrailingClosure(arg) { (arg: () -> 5️⃣()) -> 6️⃣() in body } + """, + expected: """ + callWithTrailingClosure(arg) { arg -> Void in body } + callWithTrailingClosure(arg) { arg -> Void in + nestedCallWithTrailingClosure(arg) { arg -> Void in + body + } + } + callWithTrailingClosure(arg) { (arg: () -> Void) -> Int in body } + callWithTrailingClosure(arg) { (arg: () -> Void) -> Void in body } + """, + findings: [ + FindingSpec("1️⃣", message: "replace '()' with 'Void'"), + FindingSpec("2️⃣", message: "replace '()' with 'Void'"), + FindingSpec("3️⃣", message: "replace '()' with 'Void'"), + FindingSpec("4️⃣", message: "replace '()' with 'Void'"), + FindingSpec("5️⃣", message: "replace '()' with 'Void'"), + FindingSpec("6️⃣", message: "replace '()' with 'Void'"), + ] + ) + } + + func testTriviaPreservation() { + assertFormatting( + ReturnVoidInsteadOfEmptyTuple.self, + input: """ + let callback: () -> /*foo*/1️⃣()/*bar*/ + let callback: ((Int) -> /*foo*/ 2️⃣() /*bar*/) -> 3️⃣() + """, + expected: """ + let callback: () -> /*foo*/Void/*bar*/ + let callback: ((Int) -> /*foo*/ Void /*bar*/) -> Void + """, + findings: [ + FindingSpec("1️⃣", message: "replace '()' with 'Void'"), + FindingSpec("2️⃣", message: "replace '()' with 'Void'"), + FindingSpec("3️⃣", message: "replace '()' with 'Void'"), + ] + ) + } + + func testEmptyTupleWithInternalCommentsIsDiagnosedButNotReplaced() { + assertFormatting( + ReturnVoidInsteadOfEmptyTuple.self, + input: """ + let callback: () -> 1️⃣( ) + let callback: () -> 2️⃣(\t) + let callback: () -> 3️⃣( + ) + let callback: () -> 4️⃣( /* please don't change me! */ ) + let callback: () -> 5️⃣( /** please don't change me! */ ) + let callback: () -> 6️⃣( + // don't change me either! + ) + let callback: () -> 7️⃣( + /// don't change me either! + ) + let callback: () -> 8️⃣(\u{feff}) + + let callback: (() -> 9️⃣()) -> 🔟( /* please don't change me! */ ) + callWithTrailingClosure(arg) { (arg: () -> 0️⃣()) -> ℹ️( /* no change */ ) in body } + """, + expected: """ + let callback: () -> Void + let callback: () -> Void + let callback: () -> Void + let callback: () -> ( /* please don't change me! */ ) + let callback: () -> ( /** please don't change me! */ ) + let callback: () -> ( + // don't change me either! + ) + let callback: () -> ( + /// don't change me either! + ) + let callback: () -> (\u{feff}) + + let callback: (() -> Void) -> ( /* please don't change me! */ ) + callWithTrailingClosure(arg) { (arg: () -> Void) -> ( /* no change */ ) in body } + """, + findings: [ + FindingSpec("1️⃣", message: "replace '()' with 'Void'"), + FindingSpec("2️⃣", message: "replace '()' with 'Void'"), + FindingSpec("3️⃣", message: "replace '()' with 'Void'"), + FindingSpec("4️⃣", message: "replace '()' with 'Void'"), + FindingSpec("5️⃣", message: "replace '()' with 'Void'"), + FindingSpec("6️⃣", message: "replace '()' with 'Void'"), + FindingSpec("7️⃣", message: "replace '()' with 'Void'"), + FindingSpec("8️⃣", message: "replace '()' with 'Void'"), + FindingSpec("9️⃣", message: "replace '()' with 'Void'"), + FindingSpec("🔟", message: "replace '()' with 'Void'"), + FindingSpec("0️⃣", message: "replace '()' with 'Void'"), + FindingSpec("ℹ️", message: "replace '()' with 'Void'"), + ] + ) + } +} diff --git a/Tests/SwiftFormatTests/Rules/TypeNamesShouldBeCapitalizedTests.swift b/Tests/SwiftFormatTests/Rules/TypeNamesShouldBeCapitalizedTests.swift new file mode 100644 index 000000000..fa30409e4 --- /dev/null +++ b/Tests/SwiftFormatTests/Rules/TypeNamesShouldBeCapitalizedTests.swift @@ -0,0 +1,124 @@ +@_spi(Rules) import SwiftFormat +import _SwiftFormatTestSupport + +final class TypeNamesShouldBeCapitalizedTests: LintOrFormatRuleTestCase { + func testConstruction() { + assertLint( + TypeNamesShouldBeCapitalized.self, + """ + struct 1️⃣a {} + class 2️⃣klassName { + struct 3️⃣subType {} + } + protocol 4️⃣myProtocol {} + + extension myType { + struct 5️⃣innerType {} + } + """, + findings: [ + FindingSpec("1️⃣", message: "rename the struct 'a' using UpperCamelCase; for example, 'A'"), + FindingSpec("2️⃣", message: "rename the class 'klassName' using UpperCamelCase; for example, 'KlassName'"), + FindingSpec("3️⃣", message: "rename the struct 'subType' using UpperCamelCase; for example, 'SubType'"), + FindingSpec("4️⃣", message: "rename the protocol 'myProtocol' using UpperCamelCase; for example, 'MyProtocol'"), + FindingSpec("5️⃣", message: "rename the struct 'innerType' using UpperCamelCase; for example, 'InnerType'"), + ] + ) + } + + func testActors() { + assertLint( + TypeNamesShouldBeCapitalized.self, + """ + actor 1️⃣myActor {} + actor OtherActor {} + distributed actor 2️⃣greeter {} + distributed actor DistGreeter {} + """, + findings: [ + FindingSpec("1️⃣", message: "rename the actor 'myActor' using UpperCamelCase; for example, 'MyActor'"), + FindingSpec("2️⃣", message: "rename the actor 'greeter' using UpperCamelCase; for example, 'Greeter'"), + ] + ) + } + + func testAssociatedTypeandTypeAlias() { + assertLint( + TypeNamesShouldBeCapitalized.self, + """ + protocol P { + associatedtype 1️⃣kind + associatedtype OtherKind + } + + typealias 2️⃣x = Int + typealias Y = String + + struct MyType { + typealias 3️⃣data = Y + + func test() { + typealias Value = Y + } + } + """, + findings: [ + FindingSpec("1️⃣", message: "rename the associated type 'kind' using UpperCamelCase; for example, 'Kind'"), + FindingSpec("2️⃣", message: "rename the type alias 'x' using UpperCamelCase; for example, 'X'"), + FindingSpec("3️⃣", message: "rename the type alias 'data' using UpperCamelCase; for example, 'Data'"), + ] + ) + } + + func testThatUnderscoredNamesAreDiagnosed() { + assertLint( + TypeNamesShouldBeCapitalized.self, + """ + protocol 1️⃣_p { + associatedtype 2️⃣_value + associatedtype __Value + } + + protocol ___Q { + } + + struct 3️⃣_data { + typealias 4️⃣_x = Int + } + + struct _Data {} + + actor 5️⃣_internalActor {} + + enum 6️⃣__e { + } + + enum _OtherE { + } + + func test() { + class 7️⃣_myClass {} + do { + class _MyClass {} + } + } + + distributed actor 8️⃣__greeter {} + distributed actor __InternalGreeter {} + """, + findings: [ + FindingSpec("1️⃣", message: "rename the protocol '_p' using UpperCamelCase; for example, '_P'"), + FindingSpec("2️⃣", message: "rename the associated type '_value' using UpperCamelCase; for example, '_Value'"), + FindingSpec("3️⃣", message: "rename the struct '_data' using UpperCamelCase; for example, '_Data'"), + FindingSpec("4️⃣", message: "rename the type alias '_x' using UpperCamelCase; for example, '_X'"), + FindingSpec( + "5️⃣", + message: "rename the actor '_internalActor' using UpperCamelCase; for example, '_InternalActor'" + ), + FindingSpec("6️⃣", message: "rename the enum '__e' using UpperCamelCase; for example, '__E'"), + FindingSpec("7️⃣", message: "rename the class '_myClass' using UpperCamelCase; for example, '_MyClass'"), + FindingSpec("8️⃣", message: "rename the actor '__greeter' using UpperCamelCase; for example, '__Greeter'"), + ] + ) + } +} diff --git a/Tests/SwiftFormatRulesTests/UseEarlyExitsTests.swift b/Tests/SwiftFormatTests/Rules/UseEarlyExitsTests.swift similarity index 71% rename from Tests/SwiftFormatRulesTests/UseEarlyExitsTests.swift rename to Tests/SwiftFormatTests/Rules/UseEarlyExitsTests.swift index 9f7f443ae..b488bdcd1 100644 --- a/Tests/SwiftFormatRulesTests/UseEarlyExitsTests.swift +++ b/Tests/SwiftFormatTests/Rules/UseEarlyExitsTests.swift @@ -1,14 +1,15 @@ -import SwiftFormatRules +@_spi(Rules) import SwiftFormat +import _SwiftFormatTestSupport final class UseEarlyExitsTests: LintOrFormatRuleTestCase { func testBasicIfElse() { // In this and other tests, the indentation of the true block in the expected output is // explicitly incorrect because this formatting rule does not fix it up with the assumption that // the pretty-printer will handle it. - XCTAssertFormatting( + assertFormatting( UseEarlyExits.self, input: """ - if condition { + 1️⃣if condition { trueBlock() } else { falseBlock() @@ -21,14 +22,18 @@ final class UseEarlyExitsTests: LintOrFormatRuleTestCase { return } trueBlock() - """) + """, + findings: [ + FindingSpec("1️⃣", message: "replace this 'if/else' block with a 'guard' statement containing the early exit") + ] + ) } func testIfElseWithBothEarlyExiting() { - XCTAssertFormatting( + assertFormatting( UseEarlyExits.self, input: """ - if condition { + 1️⃣if condition { trueBlock() return } else { @@ -43,7 +48,11 @@ final class UseEarlyExitsTests: LintOrFormatRuleTestCase { } trueBlock() return - """) + """, + findings: [ + FindingSpec("1️⃣", message: "replace this 'if/else' block with a 'guard' statement containing the early exit") + ] + ) } func testElseIfsDoNotChange() { @@ -55,7 +64,7 @@ final class UseEarlyExitsTests: LintOrFormatRuleTestCase { return } """ - XCTAssertFormatting(UseEarlyExits.self, input: input, expected: input) + assertFormatting(UseEarlyExits.self, input: input, expected: input, findings: []) } func testElsesAtEndOfElseIfsDoNotChange() { @@ -70,22 +79,22 @@ final class UseEarlyExitsTests: LintOrFormatRuleTestCase { return } """ - XCTAssertFormatting(UseEarlyExits.self, input: input, expected: input) + assertFormatting(UseEarlyExits.self, input: input, expected: input, findings: []) } func testComplex() { - XCTAssertFormatting( + assertFormatting( UseEarlyExits.self, input: """ func discombobulate(_ values: [Int]) throws -> Int { // Comment 1 - /*Comment 2*/ if let first = values.first { + /*Comment 2*/ 1️⃣if let first = values.first { // Comment 3 /// Doc comment - if first >= 0 { + 2️⃣if first >= 0 { // Comment 4 var result = 0 for value in values { @@ -125,6 +134,11 @@ final class UseEarlyExitsTests: LintOrFormatRuleTestCase { } return result } - """) + """, + findings: [ + FindingSpec("1️⃣", message: "replace this 'if/else' block with a 'guard' statement containing the early exit"), + FindingSpec("2️⃣", message: "replace this 'if/else' block with a 'guard' statement containing the early exit"), + ] + ) } } diff --git a/Tests/SwiftFormatTests/Rules/UseExplicitNilCheckInConditionsTests.swift b/Tests/SwiftFormatTests/Rules/UseExplicitNilCheckInConditionsTests.swift new file mode 100644 index 000000000..062de9927 --- /dev/null +++ b/Tests/SwiftFormatTests/Rules/UseExplicitNilCheckInConditionsTests.swift @@ -0,0 +1,151 @@ +@_spi(Rules) import SwiftFormat +import _SwiftFormatTestSupport + +final class UseExplicitNilCheckInConditionsTests: LintOrFormatRuleTestCase { + func testIfExpressions() { + assertFormatting( + UseExplicitNilCheckInConditions.self, + input: """ + if 1️⃣let _ = x {} + if let x = y, 2️⃣let _ = x.m {} + if let x = y {} else if 3️⃣let _ = z {} + """, + expected: """ + if x != nil {} + if let x = y, x.m != nil {} + if let x = y {} else if z != nil {} + """, + findings: [ + FindingSpec("1️⃣", message: "compare this value using `!= nil` instead of binding and discarding it"), + FindingSpec("2️⃣", message: "compare this value using `!= nil` instead of binding and discarding it"), + FindingSpec("3️⃣", message: "compare this value using `!= nil` instead of binding and discarding it"), + ] + ) + } + + func testGuardStatements() { + assertFormatting( + UseExplicitNilCheckInConditions.self, + input: """ + guard 1️⃣let _ = x else {} + guard let x = y, 2️⃣let _ = x.m else {} + """, + expected: """ + guard x != nil else {} + guard let x = y, x.m != nil else {} + """, + findings: [ + FindingSpec("1️⃣", message: "compare this value using `!= nil` instead of binding and discarding it"), + FindingSpec("2️⃣", message: "compare this value using `!= nil` instead of binding and discarding it"), + ] + ) + } + + func testWhileStatements() { + assertFormatting( + UseExplicitNilCheckInConditions.self, + input: """ + while 1️⃣let _ = x {} + while let x = y, 2️⃣let _ = x.m {} + """, + expected: """ + while x != nil {} + while let x = y, x.m != nil {} + """, + findings: [ + FindingSpec("1️⃣", message: "compare this value using `!= nil` instead of binding and discarding it"), + FindingSpec("2️⃣", message: "compare this value using `!= nil` instead of binding and discarding it"), + ] + ) + } + + func testTriviaPreservation() { + assertFormatting( + UseExplicitNilCheckInConditions.self, + input: """ + if /*comment*/ 1️⃣let _ = x /*comment*/ {} + if 2️⃣let _ = x // comment + {} + """, + expected: """ + if /*comment*/ x != nil /*comment*/ {} + if x != nil // comment + {} + """, + findings: [ + FindingSpec("1️⃣", message: "compare this value using `!= nil` instead of binding and discarding it"), + FindingSpec("2️⃣", message: "compare this value using `!= nil` instead of binding and discarding it"), + ] + ) + } + + func testDoNotDropTrailingCommaInConditionList() { + assertFormatting( + UseExplicitNilCheckInConditions.self, + input: """ + if 1️⃣let _ = x, 2️⃣let _ = y {} + """, + expected: """ + if x != nil, y != nil {} + """, + findings: [ + FindingSpec("1️⃣", message: "compare this value using `!= nil` instead of binding and discarding it"), + FindingSpec("2️⃣", message: "compare this value using `!= nil` instead of binding and discarding it"), + ] + ) + } + + func testAddNecessaryParenthesesAroundTryExpr() { + assertFormatting( + UseExplicitNilCheckInConditions.self, + input: """ + if 1️⃣let _ = try? x {} + if 2️⃣let _ = try x {} + """, + expected: """ + if (try? x) != nil {} + if (try x) != nil {} + """, + findings: [ + FindingSpec("1️⃣", message: "compare this value using `!= nil` instead of binding and discarding it"), + FindingSpec("2️⃣", message: "compare this value using `!= nil` instead of binding and discarding it"), + ] + ) + } + + func testAddNecessaryParenthesesAroundTernaryExpr() { + assertFormatting( + UseExplicitNilCheckInConditions.self, + input: """ + if 1️⃣let _ = x ? y : z {} + """, + expected: """ + if (x ? y : z) != nil {} + """, + findings: [ + FindingSpec("1️⃣", message: "compare this value using `!= nil` instead of binding and discarding it") + ] + ) + } + + func testAddNecessaryParenthesesAroundSameOrLowerPrecedenceOperator() { + // The use of `&&` and `==` are semantically meaningless here because they don't return + // optionals. We just need them to stand in for any potential custom operator with lower or same + // precedence, respectively. + assertFormatting( + UseExplicitNilCheckInConditions.self, + input: """ + if 1️⃣let _ = x && y {} + if 2️⃣let _ = x == y {} + """, + expected: """ + if (x && y) != nil {} + if (x == y) != nil {} + """, + findings: [ + FindingSpec("1️⃣", message: "compare this value using `!= nil` instead of binding and discarding it"), + FindingSpec("2️⃣", message: "compare this value using `!= nil` instead of binding and discarding it"), + ] + ) + } +} diff --git a/Tests/SwiftFormatTests/Rules/UseLetInEveryBoundCaseVariableTests.swift b/Tests/SwiftFormatTests/Rules/UseLetInEveryBoundCaseVariableTests.swift new file mode 100644 index 000000000..bf6912635 --- /dev/null +++ b/Tests/SwiftFormatTests/Rules/UseLetInEveryBoundCaseVariableTests.swift @@ -0,0 +1,196 @@ +@_spi(Rules) import SwiftFormat +import _SwiftFormatTestSupport + +final class UseLetInEveryBoundCaseVariableTests: LintOrFormatRuleTestCase { + func testSwitchCase() { + assertLint( + UseLetInEveryBoundCaseVariable.self, + """ + switch DataPoint.labeled("hello", 100) { + case 1️⃣let .labeled(label, value): break + case .labeled(label, let value): break + case .labeled(let label, let value): break + case 2️⃣let .labeled(label, value)?: break + case 3️⃣let .labeled(label, value)!: break + case 4️⃣let .labeled(label, value)??: break + case 5️⃣let (label, value): break + case let x as SomeType: break + } + """, + findings: [ + FindingSpec( + "1️⃣", + message: "move this 'let' keyword inside the 'case' pattern, before each of the bound variables" + ), + FindingSpec( + "2️⃣", + message: "move this 'let' keyword inside the 'case' pattern, before each of the bound variables" + ), + FindingSpec( + "3️⃣", + message: "move this 'let' keyword inside the 'case' pattern, before each of the bound variables" + ), + FindingSpec( + "4️⃣", + message: "move this 'let' keyword inside the 'case' pattern, before each of the bound variables" + ), + FindingSpec( + "5️⃣", + message: "move this 'let' keyword inside the 'case' pattern, before each of the bound variables" + ), + ] + ) + } + + func testIfCase() { + assertLint( + UseLetInEveryBoundCaseVariable.self, + """ + if case 1️⃣let .labeled(label, value) = DataPoint.labeled("hello", 100) {} + if case .labeled(label, let value) = DataPoint.labeled("hello", 100) {} + if case .labeled(let label, let value) = DataPoint.labeled("hello", 100) {} + if case 2️⃣let .labeled(label, value)? = DataPoint.labeled("hello", 100) {} + if case 3️⃣let .labeled(label, value)! = DataPoint.labeled("hello", 100) {} + if case 4️⃣let .labeled(label, value)?? = DataPoint.labeled("hello", 100) {} + if case 5️⃣let (label, value) = DataPoint.labeled("hello", 100) {} + if case let x as SomeType = someValue {} + """, + findings: [ + FindingSpec( + "1️⃣", + message: "move this 'let' keyword inside the 'case' pattern, before each of the bound variables" + ), + FindingSpec( + "2️⃣", + message: "move this 'let' keyword inside the 'case' pattern, before each of the bound variables" + ), + FindingSpec( + "3️⃣", + message: "move this 'let' keyword inside the 'case' pattern, before each of the bound variables" + ), + FindingSpec( + "4️⃣", + message: "move this 'let' keyword inside the 'case' pattern, before each of the bound variables" + ), + FindingSpec( + "5️⃣", + message: "move this 'let' keyword inside the 'case' pattern, before each of the bound variables" + ), + ] + ) + } + + func testGuardCase() { + assertLint( + UseLetInEveryBoundCaseVariable.self, + """ + guard case 1️⃣let .labeled(label, value) = DataPoint.labeled("hello", 100) else {} + guard case .labeled(label, let value) = DataPoint.labeled("hello", 100) else {} + guard case .labeled(let label, let value) = DataPoint.labeled("hello", 100) else {} + guard case 2️⃣let .labeled(label, value)? = DataPoint.labeled("hello", 100) else {} + guard case 3️⃣let .labeled(label, value)! = DataPoint.labeled("hello", 100) else {} + guard case 4️⃣let .labeled(label, value)?? = DataPoint.labeled("hello", 100) else {} + guard case 5️⃣let (label, value) = DataPoint.labeled("hello", 100) else {} + guard case let x as SomeType = someValue else {} + """, + findings: [ + FindingSpec( + "1️⃣", + message: "move this 'let' keyword inside the 'case' pattern, before each of the bound variables" + ), + FindingSpec( + "2️⃣", + message: "move this 'let' keyword inside the 'case' pattern, before each of the bound variables" + ), + FindingSpec( + "3️⃣", + message: "move this 'let' keyword inside the 'case' pattern, before each of the bound variables" + ), + FindingSpec( + "4️⃣", + message: "move this 'let' keyword inside the 'case' pattern, before each of the bound variables" + ), + FindingSpec( + "5️⃣", + message: "move this 'let' keyword inside the 'case' pattern, before each of the bound variables" + ), + ] + ) + } + + func testForCase() { + assertLint( + UseLetInEveryBoundCaseVariable.self, + """ + for case 1️⃣let .labeled(label, value) in dataPoints {} + for case .labeled(label, let value) in dataPoints {} + for case .labeled(let label, let value) in dataPoints {} + for case 2️⃣let .labeled(label, value)? in dataPoints {} + for case 3️⃣let .labeled(label, value)! in dataPoints {} + for case 4️⃣let .labeled(label, value)?? in dataPoints {} + for case 5️⃣let (label, value) in dataPoints {} + for case let x as SomeType in {} + """, + findings: [ + FindingSpec( + "1️⃣", + message: "move this 'let' keyword inside the 'case' pattern, before each of the bound variables" + ), + FindingSpec( + "2️⃣", + message: "move this 'let' keyword inside the 'case' pattern, before each of the bound variables" + ), + FindingSpec( + "3️⃣", + message: "move this 'let' keyword inside the 'case' pattern, before each of the bound variables" + ), + FindingSpec( + "4️⃣", + message: "move this 'let' keyword inside the 'case' pattern, before each of the bound variables" + ), + FindingSpec( + "5️⃣", + message: "move this 'let' keyword inside the 'case' pattern, before each of the bound variables" + ), + ] + ) + } + + func testWhileCase() { + assertLint( + UseLetInEveryBoundCaseVariable.self, + """ + while case 1️⃣let .labeled(label, value) = iter.next() {} + while case .labeled(label, let value) = iter.next() {} + while case .labeled(let label, let value) = iter.next() {} + while case 2️⃣let .labeled(label, value)? = iter.next() {} + while case 3️⃣let .labeled(label, value)! = iter.next() {} + while case 4️⃣let .labeled(label, value)?? = iter.next() {} + while case 5️⃣let (label, value) = iter.next() {} + while case let x as SomeType = iter.next() {} + """, + findings: [ + FindingSpec( + "1️⃣", + message: "move this 'let' keyword inside the 'case' pattern, before each of the bound variables" + ), + FindingSpec( + "2️⃣", + message: "move this 'let' keyword inside the 'case' pattern, before each of the bound variables" + ), + FindingSpec( + "3️⃣", + message: "move this 'let' keyword inside the 'case' pattern, before each of the bound variables" + ), + FindingSpec( + "4️⃣", + message: "move this 'let' keyword inside the 'case' pattern, before each of the bound variables" + ), + FindingSpec( + "5️⃣", + message: "move this 'let' keyword inside the 'case' pattern, before each of the bound variables" + ), + ] + ) + } +} diff --git a/Tests/SwiftFormatTests/Rules/UseShorthandTypeNamesTests.swift b/Tests/SwiftFormatTests/Rules/UseShorthandTypeNamesTests.swift new file mode 100644 index 000000000..188c7e4f8 --- /dev/null +++ b/Tests/SwiftFormatTests/Rules/UseShorthandTypeNamesTests.swift @@ -0,0 +1,734 @@ +@_spi(Rules) import SwiftFormat +import _SwiftFormatTestSupport + +final class UseShorthandTypeNamesTests: LintOrFormatRuleTestCase { + func testNamesInTypeContextsAreShortened() { + assertFormatting( + UseShorthandTypeNames.self, + input: """ + var a: 1️⃣Array = [] + var b: 2️⃣Dictionary = [:] + var c: 3️⃣Optional = nil + """, + expected: """ + var a: [Int] = [] + var b: [String: Int] = [:] + var c: Foo? = nil + """, + findings: [ + FindingSpec("1️⃣", message: "use shorthand syntax for this 'Array' type"), + FindingSpec("2️⃣", message: "use shorthand syntax for this 'Dictionary' type"), + FindingSpec("3️⃣", message: "use shorthand syntax for this 'Optional' type"), + ] + ) + } + + func testNestedNamesInTypeContextsAreShortened() { + assertFormatting( + UseShorthandTypeNames.self, + input: """ + var a: 1️⃣Array<2️⃣Array> + var b: 3️⃣Array<[Int]> + var c: [4️⃣Array] + """, + expected: """ + var a: [[Int]] + var b: [[Int]] + var c: [[Int]] + """, + findings: [ + FindingSpec("1️⃣", message: "use shorthand syntax for this 'Array' type"), + FindingSpec("2️⃣", message: "use shorthand syntax for this 'Array' type"), + FindingSpec("3️⃣", message: "use shorthand syntax for this 'Array' type"), + FindingSpec("4️⃣", message: "use shorthand syntax for this 'Array' type"), + ] + ) + + assertFormatting( + UseShorthandTypeNames.self, + input: """ + var a: 1️⃣Dictionary<2️⃣Dictionary, Int> + var b: 3️⃣Dictionary> + var c: 5️⃣Dictionary<6️⃣Dictionary, 7️⃣Dictionary> + var d: 8️⃣Dictionary<[String: Int], Int> + var e: 9️⃣Dictionary + """, + expected: """ + var a: [[String: Int]: Int] + var b: [String: [String: Int]] + var c: [[String: Int]: [String: Int]] + var d: [[String: Int]: Int] + var e: [String: [String: Int]] + """, + findings: [ + FindingSpec("1️⃣", message: "use shorthand syntax for this 'Dictionary' type"), + FindingSpec("2️⃣", message: "use shorthand syntax for this 'Dictionary' type"), + FindingSpec("3️⃣", message: "use shorthand syntax for this 'Dictionary' type"), + FindingSpec("4️⃣", message: "use shorthand syntax for this 'Dictionary' type"), + FindingSpec("5️⃣", message: "use shorthand syntax for this 'Dictionary' type"), + FindingSpec("6️⃣", message: "use shorthand syntax for this 'Dictionary' type"), + FindingSpec("7️⃣", message: "use shorthand syntax for this 'Dictionary' type"), + FindingSpec("8️⃣", message: "use shorthand syntax for this 'Dictionary' type"), + FindingSpec("9️⃣", message: "use shorthand syntax for this 'Dictionary' type"), + ] + ) + + assertFormatting( + UseShorthandTypeNames.self, + input: """ + var f: 1️⃣Dictionary<[String: Int], [String: Int]> + var g: [2️⃣Dictionary: Int] + var h: [String: 3️⃣Dictionary] + var i: [4️⃣Dictionary: 5️⃣Dictionary] + """, + expected: """ + var f: [[String: Int]: [String: Int]] + var g: [[String: Int]: Int] + var h: [String: [String: Int]] + var i: [[String: Int]: [String: Int]] + """, + findings: [ + FindingSpec("1️⃣", message: "use shorthand syntax for this 'Dictionary' type"), + FindingSpec("2️⃣", message: "use shorthand syntax for this 'Dictionary' type"), + FindingSpec("3️⃣", message: "use shorthand syntax for this 'Dictionary' type"), + FindingSpec("4️⃣", message: "use shorthand syntax for this 'Dictionary' type"), + FindingSpec("5️⃣", message: "use shorthand syntax for this 'Dictionary' type"), + ] + ) + + assertFormatting( + UseShorthandTypeNames.self, + input: """ + let a: 1️⃣Optional<2️⃣Array> + let b: 3️⃣Optional<4️⃣Dictionary> + let c: 5️⃣Optional<6️⃣Optional> + let d: 7️⃣Array? + let e: 8️⃣Dictionary? + let f: 9️⃣Optional? + let g: 🔟Optional + """, + expected: """ + let a: [Int]? + let b: [String: Int]? + let c: Int?? + let d: [Int]? + let e: [String: Int]? + let f: Int?? + let g: Int?? + """, + findings: [ + FindingSpec("1️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("2️⃣", message: "use shorthand syntax for this 'Array' type"), + FindingSpec("3️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("4️⃣", message: "use shorthand syntax for this 'Dictionary' type"), + FindingSpec("5️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("6️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("7️⃣", message: "use shorthand syntax for this 'Array' type"), + FindingSpec("8️⃣", message: "use shorthand syntax for this 'Dictionary' type"), + FindingSpec("9️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("🔟", message: "use shorthand syntax for this 'Optional' type"), + ] + ) + + assertFormatting( + UseShorthandTypeNames.self, + input: """ + var a: 1️⃣Array<2️⃣Optional> + var b: 3️⃣Dictionary<4️⃣Optional, 5️⃣Optional> + var c: 6️⃣Array + var d: 7️⃣Dictionary + """, + expected: """ + var a: [Int?] + var b: [String?: Int?] + var c: [Int?] + var d: [String?: Int?] + """, + findings: [ + FindingSpec("1️⃣", message: "use shorthand syntax for this 'Array' type"), + FindingSpec("2️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("3️⃣", message: "use shorthand syntax for this 'Dictionary' type"), + FindingSpec("4️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("5️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("6️⃣", message: "use shorthand syntax for this 'Array' type"), + FindingSpec("7️⃣", message: "use shorthand syntax for this 'Dictionary' type"), + ] + ) + } + + func testNamesInNonMemberAccessExpressionContextsAreShortened() { + assertFormatting( + UseShorthandTypeNames.self, + input: """ + var a = 1️⃣Array() + var b = 2️⃣Dictionary() + var c = 3️⃣Optional(from: decoder) + """, + expected: """ + var a = [Int]() + var b = [String: Int]() + var c = String?(from: decoder) + """, + findings: [ + FindingSpec("1️⃣", message: "use shorthand syntax for this 'Array' type"), + FindingSpec("2️⃣", message: "use shorthand syntax for this 'Dictionary' type"), + FindingSpec("3️⃣", message: "use shorthand syntax for this 'Optional' type"), + ] + ) + } + + func testNestedNamesInNonMemberAccessExpressionContextsAreShortened() { + assertFormatting( + UseShorthandTypeNames.self, + input: """ + var a = 1️⃣Array<2️⃣Array>() + var b = 3️⃣Array<[Int]>() + var c = [4️⃣Array]() + """, + expected: """ + var a = [[Int]]() + var b = [[Int]]() + var c = [[Int]]() + """, + findings: [ + FindingSpec("1️⃣", message: "use shorthand syntax for this 'Array' type"), + FindingSpec("2️⃣", message: "use shorthand syntax for this 'Array' type"), + FindingSpec("3️⃣", message: "use shorthand syntax for this 'Array' type"), + FindingSpec("4️⃣", message: "use shorthand syntax for this 'Array' type"), + ] + ) + + assertFormatting( + UseShorthandTypeNames.self, + input: """ + var a = 1️⃣Dictionary<2️⃣Dictionary, Int>() + var b = 3️⃣Dictionary>() + var c = 5️⃣Dictionary<6️⃣Dictionary, 7️⃣Dictionary>() + var d = 8️⃣Dictionary<[String: Int], Int>() + var e = 9️⃣Dictionary() + """, + expected: """ + var a = [[String: Int]: Int]() + var b = [String: [String: Int]]() + var c = [[String: Int]: [String: Int]]() + var d = [[String: Int]: Int]() + var e = [String: [String: Int]]() + """, + findings: [ + FindingSpec("1️⃣", message: "use shorthand syntax for this 'Dictionary' type"), + FindingSpec("2️⃣", message: "use shorthand syntax for this 'Dictionary' type"), + FindingSpec("3️⃣", message: "use shorthand syntax for this 'Dictionary' type"), + FindingSpec("4️⃣", message: "use shorthand syntax for this 'Dictionary' type"), + FindingSpec("5️⃣", message: "use shorthand syntax for this 'Dictionary' type"), + FindingSpec("6️⃣", message: "use shorthand syntax for this 'Dictionary' type"), + FindingSpec("7️⃣", message: "use shorthand syntax for this 'Dictionary' type"), + FindingSpec("8️⃣", message: "use shorthand syntax for this 'Dictionary' type"), + FindingSpec("9️⃣", message: "use shorthand syntax for this 'Dictionary' type"), + ] + ) + + assertFormatting( + UseShorthandTypeNames.self, + input: """ + var f = 1️⃣Dictionary<[String: Int], [String: Int]>() + var g = [2️⃣Dictionary: Int]() + var h = [String: 3️⃣Dictionary]() + var i = [4️⃣Dictionary: 5️⃣Dictionary]() + """, + expected: """ + var f = [[String: Int]: [String: Int]]() + var g = [[String: Int]: Int]() + var h = [String: [String: Int]]() + var i = [[String: Int]: [String: Int]]() + """, + findings: [ + FindingSpec("1️⃣", message: "use shorthand syntax for this 'Dictionary' type"), + FindingSpec("2️⃣", message: "use shorthand syntax for this 'Dictionary' type"), + FindingSpec("3️⃣", message: "use shorthand syntax for this 'Dictionary' type"), + FindingSpec("4️⃣", message: "use shorthand syntax for this 'Dictionary' type"), + FindingSpec("5️⃣", message: "use shorthand syntax for this 'Dictionary' type"), + ] + ) + + assertFormatting( + UseShorthandTypeNames.self, + input: """ + var a = 1️⃣Optional<2️⃣Array>(from: decoder) + var b = 3️⃣Optional<4️⃣Dictionary>(from: decoder) + var c = 5️⃣Optional<6️⃣Optional>(from: decoder) + var d = 7️⃣Array?(from: decoder) + var e = 8️⃣Dictionary?(from: decoder) + var f = 9️⃣Optional?(from: decoder) + var g = 🔟Optional(from: decoder) + """, + expected: """ + var a = [Int]?(from: decoder) + var b = [String: Int]?(from: decoder) + var c = Int??(from: decoder) + var d = [Int]?(from: decoder) + var e = [String: Int]?(from: decoder) + var f = Int??(from: decoder) + var g = Int??(from: decoder) + """, + findings: [ + FindingSpec("1️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("2️⃣", message: "use shorthand syntax for this 'Array' type"), + FindingSpec("3️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("4️⃣", message: "use shorthand syntax for this 'Dictionary' type"), + FindingSpec("5️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("6️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("7️⃣", message: "use shorthand syntax for this 'Array' type"), + FindingSpec("8️⃣", message: "use shorthand syntax for this 'Dictionary' type"), + FindingSpec("9️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("🔟", message: "use shorthand syntax for this 'Optional' type"), + ] + ) + + assertFormatting( + UseShorthandTypeNames.self, + input: """ + var a = 1️⃣Array<2️⃣Optional>() + var b = 3️⃣Dictionary<4️⃣Optional, 5️⃣Optional>() + var c = 6️⃣Array() + var d = 7️⃣Dictionary() + """, + expected: """ + var a = [Int?]() + var b = [String?: Int?]() + var c = [Int?]() + var d = [String?: Int?]() + """, + findings: [ + FindingSpec("1️⃣", message: "use shorthand syntax for this 'Array' type"), + FindingSpec("2️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("3️⃣", message: "use shorthand syntax for this 'Dictionary' type"), + FindingSpec("4️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("5️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("6️⃣", message: "use shorthand syntax for this 'Array' type"), + FindingSpec("7️⃣", message: "use shorthand syntax for this 'Dictionary' type"), + ] + ) + } + + func testTypesWithMemberAccessesAreNotShortened() { + assertFormatting( + UseShorthandTypeNames.self, + input: """ + var a: Array.Index = Array.Index() + var b: Dictionary.Index = Dictionary.Index() + var c: Array<1️⃣Optional>.Index = Array<2️⃣Optional>.Index() + var d: Dictionary<3️⃣Optional, 4️⃣Array>.Index = Dictionary<5️⃣Optional, 6️⃣Array>.Index() + var e: 7️⃣Array.Index> = 8️⃣Array.Index>() + """, + expected: """ + var a: Array.Index = Array.Index() + var b: Dictionary.Index = Dictionary.Index() + var c: Array.Index = Array.Index() + var d: Dictionary.Index = Dictionary.Index() + var e: [Array.Index] = [Array.Index]() + """, + findings: [ + FindingSpec("1️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("2️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("3️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("4️⃣", message: "use shorthand syntax for this 'Array' type"), + FindingSpec("5️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("6️⃣", message: "use shorthand syntax for this 'Array' type"), + FindingSpec("7️⃣", message: "use shorthand syntax for this 'Array' type"), + FindingSpec("8️⃣", message: "use shorthand syntax for this 'Array' type"), + ] + ) + + assertFormatting( + UseShorthandTypeNames.self, + input: """ + var f: Foo<1️⃣Array>.Bar = Foo<2️⃣Array>.Bar() + var g: Foo.Index>.Bar = Foo.Index>.Bar() + var h: Foo.Bar<3️⃣Array> = Foo.Bar<4️⃣Array>() + var i: Foo.Bar.Index> = Foo.Bar.Index>() + """, + expected: """ + var f: Foo<[Int]>.Bar = Foo<[Int]>.Bar() + var g: Foo.Index>.Bar = Foo.Index>.Bar() + var h: Foo.Bar<[Int]> = Foo.Bar<[Int]>() + var i: Foo.Bar.Index> = Foo.Bar.Index>() + """, + findings: [ + FindingSpec("1️⃣", message: "use shorthand syntax for this 'Array' type"), + FindingSpec("2️⃣", message: "use shorthand syntax for this 'Array' type"), + FindingSpec("3️⃣", message: "use shorthand syntax for this 'Array' type"), + FindingSpec("4️⃣", message: "use shorthand syntax for this 'Array' type"), + ] + ) + + assertFormatting( + UseShorthandTypeNames.self, + input: """ + var j: Optional<1️⃣Array>.Publisher = Optional<2️⃣Array>.Publisher() + var k: Optional<3️⃣Dictionary>.Publisher = Optional<4️⃣Dictionary>.Publisher() + var l: Optional<5️⃣Optional>.Publisher = Optional<6️⃣Optional>.Publisher() + """, + expected: """ + var j: Optional<[Int]>.Publisher = Optional<[Int]>.Publisher() + var k: Optional<[String: Int]>.Publisher = Optional<[String: Int]>.Publisher() + var l: Optional.Publisher = Optional.Publisher() + """, + findings: [ + FindingSpec("1️⃣", message: "use shorthand syntax for this 'Array' type"), + FindingSpec("2️⃣", message: "use shorthand syntax for this 'Array' type"), + FindingSpec("3️⃣", message: "use shorthand syntax for this 'Dictionary' type"), + FindingSpec("4️⃣", message: "use shorthand syntax for this 'Dictionary' type"), + FindingSpec("5️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("6️⃣", message: "use shorthand syntax for this 'Optional' type"), + ] + ) + } + + func testFunctionTypesAreOnlyWrappedWhenShortenedAsOptionals() { + // Some of these examples are questionable since function types aren't hashable and thus not + // valid dictionary keys, nor are they codable, but syntactically they're fine. + assertFormatting( + UseShorthandTypeNames.self, + input: """ + var a: 1️⃣Array<(Foo) -> Bar> = 2️⃣Array<(Foo) -> Bar>() + var b: 3️⃣Dictionary<(Foo) -> Bar, (Foo) -> Bar> = 4️⃣Dictionary<(Foo) -> Bar, (Foo) -> Bar>() + var c: 5️⃣Optional<(Foo) -> Bar> = 6️⃣Optional<(Foo) -> Bar>(from: decoder) + var d: 7️⃣Optional<((Foo) -> Bar)> = 8️⃣Optional<((Foo) -> Bar)>(from: decoder) + """, + expected: """ + var a: [(Foo) -> Bar] = [(Foo) -> Bar]() + var b: [(Foo) -> Bar: (Foo) -> Bar] = [(Foo) -> Bar: (Foo) -> Bar]() + var c: ((Foo) -> Bar)? = ((Foo) -> Bar)?(from: decoder) + var d: ((Foo) -> Bar)? = ((Foo) -> Bar)?(from: decoder) + """, + findings: [ + FindingSpec("1️⃣", message: "use shorthand syntax for this 'Array' type"), + FindingSpec("2️⃣", message: "use shorthand syntax for this 'Array' type"), + FindingSpec("3️⃣", message: "use shorthand syntax for this 'Dictionary' type"), + FindingSpec("4️⃣", message: "use shorthand syntax for this 'Dictionary' type"), + FindingSpec("5️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("6️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("7️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("8️⃣", message: "use shorthand syntax for this 'Optional' type"), + ] + ) + } + + func testTypesWithEmptyTupleAsGenericArgumentAreNotShortenedInExpressionContexts() { + // The Swift parser will treat `()` encountered in an expression context as the void *value*, + // not the type. This extends outwards to shorthand syntax, where `()?` would be treated as an + // attempt to optional-unwrap the tuple (which is not valid), `[()]` would be an array literal + // containing the empty tuple, and `[(): ()]` would be a dictionary literal mapping the empty + // tuple to the empty tuple. Because of this, we cannot permit the empty tuple type to appear + // directly inside an expression context. In type contexts, however, it's fine. + assertFormatting( + UseShorthandTypeNames.self, + input: """ + var a: 4️⃣Optional<()> = Optional<()>(from: decoder) + var b: 1️⃣Array<()> = Array<()>() + var c: 3️⃣Dictionary<(), ()> = Dictionary<(), ()>() + var d: 2️⃣Array<(5️⃣Optional<()>) -> 6️⃣Optional<()>> = Array<(7️⃣Optional<()>) -> 8️⃣Optional<()>>() + """, + expected: """ + var a: ()? = Optional<()>(from: decoder) + var b: [()] = Array<()>() + var c: [(): ()] = Dictionary<(), ()>() + var d: [(()?) -> ()?] = Array<(()?) -> ()?>() + """, + findings: [ + FindingSpec("1️⃣", message: "use shorthand syntax for this 'Array' type"), + FindingSpec("2️⃣", message: "use shorthand syntax for this 'Array' type"), + FindingSpec("3️⃣", message: "use shorthand syntax for this 'Dictionary' type"), + FindingSpec("4️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("5️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("6️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("7️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("8️⃣", message: "use shorthand syntax for this 'Optional' type"), + ] + ) + } + + func testPreservesNestedGenericsForUnshortenedTypes() { + // Regression test for a bug that discarded the generic argument list of a nested type when + // shortening something like `Array>` to `[Range]` (instead of `[Range]`. + assertFormatting( + UseShorthandTypeNames.self, + input: """ + var a: 1️⃣Array> = 2️⃣Array>() + var b: 3️⃣Dictionary, Range> = 4️⃣Dictionary, Range>() + var c: 5️⃣Optional> = 6️⃣Optional>(from: decoder) + """, + expected: """ + var a: [Range] = [Range]() + var b: [Range: Range] = [Range: Range]() + var c: Range? = Range?(from: decoder) + """, + findings: [ + FindingSpec("1️⃣", message: "use shorthand syntax for this 'Array' type"), + FindingSpec("2️⃣", message: "use shorthand syntax for this 'Array' type"), + FindingSpec("3️⃣", message: "use shorthand syntax for this 'Dictionary' type"), + FindingSpec("4️⃣", message: "use shorthand syntax for this 'Dictionary' type"), + FindingSpec("5️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("6️⃣", message: "use shorthand syntax for this 'Optional' type"), + ] + ) + } + + func testTypesWithIncorrectNumbersOfGenericArgumentsAreNotChanged() { + assertFormatting( + UseShorthandTypeNames.self, + input: """ + var a: Array<1️⃣Array, Bar> = Array<2️⃣Array, Bar>() + var b: Dictionary<3️⃣Dictionary> = Dictionary<4️⃣Dictionary>() + var c: Optional<5️⃣Optional, Bar> = Optional<6️⃣Optional, Bar>(from: decoder) + """, + expected: """ + var a: Array<[Foo], Bar> = Array<[Foo], Bar>() + var b: Dictionary<[Foo: Bar]> = Dictionary<[Foo: Bar]>() + var c: Optional = Optional(from: decoder) + """, + findings: [ + FindingSpec("1️⃣", message: "use shorthand syntax for this 'Array' type"), + FindingSpec("2️⃣", message: "use shorthand syntax for this 'Array' type"), + FindingSpec("3️⃣", message: "use shorthand syntax for this 'Dictionary' type"), + FindingSpec("4️⃣", message: "use shorthand syntax for this 'Dictionary' type"), + FindingSpec("5️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("6️⃣", message: "use shorthand syntax for this 'Optional' type"), + ] + ) + } + + func testModuleQualifiedNamesAreNotShortened() { + assertFormatting( + UseShorthandTypeNames.self, + input: """ + var a: Swift.Array<1️⃣Array> = Swift.Array<2️⃣Array>() + var b: Swift.Dictionary<3️⃣Dictionary, 4️⃣Dictionary> = Swift.Dictionary<5️⃣Dictionary, 6️⃣Dictionary>() + var c: Swift.Optional<7️⃣Optional> = Swift.Optional<8️⃣Optional>(from: decoder) + """, + expected: """ + var a: Swift.Array<[Foo]> = Swift.Array<[Foo]>() + var b: Swift.Dictionary<[Foo: Bar], [Foo: Bar]> = Swift.Dictionary<[Foo: Bar], [Foo: Bar]>() + var c: Swift.Optional = Swift.Optional(from: decoder) + """, + findings: [ + FindingSpec("1️⃣", message: "use shorthand syntax for this 'Array' type"), + FindingSpec("2️⃣", message: "use shorthand syntax for this 'Array' type"), + FindingSpec("3️⃣", message: "use shorthand syntax for this 'Dictionary' type"), + FindingSpec("4️⃣", message: "use shorthand syntax for this 'Dictionary' type"), + FindingSpec("5️⃣", message: "use shorthand syntax for this 'Dictionary' type"), + FindingSpec("6️⃣", message: "use shorthand syntax for this 'Dictionary' type"), + FindingSpec("7️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("8️⃣", message: "use shorthand syntax for this 'Optional' type"), + ] + ) + } + + func testTypesWeDoNotCareAboutAreUnchanged() { + assertFormatting( + UseShorthandTypeNames.self, + input: """ + var a: Larry = Larry() + var b: Pictionary = Pictionary() + var c: Sectional = Sectional(from: warehouse) + """, + expected: """ + var a: Larry = Larry() + var b: Pictionary = Pictionary() + var c: Sectional = Sectional(from: warehouse) + """, + findings: [] + ) + } + + func testOptionalStoredVarsWithoutInitializersAreNotChangedUnlessImmutable() { + assertFormatting( + UseShorthandTypeNames.self, + input: """ + var a: Optional + var b: Optional { + didSet {} + } + let c: 1️⃣Optional + """, + expected: """ + var a: Optional + var b: Optional { + didSet {} + } + let c: Int? + """, + findings: [ + FindingSpec("1️⃣", message: "use shorthand syntax for this 'Optional' type") + ] + ) + } + + func testOptionalComputedVarsAreChanged() { + assertFormatting( + UseShorthandTypeNames.self, + input: """ + var a: 1️⃣Optional { nil } + var b: 2️⃣Optional { + get { 0 } + } + var c: 3️⃣Optional { + _read {} + } + var d: 4️⃣Optional { + unsafeAddress {} + } + """, + expected: """ + var a: Int? { nil } + var b: Int? { + get { 0 } + } + var c: Int? { + _read {} + } + var d: Int? { + unsafeAddress {} + } + """, + findings: [ + FindingSpec("1️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("2️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("3️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("4️⃣", message: "use shorthand syntax for this 'Optional' type"), + ] + ) + } + + func testOptionalStoredVarsWithInitializersAreChanged() { + assertFormatting( + UseShorthandTypeNames.self, + input: """ + var a: 1️⃣Optional = nil + var b: 2️⃣Optional = nil { + didSet {} + } + let c: 3️⃣Optional = nil + """, + expected: """ + var a: Int? = nil + var b: Int? = nil { + didSet {} + } + let c: Int? = nil + """, + findings: [ + FindingSpec("1️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("2️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("3️⃣", message: "use shorthand syntax for this 'Optional' type"), + ] + ) + } + + func testOptionalsNestedInOtherTypesInStoredVarsAreStillChanged() { + assertFormatting( + UseShorthandTypeNames.self, + input: """ + var c: Generic<1️⃣Optional> + var d: [2️⃣Optional] + var e: [String: 3️⃣Optional] + """, + expected: """ + var c: Generic + var d: [Int?] + var e: [String: Int?] + """, + findings: [ + FindingSpec("1️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("2️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("3️⃣", message: "use shorthand syntax for this 'Optional' type"), + ] + ) + } + + func testSomeAnyTypesInOptionalsAreParenthesized() { + // If we need to insert parentheses, verify that we do, but also verify that we don't insert + // them unnecessarily. + assertFormatting( + UseShorthandTypeNames.self, + input: """ + func f(_: 1️⃣Optional) {} + func g(_: 2️⃣Optional) {} + var x: 3️⃣Optional = S() + var y: 4️⃣Optional = S() + var z = [5️⃣Optional]([S()]) + + func f(_: 6️⃣Optional<(some P)>) {} + func g(_: 7️⃣Optional<(any P)>) {} + var x: 8️⃣Optional<(some P)> = S() + var y: 9️⃣Optional<(any P)> = S() + var z = [🔟Optional<(any P)>]([S()]) + """, + expected: """ + func f(_: (some P)?) {} + func g(_: (any P)?) {} + var x: (some P)? = S() + var y: (any P)? = S() + var z = [(any P)?]([S()]) + + func f(_: (some P)?) {} + func g(_: (any P)?) {} + var x: (some P)? = S() + var y: (any P)? = S() + var z = [(any P)?]([S()]) + """, + findings: [ + FindingSpec("1️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("2️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("3️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("4️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("5️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("6️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("7️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("8️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("9️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("🔟", message: "use shorthand syntax for this 'Optional' type"), + ] + ) + } + + func testAttributedTypesInOptionalsAreParenthesized() { + // If we need to insert parentheses, verify that we do, but also verify that we don't insert + // them unnecessarily. + assertFormatting( + UseShorthandTypeNames.self, + input: """ + var x: 1️⃣Optional = S() + var y: 2️⃣Optional<@Sendable (Int) -> Void> = S() + var z = [3️⃣Optional]([S()]) + var a = [4️⃣Optional<@Sendable (Int) -> Void>]([S()]) + + var x: 5️⃣Optional<(consuming P)> = S() + var y: 6️⃣Optional<(@Sendable (Int) -> Void)> = S() + var z = [7️⃣Optional<(consuming P)>]([S()]) + var a = [8️⃣Optional<(@Sendable (Int) -> Void)>]([S()]) + """, + expected: """ + var x: (consuming P)? = S() + var y: (@Sendable (Int) -> Void)? = S() + var z = [(consuming P)?]([S()]) + var a = [(@Sendable (Int) -> Void)?]([S()]) + + var x: (consuming P)? = S() + var y: (@Sendable (Int) -> Void)? = S() + var z = [(consuming P)?]([S()]) + var a = [(@Sendable (Int) -> Void)?]([S()]) + """, + findings: [ + FindingSpec("1️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("2️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("3️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("4️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("5️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("6️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("7️⃣", message: "use shorthand syntax for this 'Optional' type"), + FindingSpec("8️⃣", message: "use shorthand syntax for this 'Optional' type"), + ] + ) + } +} diff --git a/Tests/SwiftFormatTests/Rules/UseSingleLinePropertyGetterTests.swift b/Tests/SwiftFormatTests/Rules/UseSingleLinePropertyGetterTests.swift new file mode 100644 index 000000000..0d66e4654 --- /dev/null +++ b/Tests/SwiftFormatTests/Rules/UseSingleLinePropertyGetterTests.swift @@ -0,0 +1,74 @@ +@_spi(Rules) import SwiftFormat +import _SwiftFormatTestSupport + +final class UseSingleLinePropertyGetterTests: LintOrFormatRuleTestCase { + func testMultiLinePropertyGetter() { + assertFormatting( + UseSingleLinePropertyGetter.self, + input: """ + var g: Int { return 4 } + var h: Int { + 1️⃣get { + return 4 + } + } + var i: Int { + get { return 0 } + set { print("no set, only get") } + } + var j: Int { + mutating get { return 0 } + } + var k: Int { + get async { + return 4 + } + } + var l: Int { + get throws { + return 4 + } + } + var m: Int { + get async throws { + return 4 + } + } + """, + expected: """ + var g: Int { return 4 } + var h: Int { + return 4 + } + var i: Int { + get { return 0 } + set { print("no set, only get") } + } + var j: Int { + mutating get { return 0 } + } + var k: Int { + get async { + return 4 + } + } + var l: Int { + get throws { + return 4 + } + } + var m: Int { + get async throws { + return 4 + } + } + """, + findings: [ + FindingSpec( + "1️⃣", + message: "remove 'get {...}' around the accessor and move its body directly into the computed property" + ) + ] + ) + } +} diff --git a/Tests/SwiftFormatRulesTests/UseSynthesizedInitializerTests.swift b/Tests/SwiftFormatTests/Rules/UseSynthesizedInitializerTests.swift similarity index 56% rename from Tests/SwiftFormatRulesTests/UseSynthesizedInitializerTests.swift rename to Tests/SwiftFormatTests/Rules/UseSynthesizedInitializerTests.swift index 3dbee0502..858393acd 100644 --- a/Tests/SwiftFormatRulesTests/UseSynthesizedInitializerTests.swift +++ b/Tests/SwiftFormatTests/Rules/UseSynthesizedInitializerTests.swift @@ -1,12 +1,10 @@ -import SwiftFormatRules +@_spi(Rules) import SwiftFormat +import _SwiftFormatTestSupport final class UseSynthesizedInitializerTests: LintOrFormatRuleTestCase { - override func setUp() { - self.shouldCheckForUnassertedDiagnostics = true - } - func testMemberwiseInitializerIsDiagnosed() { - let input = + assertLint( + UseSynthesizedInitializer.self, """ public struct Person { @@ -14,20 +12,48 @@ final class UseSynthesizedInitializerTests: LintOrFormatRuleTestCase { let phoneNumber: String internal let address: String - init(name: String, phoneNumber: String, address: String) { + 1️⃣init(name: String, phoneNumber: String, address: String) { self.name = name self.address = address self.phoneNumber = phoneNumber } } + """, + findings: [ + FindingSpec( + "1️⃣", + message: "remove this explicit initializer, which is identical to the compiler-synthesized initializer" + ) + ] + ) + } + + func testNestedMemberwiseInitializerIsDiagnosed() { + assertLint( + UseSynthesizedInitializer.self, """ + public struct MyContainer { + public struct Person { + public var name: String - performLint(UseSynthesizedInitializer.self, input: input) - XCTAssertDiagnosed(.removeRedundantInitializer, line: 7) + 1️⃣init(name: String) { + self.name = name + } + } + } + """, + findings: [ + FindingSpec( + "1️⃣", + message: "remove this explicit initializer, which is identical to the compiler-synthesized initializer" + ) + ] + ) } func testInternalMemberwiseInitializerIsDiagnosed() { - let input = + assertLint( + UseSynthesizedInitializer.self, """ public struct Person { @@ -35,43 +61,53 @@ final class UseSynthesizedInitializerTests: LintOrFormatRuleTestCase { let phoneNumber: String internal let address: String - internal init(name: String, phoneNumber: String, address: String) { + 1️⃣internal init(name: String, phoneNumber: String, address: String) { self.name = name self.address = address self.phoneNumber = phoneNumber } } - """ - - performLint(UseSynthesizedInitializer.self, input: input) - XCTAssertDiagnosed(.removeRedundantInitializer, line: 7) + """, + findings: [ + FindingSpec( + "1️⃣", + message: "remove this explicit initializer, which is identical to the compiler-synthesized initializer" + ) + ] + ) } func testMemberwiseInitializerWithDefaultArgumentIsDiagnosed() { - let input = - """ - public struct Person { - - public var name: String = "John Doe" - let phoneNumber: String - internal let address: String - - init(name: String = "John Doe", phoneNumber: String, address: String) { - self.name = name - self.address = address - self.phoneNumber = phoneNumber - } - } - """ - - performLint(UseSynthesizedInitializer.self, input: input) - XCTAssertDiagnosed(.removeRedundantInitializer, line: 7) - } + assertLint( + UseSynthesizedInitializer.self, + """ + public struct Person { + + public var name: String = "John Doe" + let phoneNumber: String + internal let address: String + + 1️⃣init(name: String = "John Doe", phoneNumber: String, address: String) { + self.name = name + self.address = address + self.phoneNumber = phoneNumber + } + } + """, + findings: [ + FindingSpec( + "1️⃣", + message: "remove this explicit initializer, which is identical to the compiler-synthesized initializer" + ) + ] + ) + } func testCustomInitializerVoidsSynthesizedInitializerWarning() { // The compiler won't create a memberwise initializer when there are any other initializers. // It's valid to have a memberwise initializer when there are any custom initializers. - let input = + assertLint( + UseSynthesizedInitializer.self, """ public struct Person { @@ -85,64 +121,64 @@ final class UseSynthesizedInitializerTests: LintOrFormatRuleTestCase { self.phoneNumber = phoneNumber } - init(name: String, phoneNumber: String, address: String) { + init(name: String, address: String) { self.name = name self.phoneNumber = "1234578910" self.address = address } } - """ - - performLint(UseSynthesizedInitializer.self, input: input) - XCTAssertNotDiagnosed(.removeRedundantInitializer) + """, + findings: [] + ) } func testMemberwiseInitializerWithDefaultArgument() { - let input = - """ - public struct Person { - - public var name: String - let phoneNumber: String - let address: String - - init(name: String = "Jane Doe", phoneNumber: String, address: String) { - self.name = name - self.address = address - self.phoneNumber = phoneNumber - } - } - """ - - performLint(UseSynthesizedInitializer.self, input: input) - XCTAssertNotDiagnosed(.removeRedundantInitializer) + assertLint( + UseSynthesizedInitializer.self, + """ + public struct Person { + + public var name: String + let phoneNumber: String + let address: String + + init(name: String = "Jane Doe", phoneNumber: String, address: String) { + self.name = name + self.address = address + self.phoneNumber = phoneNumber + } + } + """, + findings: [] + ) } func testMemberwiseInitializerWithNonMatchingDefaultValues() { - let input = - """ - public struct Person { - - public var name: String = "John Doe" - let phoneNumber: String - let address: String - - init(name: String = "Jane Doe", phoneNumber: String, address: String) { - self.name = name - self.address = address - self.phoneNumber = phoneNumber - } - } - """ - - performLint(UseSynthesizedInitializer.self, input: input) - XCTAssertNotDiagnosed(.removeRedundantInitializer) + assertLint( + UseSynthesizedInitializer.self, + """ + public struct Person { + + public var name: String = "John Doe" + let phoneNumber: String + let address: String + + init(name: String = "Jane Doe", phoneNumber: String, address: String) { + self.name = name + self.address = address + self.phoneNumber = phoneNumber + } + } + """, + findings: [] + ) } func testMemberwiseInitializerMissingDefaultValues() { // When the initializer doesn't contain a matching default argument, then it isn't equivalent to // the synthesized memberwise initializer. - let input = + assertLint( + UseSynthesizedInitializer.self, """ public struct Person { @@ -156,14 +192,14 @@ final class UseSynthesizedInitializerTests: LintOrFormatRuleTestCase { self.phoneNumber = phoneNumber } } - """ - - performLint(UseSynthesizedInitializer.self, input: input) - XCTAssertNotDiagnosed(.removeRedundantInitializer) + """, + findings: [] + ) } func testCustomInitializerWithMismatchedTypes() { - let input = + assertLint( + UseSynthesizedInitializer.self, """ public struct Person { @@ -177,14 +213,14 @@ final class UseSynthesizedInitializerTests: LintOrFormatRuleTestCase { self.phoneNumber = phoneNumber } } - """ - - performLint(UseSynthesizedInitializer.self, input: input) - XCTAssertNotDiagnosed(.removeRedundantInitializer) + """, + findings: [] + ) } func testCustomInitializerWithExtraParameters() { - let input = + assertLint( + UseSynthesizedInitializer.self, """ public struct Person { @@ -198,14 +234,14 @@ final class UseSynthesizedInitializerTests: LintOrFormatRuleTestCase { self.phoneNumber = phoneNumber } } - """ - - performLint(UseSynthesizedInitializer.self, input: input) - XCTAssertNotDiagnosed(.removeRedundantInitializer) + """, + findings: [] + ) } func testCustomInitializerWithExtraStatements() { - let input = + assertLint( + UseSynthesizedInitializer.self, #""" public struct Person { @@ -221,14 +257,14 @@ final class UseSynthesizedInitializerTests: LintOrFormatRuleTestCase { print("phoneNumber: \(self.phoneNumber)") } } - """# - - performLint(UseSynthesizedInitializer.self, input: input) - XCTAssertNotDiagnosed(.removeRedundantInitializer) + """#, + findings: [] + ) } func testFailableMemberwiseInitializerIsNotDiagnosed() { - let input = + assertLint( + UseSynthesizedInitializer.self, """ public struct Person { @@ -242,14 +278,14 @@ final class UseSynthesizedInitializerTests: LintOrFormatRuleTestCase { self.phoneNumber = phoneNumber } } - """ - - performLint(UseSynthesizedInitializer.self, input: input) - XCTAssertNotDiagnosed(.removeRedundantInitializer) + """, + findings: [] + ) } func testThrowingMemberwiseInitializerIsNotDiagnosed() { - let input = + assertLint( + UseSynthesizedInitializer.self, """ public struct Person { @@ -263,14 +299,14 @@ final class UseSynthesizedInitializerTests: LintOrFormatRuleTestCase { self.phoneNumber = phoneNumber } } - """ - - performLint(UseSynthesizedInitializer.self, input: input) - XCTAssertNotDiagnosed(.removeRedundantInitializer) + """, + findings: [] + ) } func testPublicMemberwiseInitializerIsNotDiagnosed() { - let input = + assertLint( + UseSynthesizedInitializer.self, """ public struct Person { @@ -284,16 +320,16 @@ final class UseSynthesizedInitializerTests: LintOrFormatRuleTestCase { self.phoneNumber = phoneNumber } } - """ - - performLint(UseSynthesizedInitializer.self, input: input) - XCTAssertNotDiagnosed(.removeRedundantInitializer) + """, + findings: [] + ) } func testDefaultMemberwiseInitializerIsNotDiagnosed() { // The synthesized initializer is private when any member is private, so an initializer with // default access control (i.e. internal) is not equivalent to the synthesized initializer. - let input = + assertLint( + UseSynthesizedInitializer.self, """ public struct Person { @@ -305,64 +341,74 @@ final class UseSynthesizedInitializerTests: LintOrFormatRuleTestCase { self.phoneNumber = phoneNumber } } - """ - - performLint(UseSynthesizedInitializer.self, input: input) - XCTAssertNotDiagnosed(.removeRedundantInitializer) + """, + findings: [] + ) } func testPrivateMemberwiseInitializerWithPrivateMemberIsDiagnosed() { // The synthesized initializer is private when any member is private, so a private initializer // is equivalent to the synthesized initializer. - let input = + assertLint( + UseSynthesizedInitializer.self, """ public struct Person { let phoneNumber: String private let address: String - private init(phoneNumber: String, address: String) { + 1️⃣private init(phoneNumber: String, address: String) { self.address = address self.phoneNumber = phoneNumber } } - """ - - performLint(UseSynthesizedInitializer.self, input: input) - XCTAssertDiagnosed(.removeRedundantInitializer, line: 6) + """, + findings: [ + FindingSpec( + "1️⃣", + message: "remove this explicit initializer, which is identical to the compiler-synthesized initializer" + ) + ] + ) } func testFileprivateMemberwiseInitializerWithFileprivateMemberIsDiagnosed() { // The synthesized initializer is fileprivate when any member is fileprivate, so a fileprivate // initializer is equivalent to the synthesized initializer. - let input = + assertLint( + UseSynthesizedInitializer.self, """ public struct Person { let phoneNumber: String fileprivate let address: String - fileprivate init(phoneNumber: String, address: String) { + 1️⃣fileprivate init(phoneNumber: String, address: String) { self.address = address self.phoneNumber = phoneNumber } } - """ - - performLint(UseSynthesizedInitializer.self, input: input) - XCTAssertDiagnosed(.removeRedundantInitializer, line: 6) + """, + findings: [ + FindingSpec( + "1️⃣", + message: "remove this explicit initializer, which is identical to the compiler-synthesized initializer" + ) + ] + ) } func testCustomSetterAccessLevel() { // When a property has a different access level for its setter, the setter's access level // doesn't change the access level of the synthesized initializer. - let input = + assertLint( + UseSynthesizedInitializer.self, """ public struct Person { let phoneNumber: String private(set) let address: String - init(phoneNumber: String, address: String) { + 1️⃣init(phoneNumber: String, address: String) { self.address = address self.phoneNumber = phoneNumber } @@ -372,7 +418,7 @@ final class UseSynthesizedInitializerTests: LintOrFormatRuleTestCase { fileprivate let phoneNumber: String private(set) let address: String - fileprivate init(phoneNumber: String, address: String) { + 2️⃣fileprivate init(phoneNumber: String, address: String) { self.address = address self.phoneNumber = phoneNumber } @@ -382,7 +428,7 @@ final class UseSynthesizedInitializerTests: LintOrFormatRuleTestCase { fileprivate(set) let phoneNumber: String private(set) let address: String - init(phoneNumber: String, address: String) { + 3️⃣init(phoneNumber: String, address: String) { self.address = address self.phoneNumber = phoneNumber } @@ -397,11 +443,39 @@ final class UseSynthesizedInitializerTests: LintOrFormatRuleTestCase { self.phoneNumber = phoneNumber } } + """, + findings: [ + FindingSpec( + "1️⃣", + message: "remove this explicit initializer, which is identical to the compiler-synthesized initializer" + ), + FindingSpec( + "2️⃣", + message: "remove this explicit initializer, which is identical to the compiler-synthesized initializer" + ), + FindingSpec( + "3️⃣", + message: "remove this explicit initializer, which is identical to the compiler-synthesized initializer" + ), + ] + ) + } + + func testMemberwiseInitializerWithAttributeIsNotDiagnosed() { + assertLint( + UseSynthesizedInitializer.self, """ + public struct Person { + let phoneNumber: String + let address: String - performLint(UseSynthesizedInitializer.self, input: input) - XCTAssertDiagnosed(.removeRedundantInitializer, line: 5) - XCTAssertDiagnosed(.removeRedundantInitializer, line: 15) - XCTAssertDiagnosed(.removeRedundantInitializer, line: 25) + @inlinable init(phoneNumber: String, address: String) { + self.address = address + self.phoneNumber = phoneNumber + } + } + """, + findings: [] + ) } } diff --git a/Tests/SwiftFormatTests/Rules/UseTripleSlashForDocumentationCommentsTests.swift b/Tests/SwiftFormatTests/Rules/UseTripleSlashForDocumentationCommentsTests.swift new file mode 100644 index 000000000..1691e3c0b --- /dev/null +++ b/Tests/SwiftFormatTests/Rules/UseTripleSlashForDocumentationCommentsTests.swift @@ -0,0 +1,193 @@ +@_spi(Rules) import SwiftFormat +import _SwiftFormatTestSupport + +final class UseTripleSlashForDocumentationCommentsTests: LintOrFormatRuleTestCase { + func testRemoveDocBlockComments() { + assertFormatting( + UseTripleSlashForDocumentationComments.self, + input: """ + /** + * This comment should not be converted. + */ + + 1️⃣/** + * Returns a docLineComment. + * + * - Parameters: + * - withOutStar: Indicates if the comment start with a star. + * - Returns: docLineComment. + */ + func foo(withOutStar: Bool) {} + """, + expected: """ + /** + * This comment should not be converted. + */ + + /// Returns a docLineComment. + /// + /// - Parameters: + /// - withOutStar: Indicates if the comment start with a star. + /// - Returns: docLineComment. + func foo(withOutStar: Bool) {} + """, + findings: [ + FindingSpec("1️⃣", message: "replace documentation block comments with documentation line comments") + ] + ) + } + + func testRemoveDocBlockCommentsWithoutStars() { + assertFormatting( + UseTripleSlashForDocumentationComments.self, + input: """ + 1️⃣/** + Returns a docLineComment. + + - Parameters: + - withStar: Indicates if the comment start with a star. + - Returns: docLineComment. + */ + public var test = 1 + """, + expected: """ + /// Returns a docLineComment. + /// + /// - Parameters: + /// - withStar: Indicates if the comment start with a star. + /// - Returns: docLineComment. + public var test = 1 + """, + findings: [ + FindingSpec("1️⃣", message: "replace documentation block comments with documentation line comments") + ] + ) + } + + func testMultipleTypesOfDocComments() { + assertFormatting( + UseTripleSlashForDocumentationComments.self, + input: """ + /** + * This is my preamble. It could be important. + * This comment stays as-is. + */ + + /// This decl has a comment. + /// The comment is multiple lines long. + public class AClazz { + } + """, + expected: """ + /** + * This is my preamble. It could be important. + * This comment stays as-is. + */ + + /// This decl has a comment. + /// The comment is multiple lines long. + public class AClazz { + } + """, + findings: [] + ) + } + + func testMultipleDocLineComments() { + assertFormatting( + UseTripleSlashForDocumentationComments.self, + input: """ + /// This is my preamble. It could be important. + /// This comment stays as-is. + /// + + /// This decl has a comment. + /// The comment is multiple lines long. + public class AClazz { + } + """, + expected: """ + /// This is my preamble. It could be important. + /// This comment stays as-is. + /// + + /// This decl has a comment. + /// The comment is multiple lines long. + public class AClazz { + } + """, + findings: [] + ) + } + + func testManyDocComments() { + // Note that this retains the trailing space at the end of a single-line doc block comment + // (i.e., the space in `name. */`). It's fine to leave it here; the pretty printer will remove + // it later. + assertFormatting( + UseTripleSlashForDocumentationComments.self, + input: """ + /** + * This is my preamble. It could be important. + * This comment stays as-is. + */ + + /// This is a doc-line comment! + + /** This is a fairly short doc-block comment. */ + + /// Why are there so many comments? + /// Who knows! But there are loads. + + 1️⃣/** AClazz is a class with good name. */ + public class AClazz { + } + """, + expected: """ + /** + * This is my preamble. It could be important. + * This comment stays as-is. + */ + + /// This is a doc-line comment! + + /** This is a fairly short doc-block comment. */ + + /// Why are there so many comments? + /// Who knows! But there are loads. + + /// AClazz is a class with good name.\u{0020} + public class AClazz { + } + """, + findings: [ + FindingSpec("1️⃣", message: "replace documentation block comments with documentation line comments") + ] + ) + } + + func testDocLineCommentsAreNotNormalized() { + assertFormatting( + UseTripleSlashForDocumentationComments.self, + input: """ + /// + /// Normally that initial blank line and these leading spaces + /// would be removed by DocumentationCommentText. But we don't + /// touch the comment if it's already a doc line comment. + /// + public class AClazz { + } + """, + expected: """ + /// + /// Normally that initial blank line and these leading spaces + /// would be removed by DocumentationCommentText. But we don't + /// touch the comment if it's already a doc line comment. + /// + public class AClazz { + } + """, + findings: [] + ) + } +} diff --git a/Tests/SwiftFormatTests/Rules/UseWhereClausesInForLoopsTests.swift b/Tests/SwiftFormatTests/Rules/UseWhereClausesInForLoopsTests.swift new file mode 100644 index 000000000..1110e63ca --- /dev/null +++ b/Tests/SwiftFormatTests/Rules/UseWhereClausesInForLoopsTests.swift @@ -0,0 +1,95 @@ +@_spi(Rules) import SwiftFormat +import _SwiftFormatTestSupport + +final class UseWhereClausesInForLoopsTests: LintOrFormatRuleTestCase { + func testForLoopWhereClauses() { + assertFormatting( + UseWhereClausesInForLoops.self, + input: """ + for i in [0, 1, 2, 3] { + 1️⃣if i > 30 { + print(i) + } + } + + for i in [0, 1, 2, 3] { + if i > 30 { + print(i) + } else { + print(i) + } + } + + for i in [0, 1, 2, 3] { + if i > 30 { + print(i) + } else if i > 40 { + print(i) + } + } + + for i in [0, 1, 2, 3] { + if i > 30 { + print(i) + } + print(i) + } + + for i in [0, 1, 2, 3] { + if let x = (2 as Int?) { + print(i) + } + } + + for i in [0, 1, 2, 3] { + 2️⃣guard i > 30 else { + continue + } + print(i) + } + """, + expected: """ + for i in [0, 1, 2, 3] where i > 30 { + print(i) + } + + for i in [0, 1, 2, 3] { + if i > 30 { + print(i) + } else { + print(i) + } + } + + for i in [0, 1, 2, 3] { + if i > 30 { + print(i) + } else if i > 40 { + print(i) + } + } + + for i in [0, 1, 2, 3] { + if i > 30 { + print(i) + } + print(i) + } + + for i in [0, 1, 2, 3] { + if let x = (2 as Int?) { + print(i) + } + } + + for i in [0, 1, 2, 3] where i > 30 { + print(i) + } + """, + findings: [ + FindingSpec("1️⃣", message: "replace this 'if' statement with a 'where' clause"), + FindingSpec("2️⃣", message: "replace this 'guard' statement with a 'where' clause"), + ] + ) + } +} diff --git a/Tests/SwiftFormatTests/Rules/ValidateDocumentationCommentsTests.swift b/Tests/SwiftFormatTests/Rules/ValidateDocumentationCommentsTests.swift new file mode 100644 index 000000000..38e41c1d3 --- /dev/null +++ b/Tests/SwiftFormatTests/Rules/ValidateDocumentationCommentsTests.swift @@ -0,0 +1,279 @@ +@_spi(Rules) import SwiftFormat +import _SwiftFormatTestSupport + +// FIXME: Diagnostics should be emitted inside the comment, not at the beginning of the declaration. +final class ValidateDocumentationCommentsTests: LintOrFormatRuleTestCase { + func testParameterDocumentation() { + assertLint( + ValidateDocumentationComments.self, + """ + /// Uses 'Parameters' when it only has one parameter. + /// + /// - Parameters: + /// - singular: singular description. + /// - Returns: A string containing the contents of a + /// description + 1️⃣func testPluralParamDesc(singular: String) -> Bool {} + + /// Returns the output generated by executing a command with the given string + /// used as standard input. + /// + /// - Parameter command: The command to execute in the shell environment. + /// - Parameter stdin: The string to use as standard input. + /// - Returns: A string containing the contents of the invoked process's + /// standard output. + 2️⃣func testInvalidParameterDesc(command: String, stdin: String) -> String {} + """, + findings: [ + FindingSpec( + "1️⃣", + message: "replace the plural 'Parameters:' section with a singular inline 'Parameter' section" + ), + FindingSpec( + "2️⃣", + message: + "replace the singular inline 'Parameter' section with a plural 'Parameters:' section that has the parameters nested inside it" + ), + ] + ) + } + + func testParametersName() { + assertLint( + ValidateDocumentationComments.self, + """ + /// Parameters dont match. + /// + /// - Parameters: + /// - sum: The sum of all numbers. + /// - avg: The average of all numbers. + /// - Returns: The sum of sum and avg. + 1️⃣func sum(avg: Int, sum: Int) -> Int {} + + /// Missing one parameter documentation. + /// + /// - Parameters: + /// - p1: Parameter 1. + /// - p2: Parameter 2. + /// - Returns: an integer. + 2️⃣func foo(p1: Int, p2: Int, p3: Int) -> Int {} + """, + findings: [ + FindingSpec("1️⃣", message: "change the parameters of the documentation of 'sum' to match its parameters"), + FindingSpec("2️⃣", message: "change the parameters of the documentation of 'foo' to match its parameters"), + ] + ) + } + + func testThrowsDocumentation() { + assertLint( + ValidateDocumentationComments.self, + """ + /// One sentence summary. + /// + /// - Parameters: + /// - p1: Parameter 1. + /// - p2: Parameter 2. + /// - p3: Parameter 3. + /// - Throws: an error. + 1️⃣func doesNotThrow(p1: Int, p2: Int, p3: Int) {} + + /// One sentence summary. + /// + /// - Parameters: + /// - p1: Parameter 1. + /// - p2: Parameter 2. + /// - p3: Parameter 3. + func doesThrow(p1: Int, p2: Int, p3: Int) 2️⃣throws {} + + /// One sentence summary. + /// + /// - Parameter p1: Parameter 1. + /// - Throws: doesn't really throw, just rethrows + func doesRethrow(p1: (() throws -> ())) 3️⃣rethrows {} + """, + findings: [ + FindingSpec("1️⃣", message: "remove the 'Throws:' sections of 'doesNotThrow'; it does not throw any errors"), + FindingSpec("2️⃣", message: "add a 'Throws:' section to document the errors thrown by 'doesThrow'"), + FindingSpec("3️⃣", message: "remove the 'Throws:' sections of 'doesRethrow'; it does not throw any errors"), + ] + ) + } + + func testReturnDocumentation() { + assertLint( + ValidateDocumentationComments.self, + """ + /// One sentence summary. + /// + /// - Parameters: + /// - p1: Parameter 1. + /// - p2: Parameter 2. + /// - p3: Parameter 3. + /// - Returns: an integer. + 1️⃣func noReturn(p1: Int, p2: Int, p3: Int) {} + + /// One sentence summary. + /// + /// - Parameters: + /// - p1: Parameter 1. + /// - p2: Parameter 2. + /// - p3: Parameter 3. + func foo(p1: Int, p2: Int, p3: Int) 2️⃣-> Int {} + + /// One sentence summary. + /// + /// - Parameters: + /// - p1: Parameter 1. + /// - p2: Parameter 2. + /// - p3: Parameter 3. + func neverReturns(p1: Int, p2: Int, p3: Int) -> Never {} + + /// One sentence summary. + /// + /// - Parameters: + /// - p1: Parameter 1. + /// - p2: Parameter 2. + /// - p3: Parameter 3. + /// - Returns: Never returns. + func documentedNeverReturns(p1: Int, p2: Int, p3: Int) -> Never {} + """, + findings: [ + FindingSpec("1️⃣", message: "remove the 'Returns:' section of 'noReturn'; it does not return a value"), + FindingSpec("2️⃣", message: "add a 'Returns:' section to document the return value of 'foo'"), + ] + ) + } + + func testValidDocumentation() { + assertLint( + ValidateDocumentationComments.self, + """ + /// Returns the output generated by executing a command. + /// + /// - Parameter command: The command to execute in the shell environment. + /// - Returns: A string containing the contents of the invoked process's + /// standard output. + func singularParam(command: String) -> String { + // ... + } + + /// Returns the output generated by executing a command with the given string + /// used as standard input. + /// + /// - Parameters: + /// - command: The command to execute in the shell environment. + /// - stdin: The string to use as standard input. + /// - Throws: An error, possibly. + /// - Returns: A string containing the contents of the invoked process's + /// standard output. + func pluralParam(command: String, stdin: String) throws -> String { + // ... + } + + /// One sentence summary. + /// + /// - Parameter p1: Parameter 1. + func rethrower(p1: (() throws -> ())) rethrows { + // ... + } + + /// Parameter(s) and Returns tags may be omitted only if the single-sentence + /// brief summary fully describes the meaning of those items and including the + /// tags would only repeat what has already been said + func omittedFunc(p1: Int) + """, + findings: [] + ) + } + + func testSeparateLabelAndIdentifier() { + assertLint( + ValidateDocumentationComments.self, + """ + /// Returns the output generated by executing a command. + /// + /// - Parameter command: The command to execute in the shell environment. + /// - Returns: A string containing the contents of the invoked process's + /// standard output. + 1️⃣func incorrectParam(label commando: String) -> String { + // ... + } + + /// Returns the output generated by executing a command. + /// + /// - Parameter command: The command to execute in the shell environment. + /// - Returns: A string containing the contents of the invoked process's + /// standard output. + func singularParam(label command: String) -> String { + // ... + } + + /// Returns the output generated by executing a command with the given string + /// used as standard input. + /// + /// - Parameters: + /// - command: The command to execute in the shell environment. + /// - stdin: The string to use as standard input. + /// - Returns: A string containing the contents of the invoked process's + /// standard output. + func pluralParam(label command: String, label2 stdin: String) -> String { + // ... + } + """, + findings: [ + FindingSpec( + "1️⃣", + message: "change the parameters of the documentation of 'incorrectParam' to match its parameters" + ) + ] + ) + } + + func testInitializer() { + assertLint( + ValidateDocumentationComments.self, + """ + struct SomeType { + /// Brief summary. + /// + /// - Parameter command: The command to execute in the shell environment. + /// - Returns: Shouldn't be here. + 1️⃣2️⃣init(label commando: String) { + // ... + } + + /// Brief summary. + /// + /// - Parameter command: The command to execute in the shell environment. + init(label command: String) { + // ... + } + + /// Brief summary. + /// + /// - Parameters: + /// - command: The command to execute in the shell environment. + /// - stdin: The string to use as standard input. + init(label command: String, label2 stdin: String) { + // ... + } + + /// Brief summary. + /// + /// - Parameters: + /// - command: The command to execute in the shell environment. + /// - stdin: The string to use as standard input. + /// - Throws: An error. + init(label command: String, label2 stdin: String) throws { + // ... + } + } + """, + findings: [ + FindingSpec("1️⃣", message: "remove the 'Returns:' section of 'init'; it does not return a value"), + FindingSpec("2️⃣", message: "change the parameters of the documentation of 'init' to match its parameters"), + ] + ) + } +} diff --git a/Tests/SwiftFormatTests/Utilities/FileIteratorTests.swift b/Tests/SwiftFormatTests/Utilities/FileIteratorTests.swift new file mode 100644 index 000000000..6c15e6f41 --- /dev/null +++ b/Tests/SwiftFormatTests/Utilities/FileIteratorTests.swift @@ -0,0 +1,179 @@ +@_spi(Internal) @_spi(Testing) import SwiftFormat +import XCTest + +extension URL { + /// Assuming this is a file URL, resolves all symlinks in the path. + /// + /// - Note: We need this because `URL.resolvingSymlinksInPath()` not only resolves symlinks but also standardizes the + /// path by stripping away `private` prefixes. Since sourcekitd is not performing this standardization, using + /// `resolvingSymlinksInPath` can lead to slightly mismatched URLs between the sourcekit-lsp response and the test + /// assertion. + fileprivate var realpath: URL { + #if canImport(Darwin) + return self.path.withCString { path in + guard let realpath = Darwin.realpath(path, nil) else { + return self + } + let result = URL(fileURLWithPath: String(cString: realpath)) + free(realpath) + return result + } + #else + // Non-Darwin platforms don't have the `/private` stripping issue, so we can just use `self.resolvingSymlinksInPath` + // here. + return self.resolvingSymlinksInPath() + #endif + } +} + +final class FileIteratorTests: XCTestCase { + private var tmpdir: URL! + + override func setUpWithError() throws { + tmpdir = try FileManager.default.url( + for: .itemReplacementDirectory, + in: .userDomainMask, + appropriateFor: FileManager.default.temporaryDirectory, + create: true + ).realpath + + // Create a simple file tree used by the tests below. + try touch("project/real1.swift") + try touch("project/real2.swift") + try touch("project/.hidden.swift") + try touch("project/.build/generated.swift") + try symlink("project/link.swift", to: "project/.hidden.swift") + try symlink("project/rellink.swift", relativeTo: ".hidden.swift") + } + + override func tearDownWithError() throws { + try FileManager.default.removeItem(at: tmpdir) + } + + func testNoFollowSymlinks() throws { + #if os(Windows) && compiler(<5.10) + try XCTSkipIf(true, "Foundation does not follow symlinks on Windows") + #endif + let seen = allFilesSeen(iteratingOver: [tmpdir], followSymlinks: false) + XCTAssertEqual(seen.count, 2) + XCTAssertTrue(seen.contains { $0.path.hasSuffix("project/real1.swift") }) + XCTAssertTrue(seen.contains { $0.path.hasSuffix("project/real2.swift") }) + } + + func testFollowSymlinks() throws { + #if os(Windows) && compiler(<5.10) + try XCTSkipIf(true, "Foundation does not follow symlinks on Windows") + #endif + let seen = allFilesSeen(iteratingOver: [tmpdir], followSymlinks: true) + XCTAssertEqual(seen.count, 3) + XCTAssertTrue(seen.contains { $0.path.hasSuffix("project/real1.swift") }) + XCTAssertTrue(seen.contains { $0.path.hasSuffix("project/real2.swift") }) + // Hidden but found through the visible symlink project/link.swift + XCTAssertTrue(seen.contains { $0.path.hasSuffix("project/.hidden.swift") }) + } + + func testTraversesHiddenFilesIfExplicitlySpecified() throws { + #if os(Windows) && compiler(<5.10) + try XCTSkipIf(true, "Foundation does not follow symlinks on Windows") + #endif + let seen = allFilesSeen( + iteratingOver: [tmpURL("project/.build"), tmpURL("project/.hidden.swift")], + followSymlinks: false + ) + XCTAssertEqual(seen.count, 2) + XCTAssertTrue(seen.contains { $0.path.hasSuffix("project/.build/generated.swift") }) + XCTAssertTrue(seen.contains { $0.path.hasSuffix("project/.hidden.swift") }) + } + + func testDoesNotFollowSymlinksIfFollowSymlinksIsFalseEvenIfExplicitlySpecified() { + // Symlinks are not traversed even if `followSymlinks` is false even if they are explicitly + // passed to the iterator. This is meant to avoid situations where a symlink could be hidden by + // shell expansion; for example, if the user writes `swift-format --no-follow-symlinks *`, if + // the current directory contains a symlink, they would probably *not* expect it to be followed. + let seen = allFilesSeen( + iteratingOver: [tmpURL("project/link.swift"), tmpURL("project/rellink.swift")], + followSymlinks: false + ) + XCTAssertTrue(seen.isEmpty) + } + + func testDoesNotTrimFirstCharacterOfPathIfRunningInRoot() throws { + // Find the root of tmpdir. On Unix systems, this is always `/`. On Windows it is the drive. + var root = tmpdir! + while !root.isRoot { + root.deleteLastPathComponent() + } + #if os(Windows) && compiler(<6.1) + var rootPath = root.path + if rootPath.hasPrefix("/") { + // Canonicalize /C: to C: + rootPath = String(rootPath.dropFirst()) + } + #else + let rootPath = root.path + #endif + // Make sure that we don't drop the beginning of the path if we are running in root. + // https://github.com/swiftlang/swift-format/issues/862 + let seen = allFilesSeen(iteratingOver: [tmpdir], followSymlinks: false, workingDirectory: root).map(\.relativePath) + XCTAssertTrue(seen.allSatisfy { $0.hasPrefix(rootPath) }, "\(seen) does not contain root directory '\(rootPath)'") + } + + func testShowsRelativePaths() throws { + // Make sure that we still show the relative path if using them. + // https://github.com/swiftlang/swift-format/issues/862 + let seen = allFilesSeen(iteratingOver: [tmpdir], followSymlinks: false, workingDirectory: tmpdir) + XCTAssertEqual(Set(seen.map(\.relativePath)), ["project/real1.swift", "project/real2.swift"]) + } +} + +extension FileIteratorTests { + /// Returns a URL to a file or directory in the test's temporary space. + private func tmpURL(_ path: String) -> URL { + return tmpdir.appendingPathComponent(path, isDirectory: false) + } + + /// Create an empty file at the given path in the test's temporary space. + private func touch(_ path: String) throws { + let fileURL = tmpURL(path) + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + struct FailedToCreateFileError: Error { + let url: URL + } + if !FileManager.default.createFile(atPath: fileURL.path, contents: Data()) { + throw FailedToCreateFileError(url: fileURL) + } + } + + /// Create a absolute symlink between files or directories in the test's temporary space. + private func symlink(_ source: String, to target: String) throws { + try FileManager.default.createSymbolicLink( + at: tmpURL(source), + withDestinationURL: tmpURL(target) + ) + } + + /// Create a relative symlink between files or directories in the test's temporary space. + private func symlink(_ source: String, relativeTo target: String) throws { + try FileManager.default.createSymbolicLink( + atPath: tmpURL(source).path, + withDestinationPath: target + ) + } + + /// Computes the list of all files seen by using `FileIterator` to iterate over the given URLs. + private func allFilesSeen( + iteratingOver urls: [URL], + followSymlinks: Bool, + workingDirectory: URL = URL(fileURLWithPath: ".") + ) -> [URL] { + let iterator = FileIterator(urls: urls, followSymlinks: followSymlinks, workingDirectory: workingDirectory) + var seen: [URL] = [] + for next in iterator { + seen.append(next) + } + return seen + } +} diff --git a/Tests/SwiftFormatWhitespaceLinterTests/WhitespaceLintTests.swift b/Tests/SwiftFormatWhitespaceLinterTests/WhitespaceLintTests.swift deleted file mode 100644 index f2c9433c3..000000000 --- a/Tests/SwiftFormatWhitespaceLinterTests/WhitespaceLintTests.swift +++ /dev/null @@ -1,288 +0,0 @@ -import SwiftFormatConfiguration -import SwiftFormatWhitespaceLinter - -final class WhitespaceLintTests: WhitespaceTestCase { - func testSpacing() { - let input = - """ - let a : Int = 123 - let b =456 - - """ - - let expected = - """ - let a: Int = 123 - let b = 456 - - """ - - performWhitespaceLint(input: input, expected: expected) - XCTAssertDiagnosed(.spacingError(-1), line: 1, column: 6) - XCTAssertDiagnosed(.spacingError(1), line: 2, column: 8) - } - - func testTabSpacing() { - let input = - """ - let a\t: Int = 123 - - """ - - let expected = - """ - let a: Int = 123 - - """ - - performWhitespaceLint(input: input, expected: expected) - XCTAssertDiagnosed(.spacingCharError, line: 1, column: 6) - } - - func testSpaceIndentation() { - let input = - """ - let a = 123 - let b = 456 - let c = "abc" - \tlet d = 111 - - """ - - let expected = - """ - let a = 123 - let b = 456 - let c = "abc" - let d = 111 - - """ - - performWhitespaceLint(input: input, expected: expected) - XCTAssertDiagnosed( - .indentationError(expected: .none, actual: .homogeneous(.spaces(2))), line: 1, column: 1) - XCTAssertDiagnosed( - .indentationError(expected: .homogeneous(.spaces(4)), actual: .none), line: 2, column: 1) - XCTAssertDiagnosed( - .indentationError(expected: .none, actual: .homogeneous(.spaces(1))), line: 3, column: 1) - XCTAssertDiagnosed( - .indentationError(expected: .homogeneous(.spaces(2)), actual: .homogeneous(.tabs(1))), - line: 4, - column: 1) - } - - func testTabIndentation() { - let input = - """ - \t\tlet a = 123 - let b = 456 - let c = "abc" - let d = 111 - - """ - - let expected = - """ - let a = 123 - \tlet b = 456 - let c = "abc" - \t\tlet d = 111 - - """ - - performWhitespaceLint(input: input, expected: expected) - XCTAssertDiagnosed( - .indentationError(expected: .none, actual: .homogeneous(.tabs(2))), line: 1, column: 1) - XCTAssertDiagnosed( - .indentationError(expected: .homogeneous(.tabs(1)), actual: .none), line: 2, column: 1) - XCTAssertDiagnosed( - .indentationError(expected: .none, actual: .homogeneous(.spaces(2))), line: 3, column: 1) - XCTAssertDiagnosed( - .indentationError(expected: .homogeneous(.tabs(2)), actual: .homogeneous(.spaces(1))), - line: 4, - column: 1) - } - - func testHeterogeneousIndentation() { - let input = - """ - \t\t \t let a = 123 - let b = 456 - let c = "abc" - \tlet d = 111 - \t let e = 111 - - """ - - let expected = - """ - let a = 123 - \t \t let b = 456 - let c = "abc" - let d = 111 - \tlet e = 111 - - """ - - performWhitespaceLint(input: input, expected: expected) - XCTAssertDiagnosed( - .indentationError( - expected: .homogeneous(.spaces(2)), - actual: .heterogeneous([.tabs(2), .spaces(2), .tabs(1), .spaces(1)])), - line: 1, - column: 1) - XCTAssertDiagnosed( - .indentationError( - expected: .heterogeneous([.tabs(1), .spaces(2), .tabs(1), .spaces(1)]), - actual: .none), - line: 2, - column: 1) - XCTAssertDiagnosed( - .indentationError( - expected: .none, - actual: .homogeneous(.spaces(2))), - line: 3, - column: 1) - XCTAssertDiagnosed( - .indentationError( - expected: .homogeneous(.spaces(2)), - actual: .homogeneous(.tabs(1))), - line: 4, - column: 1) - XCTAssertDiagnosed( - .indentationError( - expected: .heterogeneous([.spaces(1), .tabs(1)]), - actual: .heterogeneous([.tabs(1), .spaces(1)])), - line: 5, - column: 1) - } - - func testTrailingWhitespace() { - let input = - """ - let a = 123\u{20}\u{20} - let b = "abc"\u{20} - let c = "def" - \u{20}\u{20} - let d = 456\u{20}\u{20}\u{20} - - """ - - let expected = - """ - let a = 123 - let b = "abc" - let c = "def" - - let d = 456 - - """ - - performWhitespaceLint(input: input, expected: expected) - XCTAssertDiagnosed(.trailingWhitespaceError, line: 1, column: 12) - XCTAssertDiagnosed(.trailingWhitespaceError, line: 2, column: 14) - XCTAssertDiagnosed(.trailingWhitespaceError, line: 4, column: 1) - XCTAssertDiagnosed(.trailingWhitespaceError, line: 5, column: 12) - } - - func testAddLines() { - let input = - """ - let a = 123 - let b = "abc" - func myfun() { return } - - """ - - let expected = - """ - let a = 123 - - let b = "abc" - func myfun() { - return - } - - """ - - performWhitespaceLint(input: input, expected: expected) - XCTAssertDiagnosed(.addLinesError(1), line: 1, column: 12) - XCTAssertDiagnosed(.addLinesError(1), line: 3, column: 15) - XCTAssertDiagnosed(.addLinesError(1), line: 3, column: 22) - } - - func testRemoveLines() { - let input = - """ - let a = 123 - - let b = "abc" - - - let c = 456 - func myFun() { - return someValue - } - - """ - - let expected = - """ - let a = 123 - let b = "abc" - let c = 456 - func myFun() { return someValue } - - """ - - performWhitespaceLint(input: input, expected: expected) - XCTAssertDiagnosed(.removeLineError, line: 1, column: 12) - XCTAssertDiagnosed(.removeLineError, line: 3, column: 14) - XCTAssertDiagnosed(.removeLineError, line: 4, column: 1) - XCTAssertDiagnosed(.removeLineError, line: 7, column: 15) - XCTAssertDiagnosed(.removeLineError, line: 8, column: 19) - } - - func testLineLength() { - let input = - """ - func myFunc(longVar1: Bool, longVar2: Bool, longVar3: Bool, longVar4: Bool) { - // do stuff - } - - func myFunc(longVar1: Bool, longVar2: Bool, - longVar3: Bool, - longVar4: Bool) { - // do stuff - } - - """ - - let expected = - """ - func myFunc( - longVar1: Bool, - longVar2: Bool, - longVar3: Bool, - longVar4: Bool - ) { - // do stuff - } - - func myFunc( - longVar1: Bool, - longVar2: Bool, - longVar3: Bool, - longVar4: Bool - ) { - // do stuff - } - - """ - - performWhitespaceLint(input: input, expected: expected, linelength: 30) - XCTAssertDiagnosed(.lineLengthError, line: 1, column: 1) - XCTAssertDiagnosed(.lineLengthError, line: 5, column: 1) - XCTAssertDiagnosed(.addLinesError(1), line: 7, column: 17) - } -} diff --git a/Tests/SwiftFormatWhitespaceLinterTests/WhitespaceTestCase.swift b/Tests/SwiftFormatWhitespaceLinterTests/WhitespaceTestCase.swift deleted file mode 100644 index 07b64cfb4..000000000 --- a/Tests/SwiftFormatWhitespaceLinterTests/WhitespaceTestCase.swift +++ /dev/null @@ -1,33 +0,0 @@ -import SwiftFormatConfiguration -import SwiftFormatCore -import SwiftFormatTestSupport -import SwiftFormatWhitespaceLinter -import SwiftSyntax -import SwiftParser -import XCTest - -class WhitespaceTestCase: DiagnosingTestCase { - override func setUp() { - super.setUp() - shouldCheckForUnassertedDiagnostics = true - } - - /// Perform whitespace linting by comparing the input text from the user with the expected - /// formatted text. - /// - /// - Parameters: - /// - input: The user's input text. - /// - expected: The formatted text. - /// - linelength: The maximum allowed line length of the output. - final func performWhitespaceLint(input: String, expected: String, linelength: Int? = nil) { - let sourceFileSyntax = Parser.parse(source: input) - var configuration = Configuration() - if let linelength = linelength { - configuration.lineLength = linelength - } - - let context = makeContext(sourceFileSyntax: sourceFileSyntax, configuration: configuration) - let linter = WhitespaceLinter(user: input, formatted: expected, context: context) - linter.lint() - } -} diff --git a/api-breakages.txt b/api-breakages.txt new file mode 100644 index 000000000..987ca864c --- /dev/null +++ b/api-breakages.txt @@ -0,0 +1,4 @@ +6.2 +--- + +API breakage: constructor FileIterator.init(urls:followSymlinks:) has been removed diff --git a/build-script-helper.py b/build-script-helper.py index 5b3f83516..6842ae4e8 100755 --- a/build-script-helper.py +++ b/build-script-helper.py @@ -15,211 +15,316 @@ from __future__ import print_function import argparse -import sys -import os, platform +import json +import os import subprocess +import sys +from pathlib import Path +from typing import List, Optional, Union -def printerr(message): - print(message, file=sys.stderr) +# ----------------------------------------------------------------------------- +# General utilities -def main(argv_prefix = []): - args = parse_args(argv_prefix + sys.argv[1:]) - run(args) -def parse_args(args): - parser = argparse.ArgumentParser(prog='build-script-helper.py') +def fatal_error(message: str) -> None: + print(message, file=sys.stderr) + raise SystemExit(1) - parser.add_argument('--package-path', default='') - parser.add_argument('-v', '--verbose', action='store_true', help='log executed commands') - parser.add_argument('--prefix', dest='install_prefixes', nargs='*', metavar='PATHS', help='install path') - parser.add_argument('--configuration', default='debug') - parser.add_argument('--build-path', default=None) - parser.add_argument('--multiroot-data-file', help='Path to an Xcode workspace to create a unified build of SwiftSyntax with other projects.') - parser.add_argument('--toolchain', required=True, help='the toolchain to use when building this package') - parser.add_argument('--update', action='store_true', help='update all SwiftPM dependencies') - parser.add_argument('--no-local-deps', action='store_true', help='use normal remote dependencies when building') - parser.add_argument('build_actions', help="Extra actions to perform. Can be any number of the following", choices=['all', 'build', 'test', 'generate-xcodeproj', 'install'], nargs="*", default=['build']) - parsed = parser.parse_args(args) +def printerr(message: str) -> None: + print(message, file=sys.stderr) - parsed.swift_exec = os.path.join(parsed.toolchain, 'bin', 'swift') - # Convert package_path to absolute path, relative to root of repo. - repo_path = os.path.dirname(__file__) - parsed.package_path = os.path.realpath( - os.path.join(repo_path, parsed.package_path)) +def check_call( + cmd: List[Union[str, Path]], verbose: bool, env=os.environ, **kwargs +) -> None: + if verbose: + print(" ".join([escape_cmd_arg(arg) for arg in cmd])) + subprocess.check_call(cmd, env=env, stderr=subprocess.STDOUT, **kwargs) - if not parsed.build_path: - parsed.build_path = os.path.join(parsed.package_path, '.build') - return parsed +def check_output( + cmd: List[Union[str, Path]], verbose, env=os.environ, capture_stderr=True, **kwargs +) -> str: + if verbose: + print(" ".join([escape_cmd_arg(arg) for arg in cmd])) + if capture_stderr: + stderr = subprocess.STDOUT + else: + stderr = subprocess.DEVNULL + return subprocess.check_output( + cmd, env=env, stderr=stderr, encoding="utf-8", **kwargs + ) -def run(args): - package_name = os.path.basename(args.package_path) - env = get_swiftpm_environment_variables(no_local_deps=args.no_local_deps) - # Use local dependencies (i.e. checked out next swift-format). - - if args.update: - print("** Updating dependencies of %s **" % package_name) - try: - update_swiftpm_dependencies(package_path=args.package_path, - swift_exec=args.swift_exec, - build_path=args.build_path, - env=env, - verbose=args.verbose) - except subprocess.CalledProcessError as e: - printerr('FAIL: Updating dependencies of %s failed' % package_name) - printerr('Executing: %s' % ' '.join(e.cmd)) - sys.exit(1) - - # The test action creates its own build. No need to build if we are just testing. - if should_run_action('build', args.build_actions) or should_run_action('install', args.build_actions): - print("** Building %s **" % package_name) - try: - invoke_swift(package_path=args.package_path, +def escape_cmd_arg(arg: Union[str, Path]) -> str: + arg = str(arg) + if '"' in arg or " " in arg: + return '"%s"' % arg.replace('"', '\\"') + else: + return arg + + +# ----------------------------------------------------------------------------- +# SwiftPM wrappers + + +def get_build_target(swift_exec: Path, cross_compile_config: Optional[Path]) -> str: + """Returns the target-triple of the current machine or for cross-compilation.""" + command = [swift_exec, "-print-target-info"] + if cross_compile_config: + cross_compile_json = json.load(open(cross_compile_config)) + command += ["-target", cross_compile_json["target"]] + target_info_json = subprocess.check_output( + command, stderr=subprocess.PIPE, universal_newlines=True + ).strip() + target_info = json.loads(target_info_json) + if "-apple-macosx" in target_info["target"]["unversionedTriple"]: + return target_info["target"]["unversionedTriple"] + return target_info["target"]["triple"] + + +def get_swiftpm_options( + swift_exec: Path, + package_path: Path, + build_path: Path, + multiroot_data_file: Optional[Path], + configuration: str, + cross_compile_host: Optional[str], + cross_compile_config: Optional[Path], + verbose: bool, +) -> List[Union[str, Path]]: + args: List[Union[str, Path]] = [ + "--package-path", + package_path, + "--configuration", + configuration, + "--scratch-path", + build_path, + ] + if multiroot_data_file: + args += ["--multiroot-data-file", multiroot_data_file] + if verbose: + args += ["--verbose"] + build_target = get_build_target( + swift_exec, cross_compile_config=cross_compile_config + ) + build_os = build_target.split("-")[2] + if not build_os.startswith("macosx"): + # Library rpath for swift, dispatch, Foundation, etc. when installing + args += [ + "-Xlinker", + "-rpath", + "-Xlinker", + "$ORIGIN/../lib/swift/" + build_os, + ] + args += ['--disable-local-rpath'] + + if cross_compile_host: + if build_os.startswith("macosx") and cross_compile_host.startswith("macosx-"): + args += ["--arch", "x86_64", "--arch", "arm64"] + else: + fatal_error("cannot cross-compile for %s" % cross_compile_host) + + return args + + +def get_swiftpm_environment_variables(action: str): + env = dict(os.environ) + env["SWIFTCI_USE_LOCAL_DEPS"] = "1" + if action == "install": + env["SWIFTFORMAT_CI_INSTALL"] = "1" + return env + + +def invoke_swiftpm( + package_path: Path, + swift_exec: Path, + action: str, + product: str, + build_path: Path, + multiroot_data_file: Optional[Path], + configuration: str, + cross_compile_host: Optional[str], + cross_compile_config: Optional[Path], + env, + verbose: bool, +): + """ + Build or test a single SwiftPM product. + """ + args = [swift_exec, action] + args += get_swiftpm_options( + swift_exec=swift_exec, + package_path=package_path, + build_path=build_path, + multiroot_data_file=multiroot_data_file, + configuration=configuration, + cross_compile_host=cross_compile_host, + cross_compile_config=cross_compile_config, + verbose=verbose, + ) + if action == "test": + args += ["--test-product", product, "--disable-testable-imports"] + else: + args += ["--product", product] + + check_call(args, env=env, verbose=verbose) + + +# ----------------------------------------------------------------------------- +# Actions + + +def build(args: argparse.Namespace) -> None: + print("** Building swift-format **") + env = get_swiftpm_environment_variables(args.action) + invoke_swiftpm( + package_path=args.package_path, swift_exec=args.swift_exec, - action='build', - products=['swift-format'], + action="build", + product="swift-format", build_path=args.build_path, multiroot_data_file=args.multiroot_data_file, configuration=args.configuration, + cross_compile_host=args.cross_compile_host, + cross_compile_config=args.cross_compile_config, env=env, - verbose=args.verbose) - except subprocess.CalledProcessError as e: - printerr('FAIL: Building %s failed' % package_name) - printerr('Executing: %s' % ' '.join(e.cmd)) - sys.exit(1) - - output_dir = os.path.realpath(os.path.join(args.build_path, args.configuration)) - - if should_run_action("generate-xcodeproj", args.build_actions): - print("** Generating Xcode project for %s **" % package_name) - try: - generate_xcodeproj(args.package_path, - swift_exec=args.swift_exec, - env=env, - verbose=args.verbose) - except subprocess.CalledProcessError as e: - printerr('FAIL: Generating the Xcode project failed') - printerr('Executing: %s' % ' '.join(e.cmd)) - sys.exit(1) - - if should_run_action("test", args.build_actions): - print("** Testing %s **" % package_name) - try: - invoke_swift(package_path=args.package_path, + verbose=args.verbose, + ) + + +def test(args: argparse.Namespace) -> None: + print("** Testing swift-format **") + env = get_swiftpm_environment_variables(args.action) + invoke_swiftpm( + package_path=args.package_path, swift_exec=args.swift_exec, - action='test', - products=['%sPackageTests' % package_name], + action="test", + product="swift-formatPackageTests", build_path=args.build_path, multiroot_data_file=args.multiroot_data_file, configuration=args.configuration, + cross_compile_host=args.cross_compile_host, + cross_compile_config=args.cross_compile_config, env=env, - verbose=args.verbose) - except subprocess.CalledProcessError as e: - printerr('FAIL: Testing %s failed' % package_name) - printerr('Executing: %s' % ' '.join(e.cmd)) - sys.exit(1) + verbose=args.verbose, + ) + + +def install(args: argparse.Namespace) -> None: + build(args) - if should_run_action("install", args.build_actions): - print("** Installing %s **" % package_name) + print("** Installing swift-format **") + env = get_swiftpm_environment_variables(args.action) swiftpm_args = get_swiftpm_options( - package_path=args.package_path, - build_path=args.build_path, - multiroot_data_file=args.multiroot_data_file, - configuration=args.configuration, - verbose=args.verbose + swift_exec=args.swift_exec, + package_path=args.package_path, + build_path=args.build_path, + multiroot_data_file=args.multiroot_data_file, + configuration=args.configuration, + cross_compile_host=args.cross_compile_host, + cross_compile_config=args.cross_compile_config, + verbose=args.verbose, ) - cmd = [args.swift_exec, 'build', '--show-bin-path'] + swiftpm_args - bin_path = check_output(cmd, env=env, capture_stderr=False, verbose=args.verbose).strip() - + cmd = [args.swift_exec, "build", "--show-bin-path"] + swiftpm_args + bin_path = check_output( + cmd, env=env, capture_stderr=False, verbose=args.verbose + ).strip() + for prefix in args.install_prefixes: - cmd = ['rsync', '-a', os.path.join(bin_path, 'swift-format'), os.path.join(prefix, 'bin')] + cmd = [ + "rsync", + "-a", + Path(bin_path) / "swift-format", + prefix / "bin", + ] check_call(cmd, verbose=args.verbose) -def should_run_action(action_name, selected_actions): - if action_name in selected_actions: - return True - elif "all" in selected_actions: - return True - else: - return False - -def update_swiftpm_dependencies(package_path, swift_exec, build_path, env, verbose): - args = [swift_exec, 'package', '--package-path', package_path, '--scratch-path', build_path, 'update'] - check_call(args, env=env, verbose=verbose) - -def invoke_swift(package_path, swift_exec, action, products, build_path, multiroot_data_file, configuration, env, verbose): - # Until rdar://53881101 is implemented, we cannot request a build of multiple - # targets simultaneously. For now, just build one product after the other. - for product in products: - invoke_swift_single_product(package_path, swift_exec, action, product, build_path, multiroot_data_file, configuration, env, verbose) - -def get_swiftpm_options(package_path, build_path, multiroot_data_file, configuration, verbose): - args = [ - '--package-path', package_path, - '--configuration', configuration, - '--scratch-path', build_path - ] - if multiroot_data_file: - args += ['--multiroot-data-file', multiroot_data_file] - if verbose: - args += ['--verbose'] - if platform.system() == 'Darwin': - args += [ - '-Xlinker', '-rpath', '-Xlinker', '/usr/lib/swift', - '-Xlinker', '-rpath', '-Xlinker', '@executable_path/../lib/swift/macosx', - '-Xlinker', '-rpath', '-Xlinker', '@executable_path/../lib/swift-5.5/macosx', - ] - return args - -def get_swiftpm_environment_variables(no_local_deps): - # Tell SwiftSyntax that we are building in a build-script environment so that - # it does not need to be rebuilt if it has already been built before. - env = dict(os.environ) - env['SWIFT_BUILD_SCRIPT_ENVIRONMENT'] = '1' - if not no_local_deps: - env['SWIFTCI_USE_LOCAL_DEPS'] = "1" - return env - - -def invoke_swift_single_product(package_path, swift_exec, action, product, build_path, multiroot_data_file, configuration, env, verbose): - args = [swift_exec, action] - args += get_swiftpm_options(package_path, build_path, multiroot_data_file, configuration, verbose) - if action == 'test': - args += ['--test-product', product] - else: - args += ['--product', product] - - check_call(args, env=env, verbose=verbose) - -def generate_xcodeproj(package_path, swift_exec, env, verbose): - package_name = os.path.basename(package_path) - xcodeproj_path = os.path.join(package_path, '%s.xcodeproj' % package_name) - args = [swift_exec, 'package', '--package-path', package_path, 'generate-xcodeproj', '--output', xcodeproj_path] - check_call(args, env=env, verbose=verbose) - -def check_call(cmd, verbose, env=os.environ, **kwargs): - if verbose: - print(' '.join([escape_cmd_arg(arg) for arg in cmd])) - return subprocess.check_call(cmd, env=env, stderr=subprocess.STDOUT, **kwargs) - -def check_output(cmd, verbose, env=os.environ, capture_stderr=True, **kwargs): - if verbose: - print(' '.join([escape_cmd_arg(arg) for arg in cmd])) - if capture_stderr: - stderr = subprocess.STDOUT - else: - stderr = subprocess.DEVNULL - return subprocess.check_output(cmd, env=env, stderr=stderr, encoding='utf-8', **kwargs) - -def escape_cmd_arg(arg): - if '"' in arg or ' ' in arg: - return '"%s"' % arg.replace('"', '\\"') - else: - return arg - -if __name__ == '__main__': - main() + +# ----------------------------------------------------------------------------- +# Argument parsing + + +def add_common_args(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--package-path", default="") + parser.add_argument( + "-v", "--verbose", action="store_true", help="log executed commands" + ) + parser.add_argument("--configuration", default="debug") + parser.add_argument("--build-path", type=Path, default=None) + parser.add_argument( + "--multiroot-data-file", + type=Path, + help="Path to an Xcode workspace to create a unified build of SwiftSyntax with other projects.", + ) + parser.add_argument( + "--toolchain", + required=True, + type=Path, + help="the toolchain to use when building this package", + ) + parser.add_argument( + "--cross-compile-host", help="cross-compile for another host instead" + ) + parser.add_argument( + "--cross-compile-config", + help="an SPM JSON destination file containing Swift cross-compilation flags", + ) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(prog="build-script-helper.py") + if sys.version_info >= (3, 7, 0): + subparsers = parser.add_subparsers(title="subcommands", dest="action", required=True, metavar="action") + else: + subparsers = parser.add_subparsers(title="subcommands", dest="action", metavar="action") + + build_parser = subparsers.add_parser("build", help="build the package") + add_common_args(build_parser) + + test_parser = subparsers.add_parser("test", help="test the package") + add_common_args(test_parser) + + install_parser = subparsers.add_parser("install", help="install the package") + add_common_args(install_parser) + install_parser.add_argument( + "--prefix", + dest="install_prefixes", + nargs="*", + type=Path, + metavar="PATHS", + help="install path", + ) + + parsed = parser.parse_args(sys.argv[1:]) + + parsed.swift_exec = parsed.toolchain / "bin" / "swift" + + # Convert package_path to absolute path, relative to root of repo. + repo_path = Path(__file__).parent + parsed.package_path = (repo_path / parsed.package_path).resolve() + + if not parsed.build_path: + parsed.build_path = parsed.package_path / ".build" + + return parsed + + +def main(): + args = parse_args() + + # The test action creates its own build. No need to build if we are just testing. + if args.action == "build": + build(args) + elif args.action == "test": + test(args) + elif args.action == "install": + install(args) + else: + fatal_error(f"unknown action '{args.action}'") + + +if __name__ == "__main__": + main() diff --git a/cmake/modules/CMakeLists.txt b/cmake/modules/CMakeLists.txt new file mode 100644 index 000000000..195e3625c --- /dev/null +++ b/cmake/modules/CMakeLists.txt @@ -0,0 +1,20 @@ +#[[ +This source file is part of the swift-format open source project + +Copyright (c) 2024 Apple Inc. and the swift-format project authors +Licensed under Apache License v2.0 with Runtime Library Exception + +See https://swift.org/LICENSE.txt for license information +#]] + +set(SWIFT_FORMAT_EXPORTS_FILE + ${CMAKE_CURRENT_BINARY_DIR}/SwiftFormatExports.cmake) + +configure_file(SwiftFormatConfig.cmake.in + ${CMAKE_CURRENT_BINARY_DIR}/SwiftFormatConfig.cmake) + +get_property(SWIFT_FORMAT_EXPORTS GLOBAL PROPERTY SWIFT_FORMAT_EXPORTS) +export(TARGETS ${SWIFT_FORMAT_EXPORTS} + NAMESPACE SwiftFormat:: + FILE ${SWIFT_FORMAT_EXPORTS_FILE} + EXPORT_LINK_INTERFACE_LIBRARIES) diff --git a/cmake/modules/SwiftFormatConfig.cmake.in b/cmake/modules/SwiftFormatConfig.cmake.in new file mode 100644 index 000000000..4c165f9cf --- /dev/null +++ b/cmake/modules/SwiftFormatConfig.cmake.in @@ -0,0 +1,12 @@ +#[[ +This source file is part of the swift-format open source project + +Copyright (c) 2024 Apple Inc. and the swift-format project authors +Licensed under Apache License v2.0 with Runtime Library Exception + +See https://swift.org/LICENSE.txt for license information +#]] + +if(NOT TARGET SwiftFormat) + include("@SWIFT_FORMAT_EXPORTS_FILE@") +endif() diff --git a/cmake/modules/SwiftSupport.cmake b/cmake/modules/SwiftSupport.cmake new file mode 100644 index 000000000..c37055a33 --- /dev/null +++ b/cmake/modules/SwiftSupport.cmake @@ -0,0 +1,111 @@ +#[[ +This source file is part of the swift-format open source project + +Copyright (c) 2024 Apple Inc. and the swift-format project authors +Licensed under Apache License v2.0 with Runtime Library Exception + +See https://swift.org/LICENSE.txt for license information +#]] + +# Returns the current architecture name in a variable +# +# Usage: +# get_swift_host_arch(result_var_name) +# +# If the current architecture is supported by Swift, sets ${result_var_name} +# with the sanitized host architecture name derived from CMAKE_SYSTEM_PROCESSOR. +function(get_swift_host_arch result_var_name) + if("${CMAKE_SYSTEM_PROCESSOR}" STREQUAL "x86_64") + set("${result_var_name}" "x86_64" PARENT_SCOPE) + elseif ("${CMAKE_SYSTEM_PROCESSOR}" MATCHES "AArch64|aarch64|arm64|ARM64") + if(CMAKE_SYSTEM_NAME MATCHES Darwin) + set("${result_var_name}" "arm64" PARENT_SCOPE) + else() + set("${result_var_name}" "aarch64" PARENT_SCOPE) + endif() + elseif("${CMAKE_SYSTEM_PROCESSOR}" STREQUAL "ppc64") + set("${result_var_name}" "powerpc64" PARENT_SCOPE) + elseif("${CMAKE_SYSTEM_PROCESSOR}" STREQUAL "ppc64le") + set("${result_var_name}" "powerpc64le" PARENT_SCOPE) + elseif("${CMAKE_SYSTEM_PROCESSOR}" STREQUAL "s390x") + set("${result_var_name}" "s390x" PARENT_SCOPE) + elseif("${CMAKE_SYSTEM_PROCESSOR}" STREQUAL "armv6l") + set("${result_var_name}" "armv6" PARENT_SCOPE) + elseif("${CMAKE_SYSTEM_PROCESSOR}" STREQUAL "armv7l") + set("${result_var_name}" "armv7" PARENT_SCOPE) + elseif("${CMAKE_SYSTEM_PROCESSOR}" STREQUAL "armv7-a") + set("${result_var_name}" "armv7" PARENT_SCOPE) + elseif("${CMAKE_SYSTEM_PROCESSOR}" STREQUAL "amd64") + set("${result_var_name}" "amd64" PARENT_SCOPE) + elseif("${CMAKE_SYSTEM_PROCESSOR}" STREQUAL "AMD64") + set("${result_var_name}" "x86_64" PARENT_SCOPE) + elseif("${CMAKE_SYSTEM_PROCESSOR}" STREQUAL "IA64") + set("${result_var_name}" "itanium" PARENT_SCOPE) + elseif("${CMAKE_SYSTEM_PROCESSOR}" STREQUAL "x86") + set("${result_var_name}" "i686" PARENT_SCOPE) + elseif("${CMAKE_SYSTEM_PROCESSOR}" STREQUAL "i686") + set("${result_var_name}" "i686" PARENT_SCOPE) + elseif("${CMAKE_SYSTEM_PROCESSOR}" STREQUAL "wasm32") + set("${result_var_name}" "wasm32" PARENT_SCOPE) + elseif("${CMAKE_SYSTEM_PROCESSOR}" STREQUAL "riscv64") + set("${result_var_name}" "riscv64" PARENT_SCOPE) + else() + message(FATAL_ERROR "Unrecognized architecture on host system: ${CMAKE_SYSTEM_PROCESSOR}") + endif() +endfunction() + +# Returns the os name in a variable +# +# Usage: +# get_swift_host_os(result_var_name) +# +# +# Sets ${result_var_name} with the converted OS name derived from +# CMAKE_SYSTEM_NAME. +function(get_swift_host_os result_var_name) + if(CMAKE_SYSTEM_NAME STREQUAL Darwin) + set(${result_var_name} macosx PARENT_SCOPE) + else() + string(TOLOWER ${CMAKE_SYSTEM_NAME} cmake_system_name_lc) + set(${result_var_name} ${cmake_system_name_lc} PARENT_SCOPE) + endif() +endfunction() + +function(_install_target module) + get_swift_host_os(swift_os) + get_target_property(type ${module} TYPE) + + if(type STREQUAL STATIC_LIBRARY) + set(swift swift_static) + else() + set(swift swift) + endif() + + install(TARGETS ${module} + ARCHIVE DESTINATION lib/${swift}/${swift_os} + LIBRARY DESTINATION lib/${swift}/${swift_os} + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) + if(type STREQUAL EXECUTABLE) + return() + endif() + + get_swift_host_arch(swift_arch) + get_target_property(module_name ${module} Swift_MODULE_NAME) + if(NOT module_name) + set(module_name ${module}) + endif() + + if(CMAKE_SYSTEM_NAME STREQUAL Darwin) + install(FILES $/${module_name}.swiftdoc + DESTINATION lib/${swift}/${swift_os}/${module_name}.swiftmodule + RENAME ${swift_arch}.swiftdoc) + install(FILES $/${module_name}.swiftmodule + DESTINATION lib/${swift}/${swift_os}/${module_name}.swiftmodule + RENAME ${swift_arch}.swiftmodule) + else() + install(FILES + $/${module_name}.swiftdoc + $/${module_name}.swiftmodule + DESTINATION lib/${swift}/${swift_os}/${swift_arch}) + endif() +endfunction()