├── .config └── nextest.toml ├── .dockerignore ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml └── workflows │ ├── CD.yml │ ├── CI.yml │ ├── coverage.yml │ └── post-deploy.yml ├── .gitignore ├── .gitmodules ├── .pre-commit-config.yaml ├── CODEOWNERS ├── Cargo.lock ├── Cargo.toml ├── Cross.toml ├── Dockerfile ├── LICENSE ├── README.md ├── assets ├── rust_example.png ├── rust_example_logs.png └── sample_config.json5 ├── bin ├── git-diffsitter └── update-grammars.sh ├── build.rs ├── clippy.toml ├── deployment ├── brew_packager.py ├── git-archive-all.bash └── macos │ └── homebrew │ └── homebrew_formula.rb.template ├── dev_scripts ├── build_with_sanitizer.bash └── update_dependencies.bash ├── docs ├── CODE_OF_CONDUCT.md └── CONTRIBUTING.md ├── resources └── test_configs │ ├── empty_dict.json5 │ ├── partial_section_1.json │ └── partial_section_2.json ├── rustfmt.toml ├── src ├── bin │ ├── diffsitter.rs │ ├── diffsitter_completions.rs │ └── diffsitter_utils.rs ├── cli.rs ├── config.rs ├── console_utils.rs ├── diff.rs ├── input_processing.rs ├── lib.rs ├── neg_idx_vec.rs ├── parse.rs └── render │ ├── json.rs │ ├── mod.rs │ └── unified.rs ├── test_data ├── medium │ ├── cpp │ │ ├── a.cpp │ │ └── b.cpp │ └── rust │ │ ├── a.rs │ │ └── b.rs └── short │ ├── go │ ├── a.go │ └── b.go │ ├── markdown │ ├── a.md │ └── b.md │ ├── python │ ├── a.py │ └── b.py │ └── rust │ ├── a.rs │ └── b.rs └── tests ├── regression_test.rs └── snapshots ├── regression_test__tests__medium_cpp_false.snap ├── regression_test__tests__medium_cpp_split_graphames_false_strip_whitespace_true.snap ├── regression_test__tests__medium_cpp_split_graphames_true_strip_whitespace_true.snap ├── regression_test__tests__medium_cpp_split_graphemes_false_strip_whitespace_true.snap ├── regression_test__tests__medium_cpp_split_graphemes_true_strip_whitespace_true.snap ├── regression_test__tests__medium_cpp_true.snap ├── regression_test__tests__medium_rust_false.snap ├── regression_test__tests__medium_rust_split_graphames_false_strip_whitespace_false.snap ├── regression_test__tests__medium_rust_split_graphames_true_strip_whitespace_false.snap ├── regression_test__tests__medium_rust_split_graphemes_false_strip_whitespace_false.snap ├── regression_test__tests__medium_rust_split_graphemes_true_strip_whitespace_false.snap ├── regression_test__tests__medium_rust_true.snap ├── regression_test__tests__short_go_split_graphames_true_strip_whitespace_true.snap ├── regression_test__tests__short_go_split_graphemes_true_strip_whitespace_true.snap ├── regression_test__tests__short_go_true.snap ├── regression_test__tests__short_markdown_false.snap ├── regression_test__tests__short_markdown_split_graphemes_false_strip_whitespace_true.snap ├── regression_test__tests__short_markdown_split_graphemes_true_strip_whitespace_true.snap ├── regression_test__tests__short_python_split_graphames_true_strip_whitespace_true.snap ├── regression_test__tests__short_python_split_graphemes_true_strip_whitespace_true.snap ├── regression_test__tests__short_python_true.snap ├── regression_test__tests__short_rust_split_graphames_true_strip_whitespace_true.snap ├── regression_test__tests__short_rust_split_graphemes_true_strip_whitespace_true.snap └── regression_test__tests__short_rust_true.snap /.config/nextest.toml: -------------------------------------------------------------------------------- 1 | [profile.ci.junit] 2 | path = "junit.xml" 3 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | target/ 2 | **/*.md 3 | assets/ 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Log output/screenshots** 24 | Please provide the output with the `--debug` flag 25 | 26 | **Platform:** 27 | OS: [e.g. Linux] 28 | 29 | **Additional context** 30 | Add any other context about the problem here. 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: cargo 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "13:00" 8 | open-pull-requests-limit: 10 9 | ignore: 10 | - dependency-name: anyhow 11 | versions: 12 | - 1.0.39 13 | - dependency-name: console 14 | versions: 15 | - 0.14.1 16 | - dependency-name: tree-sitter 17 | versions: 18 | - 0.19.0 19 | - 0.19.1 20 | - 0.19.2 21 | - dependency-name: serde_json 22 | versions: 23 | - 1.0.63 24 | - 1.0.64 25 | -------------------------------------------------------------------------------- /.github/workflows/CD.yml: -------------------------------------------------------------------------------- 1 | name: CD 2 | 3 | on: 4 | schedule: 5 | - cron: '5 5 * * *' 6 | workflow_dispatch: 7 | inputs: 8 | tag_name: 9 | description: 'Tag name for release' 10 | required: true 11 | default: nightly 12 | push: 13 | tags: 14 | - '*' 15 | 16 | env: 17 | CARGO_TERM_COLOR: always 18 | 19 | jobs: 20 | create_github_release: 21 | name: Create github release 22 | runs-on: ubuntu-latest 23 | outputs: 24 | version: ${{ steps.get_version.outputs.version }} 25 | steps: 26 | - name: Get release version from tag 27 | if: env.VERSION == '' && github.event_name != 'workflow_dispatch' && github.event_name != 'schedule' 28 | run: | 29 | if [[ -n "${{ github.event.inputs.tag_name }}" ]]; then 30 | echo "Manual run against a tag; overriding actual tag in the environment..." 31 | echo "VERSION=${{ github.event.inputs.tag_name }}" >> $GITHUB_ENV 32 | else 33 | echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_ENV" 34 | fi 35 | - name: Get release version from inputs 36 | if: env.VERSION == '' && (github.event_name == 'workflow_dispatch' || github.event_name == 'schedule') 37 | env: 38 | default_tag_name: nightly 39 | run: | 40 | echo "VERSION=${{ github.event.inputs.tag_name || env.default_tag_name }}" >> "$GITHUB_ENV" 41 | - name: Validate version environment variable 42 | run: | 43 | echo "Version being built against is version ${{ env.VERSION }}"! 44 | - name: Save version to output 45 | id: get_version 46 | run: | 47 | echo "version=${{ env.VERSION }}" >> "$GITHUB_OUTPUT" 48 | 49 | - uses: actions/checkout@v3 50 | with: 51 | submodules: true 52 | if: env.VERSION == 'nightly' 53 | 54 | # We need to update the nightly tag every night, otherwise the CD 55 | # pipeline will release the same revision every time 56 | - name: Nightly set up 57 | if: env.VERSION == 'nightly' 58 | env: 59 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 60 | run: | 61 | gh release delete nightly --yes --cleanup-tag || true 62 | git tag nightly 63 | git push origin nightly 64 | echo "PRELEASE=true" >> $GITHUB_ENV 65 | 66 | - if: env.VERSION != 'nightly' 67 | run: | 68 | echo "PRELEASE=false" >> $GITHUB_ENV 69 | 70 | - name: Create GitHub release (tag) 71 | if: github.event_name != 'workflow_dispatch' && github.event_name != 'schedule' 72 | id: release_tag 73 | uses: ncipollo/release-action@v1 74 | with: 75 | token: ${{ secrets.GITHUB_TOKEN }} 76 | draft: true 77 | name: ${{ env.VERSION }} 78 | prerelease: ${{ env.PRELEASE }} 79 | env: 80 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 81 | 82 | - name: Create GitHub release (schedule/workflow dispatch) 83 | if: github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' 84 | id: release_dispatch 85 | uses: ncipollo/release-action@v1 86 | with: 87 | token: ${{ secrets.GITHUB_TOKEN }} 88 | allowUpdates: true 89 | name: ${{ env.VERSION }} 90 | tag: ${{ env.VERSION }} 91 | prerelease: ${{ env.PRELEASE }} 92 | env: 93 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 94 | 95 | - name: Create artifacts directory 96 | run: mkdir artifacts 97 | 98 | - name: Save release upload URL to artifact (tag) 99 | if: github.event_name != 'workflow_dispatch' && github.event_name != 'schedule' 100 | run: echo "${{ steps.release_tag.outputs.upload_url }}" > artifacts/release-upload-url 101 | 102 | - name: Save release upload URL to artifact (workflow/schedule) 103 | if: github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' 104 | run: echo "${{ steps.release_dispatch.outputs.upload_url }}" > artifacts/release-upload-url 105 | 106 | - name: Save version number to artifact 107 | run: echo "${{ env.VERSION }}" > artifacts/release-version 108 | 109 | - name: Upload artifacts 110 | uses: actions/upload-artifact@v4 111 | with: 112 | name: artifacts 113 | path: artifacts 114 | 115 | publish: 116 | name: Publish for ${{ matrix.job.target }} 117 | runs-on: ${{ matrix.job.os }} 118 | needs: [create_github_release] 119 | strategy: 120 | matrix: 121 | job: 122 | - os: ubuntu-latest 123 | artifact_name: diffsitter 124 | target: x86_64-unknown-linux-gnu 125 | - os: ubuntu-latest 126 | artifact_name: diffsitter 127 | target: i686-unknown-linux-gnu 128 | - os: ubuntu-latest 129 | artifact_name: diffsitter 130 | target: arm-unknown-linux-gnueabi 131 | - os: ubuntu-latest 132 | artifact_name: diffsitter 133 | target: aarch64-unknown-linux-gnu 134 | - os: macOS-latest 135 | artifact_name: diffsitter 136 | target: x86_64-apple-darwin 137 | - os: macOS-latest 138 | artifact_name: diffsitter 139 | target: aarch64-apple-darwin 140 | - os: windows-latest 141 | artifact_name: diffsitter.exe 142 | target: x86_64-pc-windows-msvc 143 | - os: windows-latest 144 | artifact_name: diffsitter 145 | target: aarch64-pc-windows-msvc 146 | - os: ubuntu-latest 147 | artifact_name: diffsitter 148 | target: x86_64-unknown-freebsd 149 | - os: ubuntu-latest 150 | artifact_name: diffsitter 151 | target: powerpc64le-unknown-linux-gnu 152 | - os: ubuntu-latest 153 | artifact_name: diffsitter 154 | target: riscv64gc-unknown-linux-gnu 155 | steps: 156 | - uses: actions/checkout@v3 157 | with: 158 | submodules: true 159 | 160 | - uses: Swatinem/rust-cache@v2 161 | with: 162 | key: cd-${{steps.toolchain.outputs.name}} 163 | - if: ${{ matrix.job.os == 'ubuntu-latest' }} 164 | name: Install cross-compilation tools 165 | uses: taiki-e/setup-cross-toolchain-action@v1 166 | with: 167 | target: ${{ matrix.job.target }} 168 | - if: ${{ matrix.job.os != 'ubuntu-latest' }} 169 | name: Install native Rust toolchain 170 | uses: dtolnay/rust-toolchain@master 171 | with: 172 | targets: ${{ matrix.job.target }} 173 | components: rustfmt,clippy 174 | toolchain: stable 175 | - uses: taiki-e/install-action@nextest 176 | - name: Install packages (Windows) 177 | if: matrix.job.os == 'windows-latest' 178 | uses: crazy-max/ghaction-chocolatey@v1.4.0 179 | with: 180 | args: install -y zip 181 | 182 | - name: Install packages (macOS) 183 | if: matrix.job.os == 'macos-latest' 184 | shell: bash 185 | run: | 186 | brew install coreutils 187 | 188 | - uses: actions/setup-python@v5 189 | with: 190 | python-version: '3.9' 191 | 192 | - name: Get release download URL 193 | uses: actions/download-artifact@v4 194 | with: 195 | name: artifacts 196 | path: artifacts 197 | 198 | - name: Set release upload URL and release version 199 | shell: bash 200 | run: | 201 | release_upload_url="$(cat ./artifacts/release-upload-url)" 202 | echo "RELEASE_UPLOAD_URL=$release_upload_url" >> $GITHUB_ENV 203 | release_version="$(cat ./artifacts/release-version)" 204 | echo "RELEASE_VERSION=$release_version" >> $GITHUB_ENV 205 | 206 | # For nightly releases we need to manually specify the tag because the 207 | # action was triggered by a schedule not a tag. 208 | 209 | # TODO: separate out the logic so we have another action that creates and 210 | # pushes the tag so this file doesn't have to handle that logic. What we 211 | # really should do is simply have this job get triggered by tags and 212 | # have some way to specify whether it's a prerelease tag and handle it 213 | # from there. 214 | - name: Upload Rust binaries for nightly 215 | uses: taiki-e/upload-rust-binary-action@v1 216 | if: needs.create_github_release.outputs.version == 'nightly' 217 | with: 218 | bin: diffsitter 219 | archive: $bin-$target 220 | # (required) GitHub token for uploading assets to GitHub Releases. 221 | token: ${{ secrets.GITHUB_TOKEN }} 222 | tar: unix 223 | zip: windows 224 | checksum: sha256 225 | target: ${{ matrix.job.target }} 226 | ref: refs/tags/nightly 227 | include: bin/git-diffsitter 228 | profile: production 229 | 230 | - name: Upload Rust binaries for release 231 | uses: taiki-e/upload-rust-binary-action@v1 232 | if: needs.create_github_release.outputs.version != 'nightly' 233 | with: 234 | bin: diffsitter 235 | archive: $bin-$target 236 | # (required) GitHub token for uploading assets to GitHub Releases. 237 | token: ${{ secrets.GITHUB_TOKEN }} 238 | tar: unix 239 | zip: windows 240 | checksum: sha256 241 | target: ${{ matrix.job.target }} 242 | include: bin/git-diffsitter 243 | profile: production 244 | 245 | - name: Install cargo deb 246 | uses: taiki-e/install-action@v2 247 | if: matrix.job.target == 'x86_64-unknown-linux-gnu' 248 | with: 249 | tool: cargo-deb@1.43.0 250 | 251 | - name: Build Debian release (x86_64-unknown-linux-gnu) 252 | if: matrix.job.target == 'x86_64-unknown-linux-gnu' 253 | run: | 254 | cargo build --profile production --locked 255 | cargo deb --profile production --target ${{ matrix.job.target }} --no-build 256 | cp ./target/${{ matrix.job.target }}/debian/diffsitter_*.deb ./diffsitter_${{ env.RELEASE_VERSION }}_amd64.deb 257 | env: 258 | CARGO_TARGET_DIR: "./target" 259 | 260 | - name: Upload Debian file (x86_64-unknown-linux-gnu) 261 | if: matrix.job.target == 'x86_64-unknown-linux-gnu' 262 | uses: actions/upload-release-asset@v1.0.1 263 | env: 264 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 265 | with: 266 | upload_url: ${{ env.RELEASE_UPLOAD_URL }} 267 | asset_path: diffsitter_${{ env.RELEASE_VERSION }}_amd64.deb 268 | asset_name: diffsitter_${{ env.RELEASE_VERSION }}_amd64.deb 269 | asset_content_type: application/octet-stream 270 | 271 | - name: Generate tarball with submodules 272 | if: matrix.job.target == 'x86_64-unknown-linux-gnu' 273 | shell: bash 274 | run: | 275 | bash deployment/git-archive-all.bash --prefix "diffsitter_src/" --format tar.gz diffsitter_src.tar.gz 276 | 277 | - name: Upload tarball with submodules 278 | if: matrix.job.target == 'x86_64-unknown-linux-gnu' 279 | uses: actions/upload-release-asset@v1.0.1 280 | env: 281 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 282 | with: 283 | upload_url: ${{ env.RELEASE_UPLOAD_URL }} 284 | asset_path: diffsitter_src.tar.gz 285 | asset_name: diffsitter_src.tar.gz 286 | asset_content_type: application/octet-stream 287 | -------------------------------------------------------------------------------- /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | build: 14 | name: Build ${{ matrix.job.target }} 15 | runs-on: ${{ matrix.job.os }} 16 | strategy: 17 | matrix: 18 | job: 19 | - { os: macos-latest, target: x86_64-apple-darwin } 20 | - { 21 | os: ubuntu-latest, 22 | target: x86_64-unknown-linux-gnu, 23 | } 24 | - { 25 | os: ubuntu-latest, 26 | target: i686-unknown-linux-gnu, 27 | } 28 | - { 29 | os: ubuntu-latest, 30 | target: aarch64-unknown-linux-gnu, 31 | } 32 | - { 33 | os: windows-latest, 34 | target: x86_64-pc-windows-msvc, 35 | } 36 | steps: 37 | - uses: actions/checkout@v4 38 | with: 39 | submodules: true 40 | - if: ${{ matrix.job.os == 'ubuntu-latest' }} 41 | name: Install cross-compilation tools 42 | uses: taiki-e/setup-cross-toolchain-action@v1 43 | with: 44 | target: ${{ matrix.job.target }} 45 | - if: ${{ matrix.job.os != 'ubuntu-latest' }} 46 | name: Install native Rust toolchain 47 | uses: dtolnay/rust-toolchain@master 48 | with: 49 | targets: ${{ matrix.job.target }} 50 | components: rustfmt,clippy 51 | toolchain: stable 52 | - uses: Swatinem/rust-cache@v2 53 | with: 54 | key: ci-${{steps.toolchain.outputs.name}} 55 | - uses: taiki-e/install-action@v2 56 | with: 57 | tool: nextest 58 | - name: Check formatting 59 | run: cargo fmt --all -- --check 60 | - if: ${{ matrix.job.target == 'x86_64-unknown-linux-gnu' }} 61 | name: Doc tests 62 | run: cargo test --doc --features better-build-info,static-grammar-libs,dynamic-grammar-libs 63 | - name: Tests 64 | run: cargo nextest run --locked --target ${{ matrix.job.target }} --features better-build-info,static-grammar-libs,dynamic-grammar-libs --profile ci 65 | - name: Publish Test Report 66 | uses: mikepenz/action-junit-report@v3 67 | if: success() || failure() # always run even if the previous step fails 68 | with: 69 | report_paths: '**/target/nextest/ci/junit.xml' 70 | check_name: diffsitter tests ${{ matrix.job.target }} 71 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | # Taken from: https://github.com/nextest-rs/nextest/blob/c54694dfe7be016993983b5dedbcf2b50d4b1a6e/.github/workflows/coverage.yml 2 | on: 3 | push: 4 | branches: [ main ] 5 | pull_request: 6 | branches: 7 | - main 8 | 9 | name: Test coverage 10 | 11 | jobs: 12 | coverage: 13 | name: Collect test coverage 14 | runs-on: ubuntu-latest 15 | # nightly rust might break from time to time 16 | continue-on-error: true 17 | env: 18 | RUSTFLAGS: -D warnings 19 | CARGO_TERM_COLOR: always 20 | steps: 21 | - uses: actions/checkout@v3 22 | with: 23 | submodules: true 24 | - uses: dtolnay/rust-toolchain@stable 25 | with: 26 | components: llvm-tools-preview 27 | - uses: Swatinem/rust-cache@v2 28 | with: 29 | key: coverage 30 | - name: Install latest nextest release 31 | uses: taiki-e/install-action@nextest 32 | - name: Install cargo-llvm-cov 33 | uses: taiki-e/install-action@cargo-llvm-cov 34 | 35 | - name: Collect coverage data 36 | run: cargo llvm-cov nextest --lcov --output-path lcov.info 37 | - name: Upload coverage data to codecov 38 | uses: codecov/codecov-action@v3 39 | with: 40 | files: lcov.info 41 | -------------------------------------------------------------------------------- /.github/workflows/post-deploy.yml: -------------------------------------------------------------------------------- 1 | name: post-deploy script 2 | 3 | on: 4 | release: 5 | types: 6 | - published 7 | 8 | jobs: 9 | post-deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | 14 | - name: Set env 15 | run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV 16 | 17 | - name: Test env 18 | run: | 19 | echo $RELEASE_VERSION 20 | - name: Make sure you're not on master... 21 | run: | 22 | if [[ $RELEASE_VERSION == "master" ]]; then 23 | exit 1 24 | fi 25 | - name: Make sure you're not on nightly... 26 | run: | 27 | if [[ $RELEASE_VERSION == "nightly" ]]; then 28 | exit 1 29 | fi 30 | 31 | - name: Trigger homebrew 32 | run: | 33 | curl -X POST https://api.github.com/repos/afnanenayet/homebrew-tap/dispatches \ 34 | -H 'Accept: application/vnd.github.v3+json' \ 35 | -u ${{ secrets.DIFFSITTER_BREW_PACKAGE_DEPLOYMENT }} \ 36 | --data '{ "event_type": "update-diffsitter", "client_payload": { "version": "'"$RELEASE_VERSION"'" } }' 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .vscode/ 3 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "grammars/tree-sitter-php"] 2 | path = grammars/tree-sitter-php 3 | url = https://github.com/afnanenayet/tree-sitter-php.git 4 | [submodule "grammars/tree-sitter-rust"] 5 | path = grammars/tree-sitter-rust 6 | url = https://github.com/afnanenayet/tree-sitter-rust.git 7 | [submodule "grammars/tree-sitter-haskell"] 8 | path = grammars/tree-sitter-haskell 9 | url = https://github.com/tree-sitter/tree-sitter-haskell.git 10 | [submodule "tree-sitter-c"] 11 | path = tree-sitter-c 12 | url = https://github.com/tree-sitter/tree-sitter-c.git 13 | [submodule "grammars/tree-sitter-c"] 14 | path = grammars/tree-sitter-c 15 | url = https://github.com/afnanenayet/tree-sitter-c.git 16 | [submodule "grammars/tree-sitter-bash"] 17 | path = grammars/tree-sitter-bash 18 | url = https://github.com/afnanenayet/tree-sitter-bash.git 19 | [submodule "grammars/tree-sitter-css"] 20 | path = grammars/tree-sitter-css 21 | url = https://github.com/afnanenayet/tree-sitter-css.git 22 | [submodule "grammars/tree-sitter-html"] 23 | path = grammars/tree-sitter-html 24 | url = https://github.com/tree-sitter/tree-sitter-html.git 25 | [submodule "grammars/tree-sitter-ocaml"] 26 | path = grammars/tree-sitter-ocaml 27 | url = https://github.com/afnanenayet/tree-sitter-ocaml.git 28 | [submodule "grammars/tree-sitter-typescript"] 29 | path = grammars/tree-sitter-typescript 30 | url = https://github.com/afnanenayet/tree-sitter-typescript.git 31 | [submodule "grammars/tree-sitter-verilog"] 32 | path = grammars/tree-sitter-verilog 33 | url = https://github.com/tree-sitter/tree-sitter-verilog.git 34 | [submodule "grammars/tree-sitter-julia"] 35 | path = grammars/tree-sitter-julia 36 | url = https://github.com/tree-sitter/tree-sitter-julia.git 37 | [submodule "grammars/tree-sitter-agda"] 38 | path = grammars/tree-sitter-agda 39 | url = https://github.com/tree-sitter/tree-sitter-agda.git 40 | [submodule "grammars/tree-sitter-scala"] 41 | path = grammars/tree-sitter-scala 42 | url = https://github.com/tree-sitter/tree-sitter-scala.git 43 | [submodule "grammars/tree-sitter-swift"] 44 | path = grammars/tree-sitter-swift 45 | url = https://github.com/tree-sitter/tree-sitter-swift.git 46 | [submodule "grammars/tree-sitter-cpp"] 47 | path = grammars/tree-sitter-cpp 48 | url = https://github.com/afnanenayet/tree-sitter-cpp.git 49 | [submodule "grammars/tree-sitter-go"] 50 | path = grammars/tree-sitter-go 51 | url = https://github.com/afnanenayet/tree-sitter-go.git 52 | [submodule "grammars/tree-sitter-c-sharp"] 53 | path = grammars/tree-sitter-c-sharp 54 | url = https://github.com/afnanenayet/tree-sitter-c-sharp.git 55 | [submodule "grammars/tree-sitter-java"] 56 | path = grammars/tree-sitter-java 57 | url = https://github.com/afnanenayet/tree-sitter-java.git 58 | [submodule "grammars/tree-sitter-python"] 59 | path = grammars/tree-sitter-python 60 | url = https://github.com/afnanenayet/tree-sitter-python.git 61 | [submodule "grammars/tree-sitter-ruby"] 62 | path = grammars/tree-sitter-ruby 63 | url = https://github.com/afnanenayet/tree-sitter-ruby.git 64 | [submodule "grammars/tree-sitter-hcl"] 65 | path = grammars/tree-sitter-hcl 66 | url = https://github.com/afnanenayet/tree-sitter-hcl 67 | [submodule "grammars/tree-sitter-json"] 68 | path = grammars/tree-sitter-json 69 | url = https://github.com/afnanenayet/tree-sitter-json 70 | [submodule "grammars/tree-sitter-markdown"] 71 | path = grammars/tree-sitter-markdown 72 | url = https://github.com/afnanenayet/tree-sitter-markdown.git 73 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.5.0 4 | hooks: 5 | - id: check-yaml 6 | exclude: ^grammars/.* 7 | - id: end-of-file-fixer 8 | exclude: ^grammars/.* 9 | - id: trailing-whitespace 10 | exclude: grammars/.* 11 | - repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks 12 | rev: v2.12.0 13 | hooks: 14 | - id: pretty-format-rust 15 | exclude: ^grammars/.*|^test_data/.* 16 | - repo: https://github.com/afnanenayet/pre-commit-hooks 17 | rev: v0.1.0 18 | hooks: 19 | - id: pretty-format-toml-taplo 20 | - id: check-toml-schema-taplo 21 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Global owners 2 | * @afnanenayet 3 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "diffsitter" 3 | description = "An AST based difftool for meaningful diffs" 4 | readme = "README.md" 5 | version = "0.9.0" 6 | authors = ["Afnan Enayet "] 7 | edition = "2021" 8 | license = "MIT" 9 | keywords = ["diff", "ast", "difftool"] 10 | categories = ["command-line-utilities"] 11 | build = "build.rs" 12 | homepage = "https://github.com/afnanenayet/diffsitter" 13 | repository = "https://github.com/afnanenayet/diffsitter" 14 | include = [ 15 | "src/**/*", 16 | "LICENSE", 17 | "README.md", 18 | "grammars/**/*.c", 19 | "grammars/**/*.cc", 20 | "grammars/**/*.cpp", 21 | "grammars/**/*.h", 22 | "grammars/**/*.hpp", 23 | "build.rs", 24 | "!**/*.png", 25 | "!**/test/**/*", 26 | "!**/*_test.*", 27 | "!**/examples/**/*", 28 | "!**/target/**/*", 29 | "!assets/*", 30 | ] 31 | default-run = "diffsitter" 32 | 33 | [[bin]] 34 | name = "diffsitter" 35 | path = "src/bin/diffsitter.rs" 36 | 37 | [[bin]] 38 | name = "diffsitter_completions" 39 | path = "src/bin/diffsitter_completions.rs" 40 | 41 | [[bin]] 42 | name = "diffsitter-utils" 43 | path = "src/bin/diffsitter_utils.rs" 44 | 45 | [lib] 46 | name = "libdiffsitter" 47 | path = "src/lib.rs" 48 | 49 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 50 | 51 | [dependencies] 52 | tree-sitter = "0.25.4" 53 | clap = { version = "4.5.38", features = [ 54 | "derive", 55 | "env", 56 | "unicode", 57 | "wrap_help", 58 | ] } 59 | clap_complete = "4.5.51" 60 | anyhow = { version = "1.0.98", features = ["backtrace"] } 61 | phf = { version = "0.11.3", features = ["macros"] } 62 | console = "0.15.11" 63 | strum = { version = "0.27.1", features = ["derive"] } 64 | strum_macros = "0.27.1" 65 | serde = { version = "1.0.219", features = ["derive"] } 66 | serde_json = "1.0.140" 67 | json5 = "0.4.1" 68 | pretty_env_logger = "0.5.0" 69 | log = { version = "0.4.27", features = ["std"] } 70 | thiserror = "2.0.12" 71 | logging_timer = "1.1.1" 72 | jemallocator = { version = "0.5.4", optional = true } 73 | libloading = "0.8.7" 74 | unicode-segmentation = "1.12.0" 75 | human-panic = "2.0.2" 76 | shadow-rs = { version = "1.1.1", optional = true } 77 | enum_dispatch = "0.3.13" 78 | lazy_static = { version = "1.5.0", optional = true } 79 | 80 | [dev-dependencies] 81 | test-case = "3.3.1" 82 | pretty_assertions = "1.4.1" 83 | mockall = "0.13.1" 84 | rstest = "0.25" 85 | 86 | # We need the backtrace feature to enable snapshot name generation in 87 | # single-threaded tests (tests using cargo cross run single-threaded due to 88 | # limitations with QEMU). 89 | insta = { version = "1.43.1" } 90 | 91 | [target.'cfg(target_os = "windows")'.dependencies] 92 | # We use directories next to get the windows config path 93 | directories-next = "2.0.0" 94 | 95 | [target.'cfg(not(target_os = "windows"))'.dependencies] 96 | # We use XDG for everything else 97 | xdg = "2.5.2" 98 | 99 | [build-dependencies] 100 | cc = { version = "1.2.23", features = ["parallel"] } 101 | phf = { version = "0.11.3", features = ["macros"] } 102 | anyhow = "1.0.98" 103 | cargo-emit = "0.2.1" 104 | rayon = "1.10.0" 105 | thiserror = "2.0.12" 106 | shadow-rs = { version = "1.1.1", optional = true } 107 | 108 | [features] 109 | default = ["static-grammar-libs"] 110 | 111 | # Enable full build info as a subcommand. This takes longer to build so it's 112 | # generally just enabled for releases. 113 | better-build-info = ["shadow-rs"] 114 | 115 | # Enable dynamically loading libraries instead of compiling the libraries as 116 | # submodules. 117 | dynamic-grammar-libs = [] 118 | 119 | # Compile the static tree-sitter grammars from the submodules in this repo. 120 | static-grammar-libs = ["lazy_static"] 121 | 122 | [profile.profiling] 123 | inherits = "release" 124 | # Debug symbols are required for profiling 125 | strip = false 126 | # Want to keep debug info 127 | debug = true 128 | 129 | [profile.production] 130 | inherits = "release" 131 | lto = true 132 | codegen-units = 1 133 | # Makes the binary a little smaller 134 | strip = true 135 | -------------------------------------------------------------------------------- /Cross.toml: -------------------------------------------------------------------------------- 1 | [build.env] 2 | volumes = ["BUILD_DIR"] 3 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM rust:1.67 as builder 2 | WORKDIR /usr/src/diffsitter 3 | COPY . . 4 | RUN cargo install --path . 5 | 6 | FROM debian:bullseye-slim 7 | # RUN apt-get update && apt-get install -y extra-runtime-dependencies && rm -rf /var/lib/apt/lists/* 8 | COPY --from=builder /usr/local/cargo/bin/diffsitter /usr/local/bin/diffsitter 9 | CMD ["diffsitter"] 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2022 Afnan Enayet 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # diffsitter 2 | 3 | [![CI](https://github.com/afnanenayet/diffsitter/actions/workflows/CI.yml/badge.svg)](https://github.com/afnanenayet/diffsitter/actions/workflows/CI.yml) 4 | [![CD](https://github.com/afnanenayet/diffsitter/actions/workflows/CD.yml/badge.svg)](https://github.com/afnanenayet/diffsitter/actions/workflows/CD.yml) 5 | [![codecov](https://codecov.io/gh/afnanenayet/diffsitter/branch/main/graph/badge.svg?token=GBTJGXEXOS)](https://codecov.io/gh/afnanenayet/diffsitter) 6 | [![crates version](https://img.shields.io/crates/v/diffsitter)](https://crates.io/crates/diffsitter) 7 | [![GitHub release (latest by date)](https://img.shields.io/github/v/release/afnanenayet/diffsitter)](https://github.com/afnanenayet/diffsitter/releases/latest) 8 | ![downloads](https://img.shields.io/crates/d/diffsitter) 9 | [![license](https://img.shields.io/github/license/afnanenayet/diffsitter)](./LICENSE) 10 | 11 | [![asciicast](https://asciinema.org/a/joEIfP8XoxUhZKXEqUD8CEP7j.svg)](https://asciinema.org/a/joEIfP8XoxUhZKXEqUD8CEP7j) 12 | 13 | ## Disclaimer 14 | 15 | `diffsitter` is very much a work in progress and nowhere close to production 16 | ready (yet). Contributions are always welcome! 17 | 18 | ## Summary 19 | 20 | `diffsitter` creates semantically meaningful diffs that ignore formatting 21 | differences like spacing. It does so by computing a diff on the AST (abstract 22 | syntax tree) of a file rather than computing the diff on the text contents of 23 | the file. 24 | 25 | `diffsitter` uses the parsers from the 26 | [tree-sitter](https://tree-sitter.github.io/tree-sitter) project to parse 27 | source code. As such, the languages supported by this tool are restricted to the 28 | languages supported by tree-sitter. 29 | 30 | `diffsitter` supports the following languages: 31 | 32 | * Bash 33 | * C# 34 | * C++ 35 | * CSS 36 | * Go 37 | * Java 38 | * OCaml 39 | * PHP 40 | * Python 41 | * Ruby 42 | * Rust 43 | * Typescript/TSX 44 | * HCL 45 | 46 | ## Examples 47 | 48 | Take the following files: 49 | 50 | [`a.rs`](test_data/short/rust/a.rs) 51 | 52 | ```rust 53 | fn main() { 54 | let x = 1; 55 | } 56 | 57 | fn add_one { 58 | } 59 | ``` 60 | 61 | [`b.rs`](test_data/short/rust/b.rs) 62 | 63 | ```rust 64 | fn 65 | 66 | 67 | 68 | main 69 | 70 | () 71 | 72 | { 73 | } 74 | 75 | fn addition() { 76 | } 77 | 78 | fn add_two() { 79 | } 80 | ``` 81 | 82 | The standard output from `diff` will get you: 83 | 84 | ```text 85 | 1,2c1,12 86 | < fn main() { 87 | < let x = 1; 88 | --- 89 | > fn 90 | > 91 | > 92 | > 93 | > main 94 | > 95 | > () 96 | > 97 | > { 98 | > } 99 | > 100 | > fn addition() { 101 | 5c15 102 | < fn add_one { 103 | --- 104 | > fn add_two() { 105 | ``` 106 | 107 | You can see that it picks up the formatting differences for the `main` 108 | function, even though they aren't semantically different. 109 | 110 | Check out the output from `diffsitter`: 111 | 112 | ``` 113 | test_data/short/rust/a.rs -> test_data/short/rust/b.rs 114 | ====================================================== 115 | 116 | 9: 117 | -- 118 | + } 119 | 120 | 11: 121 | --- 122 | + fn addition() { 123 | 124 | 1: 125 | -- 126 | - let x = 1; 127 | 128 | 14: 129 | --- 130 | + fn add_two() { 131 | 132 | 4: 133 | -- 134 | - fn add_one { 135 | ``` 136 | 137 | *Note: the numbers correspond to line numbers from the original files.* 138 | 139 | You can also filter which tree sitter nodes are considered in the diff through 140 | the config file. 141 | 142 | Since it uses the AST to calculate the difference, it knows that the formatting 143 | differences in `main` between the two files isn't a meaningful difference, so 144 | it doesn't show up in the diff. 145 | 146 | `diffsitter` has some nice (terminal aware) formatting too: 147 | 148 | ![screenshot of rust diff](assets/rust_example.png) 149 | 150 | It also has extensive logging if you want to debug or see timing information: 151 | 152 | ![screenshot of rust diff with logs](assets/rust_example_logs.png) 153 | 154 | ### Node filtering 155 | 156 | You can filter the nodes that are considered in the diff by setting 157 | `include_nodes` or `exclude_nodes` in the config file. `exclude_nodes` always 158 | takes precedence over `include_nodes`, and the type of a node is the `kind` 159 | of a tree-sitter node. The `kind` directly corresponds to whatever is reported 160 | by the tree-sitter API, so this example may occasionally go out of date. 161 | 162 | This feature currently only applies to leaf nodes, but we could exclude nodes 163 | recursively if there's demand for it. 164 | 165 | ```json5 166 | "input-processing": { 167 | // You can exclude different tree sitter node types - this rule takes precedence over `include_kinds`. 168 | "exclude_kinds": ["string_content"], 169 | // You can specifically allow only certain tree sitter node types 170 | "include_kinds": ["method_definition"], 171 | } 172 | ``` 173 | 174 | ## Installation 175 | 176 | 177 | Packaging status 178 | 179 | 180 | ### Published binaries 181 | 182 | This project uses Github actions to build and publish binaries for each tagged 183 | release. You can download binaries from there if your platform is listed. We 184 | publish [nightly releases](https://github.com/afnanenayet/diffsitter/releases/tag/nightly) 185 | as well as tagged [stable releases](https://github.com/afnanenayet/diffsitter/releases/latest). 186 | 187 | ### Cargo 188 | 189 | You can build from source with `cargo` using the following command: 190 | 191 | ```sh 192 | cargo install diffsitter --bin diffsitter 193 | ``` 194 | 195 | If you want to generate completion files and other assets you can install the 196 | `diffsitter_completions` binary with the following command: 197 | 198 | ```sh 199 | cargo install diffsitter --bin diffsitter_completions 200 | ``` 201 | 202 | ### Homebrew 203 | 204 | You can use my tap to install diffsitter: 205 | 206 | ```sh 207 | brew tap afnanenayet/tap 208 | brew install diffsitter 209 | # brew install afnanenayet/tap/diffsitter 210 | ``` 211 | 212 | ### Arch Linux (AUR) 213 | 214 | @samhh has packaged diffsitter for arch on the AUR. Use your favorite AUR 215 | helper to install [`diffsitter-bin`](https://aur.archlinux.org/packages/diffsitter-bin/). 216 | 217 | ### Alpine Linux 218 | 219 | Install package [diffsitter](https://pkgs.alpinelinux.org/packages?name=diffsitter) from the Alpine Linux repositories (on v3.16+ or Edge): 220 | 221 | ```sh 222 | apk add diffsitter 223 | ``` 224 | 225 | Tree-sitter grammars are packaged separately (search for [tree-sitter-\*](https://pkgs.alpinelinux.org/packages?name=tree-sitter-*&arch=x86_64)). 226 | You can install individual packages you need or the virtual package `tree-sitter-grammars` to install all of them. 227 | 228 | ### Building with Docker 229 | 230 | We also provide a Docker image that builds diffsitter using the standard Rust 231 | base image. It separates the compilation stage from the run stage, so you can 232 | build it and run with the following command (assuming you have Docker installed 233 | on your system): 234 | 235 | ```sh 236 | docker build -t diffsitter . 237 | docker run -it --rm --name diffsitter-interactive diffsitter 238 | ``` 239 | 240 | ## Usage 241 | 242 | For detailed help you can run `diffsitter --help` (`diffsitter -h` provides 243 | brief help messages). 244 | 245 | You can configure file associations and formatting options for `diffsitter` 246 | using a config file. If a config is not supplied, the app will use the default 247 | config, which you can see with `diffsitter dump-default-config`. It will 248 | look for a config at `${XDG_HOME:-$HOME}/.config/diffsitter/config.json5` on 249 | macOS and Linux, and the standard directory for Windows. You can also refer to 250 | the [sample config](/assets/sample_config.json5). 251 | 252 | You can override the default config path by using the `--config` flag or set 253 | the `DIFFSITTER_CONFIG` environment variable. 254 | 255 | *Note: the tests for this crate check to make sure the provided sample config 256 | is a valid config.* 257 | 258 | ### Git integration 259 | 260 | To see the changes to the current git repo in diffsitter, you can add 261 | the following to your repo's `.git/config` and run `git difftool`. 262 | 263 | ``` 264 | [diff] 265 | tool = diffsitter 266 | 267 | [difftool] 268 | prompt = false 269 | 270 | [difftool "diffsitter"] 271 | cmd = diffsitter "$LOCAL" "$REMOTE" 272 | ``` 273 | 274 | ### Shell Completion 275 | 276 | You can generate shell completion scripts using the binary using the 277 | `gen-completion` subcommand. This will print the shell completion script for a 278 | given shell to `STDOUT`. 279 | 280 | You should use the help text for the most up to date usage information, but 281 | general usage would look like this: 282 | 283 | ```sh 284 | diffsitter gen-completion bash > completion.bash 285 | ``` 286 | 287 | We currently support the following shells (via `clap_complete`): 288 | 289 | * Bash 290 | * Zsh 291 | * Fish 292 | * Elvish 293 | * Powershell 294 | 295 | ## Dependencies 296 | 297 | `diffsitter` is usually compiled as a static binary, so the `tree-sitter` 298 | grammars/libraries are baked into the binary as static libraries. There is an 299 | option to build with support for dynamic libraries which will look for shared 300 | library files in the user's default library path. This will search for 301 | library files of the form `libtree-sitter-{lang}.{ext}`, where `lang` is the 302 | language that the user is trying to diff and `ext` is the platform-specific 303 | extension for shared library files (`.so`, `.dylib`, etc). The user can 304 | override the dynamic library file for each language in the config as such: 305 | 306 | ```json5 307 | { 308 | "grammar": { 309 | // You can specify the dynamic library names for each language 310 | "dylib-overrides": { 311 | // with a filename 312 | "rust": "libtree-sitter-rust.so", 313 | // with an absolute path 314 | "c": "/usr/lib/libtree-sitter-c.so", 315 | // with a relative path 316 | "cpp": "../libtree-sitter-c.so", 317 | }, 318 | } 319 | } 320 | ``` 321 | 322 | *The above excerpt was taken from the 323 | [sample config](/assets/sample_config.json5).* 324 | 325 | ## Questions, Bugs, and Support 326 | 327 | If you notice any bugs, have any issues, want to see a new feature, or just 328 | have a question, feel free to open an 329 | [issue](https://github.com/afnanenayet/diffsitter/issues) or create a 330 | [discussion post](https://github.com/afnanenayet/diffsitter/discussions). 331 | 332 | If you file an issue, it would be preferable that you include a minimal example 333 | and/or post the log output of `diffsitter` (which you can do by adding the 334 | `-d/--debug` flag). 335 | 336 | ## Contributing 337 | 338 | See [CONTRIBUTING.md](docs/CONTRIBUTING.md). 339 | 340 | ## Similar Projects 341 | 342 | * [difftastic](https://github.com/Wilfred/difftastic) 343 | * [locust](https://github.com/bugout-dev/locust) 344 | * [gumtree](https://github.com/GumTreeDiff/gumtree) 345 | * [diffr](https://github.com/mookid/diffr) 346 | * [delta](https://github.com/dandavison/delta) 347 | * [Semantic Diff Tool](https://www.sdt.dev) 348 | -------------------------------------------------------------------------------- /assets/rust_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/afnanenayet/diffsitter/56716e4b67f05ba1c1a78e89e221564eaa640eed/assets/rust_example.png -------------------------------------------------------------------------------- /assets/rust_example_logs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/afnanenayet/diffsitter/56716e4b67f05ba1c1a78e89e221564eaa640eed/assets/rust_example_logs.png -------------------------------------------------------------------------------- /assets/sample_config.json5: -------------------------------------------------------------------------------- 1 | // This is a JSON5 file, so you can use comments and trailing commas, which 2 | // makes it a lot more convenient for configs. 3 | // diffsitter looks for config files in 4 | // ${XDG_HOME:-$HOME}/.config/diffsitter/config.json5 by default. You can 5 | // override this behavior by passing the `--config` flag or setting the 6 | // "DIFFSITTER_CONFIG" environment variable. 7 | { 8 | // Set options for terminal formatting here 9 | "formatting": { 10 | "unified": { 11 | // Set the style for diff hunks from the new document 12 | "addition": { 13 | // The color of the highlight around emphasized added text 14 | "highlight": null, 15 | // The foreground color for regular text 16 | "regular-foreground": "green", 17 | // The foreground color for emphasized text 18 | // Note that colors can either be a string or a 256 bit color value 19 | "emphasized-foreground": { 20 | "color256": 0, 21 | }, 22 | // Whether emphasized text should be bolded 23 | "bold": true, 24 | // Whether emphasized text should be underlined 25 | "underline": false, 26 | // The prefix string prepend to the contents of the diff hunk, for 27 | // an addition hunk 28 | "prefix": "+", 29 | }, 30 | // Set the style for diff hunks from the old document 31 | // These are the same as the options for "addition", the only 32 | // difference is that they apply to the deletion hunks 33 | "deletion": { 34 | "regular-foreground": "red", 35 | "emphasized-foreground": "red", 36 | "bold": true, 37 | "underline": false, 38 | "prefix": "-", 39 | }, 40 | }, 41 | // We can also define custom render modes which are defined as a 42 | // key-value mapping of tags to rendering configs. 43 | "custom": { 44 | "custom_render_mode": { 45 | "type": "unified", 46 | // Set the style for diff hunks from the new document 47 | "addition": { 48 | // The color of the highlight around emphasized added text 49 | "highlight": null, 50 | // The foreground color for regular text 51 | "regular-foreground": "green", 52 | // The foreground color for emphasized text 53 | // Note that colors can either be a string or a 256 bit color value 54 | "emphasized-foreground": { 55 | "color256": 0, 56 | }, 57 | // Whether emphasized text should be bolded 58 | "bold": true, 59 | // Whether emphasized text should be underlined 60 | "underline": false, 61 | // The prefix string prepend to the contents of the diff hunk, for 62 | // an addition hunk 63 | "prefix": "+", 64 | }, 65 | // Set the style for diff hunks from the old document 66 | // These are the same as the options for "addition", the only 67 | // difference is that they apply to the deletion hunks 68 | "deletion": { 69 | "regular-foreground": "red", 70 | "emphasized-foreground": "red", 71 | "bold": true, 72 | "underline": false, 73 | "prefix": "-", 74 | }, 75 | }, 76 | }, 77 | }, 78 | // Set options related to grammars here 79 | "grammar": { 80 | // You can set different file associations here, these will be merged with 81 | // the default associations, where the associations in the config take 82 | // precedence 83 | "file-associations": { 84 | "rs": "rust", 85 | }, 86 | // You can specify the dynamic library names for each language 87 | "dylib-overrides": { 88 | // with a filename 89 | "rust": "libtree-sitter-rust.so", 90 | // with an absolute path 91 | "c": "/usr/lib/libtree-sitter-c.so", 92 | // with a relative path 93 | "cpp": "../libtree-sitter-cpp.so", 94 | }, 95 | }, 96 | // Specify a fallback command if diffsitter can't parse the given input 97 | // files. This is invoked by diffsitter as: 98 | // 99 | // ```sh 100 | // ${fallback_cmd} ${old} ${new} 101 | // ``` 102 | "fallback-cmd": "diff", 103 | "input-processing": { 104 | "split-graphemes": true, 105 | // You can exclude different tree sitter node types - this rule takes precedence over `include_kinds`. 106 | "exclude-kinds": ["string"], 107 | // You can specifically allow only certain tree sitter node types 108 | "include-kinds": ["method_definition"], 109 | "strip-whitespace": true, 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /bin/git-diffsitter: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # Diff is called by git with 7 parameters, but we only need the two files 4 | # args: path old-file old-hex old-mode new-file new-hex new-mode 5 | # ref: https://stackoverflow.com/questions/255202/how-do-i-view-git-diff-output-with-my-preferred-diff-tool-viewer 6 | diffsitter --color on "$2" "$5" 7 | -------------------------------------------------------------------------------- /bin/update-grammars.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | # Update all submodules to latest HEAD 3 | set -ex 4 | 5 | git submodule foreach "git pull" 6 | git add . 7 | git commit -m "chore(grammars): Update grammars" 8 | -------------------------------------------------------------------------------- /clippy.toml: -------------------------------------------------------------------------------- 1 | single-char-binding-names-threshold = 10 2 | -------------------------------------------------------------------------------- /deployment/brew_packager.py: -------------------------------------------------------------------------------- 1 | """Generate a package using information retrieved from env vars 2 | 3 | This is meant to be used within a CI to generate Homebrew package information 4 | for releases. 5 | 6 | Usage: 7 | 8 | ```sh 9 | package.py TEMPLATE_FILE OUTPUT_FILE RELEASE_VERSION CHECKSUM 10 | ``` 11 | 12 | Arguments: 13 | 14 | * `TEMPLATE_FILE`: The input file to fill in 15 | * `OUTPUT_FILE`: The path to where the substituted template file should be 16 | written 17 | * `RELEASE_VERSION`: The string corresponding to the release version of the 18 | package 19 | * `CHECKSUM`: The SHA256 checksum of the package 20 | """ 21 | 22 | from string import Template 23 | from pathlib import Path 24 | from typing import Dict 25 | import sys 26 | 27 | 28 | def sub_metadata(template_file: Path, package_meta: Dict[str, str]) -> str: 29 | """Replace template arguments in a document with the actual information 30 | pulled from the environment. 31 | 32 | Args: 33 | template_file: The path to the template file 34 | package_meta: A map with package metadata to use when performing the 35 | file substitution 36 | 37 | Returns: 38 | The string contents of the template file with replacements for the 39 | metadata variables. 40 | """ 41 | 42 | with open(template_file) as f: 43 | template = Template(f.read()) 44 | 45 | return template.safe_substitute(**package_meta) 46 | 47 | 48 | def filter_version(tag: str) -> str: 49 | """Transform a version tag string to a proper semver version. 50 | 51 | Version tags are usually of the form f"v{semver_version_string}". If that 52 | is the case, this method will strip the version string of the leading "v". 53 | If the string does not follow that convention, the string will not be 54 | transformed. 55 | 56 | Args: 57 | tag: The git tag as a string 58 | 59 | Returns: 60 | The tag transformed into a SemVer compatible string. 61 | """ 62 | if not tag: 63 | return tag 64 | 65 | if tag[0] == "v": 66 | return tag[1:] 67 | 68 | return tag 69 | 70 | 71 | def main(): 72 | input_file = sys.argv[1] 73 | output_file = sys.argv[2] 74 | release_version = sys.argv[3] 75 | short_version = filter_version(sys.argv[3]) 76 | checksum = sys.argv[4] 77 | 78 | print(f"Generating {output_file} from template {input_file}") 79 | 80 | metadata = { 81 | "version": release_version, 82 | "short_version": short_version, 83 | "checksum": checksum, 84 | } 85 | 86 | print("Metadata:") 87 | 88 | for k, v in metadata.items(): 89 | print(f"* {k}: {v}") 90 | 91 | output_contents = sub_metadata(Path(input_file), metadata) 92 | 93 | with open(output_file, "w") as out_fd: 94 | print("Homebrew file:") 95 | print(output_contents) 96 | out_fd.write(output_contents) 97 | print(f"Wrote {output_file}") 98 | 99 | 100 | if __name__ == "__main__": 101 | main() 102 | -------------------------------------------------------------------------------- /deployment/git-archive-all.bash: -------------------------------------------------------------------------------- 1 | #!/bin/bash - 2 | # 3 | # File: git-archive-all.sh 4 | # 5 | # Description: A utility script that builds an archive file(s) of all 6 | # git repositories and submodules in the current path. 7 | # Useful for creating a single tarfile of a git super- 8 | # project that contains other submodules. 9 | # 10 | # Examples: Use git-archive-all.sh to create archive distributions 11 | # from git repositories. To use, simply do: 12 | # 13 | # cd $GIT_DIR; git-archive-all.sh 14 | # 15 | # where $GIT_DIR is the root of your git superproject. 16 | # 17 | # License: GPL3+ 18 | # 19 | ############################################################################### 20 | # 21 | # This program is free software; you can redistribute it and/or modify 22 | # it under the terms of the GNU General Public License as published by 23 | # the Free Software Foundation; either version 3 of the License, or 24 | # (at your option) any later version. 25 | # 26 | # This program is distributed in the hope that it will be useful, 27 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 28 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 29 | # GNU General Public License for more details. 30 | # 31 | # You should have received a copy of the GNU General Public License 32 | # along with this program; if not, write to the Free Software 33 | # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA 34 | # 35 | ############################################################################### 36 | 37 | # NOTE: taken from 38 | # https://github.com/fabacab/git-archive-all.sh/blob/fc86194f00b678438f9210859597f6eead28e765/git-archive-all.sh 39 | 40 | # DEBUGGING 41 | set -e # Exit on error (i.e., "be strict"). 42 | set -C # noclobber 43 | 44 | # TRAP SIGNALS 45 | trap 'cleanup' QUIT EXIT 46 | 47 | # For security reasons, explicitly set the internal field separator 48 | # to newline, space, tab 49 | OLD_IFS=$IFS 50 | IFS="$(printf '\n \t')" 51 | 52 | function cleanup() { 53 | rm -f $TMPFILE 54 | rm -f $TMPLIST 55 | rm -f $TOARCHIVE 56 | IFS="$OLD_IFS" 57 | } 58 | 59 | function usage() { 60 | echo "Usage is as follows:" 61 | echo 62 | echo "$PROGRAM <--version>" 63 | echo " Prints the program version number on a line by itself and exits." 64 | echo 65 | echo "$PROGRAM <--usage|--help|-?>" 66 | echo " Prints this usage output and exits." 67 | echo 68 | echo "$PROGRAM [--format ] [--prefix ] [--verbose|-v] [--separate|-s]" 69 | echo " [--worktree-attributes] [--tree-ish|-t ] [output_file]" 70 | echo " Creates an archive for the entire git superproject, and its submodules" 71 | echo " using the passed parameters, described below." 72 | echo 73 | echo " If '--format' is specified, the archive is created with the named" 74 | echo " git archiver backend. Obviously, this must be a backend that git archive" 75 | echo " understands. The format defaults to 'tar' if not specified." 76 | echo 77 | echo " If '--prefix' is specified, the archive's superproject and all submodules" 78 | echo " are created with the prefix named. The default is to not use one." 79 | echo 80 | echo " If '--worktree-attributes' is specified, the invidual archive commands will" 81 | echo " look for attributes in .gitattributes in the working directory too." 82 | echo 83 | echo " If '--separate' or '-s' is specified, individual archives will be created" 84 | echo " for each of the superproject itself and its submodules. The default is to" 85 | echo " concatenate individual archives into one larger archive." 86 | echo 87 | echo " If '--tree-ish' is specified, the archive will be created based on whatever" 88 | echo " you define the tree-ish to be. Branch names, commit hash, etc. are acceptable." 89 | echo " Defaults to HEAD if not specified. See git archive's documentation for more" 90 | echo " information on what a tree-ish is." 91 | echo 92 | echo " If 'output_file' is specified, the resulting archive is created as the" 93 | echo " file named. This parameter is essentially a path that must be writeable." 94 | echo " When combined with '--separate' ('-s') this path must refer to a directory." 95 | echo " Without this parameter or when combined with '--separate' the resulting" 96 | echo " archive(s) are named with a dot-separated path of the archived directory and" 97 | echo " a file extension equal to their format (e.g., 'superdir.submodule1dir.tar')." 98 | echo 99 | echo " The special value '-' (single dash) is treated as STDOUT and, when used, the" 100 | echo " --separate option is ignored. Use a double-dash to separate the outfile from" 101 | echo " the value of previous options. For example, to write a .zip file to STDOUT:" 102 | echo 103 | echo " ./$PROGRAM --format zip -- -" 104 | echo 105 | echo " If '--verbose' or '-v' is specified, progress will be printed." 106 | } 107 | 108 | function version() { 109 | echo "$PROGRAM version $VERSION" 110 | } 111 | 112 | # Internal variables and initializations. 113 | readonly PROGRAM=$(basename "$0") 114 | readonly VERSION=0.3 115 | 116 | SEPARATE=0 117 | VERBOSE=0 118 | 119 | TARCMD=$(command -v gtar || command -v gnutar || command -v tar) 120 | FORMAT=tar 121 | PREFIX= 122 | TREEISH=HEAD 123 | ARCHIVE_OPTS= 124 | 125 | # RETURN VALUES/EXIT STATUS CODES 126 | readonly E_BAD_OPTION=254 127 | readonly E_UNKNOWN=255 128 | 129 | # Process command-line arguments. 130 | while test $# -gt 0; do 131 | if [ x"$1" == x"--" ]; then 132 | # detect argument termination 133 | shift 134 | break 135 | fi 136 | case $1 in 137 | --format) 138 | shift 139 | FORMAT="$1" 140 | shift 141 | ;; 142 | 143 | --prefix) 144 | shift 145 | PREFIX="$1" 146 | shift 147 | ;; 148 | 149 | --worktree-attributes) 150 | ARCHIVE_OPTS+=" $1" 151 | shift 152 | ;; 153 | 154 | --separate | -s) 155 | shift 156 | SEPARATE=1 157 | ;; 158 | 159 | --tree-ish | -t) 160 | shift 161 | TREEISH="$1" 162 | shift 163 | ;; 164 | 165 | --version) 166 | version 167 | exit 168 | ;; 169 | 170 | --verbose | -v) 171 | shift 172 | VERBOSE=1 173 | ;; 174 | 175 | -? | --usage | --help) 176 | usage 177 | exit 178 | ;; 179 | 180 | -*) 181 | echo "Unrecognized option: $1" >&2 182 | usage 183 | exit $E_BAD_OPTION 184 | ;; 185 | 186 | *) 187 | break 188 | ;; 189 | esac 190 | done 191 | 192 | OLD_PWD="$(pwd)" 193 | TMPDIR=${TMPDIR:-/tmp} 194 | TMPFILE=$(mktemp "$TMPDIR/$PROGRAM.XXXXXX") # Create a place to store our work's progress 195 | TMPLIST=$(mktemp "$TMPDIR/$PROGRAM.submodules.XXXXXX") 196 | TOARCHIVE=$(mktemp "$TMPDIR/$PROGRAM.toarchive.XXXXXX") 197 | OUT_FILE=$OLD_PWD # assume "this directory" without a name change by default 198 | 199 | if [ ! -z "$1" ]; then 200 | OUT_FILE="$1" 201 | if [ "-" == "$OUT_FILE" ]; then 202 | SEPARATE=0 203 | fi 204 | shift 205 | fi 206 | 207 | # Validate parameters; error early, error often. 208 | if [ "-" == "$OUT_FILE" -o $SEPARATE -ne 1 ] && [ "$FORMAT" == "tar" -a $( 209 | $TARCMD --help | grep -q -- "--concatenate" 210 | echo $? 211 | ) -ne 0 ]; then 212 | echo "Your 'tar' does not support the '--concatenate' option, which we need" 213 | echo "to produce a single tarfile. Either install a compatible tar (such as" 214 | echo "gnutar), or invoke $PROGRAM with the '--separate' option." 215 | exit 216 | elif [ $SEPARATE -eq 1 -a ! -d "$OUT_FILE" ]; then 217 | echo "When creating multiple archives, your destination must be a directory." 218 | echo "If it's not, you risk being surprised when your files are overwritten." 219 | exit 220 | elif [ $( 221 | git config -l | grep -q '^core\.bare=true' 222 | echo $? 223 | ) -eq 0 ]; then 224 | echo "$PROGRAM must be run from a git working copy (i.e., not a bare repository)." 225 | exit 226 | fi 227 | 228 | # Create the superproject's git-archive 229 | if [ $VERBOSE -eq 1 ]; then 230 | echo -n "creating superproject archive..." 231 | fi 232 | rm -f $TMPDIR/$(basename "$(pwd)").$FORMAT 233 | git archive --format=$FORMAT --prefix="$PREFIX" $ARCHIVE_OPTS $TREEISH >$TMPDIR/$(basename "$(pwd)").$FORMAT 234 | if [ $VERBOSE -eq 1 ]; then 235 | echo "done" 236 | fi 237 | echo $TMPDIR/$(basename "$(pwd)").$FORMAT >|$TMPFILE # clobber on purpose 238 | superfile=$(head -n 1 $TMPFILE) 239 | 240 | if [ $VERBOSE -eq 1 ]; then 241 | echo -n "looking for subprojects..." 242 | fi 243 | # find all '.git' dirs, these show us the remaining to-be-archived dirs 244 | # we only want directories that are below the current directory 245 | find . -mindepth 2 -name '.git' -type d -print | sed -e 's/^\.\///' -e 's/\.git$//' >>$TOARCHIVE 246 | # as of version 1.7.8, git places the submodule .git directories under the superprojects .git dir 247 | # the submodules get a .git file that points to their .git dir. we need to find all of these too 248 | find . -mindepth 2 -name '.git' -type f -print | xargs grep -l "gitdir" | sed -e 's/^\.\///' -e 's/\.git$//' >>$TOARCHIVE 249 | if [ $VERBOSE -eq 1 ]; then 250 | echo "done" 251 | echo " found:" 252 | cat $TOARCHIVE | while read arch; do 253 | echo " $arch" 254 | done 255 | fi 256 | 257 | if [ $VERBOSE -eq 1 ]; then 258 | echo -n "archiving submodules..." 259 | fi 260 | git submodule >>"$TMPLIST" 261 | while read path; do 262 | TREEISH=$(grep "^ .*${path%/} " "$TMPLIST" | cut -d ' ' -f 2) # git submodule does not list trailing slashes in $path 263 | cd "$path" 264 | rm -f "$TMPDIR"/"$(echo "$path" | sed -e 's/\//./g')"$FORMAT 265 | git archive --format=$FORMAT --prefix="${PREFIX}$path" $ARCHIVE_OPTS ${TREEISH:-HEAD} >"$TMPDIR"/"$(echo "$path" | sed -e 's/\//./g')"$FORMAT 266 | if [ $FORMAT == 'zip' ]; then 267 | # delete the empty directory entry; zipped submodules won't unzip if we don't do this 268 | zip -d "$(tail -n 1 $TMPFILE)" "${PREFIX}${path%/}" >/dev/null 2>&1 || true # remove trailing '/' 269 | fi 270 | echo "$TMPDIR"/"$(echo "$path" | sed -e 's/\//./g')"$FORMAT >>$TMPFILE 271 | cd "$OLD_PWD" 272 | done <$TOARCHIVE 273 | if [ $VERBOSE -eq 1 ]; then 274 | echo "done" 275 | fi 276 | 277 | if [ $VERBOSE -eq 1 ]; then 278 | echo -n "concatenating archives into single archive..." 279 | fi 280 | # Concatenate archives into a super-archive. 281 | if [ $SEPARATE -eq 0 -o "-" == "$OUT_FILE" ]; then 282 | if [ $FORMAT == 'tar.gz' ]; then 283 | gunzip $superfile 284 | superfile=${superfile:0:-3} # Remove '.gz' 285 | sed -e '1d' $TMPFILE | while read file; do 286 | gunzip $file 287 | file=${file:0:-3} 288 | $TARCMD --concatenate -f "$superfile" "$file" && rm -f "$file" 289 | done 290 | gzip $superfile 291 | superfile=$superfile.gz 292 | elif [ $FORMAT == 'tar' ]; then 293 | sed -e '1d' $TMPFILE | while read file; do 294 | $TARCMD --concatenate -f "$superfile" "$file" && rm -f "$file" 295 | done 296 | elif [ $FORMAT == 'zip' ]; then 297 | sed -e '1d' $TMPFILE | while read file; do 298 | # zip incorrectly stores the full path, so cd and then grow 299 | cd $(dirname "$file") 300 | zip -g "$superfile" $(basename "$file") && rm -f "$file" 301 | done 302 | cd "$OLD_PWD" 303 | fi 304 | 305 | echo "$superfile" >|$TMPFILE # clobber on purpose 306 | fi 307 | if [ $VERBOSE -eq 1 ]; then 308 | echo "done" 309 | fi 310 | 311 | if [ $VERBOSE -eq 1 ]; then 312 | echo -n "moving archive to $OUT_FILE..." 313 | fi 314 | while read file; do 315 | if [ "-" == "$OUT_FILE" ]; then 316 | cat "$file" && rm -f "$file" 317 | else 318 | mv "$file" "$OUT_FILE" 319 | fi 320 | done <$TMPFILE 321 | if [ $VERBOSE -eq 1 ]; then 322 | echo "done" 323 | fi 324 | 325 | -------------------------------------------------------------------------------- /deployment/macos/homebrew/homebrew_formula.rb.template: -------------------------------------------------------------------------------- 1 | class Diffsitter < Formula 2 | desc "Tree-sitter based AST difftool to get meaningful semantic diffs" 3 | homepage "https://github.com/afnanenayet/diffsitter" 4 | version "$short_version" 5 | url "https://github.com/afnanenayet/diffsitter/releases/download/$version/diffsitter-x86_64-apple-darwin.tar.gz" 6 | sha256 "$checksum" 7 | license "MIT" 8 | 9 | def install 10 | bin.install "diffsitter" 11 | bin.install "git-diffsitter" 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /dev_scripts/build_with_sanitizer.bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Summary: 4 | # 5 | # A convenience script that runs cargo with environment variables that build 6 | # with address sanitizer support. This requires the nightly toolchain to be 7 | # installed on the user's system. 8 | # 9 | # This will invoke cargo with the required environment variables so that users 10 | # can run tests or build a target with one of the sanitizers compiled in. 11 | # 12 | # Users *must* provide the `DIFFSITTER_TARGET` environment variable due to a 13 | # bug with how Cargo handles targets with building with sanitizers. 14 | # 15 | # Parameters: 16 | # 17 | # * DIFFSITTER_TARGET (env var, required): The cargo target triple to build 18 | # for. This is forwarded as the --target when invoking cargo. This must be 19 | # provided, otherwise Cargo will fail with errors when trying to build a 20 | # target. 21 | # * DIFFSITTER_SANITIZER (env var, optional): The name of the sanitizer flag 22 | # to build with. You can find the full list of valid parameters here: 23 | # https://doc.rust-lang.org/beta/unstable-book/compiler-flags/sanitizer.html 24 | # 25 | # This will default to 'address' if not provided by the user. 26 | # 27 | # The flag is forwarded as the `-Zprofile` rustc flag for regular targets and 28 | # when building docs. 29 | # 30 | # Examples: 31 | # 32 | # # Using the default sanitizer which this script sets to ASAN 33 | # DIFFSITTER_TARGET=aarch64-apple-darwin ./build_with_sanitizer.bash test 34 | # 35 | # # Using a different sanitizer 36 | # DIFFSITTER_TARGET=aarch64-apple-darwin DIFFSITTER_SANITIZER=leak ./build_with_sanitizer.bash test 37 | 38 | set -exu 39 | 40 | # We set the default to address if not provided by the user 41 | diffsitter_sanitizer=${DIFFSITTER_SANITIZER:-address} 42 | 43 | # We set the malloc nano zone to 0 as a workaround for this bug: 44 | # https://stackoverflow.com/questions/64126942/malloc-nano-zone-abandoned-due-to-inability-to-preallocate-reserved-vm-space 45 | MallocNanoZone='0' \ 46 | RUSTFLAGS="-Zsanitizer=$diffsitter_sanitizer" \ 47 | RUSTDOCFLAGS="-Zsanitizer=$diffsitter_sanitizer" \ 48 | cargo +nightly $@ --target "$DIFFSITTER_TARGET" 49 | -------------------------------------------------------------------------------- /dev_scripts/update_dependencies.bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Updates Cargo dependencies and generates a commit if the operation is successful and tests pass. 3 | # This requires the user to have the following binaries available in their PATH: 4 | # 5 | # - cargo 6 | # - cargo-upgrade 7 | # - cargo-update 8 | # - cargo-nextest 9 | # 10 | # If these can't be found the script will exit with an error. 11 | 12 | required_binaries=("cargo" "cargo-upgrade" "cargo-nextest") 13 | 14 | for cmd in "${required_binaries[@]}" 15 | do 16 | if ! command -v "$cmd" 17 | then 18 | echo "$cmd was not found" 19 | exit 1 20 | fi 21 | done 22 | 23 | # Checks if the current branch is clean, we only want this script to run in a clean context so 24 | # we don't accidentally commit other changes. 25 | has_changes=$(git diff-files --quiet) 26 | 27 | if [[ "$has_changes" -ne 0 ]]; then 28 | echo "ERROR: detected local changes. You must run this script with a clean git context." 29 | exit 1 30 | fi 31 | 32 | set -ex 33 | 34 | cargo upgrade --incompatible 35 | cargo update 36 | cargo test --doc 37 | cargo nextest run 38 | git add Cargo.lock Cargo.toml 39 | git commit -m "chore(deps): Update cargo dependencies" \ 40 | -m "Done with \`dev_scripts/update_dependencies.bash\`" 41 | -------------------------------------------------------------------------------- /docs/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at afnan@afnan.io. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /docs/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Development setup 4 | 5 | This repo uses [pre-commit](https://pre-commit.com) 6 | to automatically apply linters and formatters before every commit. Install 7 | `pre-commit`. If you have it installed, then initialize the git hooks for 8 | this repo with: 9 | 10 | ```sh 11 | pre-commit install 12 | ``` 13 | 14 | Now your files will be automatically formatted before each commit. If they are not 15 | formatted then the commit check will fail and you will have to commit the updated 16 | formatted file again. 17 | 18 | ## Building 19 | 20 | This project uses a mostly standard Rust toolchain. At the time of writing, the 21 | easiest way to get set up with the Rust toolchain is 22 | [rustup](https://rustup.rs/). The rustup website has the instructions to get 23 | you set up with Rust on any platform that Rust supports. This project uses 24 | Cargo to build, like most Rust projects. 25 | 26 | The only small caveat with this projects that isn't standard is that it has 27 | bindings to tree-sitter, so it compiles tree-sitter grammars that are written 28 | in C and C++, and uses the C FFI to link from the Rust codebase to the 29 | compiled tree-sitter grammars. As such, you'll need to have a C compiler that 30 | supports `C99` or later, and a C++ compiler that supports `C++14` or later. 31 | Compilation is handled by the `cc` crate, and you can find the details on how 32 | compilers are selected in the [cc docs](https://docs.rs/cc). 33 | 34 | These tree-sitter grammars are included as [git 35 | submodules](https://git-scm.com/book/en/v2/Git-Tools-Submodules), so you need 36 | to make sure you initialize submodules when checking out the git repo. 37 | 38 | If you're cloning the repository for the first time: 39 | 40 | ```sh 41 | git clone --recurse-submodules https://github.com/afnanenayet/diffsitter.git 42 | ``` 43 | 44 | If you've already checked out the repository, you can initialize submodules 45 | with the following command: 46 | 47 | ```sh 48 | git submodule update --init --recursive 49 | ``` 50 | 51 | This command can also be used to update to the latest commits for each 52 | submodule as the repository gets updated. Sometimes you may run into build 53 | errors that complain about trying to link to nonexistent symbols, this error 54 | can be incurred if a new grammar is added to the repository but the source 55 | files aren't present, so you should run the update command to see if that fixes 56 | the error. If it doesn't, I've messed up and you should file an issue 57 | (with as much detail as possible). 58 | 59 | ### Dynamic Libraries/Grammars 60 | 61 | If you want to use dynamic libraries you don't have to clone the submodules. 62 | You can build this binary with support for dynamic libraries with the following 63 | command: 64 | 65 | ```sh 66 | cargo build --no-default-features --features dynamic-grammar-libs 67 | ``` 68 | 69 | There is an optional test that checks to see if the default library locations 70 | can be loaded correctly for every language that `diffsitter` is configured to 71 | handle by default. This will look for a shared library file in the user's 72 | default library lookup path in the form `libtree-sitter-{lang}.{ext}` where 73 | `ext` is determined by the user's platform (`.so` on Linux, `.dylib` on MacOS, 74 | and `.dll` on Windows). The test will then try to find and call the function to 75 | construct the grammar object from that file if it is able to find it. 76 | 77 | You can invoke the test with this command: 78 | 79 | ```sh 80 | cargo test --features dynamic-grammar-libs -- --ignored --exact parse::tests::dynamic_load_parsers 81 | ``` 82 | 83 | This test is marked `#[ignore]` because people may decide to package their 84 | shared libraries for `tree-sitter` differently or may want to specify different 85 | file paths for these shared libraries in their config. 86 | 87 | ### C/C++ Toolchains 88 | 89 | If you're on Mac and have [Homebrew](https://brew.sh) installed: 90 | 91 | ```sh 92 | brew install llvm 93 | 94 | # or 95 | 96 | brew install gcc 97 | ``` 98 | 99 | The built-in Apple clang that comes with XCode is also fine. 100 | 101 | If you're on Ubuntu: 102 | 103 | ```sh 104 | sudo apt install gcc 105 | ``` 106 | 107 | If you're on Arch Linux: 108 | 109 | ```sh 110 | sudo pacman -S gcc 111 | ``` 112 | 113 | ## Development 114 | 115 | There's not much to say about the architecture at the moment, this is a 116 | relatively small codebase and subject to change as we receive more feedback. I 117 | try to keep the codebase well-commented and easy to follow, but feel free to 118 | file issues about confusing architectural decisions or incomplete/underwhelming 119 | documentation. 120 | 121 | If you want to contribute, you need to make sure that the project builds and 122 | that tests pass, which you can check locally with: 123 | 124 | ```sh 125 | cargo test --all 126 | ``` 127 | 128 | The CI will test the project on all major OS's and some additional platforms on 129 | Linux, such as ARM (using the `cross` toolchain). Having these checks all pass 130 | is a prerequisite for getting any PR merged. I've found that tests can be a 131 | little flaky on the Windows platform check, so if you see that tests failed 132 | there, try re-running the checks with Github actions to see if they pass. 133 | 134 | This project targets the latest stable version of `rustc`. 135 | 136 | Note that if you update anything to do with the project config, you'll have to 137 | update the [sample config](../assets/sample_config.json5) as well to ensure 138 | that tests pass (the project will actually parse the sample config) and that 139 | it documents the various options available to users. 140 | 141 | ### Submodules 142 | 143 | We are currently vendoring tree sitter grammars in the diffsitter repository so 144 | we can compile everything statically. We strip the Rust bindings from the 145 | repository if it contains them, otherwise Rust will not include any files from 146 | these folders in the target directory, and we will not be able to compile these 147 | dependencies ourselves. 148 | 149 | We maintain these vendors and ensure they stay up to date using 150 | [nvchecker](https://github.com/lilydjwg/nvchecker). We have a repository for 151 | the grammars at: 152 | [github.com/afnananeayet/diffsitter-grammars](https://github.com/afnanenayet/diffsitter-grammars). 153 | If you update a tree sitter fork, you should file a pull request in the 154 | `diffsitter-grammars` repository and a PR in this repository with the updated 155 | submodule. You can also use that repository with `nvchecker` to find 156 | forks that are out of date, which makes for an easy first issue that you can 157 | tackle in this project. 158 | 159 | ### Testing 160 | 161 | Tests are run using cargo: 162 | 163 | ```sh 164 | cargo test --all-features 165 | ``` 166 | 167 | Tests are run on every supported platform through Github actions. 168 | 169 | We use a combination of unit testing and snapshot testing. There's certain 170 | components with expected behavior, and we use unit tests to verify that. We 171 | also utilize snapshot testing using the [insta](https://docs.rs/insta) library 172 | that verify that we're seeing consistent output between changes. 173 | 174 | We don't expect the existing unit tests to change as much, but it's very 175 | plausible that snapshot tests will change. If you change some code and snapshot 176 | tests change, feel free to update the snapshots if the change is expected. You 177 | can easily review the changes and create new snapshots using 178 | [cargo-insta](https://crates.io/crates/cargo-insta). Snapshot tests typically 179 | break because of updates to the grammars. 180 | 181 | To update snapshots, install `cargo-insta` and run the following command: 182 | 183 | ```sh 184 | cargo insta review 185 | ``` 186 | 187 | This will open up a TUI tool that lets you review snapshots and accept or 188 | reject the changes. 189 | -------------------------------------------------------------------------------- /resources/test_configs/empty_dict.json5: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /resources/test_configs/partial_section_1.json: -------------------------------------------------------------------------------- 1 | { 2 | "formatting": { 3 | "unified": { 4 | "addition": { 5 | "highlight": null, 6 | "regular-foreground": "green", 7 | "emphasized-foreground": { 8 | "color256": 0 9 | }, 10 | "bold": true, 11 | "underline": false, 12 | "prefix": "+" 13 | }, 14 | "deletion": { 15 | "regular-foreground": "red", 16 | "emphasized-foreground": "red", 17 | "bold": true, 18 | "underline": false, 19 | "prefix": "-" 20 | } 21 | }, 22 | "custom": { 23 | "custom_render_mode": { 24 | "type": "unified", 25 | "addition": { 26 | "highlight": null, 27 | "regular-foreground": "green", 28 | "emphasized-foreground": { 29 | "color256": 0 30 | }, 31 | "bold": true, 32 | "underline": false, 33 | "prefix": "+" 34 | }, 35 | "deletion": { 36 | "regular-foreground": "red", 37 | "emphasized-foreground": "red", 38 | "bold": true, 39 | "underline": false, 40 | "prefix": "-" 41 | } 42 | } 43 | } 44 | }, 45 | "grammar": { 46 | "file-associations": { 47 | "rs": "rust" 48 | }, 49 | "dylib-overrides": { 50 | "rust": "libtree-sitter-rust.so", 51 | "c": "/usr/lib/libtree-sitter-c.so", 52 | "cpp": "../libtree-sitter-cpp.so" 53 | } 54 | }, 55 | "fallback-cmd": "diff", 56 | "input-processing": { 57 | "split-graphemes": true, 58 | "exclude-kinds": [ 59 | "string" 60 | ], 61 | "include-kinds": [ 62 | "method_definition" 63 | ], 64 | "strip-whitespace": true 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /resources/test_configs/partial_section_2.json: -------------------------------------------------------------------------------- 1 | { 2 | "formatting": { 3 | "deletion": { 4 | "regular-foreground": "red", 5 | "emphasized-foreground": "red", 6 | "underline": false, 7 | "prefix": "-" 8 | }, 9 | "custom": { 10 | "custom_render_mode": { 11 | "type": "unified", 12 | "deletion": { 13 | "regular-foreground": "red" 14 | } 15 | } 16 | } 17 | }, 18 | "grammar": { 19 | "file-associations": { 20 | "rs": "rust" 21 | }, 22 | "dylib-overrides": { 23 | "rust": "libtree-sitter-rust.so", 24 | "c": "/usr/lib/libtree-sitter-c.so", 25 | "cpp": "../libtree-sitter-cpp.so" 26 | } 27 | }, 28 | "fallback-cmd": "diff" 29 | } 30 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | edition = "2021" 2 | -------------------------------------------------------------------------------- /src/bin/diffsitter.rs: -------------------------------------------------------------------------------- 1 | use ::console::Term; 2 | use anyhow::Result; 3 | use clap::CommandFactory; 4 | use clap::FromArgMatches; 5 | #[cfg(panic = "unwind")] 6 | use human_panic::setup_panic; 7 | use libdiffsitter::cli; 8 | use libdiffsitter::cli::Args; 9 | use libdiffsitter::config::{Config, ReadError}; 10 | use libdiffsitter::console_utils; 11 | use libdiffsitter::diff; 12 | use libdiffsitter::generate_ast_vector_data; 13 | use libdiffsitter::parse::generate_language; 14 | use libdiffsitter::parse::lang_name_from_file_ext; 15 | #[cfg(feature = "static-grammar-libs")] 16 | use libdiffsitter::parse::SUPPORTED_LANGUAGES; 17 | use libdiffsitter::render::{DisplayData, DocumentDiffData, Renderer}; 18 | use log::{debug, error, info, warn, LevelFilter}; 19 | use serde_json as json; 20 | use std::{ 21 | io, 22 | path::Path, 23 | process::{Child, Command}, 24 | }; 25 | 26 | #[cfg(feature = "better-build-info")] 27 | use shadow_rs::shadow; 28 | 29 | #[cfg(feature = "jemallocator")] 30 | use jemallocator::Jemalloc; 31 | 32 | #[cfg(feature = "jemallocator")] 33 | #[global_allocator] 34 | static GLOBAL: Jemalloc = Jemalloc; 35 | 36 | /// Return an instance of [Config] from a config file path (or the inferred default path) 37 | /// 38 | /// If a config path isn't provided or there is some other failure, fall back to the default 39 | /// config. This will error out if a config is found but is found to be an invalid config. 40 | fn derive_config(args: &Args) -> Result { 41 | if args.no_config { 42 | info!("`no_config` specified, falling back to default config"); 43 | return Ok(Config::default()); 44 | } 45 | match Config::try_from_file(args.config.as_ref()) { 46 | // If the config was parsed correctly with no issue, we don't have to do anything 47 | Ok(config) => Ok(config), 48 | // If there was an error, we need to figure out whether to propagate the error or fall 49 | // back to the default config 50 | Err(e) => match e { 51 | // If it is a recoverable error, ex: not being able to find the default file path or 52 | // not finding a file at all isn't a hard error, it makes sense for us to use the 53 | // default config. 54 | ReadError::ReadFileFailure(_) | ReadError::NoDefault => { 55 | warn!("{} - falling back to default config", e); 56 | Ok(Config::default()) 57 | } 58 | // If we *do* find a config file and it doesn't parse correctly, we should return an 59 | // error and let the user know that their config is incorrect. This isn't a browser, 60 | // we can't just silently march forward and hope for the best. 61 | ReadError::DeserializationFailure(e) => { 62 | error!("Failed to deserialize config file: {}", e); 63 | Err(anyhow::anyhow!(e)) 64 | } 65 | }, 66 | } 67 | } 68 | 69 | /// Check if the input files are supported by this program. 70 | /// 71 | /// If the user provides a language override, this will check that the language is supported by the 72 | /// program. If the user supplies any extension mappings, this will check to see if the extension 73 | /// is in the mapping or if it's one of the user-defined ones. 74 | /// 75 | /// This is used to determine whether the program should fall back to another diff utility. 76 | fn are_input_files_supported(args: &Args, config: &Config) -> bool { 77 | let paths = [&args.old, &args.new]; 78 | 79 | // If there's a user override at the command line, that takes priority over everything else if 80 | // it corresponds to a valid grammar/language string. 81 | if let Some(file_type) = &args.file_type { 82 | return generate_language(file_type, &config.grammar).is_ok(); 83 | } 84 | 85 | // For each path, attempt to create a parser for that given extension, checking for any 86 | // possible overrides. 87 | paths.into_iter().all(|path| match path { 88 | None => { 89 | warn!("Missing a file. You need two files to make a diff."); 90 | false 91 | } 92 | Some(path) => { 93 | debug!("Checking if {} can be parsed", path.display()); 94 | match path.extension() { 95 | None => { 96 | warn!("No filetype deduced for {}", path.display()); 97 | false 98 | } 99 | Some(ext) => { 100 | let ext = ext.to_string_lossy(); 101 | let lang_name = lang_name_from_file_ext(&ext, &config.grammar); 102 | match lang_name { 103 | Ok(lang_name) => { 104 | debug!("Deduced language {} for path {}", lang_name, path.display()); 105 | true 106 | } 107 | Err(e) => { 108 | warn!("Extension {} not supported: {}", ext, e); 109 | false 110 | } 111 | } 112 | } 113 | } 114 | } 115 | }) 116 | } 117 | 118 | /// Take the diff of two files 119 | fn run_diff(args: Args, config: Config) -> Result<()> { 120 | // Check whether we can get the renderer up front. This is more ergonomic than running the diff 121 | // and then informing the user their renderer choice is incorrect/that the config is invalid. 122 | let render_config = config.formatting; 123 | let render_param = args.renderer; 124 | let renderer = render_config.get_renderer(render_param)?; 125 | 126 | let file_type = args.file_type.as_deref(); 127 | let path_a = args.old.as_ref().unwrap(); 128 | let path_b = args.new.as_ref().unwrap(); 129 | 130 | // This looks a bit weird because the ast vectors and some other data reference data in the 131 | // AstVectorData structs. Because of that, we can't make a function that generates the ast 132 | // vectors in one shot. 133 | 134 | let ast_data_a = generate_ast_vector_data(path_a.clone(), file_type, &config.grammar)?; 135 | let ast_data_b = generate_ast_vector_data(path_b.clone(), file_type, &config.grammar)?; 136 | let diff_vec_a = config 137 | .input_processing 138 | .process(&ast_data_a.tree, &ast_data_a.text); 139 | let diff_vec_b = config 140 | .input_processing 141 | .process(&ast_data_b.tree, &ast_data_b.text); 142 | 143 | let hunks = diff::compute_edit_script(&diff_vec_a, &diff_vec_b)?; 144 | let params = DisplayData { 145 | hunks, 146 | old: DocumentDiffData { 147 | filename: &ast_data_a.path.to_string_lossy(), 148 | text: &ast_data_a.text, 149 | }, 150 | new: DocumentDiffData { 151 | filename: &ast_data_b.path.to_string_lossy(), 152 | text: &ast_data_b.text, 153 | }, 154 | }; 155 | // Use a buffered terminal instead of a normal unbuffered terminal so we can amortize the cost 156 | // of printing. It doesn't really matter how frequently the terminal prints to stdout because 157 | // the user just cares about the output at the end, we don't care about how frequently the 158 | // terminal does partial updates or anything like that. If the user is curious about progress, 159 | // they can enable logging and see when hunks are processed and written to the buffer. 160 | let mut buf_writer = Term::buffered_stdout(); 161 | let term_info = buf_writer.clone(); 162 | renderer.render(&mut buf_writer, ¶ms, Some(&term_info))?; 163 | buf_writer.flush()?; 164 | Ok(()) 165 | } 166 | 167 | /// Serialize the default options struct to a json file and print that to stdout 168 | fn dump_default_config() -> Result<()> { 169 | let config = Config::default(); 170 | println!("{}", json::to_string_pretty(&config)?); 171 | Ok(()) 172 | } 173 | 174 | /// Run the diff fallback command using the command and the given paths. 175 | fn diff_fallback(cmd: &str, old: &Path, new: &Path) -> io::Result { 176 | debug!("Spawning diff fallback process"); 177 | Command::new(cmd).args([old, new]).spawn() 178 | } 179 | 180 | /// Print a list of the languages that this instance of diffsitter was compiled with 181 | pub fn list_supported_languages() { 182 | #[cfg(feature = "static-grammar-libs")] 183 | { 184 | println!("This program was compiled with support for:"); 185 | for language in SUPPORTED_LANGUAGES.as_slice() { 186 | println!("* {language}"); 187 | } 188 | } 189 | 190 | #[cfg(feature = "dynamic-grammar-libs")] 191 | { 192 | println!("This program will dynamically load grammars from shared libraries"); 193 | } 194 | } 195 | 196 | /// Print shell completion scripts to `stdout`. 197 | /// 198 | /// This is a basic wrapper for the subcommand. 199 | fn print_shell_completion(shell: clap_complete::Shell) { 200 | let mut app = cli::Args::command(); 201 | clap_complete::generate(shell, &mut app, "diffsitter", &mut io::stdout()); 202 | } 203 | 204 | fn main() -> Result<()> { 205 | // Set up a panic handler that will yield more human-readable errors. 206 | #[cfg(panic = "unwind")] 207 | setup_panic!(); 208 | 209 | #[cfg(feature = "better-build-info")] 210 | shadow!(build); 211 | 212 | use cli::Command; 213 | 214 | #[cfg(feature = "better-build-info")] 215 | let command = Args::command().version(build::CLAP_LONG_VERSION); 216 | 217 | #[cfg(not(feature = "better-build-info"))] 218 | let command = Args::command(); 219 | let matches = command.get_matches(); 220 | let args = Args::from_arg_matches(&matches)?; 221 | 222 | // We parse the config as early as possible so users can get quick feedback if anything is off 223 | // with their config. 224 | let config = derive_config(&args)?; 225 | 226 | // Users can supply a command that will *not* run a diff, which we handle here 227 | if let Some(cmd) = args.cmd { 228 | match cmd { 229 | Command::List => list_supported_languages(), 230 | Command::DumpDefaultConfig => dump_default_config()?, 231 | Command::GenCompletion { shell } => { 232 | print_shell_completion(shell.into()); 233 | } 234 | } 235 | } else { 236 | let log_level = if args.debug { 237 | LevelFilter::Trace 238 | } else { 239 | LevelFilter::Off 240 | }; 241 | pretty_env_logger::formatted_timed_builder() 242 | .filter_level(log_level) 243 | .init(); 244 | console_utils::set_term_colors(args.color_output); 245 | // First check if the input files can be parsed with tree-sitter. 246 | let files_supported = are_input_files_supported(&args, &config); 247 | 248 | // If the files are supported by our grammars, awesome. Otherwise fall back to a diff 249 | // utility if one is specified. 250 | if files_supported { 251 | run_diff(args, config)?; 252 | } else if let Some(cmd) = config.fallback_cmd { 253 | info!("Input files are not supported but user has configured diff fallback"); 254 | diff_fallback(&cmd, &args.old.unwrap(), &args.new.unwrap())?; 255 | } else { 256 | anyhow::bail!("Unsupported file type with no fallback command specified."); 257 | } 258 | } 259 | Ok(()) 260 | } 261 | -------------------------------------------------------------------------------- /src/bin/diffsitter_completions.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | 3 | /// A program that generates completions for the `diffsitter` program. 4 | /// 5 | /// This can generate completions for different shells, manpages, etc. 6 | #[derive(Debug, Parser)] 7 | struct Args {} 8 | 9 | fn main() { 10 | eprintln!("This functionality has not yet been implemented."); 11 | } 12 | -------------------------------------------------------------------------------- /src/bin/diffsitter_utils.rs: -------------------------------------------------------------------------------- 1 | //! Utility functions that complement the diffsitter binary. 2 | 3 | use anyhow::Result; 4 | use clap::CommandFactory; 5 | use clap::FromArgMatches; 6 | use clap::Parser; 7 | use libdiffsitter::parse::construct_ts_lang_from_shared_lib; 8 | use std::path::PathBuf; 9 | 10 | /// Utility functions that complement the diffsitter binary. 11 | #[derive(Debug, Parser)] 12 | #[clap(author, version, about)] 13 | pub enum DiffsitterUtilsApp { 14 | /// Try loading a tree-sitter parser shared library object. 15 | /// 16 | /// You can use this command to check the validity of a tree-sitter parser shared library 17 | /// object file. 18 | /// 19 | /// If this operation succeeds, the binary will exist with code 0. Otherwise the exit code will 20 | /// be non-zero and an error will be printed. 21 | LoadParser { 22 | /// The name of the language/parser. 23 | /// 24 | /// This is used to get the name of the constructor that corresponds to the name of the 25 | /// symbol that is constructor method for the parser. 26 | /// 27 | /// This *must* be the tree-sitter name. 28 | /// 29 | /// For example: "C" will is turned to "tree-sitter-c". 30 | language_name: String, 31 | 32 | /// The path to the shared library object. 33 | parser_path: PathBuf, 34 | }, 35 | } 36 | 37 | fn main() -> Result<()> { 38 | let command = DiffsitterUtilsApp::command(); 39 | let matches = command.get_matches(); 40 | let args = DiffsitterUtilsApp::from_arg_matches(&matches)?; 41 | match args { 42 | DiffsitterUtilsApp::LoadParser { 43 | language_name, 44 | parser_path, 45 | } => { 46 | construct_ts_lang_from_shared_lib(&language_name, &parser_path)?; 47 | } 48 | }; 49 | Ok(()) 50 | } 51 | -------------------------------------------------------------------------------- /src/cli.rs: -------------------------------------------------------------------------------- 1 | use crate::console_utils::ColorOutputPolicy; 2 | use clap::Parser; 3 | use std::path::PathBuf; 4 | use strum_macros::EnumString; 5 | 6 | #[derive(Debug, Eq, PartialEq, Clone, Parser)] 7 | #[clap(author, version, about)] 8 | pub struct Args { 9 | /// Print debug output 10 | /// 11 | /// This will print debug logs at the trace level. This is useful for debugging and bug reports 12 | /// should contain debug logging info. 13 | #[clap(short, long)] 14 | pub debug: bool, 15 | /// Run a subcommand that doesn't perform a diff. Valid options are: "list", 16 | /// "dump_default_config", and "build_info". 17 | /// 18 | /// * "list" lists all of the filetypes/languages that this program was compiled with support 19 | /// for 20 | /// 21 | /// * "dump_default_config" will dump the default configuration to stdout 22 | /// 23 | /// * "build_info" prints extended build information 24 | #[clap(subcommand)] 25 | pub cmd: Option, 26 | /// The first file to compare against 27 | /// 28 | /// Text that is in this file but is not in the new file is considered a deletion 29 | // #[clap(name = "OLD", parse(from_os_str), required_unless_present = "cmd")] 30 | #[clap(name = "OLD")] 31 | pub old: Option, 32 | /// The file that the old file is compared against 33 | /// 34 | /// Text that is in this file but is not in the old file is considered an addition 35 | // #[clap(name = "NEW", parse(from_os_str), required_unless_present = "cmd")] 36 | #[clap(name = "NEW")] 37 | pub new: Option, 38 | /// Manually set the file type for the given files 39 | /// 40 | /// This will dictate which parser is used with the difftool. You can list all of the valid 41 | /// file type strings with `diffsitter --cmd list` 42 | #[clap(short = 't', long)] 43 | pub file_type: Option, 44 | /// Use the config provided at the given path 45 | /// 46 | /// By default, diffsitter attempts to find the config at `$XDG_CONFIG_HOME/diffsitter.json5`. 47 | /// On Windows the app will look in the standard config path. 48 | // #[clap(short, long, env = "DIFFSITTER_CONFIG")] 49 | #[clap(short, long)] 50 | pub config: Option, 51 | /// Set the color output policy. Valid values are: "auto", "on", "off". 52 | /// 53 | /// "auto" will automatically detect whether colors should be applied by trying to determine 54 | /// whether the process is outputting to a TTY. "on" will enable output and "off" will 55 | /// disable color output regardless of whether the process detects a TTY. 56 | #[clap(long = "color", default_value_t)] 57 | pub color_output: ColorOutputPolicy, 58 | /// Ignore any config files and use the default config 59 | /// 60 | /// This will cause the app to ignore any configs and all config values will use the their 61 | /// default settings. 62 | #[clap(short, long)] 63 | pub no_config: bool, 64 | 65 | /// Specify which renderer tag to use. 66 | /// 67 | /// If no option is supplied then this will fall back to the default renderer. 68 | #[clap(short, long)] 69 | pub renderer: Option, 70 | } 71 | 72 | /// A wrapper struct for `clap_complete::Shell`. 73 | /// 74 | /// We need this wrapper so we can automatically serialize strings using `EnumString` and use the 75 | /// enums as a clap argument. 76 | #[derive(Copy, Clone, EnumString, PartialEq, Eq, Debug)] 77 | #[strum(serialize_all = "snake_case")] 78 | pub enum ShellWrapper { 79 | Bash, 80 | Zsh, 81 | Fish, 82 | Elvish, 83 | 84 | #[strum(serialize = "powershell")] 85 | PowerShell, 86 | } 87 | 88 | impl Default for ShellWrapper { 89 | fn default() -> Self { 90 | Self::Bash 91 | } 92 | } 93 | 94 | impl From for clap_complete::Shell { 95 | fn from(wrapper: ShellWrapper) -> Self { 96 | use clap_complete as cc; 97 | 98 | match wrapper { 99 | ShellWrapper::Bash => cc::Shell::Bash, 100 | ShellWrapper::Zsh => cc::Shell::Zsh, 101 | ShellWrapper::Fish => cc::Shell::Fish, 102 | ShellWrapper::Elvish => cc::Shell::Elvish, 103 | ShellWrapper::PowerShell => cc::Shell::PowerShell, 104 | } 105 | } 106 | } 107 | 108 | /// Commands related to the configuration 109 | #[derive(Debug, Eq, PartialEq, Clone, Copy, Parser, EnumString)] 110 | #[strum(serialize_all = "snake_case")] 111 | pub enum Command { 112 | /// List the languages that this program was compiled for 113 | List, 114 | 115 | /// Dump the default config to stdout 116 | DumpDefaultConfig, 117 | 118 | /// Generate shell completion scripts for diffsitter 119 | GenCompletion { 120 | /// The shell to generate completion scripts for. 121 | /// 122 | /// This will print the shell completion script to stdout. bash, zsh, and fish are supported. 123 | shell: ShellWrapper, 124 | }, 125 | } 126 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | //! Utilities and definitions for config handling 2 | 3 | use crate::input_processing::TreeSitterProcessor; 4 | use crate::{parse::GrammarConfig, render::RenderConfig}; 5 | use anyhow::{Context, Result}; 6 | use json5 as json; 7 | use log::info; 8 | use serde::{Deserialize, Serialize}; 9 | use std::{ 10 | collections::HashMap, 11 | fs, io, 12 | path::{Path, PathBuf}, 13 | }; 14 | use thiserror::Error; 15 | 16 | #[cfg(target_os = "windows")] 17 | use directories_next::ProjectDirs; 18 | 19 | /// The expected filename for the config file 20 | const CFG_FILE_NAME: &str = "config.json5"; 21 | 22 | /// The config struct for the application 23 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] 24 | #[serde(rename_all = "kebab-case", default)] 25 | pub struct Config { 26 | /// Custom file extension mappings between a file extension and a language 27 | /// 28 | /// These will be merged with the existing defaults, with the user-defined mappings taking 29 | /// precedence. The existing mappings are available at: `parse::FILE_EXTS` and the user can 30 | /// list all available langauges with `diffsitter --cmd list` 31 | pub file_associations: Option>, 32 | 33 | /// Formatting options for display 34 | pub formatting: RenderConfig, 35 | 36 | /// Options for loading 37 | pub grammar: GrammarConfig, 38 | 39 | /// Options for processing tree-sitter input. 40 | pub input_processing: TreeSitterProcessor, 41 | 42 | /// The program to invoke if the given files can not be parsed by the available tree-sitter 43 | /// parsers. 44 | /// 45 | /// This will invoke the program with with the old and new file as arguments, like so: 46 | /// 47 | /// ```sh 48 | /// ${FALLBACK_PROGRAM} ${OLD} ${NEW} 49 | /// ``` 50 | pub fallback_cmd: Option, 51 | } 52 | 53 | /// The possible errors that can arise when attempting to read a config 54 | #[derive(Error, Debug)] 55 | pub enum ReadError { 56 | #[error("The file failed to deserialize")] 57 | DeserializationFailure(#[from] anyhow::Error), 58 | #[error("Failed to read the config file")] 59 | ReadFileFailure(#[from] io::Error), 60 | #[error("Unable to compute the default config file path")] 61 | NoDefault, 62 | } 63 | 64 | impl Config { 65 | /// Read a config from a given filepath, or fall back to the default file paths 66 | /// 67 | /// If a path is supplied, this method will attempt to read the contents of that path and parse 68 | /// it to a string. If a path isn't supplied, the function will attempt to figure out what the 69 | /// default config file path is supposed to be (based on OS conventions, see 70 | /// [`default_config_file_path`]). 71 | /// 72 | /// # Errors 73 | /// 74 | /// This method will return an error if the config cannot be parsed or if no default config 75 | /// exists. 76 | pub fn try_from_file>(path: Option<&P>) -> Result { 77 | // rustc will emit an incorrect warning that this variable isn't used, which is untrue. 78 | // While the variable isn't read *directly*, it is used to store the owned PathBuf from 79 | // `default_config_file_path` so we can use the reference to the variable in `config_fp`. 80 | #[allow(unused_assignments)] 81 | let mut default_config_fp = PathBuf::new(); 82 | 83 | let config_fp = if let Some(p) = path { 84 | p.as_ref() 85 | } else { 86 | default_config_fp = default_config_file_path().map_err(|_| ReadError::NoDefault)?; 87 | default_config_fp.as_ref() 88 | }; 89 | info!("Reading config at {}", config_fp.to_string_lossy()); 90 | let config_contents = fs::read_to_string(config_fp)?; 91 | let config = json::from_str(&config_contents) 92 | .with_context(|| format!("Failed to parse config at {}", config_fp.to_string_lossy())) 93 | .map_err(ReadError::DeserializationFailure)?; 94 | Ok(config) 95 | } 96 | } 97 | 98 | /// Return the default location for the config file (for *nix, Linux and `MacOS`), this will use 99 | /// $`XDG_CONFIG/.config`, where `$XDG_CONFIG` is `$HOME/.config` by default. 100 | #[cfg(not(target_os = "windows"))] 101 | fn default_config_file_path() -> Result { 102 | let xdg_dirs = xdg::BaseDirectories::with_prefix("diffsitter")?; 103 | let file_path = xdg_dirs.place_config_file(CFG_FILE_NAME)?; 104 | Ok(file_path) 105 | } 106 | 107 | /// Return the default location for the config file (for windows), this will use 108 | /// $XDG_CONFIG_HOME/.config, where `$XDG_CONFIG_HOME` is `$HOME/.config` by default. 109 | #[cfg(target_os = "windows")] 110 | fn default_config_file_path() -> Result { 111 | use anyhow::ensure; 112 | 113 | let proj_dirs = ProjectDirs::from("io", "afnan", "diffsitter"); 114 | ensure!(proj_dirs.is_some(), "Was not able to retrieve config path"); 115 | let proj_dirs = proj_dirs.unwrap(); 116 | let mut config_file: PathBuf = proj_dirs.config_dir().into(); 117 | config_file.push(CFG_FILE_NAME); 118 | Ok(config_file) 119 | } 120 | 121 | #[cfg(test)] 122 | mod tests { 123 | use super::*; 124 | use anyhow::Context; 125 | use std::{env, fs::read_dir}; 126 | 127 | #[test] 128 | fn test_sample_config() { 129 | let repo_root = 130 | env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| env::var("BUILD_DIR").unwrap()); 131 | assert!(!repo_root.is_empty()); 132 | let sample_config_path = [repo_root, "assets".into(), "sample_config.json5".into()] 133 | .iter() 134 | .collect::(); 135 | assert!(sample_config_path.exists()); 136 | Config::try_from_file(Some(sample_config_path).as_ref()).unwrap(); 137 | } 138 | 139 | #[test] 140 | fn test_configs() { 141 | let mut test_config_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); 142 | test_config_dir.push("resources/test_configs"); 143 | assert!(test_config_dir.is_dir()); 144 | 145 | for config_file_path in read_dir(test_config_dir).unwrap() { 146 | let config_file_path = config_file_path.unwrap().path(); 147 | let has_correct_ext = if let Some(ext) = config_file_path.extension() { 148 | ext == "json5" 149 | } else { 150 | false 151 | }; 152 | if !config_file_path.is_file() || !has_correct_ext { 153 | continue; 154 | } 155 | // We add the context so if there is an error you'll see the actual deserialization 156 | // error from serde and which file it failed on, which makes for a much more 157 | // informative error message in the test logs. 158 | Config::try_from_file(Some(&config_file_path)) 159 | .with_context(|| { 160 | format!( 161 | "Parsing file {}", 162 | &config_file_path.file_name().unwrap().to_string_lossy() 163 | ) 164 | }) 165 | .unwrap(); 166 | } 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/console_utils.rs: -------------------------------------------------------------------------------- 1 | //! Helper functions for dealing with the terminal 2 | 3 | use console::{set_colors_enabled, set_colors_enabled_stderr}; 4 | use strum::{Display, EnumString}; 5 | 6 | /// Whether the output to the terminal should be colored 7 | #[derive(Debug, Clone, Copy, PartialEq, Eq, EnumString, Display)] 8 | #[strum(serialize_all = "snake_case")] 9 | #[derive(Default)] 10 | pub enum ColorOutputPolicy { 11 | /// Automatically enable color if printing to a TTY, otherwise disable color 12 | #[default] 13 | Auto, 14 | /// Force plaintext output 15 | Off, 16 | /// Force color output 17 | On, 18 | } 19 | 20 | /// Set terminal color settings based on the output policy. 21 | pub fn set_term_colors(setting: ColorOutputPolicy) { 22 | if setting == ColorOutputPolicy::Auto { 23 | return; 24 | } 25 | let colors_enabled = match setting { 26 | ColorOutputPolicy::On => true, 27 | ColorOutputPolicy::Off => false, 28 | ColorOutputPolicy::Auto => { 29 | panic!("Color output policy is auto, this case should have been already handled") 30 | } 31 | }; 32 | set_colors_enabled(colors_enabled); 33 | set_colors_enabled_stderr(colors_enabled); 34 | } 35 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! The supporting library for `diffsitter`. 2 | //! 3 | //! This library is not particularly cohesive or well organized. It exists to support the 4 | //! diffsitter binary and I am open to refactoring it and organizing it better if there's demand. 5 | //! 6 | //! In the meantime, buyer beware. 7 | //! 8 | //! All of the methods used to create diffsitter are here and we have attempted to keep the library 9 | //! at least somewhat sane and organized for our own usage. 10 | 11 | pub mod cli; 12 | pub mod config; 13 | pub mod console_utils; 14 | pub mod diff; 15 | pub mod input_processing; 16 | pub mod neg_idx_vec; 17 | pub mod parse; 18 | pub mod render; 19 | 20 | use anyhow::Result; 21 | use input_processing::VectorData; 22 | use log::{debug, info}; 23 | use parse::GrammarConfig; 24 | use std::{fs, path::PathBuf}; 25 | 26 | /// Create an AST vector from a path 27 | /// 28 | /// This returns an `AstVector` and a pinned struct with the owned data, which the `AstVector` 29 | /// references. 30 | /// 31 | /// `data` is used as an out-parameter. We need some external struct we can reference because the 32 | /// return type references the data in that struct. 33 | /// 34 | /// This returns an anyhow [Result], which is bad practice for a library and will need to be 35 | /// refactored in the future. This method was originally used in the `diffsitter` binary so we 36 | /// didn't feel the need to specify a specific error type. 37 | pub fn generate_ast_vector_data( 38 | path: PathBuf, 39 | file_type: Option<&str>, 40 | grammar_config: &GrammarConfig, 41 | ) -> Result { 42 | let text = fs::read_to_string(&path)?; 43 | let file_name = path.to_string_lossy(); 44 | debug!("Reading {} to string", file_name); 45 | 46 | if let Some(file_type) = file_type { 47 | info!( 48 | "Using user-set filetype \"{}\" for {}", 49 | file_type, file_name 50 | ); 51 | } else { 52 | info!("Will deduce filetype from file extension"); 53 | }; 54 | let tree = parse::parse_file(&path, file_type, grammar_config)?; 55 | Ok(VectorData { text, tree, path }) 56 | } 57 | -------------------------------------------------------------------------------- /src/neg_idx_vec.rs: -------------------------------------------------------------------------------- 1 | //! Negative index vector 2 | //! 3 | //! A Python-style negative index vector. 4 | 5 | use std::ops::{Index, IndexMut}; 6 | 7 | /// A vector that can be indexed with a negative index, like with Python. 8 | /// 9 | /// ```rust 10 | /// use libdiffsitter::neg_idx_vec::NegIdxVec; 11 | /// let v = NegIdxVec::from(vec![1, 2, 3]); 12 | /// let last_negative = v[-1]; 13 | /// let last = v[(v.len() - 1).try_into().unwrap()]; 14 | /// ``` 15 | /// 16 | /// A negative index corresponds to an offset from the end of the vector. 17 | #[derive(Debug, Clone, Eq, PartialEq)] 18 | pub struct NegIdxVec { 19 | /// The underlying vector for the negative index vector 20 | pub data: Vec, 21 | 22 | /// An optional size constraint. Since vectors are dynamically sized, you can define the offset 23 | /// up front rather than infer it from the vector's size. 24 | len: usize, 25 | } 26 | 27 | #[allow(dead_code)] 28 | impl NegIdxVec { 29 | /// Create a negative index vector with a given size. 30 | /// 31 | /// This will create an internal vector and all offsets will be pegged relative to the size of 32 | /// this vector. 33 | /// 34 | /// ```rust 35 | /// use libdiffsitter::neg_idx_vec::NegIdxVec; 36 | /// let v: NegIdxVec = NegIdxVec::new(1, Default::default); 37 | /// ``` 38 | pub fn new(len: usize, f: F) -> Self 39 | where 40 | F: FnMut() -> T, 41 | { 42 | let mut v = Vec::new(); 43 | v.resize_with(len, f); 44 | 45 | Self { data: v, len } 46 | } 47 | 48 | /// Reserve capacity for a number of *additional* elements. 49 | pub fn reserve(&mut self, additional: usize) { 50 | self.data.reserve(additional); 51 | } 52 | 53 | /// Reserve space for exactly `additional` elements. 54 | /// 55 | /// This will not over-allocate. 56 | pub fn reserve_exact(&mut self, additional: usize) { 57 | self.data.reserve_exact(additional); 58 | } 59 | 60 | /// Return the total number of elements the vector can hold without requiring another 61 | /// allocation. 62 | pub fn capacity(&self) -> usize { 63 | self.data.capacity() 64 | } 65 | 66 | /// An internal helper for the indexing methods. 67 | /// 68 | /// This will resolve a potentially negative index to the "real" index that can be used 69 | /// directly with the internal vector. 70 | /// 71 | /// If the index is less zero then the index will be transformed by adding `idx` to the offset 72 | /// so negative indices are relative to the end of the vector. 73 | fn idx_helper(&self, idx: i32) -> usize { 74 | let len: i32 = self.len.try_into().unwrap(); 75 | 76 | let final_index = if idx >= 0 { 77 | idx.try_into().unwrap() 78 | } else { 79 | let offset_idx = len + idx; 80 | debug_assert!(offset_idx >= 0); 81 | offset_idx.try_into().unwrap() 82 | }; 83 | debug_assert!(final_index < len.try_into().unwrap()); 84 | final_index 85 | } 86 | 87 | /// Get the length of the vector 88 | #[must_use] 89 | pub fn len(&self) -> usize { 90 | self.data.len() 91 | } 92 | 93 | /// Returns whether the vector is empty. 94 | #[must_use] 95 | pub fn is_empty(&self) -> bool { 96 | self.data.is_empty() 97 | } 98 | } 99 | 100 | impl From> for NegIdxVec { 101 | fn from(v: Vec) -> Self { 102 | // Need to capture the length before the borrow, and usize is a trivial copy type. 103 | let len = v.len(); 104 | Self { data: v, len } 105 | } 106 | } 107 | 108 | impl FromIterator for NegIdxVec { 109 | fn from_iter>(iter: Iter) -> Self { 110 | let data = Vec::from_iter(iter); 111 | let len = data.len(); 112 | Self { data, len } 113 | } 114 | } 115 | 116 | impl From<&[T]> for NegIdxVec { 117 | fn from(value: &[T]) -> Self { 118 | let v: Vec = Vec::from(value); 119 | let len = v.len(); 120 | Self { data: v, len } 121 | } 122 | } 123 | 124 | impl Default for NegIdxVec { 125 | fn default() -> Self { 126 | Self { 127 | data: Vec::new(), 128 | len: 0, 129 | } 130 | } 131 | } 132 | 133 | impl Index for NegIdxVec { 134 | type Output = T; 135 | 136 | fn index(&self, idx: i32) -> &>::Output { 137 | &self.data[self.idx_helper(idx)] 138 | } 139 | } 140 | 141 | impl IndexMut for NegIdxVec { 142 | fn index_mut(&mut self, idx: i32) -> &mut >::Output { 143 | let offset_idx = self.idx_helper(idx); 144 | &mut self.data[offset_idx] 145 | } 146 | } 147 | 148 | impl IntoIterator for NegIdxVec { 149 | type Item = T; 150 | type IntoIter = std::vec::IntoIter; 151 | 152 | fn into_iter(self) -> Self::IntoIter { 153 | self.data.into_iter() 154 | } 155 | } 156 | 157 | #[cfg(test)] 158 | mod tests { 159 | use super::*; 160 | use pretty_assertions::assert_eq; 161 | use rstest::rstest; 162 | 163 | /// Generate a test vector for test cases 164 | fn test_vector() -> Vec { 165 | (0..100).collect() 166 | } 167 | 168 | #[test] 169 | fn test_negative_indices() { 170 | let vec = test_vector(); 171 | let neg_vec = NegIdxVec::::from(vec.clone()); 172 | 173 | for (idx, &elem) in vec.iter().rev().enumerate() { 174 | assert_eq!(elem, neg_vec[-(idx as i32 + 1)]); 175 | } 176 | } 177 | 178 | #[test] 179 | fn test_positive_indices() { 180 | let vec = test_vector(); 181 | let neg_vec = NegIdxVec::::from(vec.clone()); 182 | 183 | for (idx, &elem) in vec.iter().enumerate() { 184 | assert_eq!(elem, neg_vec[idx as i32]); 185 | } 186 | } 187 | 188 | #[test] 189 | #[should_panic] 190 | fn test_positive_overflow() { 191 | let vec = NegIdxVec::::from(test_vector()); 192 | let _ = vec[vec.len() as i32 + 1]; 193 | } 194 | 195 | #[test] 196 | fn test_is_empty() { 197 | { 198 | let vec = NegIdxVec::::default(); 199 | assert!(vec.is_empty()); 200 | assert_eq!(vec.len(), 0); 201 | } 202 | { 203 | let vec = NegIdxVec::::from(vec![0, 1, 2, 3]); 204 | assert!(!vec.is_empty()); 205 | assert_eq!(vec.len(), 4); 206 | } 207 | } 208 | 209 | #[test] 210 | #[should_panic] 211 | fn test_negative_overflow() { 212 | let vec = NegIdxVec::::from(test_vector()); 213 | let idx = (vec.len() as i32) * -2; 214 | let _ = vec[idx]; 215 | } 216 | 217 | #[rstest] 218 | #[case(1)] 219 | #[case(2)] 220 | #[case(10)] 221 | fn test_create_new_with_size(#[case] size: usize) { 222 | let vec = NegIdxVec::::new(size, Default::default); 223 | assert_eq!(vec.len(), size); 224 | } 225 | 226 | #[rstest] 227 | #[case(1)] 228 | #[case(10)] 229 | #[case(200)] 230 | fn test_reserve_inexact(#[case] additional_elements: usize) { 231 | let mut vec = NegIdxVec::::default(); 232 | assert_eq!(vec.len(), 0); 233 | vec.reserve(additional_elements); 234 | assert!(vec.capacity() >= additional_elements); 235 | } 236 | 237 | #[test] 238 | fn test_create_default() { 239 | let vec = NegIdxVec::::default(); 240 | assert_eq!(vec.len(), 0); 241 | assert!(vec.is_empty()); 242 | } 243 | 244 | #[test] 245 | fn test_into_iter() { 246 | let source_vec: Vec = vec![0, 1, 2, 3, 10, 49]; 247 | let neg_idx_vec: NegIdxVec = NegIdxVec::from(&source_vec[..]); 248 | let collected_vec: Vec = neg_idx_vec.into_iter().collect(); 249 | assert_eq!(source_vec, collected_vec); 250 | } 251 | 252 | #[test] 253 | fn test_from_iter() { 254 | let source_vec: Vec = vec![0, 1, 2, 3, 10, 49]; 255 | let neg_idx_vec = NegIdxVec::from_iter(source_vec.clone().into_iter()); 256 | let extracted_vec: Vec = neg_idx_vec.into_iter().collect(); 257 | assert_eq!(source_vec, extracted_vec); 258 | } 259 | } 260 | -------------------------------------------------------------------------------- /src/render/json.rs: -------------------------------------------------------------------------------- 1 | use super::DisplayData; 2 | use crate::render::Renderer; 3 | use console::Term; 4 | use logging_timer::time; 5 | use serde::{Deserialize, Serialize}; 6 | use std::io::Write; 7 | 8 | /// A renderer that outputs json data about the diff. 9 | /// 10 | /// This can be useful if you want to use `jq` or do some programatic analysis on the results. 11 | #[derive(Serialize, Deserialize, Clone, Eq, PartialEq, Debug, Default)] 12 | pub struct Json { 13 | /// Whether to pretty print the output JSON. 14 | pub pretty_print: bool, 15 | } 16 | 17 | impl Renderer for Json { 18 | fn render( 19 | &self, 20 | writer: &mut dyn Write, 21 | data: &super::DisplayData, 22 | _term_info: Option<&Term>, 23 | ) -> anyhow::Result<()> { 24 | let json_str = self.generate_json_str(data)?; 25 | write!(writer, "{}", &json_str)?; 26 | Ok(()) 27 | } 28 | } 29 | 30 | impl Json { 31 | /// Create a JSON string from the display data. 32 | /// 33 | /// This method handles display options that are set in the config. 34 | #[time("trace")] 35 | fn generate_json_str(&self, data: &DisplayData) -> Result { 36 | if self.pretty_print { 37 | return serde_json::to_string_pretty(data); 38 | } 39 | serde_json::to_string(data) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/render/mod.rs: -------------------------------------------------------------------------------- 1 | //! Utilities and modules related to rendering diff outputs. 2 | //! 3 | //! We have a modular system for displaying diff data to the terminal. Using this system makes it 4 | //! much easier to extend with new formats that people may request. 5 | //! 6 | //! This library defines a fairly minimal interface for renderers: a single trait called 7 | //! `Renderer`. From there implementers are free to do whatever they want with the diff data. 8 | //! 9 | //! This module also defines utilities that may be useful for `Renderer` implementations. 10 | 11 | mod json; 12 | mod unified; 13 | 14 | use self::json::Json; 15 | use crate::diff::RichHunks; 16 | use anyhow::anyhow; 17 | use console::{Color, Style, Term}; 18 | use enum_dispatch::enum_dispatch; 19 | use serde::{Deserialize, Serialize}; 20 | use std::io::Write; 21 | use strum::{self, Display, EnumIter, EnumString, IntoEnumIterator}; 22 | use unified::Unified; 23 | 24 | /// The parameters required to display a diff for a particular document 25 | #[derive(Debug, Clone, PartialEq, Eq, Serialize)] 26 | pub struct DocumentDiffData<'a> { 27 | /// The filename of the document 28 | pub filename: &'a str, 29 | /// The full text of the document 30 | pub text: &'a str, 31 | } 32 | 33 | /// The parameters a [Renderer] instance receives to render a diff. 34 | #[derive(Debug, Clone, PartialEq, Eq, Serialize)] 35 | pub struct DisplayData<'a> { 36 | /// The hunks constituting the diff. 37 | pub hunks: RichHunks<'a>, 38 | /// The parameters that correspond to the old document 39 | pub old: DocumentDiffData<'a>, 40 | /// The parameters that correspond to the new document 41 | pub new: DocumentDiffData<'a>, 42 | } 43 | 44 | #[enum_dispatch] 45 | #[derive(Clone, Eq, PartialEq, Debug, Serialize, Deserialize, Display, EnumIter, EnumString)] 46 | #[strum(serialize_all = "snake_case")] 47 | #[serde(tag = "type", rename_all = "snake_case")] 48 | pub enum Renderers { 49 | Unified, 50 | Json, 51 | } 52 | 53 | impl Default for Renderers { 54 | fn default() -> Self { 55 | Renderers::Unified(Unified::default()) 56 | } 57 | } 58 | 59 | /// An interface that renders given diff data. 60 | #[enum_dispatch(Renderers)] 61 | pub trait Renderer { 62 | /// Render a diff. 63 | /// 64 | /// We use anyhow for errors so errors are free form for implementors, as they are not 65 | /// recoverable. 66 | /// 67 | /// `writer` can be any generic writer - it's not guaranteed that we're writing to a particular sink (could be a 68 | /// pager, stdout, etc). `data` is the data that the renderer needs to display, this has information about the 69 | /// document being written out. `term_info` is an optional reference to a term object that can be used by the 70 | /// renderer to access information about the terminal if the current process is a TTY output. 71 | fn render( 72 | &self, 73 | writer: &mut dyn Write, 74 | data: &DisplayData, 75 | term_info: Option<&Term>, 76 | ) -> anyhow::Result<()>; 77 | } 78 | 79 | /// A copy of the [Color](console::Color) enum so we can serialize using serde, and get around the 80 | /// orphan rule. 81 | #[derive(Debug, PartialEq, Eq, Copy, Clone, Serialize, Deserialize)] 82 | #[serde(remote = "Color", rename_all = "snake_case")] 83 | #[derive(Default)] 84 | enum ColorDef { 85 | Color256(u8), 86 | #[default] 87 | Black, 88 | Red, 89 | Green, 90 | Yellow, 91 | Blue, 92 | Magenta, 93 | Cyan, 94 | White, 95 | } 96 | 97 | impl From for Color { 98 | fn from(c: ColorDef) -> Self { 99 | match c { 100 | ColorDef::Black => Color::Black, 101 | ColorDef::White => Color::White, 102 | ColorDef::Red => Color::Red, 103 | ColorDef::Green => Color::Green, 104 | ColorDef::Yellow => Color::Yellow, 105 | ColorDef::Blue => Color::Blue, 106 | ColorDef::Magenta => Color::Magenta, 107 | ColorDef::Cyan => Color::Cyan, 108 | ColorDef::Color256(c) => Color::Color256(c), 109 | } 110 | } 111 | } 112 | 113 | /// Workaround so we can use the `ColorDef` remote serialization mechanism with optional types 114 | mod opt_color_def { 115 | use super::{Color, ColorDef}; 116 | use serde::{Deserialize, Deserializer, Serialize, Serializer}; 117 | 118 | #[allow(clippy::trivially_copy_pass_by_ref)] 119 | pub fn serialize(value: &Option, serializer: S) -> Result 120 | where 121 | S: Serializer, 122 | { 123 | #[derive(Serialize)] 124 | struct Helper<'a>(#[serde(with = "ColorDef")] &'a Color); 125 | 126 | value.as_ref().map(Helper).serialize(serializer) 127 | } 128 | 129 | pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> 130 | where 131 | D: Deserializer<'de>, 132 | { 133 | #[derive(Deserialize)] 134 | struct Helper(#[serde(with = "ColorDef")] Color); 135 | 136 | let helper = Option::deserialize(deserializer)?; 137 | Ok(helper.map(|Helper(external)| external)) 138 | } 139 | } 140 | 141 | /// A helper function for the serde serializer 142 | /// 143 | /// Due to the shenanigans we're using to serialize the optional color, we need to supply this 144 | /// method so serde can infer a default value for an option when its key is missing. 145 | fn default_option() -> Option { 146 | None 147 | } 148 | 149 | /// The style that applies to regular text in a diff 150 | #[derive(Clone, Debug, PartialEq, Eq)] 151 | struct RegularStyle(Style); 152 | 153 | /// The style that applies to emphasized text in a diff 154 | #[derive(Clone, Debug, PartialEq, Eq)] 155 | struct EmphasizedStyle(Style); 156 | 157 | /// The formatting directives to use with emphasized text in the line of a diff 158 | /// 159 | /// `Bold` is used as the default emphasis strategy between two lines. 160 | #[derive(Debug, PartialEq, EnumString, Serialize, Deserialize, Eq)] 161 | #[strum(serialize_all = "snake_case")] 162 | #[derive(Default)] 163 | pub enum Emphasis { 164 | /// Don't emphasize anything 165 | /// 166 | /// This field exists because the absence of a value implies that the user wants to use the 167 | /// default emphasis strategy. 168 | None, 169 | /// Bold the differences between the two lines for emphasis 170 | #[default] 171 | Bold, 172 | /// Underline the differences between two lines for emphasis 173 | Underline, 174 | /// Use a colored highlight for emphasis 175 | Highlight(HighlightColors), 176 | } 177 | 178 | /// The colors to use when highlighting additions and deletions 179 | #[derive(Debug, Eq, PartialEq, Serialize, Deserialize)] 180 | pub struct HighlightColors { 181 | /// The background color to use with an addition 182 | #[serde(with = "ColorDef")] 183 | pub addition: Color, 184 | /// The background color to use with a deletion 185 | #[serde(with = "ColorDef")] 186 | pub deletion: Color, 187 | } 188 | 189 | impl Default for HighlightColors { 190 | fn default() -> Self { 191 | HighlightColors { 192 | addition: Color::Color256(0), 193 | deletion: Color::Color256(0), 194 | } 195 | } 196 | } 197 | 198 | /// Configurations and templates for different configuration aliases 199 | /// 200 | /// The user can define settings for each renderer as well as custom tags for different renderer 201 | /// configurations. 202 | #[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)] 203 | #[serde(rename_all = "snake_case", default)] 204 | pub struct RenderConfig { 205 | /// The default diff renderer to use. 206 | /// 207 | /// This is used if no renderer is specified at the command line. 208 | default: String, 209 | 210 | unified: unified::Unified, 211 | json: json::Json, 212 | } 213 | 214 | impl Default for RenderConfig { 215 | fn default() -> Self { 216 | let default_renderer = Renderers::default(); 217 | RenderConfig { 218 | default: default_renderer.to_string(), 219 | unified: Unified::default(), 220 | json: Json::default(), 221 | } 222 | } 223 | } 224 | 225 | impl RenderConfig { 226 | /// Get the renderer specified by the given tag. 227 | /// 228 | /// If the tag is not specified this will fall back to the default renderer. This is a 229 | /// relatively expensive operation so it should be used once and the result should be saved. 230 | pub fn get_renderer(self, tag: Option) -> anyhow::Result { 231 | if let Some(t) = tag { 232 | let cand_enum = Renderers::iter().find(|e| e.to_string() == t); 233 | match cand_enum { 234 | None => Err(anyhow!("'{}' is not a valid renderer", &t)), 235 | Some(renderer) => Ok(renderer), 236 | } 237 | } else { 238 | Ok(Renderers::default()) 239 | } 240 | } 241 | } 242 | 243 | #[cfg(test)] 244 | mod tests { 245 | use super::*; 246 | use test_case::test_case; 247 | 248 | #[test_case("unified")] 249 | #[test_case("json")] 250 | fn test_get_renderer_custom_tag(tag: &str) { 251 | let cfg = RenderConfig::default(); 252 | let res = cfg.get_renderer(Some(tag.into())); 253 | assert!(res.is_ok()); 254 | } 255 | 256 | #[test] 257 | fn test_render_config_default_tag() { 258 | let cfg = RenderConfig::default(); 259 | let res = cfg.get_renderer(None); 260 | assert_eq!(res.unwrap(), Renderers::default()); 261 | } 262 | } 263 | -------------------------------------------------------------------------------- /src/render/unified.rs: -------------------------------------------------------------------------------- 1 | use crate::diff::{Hunk, Line, RichHunk, RichHunks}; 2 | use crate::render::{ 3 | default_option, opt_color_def, ColorDef, DisplayData, EmphasizedStyle, RegularStyle, Renderer, 4 | }; 5 | use anyhow::Result; 6 | use console::{Color, Style, Term}; 7 | use log::{debug, error, info}; 8 | use serde::{Deserialize, Serialize}; 9 | use std::{cmp::max, io::Write}; 10 | 11 | /// The ascii separator used after the diff title 12 | const TITLE_SEPARATOR: &str = "="; 13 | 14 | /// The ascii separator used after the hunk title 15 | const HUNK_TITLE_SEPARATOR: &str = "-"; 16 | 17 | /// Something similar to the unified diff format. 18 | /// 19 | /// NOTE: is a huge misnomer because this isn't really a unified diff. 20 | /// 21 | /// The format is 'in-line', where differences from each document are displayed to the terminal in 22 | /// lockstep. 23 | // TODO(afnan): change this name 24 | #[derive(Debug, Serialize, Deserialize, Eq, PartialEq, Clone)] 25 | #[serde(rename_all = "kebab-case")] 26 | pub struct Unified { 27 | pub addition: TextStyle, 28 | pub deletion: TextStyle, 29 | } 30 | 31 | /// Text style options for additions or deleetions. 32 | /// 33 | /// This allows users to define text options like foreground, background colors, etc. 34 | #[derive(Debug, Serialize, Deserialize, Eq, PartialEq, Clone)] 35 | #[serde(rename_all = "kebab-case")] 36 | pub struct TextStyle { 37 | /// The highlight/background color to use with emphasized text 38 | #[serde(with = "opt_color_def", default = "default_option")] 39 | pub highlight: Option, 40 | /// The foreground color to use with un-emphasized text 41 | #[serde(with = "ColorDef")] 42 | pub regular_foreground: Color, 43 | /// The foreground color to use with emphasized text 44 | #[serde(with = "ColorDef")] 45 | pub emphasized_foreground: Color, 46 | /// Whether to bold emphasized text 47 | pub bold: bool, 48 | /// Whether to underline emphasized text 49 | pub underline: bool, 50 | /// The prefix to use with the line 51 | pub prefix: String, 52 | } 53 | 54 | impl Default for Unified { 55 | fn default() -> Self { 56 | Unified { 57 | addition: TextStyle { 58 | regular_foreground: Color::Green, 59 | emphasized_foreground: Color::Green, 60 | highlight: None, 61 | bold: true, 62 | underline: false, 63 | prefix: "+ ".into(), 64 | }, 65 | deletion: TextStyle { 66 | regular_foreground: Color::Red, 67 | emphasized_foreground: Color::Red, 68 | highlight: None, 69 | bold: true, 70 | underline: false, 71 | prefix: "- ".into(), 72 | }, 73 | } 74 | } 75 | } 76 | 77 | /// The formatting directives to use with different types of text in a diff 78 | struct FormattingDirectives<'a> { 79 | /// The formatting to use with normal unchanged text in a diff line 80 | pub regular: RegularStyle, 81 | /// The formatting to use with emphasized text in a diff line 82 | pub emphasis: EmphasizedStyle, 83 | /// The prefix (if any) to use with the line 84 | pub prefix: &'a dyn AsRef, 85 | } 86 | 87 | /// The parameters required to display a diff for a particular document 88 | #[derive(Debug, Clone, PartialEq, Eq)] 89 | pub struct DocumentDiffData<'a> { 90 | /// The filename of the document 91 | pub filename: &'a str, 92 | /// The full text of the document 93 | pub text: &'a str, 94 | } 95 | 96 | /// User supplied parameters that are required to display a diff 97 | #[derive(Debug, Clone, PartialEq, Eq)] 98 | pub struct DisplayParameters<'a> { 99 | /// The hunks constituting the diff. 100 | pub hunks: RichHunks<'a>, 101 | /// The parameters that correspond to the old document 102 | pub old: DocumentDiffData<'a>, 103 | /// The parameters that correspond to the new document 104 | pub new: DocumentDiffData<'a>, 105 | } 106 | 107 | impl<'a> From<&'a TextStyle> for FormattingDirectives<'a> { 108 | fn from(fmt_opts: &'a TextStyle) -> Self { 109 | Self { 110 | regular: fmt_opts.into(), 111 | emphasis: fmt_opts.into(), 112 | prefix: &fmt_opts.prefix, 113 | } 114 | } 115 | } 116 | 117 | impl Renderer for Unified { 118 | fn render( 119 | &self, 120 | writer: &mut dyn Write, 121 | data: &DisplayData, 122 | term_info: Option<&Term>, 123 | ) -> Result<()> { 124 | let DisplayData { hunks, old, new } = &data; 125 | let old_fmt = FormattingDirectives::from(&self.deletion); 126 | let new_fmt = FormattingDirectives::from(&self.addition); 127 | 128 | // We need access to specific line numbers in the text so we can print out text ranges 129 | // within a line. It's more efficient to break up the text by line up-front so we don't 130 | // have to redo that when we print out each line/hunk. 131 | let old_lines: Vec<_> = old.text.lines().collect(); 132 | let new_lines: Vec<_> = new.text.lines().collect(); 133 | 134 | self.print_title( 135 | writer, 136 | old.filename, 137 | new.filename, 138 | &old_fmt, 139 | &new_fmt, 140 | term_info, 141 | )?; 142 | 143 | for hunk_wrapper in &hunks.0 { 144 | match hunk_wrapper { 145 | RichHunk::Old(hunk) => { 146 | self.print_hunk(writer, &old_lines, hunk, &old_fmt)?; 147 | } 148 | RichHunk::New(hunk) => { 149 | self.print_hunk(writer, &new_lines, hunk, &new_fmt)?; 150 | } 151 | } 152 | } 153 | Ok(()) 154 | } 155 | } 156 | 157 | impl Unified { 158 | /// Print the title for the diff 159 | /// 160 | /// This will print the two files being compared. This will also attempt to modify the layout 161 | /// (stacking horizontally or vertically) based on the terminal width. 162 | fn print_title( 163 | &self, 164 | term: &mut dyn Write, 165 | old_fname: &str, 166 | new_fname: &str, 167 | old_fmt: &FormattingDirectives, 168 | new_fmt: &FormattingDirectives, 169 | term_info: Option<&Term>, 170 | ) -> std::io::Result<()> { 171 | // The different ways we can stack the title 172 | #[derive(Debug, Eq, PartialEq, PartialOrd, Ord, strum_macros::Display)] 173 | #[strum(serialize_all = "snake_case")] 174 | enum TitleStack { 175 | Vertical, 176 | Horizontal, 177 | } 178 | let divider = " -> "; 179 | 180 | // We construct the fully horizontal title string. If wider than the terminal, then we 181 | // format another title string that's vertically stacked 182 | let title_len = format!("{old_fname}{divider}{new_fname}").len(); 183 | // Set terminal width equal to the title length if there is no terminal info is available, then the title will 184 | // stack horizontally be default 185 | let term_width = if let Some(term_info) = term_info { 186 | if let Some((_height, width)) = term_info.size_checked() { 187 | width.into() 188 | } else { 189 | title_len 190 | } 191 | } else { 192 | title_len 193 | }; 194 | // We only display the horizontal title format if we know we have enough horizontal space 195 | // to display it. If we can't determine the terminal width, play it safe and default to 196 | // vertical stacking. 197 | let stack_style = if title_len <= term_width { 198 | TitleStack::Horizontal 199 | } else { 200 | TitleStack::Vertical 201 | }; 202 | 203 | info!("Using stack style {} for title", stack_style); 204 | 205 | // Generate a title string and separator based on the stacking style we determined from 206 | // the terminal width 207 | let (styled_title_str, title_sep) = match stack_style { 208 | TitleStack::Horizontal => { 209 | let title_len = old_fname.len() + divider.len() + new_fname.len(); 210 | let styled_title_str = format!( 211 | "{}{}{}", 212 | old_fmt.regular.0.apply_to(old_fname), 213 | divider, 214 | new_fmt.regular.0.apply_to(new_fname) 215 | ); 216 | let title_sep = TITLE_SEPARATOR.repeat(title_len); 217 | (styled_title_str, title_sep) 218 | } 219 | TitleStack::Vertical => { 220 | let title_len = max(old_fname.len(), new_fname.len()); 221 | let styled_title_str = format!( 222 | "{}\n{}", 223 | old_fmt.regular.0.apply_to(old_fname), 224 | new_fmt.regular.0.apply_to(new_fname) 225 | ); 226 | let title_sep = TITLE_SEPARATOR.repeat(title_len); 227 | (styled_title_str, title_sep) 228 | } 229 | }; 230 | writeln!(term, "{styled_title_str}")?; 231 | writeln!(term, "{title_sep}")?; 232 | Ok(()) 233 | } 234 | 235 | /// Print a [hunk](Hunk) to `stdout` 236 | fn print_hunk( 237 | &self, 238 | term: &mut dyn Write, 239 | lines: &[&str], 240 | hunk: &Hunk, 241 | fmt: &FormattingDirectives, 242 | ) -> Result<()> { 243 | debug!( 244 | "Printing hunk (lines {} - {})", 245 | hunk.first_line().unwrap(), 246 | hunk.last_line().unwrap() 247 | ); 248 | self.print_hunk_title(term, hunk, fmt)?; 249 | 250 | for line in &hunk.0 { 251 | let line_index = line.line_index; 252 | // It's find for this to be fatal in debug builds. We want to avoid crashing in 253 | // release. 254 | debug_assert!(line_index < lines.len()); 255 | if line_index >= lines.len() { 256 | error!( 257 | "Received invalid line index {}. Skipping printing this line.", 258 | line_index 259 | ); 260 | continue; 261 | } 262 | let text = lines[line_index]; 263 | debug!("Printing line {}", line_index); 264 | self.print_line(term, text, line, fmt)?; 265 | debug!("End line {}", line_index); 266 | } 267 | debug!( 268 | "End hunk (lines {} - {})", 269 | hunk.first_line().unwrap(), 270 | hunk.last_line().unwrap() 271 | ); 272 | Ok(()) 273 | } 274 | 275 | /// Print the title of a hunk to stdout 276 | /// 277 | /// This will print the line numbers that correspond to the hunk using the color directive for 278 | /// that file, so the user has some context for the text that's being displayed. 279 | fn print_hunk_title( 280 | &self, 281 | term: &mut dyn Write, 282 | hunk: &Hunk, 283 | fmt: &FormattingDirectives, 284 | ) -> Result<()> { 285 | let first_line = hunk.first_line().unwrap(); 286 | let last_line = hunk.last_line().unwrap(); 287 | 288 | // We don't need to display a range `x - x:` since `x:` is terser and clearer 289 | let title_str = if last_line - first_line == 0 { 290 | format!("\n{first_line}:") 291 | } else { 292 | format!("\n{first_line} - {last_line}:") 293 | }; 294 | 295 | debug!("Title string has length of {}", title_str.len()); 296 | 297 | // Note that we need to get rid of whitespace (including newlines) before we can take the 298 | // length of the string, which is why we call `trim()` 299 | let separator = HUNK_TITLE_SEPARATOR.repeat(title_str.trim().len()); 300 | writeln!(term, "{}", fmt.regular.0.apply_to(title_str))?; 301 | writeln!(term, "{separator}")?; 302 | Ok(()) 303 | } 304 | 305 | /// Print a line with edits 306 | /// 307 | /// This is a generic helper method for additions and deletions, since the logic is very 308 | /// similar, they just use different styles. 309 | /// 310 | /// `text` refers to the text that corresponds line number of the given [line](Line). 311 | fn print_line( 312 | &self, 313 | term: &mut dyn Write, 314 | text: &str, 315 | line: &Line, 316 | fmt: &FormattingDirectives, 317 | ) -> Result<()> { 318 | let regular = &fmt.regular.0; 319 | let emphasis = &fmt.emphasis.0; 320 | 321 | // First, we print the prefix to stdout 322 | write!(term, "{}", regular.apply_to(fmt.prefix.as_ref()))?; 323 | 324 | // The number of characters that have been printed out to stdout already. All indices are 325 | // in raw byte offsets, as splitting on graphemes, etc was taken care of when processing 326 | // the AST nodes. 327 | let mut printed_chars = 0; 328 | 329 | // We keep printing ranges until we've covered the entire line 330 | for entry in &line.entries { 331 | // The range of text to emphasize 332 | // TODO(afnan) deal with ranges spanning multiple rows 333 | let emphasis_range = entry.start_position().column..entry.end_position().column; 334 | 335 | // First we need to see if there's any regular text to cover. If the range has a len of 336 | // zero this is a no-op 337 | let regular_range = printed_chars..emphasis_range.start; 338 | let regular_text: String = text[regular_range].into(); 339 | write!(term, "{}", regular.apply_to(®ular_text))?; 340 | 341 | // Need to set the printed_chars marker here because emphasized_text moves the range 342 | printed_chars = emphasis_range.end; 343 | let emphasized_text: String = text[emphasis_range].into(); 344 | write!(term, "{}", emphasis.apply_to(emphasized_text))?; 345 | } 346 | // Finally, print any normal text after the last entry 347 | let remaining_range = printed_chars..text.len(); 348 | let remaining_text: String = text[remaining_range].into(); 349 | writeln!(term, "{}", regular.apply_to(remaining_text))?; 350 | Ok(()) 351 | } 352 | } 353 | 354 | impl From<&TextStyle> for RegularStyle { 355 | fn from(fmt: &TextStyle) -> Self { 356 | let mut style = Style::default(); 357 | style = style.fg(fmt.regular_foreground); 358 | RegularStyle(style) 359 | } 360 | } 361 | 362 | impl From<&TextStyle> for EmphasizedStyle { 363 | fn from(fmt: &TextStyle) -> Self { 364 | let mut style = Style::default(); 365 | style = style.fg(fmt.emphasized_foreground); 366 | 367 | if fmt.bold { 368 | style = style.bold(); 369 | } 370 | 371 | if fmt.underline { 372 | style = style.underlined(); 373 | } 374 | 375 | if let Some(color) = fmt.highlight { 376 | style = style.bg(color); 377 | } 378 | EmphasizedStyle(style) 379 | } 380 | } 381 | -------------------------------------------------------------------------------- /test_data/medium/cpp/a.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | using namespace std; 3 | 4 | int main() 5 | { 6 | char line[150]; 7 | int vowels, consonants, digits, spaces; 8 | 9 | vowels = 10 | 11 | 12 | consonants = digits = spaces = 0; 13 | 14 | cout << "Enter a line of string: "; 15 | cin.getline( 16 | line, 150); 17 | 18 | for(int j = 0; line[j]!='\0'; ++j) 19 | { 20 | if(line[i]=='a' || line[i]=='e' || line[i]=='i' || 21 | line[i]=='o' || line[i]=='u' || line[i]=='A' || 22 | line[i]=='E' || line[i]=='I' || line[i]=='O' || 23 | line[i]=='U') 24 | { 25 | ++vowels; 26 | } 27 | 28 | else if((line[i]>='a'&& line[i]<='z') || (line[i]>='A'&& line[i]<='Z')) 29 | { 30 | ++consonants; 31 | } 32 | 33 | else if(line[i]>='0' && line[i]<='9') 34 | { 35 | ++digits; 36 | } 37 | 38 | else if (line[i]==' ') 39 | { 40 | ++spaces; 41 | } 42 | } 43 | 44 | std::cout << "Vowels: " << vowels << endl; 45 | std::cout << "Consonants: " << consonants << endl; 46 | std::cout << "Digits: " << digits << endl; 47 | std::cout << "White spaces: " << spaces << endl; 48 | 49 | return 0; 50 | } 51 | -------------------------------------------------------------------------------- /test_data/medium/cpp/b.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | using namespace std; 3 | 4 | int main() 5 | { 6 | char line[150]; 7 | int vowels, consonants, digits, spaces; 8 | 9 | vowels = consonants = digits = spaces = 0; 10 | 11 | cout << "Enter a line of string: "; 12 | cin.getline(line, 150); 13 | for(int i = 0; line[i]!='\0'; ++i) { 14 | if(line[i]=='a' || line[i]=='e' || line[i]=='i' || 15 | line[i]=='o' || line[i]=='u' || line[i]=='A' || 16 | line[i]=='E' || line[i]=='I' || line[i]=='O' || 17 | line[i]=='U') { 18 | ++vowels; 19 | } 20 | else if((line[i]>='a'&& line[i]<='z') || (line[i]>='A'&& line[i]<='Z')) { 21 | ++consonants; 22 | } 23 | else if(line[i]>='0' && line[i]<='9') { 24 | ++digits; 25 | } 26 | else if (line[i]==' ') { 27 | ++spaces; 28 | } 29 | } 30 | 31 | cout << "Vowels: " << vowels << endl; 32 | cout << "Consonants: " << consonants << endl; 33 | cout << "Digits: " << digits << endl; 34 | cout << "White spaces: " << spaces << endl; 35 | 36 | return 0; 37 | } 38 | -------------------------------------------------------------------------------- /test_data/medium/rust/a.rs: -------------------------------------------------------------------------------- 1 | struct Sheep { naked: bool, name: &'static str } 2 | 3 | trait Animal { 4 | // Associated function signature; `Self` refers to the implementor type. 5 | fn new(name: &'static str) -> Self; 6 | 7 | // Method signatures; these will return a string. 8 | fn name(&self) -> &'static str; 9 | fn noise(&self) -> &'static str; 10 | 11 | // Traits can provide default method definitions. 12 | fn talk(&self) { 13 | println!("{} says {}", self.name(), self.noise()); 14 | } 15 | } 16 | 17 | impl Sheep { 18 | fn is_naked(&self) -> bool { 19 | self.naked 20 | } 21 | 22 | fn shear(&mut self) { 23 | if self.is_naked() { 24 | // Implementor methods can use the implementor's trait methods. 25 | println!("{} is already naked...", self.name()); 26 | } else { 27 | println!("{} gets a haircut!", self.name); 28 | 29 | self.naked = true; 30 | } 31 | } 32 | } 33 | 34 | // Implement the `Animal` trait for `Sheep`. 35 | impl Animal for Sheep { 36 | // `Self` is the implementor type: `Sheep`. 37 | fn new(name: &'static str) -> Sheep { 38 | Sheep { name: name, naked: false } 39 | } 40 | 41 | fn name(&self) -> &'static str { 42 | self.name 43 | } 44 | 45 | fn noise(&self) -> &'static str { 46 | if self.is_naked() { 47 | "baaaaah?" 48 | } else { 49 | "baaaaah!" 50 | } 51 | } 52 | 53 | // Default trait methods can be overridden. 54 | fn talk(&self) { 55 | // For example, we can add some quiet contemplation. 56 | println!("{} pauses briefly... {}", self.name, self.noise()); 57 | } 58 | } 59 | 60 | fn main() { 61 | // Type annotation is necessary in this case. 62 | let mut dolly: Sheep = Animal::new("Dolly"); 63 | // TODO ^ Try removing the type annotations. 64 | 65 | dolly.talk(); 66 | dolly.shear(); 67 | dolly.talk(); 68 | } -------------------------------------------------------------------------------- /test_data/medium/rust/b.rs: -------------------------------------------------------------------------------- 1 | struct Sheep { 2 | naked: bool, 3 | name: &'static str, 4 | } 5 | 6 | trait Animal { 7 | // Associated function signature; `Self` refers to the implementor type. 8 | fn new(name: &'static str) -> Self; 9 | 10 | // Method signatures; these will return a string. 11 | fn name(&self) -> &'static str; 12 | fn noise(&self) -> &'static str; 13 | 14 | // Traits can provide default method definitions. 15 | fn talk(&self) { 16 | println!("{} says {}", self.name(), self.noise()); 17 | } 18 | } 19 | 20 | impl Sheep { 21 | fn is_naked_fn(&self) -> bool { 22 | self.naked 23 | } 24 | 25 | fn shear(&mut self) { 26 | if self.is_naked() { 27 | // Implementor methods can use the implementor's trait methods. 28 | println!("{} is already naked...", self.name()); 29 | } else { 30 | println!("{} gets a haircut!", self.name); 31 | 32 | self.naked = true; 33 | } 34 | } 35 | } 36 | 37 | // Implement the `Animal` trait for `Sheep`. 38 | impl Animal for Sheep { 39 | // `Self` is the implementor type: `Sheep`. 40 | fn new(name: &'static str) -> Sheep { 41 | Sheep { 42 | name: name, 43 | naked: false, 44 | } 45 | } 46 | 47 | fn name(&self) -> &'static str { 48 | self.name 49 | } 50 | 51 | fn noise(&self) -> &'static str { 52 | if self.is_naked() { 53 | "baaaaah?" 54 | } else { 55 | "baaaaah!" 56 | } 57 | } 58 | 59 | // Default trait methods can be overridden. 60 | fn bleat(&self) { 61 | // For example, we can add some quiet contemplation. 62 | println!("{} beats briefly... {}", self.name, self.noise()); 63 | } 64 | } 65 | 66 | fn main() { 67 | // Type annotation is necessary in this case. 68 | let mut ed: Sheep = Animal::new("Logan"); 69 | // TODO ^ Try removing the type annotations. 70 | 71 | ed.bleat(); 72 | ed.shear(); 73 | ed.bleat(); 74 | } 75 | 76 | -------------------------------------------------------------------------------- /test_data/short/go/a.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "fmt" 4 | 5 | func main() { 6 | b := "string" 7 | fmt.Println(b) 8 | c := "hi" 9 | fmt.Println(c) 10 | } -------------------------------------------------------------------------------- /test_data/short/go/b.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "fmt" 4 | 5 | func main() { 6 | do() 7 | } 8 | 9 | func do() { 10 | b := "string" 11 | fmt.Println(b) 12 | c := "hi" 13 | fmt.Println(c) 14 | } 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /test_data/short/markdown/a.md: -------------------------------------------------------------------------------- 1 | # Heading 1 2 | 3 | This is 4 | a paragraph 5 | 6 | This 7 | is paragraph 2 8 | -------------------------------------------------------------------------------- /test_data/short/markdown/b.md: -------------------------------------------------------------------------------- 1 | # Heading 1 2 | 3 | This is a paragraph 4 | 5 | This 6 | is paragraph 3 7 | -------------------------------------------------------------------------------- /test_data/short/python/a.py: -------------------------------------------------------------------------------- 1 | def main(): 2 | """Hello world 3 | 4 | This is split 5 | by a newline 6 | """ 7 | pass 8 | -------------------------------------------------------------------------------- /test_data/short/python/b.py: -------------------------------------------------------------------------------- 1 | def main(): 2 | """ Hello 3 | world 4 | 5 | This is not split by a newline 6 | """ 7 | 8 | 9 | x = 1 10 | pass 11 | -------------------------------------------------------------------------------- /test_data/short/rust/a.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | let x = 1; 3 | } 4 | 5 | fn add_one { 6 | } 7 | -------------------------------------------------------------------------------- /test_data/short/rust/b.rs: -------------------------------------------------------------------------------- 1 | fn 2 | 3 | 4 | 5 | main 6 | 7 | () 8 | 9 | { 10 | } 11 | 12 | fn addition() { 13 | } 14 | 15 | fn add_two() { 16 | } 17 | -------------------------------------------------------------------------------- /tests/regression_test.rs: -------------------------------------------------------------------------------- 1 | #[cfg(test)] 2 | mod tests { 3 | use insta::assert_snapshot; 4 | use libdiffsitter::{ 5 | diff::{compute_edit_script, DocumentType, Hunk, RichHunks}, 6 | generate_ast_vector_data, 7 | input_processing::{Entry, TreeSitterProcessor}, 8 | parse::GrammarConfig, 9 | }; 10 | use std::path::PathBuf; 11 | use test_case::test_case; 12 | 13 | fn generate_snapshot_entries_string(entries: &[&Entry<'_>]) -> String { 14 | let each_entry: Vec = entries 15 | .iter() 16 | .map(|entry| { 17 | format!( 18 | // Keep the indent so the snapshot diffs are more aesthetically pleasing 19 | " Entry{{'{}', start=({}, {}), end=({}, {})}}", 20 | entry.text, 21 | entry.start_position.row, 22 | entry.start_position.column, 23 | entry.end_position.row, 24 | entry.end_position.column, 25 | ) 26 | }) 27 | .collect(); 28 | each_entry.join("\n") 29 | } 30 | 31 | fn generate_snapshot_hunk_string(hunk: &Hunk) -> String { 32 | hunk.0 33 | .iter() 34 | .map(|line| { 35 | format!( 36 | // Added the newlines so the diffs from `insta` are easier to parse 37 | "Line={{line_index={}, entries=\n[\n{}\n]}}\n", 38 | line.line_index, 39 | &generate_snapshot_entries_string(line.entries.as_slice()) 40 | ) 41 | }) 42 | .collect::>() 43 | .join("\n") 44 | } 45 | 46 | /// Generate a string representation of a rich hunk object so it can be compared using the 47 | /// `insta` snapshot library. 48 | /// 49 | /// We use this instead of the [Debug] representation of the type because the debug 50 | /// representation includes fields like kind_id that are a little more finnicky and break tests 51 | /// often. 52 | fn generate_snapshot_rich_hunks_string(hunks: RichHunks<'_>) -> String { 53 | hunks 54 | .0 55 | .iter() 56 | .map(|document_type| match document_type { 57 | DocumentType::Old(hunk) => format!("Old({})", generate_snapshot_hunk_string(hunk)), 58 | DocumentType::New(hunk) => format!("New({})", generate_snapshot_hunk_string(hunk)), 59 | }) 60 | .collect::>() 61 | .join("\n") 62 | } 63 | 64 | /// Get paths to input files for tests 65 | fn get_test_paths(test_type: &str, test_name: &str, ext: &str) -> (PathBuf, PathBuf) { 66 | let test_data_root = PathBuf::from(format!("./test_data/{test_type}/{test_name}")); 67 | let path_a = test_data_root.join(format!("a.{ext}")); 68 | let path_b = test_data_root.join(format!("b.{ext}")); 69 | assert!( 70 | path_a.exists(), 71 | "test data path {} does not exist", 72 | path_a.to_str().unwrap() 73 | ); 74 | assert!( 75 | path_b.exists(), 76 | "test data path {} does not exist", 77 | path_b.to_str().unwrap() 78 | ); 79 | 80 | (path_a, path_b) 81 | } 82 | 83 | #[test_case("short", "rust", "rs", true, true)] 84 | #[test_case("short", "python", "py", true, true)] 85 | #[test_case("short", "go", "go", true, true)] 86 | #[test_case("medium", "rust", "rs", true, false)] 87 | #[test_case("medium", "rust", "rs", false, false)] 88 | #[test_case("medium", "cpp", "cpp", true, true)] 89 | #[test_case("medium", "cpp", "cpp", false, true)] 90 | #[test_case("short", "markdown", "md", true, true)] 91 | fn diff_hunks_snapshot( 92 | test_type: &str, 93 | name: &str, 94 | ext: &str, 95 | split_graphemes: bool, 96 | strip_whitespace: bool, 97 | ) { 98 | let (path_a, path_b) = get_test_paths(test_type, name, ext); 99 | let config = GrammarConfig::default(); 100 | let ast_data_a = generate_ast_vector_data(path_a, None, &config).unwrap(); 101 | let ast_data_b = generate_ast_vector_data(path_b, None, &config).unwrap(); 102 | 103 | let processor = TreeSitterProcessor { 104 | split_graphemes, 105 | strip_whitespace, 106 | ..Default::default() 107 | }; 108 | 109 | let diff_vec_a = processor.process(&ast_data_a.tree, &ast_data_a.text); 110 | let diff_vec_b = processor.process(&ast_data_b.tree, &ast_data_b.text); 111 | let diff_hunks = compute_edit_script(&diff_vec_a, &diff_vec_b).unwrap(); 112 | 113 | // We have to set the snapshot name manually, otherwise there appear to be threading issues 114 | // and we end up with more snapshot files than there are tests, which cause 115 | // nondeterministic errors. 116 | let snapshot_name = format!("{test_type}_{name}_split_graphemes_{split_graphemes}_strip_whitespace_{strip_whitespace}"); 117 | let snapshot_string = generate_snapshot_rich_hunks_string(diff_hunks); 118 | assert_snapshot!(snapshot_name, snapshot_string); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /tests/snapshots/regression_test__tests__medium_cpp_false.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/regression_test.rs 3 | expression: diff_hunks 4 | --- 5 | RichHunks( 6 | [ 7 | New( 8 | Hunk( 9 | [ 10 | Line { 11 | line_index: 12, 12 | entries: [ 13 | Entry { 14 | reference: {Node identifier (12, 12) - (12, 13)}, 15 | text: "i", 16 | start_position: Point { 17 | row: 12, 18 | column: 12, 19 | }, 20 | end_position: Point { 21 | row: 12, 22 | column: 12, 23 | }, 24 | kind_id: 1, 25 | }, 26 | Entry { 27 | reference: {Node identifier (12, 24) - (12, 25)}, 28 | text: "i", 29 | start_position: Point { 30 | row: 12, 31 | column: 24, 32 | }, 33 | end_position: Point { 34 | row: 12, 35 | column: 24, 36 | }, 37 | kind_id: 1, 38 | }, 39 | Entry { 40 | reference: {Node identifier (12, 36) - (12, 37)}, 41 | text: "i", 42 | start_position: Point { 43 | row: 12, 44 | column: 36, 45 | }, 46 | end_position: Point { 47 | row: 12, 48 | column: 36, 49 | }, 50 | kind_id: 1, 51 | }, 52 | ], 53 | }, 54 | ], 55 | ), 56 | ), 57 | Old( 58 | Hunk( 59 | [ 60 | Line { 61 | line_index: 17, 62 | entries: [ 63 | Entry { 64 | reference: {Node identifier (17, 12) - (17, 13)}, 65 | text: "j", 66 | start_position: Point { 67 | row: 17, 68 | column: 12, 69 | }, 70 | end_position: Point { 71 | row: 17, 72 | column: 12, 73 | }, 74 | kind_id: 1, 75 | }, 76 | Entry { 77 | reference: {Node identifier (17, 24) - (17, 25)}, 78 | text: "j", 79 | start_position: Point { 80 | row: 17, 81 | column: 24, 82 | }, 83 | end_position: Point { 84 | row: 17, 85 | column: 24, 86 | }, 87 | kind_id: 1, 88 | }, 89 | Entry { 90 | reference: {Node identifier (17, 36) - (17, 37)}, 91 | text: "j", 92 | start_position: Point { 93 | row: 17, 94 | column: 36, 95 | }, 96 | end_position: Point { 97 | row: 17, 98 | column: 36, 99 | }, 100 | kind_id: 1, 101 | }, 102 | ], 103 | }, 104 | ], 105 | ), 106 | ), 107 | Old( 108 | Hunk( 109 | [ 110 | Line { 111 | line_index: 43, 112 | entries: [ 113 | Entry { 114 | reference: {Node namespace_identifier (43, 4) - (43, 7)}, 115 | text: "std", 116 | start_position: Point { 117 | row: 43, 118 | column: 4, 119 | }, 120 | end_position: Point { 121 | row: 43, 122 | column: 4, 123 | }, 124 | kind_id: 499, 125 | }, 126 | Entry { 127 | reference: {Node :: (43, 7) - (43, 9)}, 128 | text: "::", 129 | start_position: Point { 130 | row: 43, 131 | column: 7, 132 | }, 133 | end_position: Point { 134 | row: 43, 135 | column: 7, 136 | }, 137 | kind_id: 47, 138 | }, 139 | ], 140 | }, 141 | Line { 142 | line_index: 44, 143 | entries: [ 144 | Entry { 145 | reference: {Node namespace_identifier (44, 4) - (44, 7)}, 146 | text: "std", 147 | start_position: Point { 148 | row: 44, 149 | column: 4, 150 | }, 151 | end_position: Point { 152 | row: 44, 153 | column: 4, 154 | }, 155 | kind_id: 499, 156 | }, 157 | Entry { 158 | reference: {Node :: (44, 7) - (44, 9)}, 159 | text: "::", 160 | start_position: Point { 161 | row: 44, 162 | column: 7, 163 | }, 164 | end_position: Point { 165 | row: 44, 166 | column: 7, 167 | }, 168 | kind_id: 47, 169 | }, 170 | ], 171 | }, 172 | Line { 173 | line_index: 45, 174 | entries: [ 175 | Entry { 176 | reference: {Node namespace_identifier (45, 4) - (45, 7)}, 177 | text: "std", 178 | start_position: Point { 179 | row: 45, 180 | column: 4, 181 | }, 182 | end_position: Point { 183 | row: 45, 184 | column: 4, 185 | }, 186 | kind_id: 499, 187 | }, 188 | Entry { 189 | reference: {Node :: (45, 7) - (45, 9)}, 190 | text: "::", 191 | start_position: Point { 192 | row: 45, 193 | column: 7, 194 | }, 195 | end_position: Point { 196 | row: 45, 197 | column: 7, 198 | }, 199 | kind_id: 47, 200 | }, 201 | ], 202 | }, 203 | Line { 204 | line_index: 46, 205 | entries: [ 206 | Entry { 207 | reference: {Node namespace_identifier (46, 4) - (46, 7)}, 208 | text: "std", 209 | start_position: Point { 210 | row: 46, 211 | column: 4, 212 | }, 213 | end_position: Point { 214 | row: 46, 215 | column: 4, 216 | }, 217 | kind_id: 499, 218 | }, 219 | Entry { 220 | reference: {Node :: (46, 7) - (46, 9)}, 221 | text: "::", 222 | start_position: Point { 223 | row: 46, 224 | column: 7, 225 | }, 226 | end_position: Point { 227 | row: 46, 228 | column: 7, 229 | }, 230 | kind_id: 47, 231 | }, 232 | ], 233 | }, 234 | ], 235 | ), 236 | ), 237 | ], 238 | ) 239 | -------------------------------------------------------------------------------- /tests/snapshots/regression_test__tests__medium_cpp_split_graphames_false_strip_whitespace_true.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/regression_test.rs 3 | expression: diff_hunks 4 | --- 5 | RichHunks( 6 | [ 7 | New( 8 | Hunk( 9 | [ 10 | Line { 11 | line_index: 12, 12 | entries: [ 13 | Entry { 14 | reference: {Node identifier (12, 12) - (12, 13)}, 15 | text: "i", 16 | start_position: Point { 17 | row: 12, 18 | column: 12, 19 | }, 20 | end_position: Point { 21 | row: 12, 22 | column: 12, 23 | }, 24 | kind_id: 1, 25 | }, 26 | Entry { 27 | reference: {Node identifier (12, 24) - (12, 25)}, 28 | text: "i", 29 | start_position: Point { 30 | row: 12, 31 | column: 24, 32 | }, 33 | end_position: Point { 34 | row: 12, 35 | column: 24, 36 | }, 37 | kind_id: 1, 38 | }, 39 | Entry { 40 | reference: {Node identifier (12, 36) - (12, 37)}, 41 | text: "i", 42 | start_position: Point { 43 | row: 12, 44 | column: 36, 45 | }, 46 | end_position: Point { 47 | row: 12, 48 | column: 36, 49 | }, 50 | kind_id: 1, 51 | }, 52 | ], 53 | }, 54 | ], 55 | ), 56 | ), 57 | Old( 58 | Hunk( 59 | [ 60 | Line { 61 | line_index: 17, 62 | entries: [ 63 | Entry { 64 | reference: {Node identifier (17, 12) - (17, 13)}, 65 | text: "j", 66 | start_position: Point { 67 | row: 17, 68 | column: 12, 69 | }, 70 | end_position: Point { 71 | row: 17, 72 | column: 12, 73 | }, 74 | kind_id: 1, 75 | }, 76 | Entry { 77 | reference: {Node identifier (17, 24) - (17, 25)}, 78 | text: "j", 79 | start_position: Point { 80 | row: 17, 81 | column: 24, 82 | }, 83 | end_position: Point { 84 | row: 17, 85 | column: 24, 86 | }, 87 | kind_id: 1, 88 | }, 89 | Entry { 90 | reference: {Node identifier (17, 36) - (17, 37)}, 91 | text: "j", 92 | start_position: Point { 93 | row: 17, 94 | column: 36, 95 | }, 96 | end_position: Point { 97 | row: 17, 98 | column: 36, 99 | }, 100 | kind_id: 1, 101 | }, 102 | ], 103 | }, 104 | ], 105 | ), 106 | ), 107 | Old( 108 | Hunk( 109 | [ 110 | Line { 111 | line_index: 43, 112 | entries: [ 113 | Entry { 114 | reference: {Node namespace_identifier (43, 4) - (43, 7)}, 115 | text: "std", 116 | start_position: Point { 117 | row: 43, 118 | column: 4, 119 | }, 120 | end_position: Point { 121 | row: 43, 122 | column: 4, 123 | }, 124 | kind_id: 478, 125 | }, 126 | Entry { 127 | reference: {Node :: (43, 7) - (43, 9)}, 128 | text: "::", 129 | start_position: Point { 130 | row: 43, 131 | column: 7, 132 | }, 133 | end_position: Point { 134 | row: 43, 135 | column: 7, 136 | }, 137 | kind_id: 46, 138 | }, 139 | ], 140 | }, 141 | Line { 142 | line_index: 44, 143 | entries: [ 144 | Entry { 145 | reference: {Node namespace_identifier (44, 4) - (44, 7)}, 146 | text: "std", 147 | start_position: Point { 148 | row: 44, 149 | column: 4, 150 | }, 151 | end_position: Point { 152 | row: 44, 153 | column: 4, 154 | }, 155 | kind_id: 478, 156 | }, 157 | Entry { 158 | reference: {Node :: (44, 7) - (44, 9)}, 159 | text: "::", 160 | start_position: Point { 161 | row: 44, 162 | column: 7, 163 | }, 164 | end_position: Point { 165 | row: 44, 166 | column: 7, 167 | }, 168 | kind_id: 46, 169 | }, 170 | ], 171 | }, 172 | Line { 173 | line_index: 45, 174 | entries: [ 175 | Entry { 176 | reference: {Node namespace_identifier (45, 4) - (45, 7)}, 177 | text: "std", 178 | start_position: Point { 179 | row: 45, 180 | column: 4, 181 | }, 182 | end_position: Point { 183 | row: 45, 184 | column: 4, 185 | }, 186 | kind_id: 478, 187 | }, 188 | Entry { 189 | reference: {Node :: (45, 7) - (45, 9)}, 190 | text: "::", 191 | start_position: Point { 192 | row: 45, 193 | column: 7, 194 | }, 195 | end_position: Point { 196 | row: 45, 197 | column: 7, 198 | }, 199 | kind_id: 46, 200 | }, 201 | ], 202 | }, 203 | Line { 204 | line_index: 46, 205 | entries: [ 206 | Entry { 207 | reference: {Node namespace_identifier (46, 4) - (46, 7)}, 208 | text: "std", 209 | start_position: Point { 210 | row: 46, 211 | column: 4, 212 | }, 213 | end_position: Point { 214 | row: 46, 215 | column: 4, 216 | }, 217 | kind_id: 478, 218 | }, 219 | Entry { 220 | reference: {Node :: (46, 7) - (46, 9)}, 221 | text: "::", 222 | start_position: Point { 223 | row: 46, 224 | column: 7, 225 | }, 226 | end_position: Point { 227 | row: 46, 228 | column: 7, 229 | }, 230 | kind_id: 46, 231 | }, 232 | ], 233 | }, 234 | ], 235 | ), 236 | ), 237 | ], 238 | ) 239 | -------------------------------------------------------------------------------- /tests/snapshots/regression_test__tests__medium_cpp_split_graphemes_false_strip_whitespace_true.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/regression_test.rs 3 | expression: snapshot_string 4 | --- 5 | New(Line={line_index=12, entries= 6 | [ 7 | Entry{'i', start=(12, 12), end=(12, 12)} 8 | Entry{'i', start=(12, 24), end=(12, 24)} 9 | Entry{'i', start=(12, 36), end=(12, 36)} 10 | ]} 11 | ) 12 | Old(Line={line_index=17, entries= 13 | [ 14 | Entry{'j', start=(17, 12), end=(17, 12)} 15 | Entry{'j', start=(17, 24), end=(17, 24)} 16 | Entry{'j', start=(17, 36), end=(17, 36)} 17 | ]} 18 | ) 19 | Old(Line={line_index=43, entries= 20 | [ 21 | Entry{'std', start=(43, 4), end=(43, 4)} 22 | Entry{'::', start=(43, 7), end=(43, 7)} 23 | ]} 24 | 25 | Line={line_index=44, entries= 26 | [ 27 | Entry{'std', start=(44, 4), end=(44, 4)} 28 | Entry{'::', start=(44, 7), end=(44, 7)} 29 | ]} 30 | 31 | Line={line_index=45, entries= 32 | [ 33 | Entry{'std', start=(45, 4), end=(45, 4)} 34 | Entry{'::', start=(45, 7), end=(45, 7)} 35 | ]} 36 | 37 | Line={line_index=46, entries= 38 | [ 39 | Entry{'std', start=(46, 4), end=(46, 4)} 40 | Entry{'::', start=(46, 7), end=(46, 7)} 41 | ]} 42 | ) 43 | -------------------------------------------------------------------------------- /tests/snapshots/regression_test__tests__medium_cpp_split_graphemes_true_strip_whitespace_true.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/regression_test.rs 3 | expression: snapshot_string 4 | --- 5 | New(Line={line_index=12, entries= 6 | [ 7 | Entry{'i', start=(12, 12), end=(12, 13)} 8 | Entry{'i', start=(12, 24), end=(12, 25)} 9 | Entry{'i', start=(12, 36), end=(12, 37)} 10 | ]} 11 | ) 12 | Old(Line={line_index=17, entries= 13 | [ 14 | Entry{'j', start=(17, 12), end=(17, 13)} 15 | Entry{'j', start=(17, 24), end=(17, 25)} 16 | Entry{'j', start=(17, 36), end=(17, 37)} 17 | ]} 18 | ) 19 | Old(Line={line_index=43, entries= 20 | [ 21 | Entry{'s', start=(43, 4), end=(43, 5)} 22 | Entry{'t', start=(43, 5), end=(43, 6)} 23 | Entry{'d', start=(43, 6), end=(43, 7)} 24 | Entry{':', start=(43, 7), end=(43, 8)} 25 | Entry{':', start=(43, 8), end=(43, 9)} 26 | ]} 27 | 28 | Line={line_index=44, entries= 29 | [ 30 | Entry{'s', start=(44, 4), end=(44, 5)} 31 | Entry{'t', start=(44, 5), end=(44, 6)} 32 | Entry{'d', start=(44, 6), end=(44, 7)} 33 | Entry{':', start=(44, 7), end=(44, 8)} 34 | Entry{':', start=(44, 8), end=(44, 9)} 35 | ]} 36 | 37 | Line={line_index=45, entries= 38 | [ 39 | Entry{'s', start=(45, 4), end=(45, 5)} 40 | Entry{'t', start=(45, 5), end=(45, 6)} 41 | Entry{'d', start=(45, 6), end=(45, 7)} 42 | Entry{':', start=(45, 7), end=(45, 8)} 43 | Entry{':', start=(45, 8), end=(45, 9)} 44 | ]} 45 | 46 | Line={line_index=46, entries= 47 | [ 48 | Entry{'s', start=(46, 4), end=(46, 5)} 49 | Entry{'t', start=(46, 5), end=(46, 6)} 50 | Entry{'d', start=(46, 6), end=(46, 7)} 51 | Entry{':', start=(46, 7), end=(46, 8)} 52 | Entry{':', start=(46, 8), end=(46, 9)} 53 | ]} 54 | ) 55 | -------------------------------------------------------------------------------- /tests/snapshots/regression_test__tests__medium_rust_split_graphemes_false_strip_whitespace_false.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/regression_test.rs 3 | expression: snapshot_string 4 | --- 5 | New(Line={line_index=2, entries= 6 | [ 7 | Entry{',', start=(2, 26), end=(2, 26)} 8 | ]} 9 | ) 10 | New(Line={line_index=20, entries= 11 | [ 12 | Entry{'is_naked_fn', start=(20, 4), end=(20, 4)} 13 | ]} 14 | ) 15 | Old(Line={line_index=17, entries= 16 | [ 17 | Entry{'is_naked', start=(17, 7), end=(17, 7)} 18 | ]} 19 | ) 20 | New(Line={line_index=42, entries= 21 | [ 22 | Entry{',', start=(42, 19), end=(42, 19)} 23 | ]} 24 | ) 25 | New(Line={line_index=59, entries= 26 | [ 27 | Entry{'bleat', start=(59, 4), end=(59, 4)} 28 | ]} 29 | ) 30 | Old(Line={line_index=53, entries= 31 | [ 32 | Entry{'talk', start=(53, 7), end=(53, 7)} 33 | ]} 34 | ) 35 | New(Line={line_index=61, entries= 36 | [ 37 | Entry{'{} beats briefly... {}', start=(61, 12), end=(61, 12)} 38 | ]} 39 | ) 40 | Old(Line={line_index=55, entries= 41 | [ 42 | Entry{'{} pauses briefly... {}', start=(55, 18), end=(55, 18)} 43 | ]} 44 | ) 45 | New(Line={line_index=67, entries= 46 | [ 47 | Entry{'ed', start=(67, 9), end=(67, 9)} 48 | Entry{'Logan', start=(67, 34), end=(67, 34)} 49 | ]} 50 | ) 51 | Old(Line={line_index=61, entries= 52 | [ 53 | Entry{'dolly', start=(61, 12), end=(61, 12)} 54 | Entry{'Dolly', start=(61, 40), end=(61, 40)} 55 | ]} 56 | ) 57 | New(Line={line_index=70, entries= 58 | [ 59 | Entry{'ed', start=(70, 1), end=(70, 1)} 60 | Entry{'bleat', start=(70, 4), end=(70, 4)} 61 | ]} 62 | 63 | Line={line_index=71, entries= 64 | [ 65 | Entry{'ed', start=(71, 1), end=(71, 1)} 66 | ]} 67 | 68 | Line={line_index=72, entries= 69 | [ 70 | Entry{'ed', start=(72, 1), end=(72, 1)} 71 | Entry{'bleat', start=(72, 4), end=(72, 4)} 72 | ]} 73 | ) 74 | Old(Line={line_index=64, entries= 75 | [ 76 | Entry{'dolly', start=(64, 4), end=(64, 4)} 77 | Entry{'talk', start=(64, 10), end=(64, 10)} 78 | ]} 79 | 80 | Line={line_index=65, entries= 81 | [ 82 | Entry{'dolly', start=(65, 4), end=(65, 4)} 83 | ]} 84 | 85 | Line={line_index=66, entries= 86 | [ 87 | Entry{'dolly', start=(66, 4), end=(66, 4)} 88 | Entry{'talk', start=(66, 10), end=(66, 10)} 89 | ]} 90 | ) 91 | -------------------------------------------------------------------------------- /tests/snapshots/regression_test__tests__medium_rust_split_graphemes_true_strip_whitespace_false.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/regression_test.rs 3 | expression: snapshot_string 4 | --- 5 | New(Line={line_index=2, entries= 6 | [ 7 | Entry{',', start=(2, 26), end=(2, 27)} 8 | ]} 9 | ) 10 | New(Line={line_index=20, entries= 11 | [ 12 | Entry{'_', start=(20, 12), end=(20, 13)} 13 | Entry{'f', start=(20, 13), end=(20, 14)} 14 | Entry{'n', start=(20, 14), end=(20, 15)} 15 | ]} 16 | ) 17 | New(Line={line_index=42, entries= 18 | [ 19 | Entry{',', start=(42, 19), end=(42, 20)} 20 | ]} 21 | ) 22 | New(Line={line_index=59, entries= 23 | [ 24 | Entry{'b', start=(59, 4), end=(59, 5)} 25 | Entry{'l', start=(59, 5), end=(59, 6)} 26 | Entry{'e', start=(59, 6), end=(59, 7)} 27 | Entry{'a', start=(59, 7), end=(59, 8)} 28 | ]} 29 | ) 30 | Old(Line={line_index=53, entries= 31 | [ 32 | Entry{'a', start=(53, 8), end=(53, 9)} 33 | Entry{'l', start=(53, 9), end=(53, 10)} 34 | Entry{'k', start=(53, 10), end=(53, 11)} 35 | ]} 36 | ) 37 | Old(Line={line_index=55, entries= 38 | [ 39 | Entry{'p', start=(55, 21), end=(55, 22)} 40 | Entry{'u', start=(55, 23), end=(55, 24)} 41 | Entry{'s', start=(55, 24), end=(55, 25)} 42 | Entry{'e', start=(55, 25), end=(55, 26)} 43 | ]} 44 | ) 45 | New(Line={line_index=61, entries= 46 | [ 47 | Entry{'b', start=(61, 15), end=(61, 16)} 48 | Entry{'e', start=(61, 16), end=(61, 17)} 49 | Entry{'t', start=(61, 18), end=(61, 19)} 50 | ]} 51 | ) 52 | New(Line={line_index=67, entries= 53 | [ 54 | Entry{'e', start=(67, 9), end=(67, 10)} 55 | Entry{'d', start=(67, 10), end=(67, 11)} 56 | Entry{'L', start=(67, 34), end=(67, 35)} 57 | Entry{'g', start=(67, 36), end=(67, 37)} 58 | Entry{'a', start=(67, 37), end=(67, 38)} 59 | Entry{'n', start=(67, 38), end=(67, 39)} 60 | ]} 61 | ) 62 | Old(Line={line_index=61, entries= 63 | [ 64 | Entry{'d', start=(61, 12), end=(61, 13)} 65 | Entry{'o', start=(61, 13), end=(61, 14)} 66 | Entry{'l', start=(61, 14), end=(61, 15)} 67 | Entry{'l', start=(61, 15), end=(61, 16)} 68 | Entry{'y', start=(61, 16), end=(61, 17)} 69 | Entry{'D', start=(61, 40), end=(61, 41)} 70 | Entry{'l', start=(61, 42), end=(61, 43)} 71 | Entry{'l', start=(61, 43), end=(61, 44)} 72 | Entry{'y', start=(61, 44), end=(61, 45)} 73 | ]} 74 | ) 75 | New(Line={line_index=70, entries= 76 | [ 77 | Entry{'e', start=(70, 1), end=(70, 2)} 78 | Entry{'.', start=(70, 3), end=(70, 4)} 79 | Entry{'b', start=(70, 4), end=(70, 5)} 80 | Entry{'l', start=(70, 5), end=(70, 6)} 81 | Entry{'e', start=(70, 6), end=(70, 7)} 82 | Entry{'a', start=(70, 7), end=(70, 8)} 83 | ]} 84 | 85 | Line={line_index=71, entries= 86 | [ 87 | Entry{'e', start=(71, 1), end=(71, 2)} 88 | Entry{'d', start=(71, 2), end=(71, 3)} 89 | ]} 90 | 91 | Line={line_index=72, entries= 92 | [ 93 | Entry{'e', start=(72, 1), end=(72, 2)} 94 | Entry{'.', start=(72, 3), end=(72, 4)} 95 | Entry{'b', start=(72, 4), end=(72, 5)} 96 | Entry{'l', start=(72, 5), end=(72, 6)} 97 | Entry{'e', start=(72, 6), end=(72, 7)} 98 | Entry{'a', start=(72, 7), end=(72, 8)} 99 | ]} 100 | ) 101 | Old(Line={line_index=64, entries= 102 | [ 103 | Entry{'o', start=(64, 5), end=(64, 6)} 104 | Entry{'l', start=(64, 6), end=(64, 7)} 105 | Entry{'l', start=(64, 7), end=(64, 8)} 106 | Entry{'y', start=(64, 8), end=(64, 9)} 107 | Entry{'.', start=(64, 9), end=(64, 10)} 108 | Entry{'a', start=(64, 11), end=(64, 12)} 109 | Entry{'l', start=(64, 12), end=(64, 13)} 110 | Entry{'k', start=(64, 13), end=(64, 14)} 111 | ]} 112 | 113 | Line={line_index=65, entries= 114 | [ 115 | Entry{'d', start=(65, 4), end=(65, 5)} 116 | Entry{'o', start=(65, 5), end=(65, 6)} 117 | Entry{'l', start=(65, 6), end=(65, 7)} 118 | Entry{'l', start=(65, 7), end=(65, 8)} 119 | Entry{'y', start=(65, 8), end=(65, 9)} 120 | ]} 121 | 122 | Line={line_index=66, entries= 123 | [ 124 | Entry{'o', start=(66, 5), end=(66, 6)} 125 | Entry{'l', start=(66, 6), end=(66, 7)} 126 | Entry{'l', start=(66, 7), end=(66, 8)} 127 | Entry{'y', start=(66, 8), end=(66, 9)} 128 | Entry{'.', start=(66, 9), end=(66, 10)} 129 | Entry{'a', start=(66, 11), end=(66, 12)} 130 | Entry{'l', start=(66, 12), end=(66, 13)} 131 | Entry{'k', start=(66, 13), end=(66, 14)} 132 | ]} 133 | ) 134 | -------------------------------------------------------------------------------- /tests/snapshots/regression_test__tests__short_go_split_graphames_true_strip_whitespace_true.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/regression_test.rs 3 | expression: diff_hunks 4 | --- 5 | RichHunks( 6 | [ 7 | New( 8 | Hunk( 9 | [ 10 | Line { 11 | line_index: 5, 12 | entries: [ 13 | Entry { 14 | reference: {Node identifier (5, 1) - (5, 3)}, 15 | text: "d", 16 | start_position: Point { 17 | row: 5, 18 | column: 1, 19 | }, 20 | end_position: Point { 21 | row: 5, 22 | column: 2, 23 | }, 24 | kind_id: 1, 25 | }, 26 | Entry { 27 | reference: {Node identifier (5, 1) - (5, 3)}, 28 | text: "o", 29 | start_position: Point { 30 | row: 5, 31 | column: 2, 32 | }, 33 | end_position: Point { 34 | row: 5, 35 | column: 3, 36 | }, 37 | kind_id: 1, 38 | }, 39 | Entry { 40 | reference: {Node ( (5, 3) - (5, 4)}, 41 | text: "(", 42 | start_position: Point { 43 | row: 5, 44 | column: 3, 45 | }, 46 | end_position: Point { 47 | row: 5, 48 | column: 4, 49 | }, 50 | kind_id: 9, 51 | }, 52 | Entry { 53 | reference: {Node ) (5, 4) - (5, 5)}, 54 | text: ")", 55 | start_position: Point { 56 | row: 5, 57 | column: 4, 58 | }, 59 | end_position: Point { 60 | row: 5, 61 | column: 5, 62 | }, 63 | kind_id: 10, 64 | }, 65 | ], 66 | }, 67 | Line { 68 | line_index: 6, 69 | entries: [ 70 | Entry { 71 | reference: {Node } (6, 0) - (6, 1)}, 72 | text: "}", 73 | start_position: Point { 74 | row: 6, 75 | column: 0, 76 | }, 77 | end_position: Point { 78 | row: 6, 79 | column: 1, 80 | }, 81 | kind_id: 25, 82 | }, 83 | ], 84 | }, 85 | ], 86 | ), 87 | ), 88 | New( 89 | Hunk( 90 | [ 91 | Line { 92 | line_index: 8, 93 | entries: [ 94 | Entry { 95 | reference: {Node func (8, 0) - (8, 4)}, 96 | text: "f", 97 | start_position: Point { 98 | row: 8, 99 | column: 0, 100 | }, 101 | end_position: Point { 102 | row: 8, 103 | column: 1, 104 | }, 105 | kind_id: 15, 106 | }, 107 | Entry { 108 | reference: {Node func (8, 0) - (8, 4)}, 109 | text: "u", 110 | start_position: Point { 111 | row: 8, 112 | column: 1, 113 | }, 114 | end_position: Point { 115 | row: 8, 116 | column: 2, 117 | }, 118 | kind_id: 15, 119 | }, 120 | Entry { 121 | reference: {Node func (8, 0) - (8, 4)}, 122 | text: "n", 123 | start_position: Point { 124 | row: 8, 125 | column: 2, 126 | }, 127 | end_position: Point { 128 | row: 8, 129 | column: 3, 130 | }, 131 | kind_id: 15, 132 | }, 133 | Entry { 134 | reference: {Node func (8, 0) - (8, 4)}, 135 | text: "c", 136 | start_position: Point { 137 | row: 8, 138 | column: 3, 139 | }, 140 | end_position: Point { 141 | row: 8, 142 | column: 4, 143 | }, 144 | kind_id: 15, 145 | }, 146 | Entry { 147 | reference: {Node identifier (8, 5) - (8, 7)}, 148 | text: "d", 149 | start_position: Point { 150 | row: 8, 151 | column: 5, 152 | }, 153 | end_position: Point { 154 | row: 8, 155 | column: 6, 156 | }, 157 | kind_id: 1, 158 | }, 159 | Entry { 160 | reference: {Node identifier (8, 5) - (8, 7)}, 161 | text: "o", 162 | start_position: Point { 163 | row: 8, 164 | column: 6, 165 | }, 166 | end_position: Point { 167 | row: 8, 168 | column: 7, 169 | }, 170 | kind_id: 1, 171 | }, 172 | Entry { 173 | reference: {Node ( (8, 7) - (8, 8)}, 174 | text: "(", 175 | start_position: Point { 176 | row: 8, 177 | column: 7, 178 | }, 179 | end_position: Point { 180 | row: 8, 181 | column: 8, 182 | }, 183 | kind_id: 9, 184 | }, 185 | Entry { 186 | reference: {Node ) (8, 8) - (8, 9)}, 187 | text: ")", 188 | start_position: Point { 189 | row: 8, 190 | column: 8, 191 | }, 192 | end_position: Point { 193 | row: 8, 194 | column: 9, 195 | }, 196 | kind_id: 10, 197 | }, 198 | Entry { 199 | reference: {Node { (8, 10) - (8, 11)}, 200 | text: "{", 201 | start_position: Point { 202 | row: 8, 203 | column: 10, 204 | }, 205 | end_position: Point { 206 | row: 8, 207 | column: 11, 208 | }, 209 | kind_id: 24, 210 | }, 211 | ], 212 | }, 213 | ], 214 | ), 215 | ), 216 | ], 217 | ) 218 | -------------------------------------------------------------------------------- /tests/snapshots/regression_test__tests__short_go_split_graphemes_true_strip_whitespace_true.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/regression_test.rs 3 | expression: snapshot_string 4 | --- 5 | New(Line={line_index=5, entries= 6 | [ 7 | Entry{'d', start=(5, 1), end=(5, 2)} 8 | Entry{'o', start=(5, 2), end=(5, 3)} 9 | Entry{'(', start=(5, 3), end=(5, 4)} 10 | Entry{')', start=(5, 4), end=(5, 5)} 11 | ]} 12 | 13 | Line={line_index=6, entries= 14 | [ 15 | Entry{'}', start=(6, 0), end=(6, 1)} 16 | ]} 17 | ) 18 | New(Line={line_index=8, entries= 19 | [ 20 | Entry{'f', start=(8, 0), end=(8, 1)} 21 | Entry{'u', start=(8, 1), end=(8, 2)} 22 | Entry{'n', start=(8, 2), end=(8, 3)} 23 | Entry{'c', start=(8, 3), end=(8, 4)} 24 | Entry{'d', start=(8, 5), end=(8, 6)} 25 | Entry{'o', start=(8, 6), end=(8, 7)} 26 | Entry{'(', start=(8, 7), end=(8, 8)} 27 | Entry{')', start=(8, 8), end=(8, 9)} 28 | Entry{'{', start=(8, 10), end=(8, 11)} 29 | ]} 30 | ) 31 | -------------------------------------------------------------------------------- /tests/snapshots/regression_test__tests__short_go_true.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/regression_test.rs 3 | expression: diff_hunks 4 | --- 5 | RichHunks( 6 | [ 7 | New( 8 | Hunk( 9 | [ 10 | Line { 11 | line_index: 5, 12 | entries: [ 13 | Entry { 14 | reference: {Node identifier (5, 1) - (5, 3)}, 15 | text: "d", 16 | start_position: Point { 17 | row: 5, 18 | column: 1, 19 | }, 20 | end_position: Point { 21 | row: 5, 22 | column: 2, 23 | }, 24 | kind_id: 1, 25 | }, 26 | Entry { 27 | reference: {Node identifier (5, 1) - (5, 3)}, 28 | text: "o", 29 | start_position: Point { 30 | row: 5, 31 | column: 2, 32 | }, 33 | end_position: Point { 34 | row: 5, 35 | column: 3, 36 | }, 37 | kind_id: 1, 38 | }, 39 | Entry { 40 | reference: {Node ( (5, 3) - (5, 4)}, 41 | text: "(", 42 | start_position: Point { 43 | row: 5, 44 | column: 3, 45 | }, 46 | end_position: Point { 47 | row: 5, 48 | column: 4, 49 | }, 50 | kind_id: 9, 51 | }, 52 | Entry { 53 | reference: {Node ) (5, 4) - (5, 5)}, 54 | text: ")", 55 | start_position: Point { 56 | row: 5, 57 | column: 4, 58 | }, 59 | end_position: Point { 60 | row: 5, 61 | column: 5, 62 | }, 63 | kind_id: 10, 64 | }, 65 | ], 66 | }, 67 | Line { 68 | line_index: 6, 69 | entries: [ 70 | Entry { 71 | reference: {Node } (6, 0) - (6, 1)}, 72 | text: "}", 73 | start_position: Point { 74 | row: 6, 75 | column: 0, 76 | }, 77 | end_position: Point { 78 | row: 6, 79 | column: 1, 80 | }, 81 | kind_id: 25, 82 | }, 83 | ], 84 | }, 85 | ], 86 | ), 87 | ), 88 | New( 89 | Hunk( 90 | [ 91 | Line { 92 | line_index: 8, 93 | entries: [ 94 | Entry { 95 | reference: {Node func (8, 0) - (8, 4)}, 96 | text: "f", 97 | start_position: Point { 98 | row: 8, 99 | column: 0, 100 | }, 101 | end_position: Point { 102 | row: 8, 103 | column: 1, 104 | }, 105 | kind_id: 15, 106 | }, 107 | Entry { 108 | reference: {Node func (8, 0) - (8, 4)}, 109 | text: "u", 110 | start_position: Point { 111 | row: 8, 112 | column: 1, 113 | }, 114 | end_position: Point { 115 | row: 8, 116 | column: 2, 117 | }, 118 | kind_id: 15, 119 | }, 120 | Entry { 121 | reference: {Node func (8, 0) - (8, 4)}, 122 | text: "n", 123 | start_position: Point { 124 | row: 8, 125 | column: 2, 126 | }, 127 | end_position: Point { 128 | row: 8, 129 | column: 3, 130 | }, 131 | kind_id: 15, 132 | }, 133 | Entry { 134 | reference: {Node func (8, 0) - (8, 4)}, 135 | text: "c", 136 | start_position: Point { 137 | row: 8, 138 | column: 3, 139 | }, 140 | end_position: Point { 141 | row: 8, 142 | column: 4, 143 | }, 144 | kind_id: 15, 145 | }, 146 | Entry { 147 | reference: {Node identifier (8, 5) - (8, 7)}, 148 | text: "d", 149 | start_position: Point { 150 | row: 8, 151 | column: 5, 152 | }, 153 | end_position: Point { 154 | row: 8, 155 | column: 6, 156 | }, 157 | kind_id: 1, 158 | }, 159 | Entry { 160 | reference: {Node identifier (8, 5) - (8, 7)}, 161 | text: "o", 162 | start_position: Point { 163 | row: 8, 164 | column: 6, 165 | }, 166 | end_position: Point { 167 | row: 8, 168 | column: 7, 169 | }, 170 | kind_id: 1, 171 | }, 172 | Entry { 173 | reference: {Node ( (8, 7) - (8, 8)}, 174 | text: "(", 175 | start_position: Point { 176 | row: 8, 177 | column: 7, 178 | }, 179 | end_position: Point { 180 | row: 8, 181 | column: 8, 182 | }, 183 | kind_id: 9, 184 | }, 185 | Entry { 186 | reference: {Node ) (8, 8) - (8, 9)}, 187 | text: ")", 188 | start_position: Point { 189 | row: 8, 190 | column: 8, 191 | }, 192 | end_position: Point { 193 | row: 8, 194 | column: 9, 195 | }, 196 | kind_id: 10, 197 | }, 198 | Entry { 199 | reference: {Node { (8, 10) - (8, 11)}, 200 | text: "{", 201 | start_position: Point { 202 | row: 8, 203 | column: 10, 204 | }, 205 | end_position: Point { 206 | row: 8, 207 | column: 11, 208 | }, 209 | kind_id: 24, 210 | }, 211 | ], 212 | }, 213 | ], 214 | ), 215 | ), 216 | ], 217 | ) 218 | -------------------------------------------------------------------------------- /tests/snapshots/regression_test__tests__short_markdown_false.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/regression_test.rs 3 | expression: diff_hunks 4 | --- 5 | RichHunks( 6 | [ 7 | New( 8 | Hunk( 9 | [ 10 | Line { 11 | line_index: 2, 12 | entries: [ 13 | Entry { 14 | reference: {Node inline (2, 0) - (2, 19)}, 15 | text: "This is a paragraph", 16 | start_position: Point { 17 | row: 2, 18 | column: 0, 19 | }, 20 | end_position: Point { 21 | row: 2, 22 | column: 0, 23 | }, 24 | kind_id: 203, 25 | }, 26 | ], 27 | }, 28 | ], 29 | ), 30 | ), 31 | New( 32 | Hunk( 33 | [ 34 | Line { 35 | line_index: 4, 36 | entries: [ 37 | Entry { 38 | reference: {Node inline (4, 0) - (5, 14)}, 39 | text: "This \nis paragraph 3", 40 | start_position: Point { 41 | row: 4, 42 | column: 0, 43 | }, 44 | end_position: Point { 45 | row: 4, 46 | column: 0, 47 | }, 48 | kind_id: 203, 49 | }, 50 | ], 51 | }, 52 | ], 53 | ), 54 | ), 55 | Old( 56 | Hunk( 57 | [ 58 | Line { 59 | line_index: 2, 60 | entries: [ 61 | Entry { 62 | reference: {Node inline (2, 0) - (3, 11)}, 63 | text: "This is\na paragraph", 64 | start_position: Point { 65 | row: 2, 66 | column: 0, 67 | }, 68 | end_position: Point { 69 | row: 2, 70 | column: 0, 71 | }, 72 | kind_id: 203, 73 | }, 74 | ], 75 | }, 76 | ], 77 | ), 78 | ), 79 | Old( 80 | Hunk( 81 | [ 82 | Line { 83 | line_index: 5, 84 | entries: [ 85 | Entry { 86 | reference: {Node inline (5, 0) - (6, 14)}, 87 | text: "This \nis paragraph 2", 88 | start_position: Point { 89 | row: 5, 90 | column: 0, 91 | }, 92 | end_position: Point { 93 | row: 5, 94 | column: 0, 95 | }, 96 | kind_id: 203, 97 | }, 98 | ], 99 | }, 100 | ], 101 | ), 102 | ), 103 | ], 104 | ) 105 | -------------------------------------------------------------------------------- /tests/snapshots/regression_test__tests__short_markdown_split_graphemes_false_strip_whitespace_true.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/regression_test.rs 3 | expression: diff_hunks 4 | --- 5 | RichHunks( 6 | [ 7 | New( 8 | Hunk( 9 | [ 10 | Line { 11 | line_index: 2, 12 | entries: [ 13 | Entry { 14 | reference: {Node inline (2, 0) - (2, 19)}, 15 | text: "This is a paragraph", 16 | start_position: Point { 17 | row: 2, 18 | column: 0, 19 | }, 20 | end_position: Point { 21 | row: 2, 22 | column: 0, 23 | }, 24 | kind_id: 203, 25 | }, 26 | ], 27 | }, 28 | ], 29 | ), 30 | ), 31 | New( 32 | Hunk( 33 | [ 34 | Line { 35 | line_index: 4, 36 | entries: [ 37 | Entry { 38 | reference: {Node inline (4, 0) - (5, 14)}, 39 | text: "This \nis paragraph 3", 40 | start_position: Point { 41 | row: 4, 42 | column: 0, 43 | }, 44 | end_position: Point { 45 | row: 4, 46 | column: 0, 47 | }, 48 | kind_id: 203, 49 | }, 50 | ], 51 | }, 52 | ], 53 | ), 54 | ), 55 | Old( 56 | Hunk( 57 | [ 58 | Line { 59 | line_index: 2, 60 | entries: [ 61 | Entry { 62 | reference: {Node inline (2, 0) - (3, 11)}, 63 | text: "This is\na paragraph", 64 | start_position: Point { 65 | row: 2, 66 | column: 0, 67 | }, 68 | end_position: Point { 69 | row: 2, 70 | column: 0, 71 | }, 72 | kind_id: 203, 73 | }, 74 | ], 75 | }, 76 | ], 77 | ), 78 | ), 79 | Old( 80 | Hunk( 81 | [ 82 | Line { 83 | line_index: 5, 84 | entries: [ 85 | Entry { 86 | reference: {Node inline (5, 0) - (6, 14)}, 87 | text: "This \nis paragraph 2", 88 | start_position: Point { 89 | row: 5, 90 | column: 0, 91 | }, 92 | end_position: Point { 93 | row: 5, 94 | column: 0, 95 | }, 96 | kind_id: 203, 97 | }, 98 | ], 99 | }, 100 | ], 101 | ), 102 | ), 103 | ], 104 | ) 105 | -------------------------------------------------------------------------------- /tests/snapshots/regression_test__tests__short_markdown_split_graphemes_true_strip_whitespace_true.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/regression_test.rs 3 | expression: snapshot_string 4 | --- 5 | New(Line={line_index=5, entries= 6 | [ 7 | Entry{'3', start=(5, 13), end=(5, 14)} 8 | ]} 9 | ) 10 | Old(Line={line_index=6, entries= 11 | [ 12 | Entry{'2', start=(6, 13), end=(6, 14)} 13 | ]} 14 | ) 15 | -------------------------------------------------------------------------------- /tests/snapshots/regression_test__tests__short_python_split_graphames_true_strip_whitespace_true.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/regression_test.rs 3 | expression: diff_hunks 4 | --- 5 | RichHunks( 6 | [ 7 | New( 8 | Hunk( 9 | [ 10 | Line { 11 | line_index: 1, 12 | entries: [ 13 | Entry { 14 | reference: {Node string_content (1, 7) - (3, 4)}, 15 | text: " ", 16 | start_position: Point { 17 | row: 1, 18 | column: 7, 19 | }, 20 | end_position: Point { 21 | row: 1, 22 | column: 8, 23 | }, 24 | kind_id: 210, 25 | }, 26 | ], 27 | }, 28 | Line { 29 | line_index: 2, 30 | entries: [ 31 | Entry { 32 | reference: {Node string_content (1, 7) - (3, 4)}, 33 | text: " ", 34 | start_position: Point { 35 | row: 2, 36 | column: 0, 37 | }, 38 | end_position: Point { 39 | row: 2, 40 | column: 1, 41 | }, 42 | kind_id: 210, 43 | }, 44 | Entry { 45 | reference: {Node string_content (1, 7) - (3, 4)}, 46 | text: " ", 47 | start_position: Point { 48 | row: 2, 49 | column: 1, 50 | }, 51 | end_position: Point { 52 | row: 2, 53 | column: 2, 54 | }, 55 | kind_id: 210, 56 | }, 57 | Entry { 58 | reference: {Node string_content (1, 7) - (3, 4)}, 59 | text: " ", 60 | start_position: Point { 61 | row: 2, 62 | column: 3, 63 | }, 64 | end_position: Point { 65 | row: 2, 66 | column: 4, 67 | }, 68 | kind_id: 210, 69 | }, 70 | ], 71 | }, 72 | ], 73 | ), 74 | ), 75 | New( 76 | Hunk( 77 | [ 78 | Line { 79 | line_index: 6, 80 | entries: [ 81 | Entry { 82 | reference: {Node identifier (6, 4) - (6, 5)}, 83 | text: "x", 84 | start_position: Point { 85 | row: 6, 86 | column: 4, 87 | }, 88 | end_position: Point { 89 | row: 6, 90 | column: 5, 91 | }, 92 | kind_id: 1, 93 | }, 94 | Entry { 95 | reference: {Node = (6, 10) - (6, 11)}, 96 | text: "=", 97 | start_position: Point { 98 | row: 6, 99 | column: 10, 100 | }, 101 | end_position: Point { 102 | row: 6, 103 | column: 11, 104 | }, 105 | kind_id: 47, 106 | }, 107 | Entry { 108 | reference: {Node integer (6, 12) - (6, 13)}, 109 | text: "1", 110 | start_position: Point { 111 | row: 6, 112 | column: 12, 113 | }, 114 | end_position: Point { 115 | row: 6, 116 | column: 13, 117 | }, 118 | kind_id: 92, 119 | }, 120 | ], 121 | }, 122 | ], 123 | ), 124 | ), 125 | ], 126 | ) 127 | -------------------------------------------------------------------------------- /tests/snapshots/regression_test__tests__short_python_split_graphemes_true_strip_whitespace_true.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/regression_test.rs 3 | expression: snapshot_string 4 | --- 5 | New(Line={line_index=4, entries= 6 | [ 7 | Entry{'n', start=(4, 12), end=(4, 13)} 8 | Entry{'o', start=(4, 13), end=(4, 14)} 9 | Entry{'t', start=(4, 14), end=(4, 15)} 10 | ]} 11 | ) 12 | New(Line={line_index=8, entries= 13 | [ 14 | Entry{'x', start=(8, 4), end=(8, 5)} 15 | Entry{'=', start=(8, 10), end=(8, 11)} 16 | Entry{'1', start=(8, 12), end=(8, 13)} 17 | ]} 18 | ) 19 | -------------------------------------------------------------------------------- /tests/snapshots/regression_test__tests__short_rust_split_graphemes_true_strip_whitespace_true.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/regression_test.rs 3 | expression: snapshot_string 4 | --- 5 | New(Line={line_index=9, entries= 6 | [ 7 | Entry{'}', start=(9, 0), end=(9, 1)} 8 | ]} 9 | ) 10 | New(Line={line_index=11, entries= 11 | [ 12 | Entry{'f', start=(11, 0), end=(11, 1)} 13 | Entry{'n', start=(11, 1), end=(11, 2)} 14 | Entry{'a', start=(11, 3), end=(11, 4)} 15 | Entry{'d', start=(11, 4), end=(11, 5)} 16 | Entry{'d', start=(11, 5), end=(11, 6)} 17 | Entry{'i', start=(11, 6), end=(11, 7)} 18 | Entry{'t', start=(11, 7), end=(11, 8)} 19 | Entry{'i', start=(11, 8), end=(11, 9)} 20 | Entry{'o', start=(11, 9), end=(11, 10)} 21 | Entry{'n', start=(11, 10), end=(11, 11)} 22 | Entry{'(', start=(11, 11), end=(11, 12)} 23 | Entry{')', start=(11, 12), end=(11, 13)} 24 | Entry{'{', start=(11, 14), end=(11, 15)} 25 | ]} 26 | ) 27 | Old(Line={line_index=1, entries= 28 | [ 29 | Entry{'l', start=(1, 4), end=(1, 5)} 30 | Entry{'e', start=(1, 5), end=(1, 6)} 31 | Entry{'t', start=(1, 6), end=(1, 7)} 32 | Entry{'x', start=(1, 8), end=(1, 9)} 33 | Entry{'=', start=(1, 10), end=(1, 11)} 34 | Entry{'1', start=(1, 12), end=(1, 13)} 35 | Entry{';', start=(1, 13), end=(1, 14)} 36 | ]} 37 | ) 38 | New(Line={line_index=14, entries= 39 | [ 40 | Entry{'t', start=(14, 7), end=(14, 8)} 41 | Entry{'w', start=(14, 8), end=(14, 9)} 42 | Entry{'(', start=(14, 10), end=(14, 11)} 43 | Entry{')', start=(14, 11), end=(14, 12)} 44 | ]} 45 | ) 46 | Old(Line={line_index=4, entries= 47 | [ 48 | Entry{'n', start=(4, 8), end=(4, 9)} 49 | Entry{'e', start=(4, 9), end=(4, 10)} 50 | ]} 51 | ) 52 | --------------------------------------------------------------------------------