├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug.yml │ ├── documentation.yml │ ├── feature_request.yml │ └── support.yml ├── dependabot.yml ├── pull_request_template.md └── workflows │ ├── changelog.yml │ ├── coverage.yml │ ├── languagetool.yml │ ├── lint.yml │ ├── publish.yml │ ├── release.yml │ ├── rustbench.yml │ ├── rustcheck.yml │ ├── rustdoc.yml │ ├── rustlib.yml │ └── rustmsrv.yml ├── .gitignore ├── .markdownlint.yaml ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE.md ├── README.md ├── RELEASE-PROCESS.md ├── benches ├── bench_main.rs ├── benchmarks │ ├── check_texts.rs │ └── mod.rs ├── large.txt ├── medium.txt └── small.txt ├── docker-compose.yml ├── img └── screenshot.svg ├── rustfmt.toml ├── src ├── api │ ├── check │ │ ├── data_annotations.rs │ │ ├── mod.rs │ │ ├── requests.rs │ │ └── responses.rs │ ├── languages.rs │ ├── mod.rs │ ├── server.rs │ └── words │ │ ├── add.rs │ │ ├── delete.rs │ │ └── mod.rs ├── cli │ ├── check.rs │ ├── completions.rs │ ├── docker.rs │ ├── languages.rs │ ├── mod.rs │ ├── ping.rs │ └── words.rs ├── error.rs ├── lib.rs ├── main.rs └── parsers │ ├── html.rs │ ├── markdown.rs │ ├── mod.rs │ └── typst.rs └── tests ├── cli.rs ├── match_positions.rs ├── sample_files ├── example.html └── example.typ └── snapshots ├── cli__autodetect_html_file.snap ├── cli__autodetect_markdown_file.snap ├── cli__autodetect_markdown_file_contributing.snap └── cli__autodetect_typst_file.snap /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [jeertmans] 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.yml: -------------------------------------------------------------------------------- 1 | name: Bug 2 | description: Report an issue to help improve the project. 3 | labels: bug 4 | title: 'bug: ' 5 | body: 6 | - type: textarea 7 | id: description 8 | attributes: 9 | label: Description 10 | description: A brief description of the question or issue, also include what you tried and what didn't work 11 | validations: 12 | required: true 13 | - type: dropdown 14 | id: cli-or-crate 15 | attributes: 16 | label: Part 17 | description: Which sub-part of LTRS is it related to? 18 | options: 19 | - Executable (CLI) 20 | - Library (crate) 21 | - Both 22 | validations: 23 | required: true 24 | - type: textarea 25 | id: version 26 | attributes: 27 | label: Version 28 | description: Which version of LTRS are you using? You can use `ltrs --version` to get that information. 29 | validations: 30 | required: true 31 | - type: textarea 32 | id: platform 33 | attributes: 34 | label: Platform 35 | description: What is your platform. Linux, macOS, or Windows? 36 | validations: 37 | required: true 38 | - type: textarea 39 | id: screenshots 40 | attributes: 41 | label: Screenshots 42 | description: Please add screenshots if applicable 43 | validations: 44 | required: false 45 | - type: textarea 46 | id: extrainfo 47 | attributes: 48 | label: Additional information 49 | description: Is there anything else we should know about this bug? 50 | validations: 51 | required: false 52 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/documentation.yml: -------------------------------------------------------------------------------- 1 | name: Documentation 2 | description: Ask / Report an issue related to the documentation. 3 | title: 'doc: ' 4 | labels: [bug, docs] 5 | 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: > 10 | **Thank you for wanting to report a problem with LTRS docs!** 11 | 12 | 13 | If the problem seems straightforward, feel free to submit a PR instead! 14 | 15 | 16 | ⚠ 17 | Verify first that your issue is not already reported on GitHub [Issues]. 18 | 19 | 20 | [Issues]: 21 | https://github.com/jeertmans/languagetool-rust/issues 22 | 23 | - type: textarea 24 | attributes: 25 | label: Describe the Issue 26 | description: A clear and concise description of the issue you encountered. 27 | validations: 28 | required: true 29 | 30 | - type: input 31 | attributes: 32 | label: Affected Page 33 | description: Add a link to page with the problem. 34 | validations: 35 | required: true 36 | 37 | - type: dropdown 38 | attributes: 39 | label: Issue Type 40 | description: > 41 | Please select the option in the drop-down. 42 | 43 |
44 | 45 | Issue? 46 | 47 |
48 | options: 49 | - Documentation Enhancement 50 | - Documentation Report 51 | validations: 52 | required: true 53 | 54 | - type: textarea 55 | attributes: 56 | label: Recommended fix or suggestions 57 | description: A clear and concise description of how you want to update it. 58 | validations: 59 | required: false 60 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Have a new idea/feature? Please suggest! 3 | labels: enhancement 4 | title: 'feat: ' 5 | body: 6 | - type: dropdown 7 | id: cli-or-crate 8 | attributes: 9 | label: Part 10 | description: Which sub-part of LTRS is it related to? 11 | options: 12 | - Executable (CLI) 13 | - Library (crate) 14 | - Both 15 | validations: 16 | required: true 17 | - type: textarea 18 | id: description 19 | attributes: 20 | label: Description 21 | description: A brief description of the enhancement you propose, also include what you tried and what worked. 22 | validations: 23 | required: true 24 | - type: textarea 25 | id: screenshots 26 | attributes: 27 | label: Screenshots 28 | description: Please add screenshots if applicable 29 | validations: 30 | required: false 31 | - type: textarea 32 | id: extrainfo 33 | attributes: 34 | label: Additional information 35 | description: Is there anything else we should know about this idea? 36 | validations: 37 | required: false 38 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/support.yml: -------------------------------------------------------------------------------- 1 | name: Question/Help/Support 2 | description: Ask us about LTRS 3 | title: 'question: ' 4 | labels: [help, question] 5 | 6 | body: 7 | - type: textarea 8 | attributes: 9 | label: "Please explain the issue you're experiencing (with as much detail as possible):" 10 | description: > 11 | Please make sure to leave a reference to the document/code you're 12 | referring to. 13 | validations: 14 | required: true 15 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: cargo 4 | directory: / 5 | schedule: 6 | interval: monthly 7 | commit-message: 8 | prefix: 'chore(deps):' 9 | labels: 10 | - dependencies 11 | - rust 12 | - package-ecosystem: github-actions 13 | directory: / 14 | schedule: 15 | interval: monthly 16 | commit-message: 17 | prefix: 'ci(dependabot):' 18 | labels: 19 | - ci 20 | - dependencies 21 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | # Fixes Issue 6 | 7 | 8 | 9 | 10 | 11 | # Description 12 | 13 | 14 | 15 | ## Check List (Check all the applicable boxes) 16 | 17 | - [ ] I understand that my contributions needs to pass the checks. 18 | - [ ] If I created new functions / methods, I documented them and add type hints. 19 | - [ ] If I modified already existing code, I updated the documentation accordingly. 20 | - [ ] The title of my pull request is a short description of the requested changes. 21 | 22 | # Screenshots 23 | 24 | 25 | 26 | ## Note to reviewers 27 | 28 | 29 | -------------------------------------------------------------------------------- /.github/workflows/changelog.yml: -------------------------------------------------------------------------------- 1 | name: Check Changelog 2 | 3 | on: 4 | pull_request: 5 | types: [assigned, opened, synchronize, reopened, labeled, unlabeled] 6 | branches: 7 | - main 8 | jobs: 9 | check-changelog: 10 | name: Check Changelog Action 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: tarides/changelog-check-action@v3 14 | with: 15 | changelog: CHANGELOG.md 16 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | name: Code Coverage 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | jobs: 9 | test: 10 | name: Coverage 11 | runs-on: ubuntu-latest 12 | services: 13 | languagetool: 14 | image: erikvl87/languagetool:latest 15 | ports: 16 | - 8010:8010 17 | env: 18 | langtool_maxTextLength: 1500 19 | Java_Xmx: 2g 20 | env: 21 | LANGUAGETOOL_HOSTNAME: http://localhost 22 | LANGUAGETOOL_PORT: 8010 23 | steps: 24 | - name: Checkout repository 25 | uses: actions/checkout@v4 26 | 27 | - name: Install nightly toolchain 28 | uses: dtolnay/rust-toolchain@nightly 29 | 30 | - name: Cache dependencies 31 | uses: Swatinem/rust-cache@v2 32 | 33 | - name: Install cargo-tarpaulin 34 | uses: taiki-e/install-action@cargo-tarpaulin 35 | 36 | - name: Generate code coverage 37 | run: | 38 | cargo +nightly tarpaulin --verbose --all-features --workspace --timeout 120 --out Xml 39 | 40 | - name: Upload to codecov.io 41 | uses: codecov/codecov-action@v5 42 | with: 43 | token: ${{ secrets.CODECOV_TOKEN }} 44 | fail_ci_if_error: true 45 | -------------------------------------------------------------------------------- /.github/workflows/languagetool.yml: -------------------------------------------------------------------------------- 1 | name: LanguageTool 2 | 3 | on: 4 | pull_request: 5 | workflow_dispatch: 6 | 7 | jobs: 8 | languagetool_check: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout repository 12 | uses: actions/checkout@v4 13 | 14 | - name: Check and report 15 | uses: reviewdog/action-languagetool@v1 16 | with: 17 | reporter: github-pr-review 18 | patterns: '*.md src/**.rs' 19 | level: warning 20 | disabled_rules: WHITESPACE_RULE,EN_UNPAIRED_QUOTES,EN_QUOTES,DASH_RULE,WORD_CONTAINS_UNDERSCORE,UPPERCASE_SENTENCE_START,ARROWS,COMMA_PARENTHESIS_WHITESPACE,UNLIKELY_OPENING_PUNCTUATION,SENTENCE_WHITESPACE,CURRENCY,EN_UNPAIRED_BRACKETS,PHRASE_REPETITION,PUNCTUATION_PARAGRAPH_END,METRIC_UNITS_EN_US,ENGLISH_WORD_REPEAT_BEGINNING_RULE 21 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | # Lint code and (optionally) apply fixes 2 | name: Lint code 3 | 4 | on: 5 | pull_request: 6 | push: 7 | branches: [main] 8 | schedule: 9 | - cron: 0 0 * * 1 # Every monday 10 | workflow_dispatch: 11 | 12 | jobs: 13 | auto-update: 14 | runs-on: ubuntu-latest 15 | if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }} 16 | permissions: 17 | contents: write 18 | pull-requests: write 19 | steps: 20 | - name: Checkout repository 21 | uses: actions/checkout@v4 22 | 23 | - name: Install pre-commit 24 | run: pip install pre-commit 25 | 26 | - name: Run autoupdate 27 | run: pre-commit autoupdate 28 | 29 | - name: Create a pull request with updated versions 30 | uses: peter-evans/create-pull-request@v6 31 | with: 32 | branch: update/pre-commit-hooks 33 | title: 'chore(deps): update pre-commit hooks' 34 | commit-message: 'chore(deps): update pre-commit hooks' 35 | pre-commit: 36 | runs-on: ubuntu-latest 37 | if: ${{ github.event_name != 'schedule' }} 38 | steps: 39 | - name: Checkout repository 40 | uses: actions/checkout@v4 41 | 42 | - name: Install Rust nightly 43 | uses: dtolnay/rust-toolchain@nightly 44 | with: 45 | components: clippy,rustfmt 46 | 47 | - name: Run pre-commit hooks 48 | uses: pre-commit/action@v3.0.1 49 | 50 | - name: Apply fixes when present 51 | uses: pre-commit-ci/lite-action@v1.1.0 52 | if: always() 53 | with: 54 | msg: 'chore(fmt): auto fixes from pre-commit hooks' 55 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | # Pattern matched against refs/tags 4 | tags: 5 | - '*' # Push events to every tag not containing / 6 | pull_request: 7 | workflow_dispatch: 8 | 9 | name: Publish 10 | 11 | jobs: 12 | publish: 13 | name: Publish 14 | runs-on: ubuntu-latest 15 | if: startsWith(github.ref, 'refs/tags/v') 16 | steps: 17 | - name: Checkout sources 18 | uses: actions/checkout@v4 19 | 20 | - name: Install stable toolchain 21 | uses: dtolnay/rust-toolchain@stable 22 | 23 | - name: Publish on crates.io 24 | run: cargo publish --token ${{ secrets.CRATES_TOKEN }} 25 | check-publish: 26 | name: Check Publish 27 | runs-on: ubuntu-latest 28 | if: startsWith(github.ref, 'refs/tags/v') != true 29 | steps: 30 | - name: Checkout sources 31 | uses: actions/checkout@v4 32 | 33 | - name: Install stable toolchain 34 | uses: dtolnay/rust-toolchain@stable 35 | 36 | - name: Check if can publish on crates.io 37 | run: cargo publish --token ${{ secrets.CRATES_TOKEN }} --dry-run -v 38 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | workflow_dispatch: 8 | 9 | jobs: 10 | create-release: 11 | name: create-release 12 | runs-on: ubuntu-latest 13 | # env: 14 | # Set to force version number, e.g., when no tag exists. 15 | # LTRS_VERSION: TEST-0.0.0 16 | outputs: 17 | upload_url: ${{ steps.release.outputs.upload_url }} 18 | ltrs_version: ${{ env.LTRS_VERSION }} 19 | steps: 20 | - name: Get the release version from the tag 21 | shell: bash 22 | if: env.LTRS_VERSION == '' 23 | run: | 24 | # Apparently, this is the right way to get a tag name. Really? 25 | # 26 | # See: https://github.community/t5/GitHub-Actions/How-to-get-just-the-tag-name/m-p/32167/highlight/true#M1027 27 | echo "LTRS_VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV 28 | echo "version is: ${{ env.LTRS_VERSION }}" 29 | - name: Create GitHub release 30 | id: release 31 | uses: actions/create-release@v1 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | with: 35 | tag_name: ${{ env.LTRS_VERSION }} 36 | release_name: ${{ env.LTRS_VERSION }} 37 | draft: true 38 | 39 | build-release: 40 | name: build-release 41 | needs: [create-release] 42 | runs-on: ${{ matrix.os }} 43 | env: 44 | # For some builds, we use cross to test on 32-bit and big-endian 45 | # systems. 46 | CARGO: cargo 47 | # When CARGO is set to CROSS, this is set to `--target matrix.target`. 48 | TARGET_FLAGS: '' 49 | # When CARGO is set to CROSS, TARGET_DIR includes matrix.target. 50 | TARGET_DIR: ./target 51 | # Emit backtraces on panics. 52 | RUST_BACKTRACE: 1 53 | strategy: 54 | matrix: 55 | build: [linux, macos, win-msvc, win-gnu] 56 | include: 57 | - build: linux 58 | os: ubuntu-latest 59 | target: x86_64-unknown-linux-musl 60 | cross: true 61 | - build: macos 62 | os: macos-latest 63 | target: x86_64-apple-darwin 64 | cross: false 65 | - build: win-msvc 66 | os: windows-2019 67 | target: x86_64-pc-windows-msvc 68 | cross: false 69 | - build: win-gnu 70 | os: windows-2019 71 | target: x86_64-pc-windows-gnu 72 | cross: false 73 | 74 | steps: 75 | - name: Checkout repository 76 | uses: actions/checkout@v4 77 | 78 | - name: Install stable toolchain 79 | uses: dtolnay/rust-toolchain@stable 80 | with: 81 | target: ${{ matrix.target }} 82 | 83 | - name: Install cross 84 | if: matrix.cross == true 85 | uses: taiki-e/install-action@v2 86 | with: 87 | tool: cross 88 | 89 | - name: Setup variables 90 | shell: bash 91 | run: | 92 | echo "TARGET_FLAGS=--target ${{ matrix.target }}" >> $GITHUB_ENV 93 | echo "TARGET_DIR=./target/${{ matrix.target }}" >> $GITHUB_ENV 94 | 95 | - name: Show command used for Cargo 96 | run: | 97 | echo "target flag is: ${{ env.TARGET_FLAGS }}" 98 | echo "target dir is: ${{ env.TARGET_DIR }}" 99 | 100 | - name: Build release binary (with cross) 101 | if: matrix.cross == true 102 | run: cross build --verbose --release --no-default-features --features full,native-tls-vendored ${{ env.TARGET_FLAGS }} 103 | 104 | - name: Build release binary (with cargo) 105 | if: matrix.cross == false 106 | run: cargo build --verbose --release --no-default-features --features full,native-tls-vendored ${{ env.TARGET_FLAGS }} 107 | 108 | - name: Strip release binary (linux and macos) 109 | if: matrix.build == 'linux' || matrix.build == 'macos' 110 | run: strip "target/${{ matrix.target }}/release/ltrs" 111 | 112 | - name: Build archive 113 | shell: bash 114 | run: | 115 | staging="ltrs-${{ needs.create-release.outputs.ltrs_version }}-${{ matrix.target }}" 116 | mkdir -p "$staging" 117 | cp {README.md,LICENSE.md} "$staging/" 118 | if [ "${{ matrix.os }}" = "windows-2019" ]; then 119 | cp "target/${{ matrix.target }}/release/ltrs.exe" "$staging/" 120 | 7z a "$staging.zip" "$staging" 121 | echo "ASSET=$staging.zip" >> $GITHUB_ENV 122 | else 123 | cp "target/${{ matrix.target }}/release/ltrs" "$staging/" 124 | tar czf "$staging.tar.gz" "$staging" 125 | echo "ASSET=$staging.tar.gz" >> $GITHUB_ENV 126 | fi 127 | - name: Upload release archive 128 | uses: actions/upload-release-asset@v1.0.1 129 | env: 130 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 131 | with: 132 | upload_url: ${{ needs.create-release.outputs.upload_url }} 133 | asset_path: ${{ env.ASSET }} 134 | asset_name: ${{ env.ASSET }} 135 | asset_content_type: application/octet-stream 136 | -------------------------------------------------------------------------------- /.github/workflows/rustbench.yml: -------------------------------------------------------------------------------- 1 | on: 2 | pull_request: 3 | paths: 4 | - '**.rs' 5 | - .github/workflows/rustbench.yml 6 | - '**/Cargo.toml' 7 | push: 8 | branches: [main] 9 | workflow_dispatch: 10 | 11 | name: Benchmark 12 | 13 | permissions: 14 | pull-requests: write 15 | 16 | jobs: 17 | benchmark: 18 | runs-on: ubuntu-latest 19 | services: 20 | languagetool: 21 | image: erikvl87/languagetool:latest 22 | ports: 23 | - 8010:8010 24 | env: 25 | Java_Xms: 512m 26 | Java_Xmx: 2g 27 | env: 28 | LANGUAGETOOL_HOSTNAME: http://localhost 29 | LANGUAGETOOL_PORT: 8010 30 | steps: 31 | - uses: actions/checkout@v4 32 | 33 | - name: Setup rust toolchain, cache and cargo-codspeed binary 34 | uses: moonrepo/setup-rust@v1 35 | with: 36 | channel: stable 37 | cache-target: release 38 | bins: cargo-codspeed 39 | 40 | - name: Build the benchmark target(s) 41 | run: cargo codspeed build 42 | 43 | - name: Run the benchmarks 44 | uses: CodSpeedHQ/action@v3 45 | with: 46 | run: cargo codspeed run 47 | -------------------------------------------------------------------------------- /.github/workflows/rustcheck.yml: -------------------------------------------------------------------------------- 1 | on: 2 | pull_request: 3 | paths: 4 | - '**.rs' 5 | - Cargo.toml 6 | workflow_dispatch: 7 | 8 | name: Cargo check 9 | 10 | jobs: 11 | cargo_check: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: taiki-e/install-action@cargo-hack 16 | - run: > 17 | cargo hack check 18 | --feature-powerset 19 | --no-dev-deps 20 | --clean-per-run 21 | --group-features cli-complete,docker 22 | --group-features typst,html,markdown 23 | --mutually-exclusive-features native-tls,native-tls-vendored 24 | --exclude-features snapshots 25 | -------------------------------------------------------------------------------- /.github/workflows/rustdoc.yml: -------------------------------------------------------------------------------- 1 | on: 2 | pull_request: 3 | paths: 4 | - src/**.rs 5 | - Cargo.toml 6 | workflow_dispatch: 7 | 8 | name: Rustdoc 9 | 10 | jobs: 11 | publish: 12 | name: Publish 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout sources 16 | uses: actions/checkout@v4 17 | 18 | - name: Install nightly toolchain 19 | uses: dtolnay/rust-toolchain@nightly 20 | 21 | - name: Cache dependencies 22 | uses: Swatinem/rust-cache@v2 23 | 24 | - name: Check rustdoc build 25 | run: RUSTDOCFLAGS='--cfg docsrs' cargo +nightly doc --all-features -Zunstable-options -Zrustdoc-scrape-examples 26 | -------------------------------------------------------------------------------- /.github/workflows/rustlib.yml: -------------------------------------------------------------------------------- 1 | on: 2 | pull_request: 3 | paths: 4 | - '**.rs' 5 | - .github/workflows/rustlib.yml 6 | - Cargo.toml 7 | workflow_dispatch: 8 | 9 | name: Library testing 10 | 11 | jobs: 12 | rustdoc: 13 | name: Rustdoc 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout sources 17 | uses: actions/checkout@v4 18 | 19 | - name: Install nightly toolchain 20 | uses: dtolnay/rust-toolchain@nightly 21 | 22 | - name: Cache dependencies 23 | uses: Swatinem/rust-cache@v2 24 | 25 | - name: Check rustdoc build 26 | run: RUSTDOCFLAGS='--cfg docsrs' cargo +nightly doc --all-features -Zunstable-options -Zrustdoc-scrape-examples 27 | 28 | test: 29 | name: Test 30 | strategy: 31 | fail-fast: false 32 | matrix: 33 | tag: [latest, '5.5', '5.6', '5.7', '5.8', '5.9', '6.0', '6.1', '6.2', '6.3', '6.4', '6.5'] 34 | features: [''] 35 | include: 36 | - tag: latest 37 | features: --all-features 38 | runs-on: ubuntu-latest 39 | services: 40 | languagetool: 41 | image: erikvl87/languagetool:${{ matrix.tag }} 42 | ports: 43 | - 8010:8010 44 | env: 45 | langtool_maxTextLength: 1500 46 | env: 47 | LANGUAGETOOL_HOSTNAME: http://localhost 48 | LANGUAGETOOL_PORT: 8010 49 | steps: 50 | - name: Checkout sources 51 | uses: actions/checkout@v4 52 | 53 | - name: Install stable toolchain 54 | uses: dtolnay/rust-toolchain@stable 55 | 56 | - name: Cache dependencies 57 | uses: Swatinem/rust-cache@v2 58 | 59 | - run: curl -LsSf https://get.nexte.st/latest/linux | tar zxf - -C ${CARGO_HOME:-~/.cargo}/bin 60 | - run: cargo nextest run ${{ matrix.features }} --no-capture 61 | -------------------------------------------------------------------------------- /.github/workflows/rustmsrv.yml: -------------------------------------------------------------------------------- 1 | on: 2 | pull_request: 3 | paths: 4 | - '**.rs' 5 | - Cargo.toml 6 | workflow_dispatch: 7 | 8 | name: MSRV check 9 | 10 | jobs: 11 | msrv_check: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Install stable toolchain 17 | uses: dtolnay/rust-toolchain@stable 18 | 19 | - name: Install Cargo MSRV 20 | uses: taiki-e/install-action@v2 21 | with: 22 | tool: cargo-msrv 23 | 24 | - name: Check MSRV 25 | run: cargo msrv verify -- cargo check --all-features 26 | 27 | - name: Find MSRV if above fail 28 | if: failure() 29 | run: cargo msrv find -- cargo check --all-features 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | 3 | node_modules/ 4 | 5 | package.json 6 | 7 | package-lock.json 8 | -------------------------------------------------------------------------------- /.markdownlint.yaml: -------------------------------------------------------------------------------- 1 | # For more explanations, see: https://github.com/DavidAnson/markdownlint/blob/main/schema/.markdownlint.yaml 2 | 3 | # MD013/line-length - Line length 4 | MD013: false 5 | 6 | # MD024/no-duplicate-heading/no-duplicate-header - Multiple headings with the same content 7 | MD024: false 8 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | autofix_commit_msg: | 3 | chore(fmt): auto fixes from pre-commit.com hooks 4 | 5 | for more information, see https://pre-commit.ci 6 | autoupdate_commit_msg: 'chore(deps): pre-commit autoupdate' 7 | repos: 8 | - repo: https://github.com/pre-commit/pre-commit-hooks 9 | rev: v5.0.0 10 | hooks: 11 | - id: check-yaml 12 | - id: check-toml 13 | - id: end-of-file-fixer 14 | exclude: ^tests/ 15 | - id: trailing-whitespace 16 | exclude: ^tests/ 17 | - repo: https://github.com/igorshubovych/markdownlint-cli 18 | rev: v0.45.0 19 | hooks: 20 | - id: markdownlint-fix 21 | args: [--ignore, LICENSE.md] 22 | - repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks 23 | rev: v2.14.0 24 | hooks: 25 | - id: pretty-format-yaml 26 | args: [--autofix] 27 | - id: pretty-format-toml 28 | exclude: Cargo.lock 29 | args: [--autofix, --trailing-commas] 30 | - repo: https://github.com/doublify/pre-commit-rust 31 | rev: v1.0 32 | hooks: 33 | - id: cargo-check 34 | - id: clippy 35 | - repo: local 36 | hooks: 37 | - id: fmt 38 | name: fmt 39 | description: Format files with cargo fmt 40 | entry: cargo +nightly fmt -- 41 | language: system 42 | types: [rust] 43 | args: [] 44 | - repo: https://github.com/codespell-project/codespell 45 | rev: v2.4.1 46 | hooks: 47 | - id: codespell 48 | args: 49 | - --ignore-words-list 50 | - crate 51 | exclude: | 52 | (?x)( 53 | \.rs$| 54 | ^tests/| 55 | ^benches/ 56 | ) 57 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to `languagetool-rust` 2 | 3 | Thanks for your interest in contributing to `languagetool-rust`! This project aims to provide both (1) a fast, idiomatic Rust client for [LanguageTool](https://languagetool.org/), supporting both HTTP and local servers, and (2) a convenient command-line interface to check your files for grammar mistakes. 4 | 5 | We welcome contributions of all kinds: bug fixes, documentation improvements, feature additions, or performance enhancements. 6 | 7 | ## Table of Contents 8 | 9 | - [Getting Started](#getting-started) 10 | - [Development Guide](#development-guide) 11 | - [Testing](#testing) 12 | - [Documentation](#documentation) 13 | - [Pull Requests](#pull-requests) 14 | 15 | --- 16 | 17 | ## Getting Started 18 | 19 | 1. **Fork the repository** and clone it locally: 20 | 21 | ```bash 22 | git clone https://github.com/your-username/languagetool-rust.git 23 | cd languagetool-rust 24 | ``` 25 | 26 | 2. [**Install Rust (if you haven't already)**](https://www.rust-lang.org/learn/get-started) as well as `rustfmt` and `clippy`. 27 | 28 | This project also requires the *nightly* channel for formatting the code and building the docs. You can install it with: 29 | 30 | ```bash 31 | rustup toolchain install nightly 32 | ``` 33 | 34 | 3. **Build the project:** 35 | 36 | ```bash 37 | cargo build 38 | ``` 39 | 40 | 4. **Run the CLI to ensure everything works:** 41 | 42 | ```bash 43 | cargo run -- check --text "This text contans one mistake." # codespell:ignore contans 44 | ``` 45 | 46 | ## Development Guide 47 | 48 | This project is organized in two parts: 49 | 50 | - The API library, in `src/api/`, with the bindings to connect to the LanguageTool API; 51 | - The command-line interface (CLI), in `src/cli/`, to provide an easy-to-use tool for checking your files. 52 | 53 | Tests are located either in the Rust modules as unit tests, or in the `tests/` folder for integration tests. 54 | 55 | ## Testing 56 | 57 | To avoid spamming the free LanguageTool API, running the tests requires you to specify the LanguageTool server URL and PORT, via environment variables `LANGUAGETOOL_HOSTNAME` and `LANGUAGETOOL_PORT`. We also recommend that you [run a local server](https://dev.languagetool.org/http-server) on your machine. 58 | 59 | If you have [Docker](https://www.docker.com/) installed on your machine, we provide you with a [`docker-compose.yml`](./docker-compose.yml) file that allows you to set up a local server for testing (or actual grammar checking) with `docker compose up`. 60 | 61 | Then, you can run the test suite with: 62 | 63 | ```bash 64 | cargo test 65 | ``` 66 | 67 | Note that this project uses snapshot testing with [insta](https://github.com/mitsuhiko/insta). If you introduce changes to the snapshots when running the tests, 68 | run `cargo insta review` and review each change, ensuring that your modifications *should* change the output of the 69 | program in that way. 70 | 71 | > [!IMPORTANT] 72 | > Please write tests for any new features or bug fixes you introduce. 73 | 74 | ### Advanced Testing 75 | 76 | Beyond usual tests, it may be good to also check that changes do not break the package itself. 77 | 78 | Two things are very important on that regards: 79 | 80 | 1. **The Minimal Supported Rust Version (MSRV)** - Please use [`cargo-msrv`](https://github.com/foresterre/cargo-msrv) to check that the advertised MSRV is still valid: 81 | 82 | ```bash 83 | cargo msrv verify -- cargo check --all-features 84 | ``` 85 | 86 | If the above fails, please run the following to determine the new MSRV: 87 | 88 | ```bash 89 | cargo msrv find 90 | ``` 91 | 92 | and update the corresponding field in [`Cargo.toml`](./Cargo.toml) 93 | 2. **Compatible/Incompatible Features** - All public features should be documented in the [README](./README.md). 94 | 95 | If some features are mutually incompatible, it should also be documented (and added in the list of mutually exclusive features in [`./.github/workflows/rustlib.yml`](./.github/workflows/rustlib.yml)). We recommend using [`cargo-nextest`](https://github.com/nextest-rs/nextest) for testing compatibility with features. 96 | 97 | ## Documentation 98 | 99 | Writing good documentation is as important as writing good code. 100 | 101 | Make sure that any addition or modification to the code results in an appropriate change in the documentation. We encourage contributors to take inspiration from existing documentation. 102 | 103 | You can preview the docs with: 104 | 105 | ```bash 106 | RUSTDOCFLAGS='--cfg docsrs' cargo +nightly doc --all-features -Zunstable-options -Zrustdoc-scrape-examples --no-deps --open 107 | ``` 108 | 109 | Additional flags aim at making the documentation very similar to what it will look like when on . 110 | 111 | ## Pull Requests 112 | 113 | Before submitting your code, please run the following commands to clean and lint your code: 114 | 115 | ```bash 116 | cargo +nightly fmt 117 | cargo clippy 118 | ``` 119 | 120 | If any issue is raised by Clippy, please try to address it, or ask us for help / guidance if needed. 121 | 122 | Once your contribution is ready, you can create a pull request, and we will take time to review it! 123 | 124 | --- 125 | 126 | Thanks for making `languagetool-rust` better! 127 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [[bench]] 2 | harness = false 3 | name = "bench_main" 4 | path = "benches/bench_main.rs" 5 | 6 | [[bin]] 7 | name = "ltrs" 8 | path = "src/main.rs" 9 | required-features = ["cli"] 10 | 11 | [dependencies] 12 | annotate-snippets = {version = "0.9", optional = true} 13 | clap = {version = "4.5", features = [ 14 | "cargo", 15 | "derive", 16 | "env", 17 | "wrap_help", 18 | ], optional = true} 19 | clap-verbosity-flag = {version = "3.0", optional = true} 20 | clap_complete = {version = "4.5", optional = true} 21 | ego-tree = {version = "0.10", optional = true} 22 | enum_dispatch = {version = "0.3", optional = true} 23 | is-terminal = {version = "0.4", optional = true} 24 | lifetime = {version = "0.1", features = ["macros"]} 25 | log = {version = "0.4", optional = true} 26 | pretty_env_logger = {version = "0.5", optional = true} 27 | pulldown-cmark = {version = "0.10", optional = true} 28 | reqwest = {version = "0.12", default-features = false, features = ["json"]} 29 | scraper = {version = "0.23", optional = true} 30 | serde = {version = "1.0", features = ["derive"]} 31 | serde_json = "1.0" 32 | termcolor = {version = "1.2", optional = true} 33 | thiserror = "2.0" 34 | tokio = {version = "1.0", features = [ 35 | "macros", 36 | "rt-multi-thread", 37 | ], optional = true} 38 | typst-syntax = {version = "0.13", optional = true} 39 | 40 | [dev-dependencies] 41 | assert_cmd = "2.0.11" 42 | assert_matches = "1.5.0" 43 | codspeed-criterion-compat = "2.7.0" 44 | criterion = "0.6" 45 | futures = "0.3" 46 | insta = {version = "1.41.1", features = ["filters"]} 47 | lazy_static = "1.5.0" 48 | predicates = "3.0.3" 49 | tempfile = "3.5.0" 50 | tokio = {version = "1.0", features = ["macros"]} 51 | 52 | [features] 53 | annotate = ["dep:annotate-snippets"] 54 | cli = [ 55 | "annotate", 56 | "color", 57 | "dep:clap", 58 | "dep:clap-verbosity-flag", 59 | "dep:enum_dispatch", 60 | "dep:is-terminal", 61 | "dep:log", 62 | "dep:pretty_env_logger", 63 | "html", 64 | "markdown", 65 | "multithreaded", 66 | "typst", 67 | ] 68 | cli-complete = ["cli", "clap_complete"] 69 | color = ["annotate-snippets?/color", "dep:termcolor"] 70 | default = ["cli", "native-tls"] 71 | docker = [] 72 | full = ["cli-complete", "docker", "unstable"] 73 | html = ["dep:ego-tree", "dep:scraper"] 74 | markdown = ["dep:pulldown-cmark", "html"] 75 | multithreaded = ["dep:tokio"] 76 | native-tls = ["reqwest/native-tls"] 77 | native-tls-vendored = ["reqwest/native-tls-vendored"] 78 | snapshots = [] # Only for testing 79 | typst = ["dep:typst-syntax"] 80 | unstable = [] 81 | 82 | [lib] 83 | name = "languagetool_rust" 84 | path = "src/lib.rs" 85 | 86 | [package] 87 | authors = ["Jérome Eertmans "] 88 | description = "LanguageTool API bindings in Rust." 89 | edition = "2021" 90 | include = ["src/**/*", "LICENSE.md", "README.md", "CHANGELOG.md"] 91 | keywords = ["languagetool", "rust"] 92 | license = "MIT" 93 | name = "languagetool-rust" 94 | readme = "README.md" 95 | repository = "https://github.com/jeertmans/languagetool-rust" 96 | rust-version = "1.82.0" 97 | version = "3.0.0-rc.1" 98 | 99 | [package.metadata.docs.rs] 100 | all-features = true 101 | rustdoc-args = ["--cfg", "docsrs"] 102 | 103 | [package.metadata.release] 104 | pre-release-replacements = [ 105 | {file = "CHANGELOG.md", search = "Unreleased", replace = "{{version}}", min = 1}, 106 | {file = "CHANGELOG.md", search = "\\.\\.\\.HEAD\\)", replace = "...{{version}}) {{date}}", exactly = 1}, 107 | {file = "CHANGELOG.md", search = "", replace = "\n\n## [Unreleased](https://github.com/jeertmans/languagetool-rust/compare/v{{version}}...HEAD)", exactly = 1}, 108 | ] 109 | publish = false 110 | tag = false 111 | 112 | [profile.dev.package] 113 | insta.opt-level = 3 114 | similar.opt-level = 3 115 | 116 | [[test]] 117 | name = "cli" 118 | path = "tests/cli.rs" 119 | required-features = ["cli"] 120 | 121 | [[test]] 122 | name = "match-positions" 123 | path = "tests/match_positions.rs" 124 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2021-2025, Jérome Eertmans and contributors 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LanguageTool-Rust 2 | 3 | > **Rust bindings to connect with LanguageTool server API.** 4 | 5 | *LanguageTool is an open source grammar style checker. 6 | It can correct 30+ languages and is free to use, more on that on 7 | [languagetool.org](https://languagetool.org/). 8 | There is a public API (with a free tier), 9 | but you can also host your own server locally. 10 | LanguageTool-Rust helps you communicate with those servers very easily via Rust code!* 11 | 12 | [![Crates.io](https://img.shields.io/crates/v/languagetool-rust)](https://crates.io/crates/languagetool-rust) 13 | [![docs.rs](https://img.shields.io/docsrs/languagetool-rust)](https://docs.rs/languagetool-rust) 14 | [![codecov](https://codecov.io/gh/jeertmans/languagetool-rust/branch/main/graph/badge.svg?token=ZDZ8YBQTPH)](https://codecov.io/gh/jeertmans/languagetool-rust) 15 | 16 | 1. [About](#about) 17 | 2. [CLI Reference](#cli-reference) 18 | - [Docker](#docker) 19 | 3. [API Reference](#api-reference) 20 | - [Feature Flags](#feature-flags) 21 | 4. [CHANGELOG](https://github.com/jeertmans/languagetool-rust/blob/main/CHANGELOG.md) 22 | 5. [Related Projects](#related-projects) 23 | 6. [Contributing](#contributing) 24 | 25 | ## About 26 | 27 | LanguageTool-Rust (LTRS) is both an **executable and a Rust library** 28 | that strives to provide correct and safe bindings for the LanguageTool API. 29 | 30 | *Disclaimer: the current work relies on an approximation of the LanguageTool API. 31 | We try to avoid breaking changes as much as possible, 32 | but we still highly depend on the future evolutions of LanguageTool.* 33 | 34 | ## Installation 35 | 36 | You can install the latest version with `cargo`. 37 | 38 | ```bash 39 | cargo install languagetool-rust --features full 40 | ``` 41 | 42 | ### AUR 43 | 44 | If you are on Arch Linux, you call also install with your 45 | [AUR helper](https://wiki.archlinux.org/title/AUR_helpers): 46 | 47 | ```bash 48 | paru -S languagetool-rust 49 | ``` 50 | 51 | ## CLI Reference 52 | 53 | ![Screenshot from CLI](https://raw.githubusercontent.com/jeertmans/languagetool-rust/main/img/screenshot.svg) 54 | 55 | The command line interface of LTRS allows to very quickly use any LanguageTool server 56 | to check for grammar and style errors. 57 | 58 | The reference for the CLI can be accessed via `ltrs --help`. 59 | 60 | By default, LTRS uses the LanguageTool public API. 61 | 62 | ### Example 63 | 64 | ```bash 65 | > ltrs ping # to check if the server is alive 66 | PONG! Delay: 110 ms 67 | > ltrs languages # to list all languages 68 | [ 69 | { 70 | "name": "Arabic", 71 | "code": "ar", 72 | "longCode": "ar" 73 | }, 74 | { 75 | "name": "Asturian", 76 | "code": "ast", 77 | "longCode": "ast-ES" 78 | }, 79 | # ... 80 | ] 81 | > ltrs check --text "Some phrase with a smal mistake" # codespell:ignore smal 82 | { 83 | "language": { 84 | "code": "en-US", 85 | "detectedLanguage": { 86 | "code": "en-US", 87 | "confidence": 0.99, 88 | "name": "English (US)", 89 | "source": "ngram" 90 | }, 91 | "name": "English (US)" 92 | }, 93 | "matches": [ 94 | { 95 | "context": { 96 | "length": 4, 97 | "offset": 19, 98 | "text": "Some phrase with a smal mistake" # codespell:ignore smal 99 | }, 100 | "contextForSureMatch": 0, 101 | "ignoreForIncompleteSentence": false, 102 | "length": 4, 103 | "message": "Possible spelling mistake found.", 104 | "offset": 19, 105 | "replacements": [ 106 | { 107 | "value": "small" 108 | }, 109 | { 110 | "value": "seal" 111 | }, 112 | # ... 113 | } 114 | # ... 115 | ] 116 | # ... 117 | } 118 | > ltrs --help # for more details 119 | ``` 120 | 121 | ### Docker 122 | 123 | Since LanguageTool's installation might not be straightforward, 124 | we provide a basic Docker integration that allows to `pull`, `start`, and `stop` 125 | LanguageTool Docker containers in a few lines: 126 | 127 | ```bash 128 | ltrs docker pull # only once 129 | ltrs docker start # start the LT server 130 | ltrs --hostname http://localhost -p 8010 check -t "Some tex" 131 | # Other commands... 132 | ltrs docker stop # stop the LT server 133 | ``` 134 | 135 | > *Note:* Docker is a tool that facilitates running applications without worrying 136 | about local dependencies, platform-related issues, and so on. 137 | Installation guidelines can be found [online](https://www.docker.com/get-started/). 138 | On Linux platforms, you might need to circumvent the *sudo privilege issue* by doing 139 | [this](https://docs.docker.com/engine/install/linux-postinstall/). 140 | 141 | ## API Reference 142 | 143 | If you would like to integrate LTRS within a Rust application or crate, 144 | then we recommend reading the [documentation](https://docs.rs/languagetool-rust). 145 | 146 | To use LanguageTool-Rust in your Rust project, add to your `Cargo.toml`: 147 | 148 | ```toml 149 | [dependencies] 150 | languagetool-rust = "^2.1" 151 | ``` 152 | 153 | ### Example 154 | 155 | ```rust 156 | use languagetool_rust::api::{check, server::ServerClient}; 157 | use std::borrow::Cow; 158 | 159 | #[tokio::main] 160 | async fn main() -> Result<(), Box> { 161 | let client = ServerClient::from_env_or_default(); 162 | 163 | let req = check::Request::default() 164 | .with_text("Some phrase with a smal mistake"); // # codespell:ignore smal 165 | 166 | println!( 167 | "{}", 168 | serde_json::to_string_pretty(&client.check(&req).await?)? 169 | ); 170 | Ok(()) 171 | } 172 | ``` 173 | 174 | ### Feature Flags 175 | 176 | Below are listed the various feature flags you can enable when compiling LTRS. 177 | 178 | #### Default Features 179 | 180 | - **cli**: Adds command-line related methods for multiple structures. 181 | This feature is required to install the LTRS CLI, 182 | and enables the following features: **annotate**, **color**, **html**, **markdown**, **multithreaded**, **typst**. 183 | - **native-tls**: Enables TLS functionality provided by `native-tls`. 184 | 185 | #### Optional Features 186 | 187 | - **annotate**: Adds method(s) to annotate results from check request. 188 | - **cli-complete**: Adds commands to generate completion files for various shells. 189 | This feature also activates the **cli** feature. Enter `ltrs completions --help` to get help with installing completion files. 190 | - **color**: Enables color outputting in the terminal. If **cli** feature is also enabled, the `--color=` option will be available. 191 | - **full**: Enables all features that are mutually compatible 192 | (i.e., `cli-complete`, `docker`, and `undoc`). 193 | - **html**: Enables HTML parser utilities. 194 | - **markdown**: Enables Markdown parser utilities (and also HTML). 195 | - **multithreaded**: Enables multithreaded requests. 196 | - **native-tls-vendored**: Enables the `vendored` feature of `native-tls`. This or `native-tls` should be activated if you are planning to use HTTPS servers. 197 | - **typst**: Enables Typst parser utilities. 198 | - **undoc**: Adds more fields to JSON responses that are not present 199 | in the [Model | Example Value](https://languagetool.org/http-api/swagger-ui/#!/default/) 200 | but might be present in some cases. All added fields are stored in a hashmap as 201 | JSON values. 202 | 203 | ## Related Projects 204 | 205 | Here are listed some projects that use LTRS. 206 | 207 | - [`null-ls`](https://github.com/jose-elias-alvarez/null-ls.nvim): 208 | Neovim plugin with LTRS builtin ([see PR](https://github.com/jose-elias-alvarez/null-ls.nvim/pull/997)) 209 | - [`languagetool-code-comments`](https://github.com/dustinblackman/languagetool-code-comments): 210 | uses LTRS to check for grammar errors within code comments 211 | 212 | *Do you use LTRS in your project? Contact me so I can add it to the list!* 213 | 214 | ## Contributing 215 | 216 | Contributions are more than welcome! 217 | 218 | First, check out the [CHANGELOG](https://github.com/jeertmans/languagetool-rust/blob/main/CONTRIBUTING.md) 219 | guide for detailed information about how to contribute to this project. 220 | 221 | Should you have any question, feel free to reach out via: 222 | [Issues](https://github.com/jeertmans/languagetool-rust/issues) for bugs, 223 | [Pull requests](https://github.com/jeertmans/languagetool-rust/pulls) for code contributions 224 | or [Discussions](https://github.com/jeertmans/languagetool-rust/discussions) for anything else. 225 | -------------------------------------------------------------------------------- /RELEASE-PROCESS.md: -------------------------------------------------------------------------------- 1 | # Release process 2 | 3 | If you don't have write access to **LanguageTool-Rust**' crates, you can still 4 | perform steps 1-3, and ask a maintainer with accesses to perform step 4 5 | 6 | This project uses `cargo-release` to bump the version number and update the change log with more ease. 7 | 8 | Note that, by default, every command runs in *dry mode*, and you need to append `--execute` 9 | to actually perform the action. 10 | 11 | Here are the the following steps to install `cargo-release`: 12 | 13 | ```bash 14 | cargo install cargo-release 15 | ``` 16 | 17 | Here are the following steps to release a new version: 18 | 19 | 1. create a branch `release-x.y.z` from the main branch; 20 | 2. run `cargo release `; 21 | 3. create a pull request; 22 | 4. and, once your branch was merged to `main` tag it `vx.y.z` and push it (we prefer to create tags through GitHub releases) 23 | 24 | And voilà! 25 | -------------------------------------------------------------------------------- /benches/bench_main.rs: -------------------------------------------------------------------------------- 1 | use codspeed_criterion_compat::criterion_main; 2 | 3 | mod benchmarks; 4 | criterion_main! { 5 | benchmarks::check_texts::checks, 6 | 7 | } 8 | -------------------------------------------------------------------------------- /benches/benchmarks/check_texts.rs: -------------------------------------------------------------------------------- 1 | use codspeed_criterion_compat::{criterion_group, Criterion, Throughput}; 2 | use futures::future::join_all; 3 | use languagetool_rust::{ 4 | api::{ 5 | check::{self, Request, Response}, 6 | server::ServerClient, 7 | }, 8 | error::Error, 9 | }; 10 | 11 | static FILES: [(&str, &str); 3] = [ 12 | ("small", include_str!("../small.txt")), 13 | ("medium", include_str!("../medium.txt")), 14 | ("large", include_str!("../large.txt")), 15 | ]; 16 | 17 | async fn request_until_success<'source>(req: &Request<'source>, client: &ServerClient) -> Response { 18 | loop { 19 | match client.check(req).await { 20 | Ok(resp) => return resp, 21 | Err(Error::InvalidRequest(body)) 22 | if body == *"Error: Server overloaded, please try again later" => 23 | { 24 | continue; 25 | }, 26 | Err(e) => panic!("Some unexpected error occurred: {}", e), 27 | } 28 | } 29 | } 30 | 31 | #[tokio::main] 32 | async fn check_text_basic(text: &str) -> Response { 33 | let client = ServerClient::from_env().expect( 34 | "Please use a local server for benchmarking, and configure the environ variables to use \ 35 | it.", 36 | ); 37 | let req = Request::default().with_text(text); 38 | request_until_success(&req, &client).await 39 | } 40 | 41 | #[tokio::main] 42 | async fn check_text_split(text: &str) -> Response { 43 | let client = ServerClient::from_env().expect( 44 | "Please use a local server for benchmarking, and configure the environ variables to use \ 45 | it.", 46 | ); 47 | let lines = text.lines(); 48 | 49 | let resps = join_all(lines.map(|line| { 50 | async { 51 | let req = Request::default().with_text(line.to_string()); 52 | let resp = request_until_success(&req, &client).await; 53 | check::ResponseWithContext::new(req.get_text(), resp) 54 | } 55 | })) 56 | .await; 57 | 58 | resps 59 | .into_iter() 60 | .reduce(|acc, item| acc.append(item)) 61 | .unwrap() 62 | .into() 63 | } 64 | 65 | fn bench_basic(c: &mut Criterion) { 66 | let mut group = c.benchmark_group("basic"); 67 | 68 | for (name, source) in FILES { 69 | group.throughput(Throughput::Bytes(source.len() as u64)); 70 | group.bench_with_input(name, &source, |b, &s| b.iter(|| check_text_basic(s))); 71 | } 72 | } 73 | 74 | fn bench_split(c: &mut Criterion) { 75 | let mut group = c.benchmark_group("split"); 76 | 77 | for (name, source) in FILES { 78 | group.throughput(Throughput::Bytes(source.len() as u64)); 79 | group.bench_with_input(name, &source, |b, &s| b.iter(|| check_text_split(s))); 80 | } 81 | } 82 | 83 | criterion_group!(checks, bench_basic, bench_split,); 84 | -------------------------------------------------------------------------------- /benches/benchmarks/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod check_texts; 2 | -------------------------------------------------------------------------------- /benches/large.txt: -------------------------------------------------------------------------------- 1 | The Project Gutenberg eBook of Proxy Planeteers, by Edmond Hamilton 2 | 3 | This eBook is for the use of anyone anywhere in the United States and 4 | most other parts of the world at no cost and with almost no restrictions 5 | whatsoever. You may copy it, give it away or re-use it under the terms 6 | of the Project Gutenberg License included with this eBook or online at 7 | www.gutenberg.org. If you are not located in the United States, you 8 | will have to check the laws of the country where you are located before 9 | using this eBook. 10 | 11 | Title: Proxy Planeteers 12 | 13 | Author: Edmond Hamilton 14 | 15 | Release Date: August 2, 2022 [eBook #68669] 16 | 17 | Language: English 18 | 19 | Produced by: Greg Weeks, Mary Meehan and the Online Distributed 20 | Proofreading Team at http://www.pgdp.net. 21 | 22 | *** START OF THE PROJECT GUTENBERG EBOOK PROXY PLANETEERS *** 23 | 24 | 25 | 26 | 27 | 28 | PROXY PLANETEERS 29 | 30 | By EDMOND HAMILTON 31 | 32 | [Transcriber's Note: This etext was produced from 33 | Startling Stories, July 1947. 34 | Extensive research did not uncover any evidence that 35 | the U.S. copyright on this publication was renewed.] 36 | 37 | 38 | Doug Norris hesitated for an instant. He knew that another movement 39 | might well mean disaster. 40 | 41 | Here deep in the cavernous interior of airless Mercury, catastrophe 42 | could strike suddenly. The rocks of the fissure he was following had a 43 | temperature of hundreds of degrees. And he could hear the deep rumble 44 | of shifting rock, close by. 45 | 46 | But it was not these dangers of the infernal underworld that made him 47 | hesitate. It was that sixth sense of imminent peril that he had felt 48 | twice before while exploring the Mercurian depths. Each time, it had 49 | ended disastrously. 50 | 51 | "Just nerves," Norris muttered to himself. "The uranium vein is clearly 52 | indicated. I've got to follow it." 53 | 54 | As he again moved forward and followed that thin, black stratum in the 55 | fissure wall, his eyes constantly searched ahead. 56 | 57 | Then a half-dozen little clouds of glowing gas flowed toward him from a 58 | branching fissure. Each was several feet in diameter, a faint-glowing 59 | mass of vapor with a brighter core. 60 | 61 | Norris moved hastily to avoid them. But there was a sudden flash of 62 | light. Then everything went black before his eyes. 63 | 64 | "It's happened to me again!" Doug Norris thought in sharp dismay. 65 | 66 | Frantically he jiggled his controls, cut in emergency power switches, 67 | overloaded his tight control beam to the limit. It was no use. He still 68 | could not see or hear anything whatever. 69 | 70 | Norris defeatedly took the heavy television helmet with its bulging 71 | eyepieces off his head. He stared at the control-board, then looked 72 | blankly out the window at the distant, sunlit stacks of New York Power 73 | Station. 74 | 75 | "Another Proxy gone! Seven of them wrecked in the last two weeks!" 76 | 77 | It hadn't just happened, of course. It had happened eight minutes ago. 78 | It took that long for the television beam from the Proxy to shuttle 79 | from Mercury to this control-station outside New York. And it took as 80 | long again for the Proxy control-beam to get back to it on Mercury. 81 | 82 | Sometimes, a time-lag that long could get a Proxy into trouble before 83 | its operator on Earth was aware of it. But usually that was not a big 84 | factor of danger on a lifeless world like Mercury. The Proxies, built 85 | of the toughest refractory metals, could stand nearly anything but an 86 | earthquake, and keep on functioning. 87 | 88 | "Each time, there's been no sign of falling rocks or anything like 89 | that," Norris told himself, mystified. "Each time, the Proxy has just 90 | blacked out with all its controls shot." 91 | 92 | * * * * * 93 | 94 | Then, as his mind searched for some factor common to all the disasters, 95 | a startled look came over Doug Norris' lean, earnest face. 96 | 97 | "There _were_ always some of those clouds of radon or whatever they 98 | are around, each time!" he thought. "I wonder if--" A red-hot thought 99 | brought him to his feet. "Holy cats! Maybe I've got the answer!" 100 | 101 | He jumped away from the Proxy-board without a further glance at that 102 | bank of intricate controls, and hurried down a corridor. 103 | 104 | Through the glass doors he passed, Norris could see the other operators 105 | at work. Each sat in front of his control-board, wearing his television 106 | helmet, flipping the switches with expert precision. Each was operating 107 | a mechanical Proxy somewhere on Mercury. 108 | 109 | Norris and all these other operators had been trained together when 110 | Kincaid started the Proxy Project. They had been proud of their 111 | positions, until recently. It _was_ a vitally important job, searching 112 | out the uranium so sorely needed for Earth's atomic power supply. 113 | 114 | The uranium and allied metals of Earth had years ago been ransacked 115 | and used up. There was little on Venus or Mars. Mercury had much of 116 | the precious metal in its cavernous interior. But no man, no matter 117 | how ingenious his protection, could live long enough on the terrible, 118 | semi-molten Hot Side of Mercury to conduct mining operations. 119 | 120 | That was why Kincaid had invented the Proxies. They were machines 121 | that could mine uranium where men couldn't go. Crewless ships guided 122 | by radar took the Proxies to the Base on Mercury's sunward side. From 123 | Base, each Proxy was guided by an Earth operator down into the hot 124 | fissures to find and mine the vital radioactive element. The scheme had 125 | worked well, until-- 126 | 127 | "Until we got into those deeper fissures with the Proxies," Doug Norris 128 | thought. "Seven wrecked since then! This _must_ be the answer!" 129 | 130 | Martin Kincaid looked up sharply as Norris entered his office. A look 131 | of faint dismay came on Kincaid's square, patient face. He knew that 132 | a Proxy operator wouldn't leave his board in the middle of a shift, 133 | unless there was trouble. 134 | 135 | "Go ahead and give me the bad news, Doug," he said wearily. 136 | 137 | "Proxy M-Fifty just blacked out on me, down in Fissure Four," Norris 138 | admitted. "Just like the others. But I think I know why, now!" He 139 | continued excitedly: "Mart, seven Proxies blacking out in two weeks 140 | wasn't just accident. It was done deliberately!" 141 | 142 | Kincaid stared. "You mean that Hurriman's bunch is somehow sabotaging 143 | our Project?" 144 | 145 | Doug Norris interrupted with a denial. "Not that. Hurriman and his 146 | fellow politicians merely want to get their hands on the Proxy Project, 147 | not to destroy it." 148 | 149 | "Then who did wreck our Proxies?" Kincaid demanded. 150 | 151 | Norris answered excitedly. "I believe we've run into living creatures 152 | in those depths, and they're attacking us." 153 | 154 | Kincaid grunted. "The temperature in those fissures is about four 155 | hundred degrees Centigrade, the same as Mercury's sunward side. Life 156 | can't exist in heat like that. I suggest you take a rest." 157 | 158 | "I know all that," Norris said impatiently. "But suppose we've run into 159 | a new kind of life there--one based on radioactive matter? Biologists 160 | have speculated on it more than once. Theoretically, creatures of 161 | radioactive matter could exist, drawing their energies not from 162 | chemical metabolism as we do, but from the continuous process of 163 | radioactive disintegration." 164 | 165 | "Theoretically, the sky is a big roof with holes in it that are stars," 166 | growled Kincaid. "It depends on whose theory you believe." 167 | 168 | "Every time a Proxy has blacked out down there, there's been little 169 | clouds of heavy radioactive gas near," argued Doug Norris. "Each seems 170 | to have a denser core. Suppose that core is an unknown radium compound, 171 | evolved into some kind of neuronic structure that is able to receive 172 | and remember stimuli? A sort of queer, radioactive brain? 173 | 174 | "If that's so, and biologists have said it's possible, the _body_ of 175 | the creature consists of radon gas emanated from the radium core. You 176 | remember the half-life of radon exactly equals the rate of its emission 177 | from radium, so there'd be a constant equilibrium of the thing's 178 | gaseous body, analogous to our blood circulation. Given Mercury's 179 | conditions, it's no more impossible than a jellyfish or a man here on 180 | Earth!" 181 | 182 | * * * * * 183 | 184 | Kincaid looked skeptical. 185 | 186 | "And you think these hypothetical living Raddies of yours are attacking 187 | our Proxies? Why would they?" 188 | 189 | "If they have cognition and correlation faculties they might be 190 | irritated by the tube emanations from the control-boxes of our 191 | Proxies," Norris suggested. "They get into those control-boxes and 192 | wreck the tube circuits by overloading the electron flow with their own 193 | Beta radiation!" 194 | -------------------------------------------------------------------------------- /benches/medium.txt: -------------------------------------------------------------------------------- 1 | The Project Gutenberg eBook of Alice’s Adventures in Wonderland, by Lewis Carroll 2 | 3 | This eBook is for the use of anyone anywhere in the United States and 4 | most other parts of the world at no cost and with almost no restrictions 5 | whatsoever. You may copy it, give it away or re-use it under the terms 6 | of the Project Gutenberg License included with this eBook or online at 7 | www.gutenberg.org. If you are not located in the United States, you 8 | will have to check the laws of the country where you are located before 9 | using this eBook. 10 | 11 | Title: Alice’s Adventures in Wonderland 12 | 13 | Author: Lewis Carroll 14 | 15 | Release Date: January, 1991 [eBook #11] 16 | [Most recently updated: October 12, 2020] 17 | 18 | Language: English 19 | 20 | Character set encoding: UTF-8 21 | 22 | Produced by: Arthur DiBianca and David Widger 23 | 24 | *** START OF THE PROJECT GUTENBERG EBOOK ALICE’S ADVENTURES IN WONDERLAND *** 25 | 26 | [Illustration] 27 | 28 | 29 | 30 | 31 | Alice’s Adventures in Wonderland 32 | 33 | by Lewis Carroll 34 | 35 | THE MILLENNIUM FULCRUM EDITION 3.0 36 | 37 | Contents 38 | 39 | CHAPTER I. Down the Rabbit-Hole 40 | CHAPTER II. The Pool of Tears 41 | CHAPTER III. A Caucus-Race and a Long Tale 42 | CHAPTER IV. The Rabbit Sends in a Little Bill 43 | CHAPTER V. Advice from a Caterpillar 44 | CHAPTER VI. Pig and Pepper 45 | CHAPTER VII. A Mad Tea-Party 46 | CHAPTER VIII. The Queen’s Croquet-Ground 47 | CHAPTER IX. The Mock Turtle’s Story 48 | CHAPTER X. The Lobster Quadrille 49 | CHAPTER XI. Who Stole the Tarts? 50 | CHAPTER XII. Alice’s Evidence 51 | -------------------------------------------------------------------------------- /benches/small.txt: -------------------------------------------------------------------------------- 1 | Consider the lilies of the field 2 | And why take ye thought for raiment? Consider the lilies of the field, how they grow; they toil not, neither do they spin: 3 | 4 | And yet I say unto you, That even Solomon in all his glory was not arrayed like one of these. 5 | 6 | Wherefore, if God so clothe the grass of the field, which today is, and tomorrow is cast into the oven, shall he not much more clothe you, O ye of little faith? Therefore, take no thought, saying, What shall we eat? or, What shall we drink? or, Wherewithal shall we be clothed? 7 | 8 | For your heavenly Father knoweth that ye have need of all these things . . . 9 | 10 | Take therefore no thought for the morrow: for the morrow shall take thought for the things of itself. Sufficient unto the day is the evil thereof. 11 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | languagetool: 3 | image: erikvl87/languagetool:latest 4 | container_name: languagetool 5 | pull_policy: always 6 | ports: 7 | - 8010:8010 8 | environment: 9 | langtool_maxTextLength: 1500 10 | Java_Xmx: 2g 11 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | condense_wildcard_suffixes = true 2 | edition = "2021" 3 | # error_on_line_overflow = true 4 | # error_on_unformatted = true 5 | force_multiline_blocks = true 6 | format_code_in_doc_comments = true 7 | format_macro_matchers = true 8 | format_strings = true 9 | imports_granularity = "Crate" 10 | match_block_trailing_comma = true 11 | normalize_doc_attributes = true 12 | unstable_features = true 13 | wrap_comments = true 14 | -------------------------------------------------------------------------------- /src/api/check/data_annotations.rs: -------------------------------------------------------------------------------- 1 | //! Structures for handling data annotations. 2 | 3 | use crate::error::{Error, Result}; 4 | 5 | use std::{borrow::Cow, mem}; 6 | 7 | use lifetime::IntoStatic; 8 | use serde::{Deserialize, Serialize}; 9 | 10 | /// A portion of text to be checked. 11 | #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize, Hash, IntoStatic)] 12 | #[non_exhaustive] 13 | #[serde(rename_all = "camelCase")] 14 | pub struct DataAnnotation<'source> { 15 | /// Text that should be treated as normal text. 16 | /// 17 | /// This or `markup` is required. 18 | #[serde(skip_serializing_if = "Option::is_none")] 19 | pub text: Option>, 20 | /// Text that should be treated as markup. 21 | /// 22 | /// This or `text` is required. 23 | #[serde(skip_serializing_if = "Option::is_none")] 24 | pub markup: Option>, 25 | /// If set, the markup will be interpreted as this. 26 | #[serde(skip_serializing_if = "Option::is_none")] 27 | pub interpret_as: Option>, 28 | } 29 | 30 | impl<'source> DataAnnotation<'source> { 31 | /// Instantiate a new `DataAnnotation` with text only. 32 | #[inline] 33 | #[must_use] 34 | pub fn new_text>>(text: T) -> Self { 35 | Self { 36 | text: Some(text.into()), 37 | markup: None, 38 | interpret_as: None, 39 | } 40 | } 41 | 42 | /// Instantiate a new `DataAnnotation` with markup only. 43 | #[inline] 44 | #[must_use] 45 | pub fn new_markup>>(markup: M) -> Self { 46 | Self { 47 | text: None, 48 | markup: Some(markup.into()), 49 | interpret_as: None, 50 | } 51 | } 52 | 53 | /// Instantiate a new `DataAnnotation` with markup and its interpretation. 54 | #[inline] 55 | #[must_use] 56 | pub fn new_interpreted_markup>, I: Into>>( 57 | markup: M, 58 | interpret_as: I, 59 | ) -> Self { 60 | Self { 61 | interpret_as: Some(interpret_as.into()), 62 | markup: Some(markup.into()), 63 | text: None, 64 | } 65 | } 66 | 67 | /// Return the text or markup within the data annotation. 68 | /// 69 | /// # Errors 70 | /// 71 | /// If this data annotation does not contain text or markup. 72 | pub fn try_get_text(&self) -> Result> { 73 | if let Some(ref text) = self.text { 74 | Ok(text.clone()) 75 | } else if let Some(ref markup) = self.markup { 76 | Ok(markup.clone()) 77 | } else { 78 | Err(Error::InvalidDataAnnotation(format!( 79 | "missing either text or markup field in {self:?}" 80 | ))) 81 | } 82 | } 83 | } 84 | 85 | #[cfg(test)] 86 | mod data_annotation_tests { 87 | 88 | use super::DataAnnotation; 89 | 90 | #[test] 91 | fn test_text() { 92 | let da = DataAnnotation::new_text("Hello"); 93 | 94 | assert_eq!(da.text.unwrap(), "Hello"); 95 | assert!(da.markup.is_none()); 96 | assert!(da.interpret_as.is_none()); 97 | } 98 | 99 | #[test] 100 | fn test_markup() { 101 | let da = DataAnnotation::new_markup("Hello"); 102 | 103 | assert!(da.text.is_none()); 104 | assert_eq!(da.markup.unwrap(), "Hello"); 105 | assert!(da.interpret_as.is_none()); 106 | } 107 | 108 | #[test] 109 | fn test_interpreted_markup() { 110 | let da = DataAnnotation::new_interpreted_markup("Hello", "Hello"); 111 | 112 | assert!(da.text.is_none()); 113 | assert_eq!(da.markup.unwrap(), "Hello"); 114 | assert_eq!(da.interpret_as.unwrap(), "Hello"); 115 | } 116 | } 117 | 118 | /// Alternative text to be checked. 119 | #[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq, Hash)] 120 | #[non_exhaustive] 121 | pub struct Data<'source> { 122 | /// Vector of markup text, see [`DataAnnotation`]. 123 | pub annotation: Vec>, 124 | } 125 | 126 | impl Data<'_> { 127 | /// Split data into as few fragments as possible, where each fragment 128 | /// contains (if possible) a maximum of `n` characters in it's 129 | /// annotations' markup and text fields. 130 | /// 131 | /// Pattern str `pat` is used for splitting. 132 | #[must_use] 133 | pub fn split(self, n: usize, pat: &str) -> Vec { 134 | // Build vec of breakpoints and the length of the text + markup at that 135 | // potential breakpoint 136 | let mut break_point_lengths = vec![]; 137 | let mut len = 0; 138 | for (i, ann) in self.annotation.iter().enumerate() { 139 | len += 140 | ann.text.as_deref().unwrap_or("").len() + ann.markup.as_deref().unwrap_or("").len(); 141 | if ann.text.as_ref().is_some_and(|t| t.contains(pat)) { 142 | break_point_lengths.push((i, len)); 143 | } 144 | } 145 | 146 | // Decide which breakpoints to split the annotations at 147 | let mut break_points: Vec = vec![]; 148 | if break_point_lengths.len() > 1 { 149 | let (mut i, mut ii) = (0, 1); 150 | let (mut base, mut curr) = (0, 0); 151 | while ii < break_point_lengths.len() { 152 | curr += break_point_lengths[i].1 - base; 153 | 154 | if break_point_lengths[ii].1 - base + curr > n { 155 | break_points.push(break_point_lengths[i].0); 156 | base = break_point_lengths[i].1; 157 | curr = 0; 158 | } 159 | 160 | i += 1; 161 | ii += 1; 162 | } 163 | } 164 | 165 | // Split annotations based on calculated break points 166 | let mut split = Vec::with_capacity(break_points.len()); 167 | let mut iter = self.into_iter(); 168 | let mut taken = 0; 169 | let mut annotations = vec![]; 170 | for break_point in break_points { 171 | while taken != break_point + 1 { 172 | annotations.push(iter.next().unwrap()); 173 | taken += 1; 174 | } 175 | split.push(Data::from_iter(mem::take(&mut annotations))); 176 | } 177 | 178 | split 179 | } 180 | } 181 | 182 | impl IntoStatic for Data<'_> { 183 | type Static = Data<'static>; 184 | fn into_static(self) -> Self::Static { 185 | Data { 186 | annotation: self 187 | .annotation 188 | .into_iter() 189 | .map(IntoStatic::into_static) 190 | .collect(), 191 | } 192 | } 193 | } 194 | 195 | impl<'source, T: Into>> FromIterator for Data<'source> { 196 | fn from_iter>(iter: I) -> Self { 197 | let annotation = iter.into_iter().map(std::convert::Into::into).collect(); 198 | Data { annotation } 199 | } 200 | } 201 | 202 | impl<'source> IntoIterator for Data<'source> { 203 | type Item = DataAnnotation<'source>; 204 | type IntoIter = std::vec::IntoIter; 205 | 206 | fn into_iter(self) -> Self::IntoIter { 207 | self.annotation.into_iter() 208 | } 209 | } 210 | 211 | impl Serialize for Data<'_> { 212 | fn serialize(&self, serializer: S) -> std::result::Result 213 | where 214 | S: serde::Serializer, 215 | { 216 | let mut map = std::collections::HashMap::new(); 217 | map.insert("annotation", &self.annotation); 218 | 219 | serializer.serialize_str(&serde_json::to_string(&map).unwrap()) 220 | } 221 | } 222 | 223 | #[cfg(feature = "cli")] 224 | impl std::str::FromStr for Data<'_> { 225 | type Err = Error; 226 | 227 | fn from_str(s: &str) -> Result { 228 | let v: Self = serde_json::from_str(s)?; 229 | Ok(v) 230 | } 231 | } 232 | 233 | #[cfg(test)] 234 | mod tests { 235 | use std::borrow::Cow; 236 | 237 | use super::super::{Data, DataAnnotation}; 238 | 239 | #[derive(Debug)] 240 | enum Token<'source> { 241 | Text(&'source str), 242 | Skip(&'source str), 243 | } 244 | 245 | impl<'source> From<&'source str> for Token<'source> { 246 | fn from(s: &'source str) -> Self { 247 | if s.chars().all(|c| c.is_ascii_alphabetic()) { 248 | Token::Text(s) 249 | } else { 250 | Token::Skip(s) 251 | } 252 | } 253 | } 254 | 255 | impl<'source> From> for DataAnnotation<'source> { 256 | fn from(token: Token<'source>) -> Self { 257 | match token { 258 | Token::Text(s) => DataAnnotation::new_text(s), 259 | Token::Skip(s) => DataAnnotation::new_markup(s), 260 | } 261 | } 262 | } 263 | 264 | #[test] 265 | fn test_data_annotation() { 266 | let words: Vec<&str> = "My name is Q34XY".split(' ').collect(); 267 | let data: Data = words.iter().map(|w| Token::from(*w)).collect(); 268 | 269 | let expected_data = Data { 270 | annotation: vec![ 271 | DataAnnotation::new_text("My"), 272 | DataAnnotation::new_text("name"), 273 | DataAnnotation::new_text("is"), 274 | DataAnnotation::new_markup("Q34XY"), 275 | ], 276 | }; 277 | 278 | assert_eq!(data, expected_data); 279 | } 280 | 281 | #[test] 282 | fn test_try_get_text() { 283 | const TEXT: &str = "Lorem Ipsum"; 284 | assert_eq!( 285 | DataAnnotation::new_text(TEXT).try_get_text().unwrap(), 286 | Cow::from(TEXT) 287 | ); 288 | assert_eq!( 289 | DataAnnotation::new_markup(TEXT).try_get_text().unwrap(), 290 | Cow::from(TEXT) 291 | ); 292 | assert!((DataAnnotation { 293 | text: None, 294 | markup: None, 295 | interpret_as: None 296 | }) 297 | .try_get_text() 298 | .is_err()); 299 | } 300 | } 301 | -------------------------------------------------------------------------------- /src/api/check/mod.rs: -------------------------------------------------------------------------------- 1 | //! Structures for `check` requests and responses. 2 | 3 | mod data_annotations; 4 | mod requests; 5 | mod responses; 6 | 7 | pub use data_annotations::*; 8 | pub use requests::*; 9 | pub use responses::*; 10 | use serde::Serializer; 11 | 12 | use crate::error::{Error, Result}; 13 | 14 | /// Parse `v` is valid language code. 15 | /// 16 | /// A valid language code is usually 17 | /// - a two character string matching pattern `[a-z]{2} 18 | /// - a five character string matching pattern `[a-z]{2}-[A-Z]{2} 19 | /// - or some more complex ascii string (see below) 20 | /// 21 | /// Language code is case-insensitive. 22 | /// 23 | /// Therefore, a valid language code must match the following: 24 | /// 25 | /// - `[a-zA-Z]{2,3}(-[a-zA-Z]{2}(-[a-zA-Z]+)*)?` 26 | /// 27 | /// or 28 | /// 29 | /// - "auto" 30 | /// 31 | /// > Note: a valid language code does not mean that it exists. 32 | /// 33 | /// # Examples 34 | /// 35 | /// ``` 36 | /// # use languagetool_rust::api::check::parse_language_code; 37 | /// assert!(parse_language_code("en").is_ok()); 38 | /// 39 | /// assert!(parse_language_code("en-US").is_ok()); 40 | /// 41 | /// assert!(parse_language_code("en-us").is_ok()); 42 | /// 43 | /// assert!(parse_language_code("ca-ES-valencia").is_ok()); 44 | /// 45 | /// assert!(parse_language_code("abcd").is_err()); 46 | /// 47 | /// assert!(parse_language_code("en_US").is_err()); 48 | /// 49 | /// assert!(parse_language_code("fr-french").is_err()); 50 | /// 51 | /// assert!(parse_language_code("some random text").is_err()); 52 | /// ``` 53 | #[cfg(feature = "cli")] 54 | pub fn parse_language_code(v: &str) -> Result { 55 | #[inline] 56 | fn is_match(v: &str) -> bool { 57 | let mut splits = v.split('-'); 58 | 59 | match splits.next() { 60 | Some(s) 61 | if (s.len() == 2 || s.len() == 3) && s.chars().all(|c| c.is_ascii_alphabetic()) => { 62 | }, 63 | _ => return false, 64 | } 65 | 66 | match splits.next() { 67 | Some(s) if s.len() != 2 || s.chars().any(|c| !c.is_ascii_alphabetic()) => return false, 68 | Some(_) => (), 69 | None => return true, 70 | } 71 | for s in splits { 72 | if !s.chars().all(|c| c.is_ascii_alphabetic()) { 73 | return false; 74 | } 75 | } 76 | true 77 | } 78 | 79 | if v == "auto" || is_match(v) { 80 | Ok(v.to_string()) 81 | } else { 82 | Err(Error::InvalidValue( 83 | "The value should be `\"auto\"` or match regex pattern: \ 84 | ^[a-zA-Z]{2,3}(-[a-zA-Z]{2}(-[a-zA-Z]+)*)?$" 85 | .to_string(), 86 | )) 87 | } 88 | } 89 | 90 | /// Utility function to serialize a optional vector a strings 91 | /// into a comma separated list of strings. 92 | /// 93 | /// This is required by reqwest's RequestBuilder, otherwise it 94 | /// will not work. 95 | pub(crate) fn serialize_option_vec_string( 96 | v: &Option>, 97 | serializer: S, 98 | ) -> std::result::Result 99 | where 100 | S: Serializer, 101 | { 102 | match v { 103 | Some(v) if v.len() == 1 => serializer.serialize_str(&v[0]), 104 | Some(v) if v.len() > 1 => { 105 | let size = v.iter().map(|s| s.len()).sum::() + v.len() - 1; 106 | let mut string = String::with_capacity(size); 107 | 108 | string.push_str(&v[0]); 109 | 110 | for s in &v[1..] { 111 | string.push(','); 112 | string.push_str(s); 113 | } 114 | 115 | serializer.serialize_str(string.as_ref()) 116 | }, 117 | _ => serializer.serialize_none(), 118 | } 119 | } 120 | 121 | #[cfg(test)] 122 | mod tests { 123 | use super::*; 124 | 125 | #[test] 126 | fn test_serialize_option_vec_string() { 127 | use serde::Serialize; 128 | 129 | #[derive(Serialize)] 130 | struct Foo { 131 | #[serde(serialize_with = "serialize_option_vec_string")] 132 | values: Option>, 133 | } 134 | 135 | impl Foo { 136 | fn new(values: I) -> Self 137 | where 138 | I: IntoIterator, 139 | T: ToString, 140 | { 141 | Self { 142 | values: Some(values.into_iter().map(|v| v.to_string()).collect()), 143 | } 144 | } 145 | fn none() -> Self { 146 | Self { values: None } 147 | } 148 | } 149 | 150 | let got = serde_json::to_string(&Foo::new(vec!["en-US", "de-DE"])).unwrap(); 151 | assert_eq!(got, r#"{"values":"en-US,de-DE"}"#); 152 | 153 | let got = serde_json::to_string(&Foo::new(vec!["en-US"])).unwrap(); 154 | assert_eq!(got, r#"{"values":"en-US"}"#); 155 | 156 | let got = serde_json::to_string(&Foo::new(Vec::::new())).unwrap(); 157 | assert_eq!(got, r#"{"values":null}"#); 158 | 159 | let got = serde_json::to_string(&Foo::none()).unwrap(); 160 | assert_eq!(got, r#"{"values":null}"#); 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/api/check/requests.rs: -------------------------------------------------------------------------------- 1 | //! Structures for `check` requests. 2 | 3 | use super::{serialize_option_vec_string, Data}; 4 | use std::{borrow::Cow, mem, ops::Deref}; 5 | 6 | #[cfg(feature = "cli")] 7 | use clap::ValueEnum; 8 | use lifetime::IntoStatic; 9 | use serde::{Serialize, Serializer}; 10 | 11 | use crate::error::{Error, Result}; 12 | 13 | /// Possible levels for additional rules. 14 | /// 15 | /// Currently, `Level::Picky` adds additional rules 16 | /// with respect to `Level::Default`. 17 | #[derive(Clone, Default, Debug, PartialEq, Eq, Serialize, Hash)] 18 | #[cfg_attr(feature = "cli", derive(ValueEnum))] 19 | #[serde(rename_all = "lowercase")] 20 | #[non_exhaustive] 21 | pub enum Level { 22 | /// Default level. 23 | #[default] 24 | Default, 25 | /// Picky level. 26 | Picky, 27 | } 28 | 29 | impl Level { 30 | /// Return `true` if current level is the default one. 31 | /// 32 | /// # Examples 33 | /// 34 | /// ``` 35 | /// # use languagetool_rust::api::check::Level; 36 | /// 37 | /// let level: Level = Default::default(); 38 | /// 39 | /// assert!(level.is_default()); 40 | /// ``` 41 | #[must_use] 42 | pub fn is_default(&self) -> bool { 43 | *self == Level::default() 44 | } 45 | } 46 | 47 | /// Split a string into as few fragments as possible, where each fragment 48 | /// contains (if possible) a maximum of `n` characters. Pattern str `pat` is 49 | /// used for splitting. 50 | /// 51 | /// # Examples 52 | /// 53 | /// ``` 54 | /// # use languagetool_rust::api::check::split_len; 55 | /// let s = "I have so many friends. 56 | /// They are very funny. 57 | /// I think I am very lucky to have them. 58 | /// One day, I will write them a poem. 59 | /// But, in the meantime, I write code. 60 | /// "; 61 | /// 62 | /// let split = split_len(&s, 40, "\n"); 63 | /// 64 | /// assert_eq!(split.join(""), s); 65 | /// assert_eq!( 66 | /// split, 67 | /// vec![ 68 | /// "I have so many friends.\n", 69 | /// "They are very funny.\n", 70 | /// "I think I am very lucky to have them.\n", 71 | /// "One day, I will write them a poem.\n", 72 | /// "But, in the meantime, I write code.\n" 73 | /// ] 74 | /// ); 75 | /// 76 | /// let split = split_len(&s, 80, "\n"); 77 | /// 78 | /// assert_eq!( 79 | /// split, 80 | /// vec![ 81 | /// "I have so many friends.\nThey are very funny.\n", 82 | /// "I think I am very lucky to have them.\nOne day, I will write them a poem.\n", 83 | /// "But, in the meantime, I write code.\n" 84 | /// ] 85 | /// ); 86 | /// 87 | /// let s = "I have so many friends. 88 | /// They are very funny. 89 | /// I think I am very lucky to have them. 90 | /// 91 | /// One day, I will write them a poem. 92 | /// But, in the meantime, I write code. 93 | /// "; 94 | /// 95 | /// let split = split_len(&s, 80, "\n\n"); 96 | /// 97 | /// println!("{:?}", split); 98 | /// 99 | /// assert_eq!( 100 | /// split, 101 | /// vec![ 102 | /// "I have so many friends.\nThey are very funny.\nI think I am very lucky to have \ 103 | /// them.\n\n", 104 | /// "One day, I will write them a poem.\nBut, in the meantime, I write code.\n" 105 | /// ] 106 | /// ); 107 | /// ``` 108 | #[must_use] 109 | pub fn split_len<'source>(s: &'source str, n: usize, pat: &str) -> Vec<&'source str> { 110 | let mut vec: Vec<&'source str> = Vec::with_capacity(s.len() / n); 111 | let mut splits = s.split_inclusive(pat); 112 | 113 | let mut start = 0; 114 | let mut i = 0; 115 | 116 | if let Some(split) = splits.next() { 117 | vec.push(split); 118 | } else { 119 | return Vec::new(); 120 | } 121 | 122 | for split in splits { 123 | let new_len = vec[i].len() + split.len(); 124 | if new_len < n { 125 | vec[i] = &s[start..start + new_len]; 126 | } else { 127 | vec.push(split); 128 | start += vec[i].len(); 129 | i += 1; 130 | } 131 | } 132 | 133 | vec 134 | } 135 | 136 | /// Default value for [`Request::language`]. 137 | pub const DEFAULT_LANGUAGE: &str = "auto"; 138 | 139 | /// Custom serialization for [`Request::language`]. 140 | fn serialize_language(lang: &str, s: S) -> std::result::Result 141 | where 142 | S: Serializer, 143 | { 144 | s.serialize_str(if lang.is_empty() { 145 | DEFAULT_LANGUAGE 146 | } else { 147 | lang 148 | }) 149 | } 150 | 151 | /// LanguageTool POST check request. 152 | /// 153 | /// The main feature - check a text with LanguageTool for possible style and 154 | /// grammar issues. 155 | /// 156 | /// The structure below tries to follow as closely as possible the JSON API 157 | /// described [here](https://languagetool.org/http-api/swagger-ui/#!/default/post_check). 158 | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Hash, IntoStatic)] 159 | #[serde(rename_all = "camelCase")] 160 | #[non_exhaustive] 161 | pub struct Request<'source> { 162 | /// The text to be checked. This or 'data' is required. 163 | #[serde(skip_serializing_if = "Option::is_none")] 164 | pub text: Option>, 165 | /// The text to be checked, given as a JSON document that specifies what's 166 | /// text and what's markup. This or 'text' is required. 167 | /// 168 | /// Markup will be ignored when looking for errors. Example text: 169 | /// ```html 170 | /// A test 171 | /// ``` 172 | /// JSON for the example text: 173 | /// ```json 174 | /// {"annotation":[ 175 | /// {"text": "A "}, 176 | /// {"markup": ""}, 177 | /// {"text": "test"}, 178 | /// {"markup": ""} 179 | /// ]} 180 | /// ``` 181 | /// If you have markup that should be interpreted as whitespace, like `

` 182 | /// in HTML, you can have it interpreted like this: 183 | /// 184 | /// ```json 185 | /// {"markup": "

", "interpretAs": "\n\n"} 186 | /// ``` 187 | /// The 'data' feature is not limited to HTML or XML, it can be used for any 188 | /// kind of markup. Entities will need to be expanded in this input. 189 | #[serde(skip_serializing_if = "Option::is_none")] 190 | pub data: Option>, 191 | /// A language code like `en-US`, `de-DE`, `fr`, or `auto` to guess the 192 | /// language automatically (see `preferredVariants` below). 193 | /// 194 | /// For languages with variants (English, German, Portuguese) spell checking 195 | /// will only be activated when you specify the variant, e.g. `en-GB` 196 | /// instead of just `en`. 197 | #[serde(serialize_with = "serialize_language")] 198 | pub language: String, 199 | /// Set to get Premium API access: Your username/email as used to log in at 200 | /// languagetool.org. 201 | #[serde(skip_serializing_if = "Option::is_none")] 202 | pub username: Option, 203 | /// Set to get Premium API access: your API key (see ). 204 | #[serde(skip_serializing_if = "Option::is_none")] 205 | pub api_key: Option, 206 | /// Comma-separated list of dictionaries to include words from; uses special 207 | /// default dictionary if this is unset. 208 | #[serde(serialize_with = "serialize_option_vec_string")] 209 | pub dicts: Option>, 210 | /// A language code of the user's native language, enabling false friends 211 | /// checks for some language pairs. 212 | #[serde(skip_serializing_if = "Option::is_none")] 213 | pub mother_tongue: Option, 214 | /// Comma-separated list of preferred language variants. 215 | /// 216 | /// The language detector used with `language=auto` can detect e.g. English, 217 | /// but it cannot decide whether British English or American English is 218 | /// used. Thus this parameter can be used to specify the preferred variants 219 | /// like `en-GB` and `de-AT`. Only available with `language=auto`. You 220 | /// should set variants for at least German and English, as otherwise the 221 | /// spell checking will not work for those, as no spelling dictionary can be 222 | /// selected for just `en` or `de`. 223 | #[serde(serialize_with = "serialize_option_vec_string")] 224 | pub preferred_variants: Option>, 225 | /// IDs of rules to be enabled, comma-separated. 226 | #[serde(serialize_with = "serialize_option_vec_string")] 227 | pub enabled_rules: Option>, 228 | /// IDs of rules to be disabled, comma-separated. 229 | #[serde(serialize_with = "serialize_option_vec_string")] 230 | pub disabled_rules: Option>, 231 | /// IDs of categories to be enabled, comma-separated. 232 | #[serde(serialize_with = "serialize_option_vec_string")] 233 | pub enabled_categories: Option>, 234 | /// IDs of categories to be disabled, comma-separated. 235 | #[serde(serialize_with = "serialize_option_vec_string")] 236 | pub disabled_categories: Option>, 237 | /// If true, only the rules and categories whose IDs are specified with 238 | /// `enabledRules` or `enabledCategories` are enabled. 239 | #[serde(skip_serializing_if = "std::ops::Not::not")] 240 | pub enabled_only: bool, 241 | /// If set to `picky`, additional rules will be activated, i.e. rules that 242 | /// you might only find useful when checking formal text. 243 | #[serde(skip_serializing_if = "Level::is_default")] 244 | pub level: Level, 245 | } 246 | 247 | impl<'source> Request<'source> { 248 | /// Create a new empty request with language set to `"auto"`. 249 | #[must_use] 250 | pub fn new() -> Self { 251 | Self { 252 | language: "auto".to_string(), 253 | ..Default::default() 254 | } 255 | } 256 | 257 | /// Set the text to be checked and remove potential data field. 258 | #[must_use] 259 | pub fn with_text>>(mut self, text: T) -> Self { 260 | self.text = Some(text.into()); 261 | self.data = None; 262 | self 263 | } 264 | 265 | /// Set the data to be checked and remove potential text field. 266 | #[must_use] 267 | pub fn with_data(mut self, data: Data<'source>) -> Self { 268 | self.data = Some(data); 269 | self.text = None; 270 | self 271 | } 272 | 273 | /// Set the data (obtained from string) to be checked and remove potential 274 | /// text field 275 | pub fn with_data_str(self, data: &str) -> serde_json::Result { 276 | serde_json::from_str(data).map(|data| self.with_data(data)) 277 | } 278 | 279 | /// Set the language of the text / data. 280 | #[must_use] 281 | pub fn with_language(mut self, language: String) -> Self { 282 | self.language = language; 283 | self 284 | } 285 | 286 | /// Return the text within the request. 287 | /// 288 | /// # Errors 289 | /// 290 | /// If both `self.text` and `self.data` are [`None`]. 291 | /// If any data annotation does not contain text or markup. 292 | pub fn try_get_text(&self) -> Result> { 293 | if let Some(ref text) = self.text { 294 | Ok(text.clone()) 295 | } else if let Some(ref data) = self.data { 296 | match data.annotation.len() { 297 | 0 => Ok(Default::default()), 298 | 1 => data.annotation[0].try_get_text(), 299 | _ => { 300 | let mut text = String::new(); 301 | 302 | for da in data.annotation.iter() { 303 | text.push_str(da.try_get_text()?.deref()); 304 | } 305 | 306 | Ok(Cow::Owned(text)) 307 | }, 308 | } 309 | } else { 310 | Err(Error::InvalidRequest( 311 | "missing either text or data field".to_string(), 312 | )) 313 | } 314 | } 315 | 316 | /// Return a copy of the text within the request. 317 | /// Call [`Request::try_get_text`] but panic on error. 318 | /// 319 | /// # Panics 320 | /// 321 | /// If both `self.text` and `self.data` are [`None`]. 322 | /// If any data annotation does not contain text or markup. 323 | #[must_use] 324 | pub fn get_text(&self) -> Cow<'source, str> { 325 | self.try_get_text().unwrap() 326 | } 327 | 328 | /// Split this request into multiple, using [`split_len`] function to split 329 | /// text. 330 | /// 331 | /// # Errors 332 | /// 333 | /// If `self.text` is [`None`] and `self.data` is [`None`]. 334 | pub fn try_split(mut self, n: usize, pat: &str) -> Result> { 335 | // DATA ANNOTATIONS 336 | if let Some(data) = mem::take(&mut self.data) { 337 | return Ok(data 338 | .split(n, pat) 339 | .into_iter() 340 | .map(|d| self.clone().with_data(d)) 341 | .collect()); 342 | } 343 | 344 | // TEXT 345 | let text = mem::take(&mut self.text) 346 | .ok_or_else(|| Error::InvalidRequest("missing text or data field".to_string()))?; 347 | let string: &str = match &text { 348 | Cow::Owned(s) => s.as_str(), 349 | Cow::Borrowed(s) => s, 350 | }; 351 | 352 | Ok(split_len(string, n, pat) 353 | .iter() 354 | .map(|text_fragment| { 355 | self.clone() 356 | .with_text(Cow::Owned(text_fragment.to_string())) 357 | }) 358 | .collect()) 359 | } 360 | 361 | /// Split this request into multiple, using [`split_len`] function to split 362 | /// text. 363 | /// Call [`Request::try_split`] but panic on error. 364 | /// 365 | /// # Panics 366 | /// 367 | /// If `self.text` is none. 368 | #[must_use] 369 | pub fn split(self, n: usize, pat: &str) -> Vec { 370 | self.try_split(n, pat).unwrap() 371 | } 372 | } 373 | 374 | #[cfg(test)] 375 | mod tests { 376 | use crate::api::check::DataAnnotation; 377 | 378 | use super::*; 379 | 380 | #[test] 381 | fn test_with_text() { 382 | let req = Request::default().with_text("hello"); 383 | 384 | assert_eq!(req.text.unwrap(), "hello"); 385 | assert!(req.data.is_none()); 386 | } 387 | 388 | #[test] 389 | fn test_with_data() { 390 | let req = 391 | Request::default().with_data([DataAnnotation::new_text("hello")].into_iter().collect()); 392 | 393 | assert_eq!( 394 | req.data.unwrap().annotation[0], 395 | DataAnnotation::new_text("hello") 396 | ); 397 | } 398 | 399 | #[test] 400 | fn test_with_data_str() { 401 | let req = Request::default() 402 | .with_data_str("{\"annotation\":[{\"text\": \"hello\"}]}") 403 | .unwrap(); 404 | assert_eq!( 405 | req.data.unwrap().annotation[0], 406 | DataAnnotation::new_text("hello") 407 | ); 408 | 409 | // Not a data annotation 410 | assert!(Request::default().with_data_str("hello").is_err()); 411 | } 412 | 413 | #[test] 414 | fn test_with_language() { 415 | assert_eq!( 416 | Request::default().with_language("en-US".into()).language, 417 | "en-US".to_string() 418 | ); 419 | } 420 | } 421 | -------------------------------------------------------------------------------- /src/api/check/responses.rs: -------------------------------------------------------------------------------- 1 | //! Structures for `check` responses. 2 | 3 | use std::{borrow::Cow, marker::PhantomData, ops::Deref}; 4 | 5 | #[cfg(feature = "annotate")] 6 | use annotate_snippets::{ 7 | display_list::{DisplayList, FormatOptions}, 8 | snippet::{Annotation, AnnotationType, Slice, Snippet, SourceAnnotation}, 9 | }; 10 | use lifetime::IntoStatic; 11 | use serde::{Deserialize, Serialize}; 12 | 13 | /// Detected language from check request. 14 | #[allow(clippy::derive_partial_eq_without_eq)] 15 | #[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] 16 | #[non_exhaustive] 17 | pub struct DetectedLanguage { 18 | /// Language code, e.g., `"sk-SK"` for Slovak. 19 | pub code: String, 20 | /// Confidence level, from 0 to 1. 21 | #[cfg(feature = "unstable")] 22 | pub confidence: Option, 23 | /// Language name, e.g., `"Slovak"`. 24 | pub name: String, 25 | /// Source (file) for the language detection. 26 | #[cfg(feature = "unstable")] 27 | pub source: Option, 28 | } 29 | 30 | /// Language information in check response. 31 | #[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] 32 | #[serde(rename_all = "camelCase")] 33 | #[non_exhaustive] 34 | pub struct LanguageResponse { 35 | /// Language code, e.g., `"sk-SK"` for Slovak. 36 | pub code: String, 37 | /// Detected language from provided request. 38 | pub detected_language: DetectedLanguage, 39 | /// Language name, e.g., `"Slovak"`. 40 | pub name: String, 41 | } 42 | 43 | /// Match context in check response. 44 | #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] 45 | #[non_exhaustive] 46 | pub struct Context { 47 | /// Length of the match. 48 | pub length: usize, 49 | /// Char index at which the match starts. 50 | pub offset: usize, 51 | /// Contextual text around the match. 52 | pub text: String, 53 | } 54 | 55 | /// More context, post-processed in check response. 56 | #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] 57 | #[non_exhaustive] 58 | pub struct MoreContext { 59 | /// Line number where match occurred. 60 | pub line_number: usize, 61 | /// Char index at which the match starts on the current line. 62 | pub line_offset: usize, 63 | } 64 | 65 | /// Possible replacement for a given match in check response. 66 | #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] 67 | #[non_exhaustive] 68 | pub struct Replacement { 69 | /// Possible replacement value. 70 | pub value: String, 71 | } 72 | 73 | impl From for Replacement { 74 | fn from(value: String) -> Self { 75 | Self { value } 76 | } 77 | } 78 | 79 | impl From<&str> for Replacement { 80 | fn from(value: &str) -> Self { 81 | value.to_string().into() 82 | } 83 | } 84 | 85 | /// A rule category. 86 | #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] 87 | #[non_exhaustive] 88 | pub struct Category { 89 | /// Category id. 90 | pub id: String, 91 | /// Category name. 92 | pub name: String, 93 | } 94 | 95 | /// A possible url of a rule in a check response. 96 | #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] 97 | #[non_exhaustive] 98 | pub struct Url { 99 | /// Url value. 100 | pub value: String, 101 | } 102 | 103 | /// The rule that was not satisfied in a given match. 104 | #[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)] 105 | #[serde(rename_all = "camelCase")] 106 | #[non_exhaustive] 107 | pub struct Rule { 108 | /// Rule category. 109 | pub category: Category, 110 | /// Rule description. 111 | pub description: String, 112 | /// Rule id. 113 | pub id: String, 114 | /// Indicate if the rule is from the premium API. 115 | #[cfg(feature = "unstable")] 116 | pub is_premium: Option, 117 | /// Issue type. 118 | pub issue_type: String, 119 | /// Rule source file. 120 | #[cfg(feature = "unstable")] 121 | pub source_file: Option, 122 | /// Rule sub id. 123 | pub sub_id: Option, 124 | /// Rule list of urls. 125 | pub urls: Option>, 126 | } 127 | 128 | /// Type of given match. 129 | #[derive(PartialEq, Eq, Clone, Debug, Deserialize, Serialize)] 130 | #[serde(rename_all = "camelCase")] 131 | #[non_exhaustive] 132 | pub struct Type { 133 | /// Type name. 134 | pub type_name: String, 135 | } 136 | 137 | /// Grammatical error match. 138 | #[derive(PartialEq, Eq, Clone, Debug, Deserialize, Serialize)] 139 | #[serde(rename_all = "camelCase")] 140 | #[non_exhaustive] 141 | pub struct Match { 142 | /// Match context. 143 | pub context: Context, 144 | /// Unknown: please fill a [PR](https://github.com/jeertmans/languagetool-rust/pulls) of your 145 | /// know that this attribute is used for. 146 | #[cfg(feature = "unstable")] 147 | pub context_for_sure_match: isize, 148 | /// Unknown: please fill a [PR](https://github.com/jeertmans/languagetool-rust/pulls) of your 149 | /// know that this attribute is used for. 150 | #[cfg(feature = "unstable")] 151 | pub ignore_for_incomplete_sentence: bool, 152 | /// Match length. 153 | pub length: usize, 154 | /// Error message. 155 | pub message: String, 156 | /// More context to match, post-processed using original text. 157 | #[serde(skip_serializing_if = "Option::is_none")] 158 | pub more_context: Option, 159 | /// Char index at which the match starts. 160 | pub offset: usize, 161 | /// List of possible replacements (if applies). 162 | pub replacements: Vec, 163 | /// Match rule that was not satisfied. 164 | pub rule: Rule, 165 | /// Sentence in which the error was found. 166 | pub sentence: String, 167 | /// Short message about the error. 168 | pub short_message: String, 169 | /// Match type. 170 | #[cfg(feature = "unstable")] 171 | #[serde(rename = "type")] 172 | pub type_: Type, 173 | } 174 | 175 | /// LanguageTool software details. 176 | #[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)] 177 | #[serde(rename_all = "camelCase")] 178 | #[non_exhaustive] 179 | pub struct Software { 180 | /// LanguageTool API version. 181 | pub api_version: usize, 182 | /// Some information about build date. 183 | pub build_date: String, 184 | /// Name (should be `"LanguageTool"`). 185 | pub name: String, 186 | /// Tell whether the server uses premium API or not. 187 | pub premium: bool, 188 | /// Sentence that indicates if using premium API would find more errors. 189 | #[cfg(feature = "unstable")] 190 | pub premium_hint: Option, 191 | /// Unknown: please fill a [PR](https://github.com/jeertmans/languagetool-rust/pulls) of your 192 | /// know that this attribute is used for. 193 | pub status: String, 194 | /// LanguageTool version. 195 | pub version: String, 196 | } 197 | 198 | /// Warnings about check response. 199 | #[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)] 200 | #[serde(rename_all = "camelCase")] 201 | #[non_exhaustive] 202 | pub struct Warnings { 203 | /// Indicate if results are incomplete. 204 | pub incomplete_results: bool, 205 | } 206 | 207 | /// LanguageTool POST check response. 208 | #[derive(Clone, PartialEq, Debug, Deserialize, Serialize)] 209 | #[serde(rename_all = "camelCase")] 210 | #[non_exhaustive] 211 | pub struct Response { 212 | /// Language information. 213 | pub language: LanguageResponse, 214 | /// List of error matches. 215 | pub matches: Vec, 216 | /// Ranges ([start, end]) of sentences. 217 | #[cfg(feature = "unstable")] 218 | pub sentence_ranges: Option>, 219 | /// LanguageTool software information. 220 | pub software: Software, 221 | /// Possible warnings. 222 | #[cfg(feature = "unstable")] 223 | pub warnings: Option, 224 | } 225 | 226 | impl Response { 227 | /// Return an iterator over matches. 228 | pub fn iter_matches(&self) -> std::slice::Iter<'_, Match> { 229 | self.matches.iter() 230 | } 231 | 232 | /// Return an iterator over mutable matches. 233 | pub fn iter_matches_mut(&mut self) -> std::slice::IterMut<'_, Match> { 234 | self.matches.iter_mut() 235 | } 236 | 237 | /// Creates an annotated string from current response. 238 | #[cfg(feature = "annotate")] 239 | #[must_use] 240 | pub fn annotate(&self, text: &str, origin: Option<&str>, color: bool) -> String { 241 | if self.matches.is_empty() { 242 | return "No errors were found in provided text".to_string(); 243 | } 244 | let replacements: Vec<_> = self 245 | .matches 246 | .iter() 247 | .map(|m| { 248 | m.replacements.iter().fold(String::new(), |mut acc, r| { 249 | if !acc.is_empty() { 250 | acc.push_str(", "); 251 | } 252 | acc.push_str(&r.value); 253 | acc 254 | }) 255 | }) 256 | .collect(); 257 | 258 | let snippets = self.matches.iter().zip(replacements.iter()).map(|(m, r)| { 259 | Snippet { 260 | title: Some(Annotation { 261 | label: Some(&m.message), 262 | id: Some(&m.rule.id), 263 | annotation_type: AnnotationType::Error, 264 | }), 265 | footer: vec![], 266 | slices: vec![Slice { 267 | source: &m.context.text, 268 | line_start: 1 + text.chars().take(m.offset).filter(|c| *c == '\n').count(), 269 | origin, 270 | fold: true, 271 | annotations: vec![ 272 | SourceAnnotation { 273 | label: &m.rule.description, 274 | annotation_type: AnnotationType::Error, 275 | range: (m.context.offset, m.context.offset + m.context.length), 276 | }, 277 | SourceAnnotation { 278 | label: r, 279 | annotation_type: AnnotationType::Help, 280 | range: (m.context.offset, m.context.offset + m.context.length), 281 | }, 282 | ], 283 | }], 284 | opt: FormatOptions { 285 | color, 286 | ..Default::default() 287 | }, 288 | } 289 | }); 290 | 291 | let mut annotation = String::new(); 292 | 293 | for snippet in snippets { 294 | if !annotation.is_empty() { 295 | annotation.push('\n'); 296 | } 297 | annotation.push_str(&DisplayList::from(snippet).to_string()); 298 | } 299 | annotation 300 | } 301 | 302 | /// Joins the given [`super::Request`] to the current one. 303 | /// 304 | /// This is especially useful when a request was split into multiple 305 | /// requests. 306 | #[must_use] 307 | pub fn append(mut self, mut other: Self) -> Self { 308 | #[cfg(feature = "unstable")] 309 | if let Some(ref mut sr_other) = other.sentence_ranges { 310 | match self.sentence_ranges { 311 | Some(ref mut sr_self) => { 312 | sr_self.append(sr_other); 313 | }, 314 | None => { 315 | std::mem::swap(&mut self.sentence_ranges, &mut other.sentence_ranges); 316 | }, 317 | } 318 | } 319 | 320 | self.matches.append(&mut other.matches); 321 | 322 | self 323 | } 324 | } 325 | 326 | /// Check response with additional context. 327 | /// 328 | /// This structure exists to keep a link between a check response 329 | /// and the original text that was checked. 330 | #[derive(Debug, Clone, PartialEq, IntoStatic)] 331 | pub struct ResponseWithContext<'source> { 332 | /// Original text that was checked by LT. 333 | pub text: Cow<'source, str>, 334 | /// Check response. 335 | pub response: Response, 336 | /// Text's length. 337 | pub text_length: usize, 338 | } 339 | 340 | impl Deref for ResponseWithContext<'_> { 341 | type Target = Response; 342 | fn deref(&self) -> &Self::Target { 343 | &self.response 344 | } 345 | } 346 | 347 | impl<'source> ResponseWithContext<'source> { 348 | /// Bind a check response with its original text. 349 | #[must_use] 350 | pub fn new(text: Cow<'source, str>, response: Response) -> Self { 351 | let text_length = text.chars().count(); 352 | 353 | // Add more context to response 354 | Self { 355 | text, 356 | response, 357 | text_length, 358 | } 359 | } 360 | 361 | /// Return an iterator over matches. 362 | pub fn iter_matches(&'source self) -> std::slice::Iter<'source, Match> { 363 | self.response.iter_matches() 364 | } 365 | 366 | /// Return an iterator over mutable matches. 367 | pub fn iter_matches_mut(&mut self) -> std::slice::IterMut<'_, Match> { 368 | self.response.iter_matches_mut() 369 | } 370 | 371 | /// Return an iterator over matches and corresponding line number and line 372 | /// offset. 373 | #[must_use] 374 | pub fn iter_match_positions(&self) -> MatchPositions<'_, '_, std::slice::Iter<'_, Match>> { 375 | self.into() 376 | } 377 | 378 | /// Append a check response to the current while 379 | /// adjusting the matches' offsets. 380 | /// 381 | /// This is especially useful when a text was split in multiple requests. 382 | #[must_use] 383 | pub fn append(mut self, mut other: Self) -> Self { 384 | let offset = self.text_length; 385 | for m in other.iter_matches_mut() { 386 | m.offset += offset; 387 | } 388 | 389 | #[cfg(feature = "unstable")] 390 | if let Some(ref mut sr_other) = other.response.sentence_ranges { 391 | match self.response.sentence_ranges { 392 | Some(ref mut sr_self) => { 393 | sr_self.append(sr_other); 394 | }, 395 | None => { 396 | std::mem::swap( 397 | &mut self.response.sentence_ranges, 398 | &mut other.response.sentence_ranges, 399 | ); 400 | }, 401 | } 402 | } 403 | 404 | self.response.matches.append(&mut other.response.matches); 405 | 406 | self.text.to_mut().push_str(&other.text); 407 | self.text_length += other.text_length; 408 | 409 | self 410 | } 411 | } 412 | 413 | impl<'source> From> for Response { 414 | fn from(mut resp: ResponseWithContext<'source>) -> Self { 415 | for (line_number, line_offset, m) in MatchPositions::new(&resp.text, &mut resp.response) { 416 | m.more_context = Some(MoreContext { 417 | line_number, 418 | line_offset, 419 | }); 420 | } 421 | 422 | resp.response 423 | } 424 | } 425 | 426 | /// Iterator over matches and their corresponding line number and line offset. 427 | #[derive(Clone, Debug)] 428 | pub struct MatchPositions<'source, 'response, T: Iterator + 'response> { 429 | text_chars: std::str::Chars<'source>, 430 | matches: T, 431 | line_number: usize, 432 | line_offset: usize, 433 | offset: usize, 434 | _marker: PhantomData<&'response ()>, 435 | } 436 | 437 | impl<'source, 'response> MatchPositions<'source, 'response, std::slice::IterMut<'response, Match>> { 438 | fn new(text: &'source str, response: &'response mut Response) -> Self { 439 | MatchPositions { 440 | _marker: Default::default(), 441 | text_chars: text.chars(), 442 | matches: response.iter_matches_mut(), 443 | line_number: 1, 444 | line_offset: 0, 445 | offset: 0, 446 | } 447 | } 448 | } 449 | 450 | impl<'source, 'response> From<&'source ResponseWithContext<'source>> 451 | for MatchPositions<'source, 'response, std::slice::Iter<'response, Match>> 452 | where 453 | 'source: 'response, 454 | { 455 | fn from(response: &'source ResponseWithContext) -> Self { 456 | MatchPositions { 457 | _marker: Default::default(), 458 | text_chars: response.text.chars(), 459 | matches: response.iter_matches(), 460 | line_number: 1, 461 | line_offset: 0, 462 | offset: 0, 463 | } 464 | } 465 | } 466 | 467 | impl<'source, 'response> From<&'source mut ResponseWithContext<'source>> 468 | for MatchPositions<'source, 'response, std::slice::IterMut<'response, Match>> 469 | where 470 | 'source: 'response, 471 | { 472 | fn from(response: &'source mut ResponseWithContext) -> Self { 473 | MatchPositions { 474 | _marker: Default::default(), 475 | text_chars: response.text.chars(), 476 | matches: response.response.iter_matches_mut(), 477 | line_number: 1, 478 | line_offset: 0, 479 | offset: 0, 480 | } 481 | } 482 | } 483 | 484 | impl<'response, T: Iterator + 'response> MatchPositions<'_, 'response, T> { 485 | /// Set the line number to a given value. 486 | /// 487 | /// By default, the first line number is 1. 488 | pub fn set_line_number(mut self, line_number: usize) -> Self { 489 | self.line_number = line_number; 490 | self 491 | } 492 | 493 | fn update_line_number_and_offset(&mut self, m: &Match) { 494 | let n = m.offset - self.offset; 495 | for _ in 0..n { 496 | match self.text_chars.next() { 497 | Some('\n') => { 498 | self.line_number += 1; 499 | self.line_offset = 0; 500 | }, 501 | None => { 502 | panic!( 503 | "text is shorter than expected, are you sure this text was the one used \ 504 | for the check request?" 505 | ) 506 | }, 507 | _ => self.line_offset += 1, 508 | } 509 | } 510 | self.offset = m.offset; 511 | } 512 | } 513 | 514 | impl<'source, 'response> Iterator 515 | for MatchPositions<'source, 'response, std::slice::Iter<'response, Match>> 516 | where 517 | 'response: 'source, 518 | { 519 | type Item = (usize, usize, &'source Match); 520 | 521 | fn next(&mut self) -> Option { 522 | if let Some(m) = self.matches.next() { 523 | self.update_line_number_and_offset(m); 524 | Some((self.line_number, self.line_offset, m)) 525 | } else { 526 | None 527 | } 528 | } 529 | } 530 | 531 | impl<'source, 'response> Iterator 532 | for MatchPositions<'source, 'response, std::slice::IterMut<'response, Match>> 533 | where 534 | 'response: 'source, 535 | { 536 | type Item = (usize, usize, &'source mut Match); 537 | 538 | fn next(&mut self) -> Option { 539 | if let Some(m) = self.matches.next() { 540 | self.update_line_number_and_offset(m); 541 | Some((self.line_number, self.line_offset, m)) 542 | } else { 543 | None 544 | } 545 | } 546 | } 547 | -------------------------------------------------------------------------------- /src/api/languages.rs: -------------------------------------------------------------------------------- 1 | //! Structures for `languages` requests and responses. 2 | 3 | use serde::{Deserialize, Serialize}; 4 | 5 | #[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)] 6 | #[serde(rename_all = "camelCase")] 7 | #[non_exhaustive] 8 | /// Language information 9 | pub struct Language { 10 | /// Language name, e.g., `"Ukrainian"`. 11 | pub name: String, 12 | /// Language (short) code, e.g., `"uk"`. 13 | pub code: String, 14 | /// Language long code, e.g., `"uk-UA"`. 15 | pub long_code: String, 16 | } 17 | 18 | /// LanguageTool GET languages response. 19 | /// 20 | /// List of all supported languages. 21 | pub type Response = Vec; 22 | -------------------------------------------------------------------------------- /src/api/mod.rs: -------------------------------------------------------------------------------- 1 | //! Raw bindings to the LanguageTool API v1.1.2. 2 | //! 3 | //! The current bindings were generated using the 4 | //! [HTTP API documentation](https://languagetool.org/http-api/). 5 | //! 6 | //! Unfortunately, the LanguageTool API is not as documented as we could 7 | //! hope, and requests might return undocumented fields. Those are de-serialized 8 | //! to the `undocumented` field. 9 | pub mod check; 10 | pub mod languages; 11 | pub mod server; 12 | pub mod words; 13 | -------------------------------------------------------------------------------- /src/api/words/add.rs: -------------------------------------------------------------------------------- 1 | //! Structures for `words` requests and responses related to adding. 2 | 3 | use super::*; 4 | 5 | /// LanguageTool POST words add request. 6 | /// 7 | /// Add a word to one of the user's personal dictionaries. Please note that 8 | /// this feature is considered to be used for personal dictionaries 9 | /// which must not contain more than 500 words. If this is an issue for 10 | /// you, please contact us. 11 | #[cfg_attr(feature = "cli", derive(Args))] 12 | #[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize, Hash)] 13 | #[non_exhaustive] 14 | pub struct Request { 15 | /// The word to be added. Must not be a phrase, i.e., cannot contain 16 | /// white space. The word is added to a global dictionary that 17 | /// applies to all languages. 18 | #[cfg_attr(feature = "cli", clap(required = true, value_parser = parse_word))] 19 | pub word: String, 20 | /// Login arguments. 21 | #[cfg_attr(feature = "cli", clap(flatten))] 22 | #[serde(flatten)] 23 | pub login: LoginArgs, 24 | /// Name of the dictionary to add the word to; non-existent dictionaries 25 | /// are created after calling this; if unset, adds to special 26 | /// default dictionary. 27 | #[cfg_attr(feature = "cli", clap(long))] 28 | #[serde(skip_serializing_if = "Option::is_none")] 29 | pub dict: Option, 30 | } 31 | 32 | /// LanguageTool POST word add response. 33 | #[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)] 34 | #[non_exhaustive] 35 | pub struct Response { 36 | /// `true` if word was correctly added. 37 | pub added: bool, 38 | } 39 | -------------------------------------------------------------------------------- /src/api/words/delete.rs: -------------------------------------------------------------------------------- 1 | //! Structures for `words` requests and responses related to deleting. 2 | 3 | use super::*; 4 | 5 | /// LanguageTool POST words delete request. 6 | /// 7 | /// Remove a word from one of the user's personal dictionaries. 8 | #[cfg_attr(feature = "cli", derive(Args))] 9 | #[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize, Hash)] 10 | #[non_exhaustive] 11 | pub struct Request { 12 | /// The word to be removed. 13 | #[cfg_attr(feature = "cli", clap(required = true, value_parser = parse_word))] 14 | pub word: String, 15 | /// Login arguments. 16 | #[cfg_attr(feature = "cli", clap(flatten))] 17 | #[serde(flatten)] 18 | pub login: LoginArgs, 19 | /// Name of the dictionary to add the word to; non-existent dictionaries 20 | /// are created after calling this; if unset, adds to special 21 | /// default dictionary. 22 | #[cfg_attr(feature = "cli", clap(long))] 23 | #[serde(skip_serializing_if = "Option::is_none")] 24 | pub dict: Option, 25 | } 26 | 27 | /// LanguageTool POST word delete response. 28 | #[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)] 29 | #[non_exhaustive] 30 | pub struct Response { 31 | /// `true` if word was correctly removed. 32 | pub deleted: bool, 33 | } 34 | -------------------------------------------------------------------------------- /src/api/words/mod.rs: -------------------------------------------------------------------------------- 1 | //! Structures for `words` requests and responses. 2 | 3 | use crate::error::{Error, Result}; 4 | 5 | use super::check::serialize_option_vec_string; 6 | #[cfg(feature = "cli")] 7 | use clap::Args; 8 | use serde::{Deserialize, Serialize}; 9 | 10 | pub mod add; 11 | pub mod delete; 12 | 13 | /// Parse `v` if valid word. 14 | /// 15 | /// A valid word is any string slice that does not contain any whitespace 16 | /// 17 | /// # Examples 18 | /// 19 | /// ``` 20 | /// # use languagetool_rust::api::words::parse_word; 21 | /// assert!(parse_word("word").is_ok()); 22 | /// 23 | /// assert!(parse_word("some words").is_err()); 24 | /// ``` 25 | pub fn parse_word(v: &str) -> Result { 26 | if !v.contains(' ') { 27 | return Ok(v.to_string()); 28 | } 29 | Err(Error::InvalidValue( 30 | "The value should be a word that does not contain any whitespace".to_string(), 31 | )) 32 | } 33 | 34 | /// Login arguments required by the API. 35 | #[cfg_attr(feature = "cli", derive(Args))] 36 | #[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize, Hash)] 37 | #[serde(rename_all = "camelCase")] 38 | #[non_exhaustive] 39 | pub struct LoginArgs { 40 | /// Your username as used to log in at languagetool.org. 41 | #[cfg_attr( 42 | feature = "cli", 43 | clap(short = 'u', long, required = true, env = "LANGUAGETOOL_USERNAME") 44 | )] 45 | pub username: String, 46 | /// Your API key (see ). 47 | #[cfg_attr( 48 | feature = "cli", 49 | clap(short = 'k', long, required = true, env = "LANGUAGETOOL_API_KEY") 50 | )] 51 | pub api_key: String, 52 | } 53 | 54 | /// LanguageTool GET words request. 55 | /// 56 | /// List words in the user's personal dictionaries. 57 | #[cfg_attr(feature = "cli", derive(Args))] 58 | #[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize, Hash)] 59 | #[non_exhaustive] 60 | pub struct Request { 61 | /// Offset of where to start in the list of words. 62 | /// 63 | /// Defaults to 0. 64 | #[cfg_attr(feature = "cli", clap(long))] 65 | #[serde(skip_serializing_if = "Option::is_none")] 66 | pub offset: Option, 67 | /// Maximum number of words to return. 68 | /// 69 | /// Defaults to 10. 70 | #[cfg_attr(feature = "cli", clap(long))] 71 | #[serde(skip_serializing_if = "Option::is_none")] 72 | pub limit: Option, 73 | /// Login arguments. 74 | #[cfg_attr(feature = "cli", clap(flatten))] 75 | #[serde(flatten)] 76 | pub login: LoginArgs, 77 | /// Comma-separated list of dictionaries to include words from; uses special 78 | /// default dictionary if this is unset. 79 | #[cfg_attr(feature = "cli", clap(long))] 80 | #[serde(serialize_with = "serialize_option_vec_string")] 81 | #[serde(skip_serializing_if = "Option::is_none")] 82 | pub dicts: Option>, 83 | } 84 | 85 | /// Copy of [`Request`], but used to CLI only. 86 | /// 87 | /// This is a temporary solution, until [#4697](https://github.com/clap-rs/clap/issues/4697) is 88 | /// closed. 89 | #[cfg(feature = "cli")] 90 | #[derive(Args, Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)] 91 | #[non_exhaustive] 92 | pub struct RequestArgs { 93 | /// Offset of where to start in the list of words. 94 | #[cfg_attr(feature = "cli", clap(long, default_value = "0"))] 95 | pub offset: isize, 96 | /// Maximum number of words to return. 97 | #[cfg_attr(feature = "cli", clap(long, default_value = "10"))] 98 | pub limit: isize, 99 | /// Login arguments. 100 | #[cfg_attr(feature = "cli", clap(flatten))] 101 | #[serde(flatten)] 102 | pub login: Option, 103 | /// Comma-separated list of dictionaries to include words from; uses special 104 | /// default dictionary if this is unset. 105 | #[cfg_attr(feature = "cli", clap(long))] 106 | #[serde(serialize_with = "serialize_option_vec_string")] 107 | pub dicts: Option>, 108 | } 109 | 110 | #[cfg(feature = "cli")] 111 | impl From for Request { 112 | #[inline] 113 | fn from(args: RequestArgs) -> Self { 114 | Self { 115 | offset: Some(args.offset), 116 | limit: Some(args.limit), 117 | login: args.login.unwrap(), 118 | dicts: args.dicts, 119 | } 120 | } 121 | } 122 | 123 | /// LanguageTool GET words response. 124 | #[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)] 125 | #[non_exhaustive] 126 | pub struct Response { 127 | /// List of words. 128 | pub words: Vec, 129 | } 130 | -------------------------------------------------------------------------------- /src/cli/check.rs: -------------------------------------------------------------------------------- 1 | //! Check text using LanguageTool server. 2 | //! 3 | //! The input can be one of the following: 4 | //! 5 | //! - raw text, if `--text TEXT` is provided; 6 | //! - annotated data, if `--data TEXT` is provided; 7 | //! - text from file(s), if `[FILE(S)]...` are provided. 8 | //! - raw text through `stdin`, if nothing else is provided. 9 | use std::{borrow::Cow, io, io::Write, path::PathBuf}; 10 | 11 | use clap::{Args, Parser, ValueEnum}; 12 | use is_terminal::IsTerminal; 13 | use serde::{Deserialize, Serialize}; 14 | use termcolor::{StandardStream, WriteColor}; 15 | 16 | use crate::{ 17 | api::{ 18 | check::{ 19 | self, parse_language_code, Data, DataAnnotation, Level, Request, DEFAULT_LANGUAGE, 20 | }, 21 | server::ServerClient, 22 | }, 23 | error::{Error, Result}, 24 | parsers::{html::parse_html, markdown::parse_markdown, typst::parse_typst}, 25 | }; 26 | 27 | use super::ExecuteSubcommand; 28 | 29 | /// Parse a string slice into a [`PathBuf`], and error if the file does not 30 | /// exist. 31 | fn parse_filename(s: &str) -> Result { 32 | let path_buf = PathBuf::from(s); 33 | 34 | if path_buf.is_file() { 35 | Ok(path_buf) 36 | } else { 37 | Err(Error::InvalidFilename(s.to_string())) 38 | } 39 | } 40 | 41 | /// Command to check a text with LanguageTool for possible style and grammar 42 | /// issues. 43 | #[derive(Debug, Parser)] 44 | pub struct Command { 45 | /// If present, raw JSON output will be printed instead of annotated text. 46 | /// This has no effect if `--data` is used, because it is never 47 | /// annotated. 48 | #[clap(short = 'r', long)] 49 | pub raw: bool, 50 | /// Sets the maximum number of characters before splitting. 51 | #[clap(long, default_value_t = 1500)] 52 | pub max_length: usize, 53 | /// If text is too long, will split on this pattern. 54 | #[clap(long, default_value = "\n\n")] 55 | pub split_pattern: String, 56 | /// Max. number of suggestions kept. If negative, all suggestions are kept. 57 | #[clap(long, default_value_t = 5, allow_negative_numbers = true)] 58 | pub max_suggestions: isize, 59 | /// Specify the files type to use the correct parser. 60 | /// 61 | /// If set to auto, the type is guessed from the filename extension. 62 | #[clap(long, value_enum, default_value_t = FileType::default(), ignore_case = true)] 63 | pub r#type: FileType, 64 | /// Optional filenames from which input is read. 65 | #[arg(conflicts_with_all(["text", "data"]), value_parser = parse_filename)] 66 | pub filenames: Vec, 67 | /// Inner [`Request`]. 68 | #[command(flatten, next_help_heading = "Request options")] 69 | pub request: CliRequest, 70 | } 71 | 72 | /// Support file types. 73 | #[derive(Clone, Debug, Default, ValueEnum)] 74 | #[non_exhaustive] 75 | pub enum FileType { 76 | /// Auto. 77 | #[default] 78 | Auto, 79 | /// Raw text. 80 | Raw, 81 | /// Markdown. 82 | Markdown, 83 | /// HTML. 84 | Html, 85 | /// Typst. 86 | Typst, 87 | } 88 | 89 | /// Read lines from standard input and write to buffer string. 90 | /// 91 | /// Standard output is used when waiting for user to input text. 92 | fn read_from_stdin(buffer: &mut String) -> Result<()> { 93 | if io::stdin().is_terminal() { 94 | #[cfg(windows)] 95 | log::info!("Reading from STDIN, press [CTRL+Z] when you're done."); 96 | 97 | #[cfg(unix)] 98 | log::info!("Reading from STDIN, press [CTRL+D] when you're done."); 99 | } 100 | let stdin = std::io::stdin(); 101 | 102 | while stdin.read_line(buffer)? > 0 {} 103 | Ok(()) 104 | } 105 | 106 | impl ExecuteSubcommand for Command { 107 | /// Executes the `check` subcommand. 108 | async fn execute(self, mut stdout: StandardStream, server_client: ServerClient) -> Result<()> { 109 | let mut request: check::Request = self.request.into(); 110 | #[cfg(feature = "annotate")] 111 | let color = stdout.supports_color(); 112 | 113 | let server_client = server_client.with_max_suggestions(self.max_suggestions); 114 | 115 | // ANNOTATED DATA, RAW TEXT, STDIN 116 | if self.filenames.is_empty() { 117 | // Fallback to `stdin` if nothing else is provided 118 | if request.text.is_none() && request.data.is_none() { 119 | let mut text = String::new(); 120 | read_from_stdin(&mut text)?; 121 | request = request.with_text(Cow::Owned(text)); 122 | } 123 | 124 | if let Some(ref text) = request.text { 125 | if text.is_empty() { 126 | log::warn!("No input text was provided, skipping."); 127 | return Ok(()); 128 | } 129 | } else { 130 | // Handle annotated data 131 | let response = server_client.check(&request).await?; 132 | writeln!(&mut stdout, "{}", serde_json::to_string_pretty(&response)?)?; 133 | return Ok(()); 134 | }; 135 | 136 | let requests = request.split(self.max_length, self.split_pattern.as_str()); 137 | 138 | let response = server_client.check_multiple_and_join(requests).await?; 139 | 140 | writeln!( 141 | &mut stdout, 142 | "{}", 143 | &response.annotate(response.text.as_ref(), None, color) 144 | )?; 145 | 146 | return Ok(()); 147 | } 148 | 149 | // FILES 150 | for filename in self.filenames.iter() { 151 | let mut file_type = self.r#type.clone(); 152 | 153 | // If file type is "Auto", guess file type from extension 154 | if matches!(self.r#type, FileType::Auto) { 155 | file_type = match PathBuf::from(filename).extension().and_then(|e| e.to_str()) { 156 | Some(ext) => { 157 | match ext { 158 | "typ" => FileType::Typst, 159 | "md" | "markdown" | "mdown" | "mdwn" | "mkd" | "mkdn" | "mdx" => { 160 | FileType::Markdown 161 | }, 162 | 163 | "html" | "htm" => FileType::Html, 164 | _ => { 165 | log::debug!("Unknown file type: {ext}."); 166 | FileType::Raw 167 | }, 168 | } 169 | }, 170 | None => { 171 | log::debug!("No extension found for file: {filename:?}."); 172 | FileType::Raw 173 | }, 174 | }; 175 | }; 176 | 177 | let file_content = std::fs::read_to_string(filename)?; 178 | 179 | let (response, text): (check::Response, String) = match &file_type { 180 | FileType::Auto => unreachable!(), 181 | FileType::Raw => { 182 | let requests = (request.clone().with_text(&file_content)) 183 | .split(self.max_length, self.split_pattern.as_str()); 184 | 185 | if requests.is_empty() { 186 | log::info!("Skipping empty file: {filename:?}."); 187 | continue; 188 | } 189 | 190 | let response = server_client.check_multiple_and_join(requests).await?; 191 | (response.into(), file_content) 192 | }, 193 | FileType::Typst | FileType::Markdown | FileType::Html => { 194 | let data = match file_type { 195 | FileType::Typst => parse_typst(&file_content), 196 | FileType::Html => parse_html(&file_content), 197 | FileType::Markdown => parse_markdown(&file_content), 198 | _ => unreachable!(), 199 | }; 200 | let requests = (request.clone().with_data(data)) 201 | .split(self.max_length, self.split_pattern.as_str()); 202 | let response = server_client 203 | .check_multiple_and_join_without_context(requests) 204 | .await?; 205 | (response, file_content) 206 | }, 207 | }; 208 | 209 | if !self.raw { 210 | writeln!( 211 | &mut stdout, 212 | "{}", 213 | &response.annotate(&text, filename.to_str(), color) 214 | )?; 215 | } else { 216 | writeln!(&mut stdout, "{}", serde_json::to_string_pretty(&response)?)?; 217 | } 218 | } 219 | 220 | Ok(()) 221 | } 222 | } 223 | 224 | // NOTE: The below structs are copied from `../api/check.rs` to avoid lifetime 225 | // issues with `clap` TODO: Remove these once this upstream issue is resolved: 226 | // ------------------------------------------------------------------------------------------------- 227 | 228 | /// LanguageTool POST check request. 229 | /// 230 | /// The main feature - check a text with LanguageTool for possible style and 231 | /// grammar issues. 232 | /// 233 | /// The structure below tries to follow as closely as possible the JSON API 234 | /// described [here](https://languagetool.org/http-api/swagger-ui/#!/default/post_check). 235 | #[derive(Args, Clone, Debug, Default, PartialEq, Eq, Hash)] 236 | #[non_exhaustive] 237 | pub struct CliRequest { 238 | /// The text to be checked. This, `data`, or `[FILENAMES...]` cannot be 239 | /// passed simultaneously. If nothing is specified, input will be read from 240 | /// `stdin` 241 | #[clap(short = 't', long, conflicts_with = "data", allow_hyphen_values(true))] 242 | pub text: Option, 243 | /// The text to be checked, given as a JSON document that specifies what's 244 | /// text and what's markup. This or 'text' is required. 245 | /// 246 | /// Markup will be ignored when looking for errors. Example text: 247 | /// ```html 248 | /// A test 249 | /// ``` 250 | /// JSON for the example text: 251 | /// ```json 252 | /// {"annotation":[ 253 | /// {"text": "A "}, 254 | /// {"markup": ""}, 255 | /// {"text": "test"}, 256 | /// {"markup": ""} 257 | /// ]} 258 | /// ``` 259 | /// If you have markup that should be interpreted as whitespace, like `

` 260 | /// in HTML, you can have it interpreted like this: 261 | /// 262 | /// ```json 263 | /// {"markup": "

", "interpretAs": "\n\n"} 264 | /// ``` 265 | /// The 'data' feature is not limited to HTML or XML, it can be used for any 266 | /// kind of markup. Entities will need to be expanded in this input. 267 | #[clap(short = 'd', long, conflicts_with = "text")] 268 | pub data: Option, 269 | /// A language code like `en-US`, `de-DE`, `fr`, or `auto` to guess the 270 | /// language automatically (see `preferredVariants` below). 271 | /// 272 | /// For languages with variants (English, German, Portuguese) spell checking 273 | /// will only be activated when you specify the variant, e.g. `en-GB` 274 | /// instead of just `en`. 275 | #[cfg_attr( 276 | feature = "cli", 277 | clap( 278 | short = 'l', 279 | long, 280 | default_value = DEFAULT_LANGUAGE, 281 | value_parser = parse_language_code 282 | ) 283 | )] 284 | pub language: String, 285 | /// Set to get Premium API access: Your username/email as used to log in at 286 | /// languagetool.org. 287 | #[cfg_attr( 288 | feature = "cli", 289 | clap(short = 'u', long, requires = "api_key", env = "LANGUAGETOOL_USERNAME") 290 | )] 291 | pub username: Option, 292 | /// Set to get Premium API access: your API key (see ). 293 | #[cfg_attr( 294 | feature = "cli", 295 | clap(short = 'k', long, requires = "username", env = "LANGUAGETOOL_API_KEY") 296 | )] 297 | pub api_key: Option, 298 | /// Comma-separated list of dictionaries to include words from; uses special 299 | /// default dictionary if this is unset. 300 | #[cfg_attr(feature = "cli", clap(long))] 301 | pub dicts: Option>, 302 | /// A language code of the user's native language, enabling false friends 303 | /// checks for some language pairs. 304 | #[cfg_attr(feature = "cli", clap(long))] 305 | pub mother_tongue: Option, 306 | /// Comma-separated list of preferred language variants. 307 | /// 308 | /// The language detector used with `language=auto` can detect e.g. English, 309 | /// but it cannot decide whether British English or American English is 310 | /// used. Thus this parameter can be used to specify the preferred variants 311 | /// like `en-GB` and `de-AT`. Only available with `language=auto`. You 312 | /// should set variants for at least German and English, as otherwise the 313 | /// spell checking will not work for those, as no spelling dictionary can be 314 | /// selected for just `en` or `de`. 315 | #[cfg_attr(feature = "cli", clap(long, conflicts_with = "language"))] 316 | pub preferred_variants: Option>, 317 | /// IDs of rules to be enabled, comma-separated. 318 | #[cfg_attr(feature = "cli", clap(long))] 319 | pub enabled_rules: Option>, 320 | /// IDs of rules to be disabled, comma-separated. 321 | #[cfg_attr(feature = "cli", clap(long))] 322 | pub disabled_rules: Option>, 323 | /// IDs of categories to be enabled, comma-separated. 324 | #[cfg_attr(feature = "cli", clap(long))] 325 | pub enabled_categories: Option>, 326 | /// IDs of categories to be disabled, comma-separated. 327 | #[cfg_attr(feature = "cli", clap(long))] 328 | pub disabled_categories: Option>, 329 | /// If true, only the rules and categories whose IDs are specified with 330 | /// `enabledRules` or `enabledCategories` are enabled. 331 | #[cfg_attr(feature = "cli", clap(long))] 332 | pub enabled_only: bool, 333 | /// If set to `picky`, additional rules will be activated, i.e. rules that 334 | /// you might only find useful when checking formal text. 335 | #[cfg_attr( 336 | feature = "cli", 337 | clap(long, default_value = "default", ignore_case = true, value_enum) 338 | )] 339 | pub level: Level, 340 | } 341 | 342 | impl From for Request<'_> { 343 | fn from(val: CliRequest) -> Self { 344 | Request { 345 | text: val.text.map(Cow::Owned), 346 | data: val.data.map(Into::into), 347 | language: val.language, 348 | username: val.username, 349 | api_key: val.api_key, 350 | dicts: val.dicts, 351 | mother_tongue: val.mother_tongue, 352 | preferred_variants: val.preferred_variants, 353 | enabled_rules: val.enabled_rules, 354 | disabled_rules: val.disabled_rules, 355 | enabled_categories: val.enabled_categories, 356 | disabled_categories: val.disabled_categories, 357 | enabled_only: val.enabled_only, 358 | level: val.level, 359 | } 360 | } 361 | } 362 | 363 | /// Alternative text to be checked. 364 | #[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq, Hash)] 365 | #[non_exhaustive] 366 | pub struct CliData { 367 | /// Vector of markup text, see [`DataAnnotation`]. 368 | pub annotation: Vec, 369 | } 370 | 371 | impl From for Data<'_> { 372 | fn from(val: CliData) -> Self { 373 | Data { 374 | annotation: val 375 | .annotation 376 | .into_iter() 377 | .map(|a| a.into()) 378 | .collect::>(), 379 | } 380 | } 381 | } 382 | 383 | /// A portion of text to be checked. 384 | #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize, Hash)] 385 | #[non_exhaustive] 386 | #[serde(rename_all = "camelCase")] 387 | pub struct CliDataAnnotation { 388 | /// Text that should be treated as normal text. 389 | /// 390 | /// This or `markup` is required. 391 | #[serde(skip_serializing_if = "Option::is_none")] 392 | pub text: Option, 393 | /// Text that should be treated as markup. 394 | /// 395 | /// This or `text` is required. 396 | #[serde(skip_serializing_if = "Option::is_none")] 397 | pub markup: Option, 398 | /// If set, the markup will be interpreted as this. 399 | #[serde(skip_serializing_if = "Option::is_none")] 400 | pub interpret_as: Option, 401 | } 402 | 403 | impl From for DataAnnotation<'_> { 404 | fn from(val: CliDataAnnotation) -> Self { 405 | DataAnnotation { 406 | text: val.text.map(Cow::Owned), 407 | markup: val.markup.map(Cow::Owned), 408 | interpret_as: val.interpret_as.map(Cow::Owned), 409 | } 410 | } 411 | } 412 | 413 | impl std::str::FromStr for CliData { 414 | type Err = Error; 415 | 416 | fn from_str(s: &str) -> Result { 417 | let v: Self = serde_json::from_str(s)?; 418 | Ok(v) 419 | } 420 | } 421 | 422 | #[cfg(test)] 423 | mod test { 424 | use super::*; 425 | 426 | #[test] 427 | fn test_read_from_stdin() { 428 | let handle = std::thread::spawn(|| { 429 | let mut buffer = String::new(); 430 | read_from_stdin(&mut buffer).unwrap(); 431 | buffer 432 | }); 433 | 434 | std::thread::sleep(std::time::Duration::from_millis(100)); 435 | 436 | if std::io::stdin().is_terminal() { 437 | assert!(!handle.is_finished()); 438 | } else { 439 | assert!(handle.is_finished()); 440 | } 441 | } 442 | } 443 | -------------------------------------------------------------------------------- /src/cli/completions.rs: -------------------------------------------------------------------------------- 1 | //! Completion scripts generation with [`clap_complete`]. 2 | 3 | use crate::{api::server::ServerClient, error::Result}; 4 | use clap::Parser; 5 | use clap_complete::{generate, shells::Shell}; 6 | use std::io::Write; 7 | use termcolor::StandardStream; 8 | 9 | use super::ExecuteSubcommand; 10 | 11 | /// Command structure to generate complete scripts. 12 | #[derive(Debug, Parser)] 13 | #[command( 14 | about = "Generate tab-completion scripts for supported shells", 15 | after_help = "Use --help for installation help.", 16 | after_long_help = COMPLETIONS_HELP 17 | )] 18 | pub struct Command { 19 | /// Shell for which to completion script is generated. 20 | #[arg(value_enum, ignore_case = true)] 21 | shell: Shell, 22 | } 23 | 24 | impl Command { 25 | /// Generate completion file for current shell and write to buffer. 26 | pub fn generate_completion_file(&self, build_cli: F, buffer: &mut W) 27 | where 28 | F: FnOnce() -> clap::Command, 29 | W: Write, 30 | { 31 | generate(self.shell, &mut build_cli(), "ltrs", buffer); 32 | } 33 | } 34 | 35 | impl ExecuteSubcommand for Command { 36 | /// Executes the `completions` subcommand. 37 | async fn execute(self, mut stdout: StandardStream, _: ServerClient) -> Result<()> { 38 | self.generate_completion_file(super::build_cli, &mut stdout); 39 | Ok(()) 40 | } 41 | } 42 | 43 | pub(crate) static COMPLETIONS_HELP: &str = r"DISCUSSION: 44 | Enable tab completion for Bash, Fish, Zsh, or PowerShell 45 | Elvish shell completion is currently supported, but not documented below. 46 | The script is output on `stdout`, allowing one to re-direct the 47 | output to the file of their choosing. Where you place the file 48 | will depend on which shell, and which operating system you are 49 | using. Your particular configuration may also determine where 50 | these scripts need to be placed. 51 | Here are some common set ups for the three supported shells under 52 | Unix and similar operating systems (such as GNU/Linux). 53 | BASH: 54 | Completion files are commonly stored in `/etc/bash_completion.d/` for 55 | system-wide commands, but can be stored in 56 | `~/.local/share/bash-completion/completions` for user-specific commands. 57 | Run the command: 58 | $ mkdir -p ~/.local/share/bash-completion/completions 59 | $ ltrs completions bash >> ~/.local/share/bash-completion/completions/ltrs 60 | This installs the completion script. You may have to log out and 61 | log back in to your shell session for the changes to take effect. 62 | BASH (macOS/Homebrew): 63 | Homebrew stores bash completion files within the Homebrew directory. 64 | With the `bash-completion` brew formula installed, run the command: 65 | $ mkdir -p $(brew --prefix)/etc/bash_completion.d 66 | $ ltrs completions bash > $(brew --prefix)/etc/bash_completion.d/ltrs.bash-completion 67 | FISH: 68 | Fish completion files are commonly stored in 69 | `$HOME/.config/fish/completions`. Run the command: 70 | $ mkdir -p ~/.config/fish/completions 71 | $ ltrs completions fish > ~/.config/fish/completions/ltrs.fish 72 | This installs the completion script. You may have to log out and 73 | log back in to your shell session for the changes to take effect. 74 | ZSH: 75 | ZSH completions are commonly stored in any directory listed in 76 | your `$fpath` variable. To use these completions, you must either 77 | add the generated script to one of those directories, or add your 78 | own to this list. 79 | Adding a custom directory is often the safest bet if you are 80 | unsure of which directory to use. First create the directory; for 81 | this example we'll create a hidden directory inside our `$HOME` 82 | directory: 83 | $ mkdir ~/.zfunc 84 | Then add the following lines to your `.zshrc` just before 85 | `compinit`: 86 | fpath+=~/.zfunc 87 | Now you can install the completions script using the following 88 | command: 89 | $ ltrs completions zsh > ~/.zfunc/_ltrs 90 | You must then either log out and log back in, or simply run 91 | $ exec zsh 92 | for the new completions to take effect. 93 | CUSTOM LOCATIONS: 94 | Alternatively, you could save these files to the place of your 95 | choosing, such as a custom directory inside your $HOME. Doing so 96 | will require you to add the proper directives, such as `source`ing 97 | inside your login script. Consult your shells documentation for 98 | how to add such directives. 99 | POWERSHELL: 100 | The powershell completion scripts require PowerShell v5.0+ (which 101 | comes with Windows 10, but can be downloaded separately for windows 7 102 | or 8.1). 103 | First, check if a profile has already been set 104 | PS C:\> Test-Path $profile 105 | If the above command returns `False` run the following 106 | PS C:\> New-Item -path $profile -type file -force 107 | Now open the file provided by `$profile` (if you used the 108 | `New-Item` command it will be 109 | `${env:USERPROFILE}\Documents\WindowsPowerShell\Microsoft.PowerShell_profile.ps1` 110 | Next, we either save the completions file into our profile, or 111 | into a separate file and source it inside our profile. To save the 112 | completions into our profile simply use 113 | PS C:\> ltrs completions powershell >> ${env:USERPROFILE}\Documents\WindowsPowerShell\Microsoft.PowerShell_profile.ps1 114 | SOURCE: 115 | This documentation is directly taken from: https://github.com/rust-lang/rustup/blob/8f6b53628ad996ad86f9c6225fa500cddf860905/src/cli/help.rs#L157"; 116 | -------------------------------------------------------------------------------- /src/cli/docker.rs: -------------------------------------------------------------------------------- 1 | //! Structures and methods to easily manipulate Docker images, especially for 2 | //! LanguageTool applications. 3 | 4 | use std::process::{self, Output, Stdio}; 5 | 6 | use clap::{Args, Parser}; 7 | use termcolor::StandardStream; 8 | 9 | use crate::{ 10 | api::server::ServerClient, 11 | error::{exit_status_error, Error, Result}, 12 | }; 13 | 14 | use super::ExecuteSubcommand; 15 | 16 | /// Commands to pull, start and stop a `LanguageTool` container using Docker. 17 | #[derive(Debug, Clone, Args)] 18 | pub struct Docker { 19 | /// Image or repository from a registry. 20 | #[clap( 21 | default_value = "erikvl87/languagetool", 22 | env = "LANGUAGETOOL_DOCKER_IMAGE" 23 | )] 24 | name: String, 25 | /// Path to Docker's binaries. 26 | #[clap( 27 | short = 'b', 28 | long, 29 | default_value = "docker", 30 | env = "LANGUAGETOOL_DOCKER_BIN" 31 | )] 32 | bin: String, 33 | /// Name assigned to the container. 34 | #[clap(long, default_value = "languagetool", env = "LANGUAGETOOL_DOCKER_NAME")] 35 | container_name: String, 36 | /// Publish a container's port(s) to the host. 37 | #[clap( 38 | short = 'p', 39 | long, 40 | default_value = "8010:8010", 41 | env = "LANGUAGETOOL_DOCKER_PORT" 42 | )] 43 | port: String, 44 | /// Docker action. 45 | #[clap(subcommand)] 46 | action: Action, 47 | } 48 | 49 | #[derive(clap::Subcommand, Clone, Debug)] 50 | /// Enumerate supported Docker actions. 51 | enum Action { 52 | /// Pull a docker docker image. 53 | /// 54 | /// Alias to `{docker.bin} pull {docker.name}`. 55 | Pull, 56 | /// Start a (detached) docker container. 57 | /// 58 | /// Alias to `{docker.bin} run --rm -d -p {docker.port} {docker.name}` 59 | Start, 60 | /// Stop a docker container. 61 | /// 62 | /// Alias to `{docker.bin} kill $({docker.bin} ps -l -f 63 | /// "name={docker.container_name}")`. 64 | Stop, 65 | } 66 | 67 | impl Docker { 68 | /// Pull a Docker image from the given repository/file/... 69 | pub fn pull(&self) -> Result { 70 | let output = process::Command::new(&self.bin) 71 | .args(["pull", &self.name]) 72 | .stdout(Stdio::inherit()) 73 | .stderr(Stdio::inherit()) 74 | .output() 75 | .map_err(|_| Error::CommandNotFound(self.bin.to_string()))?; 76 | 77 | exit_status_error(&output.status)?; 78 | 79 | Ok(output) 80 | } 81 | 82 | /// Start a Docker container with given specifications. 83 | pub fn start(&self) -> Result { 84 | let output = process::Command::new(&self.bin) 85 | .args([ 86 | "run", 87 | "--rm", 88 | "--name", 89 | &self.container_name, 90 | "-d", 91 | "-p", 92 | "8010:8010", 93 | &self.name, 94 | ]) 95 | .stdout(Stdio::inherit()) 96 | .stderr(Stdio::inherit()) 97 | .output() 98 | .map_err(|_| Error::CommandNotFound(self.bin.to_string()))?; 99 | 100 | exit_status_error(&output.status)?; 101 | 102 | Ok(output) 103 | } 104 | 105 | /// Stop the latest Docker container with the given name. 106 | pub fn stop(&self) -> Result { 107 | let output = process::Command::new(&self.bin) 108 | .args([ 109 | "ps", 110 | "-l", 111 | "-f", 112 | &format!("name={}", self.container_name), 113 | "-q", 114 | ]) 115 | .stderr(Stdio::inherit()) 116 | .output() 117 | .map_err(|_| Error::CommandNotFound(self.bin.to_string()))?; 118 | 119 | exit_status_error(&output.status)?; 120 | 121 | let docker_id: String = String::from_utf8_lossy(&output.stdout) 122 | .chars() 123 | .filter(|c| c.is_alphanumeric()) // This avoids newlines 124 | .collect(); 125 | 126 | let output = process::Command::new(&self.bin) 127 | .args(["kill", &docker_id]) 128 | .stdout(Stdio::inherit()) 129 | .stderr(Stdio::inherit()) 130 | .output()?; 131 | 132 | exit_status_error(&output.status)?; 133 | 134 | Ok(output) 135 | } 136 | 137 | /// Run a Docker command according to `self.action`. 138 | pub fn run_action(&self) -> Result { 139 | match self.action { 140 | Action::Pull => self.pull(), 141 | Action::Start => self.start(), 142 | Action::Stop => self.stop(), 143 | } 144 | } 145 | } 146 | 147 | /// Commands to easily run a LanguageTool server with Docker. 148 | #[derive(Debug, Parser)] 149 | pub struct Command { 150 | /// Actual command arguments. 151 | #[command(flatten)] 152 | pub docker: Docker, 153 | } 154 | 155 | impl ExecuteSubcommand for Command { 156 | /// Execute the `docker` subcommand. 157 | async fn execute(self, _stdout: StandardStream, _: ServerClient) -> Result<()> { 158 | self.docker.run_action()?; 159 | Ok(()) 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/cli/languages.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | use std::io::Write; 3 | use termcolor::StandardStream; 4 | 5 | use crate::{api::server::ServerClient, error::Result}; 6 | 7 | use super::ExecuteSubcommand; 8 | 9 | #[derive(Debug, Parser)] 10 | pub struct Command {} 11 | 12 | impl ExecuteSubcommand for Command { 13 | /// Executes the `languages` subcommand. 14 | async fn execute(self, mut stdout: StandardStream, server_client: ServerClient) -> Result<()> { 15 | let languages_response = server_client.languages().await?; 16 | let languages = serde_json::to_string_pretty(&languages_response)?; 17 | 18 | writeln!(&mut stdout, "{languages}")?; 19 | Ok(()) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/cli/mod.rs: -------------------------------------------------------------------------------- 1 | //! Command line tools. 2 | //! 3 | //! This module is specifically designed to be used by LTRS's binary target. 4 | //! It contains all the content needed to create LTRS's command line interface. 5 | 6 | pub mod check; 7 | #[cfg(feature = "cli-complete")] 8 | mod completions; 9 | #[cfg(feature = "docker")] 10 | mod docker; 11 | mod languages; 12 | mod ping; 13 | mod words; 14 | 15 | use std::io; 16 | 17 | use clap::{CommandFactory, Parser, Subcommand}; 18 | use enum_dispatch::enum_dispatch; 19 | use is_terminal::IsTerminal; 20 | #[cfg(feature = "annotate")] 21 | use termcolor::{ColorChoice, StandardStream}; 22 | 23 | #[cfg(feature = "docker")] 24 | pub use docker::Docker; 25 | 26 | use crate::{ 27 | api::server::{ServerCli, ServerClient}, 28 | error::Result, 29 | }; 30 | 31 | /// Main command line structure. Contains every subcommand. 32 | #[derive(Parser, Debug)] 33 | #[command( 34 | author, 35 | version, 36 | about = "LanguageTool API bindings in Rust.", 37 | propagate_version(true), 38 | subcommand_required(true), 39 | verbatim_doc_comment 40 | )] 41 | pub struct Cli { 42 | /// Specify WHEN to colorize output. 43 | #[arg(short, long, value_name = "WHEN", default_value = "auto", default_missing_value = "always", num_args(0..=1), require_equals(true))] 44 | pub color: clap::ColorChoice, 45 | /// [`ServerCli`] arguments. 46 | #[command(flatten, next_help_heading = "Server options")] 47 | pub server_cli: ServerCli, 48 | /// Subcommand. 49 | #[command(subcommand)] 50 | #[allow(missing_docs)] 51 | pub command: Command, 52 | #[command(flatten)] 53 | #[allow(missing_docs)] 54 | pub verbose: clap_verbosity_flag::Verbosity, 55 | } 56 | 57 | /// All possible subcommands. 58 | #[derive(Subcommand, Debug)] 59 | #[enum_dispatch] 60 | #[allow(missing_docs)] 61 | pub enum Command { 62 | /// Check text using LanguageTool server. 63 | Check(check::Command), 64 | /// Commands to easily run a LanguageTool server with Docker. 65 | #[cfg(feature = "docker")] 66 | Docker(docker::Command), 67 | /// Return list of supported languages. 68 | #[clap(visible_alias = "lang")] 69 | Languages(languages::Command), 70 | /// Ping the LanguageTool server and return time elapsed in ms if success. 71 | Ping(ping::Command), 72 | /// Retrieve some user's words list, or add / delete word from it. 73 | Words(words::Command), 74 | /// Generate tab-completion scripts for supported shells 75 | #[cfg(feature = "cli-complete")] 76 | Completions(completions::Command), 77 | } 78 | 79 | /// Provides a common interface for executing the subcommands. 80 | #[enum_dispatch(Command)] 81 | trait ExecuteSubcommand { 82 | /// Executes the subcommand. 83 | async fn execute(self, stdout: StandardStream, server_client: ServerClient) -> Result<()>; 84 | } 85 | 86 | impl Cli { 87 | /// Return a standard output stream that optionally supports color. 88 | #[must_use] 89 | fn stdout(&self) -> StandardStream { 90 | let mut choice: ColorChoice = match self.color { 91 | clap::ColorChoice::Auto => ColorChoice::Auto, 92 | clap::ColorChoice::Always => ColorChoice::Always, 93 | clap::ColorChoice::Never => ColorChoice::Never, 94 | }; 95 | 96 | if choice == ColorChoice::Auto && !io::stdout().is_terminal() { 97 | choice = ColorChoice::Never; 98 | } 99 | 100 | StandardStream::stdout(choice) 101 | } 102 | 103 | /// Execute command, possibly returning an error. 104 | pub async fn execute(self) -> Result<()> { 105 | let stdout = self.stdout(); 106 | let server_client: ServerClient = self.server_cli.into(); 107 | 108 | self.command.execute(stdout, server_client).await 109 | } 110 | } 111 | 112 | /// Build a command from the top-level command line structure. 113 | #[must_use] 114 | pub fn build_cli() -> clap::Command { 115 | Cli::command() 116 | } 117 | 118 | #[cfg(test)] 119 | mod tests { 120 | use super::*; 121 | #[test] 122 | fn test_cli() { 123 | Cli::command().debug_assert(); 124 | } 125 | 126 | #[test] 127 | fn test_cli_from_build_cli() { 128 | build_cli().debug_assert(); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/cli/ping.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | use std::io::Write; 3 | use termcolor::StandardStream; 4 | 5 | use crate::{api::server::ServerClient, error::Result}; 6 | 7 | use super::ExecuteSubcommand; 8 | 9 | #[derive(Debug, Parser)] 10 | pub struct Command {} 11 | 12 | impl ExecuteSubcommand for Command { 13 | /// Execute the `languages` subcommand. 14 | async fn execute(self, mut stdout: StandardStream, server_client: ServerClient) -> Result<()> { 15 | let ping = server_client.ping().await?; 16 | 17 | writeln!(&mut stdout, "PONG! Delay: {ping} ms")?; 18 | Ok(()) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/cli/words.rs: -------------------------------------------------------------------------------- 1 | use clap::{Parser, Subcommand}; 2 | use std::io::Write; 3 | use termcolor::StandardStream; 4 | 5 | use crate::{ 6 | api::{self, server::ServerClient, words::RequestArgs}, 7 | error::Result, 8 | }; 9 | 10 | use super::ExecuteSubcommand; 11 | 12 | /// Retrieve some user's words list. 13 | #[derive(Debug, Parser)] 14 | #[clap(args_conflicts_with_subcommands = true)] 15 | #[clap(subcommand_negates_reqs = true)] 16 | pub struct Command { 17 | /// Actual GET request. 18 | #[command(flatten)] 19 | pub request: RequestArgs, 20 | /// Optional subcommand. 21 | #[command(subcommand)] 22 | pub subcommand: Option, 23 | } 24 | 25 | /// Words' optional subcommand. 26 | #[derive(Clone, Debug, Subcommand)] 27 | pub enum WordsSubcommand { 28 | /// Add a word to some user's list. 29 | Add(api::words::add::Request), 30 | /// Remove a word from some user's list. 31 | Delete(api::words::delete::Request), 32 | } 33 | 34 | impl ExecuteSubcommand for Command { 35 | /// Executes the `words` subcommand. 36 | async fn execute(self, mut stdout: StandardStream, server_client: ServerClient) -> Result<()> { 37 | let words = match self.subcommand { 38 | Some(WordsSubcommand::Add(request)) => { 39 | let words_response = server_client.words_add(&request).await?; 40 | serde_json::to_string_pretty(&words_response)? 41 | }, 42 | Some(WordsSubcommand::Delete(request)) => { 43 | let words_response = server_client.words_delete(&request).await?; 44 | serde_json::to_string_pretty(&words_response)? 45 | }, 46 | None => { 47 | let words_response = server_client.words(&self.request.into()).await?; 48 | serde_json::to_string_pretty(&words_response)? 49 | }, 50 | }; 51 | 52 | writeln!(&mut stdout, "{words}")?; 53 | Ok(()) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | //! Error and Result structure used all across this crate. 2 | 3 | use std::process::ExitStatus; 4 | 5 | /// Enumeration of all possible error types. 6 | #[derive(Debug, thiserror::Error)] 7 | pub enum Error { 8 | /// Error from the command line parsing (see [`clap::Error`]). 9 | #[cfg(feature = "cli")] 10 | #[error(transparent)] 11 | Cli(#[from] clap::Error), 12 | 13 | /// Error when a process command was not found. 14 | #[error("command not found: {0}")] 15 | CommandNotFound(String), 16 | 17 | /// Error from a command line process (see [`std::process::Command`]). 18 | #[error("command failed: {0:?}")] 19 | ExitStatus(String), 20 | 21 | /// Error specifying an invalid 22 | /// [`DataAnnotation`](`crate::api::check::DataAnnotation`). 23 | #[error("invalid request: {0}")] 24 | InvalidDataAnnotation(String), 25 | 26 | /// Error from checking if `filename` exists and is a actually a file. 27 | #[error("invalid filename (got '{0}', does not exist or is not a file)")] 28 | InvalidFilename(String), 29 | 30 | /// Error specifying an invalid request. 31 | #[error("invalid request: {0}")] 32 | InvalidRequest(String), 33 | 34 | /// Error specifying an invalid value. 35 | #[error("invalid value: {0:?}")] 36 | InvalidValue(String), 37 | 38 | /// Error from reading and writing to IO (see [`std::io::Error`]). 39 | #[error(transparent)] 40 | IO(#[from] std::io::Error), 41 | 42 | /// Error when joining multiple futures. 43 | #[cfg(feature = "multithreaded")] 44 | #[error(transparent)] 45 | JoinError(#[from] tokio::task::JoinError), 46 | 47 | /// Error from parsing JSON (see [`serde_json::Error`]). 48 | #[error(transparent)] 49 | JSON(#[from] serde_json::Error), 50 | 51 | /// Error while parsing Action. 52 | #[error("could not parse {0:?} in a Docker action")] 53 | ParseAction(String), 54 | 55 | /// Any other error from requests (see [`reqwest::Error`]). 56 | #[error(transparent)] 57 | Reqwest(#[from] reqwest::Error), 58 | 59 | /// Error from reading environ variable (see [`std::env::VarError`]). 60 | #[error(transparent)] 61 | VarError(#[from] std::env::VarError), 62 | } 63 | 64 | /// Result type alias with error type defined above (see [`Error`]]). 65 | pub type Result = std::result::Result; 66 | 67 | #[allow(dead_code)] 68 | pub(crate) fn exit_status_error(exit_status: &ExitStatus) -> Result<()> { 69 | match exit_status.success() { 70 | true => Ok(()), 71 | false => { 72 | match exit_status.code() { 73 | Some(code) => { 74 | Err(Error::ExitStatus(format!( 75 | "Process terminated with exit code: {code}" 76 | ))) 77 | }, 78 | None => { 79 | Err(Error::ExitStatus( 80 | "Process terminated by signal".to_string(), 81 | )) 82 | }, 83 | } 84 | }, 85 | } 86 | } 87 | 88 | #[cfg(test)] 89 | mod tests { 90 | use assert_matches::assert_matches; 91 | 92 | use crate::error::Error; 93 | #[cfg(feature = "cli")] 94 | use clap::Command; 95 | 96 | #[cfg(feature = "cli")] 97 | #[test] 98 | fn test_error_cli() { 99 | let result = 100 | Command::new("").try_get_matches_from(vec!["some", "args", "that", "should", "fail"]); 101 | assert!(result.is_err()); 102 | 103 | let error: Error = result.unwrap_err().into(); 104 | 105 | assert_matches!(error, Error::Cli(_)); 106 | } 107 | 108 | #[test] 109 | fn test_error_json() { 110 | let result = serde_json::from_str::("invalid JSON"); 111 | assert!(result.is_err()); 112 | 113 | let error: Error = result.unwrap_err().into(); 114 | 115 | assert_matches!(error, Error::JSON(_)); 116 | } 117 | 118 | #[test] 119 | fn test_error_io() { 120 | let result = std::fs::read_to_string(""); 121 | assert!(result.is_err()); 122 | 123 | let error: Error = result.unwrap_err().into(); 124 | 125 | assert_matches!(error, Error::IO(_)); 126 | } 127 | 128 | #[test] 129 | fn test_error_invalid_request() { 130 | let result = crate::api::check::Request::new().try_get_text(); 131 | assert!(result.is_err()); 132 | 133 | let error: Error = result.unwrap_err().into(); 134 | 135 | assert_matches!(error, Error::InvalidRequest(_)); 136 | } 137 | 138 | #[test] 139 | fn test_error_invalid_value() { 140 | let result = crate::api::server::parse_port("test"); 141 | assert!(result.is_err()); 142 | 143 | let error: Error = result.unwrap_err().into(); 144 | 145 | assert_matches!(error, Error::InvalidValue(_)); 146 | } 147 | 148 | #[tokio::test] 149 | async fn test_error_reqwest() { 150 | let result = reqwest::get("").await; 151 | let error: Error = result.unwrap_err().into(); 152 | 153 | assert_matches!(error, Error::Reqwest(_)); 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![deny(missing_docs)] 2 | #![deny(missing_debug_implementations)] 3 | #![warn(clippy::must_use_candidate)] 4 | #![allow(clippy::doc_markdown, clippy::module_name_repetitions)] 5 | #![cfg_attr(docsrs, feature(doc_auto_cfg))] 6 | #![doc = include_str!("../README.md")] 7 | //! 8 | //! ## Note 9 | //! 10 | //! Most structures in this library are marked with 11 | //! ```ignore 12 | //! #[non_exhaustive] 13 | //! ``` 14 | //! to indicate that they are likely to change in the future. 15 | //! 16 | //! This is a consequence of using an external API (i.e., the LanguageTool API) 17 | //! that cannot be controlled and (possible) breaking changes are to be 18 | //! expected. 19 | 20 | pub mod api; 21 | #[cfg(feature = "cli")] 22 | pub mod cli; 23 | pub mod error; 24 | pub mod parsers; 25 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | use languagetool_rust::{cli::Cli, error::Result}; 3 | 4 | #[tokio::main] 5 | async fn main() { 6 | if let Err(e) = try_main().await { 7 | eprintln!("{e}"); 8 | std::process::exit(2); 9 | } 10 | } 11 | 12 | async fn try_main() -> Result<()> { 13 | let cli = Cli::parse(); 14 | pretty_env_logger::formatted_builder() 15 | .filter_level(cli.verbose.log_level_filter()) 16 | .init(); 17 | cli.execute().await 18 | } 19 | -------------------------------------------------------------------------------- /src/parsers/html.rs: -------------------------------------------------------------------------------- 1 | //! Parse the contents of HTML files into a format parseable by the LanguageTool 2 | //! API. 3 | 4 | use ego_tree::NodeRef; 5 | use scraper::{Html, Node}; 6 | 7 | use crate::{ 8 | api::check::{Data, DataAnnotation}, 9 | parsers::IGNORE, 10 | }; 11 | 12 | /// Parse the contents of an HTML file into a text format to be sent to the 13 | /// LanguageTool API. 14 | #[must_use] 15 | pub fn parse_html(file_content: &str) -> Data<'static> { 16 | let mut annotations: Vec = vec![]; 17 | 18 | fn handle_node(annotations: &mut Vec, node: NodeRef<'_, Node>) { 19 | let n = node.value(); 20 | match n { 21 | Node::Element(el) => { 22 | match el.name() { 23 | "head" | "script" | "style" => {}, 24 | 25 | "code" => { 26 | annotations.push(DataAnnotation::new_interpreted_markup( 27 | "...", 28 | IGNORE, 29 | )); 30 | }, 31 | 32 | "img" => { 33 | annotations.push(DataAnnotation::new_interpreted_markup("", IGNORE)); 34 | }, 35 | 36 | s => { 37 | match s { 38 | "p" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "li" | "td" | "th" 39 | | "div" => { 40 | annotations.push(DataAnnotation::new_interpreted_markup( 41 | format!("<{s}>"), 42 | "\n\n", 43 | )); 44 | for node in node.children() { 45 | handle_node(annotations, node); 46 | } 47 | annotations.push(DataAnnotation::new_interpreted_markup( 48 | format!(""), 49 | "\n\n", 50 | )); 51 | }, 52 | _ => { 53 | annotations.push(DataAnnotation::new_markup(format!("<{s}>"))); 54 | for node in node.children() { 55 | handle_node(annotations, node); 56 | } 57 | annotations.push(DataAnnotation::new_markup(format!(""))); 58 | }, 59 | } 60 | }, 61 | } 62 | }, 63 | 64 | Node::Text(t) => { 65 | let mut text = t.trim().to_owned(); 66 | if !text.is_empty() { 67 | let mut chars = t.chars(); 68 | 69 | // Maintain leading/trailing white space, but only a single space 70 | if chars.next().is_some_and(|c| c.is_whitespace()) { 71 | while text.chars().last().is_some_and(|c| c.is_whitespace()) { 72 | text.pop(); 73 | } 74 | text.insert(0, ' '); 75 | } 76 | if chars.last().is_some_and(|c| c.is_whitespace()) { 77 | text.push(' '); 78 | } 79 | 80 | annotations.push(DataAnnotation::new_text(text)) 81 | } else { 82 | annotations.push(DataAnnotation::new_text("\n\n")); 83 | } 84 | }, 85 | 86 | Node::Comment(c) => { 87 | let comment = c.to_string(); 88 | 89 | annotations.push(DataAnnotation::new_interpreted_markup( 90 | format!("",), 91 | format!("\n\n{comment}\n\n"), 92 | )); 93 | }, 94 | 95 | _ => {}, 96 | } 97 | } 98 | 99 | let document = Html::parse_document(file_content); 100 | for node in document.root_element().children() { 101 | handle_node(&mut annotations, node); 102 | } 103 | 104 | Data::from_iter(annotations) 105 | } 106 | -------------------------------------------------------------------------------- /src/parsers/markdown.rs: -------------------------------------------------------------------------------- 1 | //! Parse the contents of Markdown files into a format parseable by the 2 | //! LanguageTool API. 3 | 4 | use crate::{ 5 | api::check::{Data, DataAnnotation}, 6 | parsers::IGNORE, 7 | }; 8 | 9 | /// Parse the contents of a Markdown file into a text format to be sent to the 10 | /// LanguageTool API. 11 | #[must_use] 12 | pub fn parse_markdown(file_content: &str) -> Data<'_> { 13 | use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd}; 14 | 15 | let mut annotations: Vec = vec![]; 16 | 17 | // Stack to keep track of the current "tag" context 18 | let mut tags = vec![]; 19 | 20 | Parser::new_ext(file_content, Options::all()).for_each(|event| { 21 | match event { 22 | Event::Start(tag) => { 23 | match tag { 24 | // Start list items 25 | Tag::List(_) | Tag::Item => { 26 | annotations.push(DataAnnotation::new_text("- ")); 27 | }, 28 | _ => {}, 29 | } 30 | 31 | tags.push(tag); 32 | }, 33 | Event::End(tag) => { 34 | match tag { 35 | // Separate list items and table cells 36 | TagEnd::List(_) | TagEnd::Item | TagEnd::TableRow | TagEnd::TableHead => { 37 | annotations.push(DataAnnotation::new_text("\n")); 38 | }, 39 | TagEnd::TableCell => { 40 | annotations.push(DataAnnotation::new_text(" | ")); 41 | }, 42 | _ => {}, 43 | }; 44 | 45 | if tags 46 | .last() 47 | .is_some_and(|t| TagEnd::from(t.to_owned()) == tag) 48 | { 49 | tags.pop(); 50 | }; 51 | }, 52 | 53 | Event::Html(s) | Event::InlineHtml(s) => { 54 | let data = super::html::parse_html(s.as_ref()).annotation.into_iter(); 55 | annotations.extend(data); 56 | }, 57 | 58 | Event::Text(mut s) => { 59 | // Add space between sentences 60 | if s.chars() 61 | .last() 62 | .is_some_and(|c| matches!(c, '.' | '!' | '?')) 63 | { 64 | s = pulldown_cmark::CowStr::from(s.to_string() + " "); 65 | } 66 | 67 | let Some(tag) = tags.last() else { 68 | annotations.push(DataAnnotation::new_text(s.to_owned())); 69 | return; 70 | }; 71 | 72 | match tag { 73 | Tag::Heading { level, .. } => { 74 | let s = format!("{s}\n"); 75 | annotations.push(DataAnnotation::new_text(format!( 76 | "{} {s}\n", 77 | "#".repeat(*level as usize) 78 | ))); 79 | }, 80 | 81 | Tag::Emphasis => { 82 | annotations 83 | .push(DataAnnotation::new_interpreted_markup(format!("_{s}_"), s)) 84 | }, 85 | Tag::Strong => { 86 | annotations.push(DataAnnotation::new_interpreted_markup( 87 | format!("**{s}**"), 88 | s, 89 | )) 90 | }, 91 | Tag::Strikethrough => { 92 | annotations 93 | .push(DataAnnotation::new_interpreted_markup(format!("~{s}~"), s)) 94 | }, 95 | 96 | Tag::Link { 97 | title, dest_url, .. 98 | } => { 99 | annotations.push(DataAnnotation::new_interpreted_markup( 100 | format!("[{title}]({dest_url})"), 101 | title.to_string(), 102 | )); 103 | }, 104 | 105 | // No changes necessary 106 | Tag::Paragraph 107 | | Tag::List(_) 108 | | Tag::Item 109 | | Tag::BlockQuote 110 | | Tag::TableCell => { 111 | annotations.push(DataAnnotation::new_text(s)); 112 | }, 113 | 114 | // Just markup 115 | Tag::CodeBlock(_) | Tag::Image { .. } => { 116 | annotations.push(DataAnnotation::new_markup(s)); 117 | }, 118 | _ => {}, 119 | } 120 | }, 121 | Event::Code(s) => { 122 | annotations.push(DataAnnotation::new_interpreted_markup(s, IGNORE)); 123 | }, 124 | 125 | Event::HardBreak => { 126 | annotations.push(DataAnnotation::new_text("\n\n")); 127 | }, 128 | Event::SoftBreak => { 129 | if let Some(last) = annotations.last() { 130 | // Don't add space if the last text already ends with a space 131 | if last 132 | .text 133 | .as_ref() 134 | .is_some_and(|t| t.chars().last().is_some_and(|c| c.is_ascii_whitespace())) 135 | || last.interpret_as.as_ref().is_some_and(|t| { 136 | t.chars().last().is_some_and(|c| c.is_ascii_whitespace()) 137 | }) 138 | { 139 | return; 140 | }; 141 | } 142 | 143 | annotations.push(DataAnnotation::new_text(" ")); 144 | }, 145 | 146 | Event::FootnoteReference(_) | Event::TaskListMarker(_) | Event::Rule => {}, 147 | }; 148 | }); 149 | 150 | Data::from_iter(annotations) 151 | } 152 | -------------------------------------------------------------------------------- /src/parsers/mod.rs: -------------------------------------------------------------------------------- 1 | //! Utilities for parsing the contents of different file types into a format 2 | //! representation that can be parsed by the LanguageTool API. 3 | 4 | #![cfg(feature = "html")] 5 | pub mod html; 6 | 7 | #[cfg(feature = "markdown")] 8 | pub mod markdown; 9 | 10 | #[cfg(feature = "typst")] 11 | pub mod typst; 12 | 13 | /// Pattern that is ignored by the LanguageTool API. 14 | const IGNORE: &str = "_"; 15 | -------------------------------------------------------------------------------- /src/parsers/typst.rs: -------------------------------------------------------------------------------- 1 | //! Parse the contents of Typst files into a format parseable by the 2 | //! LanguageTool API. 3 | 4 | use crate::api::check::{Data, DataAnnotation}; 5 | 6 | /// Parse the contents of a Typst file into a text format to be sent to the 7 | /// LanguageTool API. 8 | pub fn parse_typst(file_content: impl AsRef) -> Data<'static> { 9 | use typst_syntax::{parse, SyntaxKind, SyntaxNode}; 10 | 11 | let mut annotations: Vec = vec![]; 12 | 13 | let parent = parse(file_content.as_ref()); 14 | let mut nodes: Vec<&SyntaxNode> = parent.children().rev().collect(); 15 | 16 | while let Some(node) = nodes.pop() { 17 | let kind = node.kind(); 18 | 19 | // MARKUP NODES 20 | match kind { 21 | // Pure markup 22 | SyntaxKind::SetRule 23 | | SyntaxKind::Ident 24 | | SyntaxKind::ShowRule 25 | | SyntaxKind::Raw 26 | | SyntaxKind::Code 27 | | SyntaxKind::CodeBlock 28 | | SyntaxKind::Math 29 | | SyntaxKind::Equation 30 | | SyntaxKind::Ref 31 | | SyntaxKind::LetBinding 32 | | SyntaxKind::FieldAccess 33 | | SyntaxKind::FuncCall 34 | | SyntaxKind::Args => { 35 | let mut markup = node.text().to_string(); 36 | if markup.is_empty() { 37 | let mut stack: Vec<&SyntaxNode> = node.children().rev().collect(); 38 | while let Some(n) = stack.pop() { 39 | if n.text().is_empty() { 40 | stack.extend(n.children().rev()); 41 | } else { 42 | markup += n.text(); 43 | } 44 | } 45 | } 46 | 47 | annotations.push(DataAnnotation::new_markup(markup)); 48 | continue; 49 | }, 50 | // Markup with valid text interpretations 51 | SyntaxKind::Heading 52 | | SyntaxKind::Markup 53 | | SyntaxKind::EnumItem 54 | | SyntaxKind::ListItem 55 | | SyntaxKind::Emph 56 | | SyntaxKind::Strong => { 57 | let (mut full_text, mut interpreted_as) = (String::new(), String::new()); 58 | let mut stack: Vec<&SyntaxNode> = node.children().rev().collect(); 59 | 60 | while let Some(n) = stack.pop() { 61 | if n.text().is_empty() { 62 | stack.extend(n.children().rev()); 63 | } else { 64 | if matches!(n.kind(), SyntaxKind::Text | SyntaxKind::Space) { 65 | interpreted_as += n.text(); 66 | } 67 | full_text += n.text(); 68 | } 69 | } 70 | 71 | annotations.push(DataAnnotation::new_interpreted_markup( 72 | full_text, 73 | interpreted_as, 74 | )); 75 | continue; 76 | }, 77 | _ => {}, 78 | } 79 | 80 | // NESTED NODES 81 | if node.children().count() > 0 && !matches!(kind, SyntaxKind::Args | SyntaxKind::FuncCall) { 82 | nodes.extend(node.children().rev()); 83 | continue; 84 | } 85 | 86 | // TEXT 87 | if matches!( 88 | kind, 89 | SyntaxKind::Text 90 | | SyntaxKind::SmartQuote 91 | | SyntaxKind::BlockComment 92 | | SyntaxKind::LineComment 93 | | SyntaxKind::Space 94 | | SyntaxKind::Parbreak 95 | ) { 96 | annotations.push(DataAnnotation::new_text(node.text().to_string())); 97 | }; 98 | } 99 | 100 | Data::from_iter(annotations) 101 | } 102 | -------------------------------------------------------------------------------- /tests/cli.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use assert_cmd::Command; 4 | use predicates::{ 5 | boolean::OrPredicate, 6 | str::{contains, is_empty, is_match}, 7 | }; 8 | 9 | lazy_static::lazy_static! { 10 | static ref PATH_ROOT: PathBuf = PathBuf::from(env!("CARGO_MANIFEST_DIR")); 11 | static ref PATH_SAMPLE_FILES: PathBuf = PATH_ROOT.join("tests").join("sample_files"); 12 | } 13 | 14 | const PATH_FILTERS: [(&str, &str); 1] = [(r" --> .*[\/].*\n", " --> [path]\n")]; 15 | macro_rules! assert_snapshot { 16 | ($label: expr, $snap: expr) => { 17 | insta::with_settings!({filters => PATH_FILTERS}, { 18 | insta::assert_snapshot!( 19 | $label, 20 | $snap 21 | ); 22 | }); 23 | }; 24 | } 25 | 26 | fn get_cmd() -> Command { 27 | let mut cmd = Command::cargo_bin("ltrs").unwrap(); 28 | cmd.args(["--hostname", "http://localhost", "--port", "8010"]); 29 | cmd 30 | } 31 | 32 | #[test] 33 | fn test_basic_check_ping() { 34 | let assert = get_cmd().arg("ping").assert(); 35 | assert.success().stdout(contains("PONG!")); 36 | } 37 | 38 | #[test] 39 | fn test_basic_check_text() { 40 | let assert = get_cmd() 41 | .arg("check") 42 | .arg("-t") 43 | .arg("\"some text that is given as text\"") 44 | .assert(); 45 | assert.success(); 46 | } 47 | 48 | #[test] 49 | fn test_basic_check_no_errors() { 50 | let assert = get_cmd() 51 | .arg("check") 52 | .arg("-l") 53 | .arg("en-US") 54 | .arg("-t") 55 | .arg("\"I am a star.\"") 56 | .assert(); 57 | assert 58 | .success() 59 | .stdout(contains("No errors were found in provided text")); 60 | } 61 | 62 | #[test] 63 | fn test_basic_check_empty_text() { 64 | let assert = get_cmd().arg("check").arg("--text=").assert(); 65 | assert 66 | .success() 67 | .stderr(is_match(r".*WARN.* No input text was provided, skipping.").unwrap()); 68 | } 69 | 70 | #[test] 71 | fn test_basic_check_data() { 72 | let assert = get_cmd() 73 | .arg("check") 74 | .arg("-d") 75 | .arg( 76 | r#"{"annotation":[{"text": "A "},{"markup": ""},{"text": "test"},{"markup": ""} 77 | ]}"#, 78 | ) 79 | .assert(); 80 | assert.success(); 81 | } 82 | 83 | #[test] 84 | fn test_basic_check_wrong_data_1() { 85 | let assert = get_cmd() 86 | .arg("check") 87 | .arg("-d") 88 | .arg("\"some text that is given as text\"") 89 | .assert(); 90 | assert.failure().stderr(contains("invalid value")); 91 | } 92 | 93 | #[test] 94 | fn test_basic_check_wrong_data_2() { 95 | let assert = get_cmd().arg("check").arg("-d").arg("\"{}\"").assert(); 96 | assert.failure().stderr(contains("invalid value")); 97 | } 98 | 99 | #[test] 100 | fn test_basic_check_wrong_data_3() { 101 | let assert = get_cmd() 102 | .arg("check") 103 | .arg("-d") 104 | .arg("\"some text that is given as text\"") 105 | .assert(); 106 | assert.failure().stderr(contains("invalid value")); 107 | } 108 | 109 | #[test] 110 | fn test_basic_check_piped() { 111 | let assert = get_cmd() 112 | .arg("check") 113 | .write_stdin("some text that is written to stdin") 114 | .assert(); 115 | assert.success(); 116 | } 117 | 118 | #[test] 119 | fn test_basic_check_stdin_verbose() { 120 | let assert = get_cmd() 121 | .arg("check") 122 | .arg("-v") 123 | .arg("-l") 124 | .arg("en-US") 125 | .write_stdin("I am a starr.") 126 | .assert(); 127 | // We only write if terminal is TTY 128 | assert.success().stderr(is_empty()); 129 | } 130 | 131 | #[test] 132 | fn test_basic_check_file() { 133 | use std::io::Write; 134 | 135 | let mut file = tempfile::NamedTempFile::new().unwrap(); 136 | writeln!(file, "Some text with a error inside.").unwrap(); 137 | 138 | let assert = get_cmd() 139 | .arg("check") 140 | .arg(file.path().to_str().unwrap()) 141 | .assert(); 142 | assert.success(); 143 | } 144 | 145 | #[test] 146 | fn test_basic_check_files() { 147 | use std::io::Write; 148 | 149 | let mut file1 = tempfile::NamedTempFile::new().unwrap(); 150 | writeln!(file1, "Some text with a error inside.").unwrap(); 151 | 152 | let mut file2 = tempfile::NamedTempFile::new().unwrap(); 153 | writeln!(file2, "Another text with an eror.").unwrap(); 154 | 155 | let assert = get_cmd() 156 | .arg("check") 157 | .arg(file1.path().to_str().unwrap()) 158 | .arg(file2.path().to_str().unwrap()) 159 | .assert(); 160 | assert.success(); 161 | } 162 | 163 | #[test] 164 | fn test_basic_check_files_with_empty_file() { 165 | use std::io::Write; 166 | 167 | let mut file1 = tempfile::NamedTempFile::new().unwrap(); 168 | writeln!(file1, "Some text with a error inside.").unwrap(); 169 | 170 | let file2 = tempfile::NamedTempFile::new().unwrap(); 171 | 172 | let assert = get_cmd() 173 | .arg("check") 174 | .arg("-v") 175 | .arg(file1.path().to_str().unwrap()) 176 | .arg(file2.path().to_str().unwrap()) 177 | .assert(); 178 | assert 179 | .success() 180 | .stderr(is_match(r".*INFO.* Skipping empty file: ").unwrap()); 181 | } 182 | 183 | #[test] 184 | fn test_basic_check_unexisting_file() { 185 | let assert = get_cmd() 186 | .arg("check") 187 | .arg("some_file_path_that_should_not_exist.txt") 188 | .assert(); 189 | assert.failure().stderr(contains("invalid filename")); 190 | } 191 | 192 | #[test] 193 | fn test_check_with_language() { 194 | let assert = get_cmd() 195 | .arg("check") 196 | .arg("-t") 197 | .arg("\"some text that is given as text\"") 198 | .arg("-l") 199 | .arg("en-US") 200 | .assert(); 201 | assert.success(); 202 | } 203 | 204 | #[test] 205 | fn test_check_with_wrong_language() { 206 | let assert = get_cmd() 207 | .arg("check") 208 | .arg("-t") 209 | .arg("\"some text that is given as text\"") 210 | .arg("-l") 211 | .arg("lang") 212 | .assert(); 213 | assert.failure().stderr(contains("invalid value")); 214 | } 215 | 216 | #[test] 217 | fn test_check_with_unexisting_language() { 218 | let assert = get_cmd() 219 | .arg("check") 220 | .arg("-t") 221 | .arg("\"some text that is given as text\"") 222 | .arg("-l") 223 | .arg("en-FR") 224 | .assert(); 225 | assert 226 | .failure() 227 | .stderr(contains("not a language code known")); 228 | } 229 | 230 | #[test] 231 | fn test_check_with_username_and_key() { 232 | // TODO: remove the "invalid request" predicate as of LT 6.0 233 | let assert = get_cmd() 234 | .arg("check") 235 | .arg("-t") 236 | .arg("\"some text that is given as text\"") 237 | .arg("--username") 238 | .arg("user") 239 | .arg("--api-key") 240 | .arg("key") 241 | .assert(); 242 | assert.failure().stderr(OrPredicate::new( 243 | contains("AuthException"), 244 | contains("invalid request"), 245 | )); 246 | } 247 | 248 | #[test] 249 | fn test_check_with_username_only() { 250 | let assert = get_cmd() 251 | .arg("check") 252 | .arg("-t") 253 | .arg("\"some text that is given as text\"") 254 | .arg("--username") 255 | .arg("user") 256 | .assert(); 257 | assert.failure().stderr(contains( 258 | "the following required arguments were not provided", 259 | )); 260 | } 261 | 262 | #[test] 263 | fn test_check_with_key_only() { 264 | let assert = get_cmd() 265 | .arg("check") 266 | .arg("-t") 267 | .arg("\"some text that is given as text\"") 268 | .arg("--api-key") 269 | .arg("key") 270 | .assert(); 271 | assert.failure().stderr(contains( 272 | "the following required arguments were not provided", 273 | )); 274 | } 275 | 276 | #[test] 277 | fn test_check_with_dict() { 278 | let assert = get_cmd() 279 | .arg("check") 280 | .arg("-t") 281 | .arg("\"some text that is given as text\"") 282 | .arg("--dicts") 283 | .arg("my_dict") 284 | .assert(); 285 | assert.success(); 286 | } 287 | 288 | #[test] 289 | fn test_check_with_dicts() { 290 | let assert = get_cmd() 291 | .arg("check") 292 | .arg("-t") 293 | .arg("\"some text that is given as text\"") 294 | .arg("--dicts") 295 | .arg("my_dict1,my_dict2") 296 | .assert(); 297 | assert.success(); 298 | } 299 | 300 | #[test] 301 | fn test_check_with_preferred_variant() { 302 | let assert = get_cmd() 303 | .arg("check") 304 | .arg("-t") 305 | .arg("\"some text that is given as text\"") 306 | .arg("--preferred-variants") 307 | .arg("en-GB") 308 | .assert(); 309 | assert.success(); 310 | } 311 | 312 | #[test] 313 | fn test_check_with_preferred_variants() { 314 | let assert = get_cmd() 315 | .arg("check") 316 | .arg("-t") 317 | .arg("\"some text that is given as text\"") 318 | .arg("--preferred-variants") 319 | .arg("en-GB,de-AT") 320 | .assert(); 321 | assert.success(); 322 | } 323 | 324 | #[test] 325 | fn test_check_with_language_and_preferred_variant() { 326 | let assert = get_cmd() 327 | .arg("check") 328 | .arg("-t") 329 | .arg("\"some text that is given as text\"") 330 | .arg("-l") 331 | .arg("en-US") 332 | .arg("--preferred-variants") 333 | .arg("en-GB") 334 | .assert(); 335 | assert.failure().stderr(contains( 336 | "the argument \'--language \' cannot be used with \'--preferred-variants \ 337 | \'", 338 | )); 339 | } 340 | 341 | #[test] 342 | fn test_check_with_enabled_rule() { 343 | let assert = get_cmd() 344 | .arg("check") 345 | .arg("-t") 346 | .arg("\"some text that is given as text\"") 347 | .arg("--enabled-rules") 348 | .arg("EMPTY_LINE") 349 | .assert(); 350 | assert.success(); 351 | } 352 | 353 | #[test] 354 | fn test_check_with_enabled_rules() { 355 | let assert = get_cmd() 356 | .arg("check") 357 | .arg("-t") 358 | .arg("\"some text that is given as text\"") 359 | .arg("--enabled-rules") 360 | .arg("EMPTY_LINE,WHITESPACE_RULE") 361 | .assert(); 362 | assert.success(); 363 | } 364 | 365 | #[test] 366 | fn test_check_with_disabled_rule() { 367 | let assert = get_cmd() 368 | .arg("check") 369 | .arg("-t") 370 | .arg("\"some text that is given as text\"") 371 | .arg("--disabled-rules") 372 | .arg("EMPTY_LINE") 373 | .assert(); 374 | assert.success(); 375 | } 376 | 377 | #[test] 378 | fn test_check_with_disabled_rules() { 379 | let assert = get_cmd() 380 | .arg("check") 381 | .arg("-t") 382 | .arg("\"some text that is given as text\"") 383 | .arg("--disabled-rules") 384 | .arg("EMPTY_LINE,WHITESPACE_RULE") 385 | .assert(); 386 | assert.success(); 387 | } 388 | 389 | #[test] 390 | fn test_check_with_enabled_category() { 391 | let assert = get_cmd() 392 | .arg("check") 393 | .arg("-t") 394 | .arg("\"some text that is given as text\"") 395 | .arg("--enabled-categories") 396 | .arg("STYLE") 397 | .assert(); 398 | assert.success(); 399 | } 400 | 401 | #[test] 402 | fn test_check_with_enabled_categories() { 403 | let assert = get_cmd() 404 | .arg("check") 405 | .arg("-t") 406 | .arg("\"some text that is given as text\"") 407 | .arg("--enabled-categories") 408 | .arg("STYLE,TYPOGRAPHY") 409 | .assert(); 410 | assert.success(); 411 | } 412 | 413 | #[test] 414 | fn test_check_with_disabled_category() { 415 | let assert = get_cmd() 416 | .arg("check") 417 | .arg("-t") 418 | .arg("\"some text that is given as text\"") 419 | .arg("--disabled-categories") 420 | .arg("STYLE") 421 | .assert(); 422 | assert.success(); 423 | } 424 | 425 | #[test] 426 | fn test_check_with_disabled_categories() { 427 | let assert = get_cmd() 428 | .arg("check") 429 | .arg("-t") 430 | .arg("\"some text that is given as text\"") 431 | .arg("--disabled-categories") 432 | .arg("STYLE,TYPOGRAPHY") 433 | .assert(); 434 | assert.success(); 435 | } 436 | 437 | #[test] 438 | fn test_check_with_enabled_only_rule() { 439 | let assert = get_cmd() 440 | .arg("check") 441 | .arg("-t") 442 | .arg("\"some text that is given as text\"") 443 | .arg("--enabled-rules") 444 | .arg("EMPTY_LINE") 445 | .arg("--enabled-only") 446 | .assert(); 447 | assert.success(); 448 | } 449 | 450 | #[test] 451 | fn test_check_with_enabled_only_category() { 452 | let assert = get_cmd() 453 | .arg("check") 454 | .arg("-t") 455 | .arg("\"some text that is given as text\"") 456 | .arg("--enabled-categories") 457 | .arg("STYLE") 458 | .arg("--enabled-only") 459 | .assert(); 460 | assert.success(); 461 | } 462 | 463 | #[test] 464 | fn test_check_with_enabled_only_without_enabled() { 465 | let assert = get_cmd() 466 | .arg("check") 467 | .arg("-t") 468 | .arg("\"some text that is given as text\"") 469 | .arg("--enabled-only") 470 | .assert(); 471 | assert.failure().stderr(contains("invalid request")); 472 | } 473 | 474 | #[test] 475 | fn test_check_with_picky_level() { 476 | let assert = get_cmd() 477 | .arg("check") 478 | .arg("-t") 479 | .arg("\"some text that is given as text\"") 480 | .arg("--level") 481 | .arg("picky") 482 | .assert(); 483 | assert.success(); 484 | } 485 | 486 | #[test] 487 | fn test_check_with_unexisting_level() { 488 | let assert = get_cmd() 489 | .arg("check") 490 | .arg("-t") 491 | .arg("\"some text that is given as text\"") 492 | .arg("--level") 493 | .arg("strict") 494 | .assert(); 495 | assert.failure(); 496 | } 497 | 498 | #[test] 499 | fn test_languages() { 500 | let assert = get_cmd().arg("languages").assert(); 501 | assert.success(); 502 | } 503 | 504 | #[test] 505 | fn test_ping() { 506 | let assert = get_cmd().arg("ping").assert(); 507 | assert.success().stdout(contains("PONG! Delay: ")); 508 | } 509 | 510 | #[test] 511 | fn test_words() { 512 | // TODO: remove the "invalid request" predicate as of LT 6.0 513 | let assert = get_cmd() 514 | .arg("words") 515 | .arg("--username") 516 | .arg("user") 517 | .arg("--api-key") 518 | .arg("key") 519 | .assert(); 520 | assert.failure().stderr(OrPredicate::new( 521 | contains("AuthException"), 522 | contains("invalid request"), 523 | )); 524 | } 525 | 526 | #[test] 527 | fn test_words_add() { 528 | // TODO: remove the "invalid request" predicate as of LT 6.0 529 | let assert = get_cmd() 530 | .arg("words") 531 | .arg("add") 532 | .arg("--username") 533 | .arg("user") 534 | .arg("--api-key") 535 | .arg("key") 536 | .arg("my-word") 537 | .assert(); 538 | assert.failure().stderr(OrPredicate::new( 539 | contains("AuthException"), 540 | contains("invalid request"), 541 | )); 542 | } 543 | 544 | #[test] 545 | fn test_words_delete() { 546 | let assert = get_cmd() 547 | .arg("words") 548 | .arg("delete") 549 | .arg("--username") 550 | .arg("user") 551 | .arg("--api-key") 552 | .arg("key") 553 | .arg("my-word") 554 | .assert(); 555 | assert.failure().stderr(OrPredicate::new( 556 | contains("AuthException"), 557 | contains("invalid request"), 558 | )); 559 | } 560 | 561 | #[cfg_attr(not(feature = "snapshots"), ignore)] 562 | #[test] 563 | fn test_check_file_typst() { 564 | let output = get_cmd() 565 | .arg("check") 566 | .arg(PATH_SAMPLE_FILES.join("example.typ")) 567 | .output() 568 | .unwrap(); 569 | assert_snapshot!( 570 | "autodetect_typst_file", 571 | String::from_utf8(output.stdout).unwrap() 572 | ); 573 | } 574 | 575 | #[cfg_attr(not(feature = "snapshots"), ignore)] 576 | #[test] 577 | fn test_check_file_html() { 578 | let output = get_cmd() 579 | .arg("check") 580 | .arg(PATH_SAMPLE_FILES.join("example.html")) 581 | .output() 582 | .unwrap(); 583 | assert_snapshot!( 584 | "autodetect_html_file", 585 | String::from_utf8(output.stdout).unwrap() 586 | ); 587 | } 588 | 589 | #[cfg_attr(not(feature = "snapshots"), ignore)] 590 | #[test] 591 | fn test_check_file_markdown() { 592 | let output = get_cmd() 593 | .arg("check") 594 | .arg(PATH_ROOT.join("README.md")) 595 | .output() 596 | .unwrap(); 597 | assert_snapshot!( 598 | "autodetect_markdown_file", 599 | String::from_utf8(output.stdout).unwrap() 600 | ); 601 | 602 | let output = get_cmd() 603 | .arg("check") 604 | .arg(PATH_ROOT.join("CONTRIBUTING.md")) 605 | .output() 606 | .unwrap(); 607 | assert_snapshot!( 608 | "autodetect_markdown_file_contributing", 609 | String::from_utf8(output.stdout).unwrap() 610 | ); 611 | } 612 | -------------------------------------------------------------------------------- /tests/match_positions.rs: -------------------------------------------------------------------------------- 1 | use languagetool_rust::api::{check, server::ServerClient}; 2 | 3 | #[macro_export] 4 | macro_rules! test_match_positions { 5 | ($name:ident, $text:expr, $(($x:expr, $y:expr)),*) => { 6 | #[tokio::test] 7 | async fn $name() -> Result<(), Box> { 8 | 9 | let client = ServerClient::from_env_or_default(); 10 | let req = check::Request::default().with_text($text); 11 | let resp = client.check(&req).await.unwrap(); 12 | let resp = check::ResponseWithContext::new(req.get_text(), resp); 13 | 14 | let expected = vec![$(($x, $y)),*]; 15 | let got = resp.iter_match_positions(); 16 | 17 | assert_eq!(expected.len(), resp.response.matches.len()); 18 | 19 | for ((lineno, lineof), got) in expected.iter().zip(got) { 20 | assert_eq!(*lineno, got.0); 21 | assert_eq!(*lineof, got.1); 22 | } 23 | 24 | Ok(()) 25 | } 26 | }; 27 | } 28 | 29 | test_match_positions!( 30 | test_match_positions_1, 31 | "Some phrase with a smal mistake. 32 | i can drive a car", 33 | (1, 19), 34 | (2, 0) 35 | ); 36 | 37 | test_match_positions!( 38 | test_match_positions_2, 39 | "Some phrase with 40 | a smal mistake. i can 41 | drive a car", 42 | (2, 2), 43 | (2, 16) 44 | ); 45 | 46 | test_match_positions!( 47 | test_match_positions_3, 48 | " Some phrase with a smal 49 | mistake. 50 | 51 | i can drive a car", 52 | (1, 0), 53 | (1, 21), 54 | (4, 0), 55 | (4, 2) 56 | ); 57 | 58 | test_match_positions!( 59 | test_match_positions_4, 60 | "Some phrase with a smal mistake. 61 | i can drive a car 62 | Some phrase with a smal mistake.", 63 | (1, 19), 64 | (2, 0), 65 | (3, 19) 66 | ); 67 | -------------------------------------------------------------------------------- /tests/sample_files/example.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | /home/rolv/Documents/zk/program/i4BJbPUbyR_stremio.md 6 | 7 | 42 | 43 | 44 | 45 | al 46 | 47 | 48 | 49 | 50 | 51 |

52 | Fork me? Fork you, @octocat! Here is a link: makarainen.net 53 |

54 | 55 | 56 | #[cfg(feature = "html")] 57 | pub fn parse_html(file_content: impl AsRef<str>) -> String { 58 | use html_parser::{ElementVariant, Node}; 59 | use select::{document::Document, node::Data, predicate}; 60 | 61 | let mut txt = String::new(); 62 | 63 | 64 | 65 |

Hello world

66 | 67 | 68 |
69 |
70 | Task 71 |
72 |
73 |
74 |
75 | Test an incorect spling 76 |
77 |
78 |
79 |
80 | 81 |
82 | 83 |

84 | Sean made a change 85 |

86 |
87 | 88 | 89 |
90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 |
Header 1Header 2Header 3Header 4
Row 1 Col 1Row 1 Col 2Row 1 Col 3Row 1 Col 4
Row 2 Col 1Row 2 Col 2Row 2 Col 3Row 2 Col 4
Row 3 Col 1Row 3 Col 2Row 3 Col 3Row 3 Col 4
120 |
121 | 122 | 123 | 124 | -------------------------------------------------------------------------------- /tests/sample_files/example.typ: -------------------------------------------------------------------------------- 1 | #set page(width: 10cm, height: auto) 2 | 3 | = Introduction 4 | In this report, we will explore the 5 | various factors that influence _fluid 6 | dynamics_ in glaciers and how they 7 | contribute to the formation and 8 | behaviour of these natural structures. 9 | 10 | + The climate 11 | - Temperatre 12 | - Precipitation 13 | + The topography 14 | + The geology 15 | 16 | Glaciers as the one shown in 17 | @glaciers will cease to exist if 18 | we don't take action soon! 19 | 20 | #figure( 21 | image("glacier.jpg", width: 70%), 22 | caption: [ 23 | _Glaciers_ form an important part 24 | of the earth's climate system. 25 | ], 26 | ) 27 | 28 | 29 | = Methods 30 | We follow the glacier melting models 31 | established in @glacier-melt. 32 | 33 | #bibliography("works.bib") 34 | 35 | The flow rate of a glacier is given 36 | by the following equation: 37 | 38 | $ Q = rho A v + "time offset" $ 39 | 40 | Total displaced soil by glacial flow: 41 | 42 | $ 7.32 beta + 43 | sum_(i=0)^nabla 44 | (Q_i (a_i - epsilon)) / 2 $ 45 | 46 | = Tables 47 | 48 | /* Text in a comment 49 | * block. */ 50 | // Text in a regular comment. 51 | 52 | #table( 53 | columns: (1fr, auto, auto), 54 | inset: 10pt, 55 | align: horizon, 56 | table.header( 57 | [], [*Volume*], [*Parameters*], 58 | ), 59 | image("cylinder.svg"), 60 | $ pi h (D^2 - d^2) / 4 $, 61 | [ 62 | $h$: height \ 63 | $D$: outer radius \ 64 | $d$: inner radius 65 | ], 66 | image("tetrahedron.svg"), 67 | $ sqrt(2) / 12 a^3 $, 68 | [$a$: edge length] 69 | ) 70 | 71 | #set table( 72 | stroke: none, 73 | gutter: 0.2em, 74 | fill: (x, y) => 75 | if x == 0 or y == 0 { gray }, 76 | inset: (right: 1.5em), 77 | ) 78 | 79 | #show table.cell: it => { 80 | if it.x == 0 or it.y == 0 { 81 | set text(white) 82 | strong(it) 83 | } else if it.body == [] { 84 | // Replace empty cells with 'N/A' 85 | pad(..it.inset)[_N/A_] 86 | } else { 87 | it 88 | } 89 | } 90 | 91 | #let a = table.cell( 92 | fill: green.lighten(60%), 93 | )[A] 94 | #let b = table.cell( 95 | fill: aqua.lighten(60%), 96 | )[B] 97 | 98 | #table( 99 | columns: 4, 100 | [], [Exam 1], [Exam 2], [Exam 3], 101 | 102 | [John], [], a, [], 103 | [Mary], [], a, a, 104 | [Robert], b, a, b, 105 | ) 106 | 107 | = Code blocks 108 | 109 | Adding `rbx` to `rcx` gives 110 | the desired result. 111 | 112 | What is ```rust fn main()``` in Rust 113 | would be ```c int main()``` in C. 114 | 115 | ```rust 116 | fn main() { 117 | println!("Hello World!"); 118 | } 119 | ``` 120 | 121 | This has ``` `backticks` ``` in it 122 | (but the spaces are trimmed). And 123 | ``` here``` the leading space is 124 | also trimmed. 125 | 126 | = Fibonacci sequence 127 | The Fibonacci sequence is defined through the 128 | recurrence relation $F_n = F_(n-1) + F_(n-2)$. 129 | It can also be expressed in _closed form:_ 130 | 131 | $ F_n = round(1 / sqrt(5) phi.alt^n), quad 132 | phi.alt = (1 + sqrt(5)) / 2 $ 133 | 134 | #let count = 8 135 | #let nums = range(1, count + 1) 136 | #let fib(n) = ( 137 | if n <= 2 { 1 } 138 | else { fib(n - 1) + fib(n - 2) } 139 | ) 140 | 141 | The first #count numbers of the sequence are: 142 | 143 | #align(center, table( 144 | columns: count, 145 | ..nums.map(n => $F_#n$), 146 | ..nums.map(n => str(fib(n))), 147 | )) 148 | -------------------------------------------------------------------------------- /tests/snapshots/cli__autodetect_html_file.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/cli.rs 3 | expression: "String::from_utf8(output.stdout).unwrap()" 4 | --- 5 | error[MORFOLOGIK_RULE_EN_US]: Possible spelling mistake found. 6 | --> [path] 7 | | 8 | 4 | [path] 14 | | 15 | 5 | ...ask
Test an incorect spling
... 16 | | ^^^^^^^^ Possible spelling mistake 17 | | -------- help: incorrect 18 | | 19 | error[MORFOLOGIK_RULE_EN_US]: Possible spelling mistake found. 20 | --> [path] 21 | | 22 | 5 | ...>
Test an incorect spling
... 23 | | ^^^^^^ Possible spelling mistake 24 | | ------ help: spring, spying, sling, spline, splint, ... (2 not shown) 25 | | 26 | -------------------------------------------------------------------------------- /tests/snapshots/cli__autodetect_markdown_file.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/cli.rs 3 | expression: "String::from_utf8(output.stdout).unwrap()" 4 | --- 5 | error[COMMA_PARENTHESIS_WHITESPACE]: Don’t put a space before the full stop. 6 | --> [path] 7 | | 8 | 6 | ...ges and is free to use, more on that on_ [](https://languagetool.org/)_. __There is a public API (with a free tie... 9 | | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Use of whitespace before comma and before/after parentheses 10 | | --------------------------------- help: . 11 | | 12 | error[WHITESPACE_RULE]: Possible typo: you repeated a whitespace 13 | --> [path] 14 | | 15 | 11 | ...rs very easily via Rust code! _Crates.io docs.rs codecov- - [](#about) - [](#cli-referenc... 16 | | ^^^^^^^^^ Whitespace repetition (bad formatting) 17 | | --------- help: 18 | | 19 | error[COMMA_PARENTHESIS_WHITESPACE]: Don’t put a space before the full stop. 20 | --> [path] 21 | | 22 | 13 | ...on. Installation guidelines can be found [](https://www.docker.com/get-started/). On Linux platforms, you might need to c... 23 | | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Use of whitespace before comma and before/after parentheses 24 | | ----------------------------------------- help: . 25 | | 26 | error[COMMA_PARENTHESIS_WHITESPACE]: Don’t put a space before the full stop. 27 | --> [path] 28 | | 29 | 14 | ...vent the _sudo privilege issue_ by doing [](https://docs.docker.com/engine/install/linux-postinstall/). ## API Reference 30 | | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Use of whitespace before comma and before/after parentheses 31 | | --------------------------------------------------------------- help: . 32 | | 33 | error[COMMA_PARENTHESIS_WHITESPACE]: Don’t put a space before the full stop. 34 | --> [path] 35 | | 36 | 5 | ... or crate, then we recommend reading the [](https://docs.rs/languagetool-rust). To use LanguageTool-Rust in your Rust p... 37 | | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Use of whitespace before comma and before/after parentheses 38 | | --------------------------------------- help: . 39 | | 40 | error[MORFOLOGIK_RULE_EN_US]: Possible spelling mistake found. 41 | --> [path] 42 | | 43 | 5 | ...piling LTRS. #### Default Features - - **cli**: Adds command-line related methods for ... 44 | | ^^^^^^^ Possible spelling mistake 45 | | ------- help: CLI, Clip, CGI, CPI, CSI, ... (1506 not shown) 46 | | 47 | error[FILE_EXTENSIONS_CASE]: File types are normally capitalized. 48 | --> [path] 49 | | 50 | 8 | ...wing features: **annotate**, **color**, **html**, **markdown**, **multithreaded**, **typ... 51 | | ^^^^^^^^ Capitalize file extensions 52 | | -------- help: HTML 53 | | 54 | error[MORFOLOGIK_RULE_EN_US]: Possible spelling mistake found. 55 | --> [path] 56 | | 57 | 9 | ...tml**, **markdown**, **multithreaded**, **typst**. - **native-tls**: Enables TLS functio... 58 | | ^^^^^^^^^ Possible spelling mistake 59 | | --------- help: Typst, typist, type, types, test, ... (28 not shown) 60 | | 61 | error[MORFOLOGIK_RULE_EN_US]: Possible spelling mistake found. 62 | --> [path] 63 | | 64 | 10 | ...own**, **multithreaded**, **typst**. - **native-tls**: Enables TLS functionality provided by ... 65 | | ^^^^^^^^^^^^^^ Possible spelling mistake 66 | | -------------- help: natives, natively, nativists, nativeness, naivetes 67 | | 68 | error[MORFOLOGIK_RULE_EN_US]: Possible spelling mistake found. 69 | --> [path] 70 | | 71 | 3 | ...annotate results from check request. - **cli-complete**: Adds commands to generate completion f... 72 | | ^^^^^^^^^^^^^^^^ Possible spelling mistake 73 | | ---------------- help: incomplete 74 | | 75 | error[MORFOLOGIK_RULE_EN_US]: Possible spelling mistake found. 76 | --> [path] 77 | | 78 | 6 | ...shells. This feature also activates the **cli** feature. Enter ltrs completions --help ... 79 | | ^^^^^^^ Possible spelling mistake 80 | | ------- help: CLI, clip, CGI, CPI, CSI, ... (1543 not shown) 81 | | 82 | error[MORFOLOGIK_RULE_EN_US]: Possible spelling mistake found. 83 | --> [path] 84 | | 85 | 10 | ...es color outputting in the terminal. If **cli** feature is also enabled, the --color= [path] 91 | | 92 | 12 | ...., cli-complete, docker, and undoc). - **html**: Enables HTML parser utilities. - **ma... 93 | | ^^^^^^^^ Capitalize file extensions 94 | | -------- help: HTML 95 | | 96 | error[MORFOLOGIK_RULE_EN_US]: Possible spelling mistake found. 97 | --> [path] 98 | | 99 | 14 | ...d**: Enables multithreaded requests. - **native-tls-vendored**: Enables the vendored feature of native... 100 | | ^^^^^^^^^^^^^^^^^^^^^^^ Possible spelling mistake 101 | | ----------------------- help: native-TLS-vendored 102 | | 103 | error[MORFOLOGIK_RULE_EN_US]: Possible spelling mistake found. 104 | --> [path] 105 | | 106 | 17 | ...u are planning to use HTTPS servers. - **typst**: Enables Typst parser utilities. - **u... 107 | | ^^^^^^^^^ Possible spelling mistake 108 | | --------- help: Typst, typist, type, types, test, ... (28 not shown) 109 | | 110 | error[MORFOLOGIK_RULE_EN_US]: Possible spelling mistake found. 111 | --> [path] 112 | | 113 | 19 | ...t**: Enables Typst parser utilities. - **undoc**: Adds more fields to JSON responses tha... 114 | | ^^^^^^^^^ Possible spelling mistake 115 | | --------- help: undo, undock, undos 116 | | 117 | error[THE_CC]: It appears that a noun is missing after “the”. 118 | --> [path] 119 | | 120 | 21 | ... JSON responses that are not present in the [](https://languagetool.org/http-api/sw... 121 | | ^^^ the and 122 | | --- 123 | | 124 | error[CONSECUTIVE_SPACES]: It seems like there are too many consecutive spaces here. 125 | --> [path] 126 | | 127 | 21 | ...ON responses that are not present in the [](https://languagetool.org/http-api/swagger-ui/#!/default/) but might be present in some cases. All ... 128 | | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Two consecutive spaces 129 | | -------------------------------------------------------------- help: 130 | | 131 | error[MORFOLOGIK_RULE_EN_US]: Possible spelling mistake found. 132 | --> [path] 133 | | 134 | 23 | ...cases. All added fields are stored in a hashmap as JSON values. ## Related Projects ... 135 | | ^^^^^^^ Possible spelling mistake 136 | | ------- help: hash map 137 | | 138 | -------------------------------------------------------------------------------- /tests/snapshots/cli__autodetect_markdown_file_contributing.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/cli.rs 3 | expression: "String::from_utf8(output.stdout).unwrap()" 4 | --- 5 | error[COMMA_PARENTHESIS_WHITESPACE]: Put a space after the comma, but not before the comma. 6 | --> [path] 7 | | 8 | 3 | ...th (1) a fast, idiomatic Rust client for [](https://languagetool.org/), supporting both HTTP and local servers,... 9 | | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Use of whitespace before comma and before/after parentheses 10 | | ------------------------------- help: , 11 | | 12 | error[CONSECUTIVE_SPACES]: It seems like there are too many consecutive spaces here. 13 | --> [path] 14 | | 15 | 3 | ...AGETOOL_PORT. We also recommend that you [](https://dev.languagetool.org/http-server) on your machine. If you have [](https://... 16 | | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Two consecutive spaces 17 | | ---------------------------------------------- help: 18 | | 19 | error[CONSECUTIVE_SPACES]: It seems like there are too many consecutive spaces here. 20 | --> [path] 21 | | 22 | 3 | ...ttp-server) on your machine. If you have [](https://www.docker.com/) installed on your machine, we provide yo... 23 | | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Two consecutive spaces 24 | | ----------------------------- help: 25 | | 26 | error[COMMA_PARENTHESIS_WHITESPACE]: Don’t put a space before the full stop. 27 | --> [path] 28 | | 29 | 12 | ... this project uses snapshot testing with [](https://github.com/mitsuhiko/insta). If you introduce changes to the snapsho... 30 | | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Use of whitespace before comma and before/after parentheses 31 | | ---------------------------------------- help: . 32 | | 33 | error[COMMA_PARENTHESIS_WHITESPACE]: Don’t put a space before the full stop. 34 | --> [path] 35 | | 36 | 10 | ...lic features should be documented in the [](./README.md). If some features are mutually incompati... 37 | | ^^^^^^^^^^^^^^^^^ Use of whitespace before comma and before/after parentheses 38 | | ----------------- help: . 39 | | 40 | -------------------------------------------------------------------------------- /tests/snapshots/cli__autodetect_typst_file.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/cli.rs 3 | expression: "String::from_utf8(output.stdout).unwrap()" 4 | --- 5 | error[MORFOLOGIK_RULE_EN_US]: Possible spelling mistake. ‘behaviour’ is British English. 6 | --> [path] 7 | | 8 | 7 | ...ow they contribute to the formation and behaviour of these natural structures. + The cli... 9 | | ^^^^^^^^^ Possible spelling mistake 10 | | --------- help: behavior 11 | | 12 | error[MORFOLOGIK_RULE_EN_US]: Possible spelling mistake found. 13 | --> [path] 14 | | 15 | 9 | ...behaviour of these natural structures. + The climate - Temperatre - Precipitation + The topography + The... 16 | | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Possible spelling mistake 17 | | ----------------------------- help: Temperature, Temperate 18 | | 19 | error[COMMA_PARENTHESIS_WHITESPACE]: Don’t put a space before the full stop. 20 | --> [path] 21 | | 22 | 8 | ...he glacier melting models established in @glacier-melt. bibliography("works.bib") The flow ra... 23 | | ^^^^^^^^^^^^^^^ Use of whitespace before comma and before/after parentheses 24 | | --------------- help: . 25 | | 26 | error[CONSECUTIVE_SPACES]: It seems like there are too many consecutive spaces here. 27 | --> [path] 28 | | 29 | 1 | = Code blocks Adding `rbx` to `rcx` gives the desired result. What... 30 | | ^^^^^^^ Two consecutive spaces 31 | | ------- help: 32 | | 33 | error[CONSECUTIVE_SPACES]: It seems like there are too many consecutive spaces here. 34 | --> [path] 35 | | 36 | 1 | = Code blocks Adding `rbx` to `rcx` gives the desired result. What is ```ru... 37 | | ^^^^^^^ Two consecutive spaces 38 | | ------- help: 39 | | 40 | error[TO_NON_BASE]: The verb after “to” should be in the base form as part of the to-infinitive. A verb can take many forms, but the base form is always used in the to-infinitive. 41 | --> [path] 42 | | 43 | 2 | = Code blocks Adding `rbx` to `rcx` gives the desired result. What is ```rust fn... 44 | | ^^^^^ 'to' + non-base form 45 | | ----- help: give 46 | | 47 | error[CONSECUTIVE_SPACES]: It seems like there are too many consecutive spaces here. 48 | --> [path] 49 | | 50 | 4 | ...`rcx` gives the desired result. What is ```rust fn main()``` in Rust would be ```c int main()``` in C... 51 | | ^^^^^^^^^^^^^^^^^^^^^^ Two consecutive spaces 52 | | ---------------------- help: 53 | | 54 | error[CONSECUTIVE_SPACES]: It seems like there are too many consecutive spaces here. 55 | --> [path] 56 | | 57 | 5 | ...is ```rust fn main()``` in Rust would be ```c int main()``` in C. ```rust fn main() { println!(... 58 | | ^^^^^^^^^^^^^^^^^^^^ Two consecutive spaces 59 | | -------------------- help: 60 | | 61 | error[CONSECUTIVE_SPACES]: It seems like there are too many consecutive spaces here. 62 | --> [path] 63 | | 64 | 8 | ...rintln!("Hello World!"); } ``` This has ``` `backticks` ``` in it (but the spaces are trimmed). And ... 65 | | ^^^^^^^^^^^^^^^^^^^^^ Two consecutive spaces 66 | | --------------------- help: 67 | | 68 | error[COMMA_PARENTHESIS_WHITESPACE]: Don’t put a space before the full stop. 69 | --> [path] 70 | | 71 | 18 | ... defined through the recurrence relation $F_n = F_(n-1) + F_(n-2)$. It can also be expressed in _closed for... 72 | | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ Use of whitespace before comma and before/after parentheses 73 | | --------------------------- help: . 74 | | 75 | --------------------------------------------------------------------------------