├── .github ├── dependabot.yml └── workflows │ ├── build.yml │ ├── cifuzz.yml │ ├── coverage.yml │ ├── prepare-release.yml │ └── publish-crate.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── benchmarks ├── Cargo.lock ├── Cargo.toml ├── indent.rs ├── la.standard.bincode ├── linear.rs └── unfill.rs ├── dprint.json ├── examples ├── binary-sizes │ ├── Cargo.lock │ ├── Cargo.toml │ ├── examples │ │ └── make-table.rs │ └── src │ │ └── main.rs ├── debug-words.rs ├── hello_world.rs ├── hyphenation.rs ├── interactive.rs ├── layout.rs ├── termwidth.rs └── wasm │ ├── Cargo.lock │ ├── Cargo.toml │ ├── LICENSE │ ├── README.md │ ├── src │ └── lib.rs │ └── www │ ├── bootstrap.js │ ├── build-info.json │ ├── index.html │ ├── index.js │ ├── package-lock.json │ ├── package.json │ └── webpack.config.js ├── fuzz ├── Cargo.lock ├── Cargo.toml └── fuzz_targets │ ├── fill_fast_path.rs │ ├── fill_first_fit.rs │ ├── fill_optimal_fit.rs │ ├── refill.rs │ ├── unfill.rs │ ├── wrap_fast_path.rs │ ├── wrap_first_fit.rs │ ├── wrap_optimal_fit.rs │ └── wrap_optimal_fit_usize.rs ├── images ├── textwrap-0.13.2.svg ├── textwrap-0.13.3.svg ├── textwrap-0.13.4.svg ├── textwrap-0.14.0.svg ├── textwrap-0.14.1.svg ├── textwrap-0.14.2.svg ├── textwrap-0.15.0.svg ├── textwrap-0.15.1.svg ├── textwrap-0.15.2.svg ├── textwrap-0.16.0.svg ├── textwrap-0.16.1.svg └── textwrap-0.16.2.svg ├── rustfmt.toml ├── src ├── columns.rs ├── core.rs ├── fill.rs ├── fuzzing.rs ├── indentation.rs ├── lib.rs ├── line_ending.rs ├── options.rs ├── refill.rs ├── termwidth.rs ├── word_separators.rs ├── word_splitters.rs ├── wrap.rs ├── wrap_algorithms.rs └── wrap_algorithms │ └── optimal_fit.rs └── tests ├── indent.rs └── version-numbers.rs /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Please see the documentation for all configuration options: 2 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 3 | 4 | version: 2 5 | updates: 6 | - package-ecosystem: github-actions 7 | directory: / 8 | schedule: 9 | interval: monthly 10 | 11 | - package-ecosystem: cargo 12 | directory: / 13 | schedule: 14 | interval: monthly 15 | ignore: 16 | - dependency-name: "*" 17 | update-types: 18 | - "version-update:semver-patch" 19 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - master 8 | schedule: 9 | - cron: "30 17 * * 5" # Every Friday at 17:30 UTC 10 | 11 | env: 12 | CARGO_TERM_COLOR: always 13 | NPM_CONFIG_COLOR: always 14 | FORCE_COLOR: 3 15 | RUSTFLAGS: -D warnings 16 | 17 | jobs: 18 | # Here we check that we can run `cargo test` on modern versions of 19 | # Rust. Below we check that we can build the library on an old 20 | # version as well. 21 | ubuntu: 22 | name: Ubuntu 23 | runs-on: ubuntu-latest 24 | strategy: 25 | fail-fast: false 26 | matrix: 27 | rust: 28 | - stable 29 | - nightly 30 | features: 31 | - no default features 32 | - all features 33 | - default features 34 | steps: 35 | - name: Checkout repository 36 | uses: actions/checkout@v4 37 | 38 | - name: Install ${{ matrix.rust }} Rust 39 | run: rustup default ${{ matrix.rust }} 40 | 41 | - uses: Swatinem/rust-cache@v2 42 | 43 | - name: Test with ${{ matrix.features }} 44 | run: | 45 | FLAG="${{ matrix.features }}" 46 | if [[ "$FLAG" = "default features" ]]; then 47 | FLAG='' # Needs no flag 48 | else 49 | FLAG="--${FLAG// /-}" # Turn 'foo bar' into '--foo-bar' 50 | fi 51 | cargo test $FLAG 52 | 53 | windows: 54 | name: Windows (stable, default features) 55 | runs-on: windows-latest 56 | steps: 57 | - name: Checkout repository 58 | uses: actions/checkout@v4 59 | 60 | - uses: Swatinem/rust-cache@v2 61 | 62 | - name: Test with default features 63 | run: cargo test 64 | 65 | msrv: 66 | name: Minimum supported Rust version 67 | runs-on: ubuntu-latest 68 | strategy: 69 | fail-fast: false 70 | matrix: 71 | features: 72 | - no default features 73 | - all features 74 | - default features 75 | steps: 76 | - name: Checkout repository 77 | uses: actions/checkout@v4 78 | 79 | - name: Find Minimum Support Rust Version 80 | run: | 81 | echo "msrv=$(egrep 'rust-version\s*=\s*"([0-9.]+)"' Cargo.toml | cut -d '"' -f 2)" >> "$GITHUB_ENV" 82 | 83 | - name: Install Rust ${{ env.msrv }} (MSRV) 84 | run: rustup default $msrv 85 | 86 | - uses: Swatinem/rust-cache@v2 87 | 88 | - name: Build with ${{ matrix.features }} 89 | run: | 90 | FLAG="${{ matrix.features }}" 91 | if [[ "$FLAG" = "default features" ]]; then 92 | FLAG='' # Needs no flag 93 | else 94 | FLAG="--${FLAG// /-}" # Turn 'foo bar' into '--foo-bar' 95 | fi 96 | cargo build $FLAG 97 | 98 | # This builds benchmarks, which are not covered above. 99 | build-benchmarks: 100 | name: Build benchmarks 101 | runs-on: ubuntu-latest 102 | steps: 103 | - name: Checkout repository 104 | uses: actions/checkout@v4 105 | 106 | - uses: Swatinem/rust-cache@v2 107 | with: 108 | workspaces: benchmarks -> target 109 | 110 | - name: Build all benchmarks 111 | run: cargo bench --no-run 112 | working-directory: benchmarks 113 | 114 | build-documentation: 115 | name: Build documentation 116 | runs-on: ubuntu-latest 117 | steps: 118 | - name: Checkout repository 119 | uses: actions/checkout@v4 120 | 121 | - uses: Swatinem/rust-cache@v2 122 | 123 | - name: Build documentation and check intra-doc links 124 | env: 125 | RUSTDOCFLAGS: --deny rustdoc::broken_intra_doc_links 126 | run: cargo doc --all-features --no-deps 127 | 128 | fuzz: 129 | name: Fuzz test 130 | runs-on: ubuntu-latest 131 | strategy: 132 | fail-fast: false 133 | matrix: 134 | fuzz-target: 135 | - fill_first_fit 136 | - fill_optimal_fit 137 | - fill_fast_path 138 | - wrap_first_fit 139 | - wrap_optimal_fit 140 | - wrap_optimal_fit_usize 141 | - wrap_fast_path 142 | - unfill 143 | - refill 144 | 145 | steps: 146 | - name: Checkout repository 147 | uses: actions/checkout@v4 148 | 149 | - name: Install nightly Rust 150 | run: rustup default nightly 151 | 152 | - uses: Swatinem/rust-cache@v2 153 | with: 154 | workspaces: fuzz -> target 155 | 156 | - name: Install cargo-fuzz 157 | run: cargo install cargo-fuzz 158 | 159 | - name: Cache fuzz corpus 160 | uses: actions/cache@v4 161 | with: 162 | path: fuzz/corpus/${{ matrix.fuzz-target }} 163 | key: fuzz-corpus-${{ matrix.fuzz-target }}-${{ github.run_id }} 164 | restore-keys: | 165 | fuzz-corpus-${{ matrix.fuzz-target }} 166 | 167 | - name: Run fuzz test 168 | run: cargo fuzz run ${{ matrix.fuzz-target }} -- -max_total_time=30 169 | 170 | - name: Minimize fuzz corpus 171 | run: cargo fuzz cmin ${{ matrix.fuzz-target }} 172 | 173 | binary-sizes: 174 | name: Compute binary sizes 175 | runs-on: ubuntu-latest 176 | steps: 177 | - name: Checkout repository 178 | uses: actions/checkout@v4 179 | 180 | - uses: Swatinem/rust-cache@v2 181 | with: 182 | workspaces: examples/binary-sizes -> target 183 | 184 | - name: Make binary size table 185 | run: cargo run --example make-table 186 | working-directory: examples/binary-sizes 187 | 188 | wasm-build: 189 | name: Build Wasm demo 190 | runs-on: ubuntu-latest 191 | steps: 192 | - name: Checkout repository 193 | uses: actions/checkout@v4 194 | 195 | - name: Install wasm-pack 196 | run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh 197 | 198 | - uses: Swatinem/rust-cache@v2 199 | with: 200 | workspaces: examples/wasm -> target 201 | 202 | - name: Build textwrap-wasm-demo 203 | run: wasm-pack build 204 | working-directory: examples/wasm 205 | 206 | - name: Install textwrap-wasm-demo-app dependencies 207 | run: npm install 208 | working-directory: examples/wasm/www 209 | 210 | - name: Bundle textwrap-wasm-demo-app 211 | run: npm run build 212 | working-directory: examples/wasm/www 213 | 214 | - name: Upload bundled textwrap-wasm-demo-app 215 | uses: actions/upload-artifact@v4 216 | with: 217 | name: textwrap-wasm-demo-app 218 | path: examples/wasm/www/dist 219 | 220 | wasm-deploy: 221 | name: Deploy Wasm demo 222 | needs: wasm-build 223 | if: github.ref == 'refs/heads/master' 224 | runs-on: ubuntu-latest 225 | steps: 226 | - name: Check out repository 227 | uses: actions/checkout@v4 228 | with: 229 | ref: gh-pages 230 | 231 | - name: Cleanup previous deployment 232 | run: rm * 233 | 234 | - name: Download bundled textwrap-wasm-demo-app 235 | uses: actions/download-artifact@v4 236 | with: 237 | name: textwrap-wasm-demo-app 238 | 239 | - name: Add and remove changed files 240 | id: git-add 241 | run: | 242 | git restore build-info.json 243 | git add --verbose --all 244 | if git diff --staged --quiet --exit-code; then 245 | echo "No changes found in textwrap-wasm-demo-app" 246 | echo 'has-changes=false' >> $GITHUB_OUTPUT 247 | else 248 | echo 'has-changes=true' >> $GITHUB_OUTPUT 249 | fi 250 | 251 | - name: Record build info 252 | if: steps.git-add.outputs.has-changes == 'true' 253 | run: | 254 | cat > build-info.json <> $GITHUB_OUTPUT 29 | echo "old-version=$OLD_VERSION" >> $GITHUB_OUTPUT 30 | echo "new-version=$NEW_VERSION" >> $GITHUB_OUTPUT 31 | 32 | - name: Verify version format 33 | run: | 34 | echo '${{ steps.vars.outputs.new-version }}' | grep -q '^[0-9]\+\.[0-9]\+\.[0-9]\+$' 35 | 36 | pull-request: 37 | needs: setup 38 | if: needs.setup.outputs.old-version != needs.setup.outputs.new-version 39 | runs-on: ubuntu-latest 40 | steps: 41 | - name: Check out repository 42 | uses: actions/checkout@v4 43 | 44 | - name: Configure Git user 45 | run: | 46 | git config user.name "Martin Geisler" 47 | git config user.email "martin@geisler.net" 48 | 49 | - name: Install Graphviz 50 | uses: ts-graphviz/setup-graphviz@v2 51 | 52 | # We use debug builds since they compile a little faster. 53 | - name: Install cargo-depgraph 54 | run: | 55 | cargo install --debug cargo-depgraph 56 | 57 | - name: Install svgcleaner 58 | run: | 59 | cargo install --debug svgcleaner 60 | 61 | - name: Generate dependency graph 62 | run: | 63 | cargo depgraph \ 64 | | dot -Tsvg -Nfontname=monospace \ 65 | | sed 's/stroke="transparent"/stroke="none"/' \ 66 | | svgcleaner --indent 0 --stdout - \ 67 | > images/textwrap-${{ needs.setup.outputs.new-version }}.svg 68 | 69 | - name: Update dependency graph 70 | run: | 71 | import fileinput, re, sys 72 | 73 | NAME = '${{ needs.setup.outputs.name }}' 74 | NEW_VERSION = '${{ needs.setup.outputs.new-version }}' 75 | 76 | for line in fileinput.input(inplace=True): 77 | sys.stdout.write( 78 | re.sub(f'/images/{NAME}-.+\\.svg', 79 | f'/images/{NAME}-{NEW_VERSION}.svg', line)) 80 | shell: python3 {0} src/lib.rs 81 | 82 | - name: Commit dependency graph 83 | run: | 84 | git add images/textwrap-${{ needs.setup.outputs.new-version }}.svg src/lib.rs 85 | git commit -m "Add dependency graph for version ${{ needs.setup.outputs.new-version }}" 86 | 87 | - name: Update changelog for version ${{ needs.setup.outputs.new-version }} 88 | id: changelog 89 | uses: actions/github-script@v7 90 | with: 91 | script: | 92 | var fs = require('fs') 93 | const old_version = '${{ needs.setup.outputs.old-version }}' 94 | const new_version = '${{ needs.setup.outputs.new-version }}' 95 | 96 | let cutoff = '1970-01-01' 97 | const releases = await github.rest.repos.listReleases(context.repo) 98 | for (const release of releases.data) { 99 | if (release.tag_name == old_version) { 100 | cutoff = release.published_at 101 | break 102 | } 103 | } 104 | core.info(`Finding merged PRs after ${cutoff}`) 105 | 106 | let q = [`repo:${context.repo.owner}/${context.repo.repo}`, 107 | 'is:pr', 'is:merged', `merged:>${cutoff}`] 108 | const prs = await github.paginate(github.rest.search.issuesAndPullRequests, { 109 | q: q.join(' '), 110 | sort: 'created', 111 | order: 'asc', 112 | }) 113 | core.info(`Found ${prs.length} merged PRs`) 114 | 115 | const changelog = prs.map( 116 | pr => `* [#${pr.number}](${pr.html_url}): ${pr.title}` 117 | ).join('\n') 118 | core.exportVariable('CHANGELOG', changelog) 119 | 120 | var content = fs.readFileSync('CHANGELOG.md', 'utf8') 121 | const today = new Date().toISOString().split('T')[0] 122 | const heading = `## Version ${new_version} (${today})\n` 123 | if (content.match('## Unreleased')) { 124 | content = content.replace('## Unreleased', `${heading}\n${changelog}`) 125 | } else { 126 | content = content.replace('## Version', `${heading}\n${changelog}\n\n## Version`) 127 | } 128 | fs.writeFileSync('CHANGELOG.md', content) 129 | 130 | - name: Commit changelog 131 | run: | 132 | git commit --all -m "Update changelog for version ${{ needs.setup.outputs.new-version }}" 133 | 134 | - name: Update TOML code blocks 135 | run: | 136 | import fileinput, re, sys 137 | 138 | NAME = '${{ needs.setup.outputs.name }}' 139 | NEW_VERSION = '${{ needs.setup.outputs.new-version }}' 140 | MAJOR_MINOR = '.'.join(NEW_VERSION.split('.')[:2]) 141 | 142 | for line in fileinput.input(inplace=True): 143 | line = re.sub(f'{NAME} = "[^"]+"', 144 | f'{NAME} = "{MAJOR_MINOR}"', line) 145 | line = re.sub(f'{NAME} = {{ version = "[^"]+"', 146 | f'{NAME} = {{ version = "{MAJOR_MINOR}"', line) 147 | sys.stdout.write(line) 148 | shell: python3 {0} README.md 149 | 150 | - name: Update html_root_url 151 | run: | 152 | import fileinput, re, sys 153 | 154 | NAME = '${{ needs.setup.outputs.name }}' 155 | NEW_VERSION = '${{ needs.setup.outputs.new-version }}' 156 | 157 | for line in fileinput.input(inplace=True): 158 | sys.stdout.write( 159 | re.sub(f'html_root_url = "https://docs.rs/{NAME}/[^"]+"', 160 | f'html_root_url = "https://docs.rs/{NAME}/{NEW_VERSION}"', line)) 161 | shell: python3 {0} src/lib.rs 162 | 163 | - name: Update crate version to ${{ needs.setup.outputs.new-version }} 164 | uses: thomaseizinger/set-crate-version@1.0.1 165 | with: 166 | version: ${{ needs.setup.outputs.new-version }} 167 | 168 | - name: Check semver compatibility 169 | uses: obi1kenobi/cargo-semver-checks-action@v2 170 | 171 | - name: Build and test 172 | run: | 173 | cargo test 174 | 175 | - name: Commit version bump 176 | run: | 177 | git commit --all -m "Bump version to ${{ needs.setup.outputs.new-version }}" 178 | 179 | - name: Push version bump 180 | run: git push origin 181 | 182 | - name: Create pull request 183 | uses: actions/github-script@v7 184 | with: 185 | script: | 186 | const pr = await github.rest.pulls.create({ 187 | owner: context.repo.owner, 188 | repo: context.repo.repo, 189 | head: 'release-${{ needs.setup.outputs.new-version }}', 190 | base: 'master', 191 | title: 'Release ${{ needs.setup.outputs.new-version }}', 192 | body: process.env.CHANGELOG, 193 | }) 194 | core.info(`Created PR: ${pr.data.html_url}`) 195 | -------------------------------------------------------------------------------- /.github/workflows/publish-crate.yml: -------------------------------------------------------------------------------- 1 | name: Publish Crate 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths: 8 | - Cargo.toml 9 | repository_dispatch: 10 | types: publish 11 | 12 | jobs: 13 | publish: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Check out repository 17 | uses: actions/checkout@v4 18 | 19 | - name: Set variables 20 | id: vars 21 | run: | 22 | NAME=$(cargo metadata -q --no-deps | jq -r '.packages[0].name') 23 | VERSION=$(cargo metadata -q --no-deps | jq -r '.packages[0].version') 24 | CHANGELOG=$(awk '/^## Version/ {i++}; i==1 {print}; i>1 {exit}' CHANGELOG.md \ 25 | | python3 -c 'import sys, json; print(json.dumps(sys.stdin.read()))') 26 | echo "name=$NAME" >> $GITHUB_OUTPUT 27 | echo "version=$VERSION" >> $GITHUB_OUTPUT 28 | echo "changelog=$CHANGELOG" >> $GITHUB_OUTPUT 29 | echo "Found $NAME-$VERSION" 30 | 31 | - name: Lookup ${{ steps.vars.outputs.version }} tag 32 | id: need-release 33 | uses: actions/github-script@v7 34 | with: 35 | script: | 36 | const version = '${{ steps.vars.outputs.version }}' 37 | const tags = await github.rest.repos.listTags(context.repo) 38 | if (tags.data.some(tag => tag.name == version)) { 39 | core.info(`Found ${version} tag -- will skip publish step`) 40 | return false 41 | } 42 | core.info(`Found no ${version} tag -- will proceed with publishing`) 43 | return true 44 | 45 | # The result from above is JSON-encoded, meaning that we 46 | # end up with the string 'true', not the Boolean true. 47 | - if: steps.need-release.outputs.result == 'true' 48 | name: Publish crate to crates.io 49 | run: | 50 | echo "Publishing ${{ steps.vars.outputs.name }}-${{ steps.vars.outputs.version }}" 51 | cargo publish --token ${{ secrets.CARGO_TOKEN }} 52 | 53 | - if: steps.need-release.outputs.result == 'true' 54 | name: Create GitHub release 55 | uses: actions/create-release@v1 56 | env: 57 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 58 | with: 59 | tag_name: ${{ steps.vars.outputs.version }} 60 | release_name: ${{ steps.vars.outputs.name }}-${{ steps.vars.outputs.version }} 61 | body: ${{ fromJson(steps.vars.outputs.changelog) }} 62 | draft: false 63 | prerelease: false 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | 3 | *~ 4 | *.rej 5 | *.orig 6 | 7 | examples/wasm/pkg/ 8 | examples/wasm/www/dist/ 9 | examples/wasm/www/node_modules/ 10 | 11 | fuzz/artifacts/ 12 | fuzz/corpus/ 13 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "textwrap" 3 | version = "0.16.2" 4 | authors = ["Martin Geisler "] 5 | categories = ["text-processing", "command-line-interface"] 6 | documentation = "https://docs.rs/textwrap/" 7 | edition = "2021" 8 | exclude = [".github/", ".gitignore", "benchmarks/", "examples/", "fuzz/", "images/"] 9 | keywords = ["text", "formatting", "wrap", "typesetting", "hyphenation"] 10 | license = "MIT" 11 | readme = "README.md" 12 | repository = "https://github.com/mgeisler/textwrap" 13 | rust-version = "1.70" 14 | description = "Library for word wrapping, indenting, and dedenting strings. Has optional support for Unicode and emojis as well as machine hyphenation." 15 | 16 | [[example]] 17 | name = "hyphenation" 18 | path = "examples/hyphenation.rs" 19 | required-features = ["hyphenation"] 20 | 21 | [[example]] 22 | name = "termwidth" 23 | path = "examples/termwidth.rs" 24 | required-features = ["terminal_size"] 25 | 26 | [package.metadata.docs.rs] 27 | all-features = true 28 | 29 | [features] 30 | default = ["unicode-linebreak", "unicode-width", "smawk"] 31 | 32 | [lints.rust] 33 | unexpected_cfgs = { level = "warn", check-cfg = ["cfg(fuzzing)"] } 34 | 35 | [dependencies] 36 | hyphenation = { version = "0.8.4", optional = true, features = ["embed_en-us"] } 37 | smawk = { version = "0.3.2", optional = true } 38 | terminal_size = { version = "0.4.0", optional = true } 39 | unicode-linebreak = { version = "0.1.5", optional = true } 40 | unicode-width = { version = "0.2.0", optional = true } 41 | 42 | [dev-dependencies] 43 | unic-emoji-char = "0.9.0" 44 | version-sync = "0.9.5" 45 | 46 | [target."cfg(unix)".dev-dependencies] 47 | termion = "4.0.2" 48 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Martin Geisler 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Textwrap 2 | 3 | [![](https://github.com/mgeisler/textwrap/workflows/build/badge.svg)][build-status] 4 | [![](https://codecov.io/gh/mgeisler/textwrap/branch/master/graph/badge.svg)][codecov] 5 | [![](https://img.shields.io/crates/v/textwrap.svg)][crates-io] 6 | [![](https://docs.rs/textwrap/badge.svg)][api-docs] 7 | 8 | Textwrap is a library for wrapping and indenting text. It is most often used by 9 | command-line programs to format dynamic output nicely so it looks good in a 10 | terminal. You can also use Textwrap to wrap text set in a proportional font—such 11 | as text used to generate PDF files, or drawn on a 12 | [HTML5 canvas using WebAssembly][wasm-demo]. 13 | 14 | ## Usage 15 | 16 | To use the textwrap crate, add this to your `Cargo.toml` file: 17 | 18 | ```toml 19 | [dependencies] 20 | textwrap = "0.16" 21 | ``` 22 | 23 | By default, this enables word wrapping with support for Unicode strings. Extra 24 | features can be enabled with Cargo features—and the Unicode support can be 25 | disabled if needed. This allows you slim down the library and so you will only 26 | pay for the features you actually use. 27 | 28 | Please see the 29 | [_Cargo Features_ in the crate 30 | documentation](https://docs.rs/textwrap/#cargo-features) for a full list of the 31 | available features as well as their impact on the size of your binary. 32 | 33 | ## Documentation 34 | 35 | **[API documentation][api-docs]** 36 | 37 | ## Getting Started 38 | 39 | Word wrapping is easy using the `wrap` and `fill` functions: 40 | 41 | ```rust 42 | #[cfg(feature = "smawk")] { 43 | let text = "textwrap: an efficient and powerful library for wrapping text."; 44 | assert_eq!( 45 | textwrap::wrap(text, 28), 46 | vec![ 47 | "textwrap: an efficient", 48 | "and powerful library for", 49 | "wrapping text.", 50 | ] 51 | ); 52 | } 53 | ``` 54 | 55 | Sharp-eyed readers will notice that the first line is 22 columns wide. So why is 56 | the word “and” put in the second line when there is space for it in the first 57 | line? 58 | 59 | The explanation is that textwrap does not just wrap text one line at a time. 60 | Instead, it uses an optimal-fit algorithm which looks ahead and chooses line 61 | breaks which minimize the gaps left at ends of lines. This is controlled with 62 | the `smawk` Cargo feature, which is why the example is wrapped in the 63 | `cfg`-block. 64 | 65 | Without look-ahead, the first line would be longer and the text would look like 66 | this: 67 | 68 | ```rust 69 | #[cfg(not(feature = "smawk"))] { 70 | let text = "textwrap: an efficient and powerful library for wrapping text."; 71 | assert_eq!( 72 | textwrap::wrap(text, 28), 73 | vec![ 74 | "textwrap: an efficient and", 75 | "powerful library for", 76 | "wrapping text.", 77 | ] 78 | ); 79 | } 80 | ``` 81 | 82 | The second line is now shorter and the text is more ragged. The kind of wrapping 83 | can be configured via `Options::wrap_algorithm`. 84 | 85 | If you enable the `hyphenation` Cargo feature, you get support for automatic 86 | hyphenation for [about 70 languages][patterns] via high-quality TeX hyphenation 87 | patterns. 88 | 89 | Your program must load the hyphenation pattern and configure 90 | `Options::word_splitter` to use it: 91 | 92 | ```rust 93 | #[cfg(feature = "hyphenation")] { 94 | use hyphenation::{Language, Load, Standard}; 95 | use textwrap::{fill, Options, WordSplitter}; 96 | 97 | let dictionary = Standard::from_embedded(Language::EnglishUS).unwrap(); 98 | let options = textwrap::Options::new(28).word_splitter(WordSplitter::Hyphenation(dictionary)); 99 | let text = "textwrap: an efficient and powerful library for wrapping text."; 100 | 101 | assert_eq!( 102 | textwrap::wrap(text, &options), 103 | vec![ 104 | "textwrap: an efficient and", 105 | "powerful library for wrap-", 106 | "ping text." 107 | ] 108 | ); 109 | } 110 | ``` 111 | 112 | The US-English hyphenation patterns are embedded when you enable the 113 | `hyphenation` feature. They are licensed under a 114 | [permissive license][en-us license] and take up about 88 KB in your binary. If 115 | you need hyphenation for other languages, you need to download a 116 | [precompiled `.bincode` file][bincode] and load it yourself. Please see the 117 | [`hyphenation` documentation] for details. 118 | 119 | ## Wrapping Strings at Compile Time 120 | 121 | If your strings are known at compile time, please take a look at the procedural 122 | macros from the [`textwrap-macros` crate]. 123 | 124 | ## Examples 125 | 126 | The library comes with 127 | [a collection](https://github.com/mgeisler/textwrap/tree/master/examples) of 128 | small example programs that shows various features. 129 | 130 | If you want to see Textwrap in action right away, then take a look at 131 | [`examples/wasm/`], which shows how to wrap sans-serif, serif, and monospace 132 | text. It uses WebAssembly and is automatically deployed to 133 | https://mgeisler.github.io/textwrap/. 134 | 135 | For the command-line examples, you’re invited to clone the repository and try 136 | them out for yourself! Of special note is [`examples/interactive.rs`]. This is a 137 | demo program which demonstrates most of the available features: you can enter 138 | text and adjust the width at which it is wrapped interactively. You can also 139 | adjust the `Options` used to see the effect of different `WordSplitter`s and 140 | wrap algorithms. 141 | 142 | Run the demo with 143 | 144 | ```sh 145 | $ cargo run --example interactive 146 | ``` 147 | 148 | The demo needs a Linux terminal to function. 149 | 150 | ## Release History 151 | 152 | Please see the [CHANGELOG file] for details on the changes made in each release. 153 | 154 | ## License 155 | 156 | Textwrap can be distributed according to the [MIT license][mit]. Contributions 157 | will be accepted under the same license. 158 | 159 | [crates-io]: https://crates.io/crates/textwrap 160 | [build-status]: https://github.com/mgeisler/textwrap/actions?query=workflow%3Abuild+branch%3Amaster 161 | [codecov]: https://codecov.io/gh/mgeisler/textwrap 162 | [wasm-demo]: https://mgeisler.github.io/textwrap/ 163 | [`textwrap-macros` crate]: https://crates.io/crates/textwrap-macros 164 | [`hyphenation` example]: https://github.com/mgeisler/textwrap/blob/master/examples/hyphenation.rs 165 | [`termwidth` example]: https://github.com/mgeisler/textwrap/blob/master/examples/termwidth.rs 166 | [patterns]: https://github.com/tapeinosyne/hyphenation/tree/master/patterns 167 | [en-us license]: https://github.com/hyphenation/tex-hyphen/blob/master/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-en-us.tex 168 | [bincode]: https://github.com/tapeinosyne/hyphenation/tree/master/dictionaries 169 | [`hyphenation` documentation]: http://docs.rs/hyphenation 170 | [`examples/wasm/`]: https://github.com/mgeisler/textwrap/tree/master/examples/wasm 171 | [`examples/interactive.rs`]: https://github.com/mgeisler/textwrap/tree/master/examples/interactive.rs 172 | [api-docs]: https://docs.rs/textwrap/ 173 | [CHANGELOG file]: https://github.com/mgeisler/textwrap/blob/master/CHANGELOG.md 174 | [mit]: LICENSE 175 | -------------------------------------------------------------------------------- /benchmarks/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "textwrap-benchmarks" 3 | version = "0.1.0" 4 | authors = ["Martin Geisler "] 5 | edition = "2021" 6 | license-file = "../LICENSE" 7 | publish = false # This should not be uploaded to crates.io 8 | repository = "https://github.com/mgeisler/textwrap" 9 | description = "Textwrap benchmarks" 10 | 11 | [[bench]] 12 | name = "linear" 13 | harness = false 14 | path = "linear.rs" 15 | 16 | [[bench]] 17 | name = "indent" 18 | harness = false 19 | path = "indent.rs" 20 | 21 | [[bench]] 22 | name = "unfill" 23 | harness = false 24 | path = "unfill.rs" 25 | 26 | [dependencies] 27 | textwrap = { path = "../", features = ["hyphenation"] } 28 | 29 | [dev-dependencies] 30 | criterion = "0.4.0" 31 | hyphenation = { version = "0.8.4", features = ["embed_en-us"] } 32 | lipsum = "0.8.0" 33 | -------------------------------------------------------------------------------- /benchmarks/indent.rs: -------------------------------------------------------------------------------- 1 | use criterion::{criterion_group, criterion_main, Criterion}; 2 | 3 | pub fn benchmark(c: &mut Criterion) { 4 | // Generate a piece of text with some empty lines. 5 | let words_per_line = [ 6 | 5, 10, 15, 5, 0, 10, 5, 0, 5, 10, // 10 lines 7 | 10, 10, 5, 5, 5, 5, 15, 10, 5, 0, // 20 lines 8 | 10, 5, 0, 0, 15, 10, 10, 5, 5, 5, // 30 lines 9 | 15, 5, 0, 10, 5, 0, 0, 15, 5, 10, // 40 lines 10 | 5, 15, 0, 5, 15, 0, 10, 10, 5, 5, // 50 lines 11 | ]; 12 | let mut text = String::new(); 13 | for (line_no, word_count) in words_per_line.iter().enumerate() { 14 | text.push_str(&lipsum::lipsum_words_from_seed(*word_count, line_no as u64)); 15 | text.push('\n'); 16 | } 17 | assert_eq!(text.len(), 2304); // The size for reference. 18 | 19 | c.bench_function("indent", |b| b.iter(|| textwrap::indent(&text, " "))); 20 | } 21 | 22 | criterion_group!(benches, benchmark); 23 | criterion_main!(benches); 24 | -------------------------------------------------------------------------------- /benchmarks/la.standard.bincode: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mgeisler/textwrap/77a634f72506cc8ec26b499949a500fd6a9d5cdc/benchmarks/la.standard.bincode -------------------------------------------------------------------------------- /benchmarks/linear.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion}; 4 | 5 | // The benchmarks here verify that the complexity grows as O(*n*) 6 | // where *n* is the number of characters in the text to be wrapped. 7 | 8 | use lipsum::lipsum_words_from_seed; 9 | 10 | const LINE_LENGTH: usize = 60; 11 | 12 | /// Generate a lorem ipsum text with the given number of characters. 13 | fn lorem_ipsum(length: usize) -> String { 14 | // The average word length in the lorem ipsum text is somewhere 15 | // between 6 and 7. So we conservatively divide by 5 to have a 16 | // long enough text that we can truncate below. 17 | let mut text = lipsum_words_from_seed(length / 5, 42); 18 | text.truncate(length); 19 | text 20 | } 21 | 22 | pub fn benchmark(c: &mut Criterion) { 23 | let mut group = c.benchmark_group("fill"); 24 | let lengths = [ 25 | 0, 5, 10, 20, 30, 40, 50, 60, 80, 100, 200, 300, 400, 600, 800, 1200, 1600, 2400, 3200, 26 | 4800, 6400, 27 | ]; 28 | let wrap_algorithms = [ 29 | (textwrap::WrapAlgorithm::new_optimal_fit(), "optimal_fit"), 30 | (textwrap::WrapAlgorithm::FirstFit, "first_fit"), 31 | ]; 32 | let word_separators = [ 33 | (textwrap::WordSeparator::UnicodeBreakProperties, "unicode"), 34 | (textwrap::WordSeparator::AsciiSpace, "ascii"), 35 | ]; 36 | 37 | for length in lengths { 38 | let text = lorem_ipsum(length); 39 | let length_id = format!("{length:04}"); 40 | 41 | for (algorithm, algorithm_name) in &wrap_algorithms { 42 | for (separator, separator_name) in &word_separators { 43 | let name = format!("{algorithm_name}_{separator_name}"); 44 | let options = textwrap::Options::new(LINE_LENGTH) 45 | .wrap_algorithm(*algorithm) 46 | .word_separator(*separator); 47 | group.bench_with_input(BenchmarkId::new(&name, &length_id), &text, |b, text| { 48 | b.iter(|| textwrap::fill(text, &options)); 49 | }); 50 | } 51 | } 52 | 53 | group.bench_function(BenchmarkId::new("inplace", &length_id), |b| { 54 | b.iter_batched( 55 | || text.clone(), 56 | |mut text| textwrap::fill_inplace(&mut text, LINE_LENGTH), 57 | criterion::BatchSize::SmallInput, 58 | ); 59 | }); 60 | 61 | use hyphenation::{Language, Load, Standard}; 62 | let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("la.standard.bincode"); 63 | let dictionary = Standard::from_path(Language::Latin, &path).unwrap(); 64 | let options = textwrap::Options::new(LINE_LENGTH) 65 | .wrap_algorithm(textwrap::WrapAlgorithm::new_optimal_fit()) 66 | .word_separator(textwrap::WordSeparator::AsciiSpace) 67 | .word_splitter(textwrap::WordSplitter::Hyphenation(dictionary)); 68 | group.bench_with_input( 69 | BenchmarkId::new("optimal_fit_ascii_hyphenation", &length_id), 70 | &text, 71 | |b, text| { 72 | b.iter(|| textwrap::fill(text, &options)); 73 | }, 74 | ); 75 | } 76 | group.finish(); 77 | } 78 | 79 | criterion_group!( 80 | name = benches; 81 | config = Criterion::default().warm_up_time(Duration::from_millis(500)); 82 | targets = benchmark 83 | ); 84 | criterion_main!(benches); 85 | -------------------------------------------------------------------------------- /benchmarks/unfill.rs: -------------------------------------------------------------------------------- 1 | use criterion::{criterion_group, criterion_main, Criterion}; 2 | 3 | pub fn benchmark(c: &mut Criterion) { 4 | let words_per_line = [ 5 | 5, 10, 15, 5, 5, 10, 5, 5, 5, 10, // 10 lines 6 | 10, 10, 5, 5, 5, 5, 15, 10, 5, 5, // 20 lines 7 | 10, 5, 5, 5, 15, 10, 10, 5, 5, 5, // 30 lines 8 | 15, 5, 5, 10, 5, 5, 5, 15, 5, 10, // 40 lines 9 | 5, 15, 5, 5, 15, 5, 10, 10, 5, 5, // 50 lines 10 | ]; 11 | let mut text = String::new(); 12 | for (line_no, word_count) in words_per_line.iter().enumerate() { 13 | text.push_str(&lipsum::lipsum_words_from_seed(*word_count, line_no as u64)); 14 | text.push('\n'); 15 | } 16 | text.push_str("\n\n\n\n"); 17 | assert_eq!(text.len(), 2650); // The size for reference. 18 | 19 | c.bench_function("unfill", |b| b.iter(|| textwrap::unfill(&text))); 20 | } 21 | 22 | criterion_group!(benches, benchmark); 23 | criterion_main!(benches); 24 | -------------------------------------------------------------------------------- /dprint.json: -------------------------------------------------------------------------------- 1 | { 2 | "markdown": { 3 | "textWrap": "always" 4 | }, 5 | "exec": { 6 | "commands": [{ 7 | "command": "rustfmt", 8 | "exts": ["rs"] 9 | }] 10 | }, 11 | "excludes": ["target/", "package-lock.json"], 12 | "plugins": [ 13 | "https://plugins.dprint.dev/json-0.20.0.wasm", 14 | "https://plugins.dprint.dev/markdown-0.18.0.wasm", 15 | "https://plugins.dprint.dev/toml-0.7.0.wasm", 16 | "https://plugins.dprint.dev/exec-0.5.1.json@492414e39dea4dccc07b4af796d2f4efdb89e84bae2bd4e1e924c0cc050855bf", 17 | "https://plugins.dprint.dev/prettier-0.57.0.json@1bc6b449e982d5b91a25a7c59894102d40c5748651a08a095fb3926e64d55a31" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /examples/binary-sizes/Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "ahash" 7 | version = "0.7.6" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "fcb51a0695d8f838b1ee009b3fbf66bda078cd64590202a864a8f3e8c4315c47" 10 | dependencies = [ 11 | "getrandom", 12 | "once_cell", 13 | "version_check", 14 | ] 15 | 16 | [[package]] 17 | name = "aho-corasick" 18 | version = "0.7.19" 19 | source = "registry+https://github.com/rust-lang/crates.io-index" 20 | checksum = "b4f55bd91a0978cbfd91c457a164bab8b4001c833b7f323132c0a4e1922dd44e" 21 | dependencies = [ 22 | "memchr", 23 | ] 24 | 25 | [[package]] 26 | name = "bincode" 27 | version = "1.3.3" 28 | source = "registry+https://github.com/rust-lang/crates.io-index" 29 | checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" 30 | dependencies = [ 31 | "serde", 32 | ] 33 | 34 | [[package]] 35 | name = "cfg-if" 36 | version = "1.0.0" 37 | source = "registry+https://github.com/rust-lang/crates.io-index" 38 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 39 | 40 | [[package]] 41 | name = "fst" 42 | version = "0.4.7" 43 | source = "registry+https://github.com/rust-lang/crates.io-index" 44 | checksum = "7ab85b9b05e3978cc9a9cf8fea7f01b494e1a09ed3037e16ba39edc7a29eb61a" 45 | 46 | [[package]] 47 | name = "getrandom" 48 | version = "0.2.7" 49 | source = "registry+https://github.com/rust-lang/crates.io-index" 50 | checksum = "4eb1a864a501629691edf6c15a593b7a51eebaa1e8468e9ddc623de7c9b58ec6" 51 | dependencies = [ 52 | "cfg-if", 53 | "libc", 54 | "wasi", 55 | ] 56 | 57 | [[package]] 58 | name = "hashbrown" 59 | version = "0.12.3" 60 | source = "registry+https://github.com/rust-lang/crates.io-index" 61 | checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" 62 | dependencies = [ 63 | "ahash", 64 | ] 65 | 66 | [[package]] 67 | name = "hyphenation" 68 | version = "0.8.4" 69 | source = "registry+https://github.com/rust-lang/crates.io-index" 70 | checksum = "bcf4dd4c44ae85155502a52c48739c8a48185d1449fff1963cffee63c28a50f0" 71 | dependencies = [ 72 | "bincode", 73 | "fst", 74 | "hyphenation_commons", 75 | "pocket-resources", 76 | "serde", 77 | ] 78 | 79 | [[package]] 80 | name = "hyphenation_commons" 81 | version = "0.8.4" 82 | source = "registry+https://github.com/rust-lang/crates.io-index" 83 | checksum = "5febe7a2ade5c7d98eb8b75f946c046b335324b06a14ea0998271504134c05bf" 84 | dependencies = [ 85 | "fst", 86 | "serde", 87 | ] 88 | 89 | [[package]] 90 | name = "libc" 91 | version = "0.2.134" 92 | source = "registry+https://github.com/rust-lang/crates.io-index" 93 | checksum = "329c933548736bc49fd575ee68c89e8be4d260064184389a5b77517cddd99ffb" 94 | 95 | [[package]] 96 | name = "memchr" 97 | version = "2.5.0" 98 | source = "registry+https://github.com/rust-lang/crates.io-index" 99 | checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" 100 | 101 | [[package]] 102 | name = "once_cell" 103 | version = "1.15.0" 104 | source = "registry+https://github.com/rust-lang/crates.io-index" 105 | checksum = "e82dad04139b71a90c080c8463fe0dc7902db5192d939bd0950f074d014339e1" 106 | 107 | [[package]] 108 | name = "pocket-resources" 109 | version = "0.3.2" 110 | source = "registry+https://github.com/rust-lang/crates.io-index" 111 | checksum = "c135f38778ad324d9e9ee68690bac2c1a51f340fdf96ca13e2ab3914eb2e51d8" 112 | 113 | [[package]] 114 | name = "proc-macro2" 115 | version = "1.0.46" 116 | source = "registry+https://github.com/rust-lang/crates.io-index" 117 | checksum = "94e2ef8dbfc347b10c094890f778ee2e36ca9bb4262e86dc99cd217e35f3470b" 118 | dependencies = [ 119 | "unicode-ident", 120 | ] 121 | 122 | [[package]] 123 | name = "quote" 124 | version = "1.0.21" 125 | source = "registry+https://github.com/rust-lang/crates.io-index" 126 | checksum = "bbe448f377a7d6961e30f5955f9b8d106c3f5e449d493ee1b125c1d43c2b5179" 127 | dependencies = [ 128 | "proc-macro2", 129 | ] 130 | 131 | [[package]] 132 | name = "regex" 133 | version = "1.6.0" 134 | source = "registry+https://github.com/rust-lang/crates.io-index" 135 | checksum = "4c4eb3267174b8c6c2f654116623910a0fef09c4753f8dd83db29c48a0df988b" 136 | dependencies = [ 137 | "aho-corasick", 138 | "memchr", 139 | "regex-syntax", 140 | ] 141 | 142 | [[package]] 143 | name = "regex-syntax" 144 | version = "0.6.27" 145 | source = "registry+https://github.com/rust-lang/crates.io-index" 146 | checksum = "a3f87b73ce11b1619a3c6332f45341e0047173771e8b8b73f87bfeefb7b56244" 147 | 148 | [[package]] 149 | name = "serde" 150 | version = "1.0.145" 151 | source = "registry+https://github.com/rust-lang/crates.io-index" 152 | checksum = "728eb6351430bccb993660dfffc5a72f91ccc1295abaa8ce19b27ebe4f75568b" 153 | dependencies = [ 154 | "serde_derive", 155 | ] 156 | 157 | [[package]] 158 | name = "serde_derive" 159 | version = "1.0.145" 160 | source = "registry+https://github.com/rust-lang/crates.io-index" 161 | checksum = "81fa1584d3d1bcacd84c277a0dfe21f5b0f6accf4a23d04d4c6d61f1af522b4c" 162 | dependencies = [ 163 | "proc-macro2", 164 | "quote", 165 | "syn", 166 | ] 167 | 168 | [[package]] 169 | name = "smawk" 170 | version = "0.3.1" 171 | source = "registry+https://github.com/rust-lang/crates.io-index" 172 | checksum = "f67ad224767faa3c7d8b6d91985b78e70a1324408abcb1cfcc2be4c06bc06043" 173 | 174 | [[package]] 175 | name = "syn" 176 | version = "1.0.101" 177 | source = "registry+https://github.com/rust-lang/crates.io-index" 178 | checksum = "e90cde112c4b9690b8cbe810cba9ddd8bc1d7472e2cae317b69e9438c1cba7d2" 179 | dependencies = [ 180 | "proc-macro2", 181 | "quote", 182 | "unicode-ident", 183 | ] 184 | 185 | [[package]] 186 | name = "textwrap" 187 | version = "0.16.0" 188 | dependencies = [ 189 | "hyphenation", 190 | "smawk", 191 | "unicode-linebreak", 192 | "unicode-width", 193 | ] 194 | 195 | [[package]] 196 | name = "textwrap-binary-sizes-demo" 197 | version = "0.1.0" 198 | dependencies = [ 199 | "textwrap", 200 | ] 201 | 202 | [[package]] 203 | name = "unicode-ident" 204 | version = "1.0.4" 205 | source = "registry+https://github.com/rust-lang/crates.io-index" 206 | checksum = "dcc811dc4066ac62f84f11307873c4850cb653bfa9b1719cee2bd2204a4bc5dd" 207 | 208 | [[package]] 209 | name = "unicode-linebreak" 210 | version = "0.1.4" 211 | source = "registry+https://github.com/rust-lang/crates.io-index" 212 | checksum = "c5faade31a542b8b35855fff6e8def199853b2da8da256da52f52f1316ee3137" 213 | dependencies = [ 214 | "hashbrown", 215 | "regex", 216 | ] 217 | 218 | [[package]] 219 | name = "unicode-width" 220 | version = "0.1.10" 221 | source = "registry+https://github.com/rust-lang/crates.io-index" 222 | checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b" 223 | 224 | [[package]] 225 | name = "version_check" 226 | version = "0.9.4" 227 | source = "registry+https://github.com/rust-lang/crates.io-index" 228 | checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" 229 | 230 | [[package]] 231 | name = "wasi" 232 | version = "0.11.0+wasi-snapshot-preview1" 233 | source = "registry+https://github.com/rust-lang/crates.io-index" 234 | checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" 235 | -------------------------------------------------------------------------------- /examples/binary-sizes/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "textwrap-binary-sizes-demo" 3 | version = "0.1.0" 4 | authors = ["Martin Geisler "] 5 | edition = "2021" 6 | license-file = "../../LICENSE" 7 | publish = false # This demo project should not be uploaded to crates.io 8 | repository = "https://github.com/mgeisler/textwrap" 9 | description = "Textwrap binary size demo" 10 | 11 | [profile.release] 12 | lto = true 13 | codegen-units = 1 14 | 15 | [features] 16 | smawk = ["textwrap/smawk"] 17 | unicode-linebreak = ["textwrap/unicode-linebreak"] 18 | unicode-width = ["textwrap/unicode-width"] 19 | hyphenation = ["textwrap/hyphenation"] 20 | 21 | [dependencies] 22 | textwrap = { path = "../../", default-features = false, optional = true } 23 | 24 | [dev-dependencies] 25 | textwrap = { path = "../../" } 26 | -------------------------------------------------------------------------------- /examples/binary-sizes/examples/make-table.rs: -------------------------------------------------------------------------------- 1 | //! Compile Textwrap with different features and record the resulting 2 | //! binary size. Produces a Markdown table. 3 | 4 | use std::fmt::Write; 5 | use std::ops::Range; 6 | use std::process::Command; 7 | use std::{fmt, fs, io}; 8 | 9 | fn compile(extra_args: &[&str]) -> io::Result { 10 | let status = Command::new("cargo") 11 | .arg("build") 12 | .arg("--quiet") 13 | .arg("--release") 14 | .args(extra_args) 15 | .status()?; 16 | if !status.success() { 17 | return Err(io::Error::new( 18 | io::ErrorKind::Other, 19 | format!("failed to compile: {}", status), 20 | )); 21 | } 22 | 23 | let path = "target/release/textwrap-binary-sizes-demo"; 24 | let status = Command::new("strip").arg(path).status()?; 25 | if !status.success() { 26 | return Err(io::Error::new( 27 | io::ErrorKind::Other, 28 | format!("failed to strip: {}", status), 29 | )); 30 | } 31 | 32 | let metadata = fs::metadata(path).map_err(|err| { 33 | io::Error::new( 34 | io::ErrorKind::Other, 35 | format!("failed to read metadata for {}: {}", path, err), 36 | ) 37 | })?; 38 | Ok(metadata.len()) 39 | } 40 | 41 | fn rustc_version() -> Result { 42 | let output = Command::new("rustc") 43 | .arg("--version") 44 | .output() 45 | .map_err(|err| PrettyError(format!("Could not determine rustc version: {err}")))?; 46 | let output = String::from_utf8(output.stdout) 47 | .map_err(|err| PrettyError(format!("Could convert output to UTF-8: {err}")))?; 48 | output 49 | .split_ascii_whitespace() 50 | .skip(1) 51 | .next() 52 | .map(|p| p.to_owned()) 53 | .ok_or(PrettyError(format!( 54 | "Could not find rustc version in {output:?}" 55 | ))) 56 | } 57 | 58 | fn make_table() -> Result { 59 | let mut table = String::new(); 60 | 61 | macro_rules! printcols { 62 | ($($value:expr),+) => { 63 | writeln!(&mut table, 64 | "| {:b$} | {:>c$} |", 65 | $($value),+, 66 | a = 40, b = 12, c = 8)?; 67 | }; 68 | } 69 | 70 | printcols!("Configuration", "Binary Size", "Delta"); 71 | printcols!(":---", "---:", "---:"); 72 | 73 | let features = [ 74 | ("textwrap", "textwrap without default features"), 75 | ("smawk", "textwrap with smawk"), 76 | ("unicode-width", "textwrap with unicode-width"), 77 | ("unicode-linebreak", "textwrap with unicode-linebreak"), 78 | ]; 79 | let base_size = compile(&[])?; 80 | printcols!("quick-and-dirty implementation", kb(base_size), "— KB"); 81 | 82 | for (feature, label) in features.iter() { 83 | let size = compile(&["--features", feature])?; 84 | let delta = size - base_size; 85 | printcols!(label, kb(size), kb(delta)); 86 | } 87 | 88 | Ok(table) 89 | } 90 | 91 | struct PrettyError(String); 92 | 93 | // Simply print the inner error with `Display` (not `Debug`) to get a 94 | // human-readable error message. 95 | impl fmt::Debug for PrettyError { 96 | fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { 97 | write!(fmt, "{}", self.0) 98 | } 99 | } 100 | 101 | impl From for PrettyError { 102 | fn from(error: io::Error) -> Self { 103 | Self(error.to_string()) 104 | } 105 | } 106 | 107 | impl From for PrettyError { 108 | fn from(error: fmt::Error) -> Self { 109 | Self(error.to_string()) 110 | } 111 | } 112 | 113 | fn kb(size: u64) -> String { 114 | format!("{} KB", size / 1000) 115 | } 116 | 117 | fn usage() { 118 | println!("usage: make-table [--update PATH]"); 119 | } 120 | 121 | fn find_marker(path: &str, marker: &str, content: &str) -> Result, PrettyError> { 122 | let start_marker = format!(""); 123 | let end_marker = format!(""); 124 | 125 | let start = content 126 | .find(&start_marker) 127 | .and_then(|idx| Some(idx + content[idx..].find('\n')?)) 128 | .map(|idx| idx + b"\n".len()) 129 | .ok_or(PrettyError(format!( 130 | "Could not find {start_marker:?} in {path}" 131 | )))?; 132 | let end = content 133 | .find(&end_marker) 134 | .and_then(|idx| content[..idx].rfind('\n')) 135 | .map(|idx| idx + b"\n".len()) 136 | .ok_or(PrettyError(format!( 137 | "Could not find {end_marker:?} in {path}" 138 | )))?; 139 | 140 | Ok(start..end) 141 | } 142 | 143 | fn main() -> Result<(), PrettyError> { 144 | let args = std::env::args().collect::>(); 145 | let args = args.iter().map(|a| a.as_str()).collect::>(); 146 | match args.as_slice() { 147 | &[_, "--update", path] => { 148 | println!("Updating {path}"); 149 | let mut content = std::fs::read_to_string(path)?; 150 | let range = find_marker(&path, "binary-sizes", &content)?; 151 | let intro = format!( 152 | "With Rust {}, the size impact of the above features \ 153 | on your binary is as follows:\n", 154 | rustc_version()? 155 | ); 156 | let intro = textwrap::fill(&intro, 70 - b"//! ".len()); 157 | let table = make_table()?; 158 | content.replace_range( 159 | range, 160 | &textwrap::indent(&format!("\n{intro}\n{table}\n"), "//! "), 161 | ); 162 | std::fs::write(path, content)?; 163 | } 164 | &[_] => println!("{}", make_table()?), 165 | _ => usage(), 166 | } 167 | Ok(()) 168 | } 169 | -------------------------------------------------------------------------------- /examples/binary-sizes/src/main.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "textwrap")] 2 | use textwrap::fill; 3 | 4 | #[cfg(not(feature = "textwrap"))] 5 | /// Quick-and-dirty fill implementation. 6 | /// 7 | /// Assumes single space between words, assumes 1 column per Unicode 8 | /// character (no emoji handling) and assumes that the longest word 9 | /// fit on the line (no handling of hyphens or over-long words). 10 | fn fill(text: &str, width: usize) -> String { 11 | let mut result = String::with_capacity(text.len()); 12 | let mut line_width = 0; 13 | for word in text.split_whitespace() { 14 | if line_width + 1 + word.len() > width { 15 | result.push('\n'); 16 | line_width = 0; 17 | } 18 | 19 | result.push_str(word); 20 | result.push(' '); 21 | line_width += word.len() + 1; 22 | } 23 | 24 | // Remove final ' '. 25 | result.truncate(result.len() - 1); 26 | result 27 | } 28 | 29 | fn main() { 30 | let text = "Hello, welcome to a world with beautifully wrapped \ 31 | text in your command-line programs. This includes \ 32 | non-ASCII text such as Açai, Jalapeño, Frappé"; 33 | for line in fill(text, 18).lines() { 34 | println!("│ {:18} │", line); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /examples/debug-words.rs: -------------------------------------------------------------------------------- 1 | use textwrap::WordSeparator; 2 | 3 | fn main() { 4 | #[cfg(feature = "unicode-linebreak")] 5 | let word_separator = WordSeparator::UnicodeBreakProperties; 6 | #[cfg(not(feature = "unicode-linebreak"))] 7 | let word_separator = WordSeparator::AsciiSpace; 8 | 9 | let args = std::env::args().skip(1).collect::>(); 10 | let text = args.join(" "); 11 | let words = word_separator.find_words(&text).collect::>(); 12 | 13 | println!("word_separator = {:?}", word_separator); 14 | println!("text = {:?}", text); 15 | println!("words = {:#?}", words); 16 | } 17 | -------------------------------------------------------------------------------- /examples/hello_world.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | let text = "Hello, welcome to a world of beautifully wrapped text!"; 3 | println!("{}", textwrap::fill(text, 30)); 4 | } 5 | -------------------------------------------------------------------------------- /examples/hyphenation.rs: -------------------------------------------------------------------------------- 1 | use hyphenation::{Language, Load, Standard}; 2 | use textwrap::WordSplitter; 3 | 4 | fn main() { 5 | let text = "textwrap: a small library for wrapping text."; 6 | let dictionary = Standard::from_embedded(Language::EnglishUS).unwrap(); 7 | let options = textwrap::Options::new(18).word_splitter(WordSplitter::Hyphenation(dictionary)); 8 | println!("{}", textwrap::fill(text, &options)); 9 | } 10 | -------------------------------------------------------------------------------- /examples/interactive.rs: -------------------------------------------------------------------------------- 1 | // The example only works on Linux since Termion does not yet support 2 | // Windows: https://gitlab.redox-os.org/redox-os/termion/-/issues/103 3 | // The precise library doesn't matter much, so feel free to send a PR 4 | // if there is a library with good Windows support. 5 | 6 | fn main() -> Result<(), std::io::Error> { 7 | #[cfg(not(unix))] 8 | panic!("Sorry, this example currently only works on Unix!"); 9 | 10 | #[cfg(unix)] 11 | unix_only::main() 12 | } 13 | 14 | #[cfg(unix)] 15 | mod unix_only { 16 | use std::io::{self, Write}; 17 | use termion::event::Key; 18 | use termion::input::TermRead; 19 | use termion::raw::{IntoRawMode, RawTerminal}; 20 | use termion::screen::IntoAlternateScreen; 21 | use termion::{color, cursor, style}; 22 | use textwrap::{wrap, Options, WordSeparator, WordSplitter, WrapAlgorithm}; 23 | 24 | #[cfg(feature = "hyphenation")] 25 | use hyphenation::{Language, Load, Standard}; 26 | 27 | fn draw_margins( 28 | row: u16, 29 | col: u16, 30 | line_width: u16, 31 | left: char, 32 | right: char, 33 | stdout: &mut RawTerminal, 34 | ) -> Result<(), io::Error> { 35 | write!( 36 | stdout, 37 | "{}{}{}{}", 38 | cursor::Goto(col - 1, row), 39 | color::Fg(color::Red), 40 | left, 41 | color::Fg(color::Reset), 42 | )?; 43 | write!( 44 | stdout, 45 | "{}{}{}{}", 46 | cursor::Goto(col + line_width, row), 47 | color::Fg(color::Red), 48 | right, 49 | color::Fg(color::Reset), 50 | )?; 51 | 52 | Ok(()) 53 | } 54 | 55 | fn draw_text<'a>( 56 | text: &str, 57 | options: &Options<'a>, 58 | word_splitter_label: &str, 59 | stdout: &mut RawTerminal, 60 | ) -> Result<(), io::Error> { 61 | let mut left_row: u16 = 1; 62 | let left_col: u16 = 3; 63 | 64 | write!(stdout, "{}", termion::clear::All)?; 65 | write!( 66 | stdout, 67 | "{}{}Options:{}", 68 | cursor::Goto(left_col, left_row), 69 | style::Bold, 70 | style::Reset, 71 | )?; 72 | left_row += 1; 73 | 74 | write!( 75 | stdout, 76 | "{}- width: {}{}{} (use ← and → to change)", 77 | cursor::Goto(left_col, left_row), 78 | style::Bold, 79 | options.width, 80 | style::Reset, 81 | )?; 82 | left_row += 1; 83 | 84 | write!( 85 | stdout, 86 | "{}- break_words: {}{:?}{} (toggle with Ctrl-b)", 87 | cursor::Goto(left_col, left_row), 88 | style::Bold, 89 | options.break_words, 90 | style::Reset, 91 | )?; 92 | left_row += 1; 93 | 94 | write!( 95 | stdout, 96 | "{}- splitter: {}{}{} (cycle with Ctrl-s)", 97 | cursor::Goto(left_col, left_row), 98 | style::Bold, 99 | word_splitter_label, 100 | style::Reset, 101 | )?; 102 | left_row += 1; 103 | 104 | #[cfg(feature = "smawk")] 105 | { 106 | // The OptimalFit struct formats itself with a ton of 107 | // parameters. This removes the parameters, leaving only 108 | // the struct name behind. 109 | let wrap_algorithm_label = format!("{:?}", options.wrap_algorithm) 110 | .split('(') 111 | .next() 112 | .unwrap() 113 | .to_string(); 114 | write!( 115 | stdout, 116 | "{}- algorithm: {}{}{} (toggle with Ctrl-o)", 117 | cursor::Goto(left_col, left_row), 118 | style::Bold, 119 | wrap_algorithm_label, 120 | style::Reset, 121 | )?; 122 | left_row += 1; 123 | } 124 | 125 | let now = std::time::Instant::now(); 126 | let mut lines = wrap(text, options); 127 | let elapsed = now.elapsed(); 128 | 129 | let right_col: u16 = 55; 130 | let mut right_row: u16 = 1; 131 | write!( 132 | stdout, 133 | "{}{}Performance:{}", 134 | cursor::Goto(right_col, right_row), 135 | style::Bold, 136 | style::Reset, 137 | )?; 138 | right_row += 1; 139 | 140 | write!( 141 | stdout, 142 | "{}- build: {}{}{}", 143 | cursor::Goto(right_col, right_row), 144 | style::Bold, 145 | if cfg!(debug_assertions) { 146 | "debug" 147 | } else { 148 | "release" 149 | }, 150 | style::Reset, 151 | )?; 152 | right_row += 1; 153 | 154 | write!( 155 | stdout, 156 | "{}- words: {}{}{}", 157 | cursor::Goto(right_col, right_row), 158 | style::Bold, 159 | text.split_whitespace().count(), 160 | style::Reset, 161 | )?; 162 | right_row += 1; 163 | 164 | write!( 165 | stdout, 166 | "{}- characters: {}{}{}", 167 | cursor::Goto(right_col, right_row), 168 | style::Bold, 169 | text.chars().count(), 170 | style::Reset, 171 | )?; 172 | right_row += 1; 173 | 174 | write!( 175 | stdout, 176 | "{}- latency: {}{} usec{}", 177 | cursor::Goto(right_col, right_row), 178 | style::Bold, 179 | elapsed.as_micros(), 180 | style::Reset, 181 | )?; 182 | 183 | // Empty line. 184 | left_row += 1; 185 | 186 | if let Some(line) = lines.last_mut() { 187 | let trailing_whitespace = &text[text.trim_end_matches(' ').len()..]; 188 | if !trailing_whitespace.is_empty() { 189 | // Trailing whitespace is discarded by 190 | // `textwrap::wrap`. We reinsert it here. If multiple 191 | // spaces are added, this can overflow the margins 192 | // which look a bit odd. Handling this would require 193 | // some more tinkering... 194 | *line = format!("{}{}", line, trailing_whitespace).into(); 195 | } else if line.ends_with('\n') { 196 | // If `text` ends with a newline, the final wrapped line 197 | // contains this newline. This will in turn leave the 198 | // cursor hanging in the middle of the line. Pushing an 199 | // extra empty line fixes this. 200 | lines.push("".into()); 201 | } 202 | } else { 203 | // No lines -> we add an empty line so we have a place 204 | // where we can display the cursor. 205 | lines.push("".into()); 206 | } 207 | 208 | // Draw margins above and below the wrapped text. We draw the 209 | // margin before the text so that 1) the text can overwrite 210 | // the margin if `break_words` is `false` and `width` is very 211 | // small and 2) so the cursor remains at the end of the last 212 | // line of text. 213 | draw_margins(left_row, left_col, options.width as u16, '┌', '┐', stdout)?; 214 | left_row += 1; 215 | let final_row = left_row + lines.len() as u16; 216 | draw_margins(final_row, left_col, options.width as u16, '└', '┘', stdout)?; 217 | 218 | let (_, rows) = termion::terminal_size()?; 219 | write!(stdout, "{}", cursor::Show)?; 220 | for line in lines { 221 | if left_row > rows { 222 | // The text does not fits on the terminal -- we hide 223 | // the cursor since it's supposed to be "below" the 224 | // bottom of the terminal. 225 | write!(stdout, "{}", cursor::Hide)?; 226 | break; 227 | } 228 | draw_margins(left_row, left_col, options.width as u16, '│', '│', stdout)?; 229 | write!(stdout, "{}{}", cursor::Goto(left_col, left_row), line)?; 230 | left_row += 1; 231 | } 232 | 233 | stdout.flush() 234 | } 235 | 236 | pub fn main() -> Result<(), io::Error> { 237 | let mut wrap_algorithms = Vec::new(); 238 | #[cfg(feature = "smawk")] 239 | wrap_algorithms.push(WrapAlgorithm::OptimalFit( 240 | textwrap::wrap_algorithms::Penalties::new(), 241 | )); 242 | wrap_algorithms.push(WrapAlgorithm::FirstFit); 243 | 244 | let mut word_splitters: Vec = 245 | vec![WordSplitter::HyphenSplitter, WordSplitter::NoHyphenation]; 246 | let mut word_splitter_labels: Vec = 247 | word_splitters.iter().map(|s| format!("{:?}", s)).collect(); 248 | 249 | // If you like, you can download more dictionaries from 250 | // https://github.com/tapeinosyne/hyphenation/tree/master/dictionaries 251 | // Place the dictionaries in the examples/ directory. Here we 252 | // just load the embedded en-us dictionary. 253 | #[cfg(feature = "hyphenation")] 254 | for lang in &[Language::EnglishUS] { 255 | let dictionary = Standard::from_embedded(*lang).or_else(|_| { 256 | let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) 257 | .join("examples") 258 | .join(format!("{}.standard.bincode", lang.code())); 259 | Standard::from_path(*lang, &path) 260 | }); 261 | 262 | if let Ok(dict) = dictionary { 263 | word_splitters.insert(0, WordSplitter::Hyphenation(dict)); 264 | word_splitter_labels.insert(0, format!("{} hyphenation", lang.code())); 265 | } 266 | } 267 | 268 | let mut options = Options::new(35) 269 | .break_words(false) 270 | .wrap_algorithm(wrap_algorithms.remove(0)) 271 | .word_splitter(word_splitters.remove(0)) 272 | .word_separator(WordSeparator::AsciiSpace); 273 | let mut word_splitter_label = word_splitter_labels.remove(0); 274 | 275 | let args = std::env::args().collect::>(); 276 | let mut text = if args.len() > 1 { 277 | args[1..].join(" ") 278 | } else { 279 | String::from( 280 | "Welcome to the interactive demo! The following is from The \ 281 | Emperor’s New Clothes by Hans Christian Andersen. You can edit the \ 282 | text!\n\n\ 283 | Many years ago there was an Emperor, who was so excessively fond \ 284 | of new clothes that he spent all his money on them. He cared \ 285 | nothing about his soldiers, nor for the theatre, nor for driving \ 286 | in the woods except for the sake of showing off his new clothes. \ 287 | He had a costume for every hour in the day, and instead of saying, \ 288 | as one does about any other king or emperor, ‘He is in his council \ 289 | chamber,’ here one always said, ‘The Emperor is in his \ 290 | dressing-room.’", 291 | ) 292 | }; 293 | 294 | let stdin = io::stdin(); 295 | let mut screen = io::stdout().into_raw_mode()?.into_alternate_screen()?; 296 | write!(screen, "{}", cursor::BlinkingUnderline)?; 297 | draw_text(&text, &options, &word_splitter_label, &mut screen)?; 298 | 299 | for c in stdin.keys() { 300 | match c? { 301 | Key::Esc | Key::Ctrl('c') => break, 302 | Key::Left => options.width = options.width.saturating_sub(1), 303 | Key::Right => options.width = options.width.saturating_add(1), 304 | Key::Ctrl('b') => options.break_words = !options.break_words, 305 | #[cfg(feature = "smawk")] 306 | Key::Ctrl('o') => { 307 | std::mem::swap(&mut options.wrap_algorithm, &mut wrap_algorithms[0]); 308 | wrap_algorithms.rotate_left(1); 309 | } 310 | Key::Ctrl('s') => { 311 | // We always keep the next splitter at position 0. 312 | std::mem::swap(&mut options.word_splitter, &mut word_splitters[0]); 313 | word_splitters.rotate_left(1); 314 | std::mem::swap(&mut word_splitter_label, &mut word_splitter_labels[0]); 315 | word_splitter_labels.rotate_left(1); 316 | } 317 | Key::Char(c) => text.push(c), 318 | Key::Backspace => { 319 | text.pop(); 320 | } 321 | // Also known as Ctrl-Backspace 322 | Key::Ctrl('h') => text.truncate(text.rfind(' ').unwrap_or(0)), 323 | _ => {} 324 | } 325 | 326 | draw_text(&text, &options, &word_splitter_label, &mut screen)?; 327 | } 328 | 329 | // TODO: change to cursor::DefaultStyle if 330 | // https://github.com/redox-os/termion/pull/157 is merged. 331 | screen.write_all(b"\x1b[0 q")?; 332 | screen.flush() 333 | } 334 | } 335 | -------------------------------------------------------------------------------- /examples/layout.rs: -------------------------------------------------------------------------------- 1 | use textwrap::{wrap, Options, WordSplitter}; 2 | 3 | fn main() { 4 | let example = "Memory safety without garbage collection. \ 5 | Concurrency without data races. \ 6 | Zero-cost abstractions."; 7 | let mut prev_lines = vec![]; 8 | 9 | let mut options = Options::new(0).word_splitter(WordSplitter::HyphenSplitter); 10 | #[cfg(feature = "hyphenation")] 11 | { 12 | use hyphenation::Load; 13 | let language = hyphenation::Language::EnglishUS; 14 | let dictionary = hyphenation::Standard::from_embedded(language).unwrap(); 15 | options.word_splitter = WordSplitter::Hyphenation(dictionary); 16 | } 17 | 18 | for width in 15..60 { 19 | options.width = width; 20 | let lines = wrap(example, &options); 21 | if lines != prev_lines { 22 | let title = format!(" Width: {} ", width); 23 | println!(".{:-^1$}.", title, width + 2); 24 | for line in &lines { 25 | println!("| {:1$} |", line, width); 26 | } 27 | prev_lines = lines; 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /examples/termwidth.rs: -------------------------------------------------------------------------------- 1 | use textwrap::{fill, Options, WordSplitter}; 2 | 3 | fn main() { 4 | let example = "Memory safety without garbage collection. \ 5 | Concurrency without data races. \ 6 | Zero-cost abstractions."; 7 | 8 | #[cfg(not(feature = "hyphenation"))] 9 | let (msg, options) = ("without hyphenation", Options::with_termwidth()); 10 | 11 | #[cfg(feature = "hyphenation")] 12 | use hyphenation::Load; 13 | 14 | #[cfg(feature = "hyphenation")] 15 | let (msg, options) = ( 16 | "with hyphenation", 17 | Options::with_termwidth().word_splitter(WordSplitter::Hyphenation( 18 | hyphenation::Standard::from_embedded(hyphenation::Language::EnglishUS).unwrap(), 19 | )), 20 | ); 21 | 22 | println!("Formatted {} in {} columns:", msg, options.width); 23 | println!("----"); 24 | println!("{}", fill(example, &options)); 25 | println!("----"); 26 | } 27 | -------------------------------------------------------------------------------- /examples/wasm/Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "bumpalo" 7 | version = "3.16.0" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" 10 | 11 | [[package]] 12 | name = "cc" 13 | version = "1.2.9" 14 | source = "registry+https://github.com/rust-lang/crates.io-index" 15 | checksum = "c8293772165d9345bdaaa39b45b2109591e63fe5e6fbc23c6ff930a048aa310b" 16 | dependencies = [ 17 | "shlex", 18 | ] 19 | 20 | [[package]] 21 | name = "cfg-if" 22 | version = "1.0.0" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 25 | 26 | [[package]] 27 | name = "console_error_panic_hook" 28 | version = "0.1.7" 29 | source = "registry+https://github.com/rust-lang/crates.io-index" 30 | checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" 31 | dependencies = [ 32 | "cfg-if", 33 | "wasm-bindgen", 34 | ] 35 | 36 | [[package]] 37 | name = "js-sys" 38 | version = "0.3.76" 39 | source = "registry+https://github.com/rust-lang/crates.io-index" 40 | checksum = "6717b6b5b077764fb5966237269cb3c64edddde4b14ce42647430a78ced9e7b7" 41 | dependencies = [ 42 | "once_cell", 43 | "wasm-bindgen", 44 | ] 45 | 46 | [[package]] 47 | name = "log" 48 | version = "0.4.22" 49 | source = "registry+https://github.com/rust-lang/crates.io-index" 50 | checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" 51 | 52 | [[package]] 53 | name = "minicov" 54 | version = "0.3.7" 55 | source = "registry+https://github.com/rust-lang/crates.io-index" 56 | checksum = "f27fe9f1cc3c22e1687f9446c2083c4c5fc7f0bcf1c7a86bdbded14985895b4b" 57 | dependencies = [ 58 | "cc", 59 | "walkdir", 60 | ] 61 | 62 | [[package]] 63 | name = "once_cell" 64 | version = "1.20.2" 65 | source = "registry+https://github.com/rust-lang/crates.io-index" 66 | checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" 67 | 68 | [[package]] 69 | name = "proc-macro2" 70 | version = "1.0.93" 71 | source = "registry+https://github.com/rust-lang/crates.io-index" 72 | checksum = "60946a68e5f9d28b0dc1c21bb8a97ee7d018a8b322fa57838ba31cc878e22d99" 73 | dependencies = [ 74 | "unicode-ident", 75 | ] 76 | 77 | [[package]] 78 | name = "quote" 79 | version = "1.0.38" 80 | source = "registry+https://github.com/rust-lang/crates.io-index" 81 | checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" 82 | dependencies = [ 83 | "proc-macro2", 84 | ] 85 | 86 | [[package]] 87 | name = "same-file" 88 | version = "1.0.6" 89 | source = "registry+https://github.com/rust-lang/crates.io-index" 90 | checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" 91 | dependencies = [ 92 | "winapi-util", 93 | ] 94 | 95 | [[package]] 96 | name = "scoped-tls" 97 | version = "1.0.1" 98 | source = "registry+https://github.com/rust-lang/crates.io-index" 99 | checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" 100 | 101 | [[package]] 102 | name = "shlex" 103 | version = "1.3.0" 104 | source = "registry+https://github.com/rust-lang/crates.io-index" 105 | checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 106 | 107 | [[package]] 108 | name = "smawk" 109 | version = "0.3.2" 110 | source = "registry+https://github.com/rust-lang/crates.io-index" 111 | checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" 112 | 113 | [[package]] 114 | name = "syn" 115 | version = "2.0.96" 116 | source = "registry+https://github.com/rust-lang/crates.io-index" 117 | checksum = "d5d0adab1ae378d7f53bdebc67a39f1f151407ef230f0ce2883572f5d8985c80" 118 | dependencies = [ 119 | "proc-macro2", 120 | "quote", 121 | "unicode-ident", 122 | ] 123 | 124 | [[package]] 125 | name = "textwrap" 126 | version = "0.16.1" 127 | dependencies = [ 128 | "smawk", 129 | "unicode-linebreak", 130 | "unicode-width", 131 | ] 132 | 133 | [[package]] 134 | name = "textwrap-wasm-demo" 135 | version = "0.1.0" 136 | dependencies = [ 137 | "console_error_panic_hook", 138 | "js-sys", 139 | "textwrap", 140 | "unicode-segmentation", 141 | "wasm-bindgen", 142 | "wasm-bindgen-test", 143 | "web-sys", 144 | ] 145 | 146 | [[package]] 147 | name = "unicode-ident" 148 | version = "1.0.14" 149 | source = "registry+https://github.com/rust-lang/crates.io-index" 150 | checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" 151 | 152 | [[package]] 153 | name = "unicode-linebreak" 154 | version = "0.1.5" 155 | source = "registry+https://github.com/rust-lang/crates.io-index" 156 | checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" 157 | 158 | [[package]] 159 | name = "unicode-segmentation" 160 | version = "1.12.0" 161 | source = "registry+https://github.com/rust-lang/crates.io-index" 162 | checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" 163 | 164 | [[package]] 165 | name = "unicode-width" 166 | version = "0.2.0" 167 | source = "registry+https://github.com/rust-lang/crates.io-index" 168 | checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" 169 | 170 | [[package]] 171 | name = "walkdir" 172 | version = "2.5.0" 173 | source = "registry+https://github.com/rust-lang/crates.io-index" 174 | checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" 175 | dependencies = [ 176 | "same-file", 177 | "winapi-util", 178 | ] 179 | 180 | [[package]] 181 | name = "wasm-bindgen" 182 | version = "0.2.99" 183 | source = "registry+https://github.com/rust-lang/crates.io-index" 184 | checksum = "a474f6281d1d70c17ae7aa6a613c87fce69a127e2624002df63dcb39d6cf6396" 185 | dependencies = [ 186 | "cfg-if", 187 | "once_cell", 188 | "wasm-bindgen-macro", 189 | ] 190 | 191 | [[package]] 192 | name = "wasm-bindgen-backend" 193 | version = "0.2.99" 194 | source = "registry+https://github.com/rust-lang/crates.io-index" 195 | checksum = "5f89bb38646b4f81674e8f5c3fb81b562be1fd936d84320f3264486418519c79" 196 | dependencies = [ 197 | "bumpalo", 198 | "log", 199 | "proc-macro2", 200 | "quote", 201 | "syn", 202 | "wasm-bindgen-shared", 203 | ] 204 | 205 | [[package]] 206 | name = "wasm-bindgen-futures" 207 | version = "0.4.49" 208 | source = "registry+https://github.com/rust-lang/crates.io-index" 209 | checksum = "38176d9b44ea84e9184eff0bc34cc167ed044f816accfe5922e54d84cf48eca2" 210 | dependencies = [ 211 | "cfg-if", 212 | "js-sys", 213 | "once_cell", 214 | "wasm-bindgen", 215 | "web-sys", 216 | ] 217 | 218 | [[package]] 219 | name = "wasm-bindgen-macro" 220 | version = "0.2.99" 221 | source = "registry+https://github.com/rust-lang/crates.io-index" 222 | checksum = "2cc6181fd9a7492eef6fef1f33961e3695e4579b9872a6f7c83aee556666d4fe" 223 | dependencies = [ 224 | "quote", 225 | "wasm-bindgen-macro-support", 226 | ] 227 | 228 | [[package]] 229 | name = "wasm-bindgen-macro-support" 230 | version = "0.2.99" 231 | source = "registry+https://github.com/rust-lang/crates.io-index" 232 | checksum = "30d7a95b763d3c45903ed6c81f156801839e5ee968bb07e534c44df0fcd330c2" 233 | dependencies = [ 234 | "proc-macro2", 235 | "quote", 236 | "syn", 237 | "wasm-bindgen-backend", 238 | "wasm-bindgen-shared", 239 | ] 240 | 241 | [[package]] 242 | name = "wasm-bindgen-shared" 243 | version = "0.2.99" 244 | source = "registry+https://github.com/rust-lang/crates.io-index" 245 | checksum = "943aab3fdaaa029a6e0271b35ea10b72b943135afe9bffca82384098ad0e06a6" 246 | 247 | [[package]] 248 | name = "wasm-bindgen-test" 249 | version = "0.3.49" 250 | source = "registry+https://github.com/rust-lang/crates.io-index" 251 | checksum = "c61d44563646eb934577f2772656c7ad5e9c90fac78aa8013d776fcdaf24625d" 252 | dependencies = [ 253 | "js-sys", 254 | "minicov", 255 | "scoped-tls", 256 | "wasm-bindgen", 257 | "wasm-bindgen-futures", 258 | "wasm-bindgen-test-macro", 259 | ] 260 | 261 | [[package]] 262 | name = "wasm-bindgen-test-macro" 263 | version = "0.3.49" 264 | source = "registry+https://github.com/rust-lang/crates.io-index" 265 | checksum = "54171416ce73aa0b9c377b51cc3cb542becee1cd678204812e8392e5b0e4a031" 266 | dependencies = [ 267 | "proc-macro2", 268 | "quote", 269 | "syn", 270 | ] 271 | 272 | [[package]] 273 | name = "web-sys" 274 | version = "0.3.76" 275 | source = "registry+https://github.com/rust-lang/crates.io-index" 276 | checksum = "04dd7223427d52553d3702c004d3b2fe07c148165faa56313cb00211e31c12bc" 277 | dependencies = [ 278 | "js-sys", 279 | "wasm-bindgen", 280 | ] 281 | 282 | [[package]] 283 | name = "winapi-util" 284 | version = "0.1.9" 285 | source = "registry+https://github.com/rust-lang/crates.io-index" 286 | checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" 287 | dependencies = [ 288 | "windows-sys", 289 | ] 290 | 291 | [[package]] 292 | name = "windows-sys" 293 | version = "0.59.0" 294 | source = "registry+https://github.com/rust-lang/crates.io-index" 295 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 296 | dependencies = [ 297 | "windows-targets", 298 | ] 299 | 300 | [[package]] 301 | name = "windows-targets" 302 | version = "0.52.6" 303 | source = "registry+https://github.com/rust-lang/crates.io-index" 304 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 305 | dependencies = [ 306 | "windows_aarch64_gnullvm", 307 | "windows_aarch64_msvc", 308 | "windows_i686_gnu", 309 | "windows_i686_gnullvm", 310 | "windows_i686_msvc", 311 | "windows_x86_64_gnu", 312 | "windows_x86_64_gnullvm", 313 | "windows_x86_64_msvc", 314 | ] 315 | 316 | [[package]] 317 | name = "windows_aarch64_gnullvm" 318 | version = "0.52.6" 319 | source = "registry+https://github.com/rust-lang/crates.io-index" 320 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 321 | 322 | [[package]] 323 | name = "windows_aarch64_msvc" 324 | version = "0.52.6" 325 | source = "registry+https://github.com/rust-lang/crates.io-index" 326 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 327 | 328 | [[package]] 329 | name = "windows_i686_gnu" 330 | version = "0.52.6" 331 | source = "registry+https://github.com/rust-lang/crates.io-index" 332 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 333 | 334 | [[package]] 335 | name = "windows_i686_gnullvm" 336 | version = "0.52.6" 337 | source = "registry+https://github.com/rust-lang/crates.io-index" 338 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 339 | 340 | [[package]] 341 | name = "windows_i686_msvc" 342 | version = "0.52.6" 343 | source = "registry+https://github.com/rust-lang/crates.io-index" 344 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 345 | 346 | [[package]] 347 | name = "windows_x86_64_gnu" 348 | version = "0.52.6" 349 | source = "registry+https://github.com/rust-lang/crates.io-index" 350 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 351 | 352 | [[package]] 353 | name = "windows_x86_64_gnullvm" 354 | version = "0.52.6" 355 | source = "registry+https://github.com/rust-lang/crates.io-index" 356 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 357 | 358 | [[package]] 359 | name = "windows_x86_64_msvc" 360 | version = "0.52.6" 361 | source = "registry+https://github.com/rust-lang/crates.io-index" 362 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 363 | -------------------------------------------------------------------------------- /examples/wasm/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "textwrap-wasm-demo" 3 | version = "0.1.0" 4 | authors = ["Martin Geisler "] 5 | edition = "2021" 6 | license = "MIT" 7 | publish = false # This project should not be uploaded to crates.io 8 | repository = "https://github.com/mgeisler/textwrap" 9 | description = "Textwrap WebAssembly demo" 10 | 11 | [lib] 12 | crate-type = ["cdylib", "rlib"] 13 | 14 | [dependencies] 15 | textwrap = { path = "../../" } 16 | 17 | console_error_panic_hook = "0.1.7" 18 | js-sys = "0.3.57" 19 | unicode-segmentation = "1.9.0" 20 | wasm-bindgen = "0.2.80" 21 | web-sys = { version = "0.3.57", features = ["CanvasRenderingContext2d", "TextMetrics"] } 22 | 23 | [dev-dependencies] 24 | wasm-bindgen-test = "0.3.30" 25 | 26 | [profile.release] 27 | opt-level = "s" # Optimize for small code size 28 | -------------------------------------------------------------------------------- /examples/wasm/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Martin Geisler 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /examples/wasm/README.md: -------------------------------------------------------------------------------- 1 | # Textwrap WebAssembly Demo 2 | 3 | An example showing how Textwrap can be used to wrap text in a HTML canvas. Both 4 | monospace and proportional fonts can be used since we use the browser to measure 5 | the pixel width of each word as it is rendered to the canvas. 6 | 7 | ## Links 8 | 9 | - **Live demo: 10 | [mgeisler.github.io/textwrap/](https://mgeisler.github.io/textwrap/).** Here 11 | you can try the demo. It is automatically deployed on every merge to the 12 | `master` branch. 13 | 14 | - **Source code: 15 | [`examples/wasm/`](https://github.com/mgeisler/textwrap/tree/master/examples/wasm).** 16 | This is the Rust code which make up the demo. You will also find some 17 | JavaScript and HTML glue code. 18 | 19 | - **GitHub Action: 20 | [`build.yml`](https://github.com/mgeisler/textwrap/blob/master/.github/workflows/build.yml).** 21 | This is the script which compiles and deploys the code. We use 22 | [`wasm-pack`](https://github.com/rustwasm/wasm-pack) to easily compile 23 | Textwrap and its dependencies to Wasm. 24 | 25 | - **Deployment branch: 26 | [`gh-pages`](https://github.com/mgeisler/textwrap/tree/gh-pages).** The 27 | compiled Wasm code is pushed to this branch. The branch might be squashed from 28 | time to time if it grows too big. 29 | -------------------------------------------------------------------------------- /examples/wasm/src/lib.rs: -------------------------------------------------------------------------------- 1 | use unicode_segmentation::UnicodeSegmentation; 2 | use wasm_bindgen::prelude::*; 3 | use wasm_bindgen::JsCast; 4 | 5 | use textwrap::word_splitters::split_words; 6 | use textwrap::wrap_algorithms::{wrap_first_fit, wrap_optimal_fit, Penalties}; 7 | use textwrap::{WordSeparator, WordSplitter}; 8 | 9 | #[wasm_bindgen] 10 | extern "C" { 11 | // https://github.com/rustwasm/wasm-bindgen/issues/2069#issuecomment-774038243 12 | type ExtendedTextMetrics; 13 | 14 | #[wasm_bindgen(method, getter, js_name = actualBoundingBoxLeft)] 15 | fn actual_bounding_box_left(this: &ExtendedTextMetrics) -> f64; 16 | 17 | #[wasm_bindgen(method, getter, js_name = actualBoundingBoxRight)] 18 | fn actual_bounding_box_right(this: &ExtendedTextMetrics) -> f64; 19 | 20 | #[wasm_bindgen(method, getter, js_name = actualBoundingBoxAscent)] 21 | fn actual_bounding_box_ascent(this: &ExtendedTextMetrics) -> f64; 22 | 23 | #[wasm_bindgen(method, getter, js_name = actualBoundingBoxDescent)] 24 | fn actual_bounding_box_descent(this: &ExtendedTextMetrics) -> f64; 25 | 26 | // TODO: Enable when Firefox and Edge support these methods, see 27 | // https://developer.mozilla.org/en-US/docs/Web/API/TextMetrics 28 | // 29 | // #[wasm_bindgen(method, getter, js_name = fontBoundingBoxAscent)] 30 | // fn font_bounding_box_ascent(this: &ExtendedTextMetrics) -> f64; 31 | // 32 | // #[wasm_bindgen(method, getter, js_name = fontBoundingBoxDescent)] 33 | // fn font_bounding_box_descent(this: &ExtendedTextMetrics) -> f64; 34 | 35 | #[wasm_bindgen(method, getter)] 36 | fn width(this: &ExtendedTextMetrics) -> f64; 37 | } 38 | 39 | fn canvas_width(ctx: &web_sys::CanvasRenderingContext2d, text: &str) -> f64 { 40 | ctx.measure_text(text).unwrap().width() 41 | } 42 | 43 | #[derive(Debug, Copy, Clone, PartialEq)] 44 | pub struct CanvasWord<'a> { 45 | word: &'a str, 46 | width: f64, 47 | whitespace: &'a str, 48 | whitespace_width: f64, 49 | penalty: &'a str, 50 | penalty_width: f64, 51 | } 52 | 53 | impl<'a> CanvasWord<'a> { 54 | fn from(ctx: &'_ web_sys::CanvasRenderingContext2d, word: textwrap::core::Word<'a>) -> Self { 55 | CanvasWord { 56 | word: word.word, 57 | width: canvas_width(ctx, word.word), 58 | whitespace: word.whitespace, 59 | whitespace_width: canvas_width(ctx, word.whitespace), 60 | penalty: word.penalty, 61 | penalty_width: canvas_width(ctx, word.penalty), 62 | } 63 | } 64 | 65 | fn break_apart( 66 | self, 67 | ctx: &'_ web_sys::CanvasRenderingContext2d, 68 | max_width: f64, 69 | ) -> Vec> { 70 | if self.width <= max_width { 71 | return vec![self]; 72 | } 73 | 74 | let mut start = 0; 75 | let mut words = Vec::new(); 76 | for (idx, grapheme) in self.word.grapheme_indices(true) { 77 | let with_grapheme = &self.word[start..idx + grapheme.len()]; 78 | let without_grapheme = &self.word[start..idx]; 79 | if idx > 0 && canvas_width(ctx, with_grapheme) > max_width { 80 | // The part without the grapheme fits on the line. We 81 | // give it a width of max_width instead of its natural 82 | // width to ensure that it takes up the full line. 83 | // 84 | // Otherwise, we can end up with a situation where a 85 | // text fits in _fewer_ lines when the line width is 86 | // _smaller_. This happens with proportional fonts, 87 | // such as the sans-serif or serif fonts. An example 88 | // text which illustrates the problem is: 89 | // 90 | // i XYZ 91 | // 92 | // Line width: 42px. Normal break, XYZ doesn't fit on 93 | // first line: 94 | // 95 | // i 96 | // XYZ 97 | // 98 | // Line width: 41px. XYZ takes up 41.1px, so it is 99 | // broken apart. The first part now fits on the first 100 | // line: 101 | // 102 | // i XY 103 | // Z 104 | // 105 | // Line width: 39px. There is no longer room for XY on 106 | // the first line: 107 | // 108 | // i 109 | // XY 110 | // Z 111 | // 112 | // Line width: 28px. XY takes up 28.9px, so it is 113 | // broken apart. YZ takes up 26.7px, so everything 114 | // suddenly fits on two lines again: 115 | // 116 | // i X 117 | // YZ 118 | // 119 | // We can be a more "natural" or "monotone" behavior 120 | // by making the parts take up at least the full line 121 | // width. 122 | let natural_width = canvas_width(ctx, without_grapheme); 123 | words.push(CanvasWord { 124 | word: without_grapheme, 125 | width: max_width.max(natural_width), 126 | whitespace: "", 127 | whitespace_width: 0.0, 128 | penalty: "", 129 | penalty_width: 0.0, 130 | }); 131 | start = idx; 132 | } 133 | } 134 | 135 | words.push(CanvasWord { 136 | word: &self.word[start..], 137 | width: canvas_width(ctx, &self.word[start..]), 138 | whitespace: self.whitespace, 139 | whitespace_width: self.whitespace_width, 140 | penalty: self.penalty, 141 | penalty_width: self.penalty_width, 142 | }); 143 | 144 | words 145 | } 146 | } 147 | 148 | impl textwrap::core::Fragment for CanvasWord<'_> { 149 | #[inline] 150 | fn width(&self) -> f64 { 151 | self.width 152 | } 153 | 154 | #[inline] 155 | fn whitespace_width(&self) -> f64 { 156 | self.whitespace_width 157 | } 158 | 159 | #[inline] 160 | fn penalty_width(&self) -> f64 { 161 | self.penalty_width 162 | } 163 | } 164 | 165 | fn draw_path( 166 | ctx: &web_sys::CanvasRenderingContext2d, 167 | style: &str, 168 | (mut x, mut y): (f64, f64), 169 | steps: &[(f64, f64)], 170 | ) { 171 | ctx.save(); 172 | ctx.set_stroke_style_str(style); 173 | ctx.begin_path(); 174 | ctx.move_to(x, y); 175 | for (delta_x, delta_y) in steps { 176 | x += delta_x; 177 | y += delta_y; 178 | ctx.line_to(x, y); 179 | } 180 | ctx.stroke(); 181 | ctx.restore(); 182 | } 183 | 184 | // We offset all text by the width of the round slider. This ensures 185 | // no clipping due to anti-aliasing. 186 | const X_OFFSET: f64 = 8.0; 187 | 188 | fn draw_word( 189 | ctx: &web_sys::CanvasRenderingContext2d, 190 | x: f64, 191 | y: f64, 192 | word: &CanvasWord, 193 | last_word: bool, 194 | ) -> Result<(), JsValue> { 195 | ctx.fill_text(word.word, x, y)?; 196 | 197 | draw_path( 198 | ctx, 199 | "orange", 200 | (x, y - 10.0), 201 | &[(0.0, 10.0), (word.width, 0.0)], 202 | ); 203 | 204 | ctx.save(); 205 | ctx.set_font("10px sans-serif"); 206 | ctx.set_text_align("center"); 207 | ctx.set_text_baseline("top"); 208 | ctx.fill_text( 209 | &format!("{:.1}px", word.width), 210 | x + word.width / 2.0, 211 | y + 3.0, 212 | )?; 213 | ctx.restore(); 214 | 215 | let x = x + word.width; 216 | if last_word { 217 | ctx.fill_text(word.penalty, x, y)?; 218 | draw_path(ctx, "red", (x, y), &[(word.penalty_width, 0.0)]); 219 | } else { 220 | ctx.fill_text(word.whitespace, x, y)?; 221 | draw_path(ctx, "lightblue", (x, y), &[(word.whitespace_width, 0.0)]); 222 | } 223 | 224 | Ok(()) 225 | } 226 | 227 | #[wasm_bindgen] 228 | #[derive(Copy, Clone, Debug)] 229 | pub enum WasmWordSeparator { 230 | AsciiSpace = "AsciiSpace", 231 | UnicodeBreakProperties = "UnicodeBreakProperties", 232 | } 233 | 234 | #[wasm_bindgen] 235 | #[derive(Copy, Clone, Debug)] 236 | pub enum WasmWordSplitter { 237 | NoHyphenation = "NoHyphenation", 238 | HyphenSplitter = "HyphenSplitter", 239 | } 240 | 241 | #[wasm_bindgen] 242 | #[derive(Copy, Clone, Debug)] 243 | pub enum WasmWrapAlgorithm { 244 | FirstFit = "FirstFit", 245 | OptimalFit = "OptimalFit", 246 | } 247 | 248 | #[wasm_bindgen] 249 | #[derive(Copy, Clone, Debug, Default)] 250 | pub struct WasmPenalties { 251 | pub nline_penalty: usize, 252 | pub overflow_penalty: usize, 253 | pub short_last_line_fraction: usize, 254 | pub short_last_line_penalty: usize, 255 | pub hyphen_penalty: usize, 256 | } 257 | 258 | #[wasm_bindgen] 259 | impl WasmPenalties { 260 | #[wasm_bindgen(constructor)] 261 | pub fn new( 262 | nline_penalty: usize, 263 | overflow_penalty: usize, 264 | short_last_line_fraction: usize, 265 | short_last_line_penalty: usize, 266 | hyphen_penalty: usize, 267 | ) -> WasmPenalties { 268 | WasmPenalties { 269 | nline_penalty, 270 | overflow_penalty, 271 | short_last_line_fraction, 272 | short_last_line_penalty, 273 | hyphen_penalty, 274 | } 275 | } 276 | } 277 | 278 | impl From for Penalties { 279 | fn from(val: WasmPenalties) -> Self { 280 | Penalties { 281 | nline_penalty: val.nline_penalty, 282 | overflow_penalty: val.overflow_penalty, 283 | short_last_line_fraction: val.short_last_line_fraction, 284 | short_last_line_penalty: val.short_last_line_penalty, 285 | hyphen_penalty: val.hyphen_penalty, 286 | } 287 | } 288 | } 289 | 290 | #[wasm_bindgen] 291 | #[derive(Copy, Clone, Debug)] 292 | pub struct WasmOptions { 293 | pub width: f64, 294 | pub break_words: bool, 295 | pub word_separator: WasmWordSeparator, 296 | pub word_splitter: WasmWordSplitter, 297 | pub wrap_algorithm: WasmWrapAlgorithm, 298 | pub penalties: WasmPenalties, 299 | } 300 | 301 | #[wasm_bindgen] 302 | impl WasmOptions { 303 | #[wasm_bindgen(constructor)] 304 | pub fn new( 305 | width: f64, 306 | break_words: bool, 307 | word_separator: WasmWordSeparator, 308 | word_splitter: WasmWordSplitter, 309 | wrap_algorithm: WasmWrapAlgorithm, 310 | penalties: WasmPenalties, 311 | ) -> WasmOptions { 312 | WasmOptions { 313 | width, 314 | break_words, 315 | word_separator, 316 | word_splitter, 317 | wrap_algorithm, 318 | penalties, 319 | } 320 | } 321 | } 322 | 323 | #[wasm_bindgen] 324 | pub fn draw_wrapped_text( 325 | ctx: &web_sys::CanvasRenderingContext2d, 326 | options: &WasmOptions, 327 | text: &str, 328 | ) -> Result<(), JsValue> { 329 | console_error_panic_hook::set_once(); 330 | 331 | let metrics: web_sys::TextMetrics = ctx.measure_text("│").unwrap(); 332 | let metrics: ExtendedTextMetrics = metrics.unchecked_into(); 333 | // TODO: use metrics.font_bounding_box_ascent() + 334 | // metrics.font_bounding_box_descent() and measure "" instead of a 335 | // tall character when supported by Firefox. 336 | let line_height = metrics.actual_bounding_box_ascent() + metrics.actual_bounding_box_descent(); 337 | let baseline_distance = 1.5 * line_height; 338 | 339 | let word_separator = match options.word_separator { 340 | WasmWordSeparator::AsciiSpace => WordSeparator::AsciiSpace, 341 | WasmWordSeparator::UnicodeBreakProperties => WordSeparator::UnicodeBreakProperties, 342 | _ => Err("WasmOptions has an invalid word_separator field")?, 343 | }; 344 | 345 | let word_splitter = match options.word_splitter { 346 | WasmWordSplitter::NoHyphenation => WordSplitter::NoHyphenation, 347 | WasmWordSplitter::HyphenSplitter => WordSplitter::HyphenSplitter, 348 | _ => Err("WasmOptions has an invalid word_splitter field")?, 349 | }; 350 | 351 | let mut lineno = 0; 352 | for line in text.split('\n') { 353 | let words = word_separator.find_words(line); 354 | let split_words = split_words(words, &word_splitter); 355 | 356 | let canvas_words = split_words 357 | .flat_map(|word| { 358 | let canvas_word = CanvasWord::from(ctx, word); 359 | if options.break_words { 360 | canvas_word.break_apart(ctx, options.width) 361 | } else { 362 | vec![canvas_word] 363 | } 364 | }) 365 | .collect::>(); 366 | 367 | let line_lengths = [options.width]; 368 | let wrapped_words = match options.wrap_algorithm { 369 | WasmWrapAlgorithm::FirstFit => wrap_first_fit(&canvas_words, &line_lengths), 370 | WasmWrapAlgorithm::OptimalFit => { 371 | let penalties = options.penalties.into(); 372 | wrap_optimal_fit(&canvas_words, &line_lengths, &penalties).unwrap() 373 | } 374 | _ => Err("WasmOptions has an invalid wrap_algorithm field")?, 375 | }; 376 | 377 | for words_in_line in wrapped_words { 378 | lineno += 1; 379 | let mut x = X_OFFSET; 380 | let y = baseline_distance * lineno as f64; 381 | 382 | for (i, word) in words_in_line.iter().enumerate() { 383 | let last_word = i == words_in_line.len() - 1; 384 | draw_word(ctx, x, y, word, last_word)?; 385 | x += word.width; 386 | x += if last_word { 387 | word.penalty_width 388 | } else { 389 | word.whitespace_width 390 | }; 391 | } 392 | 393 | ctx.save(); 394 | ctx.set_font("10px sans-serif"); 395 | ctx.fill_text( 396 | &format!("{:.1}px", x - X_OFFSET), 397 | 1.5 * X_OFFSET + options.width, 398 | y, 399 | )?; 400 | ctx.restore(); 401 | } 402 | } 403 | 404 | draw_path( 405 | ctx, 406 | "blue", 407 | ( 408 | X_OFFSET + options.width, 409 | metrics.actual_bounding_box_ascent(), 410 | ), 411 | &[(0.0, baseline_distance * lineno as f64)], 412 | ); 413 | 414 | Ok(()) 415 | } 416 | -------------------------------------------------------------------------------- /examples/wasm/www/bootstrap.js: -------------------------------------------------------------------------------- 1 | // A dependency graph that contains any wasm must all be imported 2 | // asynchronously. This `bootstrap.js` file does the single async import, so 3 | // that no one else needs to worry about it again. 4 | import("./index.js").catch((e) => 5 | console.error("Error importing `index.js`:", e), 6 | ); 7 | -------------------------------------------------------------------------------- /examples/wasm/www/build-info.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /examples/wasm/www/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Textwrap WebAssembly demo 6 | 40 | 41 | 42 |

