├── .git-blame-ignore-revs ├── .github └── workflows │ ├── ci.yml │ └── clean.yml ├── .gitignore ├── .jvmopts ├── .scalafmt.conf ├── LICENSE ├── README.md ├── benchmark └── src │ └── main │ └── scala │ └── PaigesBenchmark.scala ├── build.sbt ├── cats ├── js │ └── src │ │ └── main │ │ └── scala │ │ └── org │ │ └── typelevel │ │ └── paiges │ │ └── Platform.scala ├── jvm │ └── src │ │ └── main │ │ └── scala │ │ └── org │ │ └── typelevel │ │ └── paiges │ │ └── Platform.scala ├── native │ └── src │ │ └── main │ │ └── scala │ │ └── org │ │ └── typelevel │ │ └── paiges │ │ └── Platform.scala └── shared │ └── src │ ├── main │ └── scala │ │ └── org │ │ └── typelevel │ │ └── paiges │ │ ├── CatsDocument.scala │ │ └── instances.scala │ └── test │ └── scala │ └── org │ └── typelevel │ └── paiges │ └── LawTests.scala ├── core └── src │ ├── main │ ├── scala-2.12- │ │ └── org │ │ │ └── typelevel │ │ │ └── paiges │ │ │ └── ScalaVersionCompat.scala │ ├── scala-2.13+ │ │ └── org │ │ │ └── typelevel │ │ │ └── paiges │ │ │ └── ScalaVersionCompat.scala │ └── scala │ │ └── org │ │ └── typelevel │ │ └── paiges │ │ ├── Chunk.scala │ │ ├── Doc.scala │ │ ├── Document.scala │ │ ├── Style.scala │ │ └── package.scala │ └── test │ └── scala │ └── org │ └── typelevel │ └── paiges │ ├── ColorTest.scala │ ├── DocumentTests.scala │ ├── Generators.scala │ ├── JsonTest.scala │ ├── PaigesScalacheckTest.scala │ └── PaigesTest.scala ├── docs └── src │ └── main │ └── mdoc │ └── index.md └── project ├── build.properties └── plugins.sbt /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Scala Steward: Reformat with scalafmt 3.8.4 2 | ef3e9f47047f2229b40f3a96977ebd50b92eb730 3 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # This file was automatically generated by sbt-github-actions using the 2 | # githubWorkflowGenerate task. You should add and commit this file to 3 | # your git repository. It goes without saying that you shouldn't edit 4 | # this file by hand! Instead, if you wish to make changes, you should 5 | # change your sbt build configuration to revise the workflow description 6 | # to meet your needs, then regenerate this file. 7 | 8 | name: Continuous Integration 9 | 10 | on: 11 | pull_request: 12 | branches: ['**', '!update/**', '!pr/**'] 13 | push: 14 | branches: ['**', '!update/**', '!pr/**'] 15 | tags: [v*] 16 | 17 | env: 18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 19 | 20 | 21 | concurrency: 22 | group: ${{ github.workflow }} @ ${{ github.ref }} 23 | cancel-in-progress: true 24 | 25 | jobs: 26 | build: 27 | name: Test 28 | strategy: 29 | matrix: 30 | os: [ubuntu-22.04] 31 | scala: [2.13, 2.12, 3] 32 | java: [temurin@8] 33 | project: [rootJS, rootJVM, rootNative] 34 | runs-on: ${{ matrix.os }} 35 | timeout-minutes: 60 36 | steps: 37 | - name: Checkout current branch (full) 38 | uses: actions/checkout@v4 39 | with: 40 | fetch-depth: 0 41 | 42 | - name: Setup sbt 43 | uses: sbt/setup-sbt@v1 44 | 45 | - name: Setup Java (temurin@8) 46 | id: setup-java-temurin-8 47 | if: matrix.java == 'temurin@8' 48 | uses: actions/setup-java@v4 49 | with: 50 | distribution: temurin 51 | java-version: 8 52 | cache: sbt 53 | 54 | - name: sbt update 55 | if: matrix.java == 'temurin@8' && steps.setup-java-temurin-8.outputs.cache-hit == 'false' 56 | run: sbt +update 57 | 58 | - name: Check that workflows are up to date 59 | run: sbt githubWorkflowCheck 60 | 61 | - name: Check headers and formatting 62 | if: matrix.java == 'temurin@8' && matrix.os == 'ubuntu-22.04' 63 | run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' headerCheckAll scalafmtCheckAll 'project /' scalafmtSbtCheck 64 | 65 | - name: scalaJSLink 66 | if: matrix.project == 'rootJS' 67 | run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' Test/scalaJSLinkerResult 68 | 69 | - name: nativeLink 70 | if: matrix.project == 'rootNative' 71 | run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' Test/nativeLink 72 | 73 | - name: Test 74 | run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' test 75 | 76 | - name: Check binary compatibility 77 | if: matrix.java == 'temurin@8' && matrix.os == 'ubuntu-22.04' 78 | run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' mimaReportBinaryIssues 79 | 80 | - name: Generate API documentation 81 | if: matrix.java == 'temurin@8' && matrix.os == 'ubuntu-22.04' 82 | run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' doc 83 | 84 | - name: Make target directories 85 | if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/master') 86 | run: mkdir -p cats/native/target core/.native/target core/.js/target core/.jvm/target cats/js/target cats/jvm/target project/target 87 | 88 | - name: Compress target directories 89 | if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/master') 90 | run: tar cf targets.tar cats/native/target core/.native/target core/.js/target core/.jvm/target cats/js/target cats/jvm/target project/target 91 | 92 | - name: Upload target directories 93 | if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/master') 94 | uses: actions/upload-artifact@v4 95 | with: 96 | name: target-${{ matrix.os }}-${{ matrix.java }}-${{ matrix.scala }}-${{ matrix.project }} 97 | path: targets.tar 98 | 99 | publish: 100 | name: Publish Artifacts 101 | needs: [build] 102 | if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/master') 103 | strategy: 104 | matrix: 105 | os: [ubuntu-22.04] 106 | java: [temurin@8] 107 | runs-on: ${{ matrix.os }} 108 | steps: 109 | - name: Checkout current branch (full) 110 | uses: actions/checkout@v4 111 | with: 112 | fetch-depth: 0 113 | 114 | - name: Setup sbt 115 | uses: sbt/setup-sbt@v1 116 | 117 | - name: Setup Java (temurin@8) 118 | id: setup-java-temurin-8 119 | if: matrix.java == 'temurin@8' 120 | uses: actions/setup-java@v4 121 | with: 122 | distribution: temurin 123 | java-version: 8 124 | cache: sbt 125 | 126 | - name: sbt update 127 | if: matrix.java == 'temurin@8' && steps.setup-java-temurin-8.outputs.cache-hit == 'false' 128 | run: sbt +update 129 | 130 | - name: Download target directories (2.13, rootJS) 131 | uses: actions/download-artifact@v4 132 | with: 133 | name: target-${{ matrix.os }}-${{ matrix.java }}-2.13-rootJS 134 | 135 | - name: Inflate target directories (2.13, rootJS) 136 | run: | 137 | tar xf targets.tar 138 | rm targets.tar 139 | 140 | - name: Download target directories (2.13, rootJVM) 141 | uses: actions/download-artifact@v4 142 | with: 143 | name: target-${{ matrix.os }}-${{ matrix.java }}-2.13-rootJVM 144 | 145 | - name: Inflate target directories (2.13, rootJVM) 146 | run: | 147 | tar xf targets.tar 148 | rm targets.tar 149 | 150 | - name: Download target directories (2.13, rootNative) 151 | uses: actions/download-artifact@v4 152 | with: 153 | name: target-${{ matrix.os }}-${{ matrix.java }}-2.13-rootNative 154 | 155 | - name: Inflate target directories (2.13, rootNative) 156 | run: | 157 | tar xf targets.tar 158 | rm targets.tar 159 | 160 | - name: Download target directories (2.12, rootJS) 161 | uses: actions/download-artifact@v4 162 | with: 163 | name: target-${{ matrix.os }}-${{ matrix.java }}-2.12-rootJS 164 | 165 | - name: Inflate target directories (2.12, rootJS) 166 | run: | 167 | tar xf targets.tar 168 | rm targets.tar 169 | 170 | - name: Download target directories (2.12, rootJVM) 171 | uses: actions/download-artifact@v4 172 | with: 173 | name: target-${{ matrix.os }}-${{ matrix.java }}-2.12-rootJVM 174 | 175 | - name: Inflate target directories (2.12, rootJVM) 176 | run: | 177 | tar xf targets.tar 178 | rm targets.tar 179 | 180 | - name: Download target directories (2.12, rootNative) 181 | uses: actions/download-artifact@v4 182 | with: 183 | name: target-${{ matrix.os }}-${{ matrix.java }}-2.12-rootNative 184 | 185 | - name: Inflate target directories (2.12, rootNative) 186 | run: | 187 | tar xf targets.tar 188 | rm targets.tar 189 | 190 | - name: Download target directories (3, rootJS) 191 | uses: actions/download-artifact@v4 192 | with: 193 | name: target-${{ matrix.os }}-${{ matrix.java }}-3-rootJS 194 | 195 | - name: Inflate target directories (3, rootJS) 196 | run: | 197 | tar xf targets.tar 198 | rm targets.tar 199 | 200 | - name: Download target directories (3, rootJVM) 201 | uses: actions/download-artifact@v4 202 | with: 203 | name: target-${{ matrix.os }}-${{ matrix.java }}-3-rootJVM 204 | 205 | - name: Inflate target directories (3, rootJVM) 206 | run: | 207 | tar xf targets.tar 208 | rm targets.tar 209 | 210 | - name: Download target directories (3, rootNative) 211 | uses: actions/download-artifact@v4 212 | with: 213 | name: target-${{ matrix.os }}-${{ matrix.java }}-3-rootNative 214 | 215 | - name: Inflate target directories (3, rootNative) 216 | run: | 217 | tar xf targets.tar 218 | rm targets.tar 219 | 220 | - name: Import signing key 221 | if: env.PGP_SECRET != '' && env.PGP_PASSPHRASE == '' 222 | env: 223 | PGP_SECRET: ${{ secrets.PGP_SECRET }} 224 | PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }} 225 | run: echo $PGP_SECRET | base64 -d -i - | gpg --import 226 | 227 | - name: Import signing key and strip passphrase 228 | if: env.PGP_SECRET != '' && env.PGP_PASSPHRASE != '' 229 | env: 230 | PGP_SECRET: ${{ secrets.PGP_SECRET }} 231 | PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }} 232 | run: | 233 | echo "$PGP_SECRET" | base64 -d -i - > /tmp/signing-key.gpg 234 | echo "$PGP_PASSPHRASE" | gpg --pinentry-mode loopback --passphrase-fd 0 --import /tmp/signing-key.gpg 235 | (echo "$PGP_PASSPHRASE"; echo; echo) | gpg --command-fd 0 --pinentry-mode loopback --change-passphrase $(gpg --list-secret-keys --with-colons 2> /dev/null | grep '^sec:' | cut --delimiter ':' --fields 5 | tail -n 1) 236 | 237 | - name: Publish 238 | env: 239 | SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} 240 | SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} 241 | SONATYPE_CREDENTIAL_HOST: ${{ secrets.SONATYPE_CREDENTIAL_HOST }} 242 | run: sbt tlCiRelease 243 | 244 | dependency-submission: 245 | name: Submit Dependencies 246 | if: github.event.repository.fork == false && github.event_name != 'pull_request' 247 | strategy: 248 | matrix: 249 | os: [ubuntu-22.04] 250 | java: [temurin@8] 251 | runs-on: ${{ matrix.os }} 252 | steps: 253 | - name: Checkout current branch (full) 254 | uses: actions/checkout@v4 255 | with: 256 | fetch-depth: 0 257 | 258 | - name: Setup sbt 259 | uses: sbt/setup-sbt@v1 260 | 261 | - name: Setup Java (temurin@8) 262 | id: setup-java-temurin-8 263 | if: matrix.java == 'temurin@8' 264 | uses: actions/setup-java@v4 265 | with: 266 | distribution: temurin 267 | java-version: 8 268 | cache: sbt 269 | 270 | - name: sbt update 271 | if: matrix.java == 'temurin@8' && steps.setup-java-temurin-8.outputs.cache-hit == 'false' 272 | run: sbt +update 273 | 274 | - name: Submit Dependencies 275 | uses: scalacenter/sbt-dependency-submission@v2 276 | with: 277 | modules-ignore: rootjs_2.13 rootjs_2.12 rootjs_3 paiges-docs_2.13 paiges-docs_2.12 paiges-docs_3 rootjvm_2.13 rootjvm_2.12 rootjvm_3 rootnative_2.13 rootnative_2.12 rootnative_3 paiges-benchmark_2.13 paiges-benchmark_2.12 paiges-benchmark_3 278 | configs-ignore: test scala-tool scala-doc-tool test-internal 279 | 280 | coverage: 281 | name: Generate coverage report 282 | strategy: 283 | matrix: 284 | os: [ubuntu-22.04] 285 | scala: [2.13.12] 286 | java: [temurin@11] 287 | runs-on: ${{ matrix.os }} 288 | steps: 289 | - name: Checkout current branch (fast) 290 | uses: actions/checkout@v4 291 | 292 | - name: Setup Java (temurin@8) 293 | id: setup-java-temurin-8 294 | if: matrix.java == 'temurin@8' 295 | uses: actions/setup-java@v4 296 | with: 297 | distribution: temurin 298 | java-version: 8 299 | cache: sbt 300 | 301 | - name: sbt update 302 | if: matrix.java == 'temurin@8' && steps.setup-java-temurin-8.outputs.cache-hit == 'false' 303 | run: sbt +update 304 | 305 | - run: sbt '++ ${{ matrix.scala }}' coverage rootJVM/test coverageAggregate 306 | 307 | - run: 'bash <(curl -s https://codecov.io/bash)' 308 | 309 | site: 310 | name: Generate Site 311 | strategy: 312 | matrix: 313 | os: [ubuntu-22.04] 314 | java: [temurin@11] 315 | runs-on: ${{ matrix.os }} 316 | steps: 317 | - name: Checkout current branch (full) 318 | uses: actions/checkout@v4 319 | with: 320 | fetch-depth: 0 321 | 322 | - name: Setup sbt 323 | uses: sbt/setup-sbt@v1 324 | 325 | - name: Setup Java (temurin@8) 326 | id: setup-java-temurin-8 327 | if: matrix.java == 'temurin@8' 328 | uses: actions/setup-java@v4 329 | with: 330 | distribution: temurin 331 | java-version: 8 332 | cache: sbt 333 | 334 | - name: sbt update 335 | if: matrix.java == 'temurin@8' && steps.setup-java-temurin-8.outputs.cache-hit == 'false' 336 | run: sbt +update 337 | 338 | - name: Setup Java (temurin@11) 339 | id: setup-java-temurin-11 340 | if: matrix.java == 'temurin@11' 341 | uses: actions/setup-java@v4 342 | with: 343 | distribution: temurin 344 | java-version: 11 345 | cache: sbt 346 | 347 | - name: sbt update 348 | if: matrix.java == 'temurin@11' && steps.setup-java-temurin-11.outputs.cache-hit == 'false' 349 | run: sbt +update 350 | 351 | - name: Generate site 352 | run: sbt docs/tlSite 353 | 354 | - name: Publish site 355 | if: github.event_name != 'pull_request' && github.ref == 'refs/heads/master' 356 | uses: peaceiris/actions-gh-pages@v4.0.0 357 | with: 358 | github_token: ${{ secrets.GITHUB_TOKEN }} 359 | publish_dir: docs/target/docs/site 360 | keep_files: true 361 | -------------------------------------------------------------------------------- /.github/workflows/clean.yml: -------------------------------------------------------------------------------- 1 | # This file was automatically generated by sbt-github-actions using the 2 | # githubWorkflowGenerate task. You should add and commit this file to 3 | # your git repository. It goes without saying that you shouldn't edit 4 | # this file by hand! Instead, if you wish to make changes, you should 5 | # change your sbt build configuration to revise the workflow description 6 | # to meet your needs, then regenerate this file. 7 | 8 | name: Clean 9 | 10 | on: push 11 | 12 | jobs: 13 | delete-artifacts: 14 | name: Delete Artifacts 15 | runs-on: ubuntu-latest 16 | env: 17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 18 | steps: 19 | - name: Delete artifacts 20 | run: | 21 | # Customize those three lines with your repository and credentials: 22 | REPO=${GITHUB_API_URL}/repos/${{ github.repository }} 23 | 24 | # A shortcut to call GitHub API. 25 | ghapi() { curl --silent --location --user _:$GITHUB_TOKEN "$@"; } 26 | 27 | # A temporary file which receives HTTP response headers. 28 | TMPFILE=/tmp/tmp.$$ 29 | 30 | # An associative array, key: artifact name, value: number of artifacts of that name. 31 | declare -A ARTCOUNT 32 | 33 | # Process all artifacts on this repository, loop on returned "pages". 34 | URL=$REPO/actions/artifacts 35 | while [[ -n "$URL" ]]; do 36 | 37 | # Get current page, get response headers in a temporary file. 38 | JSON=$(ghapi --dump-header $TMPFILE "$URL") 39 | 40 | # Get URL of next page. Will be empty if we are at the last page. 41 | URL=$(grep '^Link:' "$TMPFILE" | tr ',' '\n' | grep 'rel="next"' | head -1 | sed -e 's/.*.*//') 42 | rm -f $TMPFILE 43 | 44 | # Number of artifacts on this page: 45 | COUNT=$(( $(jq <<<$JSON -r '.artifacts | length') )) 46 | 47 | # Loop on all artifacts on this page. 48 | for ((i=0; $i < $COUNT; i++)); do 49 | 50 | # Get name of artifact and count instances of this name. 51 | name=$(jq <<<$JSON -r ".artifacts[$i].name?") 52 | ARTCOUNT[$name]=$(( $(( ${ARTCOUNT[$name]} )) + 1)) 53 | 54 | id=$(jq <<<$JSON -r ".artifacts[$i].id?") 55 | size=$(( $(jq <<<$JSON -r ".artifacts[$i].size_in_bytes?") )) 56 | printf "Deleting '%s' #%d, %'d bytes\n" $name ${ARTCOUNT[$name]} $size 57 | ghapi -X DELETE $REPO/actions/artifacts/$id 58 | done 59 | done 60 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.class 2 | *.log 3 | 4 | # sbt specific 5 | .cache 6 | .history 7 | .lib/ 8 | dist/* 9 | target/ 10 | lib_managed/ 11 | src_managed/ 12 | project/boot/ 13 | project/plugins/project/ 14 | docs/_site 15 | 16 | # Scala-IDE specific 17 | .scala_dependencies 18 | .worksheet 19 | 20 | # Sublime 21 | *.sublime-project 22 | *.sublime-workspace 23 | 24 | # VSCode 25 | /.vscode/ 26 | 27 | # Metals 28 | /.bsp/ 29 | /project/**/metals.sbt 30 | 31 | # emacs 32 | TAGS 33 | -------------------------------------------------------------------------------- /.jvmopts: -------------------------------------------------------------------------------- 1 | -Xms1G 2 | -Xmx4G 3 | -XX:+UseG1GC -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version = "3.8.4" 2 | align.openParenCallSite = true 3 | align.openParenDefnSite = true 4 | maxColumn = 120 5 | continuationIndent.defnSite = 2 6 | assumeStandardLibraryStripMargin = true 7 | danglingParentheses.preset = true 8 | rewrite.rules = [AvoidInfix, SortImports, RedundantBraces, RedundantParens, SortModifiers] 9 | docstrings.style = Asterisk 10 | docstrings.wrap = no 11 | runner.dialect = scala213 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Paiges 2 | 3 | ## Overview 4 | 5 | Paiges is an implementation of 6 | [Wadler's "A Prettier Printer"](http://homepages.inf.ed.ac.uk/wadler/papers/prettier/prettier.pdf). 7 | 8 | The library is useful any time you find yourself generating text or 9 | source code where you'd like to control the length of lines (e.g. 10 | paragraph wrapping). See the [documentation site](http://typelevel.org/paiges/) for some examples. 11 | 12 | The name *Paiges* is a reference to the [Paige compositor](https://en.wikipedia.org/wiki/Paige_Compositor) 13 | and the fact that it helps you layout pages. 14 | 15 | ![CI](https://github.com/typelevel/paiges/actions/workflows/ci.yml/badge.svg) 16 | [![codecov.io](http://codecov.io/github/typelevel/paiges/coverage.svg?branch=master)](http://codecov.io/github/typelevel/paiges?branch=master) 17 | [![Latest version](https://index.scala-lang.org/typelevel/paiges/paiges-core/latest.svg?color=orange)](https://index.scala-lang.org/typelevel/paiges/paiges-core) 18 | 19 | ## Quick Start 20 | 21 | Paiges supports Scala 2.11, 2.12, 2.13, and 3. It supports JVM, Scala.js, 22 | and Scala Native platforms. 23 | 24 | To use Paiges in your own project, you can include this snippet in 25 | your `build.sbt` file: 26 | 27 | ```scala 28 | // use this snippet for the JVM 29 | libraryDependencies += "org.typelevel" %% "paiges-core" % "0.4.2" 30 | 31 | // use this snippet for JS, Native, or cross-building 32 | libraryDependencies += "org.typelevel" %%% "paiges-core" % "0.4.2" 33 | ``` 34 | 35 | Paiges also provides types to work with Cats via the *paiges-cats* 36 | module: 37 | 38 | ```scala 39 | // use this snippet for the JVM 40 | libraryDependencies += "org.typelevel" %% "paiges-cats" % "0.4.2" 41 | 42 | // use this snippet for JS, Native, or cross-building 43 | libraryDependencies += "org.typelevel" %%% "paiges-cats" % "0.4.2" 44 | ``` 45 | 46 | ## Description 47 | 48 | This code is a direct port of the code in section 7 of this paper, 49 | with an attempt to be idiomatic in scala while preserving the original 50 | code's properties, including laziness. 51 | 52 | This algorithm is optimal and bounded. From the paper: 53 | 54 | > Say that a pretty printing algorithm is optimal if it chooses line 55 | > breaks so as to avoid overflow whenever possible; say that it is 56 | > bounded if it can make this choice after looking at no more than the 57 | > next w characters, where w is the line width. Hughes notes that 58 | > there is no algorithm to choose line breaks for his combinators that 59 | > is optimal and bounded, while the layout algorithm presented here 60 | > has both properties. 61 | 62 | Some selling points of this code: 63 | 64 | 1. Lazy, O(1) concatenation 65 | 2. Competitive performance (e.g. 3-5x slower than `mkString`) 66 | 3. Elegantly handle indentation 67 | 4. Flexible line-wrapping strategies 68 | 5. Functional cred ;) 69 | 70 | ## Examples 71 | 72 | Here's an example of using Paiges to generate the source code for a 73 | case class: 74 | 75 | ```scala 76 | import org.typelevel.paiges._ 77 | 78 | /** 79 | * Produces a case class given a name and zero-or-more 80 | * field/type pairs. 81 | */ 82 | def mkCaseClass(name: String, fields: (String, String)*): Doc = { 83 | val prefix = Doc.text("case class ") + Doc.text(name) + Doc.char('(') 84 | val suffix = Doc.char(')') 85 | val types = fields.map { case (k, v) => 86 | Doc.text(k) + Doc.char(':') + Doc.space + Doc.text(v) 87 | } 88 | val body = Doc.intercalate(Doc.char(',') + Doc.line, types) 89 | body.tightBracketBy(prefix, suffix) 90 | } 91 | 92 | val c = mkCaseClass( 93 | "Dog", "name" -> "String", "breed" -> "String", 94 | "height" -> "Int", "weight" -> "Int") 95 | 96 | c.render(80) 97 | // case class Dog(name: String, breed: String, height: Int, weight: Int) 98 | 99 | c.render(60) 100 | // case class Dog( 101 | // name: String, 102 | // breed: String, 103 | // height: Int, 104 | // weight: Int 105 | // ) 106 | ``` 107 | 108 | For more examples, see the [tutorial](docs/src/main/mdoc/index.md). 109 | 110 | ## Benchmarks 111 | 112 | The Paiges benchmarks are written against JMH. To run them, you'll 113 | want to use a command like this from SBT: 114 | 115 | ``` 116 | benchmark/jmh:run -wi 5 -i 5 -f1 -t1 bench.PaigesBenchmark 117 | ``` 118 | 119 | By default the values reported are *ops/ms* (operations per 120 | millisecond), so higher numbers are better. 121 | 122 | The parameters used here are: 123 | 124 | * `-wi`: the number of times to run during warmup 125 | * `-i`: the number of times to benchmark 126 | * `-f`: the number of processes to use during benchmarking 127 | * `-t`: the number of threads to use during benchmarking 128 | 129 | In other words, the example command-line runs one thread in one 130 | process, with a relatively small number of warmups + runs (so that it 131 | will finish relatively quickly). 132 | 133 | ## Organization 134 | 135 | The current Paiges maintainers are: 136 | 137 | * [Oscar Boykin](https://github.com/johnynek) 138 | * [Colt Frederickson](https://github.com/coltfred) 139 | * [Erik Osheim](https://github.com/non) 140 | 141 | People are expected to follow the [Typelevel Code of Conduct](http://typelevel.org/conduct.html) 142 | when discussing Paiges on the Github page or other official venues. 143 | 144 | Concerns or issues can be sent to any of Paiges' maintainers, or to 145 | the Typelevel organization. 146 | 147 | ## License 148 | 149 | Paiges is licensed under the [Apache License, Version 2.0](LICENSE) 150 | (the "License"); you may not use this software except in compliance 151 | with the License. 152 | 153 | Unless required by applicable law or agreed to in writing, software 154 | distributed under the License is distributed on an "AS IS" BASIS, 155 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 156 | implied. See the License for the specific language governing 157 | permissions and limitations under the License. 158 | -------------------------------------------------------------------------------- /benchmark/src/main/scala/PaigesBenchmark.scala: -------------------------------------------------------------------------------- 1 | package bench 2 | 3 | import java.util.concurrent.TimeUnit 4 | import org.openjdk.jmh.annotations._ 5 | 6 | import org.typelevel.paiges.Doc 7 | 8 | @State(Scope.Benchmark) 9 | @OutputTimeUnit(TimeUnit.MILLISECONDS) 10 | class PaigesBenchmark { 11 | 12 | @Param(Array("1", "10", "100", "1000", "10000")) 13 | var size: Int = 0 14 | 15 | var strs: Vector[String] = Vector.empty 16 | 17 | @Setup 18 | def setup(): Unit = 19 | strs = (1 to size).map(_.toString).toVector 20 | 21 | @Benchmark 22 | def mkstring(): String = 23 | strs.mkString 24 | 25 | @Benchmark 26 | def mkstringComma(): String = 27 | strs.mkString(", ") 28 | 29 | @Benchmark 30 | def docConcat(): String = 31 | strs 32 | .foldLeft(Doc.empty)((d1, s) => d1 :+ s) 33 | .render(0) 34 | 35 | @Benchmark 36 | def docConcatComma(): String = 37 | strs 38 | .foldLeft(Doc.empty)((d1, s) => d1 :+ ", " :+ s) 39 | .render(0) 40 | 41 | @Benchmark 42 | def intercalate(): String = 43 | Doc.intercalate(Doc.text(", "), strs.map(Doc.text)).render(0) 44 | 45 | @Benchmark 46 | def fill0(): String = 47 | Doc.fill(Doc.line, strs.map(Doc.text)).render(0) 48 | 49 | @Benchmark 50 | def fill100(): String = 51 | Doc.fill(Doc.line, strs.map(Doc.text)).render(100) 52 | } 53 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | val Scala212 = "2.12.20" 2 | val Scala213 = "2.13.16" 3 | val Scala3Version = "3.3.4" 4 | 5 | ThisBuild / tlBaseVersion := "0.4" 6 | 7 | ThisBuild / startYear := Some(2017) 8 | ThisBuild / scalaVersion := Scala213 9 | ThisBuild / tlVersionIntroduced := Map("3" -> "0.4.2") 10 | ThisBuild / crossScalaVersions := Seq(Scala213, Scala212, Scala3Version) 11 | 12 | // Setup coverage 13 | ThisBuild / githubWorkflowAddedJobs += 14 | WorkflowJob( 15 | id = "coverage", 16 | name = "Generate coverage report", 17 | scalas = List("2.13.12"), 18 | steps = List(WorkflowStep.Checkout) ++ WorkflowStep.SetupJava( 19 | githubWorkflowJavaVersions.value.toList 20 | ) ++ githubWorkflowGeneratedCacheSteps.value ++ List( 21 | WorkflowStep.Sbt(List("coverage", "rootJVM/test", "coverageAggregate")), 22 | WorkflowStep.Run(List("bash <(curl -s https://codecov.io/bash)")) 23 | ) 24 | ) 25 | 26 | ThisBuild / tlCiReleaseBranches := Seq("master") 27 | ThisBuild / tlSitePublishBranch := Some("master") 28 | 29 | lazy val root = tlCrossRootProject.aggregate(core, cats) 30 | 31 | ThisBuild / developers := List( 32 | // your GitHub handle and name 33 | tlGitHubDev("johnynek", "Oscar Boykin"), 34 | tlGitHubDev("coltfred", "Colt Frederickson"), 35 | tlGitHubDev("non", "Erik Osheim") 36 | ) 37 | 38 | lazy val core = crossProject(JSPlatform, JVMPlatform, NativePlatform) 39 | .crossType(CrossType.Pure) 40 | .in(file("core")) 41 | .settings( 42 | commonSettings, 43 | name := "paiges-core", 44 | moduleName := "paiges-core", 45 | libraryDependencies ++= Seq( 46 | "org.scalatestplus" %%% "scalacheck-1-18" % "3.2.19.0" % Test, 47 | "org.scalatest" %%% "scalatest-funsuite" % "3.2.19" % Test 48 | ), 49 | // TODO: 2.13 has warnings for using Stream, but scalacheck Shrink 50 | tlFatalWarnings := scalaVersion.value.startsWith("2.12."), 51 | mimaBinaryIssueFilters ++= { 52 | if (tlIsScala3.value) { 53 | import com.typesafe.tools.mima.core._ 54 | Seq( 55 | ProblemFilters.exclude[IncompatibleMethTypeProblem]("org.typelevel.paiges.Chunk*"), 56 | ProblemFilters.exclude[DirectMissingMethodProblem]("org.typelevel.paiges.Chunk#ChunkStream#3#Empty.this") 57 | ) 58 | } else Nil 59 | } 60 | ) 61 | .disablePlugins(JmhPlugin) 62 | .jsSettings(commonJsSettings) 63 | .jvmSettings(commonJvmSettings) 64 | .nativeSettings(commonNativeSettings) 65 | 66 | lazy val coreJVM = core.jvm 67 | lazy val coreJS = core.js 68 | lazy val coreNative = core.native 69 | 70 | lazy val cats = crossProject(JSPlatform, JVMPlatform, NativePlatform) 71 | .crossType(CrossType.Full) 72 | .in(file("cats")) 73 | .dependsOn(core % "compile->compile;test->test") 74 | .settings( 75 | commonSettings, 76 | name := "paiges-cats", 77 | moduleName := "paiges-cats", 78 | libraryDependencies ++= Seq( 79 | "org.typelevel" %%% "cats-core" % "2.12.0", 80 | "org.typelevel" %%% "cats-laws" % "2.12.0" % Test, 81 | "org.typelevel" %%% "discipline-scalatest" % "2.3.0" % Test 82 | ) 83 | ) 84 | .disablePlugins(JmhPlugin) 85 | .jsSettings(commonJsSettings) 86 | .jvmSettings(commonJvmSettings) 87 | .nativeSettings(commonNativeSettings) 88 | 89 | lazy val catsJVM = cats.jvm 90 | lazy val catsJS = cats.js 91 | lazy val catsNative = cats.native 92 | 93 | lazy val benchmark = project 94 | .in(file("benchmark")) 95 | .dependsOn(coreJVM, catsJVM) 96 | .enablePlugins(NoPublishPlugin) 97 | .settings( 98 | name := "paiges-benchmark" 99 | ) 100 | .enablePlugins(JmhPlugin) 101 | 102 | lazy val docs = project 103 | .in(file("docs")) 104 | .dependsOn(coreJVM, catsJVM) 105 | .enablePlugins(TypelevelSitePlugin) 106 | .settings( 107 | name := "paiges-docs", 108 | mdocIn := (LocalRootProject / baseDirectory).value / "docs" / "src" / "main" / "mdoc" 109 | ) 110 | 111 | lazy val commonSettings = Seq( 112 | scalacOptions ++= ( 113 | CrossVersion.partialVersion(scalaVersion.value) match { 114 | case Some((2, n)) if n <= 12 => 115 | Seq( 116 | "-Xfatal-warnings", 117 | "-Yno-adapted-args", 118 | "-Xfuture" 119 | ) 120 | case _ => 121 | Nil 122 | } 123 | ) 124 | ) 125 | 126 | lazy val commonJvmSettings = Seq( 127 | Test / testOptions += Tests.Argument(TestFrameworks.ScalaTest, "-oDF") 128 | ) 129 | 130 | lazy val commonJsSettings = Seq( 131 | coverageEnabled := false 132 | ) 133 | 134 | lazy val commonNativeSettings = Seq( 135 | // Remove when native is published for the default previous versions 136 | tlVersionIntroduced := List("2.12", "2.13", "3").map(_ -> "0.5.0").toMap, 137 | coverageEnabled := false 138 | ) 139 | -------------------------------------------------------------------------------- /cats/js/src/main/scala/org/typelevel/paiges/Platform.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2017 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package org.typelevel.paiges 18 | 19 | private[paiges] object Platform { 20 | // using `final val` makes compiler constant-fold any use of these values, dropping dead code automatically 21 | // $COVERAGE-OFF$ 22 | final val isJvm = false 23 | final val isJs = true 24 | final val isNative = false 25 | // $COVERAGE-ON$ 26 | } 27 | -------------------------------------------------------------------------------- /cats/jvm/src/main/scala/org/typelevel/paiges/Platform.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2017 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package org.typelevel.paiges 18 | 19 | private[paiges] object Platform { 20 | // using `final val` makes compiler constant-fold any use of these values, dropping dead code automatically 21 | // $COVERAGE-OFF$ 22 | final val isJvm = true 23 | final val isJs = false 24 | final val isNative = false 25 | // $COVERAGE-ON$ 26 | } 27 | -------------------------------------------------------------------------------- /cats/native/src/main/scala/org/typelevel/paiges/Platform.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2017 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package org.typelevel.paiges 18 | 19 | private[paiges] object Platform { 20 | // using `final val` makes compiler constant-fold any use of these values, dropping dead code automatically 21 | // $COVERAGE-OFF$ 22 | final val isJvm = false 23 | final val isJs = false 24 | final val isNative = true 25 | // $COVERAGE-ON$ 26 | } 27 | -------------------------------------------------------------------------------- /cats/shared/src/main/scala/org/typelevel/paiges/CatsDocument.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2017 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package org.typelevel.paiges 18 | 19 | import cats.{Contravariant, Defer, Semigroupal} 20 | 21 | trait CatsDocument { 22 | implicit val contravariantDocument: Contravariant[Document] = 23 | new Contravariant[Document] { 24 | def contramap[A, Z](d: Document[A])(f: Z => A): Document[Z] = d.contramap(f) 25 | } 26 | 27 | def semigroupalDocument(sep: Doc): Semigroupal[Document] = 28 | new Semigroupal[Document] { 29 | def product[A, B](fa: Document[A], fb: Document[B]): Document[(A, B)] = 30 | Document.instance { case (a, b) => 31 | fa.document(a) + sep + fb.document(b) 32 | } 33 | } 34 | 35 | implicit val deferDocument: Defer[Document] = 36 | new Defer[Document] { 37 | def defer[A](d: => Document[A]): Document[A] = 38 | Document.defer(d) 39 | } 40 | } 41 | 42 | object CatsDocument extends CatsDocument 43 | -------------------------------------------------------------------------------- /cats/shared/src/main/scala/org/typelevel/paiges/instances.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2017 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package org.typelevel.paiges 18 | 19 | import cats.kernel.{Eq, Monoid} 20 | 21 | package object instances { 22 | implicit val paigesDocMonoid: Monoid[Doc] = 23 | new Monoid[Doc] { 24 | def empty: Doc = Doc.empty 25 | def combine(x: Doc, y: Doc): Doc = x + y 26 | } 27 | 28 | implicit val paigesStyleMonoid: Monoid[Style] = 29 | new Monoid[Style] { 30 | def empty: Style = Style.Empty 31 | def combine(x: Style, y: Style): Style = x ++ y 32 | } 33 | 34 | implicit val paigesStyleEq: Eq[Style] = 35 | Eq.fromUniversalEquals 36 | } 37 | -------------------------------------------------------------------------------- /cats/shared/src/test/scala/org/typelevel/paiges/LawTests.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2017 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package org.typelevel.paiges 18 | 19 | import cats.Semigroupal 20 | import cats.Contravariant 21 | import cats.kernel.{Eq, Monoid} 22 | import cats.laws.discipline.{ContravariantTests, DeferTests, ExhaustiveCheck, SemigroupalTests, SerializableTests} 23 | import cats.kernel.laws.discipline.MonoidTests 24 | import cats.laws.discipline.eq.catsLawsEqForFn1Exhaustive 25 | 26 | import org.typelevel.discipline.scalatest.FunSuiteDiscipline 27 | import org.scalacheck.Arbitrary 28 | import org.scalactic.anyvals.{PosInt, PosZDouble, PosZInt} 29 | import org.scalatest.funsuite.AnyFunSuite 30 | import org.scalatest.prop.Configuration 31 | 32 | class LawTests extends LawChecking with CatsDocument { 33 | import org.typelevel.paiges.Generators._ 34 | import org.typelevel.paiges.instances._ 35 | 36 | implicit val docEq: Eq[Doc] = 37 | Eq.instance((x: Doc, y: Doc) => PaigesTest.docEquiv.equiv(x, y)) 38 | 39 | implicit def monoidTests[A: Monoid]: MonoidTests[A] = MonoidTests[A] 40 | 41 | implicit def arbitraryForDocument[A]: Arbitrary[Document[A]] = 42 | Arbitrary(Document.useToString[A]) 43 | 44 | implicit def eqForDocument[A: ExhaustiveCheck]: Eq[Document[A]] = 45 | Eq.by[Document[A], A => Doc](inst => (a: A) => inst.document(a)) 46 | 47 | implicit val eqBool: Eq[Boolean] = 48 | Eq.instance[Boolean](_ == _) 49 | 50 | checkAll("Monoid[Doc]", MonoidTests[Doc].monoid) 51 | checkAll("Monoid[Style]", MonoidTests[Style].monoid) 52 | 53 | checkAll("Contravariant[Document]", ContravariantTests[Document].contravariant[Boolean, Boolean, Boolean]) 54 | checkAll("Contravariant[Document]", SerializableTests.serializable(Contravariant[Document])) 55 | checkAll("Defer[Document]", DeferTests[Document].defer[Boolean]) 56 | 57 | { 58 | implicit val semigroupalDocument: Semigroupal[Document] = 59 | CatsDocument.semigroupalDocument(Doc.char(',')) 60 | checkAll("Semigroupal[Document]", SemigroupalTests[Document].semigroupal[Boolean, Boolean, Boolean]) 61 | checkAll("Semigroupal[Document]", SerializableTests.serializable(Semigroupal[Document])) 62 | } 63 | } 64 | 65 | abstract class LawChecking extends AnyFunSuite with Configuration with FunSuiteDiscipline { 66 | 67 | lazy val checkConfiguration: PropertyCheckConfiguration = 68 | PropertyCheckConfiguration( 69 | minSuccessful = if (Platform.isJvm) PosInt(50) else PosInt(5), 70 | maxDiscardedFactor = if (Platform.isJvm) PosZDouble(5.0) else PosZDouble(50.0), 71 | minSize = PosZInt(0), 72 | sizeRange = if (Platform.isJvm) PosZInt(10) else PosZInt(5), 73 | workers = PosInt(1) 74 | ) 75 | 76 | // The scalacheck defaults 'sizeRange' (100) is too high for Scala-js, so we reduce to 10. 77 | // We also set `minSuccessful` to 100 unconditionally. 78 | implicit override val generatorDrivenConfig: PropertyCheckConfiguration = 79 | if (Platform.isJvm) PropertyCheckConfiguration(sizeRange = 100, minSuccessful = 100) 80 | else PropertyCheckConfiguration(sizeRange = 10, minSuccessful = 100) 81 | } 82 | -------------------------------------------------------------------------------- /core/src/main/scala-2.12-/org/typelevel/paiges/ScalaVersionCompat.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2017 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package org.typelevel.paiges 18 | 19 | object ScalaVersionCompat { 20 | type LazyList[+A] = scala.collection.immutable.Stream[A] 21 | val LazyList = scala.collection.immutable.Stream 22 | 23 | def lazyListFromIterator[A](it: Iterator[A]): LazyList[A] = 24 | it.toStream 25 | } 26 | -------------------------------------------------------------------------------- /core/src/main/scala-2.13+/org/typelevel/paiges/ScalaVersionCompat.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2017 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package org.typelevel.paiges 18 | 19 | object ScalaVersionCompat { 20 | def lazyListFromIterator[A](it: Iterator[A]): LazyList[A] = 21 | LazyList.from(it) 22 | } 23 | -------------------------------------------------------------------------------- /core/src/main/scala/org/typelevel/paiges/Chunk.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2017 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package org.typelevel.paiges 18 | 19 | import scala.annotation.tailrec 20 | 21 | private[paiges] object Chunk { 22 | 23 | /** 24 | * Given a width and Doc find the Iterator 25 | * of Chunks. 26 | */ 27 | def best(w: Int, d: Doc, trim: Boolean): Iterator[String] = { 28 | 29 | val nonNegW = w.max(0) 30 | 31 | sealed abstract class ChunkStream 32 | object ChunkStream { 33 | case object Empty extends ChunkStream 34 | case class Item(str: String, position: Int, stack: List[(Int, Doc)]) extends ChunkStream { 35 | def isLine: Boolean = str == null 36 | def stringChunk: String = if (isLine) lineToStr(position) else str 37 | private[this] var next: ChunkStream = _ 38 | def step: ChunkStream = { 39 | // do a cheap local computation. 40 | // lazy val is thread-safe, but more expensive 41 | // since everything is immutable here, this is 42 | // safe 43 | val res = next 44 | if (res != null) res 45 | else { 46 | val c = loop(position, stack) 47 | next = c 48 | c 49 | } 50 | } 51 | } 52 | } 53 | 54 | class ChunkIterator(var current: ChunkStream) extends Iterator[String] { 55 | def hasNext: Boolean = current != ChunkStream.Empty 56 | def next(): String = { 57 | val item = current.asInstanceOf[ChunkStream.Item] 58 | val res = item.stringChunk 59 | current = item.step 60 | res 61 | } 62 | } 63 | 64 | class TrimChunkIterator(var current: ChunkStream) extends Iterator[String] { 65 | private val lineCombiner = new TrimChunkIterator.LineCombiner 66 | def hasNext: Boolean = current != ChunkStream.Empty || lineCombiner.nonEmpty 67 | def next(): String = 68 | current match { 69 | case ChunkStream.Empty => lineCombiner.finalLine() 70 | case item: ChunkStream.Item => 71 | current = item.step 72 | lineCombiner.addItem(item).getOrElse(next()) 73 | } 74 | } 75 | 76 | object TrimChunkIterator { 77 | class LineCombiner { 78 | private var line: StringBuilder = new StringBuilder 79 | def nonEmpty: Boolean = line.nonEmpty 80 | def finalLine(): String = { 81 | val res = line.toString 82 | line = new StringBuilder 83 | LineCombiner.trim(res) 84 | } 85 | def addItem(item: ChunkStream.Item): Option[String] = 86 | if (item.isLine) { 87 | val v = LineCombiner.trim(line.toString) 88 | line = new StringBuilder(lineToStr(item.position)) 89 | Some(v) 90 | } else { 91 | line.append(item.str) 92 | None 93 | } 94 | } 95 | object LineCombiner { 96 | private def trim(s: String) = { 97 | var ind = s.length 98 | while (ind >= 1 && s.charAt(ind - 1) == ' ') ind = ind - 1 99 | s.substring(0, ind) 100 | } 101 | } 102 | } 103 | 104 | @tailrec 105 | def fits(pos: Int, d: ChunkStream): Boolean = 106 | (nonNegW >= pos) && { 107 | d match { 108 | case ChunkStream.Empty => true 109 | case item: ChunkStream.Item => 110 | item.isLine || fits(item.position, item.step) 111 | } 112 | } 113 | /* 114 | * This is not really tail recursive but many branches are, so 115 | * we cheat below in non-tail positions 116 | */ 117 | @tailrec 118 | def loop(pos: Int, lst: List[(Int, Doc)]): ChunkStream = 119 | lst match { 120 | case Nil => ChunkStream.Empty 121 | case (_, Doc.Empty) :: z => loop(pos, z) 122 | case (i, Doc.FlatAlt(a, _)) :: z => loop(pos, (i, a) :: z) 123 | case (i, Doc.Concat(a, b)) :: z => loop(pos, (i, a) :: (i, b) :: z) 124 | case (i, Doc.Nest(j, d)) :: z => loop(pos, (i + j, d) :: z) 125 | case (_, Doc.Align(d)) :: z => loop(pos, (pos, d) :: z) 126 | case (_, Doc.Text(s)) :: z => ChunkStream.Item(s, pos + s.length, z) 127 | case (_, Doc.ZeroWidth(s)) :: z => ChunkStream.Item(s, pos, z) 128 | case (i, Doc.Line) :: z => ChunkStream.Item(null, i, z) 129 | case (i, d @ Doc.LazyDoc(_)) :: z => loop(pos, (i, d.evaluated) :: z) 130 | case (i, Doc.Union(x, y)) :: z => 131 | /* 132 | * If we can fit the next line from x, we take it. 133 | */ 134 | val first = cheat(pos, (i, x) :: z) 135 | /* 136 | * Note that in Union the left side is always 2-right-associated. 137 | * This means the "fits" branch in rendering 138 | * always has a 2-right-associated Doc which means it is O(w) 139 | * to find if you can fit in width w. 140 | */ 141 | if (fits(pos, first)) first 142 | else loop(pos, (i, y) :: z) 143 | } 144 | 145 | def cheat(pos: Int, lst: List[(Int, Doc)]) = 146 | loop(pos, lst) 147 | 148 | val stream = loop(0, (0, d) :: Nil) 149 | if (trim) new TrimChunkIterator(stream) else new ChunkIterator(stream) 150 | } 151 | 152 | // $COVERAGE-OFF$ 153 | // code of the form `final val x = ...` is inlined. we never 154 | // access this field at runtime, but it is still used. 155 | final private[this] val indentMax = 100 156 | // $COVERAGE-ON$ 157 | 158 | private[this] def makeIndentStr(i: Int): String = "\n" + (" " * i) 159 | 160 | private[this] val indentTable: Array[String] = 161 | (0 to indentMax).iterator 162 | .map(makeIndentStr) 163 | .toArray 164 | 165 | def lineToStr(indent: Int): String = 166 | if (indent <= indentMax) indentTable(indent) 167 | else makeIndentStr(indent) 168 | } 169 | -------------------------------------------------------------------------------- /core/src/main/scala/org/typelevel/paiges/Doc.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2017 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package org.typelevel.paiges 18 | 19 | import java.io.PrintWriter 20 | import java.lang.StringBuilder 21 | 22 | import scala.annotation.tailrec 23 | import scala.util.matching.Regex 24 | 25 | // use LazyList on 2.13, Stream on 2.11, 2.12 26 | import ScalaVersionCompat._ 27 | 28 | /** 29 | * implementation of Wadler's classic "A Prettier Printer" 30 | * 31 | * http://homepages.inf.ed.ac.uk/wadler/papers/prettier/prettier.pdf 32 | */ 33 | sealed abstract class Doc extends Product with Serializable { 34 | 35 | import Doc.{Align, Concat, Empty, FlatAlt, LazyDoc, Line, Nest, Text, Union, ZeroWidth} 36 | 37 | /** 38 | * Append the given Doc to this one. 39 | */ 40 | def +(that: Doc): Doc = 41 | Concat(this, that) 42 | 43 | /** 44 | * Prepend the given String to this Doc. 45 | * 46 | * The expression `str +: d` is equivalent to `Doc.text(str) + d`. 47 | */ 48 | def +:(str: String): Doc = 49 | Doc.text(str) + this 50 | 51 | /** 52 | * Append the given String to this Doc. 53 | * 54 | * The expression `d :+ str` is equivalent to `d + Doc.text(str)`. 55 | */ 56 | def :+(str: String): Doc = 57 | this + Doc.text(str) 58 | 59 | /** 60 | * Synonym for .repeat. If n > 0 repeat the doc n times, 61 | * else return empty 62 | */ 63 | def *(n: Int): Doc = 64 | repeat(n) 65 | 66 | /** 67 | * Append the given Doc to this one, separated by a newline. 68 | */ 69 | def /(that: Doc): Doc = 70 | Concat(this, Concat(Doc.line, that)) 71 | 72 | /** 73 | * Append the given Doc to this one, separated by a newline. 74 | */ 75 | def line(that: Doc): Doc = 76 | this / that 77 | 78 | /** 79 | * Prepend the given String to this Doc, separated by a newline. 80 | * 81 | * The expression `str /: d` is equivalent to `Doc.text(str) / d`. 82 | */ 83 | def /:(str: String): Doc = 84 | Concat(Doc.text(str), Concat(Doc.line, this)) 85 | 86 | /** 87 | * Append the given String to this Doc, separated by a newline. 88 | * 89 | * The expression `d :/ str` is equivalent to `d / Doc.text(str)`. 90 | */ 91 | def :/(str: String): Doc = 92 | this / Doc.text(str) 93 | 94 | /** 95 | * This makes all newlines indent with the current position plus 96 | * i. 97 | * same as nested(i).aligned 98 | * 99 | * so, Doc.split("this is an example of some text").hang(2).render(0) 100 | * would be: 101 | * this 102 | * is 103 | * an 104 | * example 105 | * of 106 | * some 107 | * text 108 | */ 109 | def hang(i: Int): Doc = nested(i).aligned 110 | 111 | /** 112 | * indent this entire Doc by i 113 | * same as (spaces(i) + this).hang(i) 114 | */ 115 | def indent(i: Int): Doc = (Doc.spaces(i) + this).hang(i) 116 | 117 | /** 118 | * Append the given String to this one, separated by a newline. 119 | */ 120 | def line(str: String): Doc = 121 | this :/ str 122 | 123 | /** 124 | * Append the given Doc to this one, separated by a space. 125 | */ 126 | def space(that: Doc): Doc = 127 | Concat(this, Concat(Doc.space, that)) 128 | 129 | /** 130 | * Append the given String to this Doc, separated by a space. 131 | */ 132 | def space(that: String): Doc = 133 | this.space(Doc.text(that)) 134 | 135 | /** 136 | * Append the given Doc to this one, separated by a space. 137 | */ 138 | def &(that: Doc): Doc = 139 | this.space(that) 140 | 141 | /** 142 | * Append the given String to this Doc, separated by a space. 143 | * 144 | * The expression `str &: d` is equivalent to `Doc.text(str) & d`. 145 | */ 146 | def &:(that: String): Doc = 147 | Doc.text(that).space(this) 148 | 149 | /** 150 | * Append the given String to this Doc, separated by a space. 151 | * 152 | * The expression `d :& str` is equivalent to `d & Doc.text(str)`. 153 | */ 154 | def :&(that: String): Doc = 155 | this.space(Doc.text(that)) 156 | 157 | /** 158 | * Append the given Doc to this one, using a space (if there is 159 | * enough room), or a newline otherwise. 160 | */ 161 | def lineOrSpace(that: Doc): Doc = 162 | Concat(this, Concat(Doc.lineOrSpace, that)) 163 | 164 | /** 165 | * Append the given String to this Doc, using a space (if there is 166 | * enough room), or a newline otherwise. 167 | */ 168 | def lineOrSpace(that: String): Doc = 169 | lineOrSpace(Doc.text(that)) 170 | 171 | /** 172 | * Apply the given style to this document. 173 | * 174 | * This will replace all other styling the doc has so far. To avoid 175 | * this, consider concatenating documents with separate styles. 176 | */ 177 | def style(style: Style): Doc = 178 | Doc.zeroWidth(style.start) + (this.unzero + Doc.zeroWidth(style.end)) 179 | 180 | /** 181 | * Remove all zero-width nodes from the Doc. 182 | */ 183 | def unzero: Doc = 184 | this match { 185 | case ZeroWidth(_) => Empty 186 | case FlatAlt(a, b) => FlatAlt(a.unzero, Doc.defer(b.unzero)) 187 | case Concat(a, b) => Concat(a.unzero, b.unzero) 188 | case Nest(i, d) => Nest(i, d.unzero) 189 | case Union(a, b) => Union(Doc.defer(a.unzero), Doc.defer(b.unzero)) 190 | case d @ LazyDoc(_) => Doc.defer(d.evaluated.unzero) 191 | case Align(d) => Align(d.unzero) 192 | case Text(_) | Empty | Line => this 193 | } 194 | 195 | /** 196 | * Bookend this Doc between the given Docs. 197 | * 198 | * If the documents (when flattened) all fit on one line, then 199 | * newlines will be collapsed, spaces will be added, 200 | * and the document will render on one line. If you do not want 201 | * a space, see tightBracketBy 202 | * 203 | * Otherwise, newlines will be used on either side of this document, 204 | * and the requested level of indentation will be added as well. 205 | */ 206 | def bracketBy(left: Doc, right: Doc, indent: Int = 2): Doc = 207 | Concat(left, Concat(Concat(Doc.line, this).nested(indent), Concat(Doc.line, right)).grouped) 208 | 209 | /** 210 | * Bookend this Doc between the given Docs. 211 | * 212 | * If the documents (when flattened) all fit on one line, then 213 | * newlines will be collapsed, without a space 214 | * and the document will render on one line. If you want 215 | * the newline to collapse to a space, see bracketBy. 216 | * 217 | * Otherwise, newlines will be used on either side of this document, 218 | * and the requested level of indentation will be added as well. 219 | */ 220 | def tightBracketBy(left: Doc, right: Doc, indent: Int = 2): Doc = 221 | Concat(left, Concat(Concat(Doc.lineBreak, this).nested(indent), Concat(Doc.lineBreak, right)).grouped) 222 | 223 | /** 224 | * Treat this Doc as a group that can be compressed. 225 | * 226 | * The effect of this is to replace newlines with spaces, if there 227 | * is enough room. Otherwise, the Doc will be rendered as-is. 228 | */ 229 | def grouped: Doc = { 230 | val (flattened, changed) = flattenBoolean 231 | if (changed) Union(flattened, this) 232 | else flattened 233 | } 234 | 235 | /** 236 | * Returns true if all renders return the empty string 237 | */ 238 | def isEmpty: Boolean = { 239 | @tailrec def loop(doc: Doc, stack: List[Doc]): Boolean = 240 | doc match { 241 | case Empty => 242 | stack match { 243 | case d1 :: tail => loop(d1, tail) 244 | case Nil => true 245 | } 246 | case FlatAlt(a, b) => loop(a, b :: stack) 247 | case Concat(_, Line) => 248 | false // minor optimization to short circuit sooner 249 | case Concat(a, Text(s)) => 250 | // minor optimization to short circuit sooner 251 | s.isEmpty && loop(a, stack) 252 | case Concat(a, b) => loop(a, b :: stack) 253 | case Nest(_, d) => loop(d, stack) 254 | case Align(d) => loop(d, stack) 255 | case Text(s) => 256 | // shouldn't be empty by construction, but defensive 257 | s.isEmpty && loop(Empty, stack) 258 | case ZeroWidth(s) => 259 | // shouldn't be empty by construction, but defensive 260 | s.isEmpty && loop(Empty, stack) 261 | case Line => false 262 | case d @ LazyDoc(_) => loop(d.evaluated, stack) 263 | case Union(flattened, _) => 264 | // flattening cannot change emptiness 265 | loop(flattened, stack) 266 | } 267 | loop(this, Nil) 268 | } 269 | 270 | /** 271 | * d.nonEmpty == !d.isEmpty 272 | */ 273 | def nonEmpty: Boolean = !isEmpty 274 | 275 | private def renderGen(width: Int, trim: Boolean): String = { 276 | val bldr = new StringBuilder 277 | val it = Chunk.best(width, this, trim) 278 | while (it.hasNext) 279 | bldr.append(it.next()) 280 | bldr.toString 281 | } 282 | 283 | /** 284 | * Render this Doc as a String, limiting line lengths to `width` or 285 | * shorter when possible. 286 | * 287 | * Note that this method does not guarantee there are no lines 288 | * longer than `width` -- it just attempts to keep lines within this 289 | * length when possible. 290 | */ 291 | def render(width: Int): String = renderGen(width, false) 292 | 293 | /** 294 | * Render this Doc as a String, limiting line lengths to `width` or 295 | * shorter when possible. 296 | * 297 | * Note that this method does not guarantee there are no lines 298 | * longer than `width` -- it just attempts to keep lines within this 299 | * length when possible. 300 | * 301 | * Lines consisting of only indentation are represented by the empty string. 302 | */ 303 | def renderTrim(width: Int): String = renderGen(width, true) 304 | 305 | /** 306 | * Render this Doc as a stream of strings, treating `width` in the 307 | * same way as `render` does. 308 | * 309 | * The expression `d.renderStream(w).mkString` is equivalent to 310 | * `d.render(w)`. 311 | */ 312 | def renderStream(width: Int): LazyList[String] = 313 | lazyListFromIterator(Chunk.best(width, this, false)) 314 | 315 | /** 316 | * Render this Doc as a stream of strings, treating `width` in the 317 | * same way as `render` does. 318 | * 319 | * The expression `d.renderStream(w).mkString` is equivalent to 320 | * `d.render(w)`. 321 | * 322 | * Lines consisting of only indentation are represented by the empty string. 323 | */ 324 | def renderStreamTrim(width: Int): LazyList[String] = 325 | lazyListFromIterator(Chunk.best(width, this, true)) 326 | 327 | /** 328 | * Render this Doc as a stream of strings, using 329 | * the widest possible variant. This is the same 330 | * as render(Int.MaxValue) except it is more efficient. 331 | */ 332 | def renderWideStream: LazyList[String] = { 333 | @tailrec 334 | def loop(pos: Int, lst: List[(Int, Doc)]): LazyList[String] = 335 | lst match { 336 | case Nil => LazyList.empty 337 | case (_, Empty) :: z => loop(pos, z) 338 | case (i, FlatAlt(a, _)) :: z => loop(pos, (i, a) :: z) 339 | case (i, Concat(a, b)) :: z => loop(pos, (i, a) :: (i, b) :: z) 340 | case (i, Nest(j, d)) :: z => loop(pos, (i + j, d) :: z) 341 | case (_, Align(d)) :: z => loop(pos, (pos, d) :: z) 342 | case (_, Text(s)) :: z => s #:: cheat(pos + s.length, z) 343 | case (_, ZeroWidth(s)) :: z => s #:: cheat(pos, z) 344 | case (i, Line) :: z => Chunk.lineToStr(i) #:: cheat(i, z) 345 | case (i, d @ LazyDoc(_)) :: z => loop(pos, (i, d.evaluated) :: z) 346 | case (i, Union(a, _)) :: z => 347 | /* 348 | * if we are infinitely wide, a always fits 349 | */ 350 | loop(pos, (i, a) :: z) 351 | } 352 | def cheat(pos: Int, lst: List[(Int, Doc)]) = 353 | loop(pos, lst) 354 | 355 | loop(0, (0, this) :: Nil) 356 | } 357 | 358 | /** 359 | * If n > 0, repeat the Doc that many times, else 360 | * return empty 361 | */ 362 | def repeat(count: Int): Doc = { 363 | /* 364 | * only have log depth, so recursion is fine 365 | * d * (2n + c) = (dn + dn) + c 366 | */ 367 | def loop(d: Doc, cnt: Int): Doc = { 368 | val n = cnt >> 1 369 | val dn2 = 370 | if (n > 0) { 371 | val dn = loop(d, n) 372 | Concat(dn, dn) 373 | } else 374 | Empty 375 | if ((cnt & 1) == 1) Concat(dn2, d) else dn2 376 | } 377 | if (count <= 0) Empty 378 | else loop(this, count) 379 | } 380 | 381 | /** 382 | * Nest appends spaces to any newlines occurring within this Doc. 383 | * 384 | * The effect of this is cumulative. For example, the expression 385 | * `x.nested(1).nested(2)` is equivalent to `x.nested(3)`. 386 | */ 387 | def nested(amount: Int): Doc = 388 | this match { 389 | case Nest(i, d) => Nest(i + amount, d) 390 | case _ => Nest(amount, this) 391 | } 392 | 393 | /** 394 | * aligned sets the nesting to the column position before we 395 | * render the current doc. This is useful if you have: 396 | * 397 | * Doc.text("foo") + (Doc.text("bar").line(Doc.text("baz"))).align 398 | * 399 | * which will render as: 400 | * 401 | * foobar 402 | * baz 403 | */ 404 | def aligned: Doc = Align(this) 405 | 406 | private def writeToGen(width: Int, pw: PrintWriter, trim: Boolean): Unit = { 407 | val it = Chunk.best(width, this, trim) 408 | while (it.hasNext) 409 | pw.append(it.next()) 410 | } 411 | 412 | /** 413 | * Render this Doc at the given `width`, and write it to the given 414 | * PrintWriter. 415 | * 416 | * The expression `x.writeTo(w, pw)` is equivalent to 417 | * `pw.print(x.render(w))`, but will usually be much more efficient. 418 | * 419 | * This method does not close `pw` or have any side-effects other 420 | * than the actual writing. 421 | */ 422 | def writeTo(width: Int, pw: PrintWriter): Unit = 423 | writeToGen(width, pw, false) 424 | 425 | /** 426 | * Render this Doc at the given `width`, and write it to the given 427 | * PrintWriter. 428 | * 429 | * The expression `x.writeTo(w, pw)` is equivalent to 430 | * `pw.print(x.render(w))`, but will usually be much more efficient. 431 | * 432 | * This method does not close `pw` or have any side-effects other 433 | * than the actual writing. 434 | * 435 | * Lines consisting only of indentation are represented by the empty string. 436 | */ 437 | def writeToTrim(width: Int, pw: PrintWriter): Unit = 438 | writeToGen(width, pw, true) 439 | 440 | /** 441 | * Compute a hash code for this Doc. 442 | */ 443 | override lazy val hashCode: Int = { 444 | 445 | @inline def hash(curr: Int, c: Char): Int = 446 | curr * 1500450271 + c.toInt 447 | 448 | @tailrec def shash(n: Int, s: String, i: Int): Int = 449 | if (i < s.length) shash(hash(n, s.charAt(i)), s, i + 1) else n 450 | 451 | // Always go left to avoid triggering the lazy fill evaluation. 452 | renderWideStream.foldLeft(0xdead60d5) { case (n, s) => 453 | shash(n, s, 0) 454 | } 455 | } 456 | 457 | /** 458 | * Return a very terse string for this Doc. 459 | * 460 | * To get a full representation of the document's internal 461 | * structure, see `verboseString`. 462 | */ 463 | override def toString: String = 464 | "Doc(...)" 465 | 466 | /** 467 | * Produce a verbose string representation of this Doc. 468 | * 469 | * Unlike `render`, this method will reveal the internal tree 470 | * structure of the Doc (i.e. how concatenation and union nodes are 471 | * constructed), as well as the contents of every text node. 472 | * 473 | * By default, only the left side of union nodes is displayed. If 474 | * `forceLazy = true` is passed, then any LazyDoc nodes are 475 | * evaluated (making this potentially-expensive method even more 476 | * expensive). 477 | */ 478 | def representation(forceLazy: Boolean = false): Doc = { 479 | @tailrec def loop(stack: List[Either[Doc, String]], suffix: Doc): Doc = 480 | stack match { 481 | case head :: tail => 482 | head match { 483 | case Right(s) => 484 | loop(tail, s +: suffix) 485 | case Left(d) => 486 | d match { 487 | case Empty => 488 | loop(tail, "Empty" +: suffix) 489 | case FlatAlt(x, y) => 490 | loop(Left(y) :: Right(", ") :: Left(x) :: Right("FlatAlt(") :: tail, ")" +: suffix) 491 | case Line => 492 | loop(tail, s"Line" +: suffix) 493 | case Text(s) => 494 | loop(tail, "Text(" +: s +: ")" +: suffix) 495 | case Nest(i, d) => 496 | loop(Left(d) :: Right(", ") :: Right(i.toString) :: Right("Nest(") :: tail, ")" +: suffix) 497 | case Align(d) => 498 | loop(Left(d) :: Right("Align(") :: tail, ")" +: suffix) 499 | case Concat(x, y) => 500 | loop(Left(y) :: Right(", ") :: Left(x) :: Right("Concat(") :: tail, ")" +: suffix) 501 | case d @ LazyDoc(_) => 502 | if (forceLazy) loop(Left(d.evaluated) :: tail, suffix) 503 | else loop(tail, "LazyDoc(() => ...)" +: suffix) 504 | case Union(x, y) => 505 | loop(Left(y) :: Right(", ") :: Left(x) :: Right("Union(") :: tail, ")" +: suffix) 506 | case ZeroWidth(s) => 507 | loop(tail, "ZeroWidth(" +: s +: ")" +: suffix) 508 | } 509 | } 510 | case Nil => 511 | suffix 512 | } 513 | loop(Left(this) :: Nil, Doc.empty) 514 | } 515 | 516 | /** 517 | * This method is similar to flatten, but returns None if 518 | * no change is made to the document. 519 | * 520 | * Note, some documents contain hardLine, which cannot be 521 | * completely flattened. 522 | * 523 | * @see flatten and prefer that when you don't care if 524 | * flattening has happened, as flatten also may optimize 525 | * the original input even when there is no flattening 526 | */ 527 | def flattenOption: Option[Doc] = { 528 | val res = flattenBoolean 529 | if (res._2) Some(res._1) else None 530 | } 531 | 532 | /** 533 | * Convert this Doc to a minimal representation. 534 | * 535 | * All flattenable (non-hardLine) newlines are replaced with spaces (and optional indentation 536 | * is ignored). 537 | * 538 | * If a hardLine is encountered, an identical value is returned. 539 | * Note, it isn't true that x.flattenOption.isEmpty implies x.flatten eq x 540 | * because flattening also right associates Concat nodes as it is working 541 | * to improve rendering performance. 542 | */ 543 | def flatten: Doc = flattenBoolean._1 544 | 545 | // return the flattened doc, and if it is different 546 | private def flattenBoolean: (Doc, Boolean) = { 547 | 548 | type DB = (Doc, Boolean) 549 | 550 | def finish(last: DB, front: List[DB]): DB = 551 | front.foldLeft(last) { case ((d1, c1), (d0, c2)) => 552 | (Concat(d0, d1), c1 || c2) 553 | } 554 | 555 | def cheat(h: DB): DB = { 556 | val (last, front) = loop(h, Nil, Nil) 557 | finish(last, front) 558 | } 559 | 560 | @tailrec 561 | def loop(h: DB, stack: List[DB], front: List[DB]): (DB, List[DB]) = 562 | h._1 match { 563 | case Empty | Text(_) | Line | ZeroWidth(_) => 564 | stack match { 565 | case Nil => (h, front) 566 | case x :: xs => loop(x, xs, h :: front) 567 | } 568 | case FlatAlt(_, next) => 569 | val change = (next, true) 570 | stack match { 571 | case Nil => (change, front) 572 | case x :: xs => loop(x, xs, change :: front) 573 | } 574 | case Nest(i, d) => 575 | // This costs stack, but if can't see if there 576 | // is a Line inside, we assume the worst 577 | // rather than pay the cost to check 578 | val (dd, bb) = cheat((d, h._2)) 579 | val next = (Nest(i, dd), bb) 580 | stack match { 581 | case Nil => (next, front) 582 | case x :: xs => loop(x, xs, next :: front) 583 | } 584 | case Align(d) => 585 | // This costs stack, but if can't see if there 586 | // is a Line inside, we assume the worst 587 | // rather than pay the cost to check 588 | val (dd, bb) = cheat((d, h._2)) 589 | val next = (Align(dd), bb) 590 | stack match { 591 | case Nil => (next, front) 592 | case x :: xs => loop(x, xs, next :: front) 593 | } 594 | case d @ LazyDoc(_) => loop((d.evaluated, h._2), stack, front) 595 | case Union(a, _) => loop((a, true), stack, front) // invariant: flatten(union(a, b)) == flatten(a) 596 | case Concat(a, b) => loop((a, h._2), (b, h._2) :: stack, front) 597 | } 598 | 599 | val (last, front) = loop((this, false), Nil, Nil) 600 | finish(last, front) 601 | } 602 | 603 | /** 604 | * Returns the largest width which may affect how this Doc 605 | * renders. All widths larger than this amount are guaranteed to 606 | * render the same. 607 | * 608 | * Note that this does not guarantee that all widths below this 609 | * value are distinct, just that they may be distinct. This value is 610 | * an upper-bound on widths that produce distinct renderings, but 611 | * not a least upper-bound. 612 | */ 613 | def maxWidth: Int = { 614 | @tailrec 615 | def loop(pos: Int, lst: List[(Int, Doc)], max: Int): Int = 616 | lst match { 617 | case Nil => math.max(max, pos) 618 | case (_, Empty) :: z => loop(pos, z, max) 619 | case (i, FlatAlt(default, _)) :: z => loop(pos, (i, default) :: z, max) 620 | case (i, Concat(a, b)) :: z => loop(pos, (i, a) :: (i, b) :: z, max) 621 | case (i, Nest(j, d)) :: z => loop(pos, (i + j, d) :: z, max) 622 | case (_, Align(d)) :: z => loop(pos, (pos, d) :: z, max) 623 | case (_, Text(s)) :: z => loop(pos + s.length, z, max) 624 | case (_, ZeroWidth(_)) :: z => loop(pos, z, max) 625 | case (i, Line) :: z => loop(i, z, math.max(max, pos)) 626 | case (i, d @ LazyDoc(_)) :: z => loop(pos, (i, d.evaluated) :: z, max) 627 | case (i, Union(a, _)) :: z => 628 | // we always go left, take the widest branch 629 | loop(pos, (i, a) :: z, max) 630 | } 631 | 632 | loop(0, (0, this) :: Nil, 0) 633 | } 634 | } 635 | 636 | object Doc { 637 | 638 | /** 639 | * Represents an empty document (the empty string). 640 | */ 641 | private[paiges] case object Empty extends Doc 642 | 643 | /** 644 | * Render 'default' except when flattened. 645 | * Invariant: width(default) <= width(whenFlat) 646 | * Invariant: default != whenFlat (otherwise the FlatAlt is redundant) 647 | * Invariant: `FlatAlt` does not occur on the left of a `Union`. 648 | * (`Union`s arise only by flattening, and the left is always the `whenFlat` case.) 649 | */ 650 | private[paiges] case class FlatAlt(default: Doc, whenFlat: Doc) extends Doc 651 | 652 | /** 653 | * Represents a single, literal newline. 654 | * This is always nested inside the left of a FlatAlt. 655 | * Exposing this at the top level breaks a number of invariants we have. 656 | */ 657 | private[paiges] case object Line extends Doc 658 | 659 | /** 660 | * The string must not be empty, and may not contain newlines. 661 | */ 662 | private[paiges] case class Text(str: String) extends Doc 663 | 664 | /** 665 | * Works like Text, except its length is treated as zero. 666 | * 667 | * This is useful for things like ANSI formatting codes. It is only 668 | * generated internally during rendering, so it should not appear in 669 | * stable Doc values. 670 | */ 671 | private[paiges] case class ZeroWidth(str: String) extends Doc 672 | 673 | /** 674 | * Represents a concatenation of two documents. 675 | */ 676 | private[paiges] case class Concat(a: Doc, b: Doc) extends Doc 677 | 678 | /** 679 | * Represents a "remembered indentation level" for a 680 | * document. Newlines in this document will be followed by at least 681 | * this much indentation (nesting is cumulative). 682 | */ 683 | private[paiges] case class Nest(indent: Int, doc: Doc) extends Doc 684 | 685 | /** 686 | * Align sets the nesting at the current position 687 | */ 688 | private[paiges] case class Align(doc: Doc) extends Doc 689 | 690 | private[paiges] case class LazyDoc(thunk: () => Doc) extends Doc { 691 | private var computed: Doc = null 692 | // This is never a LazyDoc 693 | lazy val evaluated: Doc = { 694 | @tailrec 695 | def loop(d: Doc, toUpdate: List[LazyDoc]): Doc = 696 | d match { 697 | case lzy @ LazyDoc(thunk) => 698 | // note: we are intentionally shadowing thunk here because 699 | // we want to make it impossible to accidentally use the outer 700 | // thunk 701 | // 702 | // lzy points to another, and therefore equivalent LazyDoc 703 | // short circuit if we this has already computed 704 | val lzyC = lzy.computed 705 | // lzy isn't computed, add it to the list of LazyDocs to fill in 706 | if (lzyC == null) loop(thunk(), lzy :: toUpdate) 707 | else loop(lzyC, toUpdate) 708 | case _ => 709 | toUpdate.foreach(_.computed = d) 710 | d 711 | } 712 | 713 | if (computed == null) 714 | computed = loop(thunk(), Nil) 715 | computed 716 | } 717 | } 718 | 719 | /** 720 | * Represents an optimistic rendering (on the left) as well as a 721 | * fallback rendering (on the right) if the first line of the left 722 | * is too long. In `Union(a, b)`, we have the invariants: 723 | * 724 | * - `a.flatten == b.flatten` 725 | * - `a != b` (otherwise the Union would be redundant) 726 | * - `a` is 2-right-associated with respect to `Concat` nodes to maintain efficiency in rendering. 727 | * - The first line of `a` is longer than the first line of `b` at all widths. 728 | * 729 | * A `Doc` is 2-right-associated if there are no subterms of the form 730 | * `Concat(Concat(Concat(_, _), _), _)`. Due to how `fill` is implemented, 731 | * subterms of the form `Concat(Concat(_, _), _)` can appear, but as long 732 | * as the left-associated chains are not very long, we avoid the potentially 733 | * quadratic behavior of unconstrained terms. 734 | * 735 | * By construction all `Union` nodes have these properties; to preserve 736 | * this we don't expose the `Union` constructor directly, but only 737 | * the `grouped` and `fill` methods. 738 | */ 739 | private[paiges] case class Union(a: Doc, b: Doc) extends Doc 740 | 741 | private[this] val maxSpaceTable = 20 742 | 743 | private[this] val spaceArray: Array[Text] = 744 | (1 to maxSpaceTable).map(i => Text(" " * i)).toArray 745 | 746 | /** 747 | * Defer creation of a Doc until absolutely needed. 748 | * This is useful in some recursive algorithms 749 | */ 750 | def defer(d: => Doc): Doc = 751 | LazyDoc(() => d) 752 | 753 | /** 754 | * Produce a document of exactly `n` spaces. 755 | * 756 | * If `n < 1`, and empty document is returned. 757 | */ 758 | def spaces(n: Int): Doc = 759 | if (n < 1) Empty 760 | else if (n <= maxSpaceTable) spaceArray(n - 1) 761 | else { 762 | // n = max * d + r 763 | val d = n / maxSpaceTable 764 | val r = n % maxSpaceTable 765 | spaceArray(maxSpaceTable - 1) * d + spaces(r) 766 | } 767 | 768 | val space: Doc = spaceArray(0) 769 | val empty: Doc = Empty 770 | 771 | /** 772 | * when flattened a line becomes a space 773 | * You might also @see lineBreak if you want a line that 774 | * is flattened into empty 775 | */ 776 | val line: Doc = FlatAlt(Line, space) 777 | 778 | /** 779 | * A lineBreak is a line that is flattened into 780 | * an empty Doc. This is generally useful in code 781 | * following tokens that parse without the need for 782 | * whitespace termination (consider ";" "," "=>" etc..) 783 | * 784 | * when we call `.grouped` on lineBreak we get lineOrEmpty 785 | */ 786 | val lineBreak: Doc = FlatAlt(Line, empty) 787 | 788 | /** 789 | * Puts a hard line that cannot be removed by grouped 790 | * or flattening. This is useful for source code 791 | * generation when you absolutely need a new line. 792 | * 793 | * @see lineOr which is useful when you have a string 794 | * that can replace newline, e.g. "; " or similar 795 | */ 796 | def hardLine: Doc = Line 797 | 798 | /** 799 | * lineOr(d) renders as d if we can fit the rest 800 | * or inserts a newline. 801 | * 802 | * If it is not already flat, d will be flattened. 803 | * 804 | * and example would be: 805 | * stmt1 + lineOr(Doc.text("; ")) + stmt2 806 | * in a programming language that semicolons to 807 | * separate statments, but does not require them 808 | * on the end of a line 809 | */ 810 | def lineOr(doc: Doc): Doc = 811 | doc.flatten match { 812 | case Line => 813 | // we don't want to create FlatAlt(x, x) 814 | Line 815 | case d => FlatAlt(Line, d).grouped 816 | } 817 | 818 | /** 819 | * lineOrSpace renders a space if we can fit the rest 820 | * or inserts a newline. Identical to line.grouped 821 | */ 822 | val lineOrSpace: Doc = lineOr(space) 823 | 824 | /** 825 | * lineOrEmpty renders as empty if we can fit the rest 826 | * or inserts a newline. Identical to lineBreak.grouped 827 | */ 828 | val lineOrEmpty: Doc = lineOr(empty) 829 | 830 | /** 831 | * Order documents by how they render at the given width. 832 | */ 833 | def orderingAtWidth(w: Int): Ordering[Doc] = 834 | Ordering.by((d: Doc) => d.render(w)) 835 | 836 | /** 837 | * Require documents to be equivalent at all the given widths, as 838 | * well as at their "wide" renderings. 839 | */ 840 | def equivAtWidths(widths: List[Int]): Equiv[Doc] = 841 | new Equiv[Doc] { 842 | def equiv(x: Doc, y: Doc): Boolean = 843 | widths.forall(w => x.render(w) == y.render(w)) && 844 | x.renderWideStream.mkString == y.renderWideStream.mkString 845 | } 846 | 847 | private[this] val charTable: Array[Doc] = 848 | (32 to 126).map(i => Text(i.toChar.toString)).toArray 849 | 850 | /** 851 | * Build a document from a single character. 852 | */ 853 | def char(c: Char): Doc = 854 | if ((' ' <= c) && (c <= '~')) charTable(c.toInt - 32) 855 | else if (c == '\n') line 856 | else Text(new String(Array(c))) 857 | 858 | /** 859 | * a literal comma, equivalent to char(',') 860 | */ 861 | val comma: Doc = char(',') 862 | 863 | /** 864 | * Convert a string to text. 865 | * 866 | * This method translates newlines into the given 867 | * Doc, which should represent a new line and may be 868 | * Doc.line, Doc.hardLine, Doc.lineOrSpace 869 | */ 870 | def textWithLine(str: String, line: Doc): Doc = { 871 | def tx(i: Int, j: Int): Doc = 872 | if (i == j) Empty else Text(str.substring(i, j)) 873 | 874 | // parse the string right-to-left, splitting at newlines. 875 | // this ensures that our concatenations are right-associated. 876 | @tailrec def parse(i: Int, limit: Int, doc: Doc): Doc = 877 | if (i < 0) tx(0, limit) + doc 878 | else 879 | str.charAt(i) match { 880 | case '\n' => parse(i - 1, i, line + (tx(i + 1, limit) + doc)) 881 | case _ => parse(i - 1, limit, doc) 882 | } 883 | 884 | val len = str.length 885 | 886 | if (len == 0) Empty 887 | else if (len == 1) { 888 | val c = str.charAt(0) 889 | if ((' ' <= c) && (c <= '~')) charTable(c.toInt - 32) 890 | else if (c == '\n') line 891 | else Text(str) 892 | } else if (str.indexOf('\n') < 0) Text(str) 893 | else parse(len - 1, len, Empty) 894 | } 895 | 896 | /** 897 | * Convert a string to text. 898 | * 899 | * This method translates newlines into Doc.line (which can be flattened). 900 | */ 901 | def text(str: String): Doc = textWithLine(str, line) 902 | 903 | /** 904 | * Convert an arbitrary value to a Doc, using `toString`. 905 | * 906 | * This method is equivalent to `Doc.text(t.toString)`. 907 | */ 908 | def str[T](t: T): Doc = 909 | text(t.toString) 910 | 911 | private val splitWhitespace: Regex = """\s+""".r 912 | 913 | /** 914 | * Convert a string to text, replacing instances of the given 915 | * pattern with the corresponding separator. 916 | * 917 | * Like Doc.text, this method will also lift newlines into the Doc 918 | * abstraction. 919 | * 920 | * The default pattern to use is `"""\s+""".r` and the default 921 | * separator to use is `Doc.lineOrSpace`. 922 | */ 923 | def split(str: String, pat: Regex = Doc.splitWhitespace, sep: Doc = Doc.lineOrSpace): Doc = 924 | foldDocs(pat.pattern.split(str, -1).map(Doc.text))((x, y) => x + (sep + y)) 925 | 926 | /** 927 | * Collapse a collection of documents into one document, delimited 928 | * by a separator. 929 | * 930 | * This is equivalent to the following code, but is much 931 | * more complex to avoid stack overflows and exponential 932 | * time complexity 933 | * {{{ 934 | * 935 | * def fill(sep: Doc, ds: List[Doc]): Doc = 936 | * ds match { 937 | * case Nil => empty 938 | * case x :: Nil => x.grouped 939 | * case x :: y :: zs => 940 | * Union( 941 | * x.flatten + (sep.flatten + fillSpec(sep, y.flatten :: zs)), 942 | * x + (sep + fillSpec(sep, y :: zs))) 943 | * } 944 | * 945 | * }}} 946 | * 947 | * For example: 948 | * 949 | * import Doc.{ comma, line, text, fill } 950 | * val ds = text("1") :: text("2") :: text("3") :: Nil 951 | * val doc = fill(comma + line, ds) 952 | * 953 | * doc.render(0) // produces "1,\n2,\n3" 954 | * doc.render(6) // produces "1, 2,\n3" 955 | * doc.render(10) // produces "1, 2, 3" 956 | */ 957 | def fill(sep: Doc, ds: Iterable[Doc]): Doc = { 958 | // when the separator is already flattened 959 | // we can optimize somewhat 960 | val (flatSep, fb) = sep.flattenBoolean 961 | val sepd = if (fb) sep else flatSep 962 | 963 | // when we don't have a branch, we would like to loop 964 | // but if we unconditionally do that we can blow the stack 965 | // loop at most this many times before building a defer 966 | val maxLoop = 20 967 | // xd suffix means original doc, xf is the flattened doc 968 | def recurse(xd: Doc, xf: Doc, ds: List[Doc], cnt: Int): Doc = 969 | ds match { 970 | case Nil => 971 | if (xd eq xf) xf 972 | else Union(xf, xd) 973 | case y :: ys => 974 | // if we don't change, the original doc is the same as flatten 975 | val (yf, yb) = y.flattenBoolean 976 | val yd = if (yb) y else yf 977 | // even though we always need rest in the first branch 978 | // we may blow the stack if we recurse now 979 | if (yd eq yf) { 980 | val rest = if (cnt < maxLoop) recurse(yd, yf, ys, cnt + 1) else defer(recurse(yd, yf, ys, 0)) 981 | // leverage (union(a + c, b + c) = union(a, b) + c 982 | Union(xf + flatSep, xd + sepd) + rest 983 | } else { 984 | val leftRest = defer(recurse(yf, yf, ys, 0)) 985 | val rest = defer(recurse(yd, yf, ys, 0)) 986 | Union(xf + (flatSep + leftRest), xd + (sepd + rest)) 987 | } 988 | } 989 | 990 | // when sep is already flat, we can optimize more 991 | def recurseSep(xd: Doc, xf: Doc, ds: List[Doc], cnt: Int): Doc = 992 | ds match { 993 | case Nil => 994 | if (xd eq xf) xf 995 | else Union(xf, xd) 996 | case y :: ys => 997 | // if we don't change, the original doc is the same as flatten 998 | val (yf, yb) = y.flattenBoolean 999 | val yd = if (yb) y else yf 1000 | // even though we always need rest in the first branch 1001 | // we may blow the stack if we recurse now 1002 | if (yd eq yf) { 1003 | val rest = if (cnt < maxLoop) recurseSep(yd, yf, ys, cnt + 1) else defer(recurseSep(yd, yf, ys, 0)) 1004 | // don't make a union if both sides are the same 1005 | val xpart = if (xf eq xd) xf else Union(xf, xd) 1006 | xpart + (sepd + rest) 1007 | } else { 1008 | val leftRest = defer(recurse(yf, yf, ys, 0)) 1009 | val rest = defer(recurse(yd, yf, ys, 0)) 1010 | Union(xf + (sepd + leftRest), xd + (sepd + rest)) 1011 | } 1012 | } 1013 | 1014 | ds.toList match { 1015 | case Nil => empty 1016 | case x :: ys => 1017 | val (xf, xb) = x.flattenBoolean 1018 | // if we don't change, the original doc is the same as flatten 1019 | val xd = if (xb) x else xf 1020 | // if we had to flattend sep, use the full recurse, else shortcut 1021 | if (fb) recurse(xd, xf, ys, 0) 1022 | else recurseSep(xd, xf, ys, 0) 1023 | } 1024 | } 1025 | 1026 | /** 1027 | * Combine documents, using the given associative function. 1028 | * 1029 | * The function `fn` must be associative. That is, the expression 1030 | * `fn(x, fn(y, z))` must be equivalent to `fn(fn(x, y), z)`. 1031 | * 1032 | * In practice this method builds documents from the right, so that 1033 | * the resulting concatenations are all right-associated. 1034 | */ 1035 | def foldDocs(ds: Iterable[Doc])(fn: (Doc, Doc) => Doc): Doc = 1036 | if (ds.isEmpty) Doc.empty 1037 | else { 1038 | val xs = ds.toArray 1039 | var d = xs(xs.length - 1) 1040 | var i = xs.length - 2 1041 | while (i >= 0) { 1042 | d = fn(xs(i), d) 1043 | i -= 1 1044 | } 1045 | d 1046 | } 1047 | 1048 | /** 1049 | * Split the given text into words (separated by whitespace), and 1050 | * then join those words with a space or newline. 1051 | * 1052 | * This produces text which will wrap naturally at line boundaries, 1053 | * producing a block of text. 1054 | * 1055 | * `paragraph` is an alias for Doc.split(s), which uses its default 1056 | * arguments to split on whitespace and to rejoin the documents with 1057 | * `Doc.lineOrSpace`. 1058 | */ 1059 | def paragraph(s: String): Doc = 1060 | split(s) 1061 | 1062 | /** 1063 | * Concatenate the given documents together, delimited by the given 1064 | * separator. 1065 | * 1066 | * For example, `intercalate(comma, List(a, b, c))` is equivalent to 1067 | * `a + comma + b + comma + b`. 1068 | */ 1069 | def intercalate(sep: Doc, ds: Iterable[Doc]): Doc = 1070 | if (sep.isEmpty) foldDocs(ds)(Concat(_, _)) 1071 | else 1072 | foldDocs(ds)((a, b) => Concat(a, Concat(sep, b))) 1073 | 1074 | /** 1075 | * Concatenate the given documents together. 1076 | * 1077 | * `cat(ds)` is equivalent to `ds.foldLeft(empty)(_ + _)` 1078 | */ 1079 | def cat(ds: Iterable[Doc]): Doc = 1080 | intercalate(empty, ds) 1081 | 1082 | /** 1083 | * Concatenate the given documents together, delimited by spaces. 1084 | */ 1085 | def spread(ds: Iterable[Doc]): Doc = 1086 | intercalate(space, ds) 1087 | 1088 | /** 1089 | * Concatenate the given documents together, delimited by newlines. 1090 | */ 1091 | def stack(ds: Iterable[Doc]): Doc = 1092 | intercalate(line, ds) 1093 | 1094 | /** 1095 | * A simple table which is the same as: 1096 | * tabulate("", ' ', "", kv) 1097 | * 1098 | * or, no right separator and a space as the fill 1099 | */ 1100 | def tabulate(kv: List[(String, Doc)]): Doc = 1101 | tabulate(' ', "", kv) 1102 | 1103 | /** 1104 | * build a table with the strings left aligned and 1105 | * the Docs starting in the column after the longest string. 1106 | * The Docs on the right are rendered aligned after the rightSep 1107 | * 1108 | * @param fill the character used to fill the columns to make the values aligned (i.e. ' ' or '.') 1109 | * @param rightSep a string append left to the left of the value. Intended for use with bullets on values 1110 | * @param rows a List of key, value pairs to put in a table. 1111 | */ 1112 | def tabulate(fill: Char, rightSep: String, rows: Iterable[(String, Doc)]): Doc = 1113 | if (rows.isEmpty) empty 1114 | else { 1115 | val fills = rows.iterator.map(_._1.length).max 1116 | val rightD = Doc.text(rightSep) 1117 | def keyToDoc(s: String): Doc = Doc.text(s) + Doc.char(fill).repeat(fills - s.length) + rightD 1118 | intercalate(line, rows.map { case (k, v) => keyToDoc(k) + v.aligned }) 1119 | } 1120 | 1121 | /** 1122 | * Introduce some zero-width text. 1123 | * 1124 | * WARNING: If this text ends up being seen by the viewer, it will 1125 | * make the formatting appear incorrect. This is an advanced 1126 | * feature -- prefer using styling where possible. 1127 | */ 1128 | def zeroWidth(s: String): Doc = 1129 | if (s.isEmpty) Empty else ZeroWidth(s) 1130 | 1131 | /** 1132 | * Creates a zero-width Doc containing the given ANSI control 1133 | * sequences. 1134 | */ 1135 | def ansiControl(ns: Int*): Doc = 1136 | zeroWidth(ns.mkString("\u001b[", ";", "m")) 1137 | } 1138 | -------------------------------------------------------------------------------- /core/src/main/scala/org/typelevel/paiges/Document.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2017 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package org.typelevel.paiges 18 | 19 | trait Document[A] { self => 20 | def document(a: A): Doc 21 | 22 | def contramap[Z](f: Z => A): Document[Z] = 23 | Document.instance(z => self.document(f(z))) 24 | } 25 | 26 | object Document { 27 | 28 | def apply[A](implicit ev: Document[A]): Document[A] = ev 29 | 30 | private case class LazyDocument[A](thunk: () => Document[A]) extends Document[A] { 31 | private var computed: Document[A] = null 32 | // This is never a LazyDocument 33 | lazy val evaluated: Document[A] = { 34 | @annotation.tailrec 35 | def loop(d: Document[A], toUpdate: List[LazyDocument[A]]): Document[A] = 36 | d match { 37 | case lzy @ LazyDocument(thunk) => 38 | // note: we are intentionally shadowing thunk here because 39 | // we want to make it impossible to accidentally use the outer 40 | // thunk 41 | // 42 | // lzy points to another, and therefore equivalent LazyDocument 43 | // short circuit if we this has already computed 44 | val lzyC = lzy.computed 45 | // lzy isn't computed, add it to the list of LazyDocuments to fill in 46 | if (lzyC == null) loop(thunk(), lzy :: toUpdate) 47 | else loop(lzyC, toUpdate) 48 | case _ => 49 | toUpdate.foreach(_.computed = d) 50 | d 51 | } 52 | 53 | if (computed == null) 54 | computed = loop(thunk(), Nil) 55 | computed 56 | } 57 | 58 | def document(a: A): Doc = evaluated.document(a) 59 | } 60 | 61 | def defer[A](doc: => Document[A]): Document[A] = 62 | LazyDocument(() => doc) 63 | 64 | def instance[A](f: A => Doc): Document[A] = 65 | new Document[A] { 66 | def document(a: A): Doc = f(a) 67 | } 68 | 69 | implicit val documentString: Document[String] = 70 | Document.instance(Doc.text) 71 | 72 | implicit val documentChar: Document[Char] = 73 | Document.instance(Doc.char) 74 | 75 | implicit val documentUnit: Document[Unit] = useToString[Unit] 76 | implicit val documentBoolean: Document[Boolean] = useToString[Boolean] 77 | implicit val documentByte: Document[Byte] = useToString[Byte] 78 | implicit val documentShort: Document[Short] = useToString[Short] 79 | implicit val documentInt: Document[Int] = useToString[Int] 80 | implicit val documentLong: Document[Long] = useToString[Long] 81 | implicit val documentFloat: Document[Float] = useToString[Float] 82 | implicit val documentDouble: Document[Double] = useToString[Double] 83 | 84 | def documentIterable[A](name: String)(implicit ev: Document[A]): Document[Iterable[A]] = 85 | Document.instance { xs => 86 | val body = Doc.fill(Doc.comma + Doc.line, xs.map(ev.document)) 87 | body.tightBracketBy(Doc.text(name) :+ "(", Doc.char(')')) 88 | } 89 | 90 | /** 91 | * In general you will get better rendering and performance by 92 | * writing your own instance of Document[A] using the document 93 | * combinators. However, this method is provided as a convenience 94 | * when these issues are not applicable. 95 | */ 96 | def useToString[A]: Document[A] = 97 | FromToString.asInstanceOf[Document[A]] 98 | 99 | object FromToString extends Document[Any] { 100 | def document(any: Any): Doc = Doc.text(any.toString) 101 | } 102 | 103 | trait Ops[A] { 104 | def instance: Document[A] 105 | def self: A 106 | def doc: Doc = instance.document(self) 107 | } 108 | 109 | trait ToDocumentOps { 110 | implicit def toDocumentOps[A](target: A)(implicit tc: Document[A]): Ops[A] = 111 | new Ops[A] { 112 | val instance: Document[A] = tc 113 | val self: A = target 114 | } 115 | } 116 | 117 | object ops extends ToDocumentOps 118 | } 119 | -------------------------------------------------------------------------------- /core/src/main/scala/org/typelevel/paiges/Style.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2017 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package org.typelevel.paiges 18 | 19 | /** 20 | * Text styling for viewing in a terminal. 21 | * 22 | * This type represents foreground and background colors as well as 23 | * other text attributes. It uses a CSI sequence (ESC - '[') to start 24 | * a zero-width string containing one or more numeric codes, separated 25 | * by ; and ending in 'm'. 26 | * 27 | * Styles can be combined with ++; if two styles would conflict the 28 | * one on the right wins. For example: (Fg.Red ++ Fg.Green) = Fg.Green. 29 | * 30 | * See https://en.wikipedia.org/wiki/ANSI_escape_code#Escape_sequences 31 | * for more information about escape sequences. 32 | */ 33 | sealed abstract class Style extends Serializable { lhs => 34 | def start: String 35 | def end: String = Style.Reset 36 | 37 | def ++(rhs: Style): Style = { 38 | val Style.Impl(fg0, bg0, sg0) = lhs 39 | val Style.Impl(fg1, bg1, sg1) = rhs 40 | Style.Impl(fg1.orElse(fg0), bg1.orElse(bg0), sg0 ::: sg1) 41 | } 42 | } 43 | 44 | object Style { 45 | 46 | /** 47 | * Represents neutral styling. 48 | * 49 | * Any other styles combined with Empty will apply their own 50 | * settings. (a ++ Empty) = a. 51 | */ 52 | val Empty: Style = Impl(None, None, Nil) 53 | 54 | private def genCodes(ns: List[String]): String = 55 | ns.mkString("\u001b[", ";", "m") 56 | 57 | private case class Impl(fg: Option[String], bg: Option[String], sg: List[String]) extends Style { 58 | val start: String = 59 | if (fg.isEmpty && bg.isEmpty && sg.isEmpty) Reset 60 | else genCodes(fg.toList ::: bg.toList ::: sg.toList) 61 | } 62 | 63 | private val Reset: String = genCodes("0" :: Nil) 64 | 65 | /** 66 | * These escapes should be valid (although possibly not rendered) on 67 | * all ANSI-compatible terminals. 68 | * 69 | * See https://en.wikipedia.org/wiki/ANSI_escape_code#3/4_bit 70 | */ 71 | object Ansi { 72 | 73 | /** 74 | * Styling attributes such as bold, italic, etc. 75 | */ 76 | object Attr { 77 | 78 | private def code(n: Int): Style = Impl(None, None, n.toString :: Nil) 79 | 80 | val Bold = code(1) 81 | val Faint = code(2) 82 | val Italic = code(3) 83 | val Underline = code(4) 84 | val SlowBlink = code(5) 85 | val FastBlink = code(6) 86 | val Inverse = code(7) 87 | val Conceal = code(8) 88 | val CrossedOut = code(9) 89 | 90 | val BoldOff = code(21) 91 | val FaintOff = code(22) 92 | val ItalicOff = code(23) 93 | val UnderlineOff = code(24) 94 | val BlinkOff = code(25) 95 | val InverseOff = code(27) 96 | val ConcealOff = code(28) 97 | val CrossedOutOff = code(29) 98 | } 99 | 100 | /** 101 | * Foreground colors. 102 | */ 103 | object Fg { 104 | 105 | private def code(n: Int): Style = Impl(Some(n.toString), None, Nil) 106 | 107 | val Black = code(30) 108 | val Red = code(31) 109 | val Green = code(32) 110 | val Yellow = code(33) 111 | val Blue = code(34) 112 | val Magenta = code(35) 113 | val Cyan = code(36) 114 | val White = code(37) 115 | val Default = code(39) 116 | 117 | val BrightBlack = code(90) 118 | val BrightRed = code(91) 119 | val BrightGreen = code(92) 120 | val BrightYellow = code(93) 121 | val BrightBlue = code(94) 122 | val BrightMagenta = code(95) 123 | val BrightCyan = code(96) 124 | val BrightWhite = code(97) 125 | } 126 | 127 | /** 128 | * Background colors. 129 | */ 130 | object Bg { 131 | 132 | private def code(n: Int): Style = Impl(None, Some(n.toString), Nil) 133 | 134 | val Black = code(40) 135 | val Red = code(41) 136 | val Green = code(42) 137 | val Yellow = code(43) 138 | val Blue = code(44) 139 | val Magenta = code(45) 140 | val Cyan = code(46) 141 | val White = code(47) 142 | val Default = code(49) 143 | 144 | val BrightBlack = code(100) 145 | val BrightRed = code(101) 146 | val BrightGreen = code(102) 147 | val BrightYellow = code(103) 148 | val BrightBlue = code(104) 149 | val BrightMagenta = code(105) 150 | val BrightCyan = code(106) 151 | val BrightWhite = code(107) 152 | 153 | } 154 | } 155 | 156 | /** 157 | * Colors for modern XTerm and compatible terminals. 158 | * 159 | * See https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit 160 | */ 161 | object XTerm { 162 | 163 | abstract protected class Api { 164 | 165 | protected def start: String 166 | protected def fromLine(line: String): Style 167 | 168 | /** 169 | * Uses a 6x6x6 color cube to render 8-bit colors. 170 | * 171 | * Requires r, g, and b to be in [0, 5]. 172 | */ 173 | def color(r: Int, g: Int, b: Int): Style = { 174 | require(0 <= r && r <= 5, s"invalid red: $r (should be 0-5)") 175 | require(0 <= g && g <= 5, s"invalid green: $g (should be 0-5)") 176 | require(0 <= b && b <= 5, s"invalid blue: $b (should be 0-5)") 177 | // 16 + 36 × r + 6 × g + b 178 | colorCode(16 + 36 * r + 6 * g + b) 179 | } 180 | 181 | /** 182 | * Uses a 6x6x6 color cube to render 8-bit colors. 183 | * 184 | * Ensures that integer values are in [0, 5], other values are 185 | * squashed into the interval. 186 | */ 187 | def laxColor(r: Int, g: Int, b: Int): Style = { 188 | def fix(n: Int): Int = Math.max(0, Math.min(5, n)) 189 | color(fix(r), fix(g), fix(b)) 190 | } 191 | 192 | /** 193 | * Uses 24 steps to render a gray 8-bit color. 194 | * 195 | * Step must be in [0, 23]. 196 | */ 197 | def gray(step: Int): Style = { 198 | require(0 <= step && step <= 23, s"invalid step: $step (should be 0-23)") 199 | colorCode(step + 232) 200 | } 201 | 202 | /** 203 | * Uses 24 steps to render a gray 8-bit color. 204 | * 205 | * Ensures that integer values are in [0, 23], other values are 206 | * squashed into the interval. 207 | */ 208 | def laxGray(step: Int): Style = 209 | gray(Math.max(0, Math.min(23, step))) 210 | 211 | /** 212 | * Renders an 8-bit color from the given code. 213 | * 214 | * Codes must be in [0, 255]. 215 | */ 216 | def colorCode(code: Int): Style = { 217 | require(0 <= code && code <= 255) 218 | fromLine(s"$start;5;$code") 219 | } 220 | 221 | /** 222 | * Renders an 8-bit color from the given code. 223 | * 224 | * Ensures that integer values are in [0, 255], other values are 225 | * squashed into the interval. 226 | */ 227 | def laxColorCode(code: Int): Style = 228 | colorCode(Math.max(0, Math.min(255, code))) 229 | } 230 | 231 | object Fg extends Api { 232 | protected val start = "38" 233 | protected def fromLine(line: String): Style = 234 | Impl(Some(line), None, Nil) 235 | } 236 | 237 | object Bg extends Api { 238 | protected val start = "48" 239 | protected def fromLine(line: String): Style = 240 | Impl(None, Some(line), Nil) 241 | } 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /core/src/main/scala/org/typelevel/paiges/package.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2017 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package org.typelevel 18 | 19 | package object paiges { 20 | 21 | @annotation.tailrec 22 | private[paiges] def call[A](a: A, stack: List[A => A]): A = 23 | stack match { 24 | case Nil => a 25 | case h :: tail => call(h(a), tail) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /core/src/test/scala/org/typelevel/paiges/ColorTest.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2017 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package org.typelevel.paiges 18 | 19 | import org.scalatest.funsuite.AnyFunSuite 20 | 21 | class ColorTest extends AnyFunSuite { 22 | 23 | val Quote = """Three Rings for the Elven-kings under the sky, 24 | Seven for the Dwarf-lords in their halls of stone, 25 | Nine for Mortal Men doomed to die, 26 | One for the Dark Lord on his dark throne 27 | In the Land of Mordor where the Shadows lie. 28 | One Ring to rule them all, One Ring to find them, 29 | One Ring to bring them all and in the darkness bind them 30 | In the Land of Mordor where the Shadows lie. 31 | """ 32 | 33 | val TwoPi = Math.PI * 2.0 34 | val TwoThirdsPi = TwoPi / 3.0 35 | 36 | // x cycles in [0, 2π). 37 | def fromAngle(x: Double): (Int, Int, Int) = { 38 | val r = ((0.5 + Math.cos(x) / 2) * 6).toInt 39 | val g = ((0.5 + Math.cos(x - TwoThirdsPi) / 2) * 6).toInt 40 | val b = ((0.5 + Math.cos(x + TwoThirdsPi) / 2) * 6).toInt 41 | (r, g, b) 42 | } 43 | 44 | def fg(x: Double): Style = { 45 | val (r, g, b) = fromAngle(x) 46 | Style.XTerm.Fg.laxColor(r, g, b) 47 | } 48 | 49 | def bg(x: Double): Style = { 50 | val (r, g, b) = fromAngle(x) 51 | Style.XTerm.Bg.laxColor(r, g, b) ++ Style.Ansi.Fg.Black 52 | } 53 | 54 | def rainbow(s: String, steps: Int = -1, styler: Double => Style): Doc = { 55 | val n = if (steps <= 0) s.length else steps 56 | def loop(acc: Doc, i: Int, j: Int): Doc = 57 | if (i >= s.length) acc 58 | else 59 | s.charAt(i) match { 60 | case ' ' => 61 | loop(acc + Doc.lineOrSpace, i + 1, j) 62 | case c => 63 | val x = (j * TwoPi) / n 64 | val d0 = Doc.char(c).style(styler(x)) 65 | loop(acc + d0, i + 1, j + 1) 66 | } 67 | loop(Doc.empty, 0, 0) 68 | } 69 | 70 | test("rainbow demo") { 71 | val demo = rainbow(Quote, styler = fg).render(80) + "\n\n" + rainbow(Quote, 7, styler = bg).render(28) 72 | println(demo) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /core/src/test/scala/org/typelevel/paiges/DocumentTests.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2017 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package org.typelevel.paiges 18 | 19 | import org.scalatest.funsuite.AnyFunSuite 20 | import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks._ 21 | 22 | class DocumentTest extends AnyFunSuite { 23 | import Doc.text 24 | import PaigesTest._ 25 | 26 | implicit val generatorDrivenConfig: PropertyCheckConfiguration = 27 | PropertyCheckConfiguration(minSuccessful = 500) 28 | 29 | def document[A](a: A)(implicit d: Document[A]): Doc = 30 | d.document(a) 31 | 32 | test("Document[Unit]") { 33 | document(()) === text("()") 34 | } 35 | 36 | test("Document[Boolean]") { 37 | document(true) === text("true") 38 | document(false) === text("false") 39 | } 40 | 41 | test("Document[Byte]") { 42 | forAll((x: Byte) => document(x) === text(x.toString)) 43 | } 44 | 45 | test("Document[Short]") { 46 | forAll((x: Short) => document(x) === text(x.toString)) 47 | } 48 | 49 | test("Document[Int]") { 50 | forAll((x: Int) => document(x) === text(x.toString)) 51 | } 52 | 53 | test("Document[Long]") { 54 | forAll((x: Long) => document(x) === text(x.toString)) 55 | } 56 | 57 | test("Document[Float]") { 58 | forAll((x: Float) => document(x) === text(x.toString)) 59 | } 60 | 61 | test("Document[Double]") { 62 | forAll((x: Double) => document(x) === text(x.toString)) 63 | } 64 | 65 | test("Document[String]") { 66 | forAll((s: String) => document(s) === text(s)) 67 | } 68 | 69 | test("Document[List[Int]]") { 70 | val inst = Document.documentIterable[Int]("list") 71 | val d = inst.document(1 to 12) 72 | assert(d.render(80) == "list(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12)") 73 | 74 | val expected = """list( 75 | | 1, 2, 3, 76 | | 4, 5, 6, 77 | | 7, 8, 9, 78 | | 10, 11, 79 | | 12 80 | |)""".stripMargin 81 | assert(d.render(10) == expected) 82 | } 83 | 84 | test("Ops") { 85 | import Document.ops._ 86 | assert(("a".doc + "b".doc).render(80) == "ab") 87 | assert(1.doc.space("is").space(true.doc).render(80) == "1 is true") 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /core/src/test/scala/org/typelevel/paiges/Generators.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2017 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package org.typelevel.paiges 18 | 19 | import org.scalacheck.Shrink.shrink 20 | import org.scalacheck.{Arbitrary, Cogen, Gen, Shrink} 21 | 22 | object Generators { 23 | import Doc.text 24 | 25 | val asciiString: Gen[String] = 26 | for { 27 | n <- Gen.choose(1, 10) 28 | cs <- Gen.listOfN(n, Gen.choose(32.toChar, 126.toChar)) 29 | } yield cs.mkString 30 | 31 | val generalString: Gen[String] = 32 | implicitly[Arbitrary[String]].arbitrary 33 | 34 | val doc0Gen: Gen[Doc] = Gen.frequency( 35 | (1, Doc.empty), 36 | (1, Doc.space), 37 | (1, Doc.line), 38 | (1, Doc.lineBreak), 39 | (1, Doc.lineOrSpace), 40 | (1, Doc.lineOrEmpty), 41 | (1, Doc.lineOr(Doc.hardLine + Doc.empty)), // stress-test hardLine 42 | (1, Doc.hardLine), 43 | (15, asciiString.map(text(_))), 44 | (15, generalString.map(text(_))), 45 | (3, asciiString.map(Doc.zeroWidth(_))), 46 | (3, asciiString.map(Doc.split(_))), 47 | (3, generalString.map(Doc.split(_))), 48 | (3, generalString.map(Doc.paragraph(_))) 49 | ) 50 | 51 | val combinators: Gen[(Doc, Doc) => Doc] = 52 | Gen.oneOf((a: Doc, b: Doc) => a + b, 53 | (a: Doc, b: Doc) => a.space(b), 54 | (a: Doc, b: Doc) => a / b, 55 | (a: Doc, b: Doc) => a.lineOrSpace(b) 56 | ) 57 | 58 | val genFg: Gen[Style] = { 59 | import Style.Ansi.Fg._ 60 | val ansi = Gen.oneOf( 61 | Black, 62 | Red, 63 | Green, 64 | Yellow, 65 | Blue, 66 | Magenta, 67 | Cyan, 68 | White, 69 | Default, 70 | BrightBlack, 71 | BrightRed, 72 | BrightGreen, 73 | BrightYellow, 74 | BrightBlue, 75 | BrightMagenta, 76 | BrightCyan, 77 | BrightWhite 78 | ) 79 | val gc = Gen.choose(-1, 6) 80 | val xterm = Gen.oneOf( 81 | Gen.choose(-1, 256).map(Style.XTerm.Fg.laxColorCode(_)), 82 | Gen.choose(-1, 24).map(Style.XTerm.Fg.laxGray(_)), 83 | Gen.zip(gc, gc, gc).map { case (r, g, b) => Style.XTerm.Fg.laxColor(r, g, b) } 84 | ) 85 | Gen.oneOf(ansi, xterm) 86 | } 87 | 88 | val genBg: Gen[Style] = { 89 | import Style.Ansi.Bg._ 90 | Gen.oneOf( 91 | Black, 92 | Red, 93 | Green, 94 | Yellow, 95 | Blue, 96 | Magenta, 97 | Cyan, 98 | White, 99 | Default, 100 | BrightBlack, 101 | BrightRed, 102 | BrightGreen, 103 | BrightYellow, 104 | BrightBlue, 105 | BrightMagenta, 106 | BrightCyan, 107 | BrightWhite 108 | ) 109 | } 110 | 111 | val genAttr: Gen[Style] = { 112 | import Style.Ansi.Attr._ 113 | Gen.oneOf( 114 | Bold, 115 | Faint, 116 | Italic, 117 | Underline, 118 | SlowBlink, 119 | FastBlink, 120 | Inverse, 121 | Conceal, 122 | CrossedOut, 123 | BoldOff, 124 | FaintOff, 125 | ItalicOff, 126 | UnderlineOff, 127 | BlinkOff, 128 | InverseOff, 129 | ConcealOff, 130 | CrossedOutOff 131 | ) 132 | } 133 | 134 | lazy val genStyle: Gen[Style] = { 135 | val recur = Gen.lzy(genStyle) 136 | Gen.frequency(10 -> genFg, 10 -> genBg, 10 -> genAttr, 5 -> Gen.zip(recur, recur).map { case (s0, s1) => s0 ++ s1 }) 137 | } 138 | 139 | implicit val arbitraryStyle: Arbitrary[Style] = 140 | Arbitrary(genStyle) 141 | 142 | val unary: Gen[Doc => Doc] = 143 | Gen.oneOf( 144 | genStyle.map(s => (d: Doc) => d.style(s)), 145 | Gen.const((d: Doc) => Doc.defer(d)), 146 | Gen.const((d: Doc) => d.grouped), 147 | Gen.const((d: Doc) => d.aligned), 148 | Gen.const((d: Doc) => Doc.lineOr(d)), 149 | Gen.choose(0, 40).map(i => (d: Doc) => d.nested(i)) 150 | ) 151 | 152 | def folds(genDoc: Gen[Doc], withFill: Boolean): Gen[(List[Doc] => Doc)] = { 153 | val gfill = genDoc.map(sep => (ds: List[Doc]) => Doc.fill(sep, ds.take(8))) 154 | 155 | Gen.frequency( 156 | (1, genDoc.map(sep => (ds: List[Doc]) => Doc.intercalate(sep, ds))), 157 | (2, Gen.const((ds: List[Doc]) => Doc.spread(ds))), 158 | (2, Gen.const((ds: List[Doc]) => Doc.stack(ds))), 159 | (if (withFill) 1 else 0, gfill) 160 | ) 161 | } 162 | 163 | def leftAssoc(max: Int): Gen[Doc] = 164 | for { 165 | n <- Gen.choose(1, max) 166 | start <- genDoc 167 | front <- Gen.listOfN(n, genDoc) 168 | } yield front.foldLeft(start)(Doc.Concat(_, _)) 169 | 170 | def fill(max: Int): Gen[Doc] = { 171 | // we start at 1 because fill(d, Nil) == Empty 172 | val c1 = Gen.choose(1, max) 173 | for { 174 | (n, m, k) <- Gen.zip(c1, c1, c1) 175 | l <- Gen.listOfN(n, leftAssoc(m)) 176 | sep <- leftAssoc(k) 177 | } yield Doc.fill(sep, l) 178 | } 179 | 180 | val maxDepth = 7 181 | 182 | def genTree(depth: Int, withFill: Boolean): Gen[Doc] = { 183 | val recur = Gen.lzy(genTree(depth - 1, withFill)) 184 | val ugen = for { 185 | u <- unary 186 | d <- recur 187 | } yield u(d) 188 | 189 | val cgen = for { 190 | c <- combinators 191 | d0 <- recur 192 | d1 <- recur 193 | } yield c(d0, d1) 194 | 195 | val fgen = for { 196 | num <- Gen.choose(0, 12) 197 | fold <- folds(recur, withFill) 198 | ds <- Gen.listOfN(num, recur) 199 | } yield fold(ds) 200 | 201 | if (depth <= 0) doc0Gen 202 | else 203 | // don't include folds, which branch greatly, 204 | // except at the top (to avoid making giant docs) 205 | Gen.frequency( 206 | // bias to simple stuff 207 | (6, doc0Gen), 208 | (1, ugen), 209 | (2, cgen), 210 | (if (depth >= maxDepth - 1) 1 else 0, fgen) 211 | ) 212 | } 213 | 214 | val genDoc: Gen[Doc] = 215 | Gen.choose(0, maxDepth).flatMap(genTree(_, withFill = true)) 216 | 217 | val genDocNoFill: Gen[Doc] = 218 | Gen.choose(0, maxDepth).flatMap(genTree(_, withFill = false)) 219 | 220 | implicit val arbDoc: Arbitrary[Doc] = 221 | Arbitrary(genDoc) 222 | 223 | implicit val cogenDoc: Cogen[Doc] = 224 | Cogen[Int].contramap((d: Doc) => d.hashCode) 225 | 226 | private def isUnion(d: Doc): Boolean = 227 | d match { 228 | case Doc.Union(_, _) => true 229 | case _ => false 230 | } 231 | 232 | // Unions generated by `fill` are poorly behaved. Some tests only 233 | // pass with Unions generated by `grouped`. 234 | val genGroupedUnion: Gen[Doc.Union] = 235 | genDocNoFill.map(_.grouped).filter(isUnion).map(_.asInstanceOf[Doc.Union]) 236 | 237 | val genUnion: Gen[Doc.Union] = 238 | Gen.oneOf(genDoc.map(_.grouped), fill(10)).filter(isUnion).map(_.asInstanceOf[Doc.Union]) 239 | 240 | implicit val arbUnion: Arbitrary[Doc.Union] = Arbitrary(genUnion) 241 | 242 | implicit val shrinkDoc: Shrink[Doc] = { 243 | import Doc._ 244 | def interleave[A](xs: Stream[A], ys: Stream[A]): Stream[A] = 245 | if (xs.isEmpty) ys 246 | else if (ys.isEmpty) xs 247 | else xs.head #:: ys.head #:: interleave(xs.tail, ys.tail) 248 | 249 | def combine[A](a: A)(f: A => A)(implicit F: Shrink[A]): Stream[A] = { 250 | val sa = shrink(a) 251 | a #:: interleave(sa, sa.map(f)) 252 | } 253 | def combine2[A](a: A, b: A)(f: (A, A) => A)(implicit F: Shrink[A]): Stream[A] = { 254 | val (sa, sb) = (shrink(a), shrink(b)) 255 | a #:: b #:: interleave(interleave(sa, sb), sa.flatMap(x => sb.map(y => f(x, y)))) 256 | } 257 | Shrink { 258 | case FlatAlt(_, b) => b #:: shrinkDoc.shrink(b) 259 | case Union(a, b) => combine2(a, b)(Union(_, _)) 260 | case Concat(a, b) => combine2(a, b)(_ + _) 261 | case Text(s) => shrink(s).map(text) 262 | case ZeroWidth(s) => shrink(s).map(zeroWidth) 263 | case Nest(i, d) => interleave(shrink(d), combine(d)(_.nested(i))) 264 | case Align(d) => interleave(shrink(d), combine(d)(_.aligned)) 265 | case Line | Empty => Stream.empty 266 | case d @ LazyDoc(_) => d.evaluated #:: shrink(d.evaluated) 267 | } 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /core/src/test/scala/org/typelevel/paiges/JsonTest.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2017 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package org.typelevel.paiges 18 | 19 | import org.scalatest.funsuite.AnyFunSuite 20 | 21 | /** 22 | * A simple JSON ast 23 | */ 24 | sealed abstract class Json { 25 | def toDoc: Doc 26 | } 27 | 28 | object Json { 29 | import Doc.{str, text} 30 | 31 | def escape(str: String): String = 32 | str.flatMap { 33 | case '\\' => "\\\\" 34 | case '\n' => "\\n" 35 | case '"' => "\"" 36 | case other => other.toString 37 | } 38 | 39 | case class JString(str: String) extends Json { 40 | def toDoc = text("\"%s\"".format(escape(str))) 41 | } 42 | case class JDouble(toDouble: Double) extends Json { 43 | def toDoc = str(toDouble) 44 | } 45 | case class JInt(toInt: Int) extends Json { 46 | def toDoc = str(toInt) 47 | } 48 | case class JBool(toBoolean: Boolean) extends Json { 49 | def toDoc = str(toBoolean) 50 | } 51 | case object JNull extends Json { 52 | def toDoc = text("null") 53 | } 54 | case class JArray(toVector: Vector[Json]) extends Json { 55 | def toDoc = { 56 | val parts = Doc.intercalate(Doc.comma, toVector.map(j => (Doc.line + j.toDoc).grouped)) 57 | "[" +: ((parts :+ " ]").nested(2)) 58 | } 59 | } 60 | case class JObject(toMap: Map[String, Json]) extends Json { 61 | def toDoc = { 62 | val kvs = toMap.map { case (s, j) => 63 | JString(s).toDoc + text(":") + ((Doc.lineOrSpace + j.toDoc).nested(2)) 64 | } 65 | val parts = Doc.fill(Doc.comma, kvs) 66 | parts.bracketBy(text("{"), text("}")) 67 | } 68 | } 69 | } 70 | 71 | class JsonTest extends AnyFunSuite { 72 | import Json._ 73 | 74 | test("test nesteded array json example") { 75 | val inner = JArray((1 to 20).map(i => JInt(i)).toVector) 76 | val outer = JArray(Vector(inner, inner, inner)) 77 | 78 | assert(outer.toDoc.render(20) == """[ 79 | [ 1, 2, 3, 4, 5, 80 | 6, 7, 8, 9, 10, 81 | 11, 12, 13, 14, 82 | 15, 16, 17, 18, 83 | 19, 20 ], 84 | [ 1, 2, 3, 4, 5, 85 | 6, 7, 8, 9, 10, 86 | 11, 12, 13, 14, 87 | 15, 16, 17, 18, 88 | 19, 20 ], 89 | [ 1, 2, 3, 4, 5, 90 | 6, 7, 8, 9, 10, 91 | 11, 12, 13, 14, 92 | 15, 16, 17, 18, 93 | 19, 20 ] ]""") 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /core/src/test/scala/org/typelevel/paiges/PaigesScalacheckTest.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2017 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package org.typelevel.paiges 18 | 19 | import org.scalacheck.Gen 20 | import org.scalatest.Assertion 21 | import org.scalatest.funsuite.AnyFunSuite 22 | import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks._ 23 | 24 | abstract class OurFunSuite extends AnyFunSuite { 25 | import PaigesTest._ 26 | 27 | def assertDoc(x: Doc)(p: Doc => Boolean): Assertion = { 28 | val ok = p(x) 29 | if (ok) succeed else fail(repr(x)) 30 | } 31 | 32 | def assertEq(x: Doc, y: Doc): Assertion = { 33 | val ok = x === y 34 | if (ok) succeed else fail(debugEq(x, y)) 35 | } 36 | 37 | def assertNeq(x: Doc, y: Doc): Assertion = { 38 | val ok = x !== y 39 | if (ok) succeed else fail(debugNeq(x, y)) 40 | } 41 | } 42 | 43 | class PaigesScalacheckTest extends OurFunSuite { 44 | import Doc.text 45 | import Generators._ 46 | import PaigesTest._ 47 | 48 | implicit val generatorDrivenConfig: PropertyCheckConfiguration = 49 | PropertyCheckConfiguration(minSuccessful = 500) 50 | 51 | test("(x = y) -> (x.## = y.##)") { 52 | forAll { (a: Doc, b: Doc) => 53 | assert(a.## == a.##) 54 | assert(b.## == b.##) 55 | if (a === b) assert(a.## == b.##) else succeed 56 | } 57 | } 58 | 59 | test("concat is associative") { 60 | forAll((a: Doc, b: Doc, c: Doc) => assertEq((a + b) + c, a + (b + c))) 61 | } 62 | 63 | test("line is associative") { 64 | forAll((a: Doc, b: Doc, c: Doc) => assertEq(a.line(b).line(c), a.line(b.line(c)))) 65 | } 66 | 67 | test("lineOrSpace is associative") { 68 | forAll((a: Doc, b: Doc, c: Doc) => assertEq(a.lineOrSpace(b).lineOrSpace(c), a.lineOrSpace(b.lineOrSpace(c)))) 69 | } 70 | 71 | test("LazyDoc.evaluate never returns a LazyDoc") { 72 | forAll { (a: Doc) => 73 | val ld = Doc.LazyDoc(() => a) 74 | assert(!ld.evaluated.isInstanceOf[Doc.LazyDoc]) 75 | } 76 | } 77 | 78 | test("writeTo works") { 79 | import java.io._ 80 | forAll { (doc: Doc, w: Int) => 81 | val baos = new ByteArrayOutputStream() 82 | val pw = new PrintWriter(baos) 83 | doc.writeTo(w, pw) 84 | pw.close() 85 | val s1 = baos.toString("UTF-8") 86 | val s2 = doc.render(w) 87 | assert(s1 == s2) 88 | } 89 | } 90 | 91 | test("writeToTrim works") { 92 | import java.io._ 93 | forAll { (doc: Doc, w: Int) => 94 | val baos = new ByteArrayOutputStream() 95 | val pw = new PrintWriter(baos) 96 | doc.writeToTrim(w, pw) 97 | pw.close() 98 | val s1 = baos.toString("UTF-8") 99 | val s2 = doc.renderTrim(w) 100 | assert(s1 == s2) 101 | } 102 | } 103 | 104 | test("empty does not change things") { 105 | forAll { (a: Doc) => 106 | assertEq(a + Doc.empty, a) 107 | assertEq(Doc.empty + a, a) 108 | } 109 | } 110 | 111 | test("spaces(n) == text(\" \") * n == text(\" \" * n)") { 112 | forAll(Gen.choose(-10, 1000)) { n => 113 | val sn = Doc.spaces(n) 114 | val tn = if (n <= 0) Doc.text("") else Doc.text(" ") * n 115 | val un = if (n <= 0) Doc.text("") else Doc.text(" " * n) 116 | 117 | assertEq(sn, tn) 118 | assertEq(tn, un) 119 | } 120 | } 121 | 122 | test("Doc.split(s, \" \", space).render(w) = s") { 123 | forAll((s: String, w: Int) => assert(Doc.split(s, " ".r, Doc.space).render(w) == s)) 124 | } 125 | 126 | test("splitting x on x gives x") { 127 | forAll(Gen.frequency((10, Gen.identifier), (1, Gen.const(" "))), Gen.choose(0, 100)) { (s, w) => 128 | assert(Doc.split(s, s.r, Doc.text(s)).render(w) == s) 129 | } 130 | } 131 | 132 | test("renderStreamTrim and renderTrim are consistent") { 133 | forAll { (d: Doc, width0: Int) => 134 | val width = width0 & 0xfff 135 | assertDoc(d)(d => d.renderStreamTrim(width).mkString == d.renderTrim(width)) 136 | } 137 | } 138 | 139 | test("trim-law: renderTrim is what we expect") { 140 | forAll((d: Doc) => assertDoc(d)(d => d.renderTrim(100) == slowRenderTrim(d, 100))) 141 | } 142 | 143 | test("(a /: b) works as expected") { 144 | forAll((a: String, b: String) => assert((a /: Doc.text(b)).render(0) == s"$a\n$b")) 145 | } 146 | 147 | test("space works as expected") { 148 | forAll { (a: String, b: String) => 149 | val res = s"$a $b" 150 | assert(text(a).space(b).render(0) == res) 151 | assert((text(a) & text(b)).render(0) == res) 152 | assert((text(a) :& b).render(0) == res) 153 | assert((a &: text(b)).render(0) == res) 154 | } 155 | } 156 | 157 | test("isEmpty == render(w).isEmpty for all w") { 158 | forAll { (d: Doc) => 159 | if (d.isEmpty) { 160 | val ok = (0 to d.maxWidth).forall(d.render(_).isEmpty) 161 | assert(ok, s"${d.representation(true).render(50)} has nonEmpty renderings") 162 | } else succeed 163 | } 164 | } 165 | 166 | test("nonEmpty == !isEmpty") { 167 | forAll((d: Doc) => assert(d.nonEmpty == !d.isEmpty)) 168 | } 169 | 170 | test("isEmpty compare empty == 0") { 171 | forAll { (d: Doc) => 172 | if (d.isEmpty) assertEq(d, Doc.empty) 173 | else succeed 174 | } 175 | } 176 | test("renders are constant after maxWidth") { 177 | forAll { (d: Doc, ws: List[Int]) => 178 | val m = d.maxWidth 179 | val maxR = d.render(m) 180 | val justAfter = (1 to 20).iterator 181 | val goodW = (justAfter ++ ws.iterator).map(w => (m + w).max(m)) 182 | goodW.foreach(w => assert(d.render(w) == maxR, repr(d))) 183 | } 184 | } 185 | 186 | test("render(w) == render(0) for w <= 0") { 187 | forAll { (a: Doc, w: Int) => 188 | val wNeg = if (w > 0) -w else w 189 | assertDoc(a)(a => a.render(wNeg) == a.render(0)) 190 | } 191 | } 192 | 193 | test("c.flatten + d.flatten == (c + d).flatten") { 194 | forAll((c: Doc, d: Doc) => assertEq(c.flatten + d.flatten, (c + d).flatten)) 195 | } 196 | 197 | test("group law") { 198 | 199 | /** 200 | * group(x) = (x' | x) where x' is flatten(x) 201 | * 202 | * (a | b)*c == (a*c | b*c) so, if flatten(c) == c we have: 203 | * c * (a | b) == (a*c | b*c) 204 | * 205 | * b.grouped + flatten(c) == (b + flatten(c)).grouped 206 | * flatten(c) + b.grouped == (flatten(c) + b).grouped 207 | */ 208 | def law(b: Doc, c: Doc): Assertion = { 209 | val flatC = c.flatten 210 | 211 | val left = b.grouped + flatC 212 | val right = (b + flatC).grouped 213 | assertEq(left, right) 214 | 215 | if (!containsHardLine(flatC)) { 216 | val lhs0 = flatC + b.grouped 217 | val rhs0 = (flatC + b).grouped 218 | assertEq(lhs0, rhs0) 219 | } 220 | 221 | // since left == right, we could have used those instead of b: 222 | val lhs1 = left.grouped + flatC 223 | val rhs1 = (right + flatC).grouped 224 | assertEq(lhs1, rhs1) 225 | } 226 | 227 | forAll((b: Doc, c: Doc) => law(b, c)) 228 | } 229 | 230 | test("flatten(group(a)) == flatten(a)") { 231 | forAll((a: Doc) => assertEq(a.grouped.flatten, a.flatten)) 232 | } 233 | test("a.flatten == a.flatten.flatten") { 234 | forAll { (a: Doc) => 235 | val aflat = a.flatten 236 | assertEq(aflat, aflat.flatten) 237 | } 238 | } 239 | test("a.flatten == a.flattenOption.getOrElse(a)") { 240 | forAll { (a: Doc) => 241 | val lhs = a.flatten 242 | val rhs = a.flattenOption.getOrElse(a) 243 | assertEq(lhs, rhs) 244 | } 245 | } 246 | 247 | test("a.aligned.aligned == a.aligned") { 248 | forAll((a: Doc) => assertEq(a.aligned.aligned, a.aligned)) 249 | } 250 | 251 | test("a is flat ==> Concat(a, Union(b, c)) === Union(Concat(a, b), Concat(a, c))") { 252 | import Doc._ 253 | forAll { (aGen: Doc, bc: Doc) => 254 | val a = aGen.flatten 255 | if (containsHardLine(a)) 256 | () 257 | else 258 | bc.grouped match { 259 | case d @ Union(b, c) => 260 | val lhs = Concat(a, d) 261 | val rhs = Union(Concat(a, b), Concat(a, c)) 262 | assertEq(lhs, rhs) 263 | case _ => 264 | } 265 | } 266 | } 267 | 268 | test("c is flat ==> Concat(Union(a, b), c) === Union(Concat(a, c), Concat(b, c))") { 269 | import Doc._ 270 | forAll { (ab: Doc, cGen: Doc) => 271 | val c = cGen.flatten 272 | ab.grouped match { 273 | case d @ Union(a, b) => 274 | assertEq(Concat(d, c), Union(Concat(a, c), Concat(b, c))) 275 | case _ => 276 | } 277 | } 278 | } 279 | 280 | test("Union invariant: `a.flatten == b.flatten`") { 281 | forAll((d: Doc.Union) => assertEq(d.a.flatten, d.b.flatten)) 282 | } 283 | 284 | test("Union invariant: `a != b`") { 285 | forAll((d: Doc.Union) => assertNeq(d.a, d.b)) 286 | } 287 | 288 | test("Union invariant: `a` has 2-right-associated `Concat` nodes") { 289 | forAll((d: Doc.Union) => assertDoc(d)(_ => PaigesTest.twoRightAssociated(d.a))) 290 | } 291 | 292 | test("Union invariant: the first line of `a` is at least as long as the first line of `b`") { 293 | forAll(Gen.choose(1, 200), genUnion) { (n, u) => 294 | def firstLine(d: Doc) = { 295 | def loop(s: Iterator[String], acc: List[String]): String = 296 | if (!s.hasNext) acc.reverse.mkString 297 | else { 298 | val head = s.next() 299 | if (head.contains('\n')) 300 | (head.takeWhile(_ != '\n') :: acc).reverse.mkString 301 | else loop(s, head :: acc) 302 | } 303 | loop(d.renderStream(n).iterator, Nil) 304 | } 305 | assert(firstLine(u.a).length >= firstLine(u.b).length) 306 | } 307 | } 308 | 309 | test("test Doc.text") { 310 | forAll((s: String) => assert(Doc.text(s).render(0) == s)) 311 | } 312 | 313 | test("Doc.repeat matches naive implementation") { 314 | 315 | /** 316 | * comparing large equal documents can be very slow 317 | * :( 318 | */ 319 | implicit val generatorDrivenConfig: PropertyCheckConfiguration = 320 | PropertyCheckConfiguration(minSuccessful = 30) 321 | val smallTree = Gen.choose(0, 3).flatMap(genTree(_, withFill = true)) 322 | val smallInt = Gen.choose(0, 10) 323 | 324 | def simple(n: Int, d: Doc, acc: Doc): Doc = 325 | if (n <= 0) acc else simple(n - 1, d, acc + d) 326 | 327 | forAll(smallTree, smallInt)((d: Doc, small: Int) => assertEq(simple(small, d, Doc.empty), d * small)) 328 | } 329 | test("(d * a) * b == d * (a * b)") { 330 | 331 | /** 332 | * comparing large equal documents can be very slow 333 | * :( 334 | */ 335 | implicit val generatorDrivenConfig: PropertyCheckConfiguration = 336 | PropertyCheckConfiguration(minSuccessful = 30) 337 | val smallTree = Gen.choose(0, 3).flatMap(genTree(_, withFill = true)) 338 | val smallInt = Gen.choose(0, 3) 339 | 340 | forAll(smallTree, smallInt, smallInt)((d: Doc, a: Int, b: Int) => assertEq((d * a) * b, d * (a * b))) 341 | } 342 | test("text(s) * n == s * n for identifiers") { 343 | forAll(Gen.identifier, Gen.choose(0, 20))((s, n) => assert((Doc.text(s) * n).render(0) == (s * n))) 344 | } 345 | 346 | test("render(w) == renderStream(w).mkString") { 347 | forAll((d: Doc, w: Int) => assert(d.render(w) == d.renderStream(w).mkString)) 348 | } 349 | 350 | test("renderWide == render(maxWidth)") { 351 | forAll { (d: Doc) => 352 | val max = d.maxWidth 353 | assertDoc(d)(d => d.renderWideStream.mkString == d.render(max)) 354 | } 355 | } 356 | 357 | test("character concat works") { 358 | forAll { (c1: Char, c2: Char) => 359 | val got = Doc.char(c1) + Doc.char(c2) 360 | val expected = Doc.text(new String(Array(c1, c2))) 361 | assertEq(got, expected) 362 | } 363 | 364 | // here is a hard case: 365 | assertEq(Doc.char('a') + Doc.char('\n'), Doc.text("a\n")) 366 | } 367 | 368 | test("fill matches spec") { 369 | val docsGen = for { 370 | n <- Gen.choose(0, 12) 371 | ds <- Gen.listOfN(n, genDoc) 372 | } yield ds 373 | forAll(genDoc, docsGen) { (sep: Doc, ds: List[Doc]) => 374 | val lhs = Doc.fill(sep, ds) 375 | val rhs = fillSpec(sep, ds) 376 | assertEq(lhs, rhs) 377 | } 378 | } 379 | 380 | test("FlatAlt invariant 0: FlatAlt renders as default") { 381 | forAll((d1: Doc, d2: Doc, w: Int) => assert(Doc.FlatAlt(d1, d2).render(w) == d1.render(w))) 382 | } 383 | 384 | test("FlatAlt invariant 1: All constructed FlatAlt have width(default) <= width(flattened)") { 385 | import Doc._ 386 | def law(d: Doc): Boolean = 387 | d match { 388 | case Empty | Text(_) | ZeroWidth(_) | Line => true 389 | case FlatAlt(a, b) => 390 | a.maxWidth <= b.maxWidth 391 | case Concat(a, b) => 392 | law(a) && law(b) 393 | case Union(a, b) => 394 | law(a) && law(b) 395 | case f @ LazyDoc(_) => law(f.evaluated) 396 | case Align(d) => law(d) 397 | case Nest(_, d) => law(d) 398 | } 399 | 400 | forAll((d: Doc) => assert(law(d))) 401 | } 402 | 403 | test("FlatAlt invariant 2: default != whenFlat (otherwise the FlatAlt is redundant)") { 404 | import Doc._ 405 | def law(d: Doc): Boolean = 406 | d match { 407 | case Empty | Text(_) | ZeroWidth(_) | Line => true 408 | case FlatAlt(a, b) => 409 | a !== b 410 | case Concat(a, b) => 411 | law(a) && law(b) 412 | case Union(a, b) => 413 | law(a) && law(b) 414 | case f @ LazyDoc(_) => law(f.evaluated) 415 | case Align(d) => law(d) 416 | case Nest(_, d) => law(d) 417 | } 418 | 419 | forAll((d: Doc) => assertDoc(d)(law)) 420 | } 421 | 422 | test("FlatAlt invariant 3: FlatAlt does not occur on the left side of a union") { 423 | import Doc._ 424 | def law(d: Doc, isLeft: Boolean): Boolean = 425 | d match { 426 | case Empty | Text(_) | ZeroWidth(_) | Line => true 427 | case FlatAlt(a, b) => !isLeft && law(a, isLeft) && law(b, isLeft) 428 | case Concat(a, b) => 429 | law(a, isLeft) && law(b, isLeft) 430 | case Union(a, b) => 431 | // we only care about the first parent of a FlatAlt node; 432 | // once we see a union its right side could have a FlatAlt 433 | // but its left side must not. 434 | law(a, true) && law(b, false) 435 | case f @ LazyDoc(_) => law(f.evaluated, isLeft) 436 | case Align(d) => law(d, isLeft) 437 | case Nest(_, d) => law(d, isLeft) 438 | } 439 | 440 | forAll((d: Doc) => assertDoc(d)(law(_, false))) 441 | } 442 | 443 | test("flattened docs never have FlatAlt") { 444 | import Doc._ 445 | def law(d: Doc): Boolean = 446 | d match { 447 | case FlatAlt(_, _) => false 448 | case Empty | Text(_) | ZeroWidth(_) | Line => true 449 | case Concat(a, b) => law(a) && law(b) 450 | case Union(a, b) => law(a) && law(b) 451 | case f @ LazyDoc(_) => law(f.evaluated) 452 | case Align(d) => law(d) 453 | case Nest(_, d) => law(d) 454 | } 455 | 456 | forAll((d: Doc) => assertDoc(d)(x => law(x.flatten))) 457 | } 458 | 459 | // remove ANSI control strings 460 | // 461 | // these strings start with ESC and '[' and end with 'm' 462 | // they contain numbers and semicolons 463 | // i.e. "\u001b[52;1m" 464 | def removeControls(s: String): String = { 465 | val sb = new StringBuilder 466 | var i = 0 467 | var good = true 468 | val last = s.length - 1 469 | while (i <= last) { 470 | val c = s.charAt(i) 471 | if (good) 472 | if (c == 27 && i < last && s.charAt(i + 1) == '[') good = false 473 | else sb.append(c) 474 | else if (c == 'm') good = true 475 | else () 476 | i += 1 477 | } 478 | sb.toString 479 | } 480 | 481 | test("removeControls(Doc.ansiControl(ns).render(w)) is empty") { 482 | forAll { (ns: List[Byte], w: Int) => 483 | val d = Doc.ansiControl(ns.map(_ & 0xff): _*) 484 | assert(removeControls(d.render(w)) == "") 485 | } 486 | } 487 | 488 | test("styles are associative under ++") { 489 | forAll((a: Style, b: Style, c: Style) => assert(((a ++ b) ++ c) == (a ++ (b ++ c)))) 490 | } 491 | 492 | test("styles have an Empty identity under ++") { 493 | forAll { (a: Style) => 494 | assert((a ++ Style.Empty) == a) 495 | assert((Style.Empty ++ a) == a) 496 | } 497 | } 498 | 499 | test("rhs wins for style fg") { 500 | forAll(genFg, genFg)((a, b) => assert((a ++ b) == b)) 501 | } 502 | 503 | test("rhs wins for style bg") { 504 | forAll(genBg, genBg)((a, b) => assert((a ++ b) == b)) 505 | } 506 | 507 | test("unzero is idempotent") { 508 | forAll { (d0: Doc) => 509 | val d1 = d0.unzero 510 | assertEq(d1.unzero, d1) 511 | } 512 | } 513 | 514 | test("unzero removes all ZeroWidth nodes") { 515 | import Doc._ 516 | def law(d: Doc): Boolean = 517 | d match { 518 | case ZeroWidth(_) => false 519 | case FlatAlt(a, b) => law(a) && law(b) 520 | case Empty | Text(_) | Line => true 521 | case Concat(a, b) => law(a) && law(b) 522 | case Union(a, b) => law(a) && law(b) 523 | case f @ LazyDoc(_) => law(f.evaluated) 524 | case Align(d) => law(d) 525 | case Nest(_, d) => law(d) 526 | } 527 | forAll((d: Doc) => assertDoc(d)(d => law(d.unzero))) 528 | } 529 | 530 | test("hang law") { 531 | val ex0 = Doc.split("this is an example").hang(2).render(0) 532 | assert(ex0 == "this\n is\n an\n example") 533 | forAll { (d: Doc, i0: Int) => 534 | // make a number between 0 and 199 535 | val i = (i0 & 0x7fffffff) % 200 536 | assertEq(d.hang(i), d.nested(i).aligned) 537 | } 538 | } 539 | 540 | test("indent law") { 541 | val ex0 = Doc.split("this is an example").indent(2).render(0) 542 | assert(ex0 == " this\n is\n an\n example") 543 | forAll { (d: Doc, i0: Int) => 544 | // make a number between 0 and 199 545 | val i = (i0 & 0x7fffffff) % 200 546 | assertEq(d.indent(i), (Doc.spaces(i) + d).nested(i).aligned) 547 | } 548 | } 549 | 550 | test("x.line(s) = (x :/ s)") { 551 | forAll((x: Doc, s: String) => assertEq(x.line(s), x :/ s)) 552 | } 553 | 554 | test("x.lineOrSpace(s) = x.lineOrSpace(Doc.text(s))") { 555 | forAll((x: Doc, s: String) => assertEq(x.lineOrSpace(s), x.lineOrSpace(Doc.text(s)))) 556 | } 557 | 558 | test("Doc.defer(x).representation(true) = x.representation(true)") { 559 | forAll((x: Doc) => assertEq(Doc.defer(x).representation(true), x.representation(true))) 560 | } 561 | 562 | test("textWithLine with hardLine has lines <= textWithLine") { 563 | forAll { (str: String, i0: Int) => 564 | // make a number between 0 and 199 565 | val width = (i0 & 0x7fffffff) % 200 566 | val str0 = Doc.text(str).flatten 567 | val str1 = Doc.textWithLine(str, Doc.line).flatten 568 | val str2 = Doc.textWithLine(str, Doc.hardLine).flatten 569 | 570 | val r1 = str0.render(width) 571 | val r2 = str1.render(width) 572 | val r3 = str2.render(width) 573 | assert(r1 == r2) 574 | 575 | def maxLineLen(str: String): Int = 576 | str.split("\n").foldLeft(0)((acc, line) => math.max(acc, line.length)) 577 | 578 | // hard lines can't be combined 579 | assert(maxLineLen(r1) >= maxLineLen(r3)) 580 | } 581 | } 582 | 583 | test("textWithLine is different from text when there is a line and we render wide") { 584 | forAll { (str: String) => 585 | val hasLine = str.exists(_ == '\n') 586 | 587 | val tStr = Doc.text(str).flatten.renderWideStream.mkString 588 | val hlStr = Doc.textWithLine(str, Doc.hardLine).flatten.renderWideStream.mkString 589 | 590 | assert(hasLine == (tStr != hlStr)) 591 | } 592 | } 593 | 594 | test("Doc.textWithLine(\"\\n\", d) eq d") { 595 | forAll { (doc: Doc) => 596 | assert(Doc.textWithLine("\n", doc) eq doc) 597 | } 598 | } 599 | } 600 | -------------------------------------------------------------------------------- /core/src/test/scala/org/typelevel/paiges/PaigesTest.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2017 Typelevel 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package org.typelevel.paiges 18 | 19 | import scala.annotation.tailrec 20 | import scala.util.Random 21 | import org.scalatest.funsuite.AnyFunSuite 22 | 23 | object PaigesTest { 24 | 25 | def repr(d: Doc): String = 26 | d.representation().render(100) 27 | 28 | def esc(s: String): String = 29 | "\"" + s.replace("\t", "\\t").replace("\n", "\\n") + "\"" 30 | 31 | def debugEq(x: Doc, y: Doc): String = { 32 | val maxW = Integer.max(x.maxWidth, y.maxWidth) 33 | (0 until maxW).find(w => !Doc.orderingAtWidth(w).equiv(x, y)) match { 34 | case Some(w) => s"$w: ${repr(x)} != ${repr(y)} (${esc(x.render(w))} != ${esc(y.render(w))})" 35 | case None => sys.error("should not happen") 36 | } 37 | } 38 | 39 | def debugNeq(x: Doc, y: Doc): String = { 40 | val maxW = Integer.max(x.maxWidth, y.maxWidth) 41 | (0 until maxW).find(w => Doc.orderingAtWidth(w).equiv(x, y)) match { 42 | case Some(w) => s"$w: ${repr(x)} == ${repr(y)} (${esc(x.render(w))} == ${esc(y.render(w))})" 43 | case None => sys.error("should not happen") 44 | } 45 | } 46 | 47 | /** 48 | * Returns true of the given doc contains a hardLine that 49 | * cannot be grouped or flattened away 50 | */ 51 | def containsHardLine(doc: Doc): Boolean = { 52 | import Doc._ 53 | @tailrec 54 | def loop(stack: List[Doc]): Boolean = 55 | stack match { 56 | case Nil => false 57 | case h :: tail => 58 | h match { 59 | case Line => true 60 | case Empty | Text(_) | ZeroWidth(_) => loop(tail) 61 | case FlatAlt(_, b) => loop(b :: tail) 62 | case Concat(a, b) => loop(a :: b :: tail) 63 | case Nest(_, d) => loop(d :: tail) 64 | case Align(d) => loop(d :: tail) 65 | case d @ LazyDoc(_) => loop(d.evaluated :: tail) 66 | case Union(a, b) => loop(a :: b :: tail) 67 | } 68 | } 69 | loop(doc :: Nil) 70 | } 71 | 72 | implicit val docEquiv: Equiv[Doc] = 73 | new Equiv[Doc] { 74 | def equiv(x: Doc, y: Doc): Boolean = { 75 | val maxWidth = Integer.max(x.maxWidth, y.maxWidth) 76 | def randomWidth(): Int = Random.nextInt(maxWidth) 77 | val widths = 78 | if (maxWidth == 0) 0 :: Nil 79 | else 0 :: randomWidth() :: randomWidth() :: Nil 80 | Doc.equivAtWidths(widths).equiv(x, y) 81 | } 82 | } 83 | 84 | implicit class EquivSyntax(lhs: Doc) { 85 | override def toString: String = lhs.toString 86 | def ===(rhs: Doc): Boolean = docEquiv.equiv(lhs, rhs) 87 | } 88 | 89 | def slowRenderTrim(d: Doc, width: Int): String = { 90 | val parts = d.render(width).split("\n", -1).toList 91 | parts match { 92 | case Nil => sys.error("unreachable") 93 | case other => 94 | other 95 | .map(str => str.reverse.dropWhile(_ == ' ').reverse) 96 | .mkString("\n") 97 | } 98 | } 99 | 100 | def twoRightAssociated(d: Doc): Boolean = { 101 | import Doc._ 102 | d match { 103 | case Empty | Text(_) | ZeroWidth(_) | Line => true 104 | case FlatAlt(a, _) => twoRightAssociated(a) 105 | case Concat(Concat(Concat(_, _), _), _) => false 106 | case Concat(a, b) => 107 | twoRightAssociated(a) && twoRightAssociated(b) 108 | case Union(a, _) => twoRightAssociated(a) 109 | case f @ LazyDoc(_) => twoRightAssociated(f.evaluated) 110 | case Align(d) => twoRightAssociated(d) 111 | case Nest(_, d) => twoRightAssociated(d) 112 | } 113 | } 114 | 115 | // Definition of `fill` from the paper 116 | def fillSpec(sep: Doc, ds: List[Doc]): Doc = { 117 | import Doc._ 118 | ds match { 119 | case Nil => empty 120 | case x :: Nil => x.grouped 121 | case x :: y :: zs => 122 | Union(x.flatten + (sep.flatten + defer(fillSpec(sep, y.flatten :: zs))), 123 | x + (sep + defer(fillSpec(sep, y :: zs))) 124 | ) 125 | } 126 | } 127 | } 128 | 129 | class PaigesTest extends AnyFunSuite { 130 | import Doc.text 131 | import PaigesTest._ 132 | 133 | test("basic test") { 134 | assert((text("hello") + text("world")).render(100) == "helloworld") 135 | } 136 | 137 | test("nested test") { 138 | assert( 139 | (text("yo") + (text("yo\nho\nho").nested(2))).render(100) == 140 | """yoyo 141 | ho 142 | ho""" 143 | ) 144 | } 145 | 146 | test("paper example") { 147 | val g = (((text("hello") :/ "a").grouped :/ "b").grouped :/ "c").grouped 148 | assert( 149 | g.render(5) == 150 | """hello 151 | a 152 | b 153 | c""" 154 | ) 155 | assert(g.render(11) == "hello a b c") 156 | } 157 | 158 | test("nesteding with paragraph") { 159 | val words = List("this", "is", "a", "test", "of", "a", "block", "of", "text") 160 | val d1 = Doc.paragraph(words.mkString(" ")) 161 | val d2 = d1 + (Doc.line :+ "love, Oscar").nested(2) 162 | assert(d2.render(0) == words.mkString("", "\n", "\n love, Oscar")) 163 | assert(d2.render(100) == words.mkString("", " ", "\n love, Oscar")) 164 | } 165 | 166 | test("test paragraph") { 167 | val p = Doc.paragraph( 168 | "This is some crazy\n text that should loook super normal\n\n after we get rid of the spaces" 169 | ) 170 | assert(p.render(10) == """This is 171 | some crazy 172 | text that 173 | should 174 | loook 175 | super 176 | normal 177 | after we 178 | get rid of 179 | the spaces""") 180 | } 181 | 182 | test("dangling space 1") { 183 | val d = Doc 184 | .stack( 185 | List( 186 | Doc.text("a"), 187 | Doc.text("b"), 188 | Doc.empty 189 | ) 190 | ) 191 | .nested(2) 192 | val expected = "a\n b\n" 193 | assert(d.renderTrim(100) == expected) 194 | assert(d.renderTrim(100) == slowRenderTrim(d, 100)) 195 | assert(d.renderStreamTrim(100).mkString == expected) 196 | } 197 | 198 | test("dangling space 2") { 199 | val d = Doc 200 | .stack( 201 | List( 202 | Doc.empty, 203 | Doc.text("a"), 204 | Doc.empty, 205 | Doc.empty, 206 | Doc.text("b") 207 | ) 208 | ) 209 | .nested(2) 210 | val expected = "\n a\n\n\n b" 211 | assert(d.renderTrim(100) == expected) 212 | assert(d.renderTrim(100) == slowRenderTrim(d, 100)) 213 | assert(d.renderStreamTrim(100).mkString == expected) 214 | } 215 | 216 | test("renderTrim trims a single line") { 217 | import Doc._ 218 | val d = Text("a ") 219 | val expected = "a" 220 | assert(d.renderTrim(100) == expected) 221 | assert(d.renderTrim(100) == slowRenderTrim(d, 100)) 222 | assert(d.renderStreamTrim(100).mkString == expected) 223 | } 224 | 225 | test("hard union cases") { 226 | 227 | /** 228 | * if s == space, and n == line 229 | * we know that: 230 | * a * (s|n) * b * (s|n) * c = 231 | * 232 | * (a * s * ((b * s * c) | (b * n * c)) | 233 | * (a * n * (b * s * c) | (b * n * c)) 234 | */ 235 | val first = Doc.paragraph("a b c") 236 | val second = Doc.fill(Doc.lineOrSpace, List("a", "b", "c").map(Doc.text)) 237 | /* 238 | * I think this fails perhaps because of the way fill constructs 239 | * Unions. It violates a stronger invariant that Union(a, b) 240 | * means a == flatten(b). It has the property that flatten(a) == flatten(b) 241 | * but that is weaker. Our current comparison algorithm seems 242 | * to leverage this fact 243 | */ 244 | assert(first === second) 245 | 246 | /** 247 | * lineOrSpace == (s | n) 248 | * flatten(lineOrSpace) = s 249 | * group(lineOrSpace) = (s | (s|n)) == (s | n) 250 | */ 251 | assert(Doc.lineOrSpace.grouped === Doc.lineOrSpace) 252 | } 253 | 254 | test("test json array example") { 255 | val items = (0 to 20).map(Doc.str(_)) 256 | val parts = Doc.fill(Doc.comma + Doc.line, items) 257 | val ary = "[" +: ((parts :+ "]").aligned) 258 | assert(ary.renderWideStream.mkString == (0 to 20).mkString("[", ", ", "]")) 259 | val expect = """[0, 1, 2, 3, 4, 5, 260 | | 6, 7, 8, 9, 10, 11, 261 | | 12, 13, 14, 15, 16, 262 | | 17, 18, 19, 20]""".stripMargin 263 | assert(ary.render(20) == expect) 264 | } 265 | 266 | test("test json map example") { 267 | val kvs = (0 to 20).map(i => text("\"%s\": %s".format(s"key$i", i))) 268 | val parts = Doc.fill(Doc.comma + Doc.lineOrSpace, kvs) 269 | 270 | val map = parts.bracketBy(Doc.text("{"), Doc.text("}")) 271 | assert( 272 | map.render(1000) == (0 to 20) 273 | .map(i => "\"%s\": %s".format(s"key$i", i)) 274 | .mkString("{ ", ", ", " }") 275 | ) 276 | assert( 277 | map.render(20) == (0 to 20) 278 | .map(i => "\"%s\": %s".format(s"key$i", i)) 279 | .map(" " + _) 280 | .mkString("{\n", ",\n", "\n}") 281 | ) 282 | 283 | val map2 = parts.tightBracketBy(Doc.text("{"), Doc.text("}")) 284 | assert( 285 | map2.render(1000) == (0 to 20) 286 | .map(i => "\"%s\": %s".format(s"key$i", i)) 287 | .mkString("{", ", ", "}") 288 | ) 289 | assert( 290 | map2.render(20) == (0 to 20) 291 | .map(i => "\"%s\": %s".format(s"key$i", i)) 292 | .map(" " + _) 293 | .mkString("{\n", ",\n", "\n}") 294 | ) 295 | } 296 | 297 | test("maxWidth is stack safe") { 298 | assert(Doc.intercalate(Doc.lineOrSpace, (1 to 100000).map(Doc.str)).maxWidth >= 0) 299 | } 300 | 301 | test("renderWide is stack safe") { 302 | val nums = 1 to 100000 303 | assert(Doc.intercalate(Doc.lineOrSpace, nums.map(Doc.str)).renderWideStream.mkString == nums.mkString(" ")) 304 | } 305 | 306 | test("lineBreak works as expected") { 307 | import Doc._ 308 | // render a tight list: 309 | val res = text("(") + Doc.intercalate((Doc.comma + Doc.lineBreak).grouped, (1 to 20).map(Doc.str)) + text(")") 310 | assert(res.render(10) == """(1,2,3,4, 311 | |5,6,7,8,9, 312 | |10,11,12, 313 | |13,14,15, 314 | |16,17,18, 315 | |19,20)""".stripMargin) 316 | assert(res.renderWideStream.mkString == (1 to 20).mkString("(", ",", ")")) 317 | } 318 | test("align works as expected") { 319 | import Doc._ 320 | // render with alignment 321 | val d1 = text("fooooo ") + text("bar").line(text("baz")).aligned 322 | 323 | assert(d1.render(0) == """fooooo bar 324 | | baz""".stripMargin) 325 | } 326 | 327 | test("fill example") { 328 | import Doc.{comma, fill, text} 329 | val ds = text("1") :: text("2") :: text("3") :: Nil 330 | val doc = fill(comma + Doc.line, ds) 331 | 332 | assert(doc.render(0) == "1,\n2,\n3") 333 | assert(doc.render(6) == "1, 2,\n3") 334 | assert(doc.render(10) == "1, 2, 3") 335 | } 336 | 337 | test("Doc.tabulate works in some example cases") { 338 | val caseMatch = List( 339 | ("Item1(x)", Doc.text("callItem(x)")), 340 | ("ItemXandItemY(x, y)", Doc.text("callItem(x)") / Doc.text("callItem(y)")), 341 | ("ItemXandItemYandZ(x, y, z)", Doc.text("callItem(x)") / Doc.text("callItem(y)") / Doc.text("callItem(z)")) 342 | ) 343 | 344 | val expected = """case Item1(x) => callItem(x) 345 | |case ItemXandItemY(x, y) => callItem(x) 346 | | callItem(y) 347 | |case ItemXandItemYandZ(x, y, z) => callItem(x) 348 | | callItem(y) 349 | | callItem(z)""".stripMargin 350 | 351 | assert(Doc.tabulate(' ', " => ", caseMatch.map { case (s, d) => ("case " + s, d) }).render(20) == expected) 352 | } 353 | 354 | test("abbreviated Doc.tabulate works in an example case") { 355 | 356 | val pairs = List( 357 | "alpha: " -> Doc.text("the first item in the list"), 358 | "beta: " -> Doc.text("another item;") / Doc.text("this one is longer"), 359 | "gamma: " -> Doc.text("a third, uninteresting case"), 360 | "delta: " -> Doc.text("a fourth,") / (Doc.text("multiline,") / Doc.text("indented")).nested(2) / Doc.text("case"), 361 | "epsilon: " -> Doc.text("the ultimate case") 362 | ) 363 | 364 | val expected = """alpha: the first item in the list 365 | |beta: another item; 366 | | this one is longer 367 | |gamma: a third, uninteresting case 368 | |delta: a fourth, 369 | | multiline, 370 | | indented 371 | | case 372 | |epsilon: the ultimate case""".stripMargin 373 | assert(Doc.tabulate(pairs).render(40) == expected) 374 | } 375 | 376 | test("cat") { 377 | assert(Doc.cat(List("1", "2", "3").map(Doc.text)).render(80) == "123") 378 | } 379 | 380 | test("defer doesn't evaluate immediately") { 381 | var count = 0 382 | val doc = Doc.defer { 383 | count += 1 384 | Doc.text("done") 385 | } 386 | assert(count == 0) 387 | assert(doc.renderWideStream.mkString == "done") 388 | assert(count == 1) 389 | } 390 | 391 | test("defer short circuits") { 392 | var d1count = 0 393 | val d1 = Doc.defer { 394 | d1count += 1 395 | Doc.empty 396 | } 397 | 398 | var d2count = 0 399 | val d2 = Doc.defer { 400 | d2count += 1 401 | d1 402 | } 403 | 404 | d2.render(0) 405 | assert(d1count == 1) 406 | assert(d2count == 1) 407 | // rendering d1 does not increment d1 408 | d1.render(0) 409 | assert(d1count == 1) 410 | assert(d2count == 1) 411 | } 412 | } 413 | -------------------------------------------------------------------------------- /docs/src/main/mdoc/index.md: -------------------------------------------------------------------------------- 1 | # Paiges 2 | 3 | Paiges is an implementation of Philip Wadler's 4 | [*Prettier Printer*](http://homepages.inf.ed.ac.uk/wadler/papers/prettier/prettier.pdf). 5 | 6 | ### Getting started 7 | 8 | To get started, you'll want to import `Doc`. This is the basis for 9 | Paiges, and is likely the only type you'll need to work with: 10 | 11 | ```scala mdoc 12 | import org.typelevel.paiges.Doc 13 | ``` 14 | 15 | ## Creating documents 16 | 17 | Many documents are defined in terms of other documents, but to get 18 | started you'll need to create documents to represent chunks of text. 19 | Here are three good ways to do it. 20 | 21 | ```scala mdoc 22 | // unbroken text from a String 23 | val cat = Doc.text("cat") 24 | 25 | // same as text, but using .toString first 26 | val pair = Doc.str((1, 2)) 27 | 28 | // allow breaking on word-boundaries 29 | val doc = Doc.paragraph( 30 | """The basic tool for the manipulation of reality is the 31 | manipulation of words. If you can control the meaning of 32 | words, you can control the people who must use them.""") 33 | ``` 34 | 35 | We can use a single line of text to contrast these methods: 36 | 37 | ```scala mdoc:silent 38 | val howl = "I saw the best minds of my generation destroyed by madness..." 39 | ``` 40 | 41 | The first two, `Doc.text` and `Doc.str`, create a fragment of text that 42 | will always render exactly as shown. This means that if you create a 43 | very long piece of text, it won't be wrapped to fit smaller widths. 44 | 45 | ```scala mdoc 46 | Doc.text(howl).render(40) 47 | ``` 48 | 49 | By contrast, `Doc.paragraph` will add line breaks to make the document 50 | fit: 51 | 52 | ```scala mdoc 53 | Doc.paragraph(howl).render(40) 54 | ``` 55 | 56 | There are also some useful methods defined in the `Doc` companion: 57 | 58 | * `Doc.empty`: an empty document, equivalent to `Doc.text("")` 59 | * `Doc.space`: a single space, equivalent to `Doc.text(" ")` 60 | * `Doc.comma`: a single comma, equivalent to `Doc.text(",")` 61 | * `Doc.line`: a single newline, equivalent to `Doc.text("\n")`. When flattened, this becomes space. 62 | * `Doc.lineBreak`: a single newline that flattens to empty. 63 | * `Doc.spaces(n)`: *n* spaces, equivalent to `Doc.text(" " * n)` 64 | * `Doc.lineOrSpace`: a space or newline, depending upon rendering width 65 | * `Doc.lineOrEmpty`: empty or newline, depending upon rendering width 66 | 67 | ## Combining documents 68 | 69 | Paiges' ability to vary rendering based on width comes from the 70 | different combinators it provides on `Doc`. In general, you'll want to 71 | build documents to represent discrete, atomic bits of text, and then 72 | use other combinators to build up larger documents. 73 | 74 | This section gives a brief overview to some of the most useful 75 | combinators. 76 | 77 | ### Concatenation 78 | 79 | The most basic operations on documents involve concatenation: stitching 80 | documents together to create new documents. Since `Doc` is immutable, 81 | none of these methods modify documents directly. Instead, they return a 82 | new document which represents the concatenation. This immutability 83 | allows Paiges to render at different widths so efficiently. 84 | 85 | Documents are concatenation with `+`. As a convenience, Paiges also 86 | provides `+:` and `:+` to prepend or append a string. 87 | 88 | ```scala mdoc 89 | val doc1 = Doc.text("my pet is a ") + Doc.text("cat") 90 | val doc2 = Doc.text("my pet is a ") :+ "cat" 91 | val doc3 = "my pet is a " +: Doc.text("cat") 92 | 93 | val doc4 = Doc.text("my pet is a " + "cat") 94 | ``` 95 | 96 | The first three documents are equivalent. However, the last is 97 | different: it performs a string concatenation before creating a single 98 | document, which will be less efficient. When using `Doc`, it's 99 | preferable to turn literal strings into documents before concatenating 100 | them. 101 | 102 | There are also several other types of concatenation (which can all be 103 | written in terms of `+`). For example, `/` concatenates documents with 104 | a newline in between: 105 | 106 | ```scala mdoc 107 | // equivalent to Doc.text("first line") + Doc.line + Doc.text("second line") 108 | val doc5 = Doc.text("first line") / Doc.text("second line") 109 | val doc6 = Doc.text("first line") :/ "second line" 110 | val doc7 = "first line" /: Doc.text("second line") 111 | ``` 112 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.11.1 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("org.portable-scala" % "sbt-scala-native-crossproject" % "1.3.2") 2 | addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.17.0") 3 | addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.5.6") 4 | addSbtPlugin("org.scalameta" % "sbt-mdoc" % "2.6.2") 5 | addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.2") 6 | addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.3.1") 7 | addSbtPlugin("org.typelevel" % "sbt-typelevel" % "0.8.0") 8 | addSbtPlugin("org.typelevel" % "sbt-typelevel-site" % "0.8.0") 9 | addSbtPlugin("pl.project13.scala" % "sbt-jmh" % "0.4.7") 10 | --------------------------------------------------------------------------------