Textwrap WebAssembly Demo

43 | 44 | 49 | 50 |
51 |
52 | px 53 | 58 |
59 | 60 |
61 | 62 | px. 63 |
64 | 65 |
66 | 67 | 68 |
69 | 70 |
71 | 72 | 76 |
77 | 78 |
79 | 80 | 84 |
85 | 86 |
87 | 88 | 92 |
93 | 94 |
95 | 96 | 97 | 104 |
105 | 106 |
107 | 108 | 109 | 116 |
117 | 118 |
119 | 120 | 121 | 128 |
129 | 130 |
131 | 132 | 133 | 140 |
141 | 142 |
143 | 144 | 145 | 152 |
153 |
154 | 155 |
156 | 157 | 158 |
159 | 160 | 166 | 167 | 168 | 173 | 174 | 175 | -------------------------------------------------------------------------------- /examples/wasm/www/index.js: -------------------------------------------------------------------------------- 1 | import { 2 | draw_wrapped_text, 3 | WasmOptions, 4 | WasmPenalties, 5 | } from "textwrap-wasm-demo"; 6 | 7 | fetch("build-info.json") 8 | .then((response) => response.json()) 9 | .then((buildInfo) => { 10 | if (buildInfo.date && buildInfo.commit) { 11 | document.getElementById("build-date").innerText = buildInfo.date; 12 | 13 | let link = document.createElement("a"); 14 | link.href = `https://github.com/mgeisler/textwrap/commit/${buildInfo.commit}`; 15 | link.innerText = buildInfo.commit.slice(0, 7); 16 | document.getElementById("build-commit").replaceWith(link); 17 | } 18 | }); 19 | 20 | function redraw(event) { 21 | let fontSize = document.getElementById("font-size").valueAsNumber; 22 | let fontFamily = document.getElementById("font-family").value; 23 | let canvas = document.getElementById("canvas"); 24 | let ctx = canvas.getContext("2d"); 25 | 26 | ctx.clearRect(0, 0, canvas.width, canvas.height); 27 | ctx.font = `${fontSize}px ${fontFamily}`; 28 | 29 | let text = document.getElementById("text").value; 30 | let lineWidth = document.getElementById("line-width").valueAsNumber; 31 | let breakWords = document.getElementById("break-words").checked; 32 | let wordSeparator = document.getElementById("word-separator").value; 33 | let wordSplitter = document.getElementById("word-splitter").value; 34 | let wrapAlgorithm = document.getElementById("wrap-algorithm").value; 35 | let penalties = new WasmPenalties( 36 | document.getElementById("nline-penalty").valueAsNumber, 37 | document.getElementById("overflow-penalty").valueAsNumber, 38 | document.getElementById("short-line-fraction").valueAsNumber, 39 | document.getElementById("short-last-line-penalty").valueAsNumber, 40 | document.getElementById("hyphen-penalty").valueAsNumber, 41 | ); 42 | let options = new WasmOptions( 43 | lineWidth, 44 | breakWords, 45 | wordSeparator, 46 | wordSplitter, 47 | wrapAlgorithm, 48 | penalties, 49 | ); 50 | draw_wrapped_text(ctx, options, text, penalties); 51 | } 52 | 53 | document.getElementById("wrap-algorithm").addEventListener("input", (event) => { 54 | let disablePenaltiesParams = event.target.value == "FirstFit"; 55 | let rangeInputIds = [ 56 | "nline-penalty", 57 | "overflow-penalty", 58 | "short-line-fraction", 59 | "short-last-line-penalty", 60 | "hyphen-penalty", 61 | ]; 62 | rangeInputIds.forEach((rangeInputId) => { 63 | let rangeInput = document.getElementById(rangeInputId); 64 | let textInput = document.getElementById(`${rangeInputId}-text`); 65 | rangeInput.disabled = disablePenaltiesParams; 66 | textInput.disabled = disablePenaltiesParams; 67 | }); 68 | }); 69 | 70 | document.querySelectorAll("input[type=range]").forEach((rangeInput) => { 71 | let textInput = document.getElementById(`${rangeInput.id}-text`); 72 | textInput.min = rangeInput.min; 73 | textInput.max = rangeInput.max; 74 | textInput.value = rangeInput.value; 75 | 76 | rangeInput.addEventListener("input", (event) => { 77 | textInput.value = rangeInput.valueAsNumber; 78 | }); 79 | textInput.addEventListener("input", (event) => { 80 | rangeInput.value = textInput.valueAsNumber; 81 | }); 82 | }); 83 | 84 | document.querySelectorAll("textarea, select, input").forEach((elem) => { 85 | elem.addEventListener("input", redraw); 86 | }); 87 | 88 | window.addEventListener("resize", (event) => { 89 | const X_OFFSET = 8; // To accommodate the size of the slider knob. 90 | 91 | let footer = document.getElementById("footer"); 92 | let canvas = document.getElementById("canvas"); 93 | let width = canvas.parentNode.clientWidth; 94 | 95 | canvas.width = width; 96 | canvas.height = footer.offsetTop - canvas.offsetTop; 97 | 98 | let lineWidth = document.getElementById("line-width"); 99 | let lineWidthText = document.getElementById("line-width-text"); 100 | lineWidth.max = width - 2 * X_OFFSET; 101 | lineWidthText.max = width - 2 * X_OFFSET; 102 | lineWidth.style.width = `${width}px`; 103 | 104 | redraw(); 105 | }); 106 | 107 | let lineWidth = document.getElementById("line-width"); 108 | let lineWidthText = document.getElementById("line-width-text"); 109 | lineWidthText.value = lineWidth.valueAsNumber; 110 | window.dispatchEvent(new Event("resize")); 111 | -------------------------------------------------------------------------------- /examples/wasm/www/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "textwrap-wasm-demo-app", 3 | "version": "0.1.0", 4 | "description": "Textwrap WebAssembly demo application", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "webpack build", 8 | "start": "webpack serve" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/mgeisler/textwrap.git" 13 | }, 14 | "author": "Martin Geisler ", 15 | "license": "MIT", 16 | "bugs": { 17 | "url": "https://github.com/mgeisler/textwrap/issues" 18 | }, 19 | "homepage": "https://github.com/mgeisler/textwrap", 20 | "dependencies": { 21 | "textwrap-wasm-demo": "file:../pkg" 22 | }, 23 | "devDependencies": { 24 | "copy-webpack-plugin": "^12.0.2", 25 | "webpack": "^5.94.0", 26 | "webpack-cli": "^6.0.1", 27 | "webpack-dev-server": "^5.2.0" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /examples/wasm/www/webpack.config.js: -------------------------------------------------------------------------------- 1 | const CopyWebpackPlugin = require("copy-webpack-plugin"); 2 | const path = require("path"); 3 | 4 | module.exports = { 5 | entry: "./bootstrap.js", 6 | output: { 7 | path: path.resolve(__dirname, "dist"), 8 | filename: "bootstrap.js", 9 | }, 10 | mode: "development", 11 | devtool: "source-map", 12 | plugins: [ 13 | new CopyWebpackPlugin({ 14 | patterns: ["index.html", "../README.md", "build-info.json"], 15 | }), 16 | ], 17 | experiments: { 18 | syncWebAssembly: true, 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /fuzz/Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "arbitrary" 7 | version = "1.3.2" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "7d5a26814d8dcb93b0e5a0ff3c6d80a8843bafb21b39e8e18a6f05471870e110" 10 | dependencies = [ 11 | "derive_arbitrary", 12 | ] 13 | 14 | [[package]] 15 | name = "cc" 16 | version = "1.0.101" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "ac367972e516d45567c7eafc73d24e1c193dcf200a8d94e9db7b3d38b349572d" 19 | dependencies = [ 20 | "jobserver", 21 | "libc", 22 | "once_cell", 23 | ] 24 | 25 | [[package]] 26 | name = "derive_arbitrary" 27 | version = "1.3.2" 28 | source = "registry+https://github.com/rust-lang/crates.io-index" 29 | checksum = "67e77553c4162a157adbf834ebae5b415acbecbeafc7a74b0e886657506a7611" 30 | dependencies = [ 31 | "proc-macro2", 32 | "quote", 33 | "syn", 34 | ] 35 | 36 | [[package]] 37 | name = "jobserver" 38 | version = "0.1.31" 39 | source = "registry+https://github.com/rust-lang/crates.io-index" 40 | checksum = "d2b099aaa34a9751c5bf0878add70444e1ed2dd73f347be99003d4577277de6e" 41 | dependencies = [ 42 | "libc", 43 | ] 44 | 45 | [[package]] 46 | name = "libc" 47 | version = "0.2.155" 48 | source = "registry+https://github.com/rust-lang/crates.io-index" 49 | checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" 50 | 51 | [[package]] 52 | name = "libfuzzer-sys" 53 | version = "0.4.7" 54 | source = "registry+https://github.com/rust-lang/crates.io-index" 55 | checksum = "a96cfd5557eb82f2b83fed4955246c988d331975a002961b07c81584d107e7f7" 56 | dependencies = [ 57 | "arbitrary", 58 | "cc", 59 | "once_cell", 60 | ] 61 | 62 | [[package]] 63 | name = "once_cell" 64 | version = "1.19.0" 65 | source = "registry+https://github.com/rust-lang/crates.io-index" 66 | checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" 67 | 68 | [[package]] 69 | name = "proc-macro2" 70 | version = "1.0.86" 71 | source = "registry+https://github.com/rust-lang/crates.io-index" 72 | checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" 73 | dependencies = [ 74 | "unicode-ident", 75 | ] 76 | 77 | [[package]] 78 | name = "quote" 79 | version = "1.0.36" 80 | source = "registry+https://github.com/rust-lang/crates.io-index" 81 | checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" 82 | dependencies = [ 83 | "proc-macro2", 84 | ] 85 | 86 | [[package]] 87 | name = "smawk" 88 | version = "0.3.2" 89 | source = "registry+https://github.com/rust-lang/crates.io-index" 90 | checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" 91 | 92 | [[package]] 93 | name = "syn" 94 | version = "2.0.68" 95 | source = "registry+https://github.com/rust-lang/crates.io-index" 96 | checksum = "901fa70d88b9d6c98022e23b4136f9f3e54e4662c3bc1bd1d84a42a9a0f0c1e9" 97 | dependencies = [ 98 | "proc-macro2", 99 | "quote", 100 | "unicode-ident", 101 | ] 102 | 103 | [[package]] 104 | name = "textwrap" 105 | version = "0.16.1" 106 | dependencies = [ 107 | "smawk", 108 | "unicode-linebreak", 109 | "unicode-width", 110 | ] 111 | 112 | [[package]] 113 | name = "textwrap-fuzz" 114 | version = "0.0.0" 115 | dependencies = [ 116 | "arbitrary", 117 | "libfuzzer-sys", 118 | "textwrap", 119 | ] 120 | 121 | [[package]] 122 | name = "unicode-ident" 123 | version = "1.0.12" 124 | source = "registry+https://github.com/rust-lang/crates.io-index" 125 | checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" 126 | 127 | [[package]] 128 | name = "unicode-linebreak" 129 | version = "0.1.5" 130 | source = "registry+https://github.com/rust-lang/crates.io-index" 131 | checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" 132 | 133 | [[package]] 134 | name = "unicode-width" 135 | version = "0.1.13" 136 | source = "registry+https://github.com/rust-lang/crates.io-index" 137 | checksum = "0336d538f7abc86d282a4189614dfaa90810dfc2c6f6427eaf88e16311dd225d" 138 | -------------------------------------------------------------------------------- /fuzz/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "textwrap-fuzz" 3 | version = "0.0.0" 4 | authors = ["Automatically generated"] 5 | edition = "2021" 6 | publish = false 7 | 8 | [package.metadata] 9 | cargo-fuzz = true 10 | 11 | [dependencies] 12 | arbitrary = { version = "1.0.3", features = ["derive"] } 13 | libfuzzer-sys = "0.4.2" 14 | textwrap = { path = ".." } 15 | 16 | # Prevent this from interfering with workspaces 17 | [workspace] 18 | members = ["."] 19 | 20 | [[bin]] 21 | name = "fill_first_fit" 22 | path = "fuzz_targets/fill_first_fit.rs" 23 | test = false 24 | doc = false 25 | 26 | [[bin]] 27 | name = "wrap_first_fit" 28 | path = "fuzz_targets/wrap_first_fit.rs" 29 | test = false 30 | doc = false 31 | 32 | [[bin]] 33 | name = "fill_optimal_fit" 34 | path = "fuzz_targets/fill_optimal_fit.rs" 35 | test = false 36 | doc = false 37 | 38 | [[bin]] 39 | name = "wrap_optimal_fit" 40 | path = "fuzz_targets/wrap_optimal_fit.rs" 41 | test = false 42 | doc = false 43 | 44 | [[bin]] 45 | name = "wrap_optimal_fit_usize" 46 | path = "fuzz_targets/wrap_optimal_fit_usize.rs" 47 | test = false 48 | doc = false 49 | 50 | [[bin]] 51 | name = "refill" 52 | path = "fuzz_targets/refill.rs" 53 | test = false 54 | doc = false 55 | 56 | [[bin]] 57 | name = "unfill" 58 | path = "fuzz_targets/unfill.rs" 59 | test = false 60 | doc = false 61 | 62 | [[bin]] 63 | name = "fill_fast_path" 64 | path = "fuzz_targets/fill_fast_path.rs" 65 | test = false 66 | doc = false 67 | 68 | [[bin]] 69 | name = "wrap_fast_path" 70 | path = "fuzz_targets/wrap_fast_path.rs" 71 | test = false 72 | doc = false 73 | -------------------------------------------------------------------------------- /fuzz/fuzz_targets/fill_fast_path.rs: -------------------------------------------------------------------------------- 1 | #![no_main] 2 | use libfuzzer_sys::fuzz_target; 3 | 4 | fuzz_target!(|input: (String, usize)| { 5 | if input.0.len() > 100_000 { 6 | return; // Avoid timeouts in OSS-Fuzz. 7 | } 8 | 9 | let options = textwrap::Options::new(input.1); 10 | let fast = textwrap::fill(&input.0, &options); 11 | let slow = textwrap::fuzzing::fill_slow_path(&input.0, options); 12 | assert_eq!(fast, slow); 13 | }); 14 | -------------------------------------------------------------------------------- /fuzz/fuzz_targets/fill_first_fit.rs: -------------------------------------------------------------------------------- 1 | #![no_main] 2 | use libfuzzer_sys::fuzz_target; 3 | use textwrap::{Options, WrapAlgorithm}; 4 | 5 | fuzz_target!(|input: (String, usize)| { 6 | if input.0.len() > 100_000 { 7 | return; // Avoid timeouts in OSS-Fuzz. 8 | } 9 | 10 | let options = Options::new(input.1).wrap_algorithm(WrapAlgorithm::FirstFit); 11 | let _ = textwrap::fill(&input.0, &options); 12 | }); 13 | -------------------------------------------------------------------------------- /fuzz/fuzz_targets/fill_optimal_fit.rs: -------------------------------------------------------------------------------- 1 | #![no_main] 2 | use libfuzzer_sys::fuzz_target; 3 | use textwrap::{Options, WrapAlgorithm}; 4 | 5 | fuzz_target!(|input: (String, usize)| { 6 | if input.0.len() > 100_000 { 7 | return; // Avoid timeouts in OSS-Fuzz. 8 | } 9 | 10 | let options = Options::new(input.1).wrap_algorithm(WrapAlgorithm::new_optimal_fit()); 11 | let _ = textwrap::fill(&input.0, &options); 12 | }); 13 | -------------------------------------------------------------------------------- /fuzz/fuzz_targets/refill.rs: -------------------------------------------------------------------------------- 1 | #![no_main] 2 | use libfuzzer_sys::fuzz_target; 3 | 4 | fuzz_target!(|input: (String, usize)| { 5 | if input.0.len() > 100_000 { 6 | return; // Avoid timeouts in OSS-Fuzz. 7 | } 8 | 9 | let (_, options) = textwrap::unfill(&input.0); 10 | if options.subsequent_indent.len() > 10_000 { 11 | // Avoid out of memory in OSS-fuzz. The indentation is added 12 | // on every line of the output, meaning that is can make the 13 | // memory usage explode. 14 | return; 15 | } 16 | 17 | let _ = textwrap::refill(&input.0, input.1); 18 | }); 19 | -------------------------------------------------------------------------------- /fuzz/fuzz_targets/unfill.rs: -------------------------------------------------------------------------------- 1 | #![no_main] 2 | use libfuzzer_sys::fuzz_target; 3 | 4 | fuzz_target!(|input: String| { 5 | if input.len() > 100_000 { 6 | return; // Avoid timeouts in OSS-Fuzz. 7 | } 8 | 9 | let _ = textwrap::unfill(&input); 10 | }); 11 | -------------------------------------------------------------------------------- /fuzz/fuzz_targets/wrap_fast_path.rs: -------------------------------------------------------------------------------- 1 | #![no_main] 2 | use libfuzzer_sys::fuzz_target; 3 | 4 | fuzz_target!(|input: (String, usize)| { 5 | if input.0.len() > 100_000 { 6 | return; // Avoid timeouts in OSS-Fuzz. 7 | } 8 | 9 | if input.0.contains('\n') { 10 | return; // Filter out multi-line input. 11 | } 12 | 13 | let options = textwrap::Options::new(input.1); 14 | let mut fast = Vec::new(); 15 | let mut slow = Vec::new(); 16 | textwrap::fuzzing::wrap_single_line(&input.0, &options, &mut fast); 17 | textwrap::fuzzing::wrap_single_line_slow_path(&input.0, &options, &mut slow); 18 | assert_eq!(fast, slow); 19 | }); 20 | -------------------------------------------------------------------------------- /fuzz/fuzz_targets/wrap_first_fit.rs: -------------------------------------------------------------------------------- 1 | #![no_main] 2 | use arbitrary::Arbitrary; 3 | use libfuzzer_sys::fuzz_target; 4 | use textwrap::core; 5 | use textwrap::wrap_algorithms::wrap_first_fit; 6 | 7 | #[derive(Arbitrary, Debug, PartialEq)] 8 | struct Word { 9 | width: f64, 10 | whitespace_width: f64, 11 | penalty_width: f64, 12 | } 13 | 14 | #[rustfmt::skip] 15 | impl core::Fragment for Word { 16 | fn width(&self) -> f64 { self.width } 17 | fn whitespace_width(&self) -> f64 { self.whitespace_width } 18 | fn penalty_width(&self) -> f64 { self.penalty_width } 19 | } 20 | 21 | fuzz_target!(|input: (f64, Vec)| { 22 | let width = input.0; 23 | let words = input.1; 24 | let _ = wrap_first_fit(&words, &[width]); 25 | }); 26 | -------------------------------------------------------------------------------- /fuzz/fuzz_targets/wrap_optimal_fit.rs: -------------------------------------------------------------------------------- 1 | #![no_main] 2 | use arbitrary::Arbitrary; 3 | use libfuzzer_sys::fuzz_target; 4 | use textwrap::wrap_algorithms::wrap_optimal_fit; 5 | use textwrap::{core, wrap_algorithms}; 6 | 7 | #[derive(Arbitrary, Debug)] 8 | struct Penalties { 9 | nline_penalty: usize, 10 | overflow_penalty: usize, 11 | short_last_line_fraction: usize, 12 | short_last_line_penalty: usize, 13 | hyphen_penalty: usize, 14 | } 15 | 16 | impl Into for Penalties { 17 | fn into(self) -> wrap_algorithms::Penalties { 18 | wrap_algorithms::Penalties { 19 | nline_penalty: self.nline_penalty, 20 | overflow_penalty: self.overflow_penalty, 21 | short_last_line_fraction: std::cmp::max(1, self.short_last_line_fraction), 22 | short_last_line_penalty: self.short_last_line_penalty, 23 | hyphen_penalty: self.hyphen_penalty, 24 | } 25 | } 26 | } 27 | 28 | #[derive(Arbitrary, Debug, PartialEq)] 29 | struct Word { 30 | width: f64, 31 | whitespace_width: f64, 32 | penalty_width: f64, 33 | } 34 | 35 | #[rustfmt::skip] 36 | impl core::Fragment for Word { 37 | fn width(&self) -> f64 { self.width } 38 | fn whitespace_width(&self) -> f64 { self.whitespace_width } 39 | fn penalty_width(&self) -> f64 { self.penalty_width } 40 | } 41 | 42 | // Check wrapping fragments with mostly arbitrary widths. Infinite 43 | // widths are not supported since they instantly trigger an overflow 44 | // in the cost computation. Similarly for very large values: the 1e100 45 | // bound used here is somewhat conservative, the real bound seems to 46 | // be around 1e170. 47 | fuzz_target!(|input: (usize, Vec, Penalties)| { 48 | let width = input.0; 49 | let words = input.1; 50 | let penalties = input.2.into(); 51 | 52 | for word in &words { 53 | for width in [word.width, word.whitespace_width, word.penalty_width] { 54 | if !width.is_finite() || width.abs() > 1e100 { 55 | return; 56 | } 57 | } 58 | } 59 | 60 | let _ = wrap_optimal_fit(&words, &[width as f64], &penalties); 61 | }); 62 | -------------------------------------------------------------------------------- /fuzz/fuzz_targets/wrap_optimal_fit_usize.rs: -------------------------------------------------------------------------------- 1 | #![no_main] 2 | use arbitrary::Arbitrary; 3 | use libfuzzer_sys::fuzz_target; 4 | use textwrap::wrap_algorithms::wrap_optimal_fit; 5 | use textwrap::{core, wrap_algorithms}; 6 | 7 | #[derive(Arbitrary, Debug)] 8 | struct Penalties { 9 | nline_penalty: usize, 10 | overflow_penalty: usize, 11 | short_last_line_fraction: usize, 12 | short_last_line_penalty: usize, 13 | hyphen_penalty: usize, 14 | } 15 | 16 | impl Into for Penalties { 17 | fn into(self) -> wrap_algorithms::Penalties { 18 | wrap_algorithms::Penalties { 19 | nline_penalty: self.nline_penalty, 20 | overflow_penalty: self.overflow_penalty, 21 | short_last_line_fraction: std::cmp::max(1, self.short_last_line_fraction), 22 | short_last_line_penalty: self.short_last_line_penalty, 23 | hyphen_penalty: self.hyphen_penalty, 24 | } 25 | } 26 | } 27 | 28 | #[derive(Arbitrary, Debug, PartialEq)] 29 | struct Word { 30 | width: usize, 31 | whitespace_width: usize, 32 | penalty_width: usize, 33 | } 34 | 35 | #[rustfmt::skip] 36 | impl core::Fragment for Word { 37 | fn width(&self) -> f64 { self.width as f64 } 38 | fn whitespace_width(&self) -> f64 { self.whitespace_width as f64 } 39 | fn penalty_width(&self) -> f64 { self.penalty_width as f64 } 40 | } 41 | 42 | // Check wrapping fragments generated with integer widths. These 43 | // fragments are of the same form as the ones generated by wrap. 44 | fuzz_target!(|input: (usize, Vec, Penalties)| { 45 | let width = input.0; 46 | let words = input.1; 47 | let penalties = input.2.into(); 48 | let _ = wrap_optimal_fit(&words, &[width as f64], &penalties); 49 | }); 50 | -------------------------------------------------------------------------------- /images/textwrap-0.13.2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | textwrap 6 | 7 | smawk 8 | 9 | 10 | 11 | 12 | 13 | unicode-width 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /images/textwrap-0.13.3.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | textwrap 6 | 7 | smawk 8 | 9 | 10 | 11 | 12 | 13 | unicode-width 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /images/textwrap-0.13.4.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | textwrap 6 | 7 | smawk 8 | 9 | 10 | 11 | 12 | 13 | unicode-width 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /images/textwrap-0.14.0.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | textwrap 6 | 7 | smawk 8 | 9 | 10 | 11 | 12 | 13 | unicode-linebreak 14 | 15 | 16 | 17 | unicode-width 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /images/textwrap-0.14.1.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | textwrap 6 | 7 | smawk 8 | 9 | 10 | 11 | 12 | 13 | unicode-linebreak 14 | 15 | 16 | 17 | unicode-width 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /images/textwrap-0.14.2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | textwrap 6 | 7 | smawk 8 | 9 | 10 | 11 | 12 | 13 | unicode-linebreak 14 | 15 | 16 | 17 | unicode-width 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /images/textwrap-0.15.0.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | textwrap 6 | 7 | smawk 8 | 9 | 10 | 11 | 12 | 13 | unicode-linebreak 14 | 15 | 16 | 17 | unicode-width 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /images/textwrap-0.15.1.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | textwrap 6 | 7 | smawk 8 | 9 | 10 | 11 | 12 | 13 | unicode-linebreak 14 | 15 | 16 | 17 | unicode-width 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /images/textwrap-0.15.2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | textwrap 6 | 7 | smawk 8 | 9 | 10 | 11 | 12 | 13 | unicode-linebreak 14 | 15 | 16 | 17 | unicode-width 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /images/textwrap-0.16.0.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | textwrap 6 | 7 | smawk 8 | 9 | 10 | 11 | 12 | 13 | unicode-linebreak 14 | 15 | 16 | 17 | unicode-width 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /images/textwrap-0.16.1.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | textwrap 6 | 7 | smawk 8 | 9 | 10 | 11 | 12 | 13 | unicode-linebreak 14 | 15 | 16 | 17 | unicode-width 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /images/textwrap-0.16.2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | textwrap 6 | 7 | smawk 8 | 9 | 10 | 11 | 12 | 13 | unicode-linebreak 14 | 15 | 16 | 17 | unicode-width 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | imports_granularity = "Module" 2 | -------------------------------------------------------------------------------- /src/columns.rs: -------------------------------------------------------------------------------- 1 | //! Functionality for wrapping text into columns. 2 | 3 | use crate::core::display_width; 4 | use crate::{wrap, Options}; 5 | 6 | /// Wrap text into columns with a given total width. 7 | /// 8 | /// The `left_gap`, `middle_gap` and `right_gap` arguments specify the 9 | /// strings to insert before, between, and after the columns. The 10 | /// total width of all columns and all gaps is specified using the 11 | /// `total_width_or_options` argument. This argument can simply be an 12 | /// integer if you want to use default settings when wrapping, or it 13 | /// can be a [`Options`] value if you want to customize the wrapping. 14 | /// 15 | /// If the columns are narrow, it is recommended to set 16 | /// [`Options::break_words`] to `true` to prevent words from 17 | /// protruding into the margins. 18 | /// 19 | /// The per-column width is computed like this: 20 | /// 21 | /// ``` 22 | /// # let (left_gap, middle_gap, right_gap) = ("", "", ""); 23 | /// # let columns = 2; 24 | /// # let options = textwrap::Options::new(80); 25 | /// let inner_width = options.width 26 | /// - textwrap::core::display_width(left_gap) 27 | /// - textwrap::core::display_width(right_gap) 28 | /// - textwrap::core::display_width(middle_gap) * (columns - 1); 29 | /// let column_width = inner_width / columns; 30 | /// ``` 31 | /// 32 | /// The `text` is wrapped using [`wrap()`] and the given `options` 33 | /// argument, but the width is overwritten to the computed 34 | /// `column_width`. 35 | /// 36 | /// # Panics 37 | /// 38 | /// Panics if `columns` is zero. 39 | /// 40 | /// # Examples 41 | /// 42 | /// ``` 43 | /// use textwrap::wrap_columns; 44 | /// 45 | /// let text = "\ 46 | /// This is an example text, which is wrapped into three columns. \ 47 | /// Notice how the final column can be shorter than the others."; 48 | /// 49 | /// #[cfg(feature = "smawk")] 50 | /// assert_eq!(wrap_columns(text, 3, 50, "| ", " | ", " |"), 51 | /// vec!["| This is | into three | column can be |", 52 | /// "| an example | columns. | shorter than |", 53 | /// "| text, which | Notice how | the others. |", 54 | /// "| is wrapped | the final | |"]); 55 | /// 56 | /// // Without the `smawk` feature, the middle column is a little more uneven: 57 | /// #[cfg(not(feature = "smawk"))] 58 | /// assert_eq!(wrap_columns(text, 3, 50, "| ", " | ", " |"), 59 | /// vec!["| This is an | three | column can be |", 60 | /// "| example text, | columns. | shorter than |", 61 | /// "| which is | Notice how | the others. |", 62 | /// "| wrapped into | the final | |"]); 63 | pub fn wrap_columns<'a, Opt>( 64 | text: &str, 65 | columns: usize, 66 | total_width_or_options: Opt, 67 | left_gap: &str, 68 | middle_gap: &str, 69 | right_gap: &str, 70 | ) -> Vec 71 | where 72 | Opt: Into>, 73 | { 74 | assert!(columns > 0); 75 | 76 | let mut options: Options = total_width_or_options.into(); 77 | 78 | let inner_width = options 79 | .width 80 | .saturating_sub(display_width(left_gap)) 81 | .saturating_sub(display_width(right_gap)) 82 | .saturating_sub(display_width(middle_gap) * (columns - 1)); 83 | 84 | let column_width = std::cmp::max(inner_width / columns, 1); 85 | options.width = column_width; 86 | let last_column_padding = " ".repeat(inner_width % column_width); 87 | let wrapped_lines = wrap(text, options); 88 | let lines_per_column = 89 | wrapped_lines.len() / columns + usize::from(wrapped_lines.len() % columns > 0); 90 | let mut lines = Vec::new(); 91 | for line_no in 0..lines_per_column { 92 | let mut line = String::from(left_gap); 93 | for column_no in 0..columns { 94 | match wrapped_lines.get(line_no + column_no * lines_per_column) { 95 | Some(column_line) => { 96 | line.push_str(column_line); 97 | line.push_str(&" ".repeat(column_width - display_width(column_line))); 98 | } 99 | None => { 100 | line.push_str(&" ".repeat(column_width)); 101 | } 102 | } 103 | if column_no == columns - 1 { 104 | line.push_str(&last_column_padding); 105 | } else { 106 | line.push_str(middle_gap); 107 | } 108 | } 109 | line.push_str(right_gap); 110 | lines.push(line); 111 | } 112 | 113 | lines 114 | } 115 | 116 | #[cfg(test)] 117 | mod tests { 118 | use super::*; 119 | 120 | #[test] 121 | fn wrap_columns_empty_text() { 122 | assert_eq!(wrap_columns("", 1, 10, "| ", "", " |"), vec!["| |"]); 123 | } 124 | 125 | #[test] 126 | fn wrap_columns_single_column() { 127 | assert_eq!( 128 | wrap_columns("Foo", 3, 30, "| ", " | ", " |"), 129 | vec!["| Foo | | |"] 130 | ); 131 | } 132 | 133 | #[test] 134 | fn wrap_columns_uneven_columns() { 135 | // The gaps take up a total of 5 columns, so the columns are 136 | // (21 - 5)/4 = 4 columns wide: 137 | assert_eq!( 138 | wrap_columns("Foo Bar Baz Quux", 4, 21, "|", "|", "|"), 139 | vec!["|Foo |Bar |Baz |Quux|"] 140 | ); 141 | // As the total width increases, the last column absorbs the 142 | // excess width: 143 | assert_eq!( 144 | wrap_columns("Foo Bar Baz Quux", 4, 24, "|", "|", "|"), 145 | vec!["|Foo |Bar |Baz |Quux |"] 146 | ); 147 | // Finally, when the width is 25, the columns can be resized 148 | // to a width of (25 - 5)/4 = 5 columns: 149 | assert_eq!( 150 | wrap_columns("Foo Bar Baz Quux", 4, 25, "|", "|", "|"), 151 | vec!["|Foo |Bar |Baz |Quux |"] 152 | ); 153 | } 154 | 155 | #[test] 156 | #[cfg(feature = "unicode-width")] 157 | fn wrap_columns_with_emojis() { 158 | assert_eq!( 159 | wrap_columns( 160 | "Words and a few emojis 😍 wrapped in ⓶ columns", 161 | 2, 162 | 30, 163 | "✨ ", 164 | " ⚽ ", 165 | " 👀" 166 | ), 167 | vec![ 168 | "✨ Words ⚽ wrapped in 👀", 169 | "✨ and a few ⚽ ⓶ columns 👀", 170 | "✨ emojis 😍 ⚽ 👀" 171 | ] 172 | ); 173 | } 174 | 175 | #[test] 176 | fn wrap_columns_big_gaps() { 177 | // The column width shrinks to 1 because the gaps take up all 178 | // the space. 179 | assert_eq!( 180 | wrap_columns("xyz", 2, 10, "----> ", " !!! ", " <----"), 181 | vec![ 182 | "----> x !!! z <----", // 183 | "----> y !!! <----" 184 | ] 185 | ); 186 | } 187 | 188 | #[test] 189 | #[should_panic] 190 | fn wrap_columns_panic_with_zero_columns() { 191 | wrap_columns("", 0, 10, "", "", ""); 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /src/fill.rs: -------------------------------------------------------------------------------- 1 | //! Functions for filling text. 2 | 3 | use crate::{wrap, wrap_algorithms, Options, WordSeparator}; 4 | 5 | /// Fill a line of text at a given width. 6 | /// 7 | /// The result is a [`String`], complete with newlines between each 8 | /// line. Use [`wrap()`] if you need access to the individual lines. 9 | /// 10 | /// The easiest way to use this function is to pass an integer for 11 | /// `width_or_options`: 12 | /// 13 | /// ``` 14 | /// use textwrap::fill; 15 | /// 16 | /// assert_eq!( 17 | /// fill("Memory safety without garbage collection.", 15), 18 | /// "Memory safety\nwithout garbage\ncollection." 19 | /// ); 20 | /// ``` 21 | /// 22 | /// If you need to customize the wrapping, you can pass an [`Options`] 23 | /// instead of an `usize`: 24 | /// 25 | /// ``` 26 | /// use textwrap::{fill, Options}; 27 | /// 28 | /// let options = Options::new(15) 29 | /// .initial_indent("- ") 30 | /// .subsequent_indent(" "); 31 | /// assert_eq!( 32 | /// fill("Memory safety without garbage collection.", &options), 33 | /// "- Memory safety\n without\n garbage\n collection." 34 | /// ); 35 | /// ``` 36 | pub fn fill<'a, Opt>(text: &str, width_or_options: Opt) -> String 37 | where 38 | Opt: Into>, 39 | { 40 | let options = width_or_options.into(); 41 | 42 | if text.len() < options.width && !text.contains('\n') && options.initial_indent.is_empty() { 43 | String::from(text.trim_end_matches(' ')) 44 | } else { 45 | fill_slow_path(text, options) 46 | } 47 | } 48 | 49 | /// Slow path for fill. 50 | /// 51 | /// This is taken when `text` is longer than `options.width`. 52 | pub(crate) fn fill_slow_path(text: &str, options: Options<'_>) -> String { 53 | // This will avoid reallocation in simple cases (no 54 | // indentation, no hyphenation). 55 | let mut result = String::with_capacity(text.len()); 56 | 57 | let line_ending_str = options.line_ending.as_str(); 58 | for (i, line) in wrap(text, options).iter().enumerate() { 59 | if i > 0 { 60 | result.push_str(line_ending_str); 61 | } 62 | result.push_str(line); 63 | } 64 | 65 | result 66 | } 67 | 68 | /// Fill `text` in-place without reallocating the input string. 69 | /// 70 | /// This function works by modifying the input string: some `' '` 71 | /// characters will be replaced by `'\n'` characters. The rest of the 72 | /// text remains untouched. 73 | /// 74 | /// Since we can only replace existing whitespace in the input with 75 | /// `'\n'` (there is no space for `"\r\n"`), we cannot do hyphenation 76 | /// nor can we split words longer than the line width. We also need to 77 | /// use `AsciiSpace` as the word separator since we need `' '` 78 | /// characters between words in order to replace some of them with a 79 | /// `'\n'`. Indentation is also ruled out. In other words, 80 | /// `fill_inplace(width)` behaves as if you had called [`fill()`] with 81 | /// these options: 82 | /// 83 | /// ``` 84 | /// # use textwrap::{core, LineEnding, Options, WordSplitter, WordSeparator, WrapAlgorithm}; 85 | /// # let width = 80; 86 | /// Options::new(width) 87 | /// .break_words(false) 88 | /// .line_ending(LineEnding::LF) 89 | /// .word_separator(WordSeparator::AsciiSpace) 90 | /// .wrap_algorithm(WrapAlgorithm::FirstFit) 91 | /// .word_splitter(WordSplitter::NoHyphenation); 92 | /// ``` 93 | /// 94 | /// The wrap algorithm is 95 | /// [`WrapAlgorithm::FirstFit`](crate::WrapAlgorithm::FirstFit) since 96 | /// this is the fastest algorithm — and the main reason to use 97 | /// `fill_inplace` is to get the string broken into newlines as fast 98 | /// as possible. 99 | /// 100 | /// A last difference is that (unlike [`fill()`]) `fill_inplace` can 101 | /// leave trailing whitespace on lines. This is because we wrap by 102 | /// inserting a `'\n'` at the final whitespace in the input string: 103 | /// 104 | /// ``` 105 | /// let mut text = String::from("Hello World!"); 106 | /// textwrap::fill_inplace(&mut text, 10); 107 | /// assert_eq!(text, "Hello \nWorld!"); 108 | /// ``` 109 | /// 110 | /// If we didn't do this, the word `World!` would end up being 111 | /// indented. You can avoid this if you make sure that your input text 112 | /// has no double spaces. 113 | /// 114 | /// # Performance 115 | /// 116 | /// In benchmarks, `fill_inplace` is about twice as fast as 117 | /// [`fill()`]. Please see the [`linear` 118 | /// benchmark](https://github.com/mgeisler/textwrap/blob/master/benchmarks/linear.rs) 119 | /// for details. 120 | pub fn fill_inplace(text: &mut String, width: usize) { 121 | let mut indices = Vec::new(); 122 | 123 | let mut offset = 0; 124 | for line in text.split('\n') { 125 | let words = WordSeparator::AsciiSpace 126 | .find_words(line) 127 | .collect::>(); 128 | let wrapped_words = wrap_algorithms::wrap_first_fit(&words, &[width as f64]); 129 | 130 | let mut line_offset = offset; 131 | for words in &wrapped_words[..wrapped_words.len() - 1] { 132 | let line_len = words 133 | .iter() 134 | .map(|word| word.len() + word.whitespace.len()) 135 | .sum::(); 136 | 137 | line_offset += line_len; 138 | // We've advanced past all ' ' characters -- want to move 139 | // one ' ' backwards and insert our '\n' there. 140 | indices.push(line_offset - 1); 141 | } 142 | 143 | // Advance past entire line, plus the '\n' which was removed 144 | // by the split call above. 145 | offset += line.len() + 1; 146 | } 147 | 148 | let mut bytes = std::mem::take(text).into_bytes(); 149 | for idx in indices { 150 | bytes[idx] = b'\n'; 151 | } 152 | *text = String::from_utf8(bytes).unwrap(); 153 | } 154 | 155 | #[cfg(test)] 156 | mod tests { 157 | use super::*; 158 | use crate::WrapAlgorithm; 159 | 160 | #[test] 161 | fn fill_simple() { 162 | assert_eq!(fill("foo bar baz", 10), "foo bar\nbaz"); 163 | } 164 | 165 | #[test] 166 | fn fill_unicode_boundary() { 167 | // https://github.com/mgeisler/textwrap/issues/390 168 | fill("\u{1b}!Ͽ", 10); 169 | } 170 | 171 | #[test] 172 | fn non_breaking_space() { 173 | let options = Options::new(5).break_words(false); 174 | assert_eq!(fill("foo bar baz", &options), "foo bar baz"); 175 | } 176 | 177 | #[test] 178 | fn non_breaking_hyphen() { 179 | let options = Options::new(5).break_words(false); 180 | assert_eq!(fill("foo‑bar‑baz", &options), "foo‑bar‑baz"); 181 | } 182 | 183 | #[test] 184 | fn fill_preserves_line_breaks_trims_whitespace() { 185 | assert_eq!(fill(" ", 80), ""); 186 | assert_eq!(fill(" \n ", 80), "\n"); 187 | assert_eq!(fill(" \n \n \n ", 80), "\n\n\n"); 188 | } 189 | 190 | #[test] 191 | fn preserve_line_breaks() { 192 | assert_eq!(fill("", 80), ""); 193 | assert_eq!(fill("\n", 80), "\n"); 194 | assert_eq!(fill("\n\n\n", 80), "\n\n\n"); 195 | assert_eq!(fill("test\n", 80), "test\n"); 196 | assert_eq!(fill("test\n\na\n\n", 80), "test\n\na\n\n"); 197 | assert_eq!( 198 | fill( 199 | "1 3 5 7\n1 3 5 7", 200 | Options::new(7).wrap_algorithm(WrapAlgorithm::FirstFit) 201 | ), 202 | "1 3 5 7\n1 3 5 7" 203 | ); 204 | assert_eq!( 205 | fill( 206 | "1 3 5 7\n1 3 5 7", 207 | Options::new(5).wrap_algorithm(WrapAlgorithm::FirstFit) 208 | ), 209 | "1 3 5\n7\n1 3 5\n7" 210 | ); 211 | } 212 | 213 | #[test] 214 | fn break_words_line_breaks() { 215 | assert_eq!(fill("ab\ncdefghijkl", 5), "ab\ncdefg\nhijkl"); 216 | assert_eq!(fill("abcdefgh\nijkl", 5), "abcde\nfgh\nijkl"); 217 | } 218 | 219 | #[test] 220 | fn break_words_empty_lines() { 221 | assert_eq!( 222 | fill("foo\nbar", &Options::new(2).break_words(false)), 223 | "foo\nbar" 224 | ); 225 | } 226 | 227 | #[test] 228 | fn fill_inplace_empty() { 229 | let mut text = String::from(""); 230 | fill_inplace(&mut text, 80); 231 | assert_eq!(text, ""); 232 | } 233 | 234 | #[test] 235 | fn fill_inplace_simple() { 236 | let mut text = String::from("foo bar baz"); 237 | fill_inplace(&mut text, 10); 238 | assert_eq!(text, "foo bar\nbaz"); 239 | } 240 | 241 | #[test] 242 | fn fill_inplace_multiple_lines() { 243 | let mut text = String::from("Some text to wrap over multiple lines"); 244 | fill_inplace(&mut text, 12); 245 | assert_eq!(text, "Some text to\nwrap over\nmultiple\nlines"); 246 | } 247 | 248 | #[test] 249 | fn fill_inplace_long_word() { 250 | let mut text = String::from("Internationalization is hard"); 251 | fill_inplace(&mut text, 10); 252 | assert_eq!(text, "Internationalization\nis hard"); 253 | } 254 | 255 | #[test] 256 | fn fill_inplace_no_hyphen_splitting() { 257 | let mut text = String::from("A well-chosen example"); 258 | fill_inplace(&mut text, 10); 259 | assert_eq!(text, "A\nwell-chosen\nexample"); 260 | } 261 | 262 | #[test] 263 | fn fill_inplace_newlines() { 264 | let mut text = String::from("foo bar\n\nbaz\n\n\n"); 265 | fill_inplace(&mut text, 10); 266 | assert_eq!(text, "foo bar\n\nbaz\n\n\n"); 267 | } 268 | 269 | #[test] 270 | fn fill_inplace_newlines_reset_line_width() { 271 | let mut text = String::from("1 3 5\n1 3 5 7 9\n1 3 5 7 9 1 3"); 272 | fill_inplace(&mut text, 10); 273 | assert_eq!(text, "1 3 5\n1 3 5 7 9\n1 3 5 7 9\n1 3"); 274 | } 275 | 276 | #[test] 277 | fn fill_inplace_leading_whitespace() { 278 | let mut text = String::from(" foo bar baz"); 279 | fill_inplace(&mut text, 10); 280 | assert_eq!(text, " foo bar\nbaz"); 281 | } 282 | 283 | #[test] 284 | fn fill_inplace_trailing_whitespace() { 285 | let mut text = String::from("foo bar baz "); 286 | fill_inplace(&mut text, 10); 287 | assert_eq!(text, "foo bar\nbaz "); 288 | } 289 | 290 | #[test] 291 | fn fill_inplace_interior_whitespace() { 292 | // To avoid an unwanted indentation of "baz", it is important 293 | // to replace the final ' ' with '\n'. 294 | let mut text = String::from("foo bar baz"); 295 | fill_inplace(&mut text, 10); 296 | assert_eq!(text, "foo bar \nbaz"); 297 | } 298 | } 299 | -------------------------------------------------------------------------------- /src/fuzzing.rs: -------------------------------------------------------------------------------- 1 | //! Fuzzing helpers. 2 | 3 | use super::Options; 4 | use std::borrow::Cow; 5 | 6 | /// Exposed for fuzzing so we can check the slow path is correct. 7 | pub fn fill_slow_path<'a>(text: &str, options: Options<'_>) -> String { 8 | crate::fill::fill_slow_path(text, options) 9 | } 10 | 11 | /// Exposed for fuzzing so we can check the slow path is correct. 12 | pub fn wrap_single_line<'a>(line: &'a str, options: &Options<'_>, lines: &mut Vec>) { 13 | crate::wrap::wrap_single_line(line, options, lines); 14 | } 15 | 16 | /// Exposed for fuzzing so we can check the slow path is correct. 17 | pub fn wrap_single_line_slow_path<'a>( 18 | line: &'a str, 19 | options: &Options<'_>, 20 | lines: &mut Vec>, 21 | ) { 22 | crate::wrap::wrap_single_line_slow_path(line, options, lines) 23 | } 24 | -------------------------------------------------------------------------------- /src/indentation.rs: -------------------------------------------------------------------------------- 1 | //! Functions related to adding and removing indentation from lines of 2 | //! text. 3 | //! 4 | //! The functions here can be used to uniformly indent or dedent 5 | //! (unindent) word wrapped lines of text. 6 | 7 | /// Indent each line by the given prefix. 8 | /// 9 | /// # Examples 10 | /// 11 | /// ``` 12 | /// use textwrap::indent; 13 | /// 14 | /// assert_eq!(indent("First line.\nSecond line.\n", " "), 15 | /// " First line.\n Second line.\n"); 16 | /// ``` 17 | /// 18 | /// When indenting, trailing whitespace is stripped from the prefix. 19 | /// This means that empty lines remain empty afterwards: 20 | /// 21 | /// ``` 22 | /// use textwrap::indent; 23 | /// 24 | /// assert_eq!(indent("First line.\n\n\nSecond line.\n", " "), 25 | /// " First line.\n\n\n Second line.\n"); 26 | /// ``` 27 | /// 28 | /// Notice how `"\n\n\n"` remained as `"\n\n\n"`. 29 | /// 30 | /// This feature is useful when you want to indent text and have a 31 | /// space between your prefix and the text. In this case, you _don't_ 32 | /// want a trailing space on empty lines: 33 | /// 34 | /// ``` 35 | /// use textwrap::indent; 36 | /// 37 | /// assert_eq!(indent("foo = 123\n\nprint(foo)\n", "# "), 38 | /// "# foo = 123\n#\n# print(foo)\n"); 39 | /// ``` 40 | /// 41 | /// Notice how `"\n\n"` became `"\n#\n"` instead of `"\n# \n"` which 42 | /// would have trailing whitespace. 43 | /// 44 | /// Leading and trailing whitespace coming from the text itself is 45 | /// kept unchanged: 46 | /// 47 | /// ``` 48 | /// use textwrap::indent; 49 | /// 50 | /// assert_eq!(indent(" \t Foo ", "->"), "-> \t Foo "); 51 | /// ``` 52 | pub fn indent(s: &str, prefix: &str) -> String { 53 | // We know we'll need more than s.len() bytes for the output, but 54 | // without counting '\n' characters (which is somewhat slow), we 55 | // don't know exactly how much. However, we can preemptively do 56 | // the first doubling of the output size. 57 | let mut result = String::with_capacity(2 * s.len()); 58 | let trimmed_prefix = prefix.trim_end(); 59 | for (idx, line) in s.split_terminator('\n').enumerate() { 60 | if idx > 0 { 61 | result.push('\n'); 62 | } 63 | if line.trim().is_empty() { 64 | result.push_str(trimmed_prefix); 65 | } else { 66 | result.push_str(prefix); 67 | } 68 | result.push_str(line); 69 | } 70 | if s.ends_with('\n') { 71 | // split_terminator will have eaten the final '\n'. 72 | result.push('\n'); 73 | } 74 | result 75 | } 76 | 77 | /// Removes common leading whitespace from each line. 78 | /// 79 | /// This function will look at each non-empty line and determine the 80 | /// maximum amount of whitespace that can be removed from all lines: 81 | /// 82 | /// ``` 83 | /// use textwrap::dedent; 84 | /// 85 | /// assert_eq!(dedent(" 86 | /// 1st line 87 | /// 2nd line 88 | /// 3rd line 89 | /// "), " 90 | /// 1st line 91 | /// 2nd line 92 | /// 3rd line 93 | /// "); 94 | /// ``` 95 | pub fn dedent(s: &str) -> String { 96 | let mut prefix = ""; 97 | let mut lines = s.lines(); 98 | 99 | // We first search for a non-empty line to find a prefix. 100 | for line in &mut lines { 101 | let mut whitespace_idx = line.len(); 102 | for (idx, ch) in line.char_indices() { 103 | if !ch.is_whitespace() { 104 | whitespace_idx = idx; 105 | break; 106 | } 107 | } 108 | 109 | // Check if the line had anything but whitespace 110 | if whitespace_idx < line.len() { 111 | prefix = &line[..whitespace_idx]; 112 | break; 113 | } 114 | } 115 | 116 | // We then continue looking through the remaining lines to 117 | // possibly shorten the prefix. 118 | for line in &mut lines { 119 | let mut whitespace_idx = line.len(); 120 | for ((idx, a), b) in line.char_indices().zip(prefix.chars()) { 121 | if a != b { 122 | whitespace_idx = idx; 123 | break; 124 | } 125 | } 126 | 127 | // Check if the line had anything but whitespace and if we 128 | // have found a shorter prefix 129 | if whitespace_idx < line.len() && whitespace_idx < prefix.len() { 130 | prefix = &line[..whitespace_idx]; 131 | } 132 | } 133 | 134 | // We now go over the lines a second time to build the result. 135 | let mut result = String::new(); 136 | for line in s.lines() { 137 | if line.starts_with(prefix) && line.chars().any(|c| !c.is_whitespace()) { 138 | let (_, tail) = line.split_at(prefix.len()); 139 | result.push_str(tail); 140 | } 141 | result.push('\n'); 142 | } 143 | 144 | if result.ends_with('\n') && !s.ends_with('\n') { 145 | let new_len = result.len() - 1; 146 | result.truncate(new_len); 147 | } 148 | 149 | result 150 | } 151 | 152 | #[cfg(test)] 153 | mod tests { 154 | use super::*; 155 | 156 | #[test] 157 | fn indent_empty() { 158 | assert_eq!(indent("\n", " "), "\n"); 159 | } 160 | 161 | #[test] 162 | #[rustfmt::skip] 163 | fn indent_nonempty() { 164 | let text = [ 165 | " foo\n", 166 | "bar\n", 167 | " baz\n", 168 | ].join(""); 169 | let expected = [ 170 | "// foo\n", 171 | "// bar\n", 172 | "// baz\n", 173 | ].join(""); 174 | assert_eq!(indent(&text, "// "), expected); 175 | } 176 | 177 | #[test] 178 | #[rustfmt::skip] 179 | fn indent_empty_line() { 180 | let text = [ 181 | " foo", 182 | "bar", 183 | "", 184 | " baz", 185 | ].join("\n"); 186 | let expected = [ 187 | "// foo", 188 | "// bar", 189 | "//", 190 | "// baz", 191 | ].join("\n"); 192 | assert_eq!(indent(&text, "// "), expected); 193 | } 194 | 195 | #[test] 196 | fn dedent_empty() { 197 | assert_eq!(dedent(""), ""); 198 | } 199 | 200 | #[test] 201 | #[rustfmt::skip] 202 | fn dedent_multi_line() { 203 | let x = [ 204 | " foo", 205 | " bar", 206 | " baz", 207 | ].join("\n"); 208 | let y = [ 209 | " foo", 210 | "bar", 211 | " baz" 212 | ].join("\n"); 213 | assert_eq!(dedent(&x), y); 214 | } 215 | 216 | #[test] 217 | #[rustfmt::skip] 218 | fn dedent_empty_line() { 219 | let x = [ 220 | " foo", 221 | " bar", 222 | " ", 223 | " baz" 224 | ].join("\n"); 225 | let y = [ 226 | " foo", 227 | "bar", 228 | "", 229 | " baz" 230 | ].join("\n"); 231 | assert_eq!(dedent(&x), y); 232 | } 233 | 234 | #[test] 235 | #[rustfmt::skip] 236 | fn dedent_blank_line() { 237 | let x = [ 238 | " foo", 239 | "", 240 | " bar", 241 | " foo", 242 | " bar", 243 | " baz", 244 | ].join("\n"); 245 | let y = [ 246 | "foo", 247 | "", 248 | " bar", 249 | " foo", 250 | " bar", 251 | " baz", 252 | ].join("\n"); 253 | assert_eq!(dedent(&x), y); 254 | } 255 | 256 | #[test] 257 | #[rustfmt::skip] 258 | fn dedent_whitespace_line() { 259 | let x = [ 260 | " foo", 261 | " ", 262 | " bar", 263 | " foo", 264 | " bar", 265 | " baz", 266 | ].join("\n"); 267 | let y = [ 268 | "foo", 269 | "", 270 | " bar", 271 | " foo", 272 | " bar", 273 | " baz", 274 | ].join("\n"); 275 | assert_eq!(dedent(&x), y); 276 | } 277 | 278 | #[test] 279 | #[rustfmt::skip] 280 | fn dedent_mixed_whitespace() { 281 | let x = [ 282 | "\tfoo", 283 | " bar", 284 | ].join("\n"); 285 | let y = [ 286 | "\tfoo", 287 | " bar", 288 | ].join("\n"); 289 | assert_eq!(dedent(&x), y); 290 | } 291 | 292 | #[test] 293 | #[rustfmt::skip] 294 | fn dedent_tabbed_whitespace() { 295 | let x = [ 296 | "\t\tfoo", 297 | "\t\t\tbar", 298 | ].join("\n"); 299 | let y = [ 300 | "foo", 301 | "\tbar", 302 | ].join("\n"); 303 | assert_eq!(dedent(&x), y); 304 | } 305 | 306 | #[test] 307 | #[rustfmt::skip] 308 | fn dedent_mixed_tabbed_whitespace() { 309 | let x = [ 310 | "\t \tfoo", 311 | "\t \t\tbar", 312 | ].join("\n"); 313 | let y = [ 314 | "foo", 315 | "\tbar", 316 | ].join("\n"); 317 | assert_eq!(dedent(&x), y); 318 | } 319 | 320 | #[test] 321 | #[rustfmt::skip] 322 | fn dedent_mixed_tabbed_whitespace2() { 323 | let x = [ 324 | "\t \tfoo", 325 | "\t \tbar", 326 | ].join("\n"); 327 | let y = [ 328 | "\tfoo", 329 | " \tbar", 330 | ].join("\n"); 331 | assert_eq!(dedent(&x), y); 332 | } 333 | 334 | #[test] 335 | #[rustfmt::skip] 336 | fn dedent_preserve_no_terminating_newline() { 337 | let x = [ 338 | " foo", 339 | " bar", 340 | ].join("\n"); 341 | let y = [ 342 | "foo", 343 | " bar", 344 | ].join("\n"); 345 | assert_eq!(dedent(&x), y); 346 | } 347 | } 348 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! The textwrap library provides functions for word wrapping and 2 | //! indenting text. 3 | //! 4 | //! # Wrapping Text 5 | //! 6 | //! Wrapping text can be very useful in command-line programs where 7 | //! you want to format dynamic output nicely so it looks good in a 8 | //! terminal. A quick example: 9 | //! 10 | //! ``` 11 | //! # #[cfg(feature = "smawk")] { 12 | //! let text = "textwrap: a small library for wrapping text."; 13 | //! assert_eq!(textwrap::wrap(text, 18), 14 | //! vec!["textwrap: a", 15 | //! "small library for", 16 | //! "wrapping text."]); 17 | //! # } 18 | //! ``` 19 | //! 20 | //! The [`wrap()`] function returns the individual lines, use 21 | //! [`fill()`] is you want the lines joined with `'\n'` to form a 22 | //! `String`. 23 | //! 24 | //! If you enable the `hyphenation` Cargo feature, you can get 25 | //! automatic hyphenation for a number of languages: 26 | //! 27 | //! ``` 28 | //! #[cfg(feature = "hyphenation")] { 29 | //! use hyphenation::{Language, Load, Standard}; 30 | //! use textwrap::{wrap, Options, WordSplitter}; 31 | //! 32 | //! let text = "textwrap: a small library for wrapping text."; 33 | //! let dictionary = Standard::from_embedded(Language::EnglishUS).unwrap(); 34 | //! let options = Options::new(18).word_splitter(WordSplitter::Hyphenation(dictionary)); 35 | //! assert_eq!(wrap(text, &options), 36 | //! vec!["textwrap: a small", 37 | //! "library for wrap-", 38 | //! "ping text."]); 39 | //! } 40 | //! ``` 41 | //! 42 | //! See also the [`unfill()`] and [`refill()`] functions which allow 43 | //! you to manipulate already wrapped text. 44 | //! 45 | //! ## Wrapping Strings at Compile Time 46 | //! 47 | //! If your strings are known at compile time, please take a look at 48 | //! the procedural macros from the [textwrap-macros] crate. 49 | //! 50 | //! ## Displayed Width vs Byte Size 51 | //! 52 | //! To word wrap text, one must know the width of each word so one can 53 | //! know when to break lines. This library will by default measure the 54 | //! width of text using the _displayed width_, not the size in bytes. 55 | //! The `unicode-width` Cargo feature controls this. 56 | //! 57 | //! This is important for non-ASCII text. ASCII characters such as `a` 58 | //! and `!` are simple and take up one column each. This means that 59 | //! the displayed width is equal to the string length in bytes. 60 | //! However, non-ASCII characters and symbols take up more than one 61 | //! byte when UTF-8 encoded: `é` is `0xc3 0xa9` (two bytes) and `⚙` is 62 | //! `0xe2 0x9a 0x99` (three bytes) in UTF-8, respectively. 63 | //! 64 | //! This is why we take care to use the displayed width instead of the 65 | //! byte count when computing line lengths. All functions in this 66 | //! library handle Unicode characters like this when the 67 | //! `unicode-width` Cargo feature is enabled (it is enabled by 68 | //! default). 69 | //! 70 | //! # Indentation and Dedentation 71 | //! 72 | //! The textwrap library also offers functions for adding a prefix to 73 | //! every line of a string and to remove leading whitespace. As an 74 | //! example, [`indent()`] allows you to turn lines of text into a 75 | //! bullet list: 76 | //! 77 | //! ``` 78 | //! let before = "\ 79 | //! foo 80 | //! bar 81 | //! baz 82 | //! "; 83 | //! let after = "\ 84 | //! * foo 85 | //! * bar 86 | //! * baz 87 | //! "; 88 | //! assert_eq!(textwrap::indent(before, "* "), after); 89 | //! ``` 90 | //! 91 | //! Removing leading whitespace is done with [`dedent()`]: 92 | //! 93 | //! ``` 94 | //! let before = " 95 | //! Some 96 | //! indented 97 | //! text 98 | //! "; 99 | //! let after = " 100 | //! Some 101 | //! indented 102 | //! text 103 | //! "; 104 | //! assert_eq!(textwrap::dedent(before), after); 105 | //! ``` 106 | //! 107 | //! # Cargo Features 108 | //! 109 | //! The textwrap library can be slimmed down as needed via a number of 110 | //! Cargo features. This means you only pay for the features you 111 | //! actually use. 112 | //! 113 | //! The full dependency graph, where dashed lines indicate optional 114 | //! dependencies, is shown below: 115 | //! 116 | //! 117 | //! 118 | //! ## Default Features 119 | //! 120 | //! These features are enabled by default: 121 | //! 122 | //! * `unicode-linebreak`: enables finding words using the 123 | //! [unicode-linebreak] crate, which implements the line breaking 124 | //! algorithm described in [Unicode Standard Annex 125 | //! #14](https://www.unicode.org/reports/tr14/). 126 | //! 127 | //! This feature can be disabled if you are happy to find words 128 | //! separated by ASCII space characters only. People wrapping text 129 | //! with emojis or East-Asian characters will want most likely want 130 | //! to enable this feature. See [`WordSeparator`] for details. 131 | //! 132 | //! * `unicode-width`: enables correct width computation of non-ASCII 133 | //! characters via the [unicode-width] crate. Without this feature, 134 | //! every [`char`] is 1 column wide, except for emojis which are 2 135 | //! columns wide. See [`core::display_width()`] for details. 136 | //! 137 | //! This feature can be disabled if you only need to wrap ASCII 138 | //! text, or if the functions in [`core`] are used directly with 139 | //! [`core::Fragment`]s for which the widths have been computed in 140 | //! other ways. 141 | //! 142 | //! * `smawk`: enables linear-time wrapping of the whole paragraph via 143 | //! the [smawk] crate. See [`wrap_algorithms::wrap_optimal_fit()`] 144 | //! for details on the optimal-fit algorithm. 145 | //! 146 | //! This feature can be disabled if you only ever intend to use 147 | //! [`wrap_algorithms::wrap_first_fit()`]. 148 | //! 149 | //! 150 | //! 151 | //! With Rust 1.64.0, the size impact of the above features on your 152 | //! binary is as follows: 153 | //! 154 | //! | Configuration | Binary Size | Delta | 155 | //! | :--- | ---: | ---: | 156 | //! | quick-and-dirty implementation | 289 KB | — KB | 157 | //! | textwrap without default features | 305 KB | 16 KB | 158 | //! | textwrap with smawk | 317 KB | 28 KB | 159 | //! | textwrap with unicode-width | 309 KB | 20 KB | 160 | //! | textwrap with unicode-linebreak | 342 KB | 53 KB | 161 | //! 162 | //! 163 | //! 164 | //! The above sizes are the stripped sizes and the binary is compiled 165 | //! in release mode with this profile: 166 | //! 167 | //! ```toml 168 | //! [profile.release] 169 | //! lto = true 170 | //! codegen-units = 1 171 | //! ``` 172 | //! 173 | //! See the [binary-sizes demo] if you want to reproduce these 174 | //! results. 175 | //! 176 | //! ## Optional Features 177 | //! 178 | //! These Cargo features enable new functionality: 179 | //! 180 | //! * `terminal_size`: enables automatic detection of the terminal 181 | //! width via the [terminal_size] crate. See 182 | //! [`Options::with_termwidth()`] for details. 183 | //! 184 | //! * `hyphenation`: enables language-sensitive hyphenation via the 185 | //! [hyphenation] crate. See the [`word_splitters::WordSplitter`] 186 | //! trait for details. 187 | //! 188 | //! [unicode-linebreak]: https://docs.rs/unicode-linebreak/ 189 | //! [unicode-width]: https://docs.rs/unicode-width/ 190 | //! [smawk]: https://docs.rs/smawk/ 191 | //! [binary-sizes demo]: https://github.com/mgeisler/textwrap/tree/master/examples/binary-sizes 192 | //! [textwrap-macros]: https://docs.rs/textwrap-macros/ 193 | //! [terminal_size]: https://docs.rs/terminal_size/ 194 | //! [hyphenation]: https://docs.rs/hyphenation/ 195 | 196 | #![doc(html_root_url = "https://docs.rs/textwrap/0.16.2")] 197 | #![forbid(unsafe_code)] // See https://github.com/mgeisler/textwrap/issues/210 198 | #![deny(missing_docs)] 199 | #![deny(missing_debug_implementations)] 200 | #![allow(clippy::redundant_field_names)] 201 | 202 | // Make `cargo test` execute the README doctests. 203 | #[cfg(doctest)] 204 | #[doc = include_str!("../README.md")] 205 | mod readme_doctest {} 206 | 207 | pub mod core; 208 | #[cfg(fuzzing)] 209 | pub mod fuzzing; 210 | pub mod word_splitters; 211 | pub mod wrap_algorithms; 212 | 213 | mod columns; 214 | mod fill; 215 | mod indentation; 216 | mod line_ending; 217 | mod options; 218 | mod refill; 219 | #[cfg(feature = "terminal_size")] 220 | mod termwidth; 221 | mod word_separators; 222 | mod wrap; 223 | 224 | pub use columns::wrap_columns; 225 | pub use fill::{fill, fill_inplace}; 226 | pub use indentation::{dedent, indent}; 227 | pub use line_ending::LineEnding; 228 | pub use options::Options; 229 | pub use refill::{refill, unfill}; 230 | #[cfg(feature = "terminal_size")] 231 | pub use termwidth::termwidth; 232 | pub use word_separators::WordSeparator; 233 | pub use word_splitters::WordSplitter; 234 | pub use wrap::wrap; 235 | pub use wrap_algorithms::WrapAlgorithm; 236 | -------------------------------------------------------------------------------- /src/line_ending.rs: -------------------------------------------------------------------------------- 1 | //! Line ending detection and conversion. 2 | 3 | use std::fmt::Debug; 4 | 5 | /// Supported line endings. Like in the Rust standard library, two line 6 | /// endings are supported: `\r\n` and `\n` 7 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] 8 | pub enum LineEnding { 9 | /// _Carriage return and line feed_ – a line ending sequence 10 | /// historically used in Windows. Corresponds to the sequence 11 | /// of ASCII control characters `0x0D 0x0A` or `\r\n` 12 | CRLF, 13 | /// _Line feed_ – a line ending historically used in Unix. 14 | /// Corresponds to the ASCII control character `0x0A` or `\n` 15 | LF, 16 | } 17 | 18 | impl LineEnding { 19 | /// Turns this [`LineEnding`] value into its ASCII representation. 20 | #[inline] 21 | pub const fn as_str(&self) -> &'static str { 22 | match self { 23 | Self::CRLF => "\r\n", 24 | Self::LF => "\n", 25 | } 26 | } 27 | } 28 | 29 | /// An iterator over the lines of a string, as tuples of string slice 30 | /// and [`LineEnding`] value; it only emits non-empty lines (i.e. having 31 | /// some content before the terminating `\r\n` or `\n`). 32 | /// 33 | /// This struct is used internally by the library. 34 | #[derive(Debug, Clone, Copy)] 35 | pub(crate) struct NonEmptyLines<'a>(pub &'a str); 36 | 37 | impl<'a> Iterator for NonEmptyLines<'a> { 38 | type Item = (&'a str, Option); 39 | 40 | fn next(&mut self) -> Option { 41 | while let Some(lf) = self.0.find('\n') { 42 | if lf == 0 || (lf == 1 && self.0.as_bytes()[lf - 1] == b'\r') { 43 | self.0 = &self.0[(lf + 1)..]; 44 | continue; 45 | } 46 | let trimmed = match self.0.as_bytes()[lf - 1] { 47 | b'\r' => (&self.0[..(lf - 1)], Some(LineEnding::CRLF)), 48 | _ => (&self.0[..lf], Some(LineEnding::LF)), 49 | }; 50 | self.0 = &self.0[(lf + 1)..]; 51 | return Some(trimmed); 52 | } 53 | if self.0.is_empty() { 54 | None 55 | } else { 56 | let line = std::mem::take(&mut self.0); 57 | Some((line, None)) 58 | } 59 | } 60 | } 61 | 62 | #[cfg(test)] 63 | mod tests { 64 | use super::*; 65 | 66 | #[test] 67 | fn non_empty_lines_full_case() { 68 | assert_eq!( 69 | NonEmptyLines("LF\nCRLF\r\n\r\n\nunterminated") 70 | .collect::)>>(), 71 | vec![ 72 | ("LF", Some(LineEnding::LF)), 73 | ("CRLF", Some(LineEnding::CRLF)), 74 | ("unterminated", None), 75 | ] 76 | ); 77 | } 78 | 79 | #[test] 80 | fn non_empty_lines_new_lines_only() { 81 | assert_eq!(NonEmptyLines("\r\n\n\n\r\n").next(), None); 82 | } 83 | 84 | #[test] 85 | fn non_empty_lines_no_input() { 86 | assert_eq!(NonEmptyLines("").next(), None); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/options.rs: -------------------------------------------------------------------------------- 1 | //! Options for wrapping text. 2 | 3 | use crate::{LineEnding, WordSeparator, WordSplitter, WrapAlgorithm}; 4 | 5 | /// Holds configuration options for wrapping and filling text. 6 | #[non_exhaustive] 7 | #[derive(Debug, Clone)] 8 | pub struct Options<'a> { 9 | /// The width in columns at which the text will be wrapped. 10 | pub width: usize, 11 | /// Line ending used for breaking lines. 12 | pub line_ending: LineEnding, 13 | /// Indentation used for the first line of output. See the 14 | /// [`Options::initial_indent`] method. 15 | pub initial_indent: &'a str, 16 | /// Indentation used for subsequent lines of output. See the 17 | /// [`Options::subsequent_indent`] method. 18 | pub subsequent_indent: &'a str, 19 | /// Allow long words to be broken if they cannot fit on a line. 20 | /// When set to `false`, some lines may be longer than 21 | /// `self.width`. See the [`Options::break_words`] method. 22 | pub break_words: bool, 23 | /// Wrapping algorithm to use, see the implementations of the 24 | /// [`WrapAlgorithm`] trait for details. 25 | pub wrap_algorithm: WrapAlgorithm, 26 | /// The line breaking algorithm to use, see the [`WordSeparator`] 27 | /// trait for an overview and possible implementations. 28 | pub word_separator: WordSeparator, 29 | /// The method for splitting words. This can be used to prohibit 30 | /// splitting words on hyphens, or it can be used to implement 31 | /// language-aware machine hyphenation. 32 | pub word_splitter: WordSplitter, 33 | /// Allow trailing spaces to be preserved at the end of the line. 34 | pub preserve_trailing_space: bool, 35 | } 36 | 37 | impl<'a> From<&'a Options<'a>> for Options<'a> { 38 | fn from(options: &'a Options<'a>) -> Self { 39 | Self { 40 | width: options.width, 41 | line_ending: options.line_ending, 42 | initial_indent: options.initial_indent, 43 | subsequent_indent: options.subsequent_indent, 44 | break_words: options.break_words, 45 | word_separator: options.word_separator, 46 | wrap_algorithm: options.wrap_algorithm, 47 | word_splitter: options.word_splitter.clone(), 48 | preserve_trailing_space: options.preserve_trailing_space, 49 | } 50 | } 51 | } 52 | 53 | impl From for Options<'_> { 54 | fn from(width: usize) -> Self { 55 | Options::new(width) 56 | } 57 | } 58 | 59 | impl<'a> Options<'a> { 60 | /// Creates a new [`Options`] with the specified width. 61 | /// 62 | /// The other fields are given default values as follows: 63 | /// 64 | /// ``` 65 | /// # use textwrap::{LineEnding, Options, WordSplitter, WordSeparator, WrapAlgorithm}; 66 | /// # let width = 80; 67 | /// let options = Options::new(width); 68 | /// assert_eq!(options.line_ending, LineEnding::LF); 69 | /// assert_eq!(options.initial_indent, ""); 70 | /// assert_eq!(options.subsequent_indent, ""); 71 | /// assert_eq!(options.break_words, true); 72 | /// assert_eq!(options.preserve_trailing_space, false); 73 | /// 74 | /// #[cfg(feature = "unicode-linebreak")] 75 | /// assert_eq!(options.word_separator, WordSeparator::UnicodeBreakProperties); 76 | /// #[cfg(not(feature = "unicode-linebreak"))] 77 | /// assert_eq!(options.word_separator, WordSeparator::AsciiSpace); 78 | /// 79 | /// #[cfg(feature = "smawk")] 80 | /// assert_eq!(options.wrap_algorithm, WrapAlgorithm::new_optimal_fit()); 81 | /// #[cfg(not(feature = "smawk"))] 82 | /// assert_eq!(options.wrap_algorithm, WrapAlgorithm::FirstFit); 83 | /// 84 | /// assert_eq!(options.word_splitter, WordSplitter::HyphenSplitter); 85 | /// ``` 86 | /// 87 | /// Note that the default word separator and wrap algorithms 88 | /// changes based on the available Cargo features. The best 89 | /// available algorithms are used by default. 90 | pub const fn new(width: usize) -> Self { 91 | Options { 92 | width, 93 | line_ending: LineEnding::LF, 94 | initial_indent: "", 95 | subsequent_indent: "", 96 | break_words: true, 97 | word_separator: WordSeparator::new(), 98 | wrap_algorithm: WrapAlgorithm::new(), 99 | word_splitter: WordSplitter::HyphenSplitter, 100 | preserve_trailing_space: false, 101 | } 102 | } 103 | 104 | /// Change [`self.line_ending`]. This specifies which of the 105 | /// supported line endings should be used to break the lines of the 106 | /// input text. 107 | /// 108 | /// # Examples 109 | /// 110 | /// ``` 111 | /// use textwrap::{refill, LineEnding, Options}; 112 | /// 113 | /// let options = Options::new(15).line_ending(LineEnding::CRLF); 114 | /// assert_eq!(refill("This is a little example.", options), 115 | /// "This is a\r\nlittle example."); 116 | /// ``` 117 | /// 118 | /// [`self.line_ending`]: #structfield.line_ending 119 | pub fn line_ending(self, line_ending: LineEnding) -> Self { 120 | Options { 121 | line_ending, 122 | ..self 123 | } 124 | } 125 | 126 | /// Set [`self.width`] to the given value. 127 | /// 128 | /// [`self.width`]: #structfield.width 129 | pub fn width(self, width: usize) -> Self { 130 | Options { width, ..self } 131 | } 132 | 133 | /// Change [`self.initial_indent`]. The initial indentation is 134 | /// used on the very first line of output. 135 | /// 136 | /// # Examples 137 | /// 138 | /// Classic paragraph indentation can be achieved by specifying an 139 | /// initial indentation and wrapping each paragraph by itself: 140 | /// 141 | /// ``` 142 | /// use textwrap::{wrap, Options}; 143 | /// 144 | /// let options = Options::new(16).initial_indent(" "); 145 | /// assert_eq!(wrap("This is a little example.", options), 146 | /// vec![" This is a", 147 | /// "little example."]); 148 | /// ``` 149 | /// 150 | /// [`self.initial_indent`]: #structfield.initial_indent 151 | pub fn initial_indent(self, initial_indent: &'a str) -> Self { 152 | Options { 153 | initial_indent, 154 | ..self 155 | } 156 | } 157 | 158 | /// Change [`self.subsequent_indent`]. The subsequent indentation 159 | /// is used on lines following the first line of output. 160 | /// 161 | /// # Examples 162 | /// 163 | /// Combining initial and subsequent indentation lets you format a 164 | /// single paragraph as a bullet list: 165 | /// 166 | /// ``` 167 | /// use textwrap::{wrap, Options}; 168 | /// 169 | /// let options = Options::new(12) 170 | /// .initial_indent("* ") 171 | /// .subsequent_indent(" "); 172 | /// #[cfg(feature = "smawk")] 173 | /// assert_eq!(wrap("This is a little example.", options), 174 | /// vec!["* This is", 175 | /// " a little", 176 | /// " example."]); 177 | /// 178 | /// // Without the `smawk` feature, the wrapping is a little different: 179 | /// #[cfg(not(feature = "smawk"))] 180 | /// assert_eq!(wrap("This is a little example.", options), 181 | /// vec!["* This is a", 182 | /// " little", 183 | /// " example."]); 184 | /// ``` 185 | /// 186 | /// [`self.subsequent_indent`]: #structfield.subsequent_indent 187 | pub fn subsequent_indent(self, subsequent_indent: &'a str) -> Self { 188 | Options { 189 | subsequent_indent, 190 | ..self 191 | } 192 | } 193 | 194 | /// Change [`self.break_words`]. This controls if words longer 195 | /// than `self.width` can be broken, or if they will be left 196 | /// sticking out into the right margin. 197 | /// 198 | /// See [`Options::word_splitter`] instead if you want to control 199 | /// hyphenation. 200 | /// 201 | /// # Examples 202 | /// 203 | /// ``` 204 | /// use textwrap::{wrap, Options}; 205 | /// 206 | /// let options = Options::new(4).break_words(true); 207 | /// assert_eq!(wrap("This is a little example.", options), 208 | /// vec!["This", 209 | /// "is a", 210 | /// "litt", 211 | /// "le", 212 | /// "exam", 213 | /// "ple."]); 214 | /// ``` 215 | /// 216 | /// [`self.break_words`]: #structfield.break_words 217 | pub fn break_words(self, break_words: bool) -> Self { 218 | Options { 219 | break_words, 220 | ..self 221 | } 222 | } 223 | 224 | /// Change [`self.word_separator`]. 225 | /// 226 | /// See the [`WordSeparator`] trait for details on the choices. 227 | /// 228 | /// [`self.word_separator`]: #structfield.word_separator 229 | pub fn word_separator(self, word_separator: WordSeparator) -> Options<'a> { 230 | Options { 231 | word_separator, 232 | ..self 233 | } 234 | } 235 | 236 | /// Change [`self.wrap_algorithm`]. 237 | /// 238 | /// See the [`WrapAlgorithm`] trait for details on the choices. 239 | /// 240 | /// [`self.wrap_algorithm`]: #structfield.wrap_algorithm 241 | pub fn wrap_algorithm(self, wrap_algorithm: WrapAlgorithm) -> Options<'a> { 242 | Options { 243 | wrap_algorithm, 244 | ..self 245 | } 246 | } 247 | 248 | /// Change [`self.word_splitter`]. The [`WordSplitter`] is used to 249 | /// fit part of a word into the current line when wrapping text. 250 | /// 251 | /// See [`Options::break_words`] instead if you want to control the 252 | /// handling of words longer than the line width. 253 | /// 254 | /// # Examples 255 | /// 256 | /// ``` 257 | /// use textwrap::{wrap, Options, WordSplitter}; 258 | /// 259 | /// // The default is WordSplitter::HyphenSplitter. 260 | /// let options = Options::new(5); 261 | /// assert_eq!(wrap("foo-bar-baz", &options), 262 | /// vec!["foo-", "bar-", "baz"]); 263 | /// 264 | /// // The word is now so long that break_words kick in: 265 | /// let options = Options::new(5) 266 | /// .word_splitter(WordSplitter::NoHyphenation); 267 | /// assert_eq!(wrap("foo-bar-baz", &options), 268 | /// vec!["foo-b", "ar-ba", "z"]); 269 | /// 270 | /// // If you want to breaks at all, disable both: 271 | /// let options = Options::new(5) 272 | /// .break_words(false) 273 | /// .word_splitter(WordSplitter::NoHyphenation); 274 | /// assert_eq!(wrap("foo-bar-baz", &options), 275 | /// vec!["foo-bar-baz"]); 276 | /// ``` 277 | /// 278 | /// [`self.word_splitter`]: #structfield.word_splitter 279 | pub fn word_splitter(self, word_splitter: WordSplitter) -> Options<'a> { 280 | Options { 281 | word_splitter, 282 | ..self 283 | } 284 | } 285 | 286 | /// Change [`self.preserve_trailing_space`]. This controls if the 287 | /// trailing spaces at the end of line is preserved, or trimmed. 288 | /// 289 | /// [`self.preserve_trailing_space`]: #structfield.preserve_trailing_space 290 | pub fn preserve_trailing_space(self, preserve_trailing_space: bool) -> Options<'a> { 291 | Options { 292 | preserve_trailing_space, 293 | ..self 294 | } 295 | } 296 | } 297 | 298 | #[cfg(test)] 299 | mod tests { 300 | use super::*; 301 | 302 | #[test] 303 | fn options_agree_with_usize() { 304 | let opt_usize = Options::from(42_usize); 305 | let opt_options = Options::new(42); 306 | 307 | assert_eq!(opt_usize.width, opt_options.width); 308 | assert_eq!(opt_usize.initial_indent, opt_options.initial_indent); 309 | assert_eq!(opt_usize.subsequent_indent, opt_options.subsequent_indent); 310 | assert_eq!(opt_usize.break_words, opt_options.break_words); 311 | assert_eq!( 312 | opt_usize.word_splitter.split_points("hello-world"), 313 | opt_options.word_splitter.split_points("hello-world") 314 | ); 315 | } 316 | } 317 | -------------------------------------------------------------------------------- /src/refill.rs: -------------------------------------------------------------------------------- 1 | //! Functionality for unfilling and refilling text. 2 | 3 | use crate::core::display_width; 4 | use crate::line_ending::NonEmptyLines; 5 | use crate::{fill, LineEnding, Options}; 6 | 7 | /// Unpack a paragraph of already-wrapped text. 8 | /// 9 | /// This function attempts to recover the original text from a single 10 | /// paragraph of wrapped text, such as what [`fill()`] would produce. 11 | /// This means that it turns 12 | /// 13 | /// ```text 14 | /// textwrap: a small 15 | /// library for 16 | /// wrapping text. 17 | /// ``` 18 | /// 19 | /// back into 20 | /// 21 | /// ```text 22 | /// textwrap: a small library for wrapping text. 23 | /// ``` 24 | /// 25 | /// In addition, it will recognize a common prefix and a common line 26 | /// ending among the lines. 27 | /// 28 | /// The prefix of the first line is returned in 29 | /// [`Options::initial_indent`] and the prefix (if any) of the the 30 | /// other lines is returned in [`Options::subsequent_indent`]. 31 | /// 32 | /// Line ending is returned in [`Options::line_ending`]. If line ending 33 | /// can not be confidently detected (mixed or no line endings in the 34 | /// input), [`LineEnding::LF`] will be returned. 35 | /// 36 | /// In addition to `' '`, the prefixes can consist of characters used 37 | /// for unordered lists (`'-'`, `'+'`, and `'*'`) and block quotes 38 | /// (`'>'`) in Markdown as well as characters often used for inline 39 | /// comments (`'#'` and `'/'`). 40 | /// 41 | /// The text must come from a single wrapped paragraph. This means 42 | /// that there can be no empty lines (`"\n\n"` or `"\r\n\r\n"`) within 43 | /// the text. It is unspecified what happens if `unfill` is called on 44 | /// more than one paragraph of text. 45 | /// 46 | /// # Examples 47 | /// 48 | /// ``` 49 | /// use textwrap::{LineEnding, unfill}; 50 | /// 51 | /// let (text, options) = unfill("\ 52 | /// * This is an 53 | /// example of 54 | /// a list item. 55 | /// "); 56 | /// 57 | /// assert_eq!(text, "This is an example of a list item.\n"); 58 | /// assert_eq!(options.initial_indent, "* "); 59 | /// assert_eq!(options.subsequent_indent, " "); 60 | /// assert_eq!(options.line_ending, LineEnding::LF); 61 | /// ``` 62 | pub fn unfill(text: &str) -> (String, Options<'_>) { 63 | let prefix_chars: &[_] = &[' ', '-', '+', '*', '>', '#', '/']; 64 | 65 | let mut options = Options::new(0); 66 | for (idx, line) in text.lines().enumerate() { 67 | options.width = std::cmp::max(options.width, display_width(line)); 68 | let without_prefix = line.trim_start_matches(prefix_chars); 69 | let prefix = &line[..line.len() - without_prefix.len()]; 70 | 71 | if idx == 0 { 72 | options.initial_indent = prefix; 73 | } else if idx == 1 { 74 | options.subsequent_indent = prefix; 75 | } else if idx > 1 { 76 | for ((idx, x), y) in prefix.char_indices().zip(options.subsequent_indent.chars()) { 77 | if x != y { 78 | options.subsequent_indent = &prefix[..idx]; 79 | break; 80 | } 81 | } 82 | if prefix.len() < options.subsequent_indent.len() { 83 | options.subsequent_indent = prefix; 84 | } 85 | } 86 | } 87 | 88 | let mut unfilled = String::with_capacity(text.len()); 89 | let mut detected_line_ending = None; 90 | 91 | for (idx, (line, ending)) in NonEmptyLines(text).enumerate() { 92 | if idx == 0 { 93 | unfilled.push_str(&line[options.initial_indent.len()..]); 94 | } else { 95 | unfilled.push(' '); 96 | unfilled.push_str(&line[options.subsequent_indent.len()..]); 97 | } 98 | match (detected_line_ending, ending) { 99 | (None, Some(_)) => detected_line_ending = ending, 100 | (Some(LineEnding::CRLF), Some(LineEnding::LF)) => detected_line_ending = ending, 101 | _ => (), 102 | } 103 | } 104 | 105 | // Add back a line ending if `text` ends with the one we detect. 106 | if let Some(line_ending) = detected_line_ending { 107 | if text.ends_with(line_ending.as_str()) { 108 | unfilled.push_str(line_ending.as_str()); 109 | } 110 | } 111 | 112 | options.line_ending = detected_line_ending.unwrap_or(LineEnding::LF); 113 | (unfilled, options) 114 | } 115 | 116 | /// Refill a paragraph of wrapped text with a new width. 117 | /// 118 | /// This function will first use [`unfill()`] to remove newlines from 119 | /// the text. Afterwards the text is filled again using [`fill()`]. 120 | /// 121 | /// The `new_width_or_options` argument specify the new width and can 122 | /// specify other options as well — except for 123 | /// [`Options::initial_indent`] and [`Options::subsequent_indent`], 124 | /// which are deduced from `filled_text`. 125 | /// 126 | /// # Examples 127 | /// 128 | /// ``` 129 | /// use textwrap::refill; 130 | /// 131 | /// // Some loosely wrapped text. The "> " prefix is recognized automatically. 132 | /// let text = "\ 133 | /// > Memory 134 | /// > safety without garbage 135 | /// > collection. 136 | /// "; 137 | /// 138 | /// assert_eq!(refill(text, 20), "\ 139 | /// > Memory safety 140 | /// > without garbage 141 | /// > collection. 142 | /// "); 143 | /// 144 | /// assert_eq!(refill(text, 40), "\ 145 | /// > Memory safety without garbage 146 | /// > collection. 147 | /// "); 148 | /// 149 | /// assert_eq!(refill(text, 60), "\ 150 | /// > Memory safety without garbage collection. 151 | /// "); 152 | /// ``` 153 | /// 154 | /// You can also reshape bullet points: 155 | /// 156 | /// ``` 157 | /// use textwrap::refill; 158 | /// 159 | /// let text = "\ 160 | /// - This is my 161 | /// list item. 162 | /// "; 163 | /// 164 | /// assert_eq!(refill(text, 20), "\ 165 | /// - This is my list 166 | /// item. 167 | /// "); 168 | /// ``` 169 | pub fn refill<'a, Opt>(filled_text: &str, new_width_or_options: Opt) -> String 170 | where 171 | Opt: Into>, 172 | { 173 | let mut new_options = new_width_or_options.into(); 174 | let (text, options) = unfill(filled_text); 175 | // The original line ending is kept by `unfill`. 176 | let stripped = text.strip_suffix(options.line_ending.as_str()); 177 | let new_line_ending = new_options.line_ending.as_str(); 178 | 179 | new_options.initial_indent = options.initial_indent; 180 | new_options.subsequent_indent = options.subsequent_indent; 181 | let mut refilled = fill(stripped.unwrap_or(&text), new_options); 182 | 183 | // Add back right line ending if we stripped one off above. 184 | if stripped.is_some() { 185 | refilled.push_str(new_line_ending); 186 | } 187 | refilled 188 | } 189 | 190 | #[cfg(test)] 191 | mod tests { 192 | use super::*; 193 | 194 | #[test] 195 | fn unfill_simple() { 196 | let (text, options) = unfill("foo\nbar"); 197 | assert_eq!(text, "foo bar"); 198 | assert_eq!(options.width, 3); 199 | assert_eq!(options.line_ending, LineEnding::LF); 200 | } 201 | 202 | #[test] 203 | fn unfill_no_new_line() { 204 | let (text, options) = unfill("foo bar"); 205 | assert_eq!(text, "foo bar"); 206 | assert_eq!(options.width, 7); 207 | assert_eq!(options.line_ending, LineEnding::LF); 208 | } 209 | 210 | #[test] 211 | fn unfill_simple_crlf() { 212 | let (text, options) = unfill("foo\r\nbar"); 213 | assert_eq!(text, "foo bar"); 214 | assert_eq!(options.width, 3); 215 | assert_eq!(options.line_ending, LineEnding::CRLF); 216 | } 217 | 218 | #[test] 219 | fn unfill_mixed_new_lines() { 220 | let (text, options) = unfill("foo\r\nbar\nbaz"); 221 | assert_eq!(text, "foo bar baz"); 222 | assert_eq!(options.width, 3); 223 | assert_eq!(options.line_ending, LineEnding::LF); 224 | } 225 | 226 | #[test] 227 | fn test_unfill_consecutive_different_prefix() { 228 | let (text, options) = unfill("foo\n*\n/"); 229 | assert_eq!(text, "foo * /"); 230 | assert_eq!(options.width, 3); 231 | assert_eq!(options.line_ending, LineEnding::LF); 232 | } 233 | 234 | #[test] 235 | fn unfill_trailing_newlines() { 236 | let (text, options) = unfill("foo\nbar\n\n\n"); 237 | assert_eq!(text, "foo bar\n"); 238 | assert_eq!(options.width, 3); 239 | } 240 | 241 | #[test] 242 | fn unfill_mixed_trailing_newlines() { 243 | let (text, options) = unfill("foo\r\nbar\n\r\n\n"); 244 | assert_eq!(text, "foo bar\n"); 245 | assert_eq!(options.width, 3); 246 | assert_eq!(options.line_ending, LineEnding::LF); 247 | } 248 | 249 | #[test] 250 | fn unfill_trailing_crlf() { 251 | let (text, options) = unfill("foo bar\r\n"); 252 | assert_eq!(text, "foo bar\r\n"); 253 | assert_eq!(options.width, 7); 254 | assert_eq!(options.line_ending, LineEnding::CRLF); 255 | } 256 | 257 | #[test] 258 | fn unfill_initial_indent() { 259 | let (text, options) = unfill(" foo\nbar\nbaz"); 260 | assert_eq!(text, "foo bar baz"); 261 | assert_eq!(options.width, 5); 262 | assert_eq!(options.initial_indent, " "); 263 | } 264 | 265 | #[test] 266 | fn unfill_differing_indents() { 267 | let (text, options) = unfill(" foo\n bar\n baz"); 268 | assert_eq!(text, "foo bar baz"); 269 | assert_eq!(options.width, 7); 270 | assert_eq!(options.initial_indent, " "); 271 | assert_eq!(options.subsequent_indent, " "); 272 | } 273 | 274 | #[test] 275 | fn unfill_list_item() { 276 | let (text, options) = unfill("* foo\n bar\n baz"); 277 | assert_eq!(text, "foo bar baz"); 278 | assert_eq!(options.width, 5); 279 | assert_eq!(options.initial_indent, "* "); 280 | assert_eq!(options.subsequent_indent, " "); 281 | } 282 | 283 | #[test] 284 | fn unfill_multiple_char_prefix() { 285 | let (text, options) = unfill(" // foo bar\n // baz\n // quux"); 286 | assert_eq!(text, "foo bar baz quux"); 287 | assert_eq!(options.width, 14); 288 | assert_eq!(options.initial_indent, " // "); 289 | assert_eq!(options.subsequent_indent, " // "); 290 | } 291 | 292 | #[test] 293 | fn unfill_block_quote() { 294 | let (text, options) = unfill("> foo\n> bar\n> baz"); 295 | assert_eq!(text, "foo bar baz"); 296 | assert_eq!(options.width, 5); 297 | assert_eq!(options.initial_indent, "> "); 298 | assert_eq!(options.subsequent_indent, "> "); 299 | } 300 | 301 | #[test] 302 | fn unfill_only_prefixes_issue_466() { 303 | // Test that we don't crash if the first line has only prefix 304 | // chars *and* the second line is shorter than the first line. 305 | let (text, options) = unfill("######\nfoo"); 306 | assert_eq!(text, " foo"); 307 | assert_eq!(options.width, 6); 308 | assert_eq!(options.initial_indent, "######"); 309 | assert_eq!(options.subsequent_indent, ""); 310 | } 311 | 312 | #[test] 313 | fn unfill_trailing_newlines_issue_466() { 314 | // Test that we don't crash on a '\r' following a string of 315 | // '\n'. The problem was that we removed both kinds of 316 | // characters in one code path, but not in the other. 317 | let (text, options) = unfill("foo\n##\n\n\r"); 318 | // The \n\n changes subsequent_indent to "". 319 | assert_eq!(text, "foo ## \r"); 320 | assert_eq!(options.width, 3); 321 | assert_eq!(options.initial_indent, ""); 322 | assert_eq!(options.subsequent_indent, ""); 323 | } 324 | 325 | #[test] 326 | fn unfill_whitespace() { 327 | assert_eq!(unfill("foo bar").0, "foo bar"); 328 | } 329 | 330 | #[test] 331 | fn refill_convert_lf_to_crlf() { 332 | let options = Options::new(5).line_ending(LineEnding::CRLF); 333 | assert_eq!(refill("foo\nbar\n", options), "foo\r\nbar\r\n",); 334 | } 335 | 336 | #[test] 337 | fn refill_convert_crlf_to_lf() { 338 | let options = Options::new(5).line_ending(LineEnding::LF); 339 | assert_eq!(refill("foo\r\nbar\r\n", options), "foo\nbar\n",); 340 | } 341 | 342 | #[test] 343 | fn refill_convert_mixed_newlines() { 344 | let options = Options::new(5).line_ending(LineEnding::CRLF); 345 | assert_eq!(refill("foo\r\nbar\n", options), "foo\r\nbar\r\n",); 346 | } 347 | 348 | #[test] 349 | fn refill_defaults_to_lf() { 350 | assert_eq!(refill("foo bar baz", 5), "foo\nbar\nbaz"); 351 | } 352 | } 353 | -------------------------------------------------------------------------------- /src/termwidth.rs: -------------------------------------------------------------------------------- 1 | //! Functions related to the terminal size. 2 | 3 | use crate::Options; 4 | 5 | /// Return the current terminal width. 6 | /// 7 | /// If the terminal width cannot be determined (typically because the 8 | /// standard output is not connected to a terminal), a default width 9 | /// of 80 characters will be used. 10 | /// 11 | /// # Examples 12 | /// 13 | /// Create an [`Options`] for wrapping at the current terminal width 14 | /// with a two column margin to the left and the right: 15 | /// 16 | /// ```no_run 17 | /// use textwrap::{termwidth, Options}; 18 | /// 19 | /// let width = termwidth() - 4; // Two columns on each side. 20 | /// let options = Options::new(width) 21 | /// .initial_indent(" ") 22 | /// .subsequent_indent(" "); 23 | /// ``` 24 | /// 25 | /// **Note:** Only available when the `terminal_size` Cargo feature is 26 | /// enabled. 27 | pub fn termwidth() -> usize { 28 | terminal_size::terminal_size().map_or(80, |(terminal_size::Width(w), _)| w.into()) 29 | } 30 | 31 | impl<'a> Options<'a> { 32 | /// Creates a new [`Options`] with `width` set to the current 33 | /// terminal width. If the terminal width cannot be determined 34 | /// (typically because the standard input and output is not 35 | /// connected to a terminal), a width of 80 characters will be 36 | /// used. Other settings use the same defaults as 37 | /// [`Options::new`]. 38 | /// 39 | /// Equivalent to: 40 | /// 41 | /// ```no_run 42 | /// use textwrap::{termwidth, Options}; 43 | /// 44 | /// let options = Options::new(termwidth()); 45 | /// ``` 46 | /// 47 | /// **Note:** Only available when the `terminal_size` feature is 48 | /// enabled. 49 | pub fn with_termwidth() -> Self { 50 | Self::new(termwidth()) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/word_splitters.rs: -------------------------------------------------------------------------------- 1 | //! Word splitting functionality. 2 | //! 3 | //! To wrap text into lines, long words sometimes need to be split 4 | //! across lines. The [`WordSplitter`] enum defines this 5 | //! functionality. 6 | 7 | use crate::core::{display_width, Word}; 8 | 9 | /// The `WordSplitter` enum describes where words can be split. 10 | /// 11 | /// If the textwrap crate has been compiled with the `hyphenation` 12 | /// Cargo feature enabled, you will find a 13 | /// [`WordSplitter::Hyphenation`] variant. Use this struct for 14 | /// language-aware hyphenation: 15 | /// 16 | /// ``` 17 | /// #[cfg(feature = "hyphenation")] { 18 | /// use hyphenation::{Language, Load, Standard}; 19 | /// use textwrap::{wrap, Options, WordSplitter}; 20 | /// 21 | /// let text = "Oxidation is the loss of electrons."; 22 | /// let dictionary = Standard::from_embedded(Language::EnglishUS).unwrap(); 23 | /// let options = Options::new(8).word_splitter(WordSplitter::Hyphenation(dictionary)); 24 | /// assert_eq!(wrap(text, &options), vec!["Oxida-", 25 | /// "tion is", 26 | /// "the loss", 27 | /// "of elec-", 28 | /// "trons."]); 29 | /// } 30 | /// ``` 31 | /// 32 | /// Please see the documentation for the [hyphenation] crate for more 33 | /// details. 34 | /// 35 | /// [hyphenation]: https://docs.rs/hyphenation/ 36 | #[derive(Debug, Clone)] 37 | pub enum WordSplitter { 38 | /// Use this as a [`Options.word_splitter`] to avoid any kind of 39 | /// hyphenation: 40 | /// 41 | /// ``` 42 | /// use textwrap::{wrap, Options, WordSplitter}; 43 | /// 44 | /// let options = Options::new(8).word_splitter(WordSplitter::NoHyphenation); 45 | /// assert_eq!(wrap("foo bar-baz", &options), 46 | /// vec!["foo", "bar-baz"]); 47 | /// ``` 48 | /// 49 | /// [`Options.word_splitter`]: super::Options::word_splitter 50 | NoHyphenation, 51 | 52 | /// `HyphenSplitter` is the default `WordSplitter` used by 53 | /// [`Options::new`](super::Options::new). It will split words on 54 | /// existing hyphens in the word. 55 | /// 56 | /// It will only use hyphens that are surrounded by alphanumeric 57 | /// characters, which prevents a word like `"--foo-bar"` from 58 | /// being split into `"--"` and `"foo-bar"`. 59 | /// 60 | /// # Examples 61 | /// 62 | /// ``` 63 | /// use textwrap::WordSplitter; 64 | /// 65 | /// assert_eq!(WordSplitter::HyphenSplitter.split_points("--foo-bar"), 66 | /// vec![6]); 67 | /// ``` 68 | HyphenSplitter, 69 | 70 | /// Use a custom function as the word splitter. 71 | /// 72 | /// This variant lets you implement a custom word splitter using 73 | /// your own function. 74 | /// 75 | /// # Examples 76 | /// 77 | /// ``` 78 | /// use textwrap::WordSplitter; 79 | /// 80 | /// fn split_at_underscore(word: &str) -> Vec { 81 | /// word.match_indices('_').map(|(idx, _)| idx + 1).collect() 82 | /// } 83 | /// 84 | /// let word_splitter = WordSplitter::Custom(split_at_underscore); 85 | /// assert_eq!(word_splitter.split_points("a_long_identifier"), 86 | /// vec![2, 7]); 87 | /// ``` 88 | Custom(fn(word: &str) -> Vec), 89 | 90 | /// A hyphenation dictionary can be used to do language-specific 91 | /// hyphenation using patterns from the [hyphenation] crate. 92 | /// 93 | /// **Note:** Only available when the `hyphenation` Cargo feature is 94 | /// enabled. 95 | /// 96 | /// [hyphenation]: https://docs.rs/hyphenation/ 97 | #[cfg(feature = "hyphenation")] 98 | Hyphenation(hyphenation::Standard), 99 | } 100 | 101 | impl PartialEq for WordSplitter { 102 | fn eq(&self, other: &WordSplitter) -> bool { 103 | match (self, other) { 104 | (WordSplitter::NoHyphenation, WordSplitter::NoHyphenation) => true, 105 | (WordSplitter::HyphenSplitter, WordSplitter::HyphenSplitter) => true, 106 | #[cfg(feature = "hyphenation")] 107 | (WordSplitter::Hyphenation(this_dict), WordSplitter::Hyphenation(other_dict)) => { 108 | this_dict.language() == other_dict.language() 109 | } 110 | (_, _) => false, 111 | } 112 | } 113 | } 114 | 115 | impl WordSplitter { 116 | /// Return all possible indices where `word` can be split. 117 | /// 118 | /// The indices are in the range `0..word.len()`. They point to 119 | /// the index _after_ the split point, i.e., after `-` if 120 | /// splitting on hyphens. This way, `word.split_at(idx)` will 121 | /// break the word into two well-formed pieces. 122 | /// 123 | /// # Examples 124 | /// 125 | /// ``` 126 | /// use textwrap::WordSplitter; 127 | /// assert_eq!(WordSplitter::NoHyphenation.split_points("cannot-be-split"), vec![]); 128 | /// assert_eq!(WordSplitter::HyphenSplitter.split_points("can-be-split"), vec![4, 7]); 129 | /// assert_eq!(WordSplitter::Custom(|word| vec![word.len()/2]).split_points("middle"), vec![3]); 130 | /// ``` 131 | pub fn split_points(&self, word: &str) -> Vec { 132 | match self { 133 | WordSplitter::NoHyphenation => Vec::new(), 134 | WordSplitter::HyphenSplitter => { 135 | let mut splits = Vec::new(); 136 | 137 | for (idx, _) in word.match_indices('-') { 138 | // We only use hyphens that are surrounded by alphanumeric 139 | // characters. This is to avoid splitting on repeated hyphens, 140 | // such as those found in --foo-bar. 141 | let prev = word[..idx].chars().next_back(); 142 | let next = word[idx + 1..].chars().next(); 143 | 144 | if prev.filter(|ch| ch.is_alphanumeric()).is_some() 145 | && next.filter(|ch| ch.is_alphanumeric()).is_some() 146 | { 147 | splits.push(idx + 1); // +1 due to width of '-'. 148 | } 149 | } 150 | 151 | splits 152 | } 153 | WordSplitter::Custom(splitter_func) => splitter_func(word), 154 | #[cfg(feature = "hyphenation")] 155 | WordSplitter::Hyphenation(dictionary) => { 156 | use hyphenation::Hyphenator; 157 | dictionary.hyphenate(word).breaks 158 | } 159 | } 160 | } 161 | } 162 | 163 | /// Split words into smaller words according to the split points given 164 | /// by `word_splitter`. 165 | /// 166 | /// Note that we split all words, regardless of their length. This is 167 | /// to more cleanly separate the business of splitting (including 168 | /// automatic hyphenation) from the business of word wrapping. 169 | pub fn split_words<'a, I>( 170 | words: I, 171 | word_splitter: &'a WordSplitter, 172 | ) -> impl Iterator> 173 | where 174 | I: IntoIterator>, 175 | { 176 | words.into_iter().flat_map(move |word| { 177 | let mut prev = 0; 178 | let mut split_points = word_splitter.split_points(&word).into_iter(); 179 | std::iter::from_fn(move || { 180 | if let Some(idx) = split_points.next() { 181 | let need_hyphen = !word[..idx].ends_with('-'); 182 | let w = Word { 183 | word: &word.word[prev..idx], 184 | width: display_width(&word[prev..idx]), 185 | whitespace: "", 186 | penalty: if need_hyphen { "-" } else { "" }, 187 | }; 188 | prev = idx; 189 | return Some(w); 190 | } 191 | 192 | if prev < word.word.len() || prev == 0 { 193 | let w = Word { 194 | word: &word.word[prev..], 195 | width: display_width(&word[prev..]), 196 | whitespace: word.whitespace, 197 | penalty: word.penalty, 198 | }; 199 | prev = word.word.len() + 1; 200 | return Some(w); 201 | } 202 | 203 | None 204 | }) 205 | }) 206 | } 207 | 208 | #[cfg(test)] 209 | mod tests { 210 | use super::*; 211 | 212 | // Like assert_eq!, but the left expression is an iterator. 213 | macro_rules! assert_iter_eq { 214 | ($left:expr, $right:expr) => { 215 | assert_eq!($left.collect::>(), $right); 216 | }; 217 | } 218 | 219 | #[test] 220 | fn split_words_no_words() { 221 | assert_iter_eq!(split_words(vec![], &WordSplitter::HyphenSplitter), vec![]); 222 | } 223 | 224 | #[test] 225 | fn split_words_empty_word() { 226 | assert_iter_eq!( 227 | split_words(vec![Word::from(" ")], &WordSplitter::HyphenSplitter), 228 | vec![Word::from(" ")] 229 | ); 230 | } 231 | 232 | #[test] 233 | fn split_words_single_word() { 234 | assert_iter_eq!( 235 | split_words(vec![Word::from("foobar")], &WordSplitter::HyphenSplitter), 236 | vec![Word::from("foobar")] 237 | ); 238 | } 239 | 240 | #[test] 241 | fn split_words_hyphen_splitter() { 242 | assert_iter_eq!( 243 | split_words(vec![Word::from("foo-bar")], &WordSplitter::HyphenSplitter), 244 | vec![Word::from("foo-"), Word::from("bar")] 245 | ); 246 | } 247 | 248 | #[test] 249 | fn split_words_no_hyphenation() { 250 | assert_iter_eq!( 251 | split_words(vec![Word::from("foo-bar")], &WordSplitter::NoHyphenation), 252 | vec![Word::from("foo-bar")] 253 | ); 254 | } 255 | 256 | #[test] 257 | fn split_words_adds_penalty() { 258 | let fixed_split_point = |_: &str| vec![3]; 259 | 260 | assert_iter_eq!( 261 | split_words( 262 | vec![Word::from("foobar")].into_iter(), 263 | &WordSplitter::Custom(fixed_split_point) 264 | ), 265 | vec![ 266 | Word { 267 | word: "foo", 268 | width: 3, 269 | whitespace: "", 270 | penalty: "-" 271 | }, 272 | Word { 273 | word: "bar", 274 | width: 3, 275 | whitespace: "", 276 | penalty: "" 277 | } 278 | ] 279 | ); 280 | 281 | assert_iter_eq!( 282 | split_words( 283 | vec![Word::from("fo-bar")].into_iter(), 284 | &WordSplitter::Custom(fixed_split_point) 285 | ), 286 | vec![ 287 | Word { 288 | word: "fo-", 289 | width: 3, 290 | whitespace: "", 291 | penalty: "" 292 | }, 293 | Word { 294 | word: "bar", 295 | width: 3, 296 | whitespace: "", 297 | penalty: "" 298 | } 299 | ] 300 | ); 301 | } 302 | } 303 | -------------------------------------------------------------------------------- /tests/indent.rs: -------------------------------------------------------------------------------- 1 | /// tests cases ported over from python standard library 2 | use textwrap::{dedent, indent}; 3 | 4 | const ROUNDTRIP_CASES: [&str; 3] = [ 5 | // basic test case 6 | "Hi.\nThis is a test.\nTesting.", 7 | // include a blank line 8 | "Hi.\nThis is a test.\n\nTesting.", 9 | // include leading and trailing blank lines 10 | "\nHi.\nThis is a test.\nTesting.\n", 11 | ]; 12 | 13 | const WINDOWS_CASES: [&str; 2] = [ 14 | // use windows line endings 15 | "Hi.\r\nThis is a test.\r\nTesting.", 16 | // pathological case 17 | "Hi.\r\nThis is a test.\n\r\nTesting.\r\n\n", 18 | ]; 19 | 20 | #[test] 21 | fn test_indent_nomargin_default() { 22 | // indent should do nothing if 'prefix' is empty. 23 | for text in ROUNDTRIP_CASES.iter() { 24 | assert_eq!(&indent(text, ""), text); 25 | } 26 | for text in WINDOWS_CASES.iter() { 27 | assert_eq!(&indent(text, ""), text); 28 | } 29 | } 30 | 31 | #[test] 32 | fn test_roundtrip_spaces() { 33 | // A whitespace prefix should roundtrip with dedent 34 | for text in ROUNDTRIP_CASES.iter() { 35 | assert_eq!(&dedent(&indent(text, " ")), text); 36 | } 37 | } 38 | 39 | #[test] 40 | fn test_roundtrip_tabs() { 41 | // A whitespace prefix should roundtrip with dedent 42 | for text in ROUNDTRIP_CASES.iter() { 43 | assert_eq!(&dedent(&indent(text, "\t\t")), text); 44 | } 45 | } 46 | 47 | #[test] 48 | fn test_roundtrip_mixed() { 49 | // A whitespace prefix should roundtrip with dedent 50 | for text in ROUNDTRIP_CASES.iter() { 51 | assert_eq!(&dedent(&indent(text, " \t \t ")), text); 52 | } 53 | } 54 | 55 | #[test] 56 | fn test_indent_default() { 57 | // Test default indenting of lines that are not whitespace only 58 | let prefix = " "; 59 | let expected = [ 60 | // Basic test case 61 | " Hi.\n This is a test.\n Testing.", 62 | // Include a blank line 63 | " Hi.\n This is a test.\n\n Testing.", 64 | // Include leading and trailing blank lines 65 | "\n Hi.\n This is a test.\n Testing.\n", 66 | ]; 67 | for (text, expect) in ROUNDTRIP_CASES.iter().zip(expected.iter()) { 68 | assert_eq!(&indent(text, prefix), expect) 69 | } 70 | let expected = [ 71 | // Use Windows line endings 72 | " Hi.\r\n This is a test.\r\n Testing.", 73 | // Pathological case 74 | " Hi.\r\n This is a test.\n\r\n Testing.\r\n\n", 75 | ]; 76 | for (text, expect) in WINDOWS_CASES.iter().zip(expected.iter()) { 77 | assert_eq!(&indent(text, prefix), expect) 78 | } 79 | } 80 | 81 | #[test] 82 | fn indented_text_should_have_the_same_number_of_lines_as_the_original_text() { 83 | let texts = ["foo\nbar", "foo\nbar\n", "foo\nbar\nbaz"]; 84 | for original in texts.iter() { 85 | let indented = indent(original, ""); 86 | assert_eq!(&indented, original); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /tests/version-numbers.rs: -------------------------------------------------------------------------------- 1 | #[test] 2 | fn test_readme_deps() { 3 | version_sync::assert_markdown_deps_updated!("README.md"); 4 | } 5 | 6 | #[test] 7 | fn test_changelog() { 8 | version_sync::assert_contains_regex!( 9 | "CHANGELOG.md", 10 | r"^## Version {version} \(20\d\d-\d\d-\d\d\)" 11 | ); 12 | } 13 | 14 | #[test] 15 | fn test_html_root_url() { 16 | version_sync::assert_html_root_url_updated!("src/lib.rs"); 17 | } 18 | 19 | #[test] 20 | fn test_dependency_graph() { 21 | version_sync::assert_contains_regex!("src/lib.rs", "master/images/textwrap-{version}.svg"); 22 | } 23 | --------------------------------------------------------------------------------