From 5565988269c169320be82d69f052b9ab132c6df6 Mon Sep 17 00:00:00 2001 From: Lukas Reineke Date: Thu, 7 Jul 2022 12:01:18 +0900 Subject: [PATCH] init --- .github/FUNDING.yml | 1 + .github/dependabot.yml | 6 + .github/workflows/pr_check.yml | 36 +++ .github/workflows/release.yml | 84 +++++ .gitignore | 1 + Cargo.lock | 565 +++++++++++++++++++++++++++++++++ Cargo.toml | 33 ++ LICENSE.md | 21 ++ README.md | 104 ++++++ src/config.rs | 13 + src/format.rs | 319 +++++++++++++++++++ src/main.rs | 225 +++++++++++++ src/tree.rs | 64 ++++ src/utils.rs | 151 +++++++++ 14 files changed, 1623 insertions(+) create mode 100644 .github/FUNDING.yml create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/pr_check.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 src/config.rs create mode 100644 src/format.rs create mode 100644 src/main.rs create mode 100644 src/tree.rs create mode 100644 src/utils.rs diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..0307e85 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: [lukas-reineke] diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..8f43162 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: 'cargo' + directory: '/' + schedule: + interval: 'monthly' diff --git a/.github/workflows/pr_check.yml b/.github/workflows/pr_check.yml new file mode 100644 index 0000000..a22c73d --- /dev/null +++ b/.github/workflows/pr_check.yml @@ -0,0 +1,36 @@ +name: Pull request check + +on: + pull_request: + +jobs: + block-fixup: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Block Fixup Commit Merge + uses: 13rac1/block-fixup-merge-action@v2.0.0 + build: + runs-on: ubuntu-latest + strategy: + matrix: + toolchain: + - stable + - beta + - nightly + steps: + - uses: actions/checkout@v3 + with: + submodules: recursive + - run: rustup update ${{ matrix.toolchain }} && rustup default ${{ matrix.toolchain }} + - run: cargo build --verbose + format: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + submodules: recursive + - run: rustup update stable && rustup default stable + - run: rustup component add rustfmt + - run: cargo fmt --all -- --check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7c29362 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,84 @@ +name: upload + +# copied from https://github.com/rust-lang/rustfmt/blob/master/.github/workflows/upload-assets.yml + +on: + push: + release: + types: [created] + workflow_dispatch: + +jobs: + build-release: + name: build-release + strategy: + matrix: + build: + [linux-x86_64, macos-x86_64, windows-x86_64-gnu, windows-x86_64-msvc] + include: + - build: linux-x86_64 + os: ubuntu-latest + rust: nightly + target: x86_64-unknown-linux-gnu + - build: macos-x86_64 + os: macos-latest + rust: nightly + target: x86_64-apple-darwin + - build: windows-x86_64-gnu + os: windows-latest + rust: nightly-x86_64-gnu + target: x86_64-pc-windows-gnu + - build: windows-x86_64-msvc + os: windows-latest + rust: nightly-x86_64-msvc + target: x86_64-pc-windows-msvc + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v3 + + # Run build + - name: install rustup + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs > rustup-init.sh + sh rustup-init.sh -y --default-toolchain none + rustup target add ${{ matrix.target }} + + - name: Add mingw64 to path for x86_64-gnu + run: echo "C:\msys64\mingw64\bin" >> $GITHUB_PATH + if: matrix.rust == 'nightly-x86_64-gnu' + shell: bash + + - name: Build release binaries + uses: actions-rs/cargo@v1 + with: + command: build + args: --release + + - name: Build archive + shell: bash + run: | + staging="cbfmt_${{ matrix.build }}_${{ github.event.release.tag_name }}" + mkdir -p "$staging" + + cp {README.md,LICENSE.md} "$staging/" + + if [ "${{ matrix.os }}" = "windows-latest" ]; then + cp target/release/cbfmt.exe "$staging/" + 7z a "$staging.zip" "$staging" + echo "ASSET=$staging.zip" >> $GITHUB_ENV + else + cp target/release/cbfmt "$staging/" + tar czf "$staging.tar.gz" "$staging" + echo "ASSET=$staging.tar.gz" >> $GITHUB_ENV + fi + + - name: Upload Release Asset + if: github.event_name == 'release' + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ github.event.release.upload_url }} + asset_path: ${{ env.ASSET }} + asset_name: ${{ env.ASSET }} + asset_content_type: application/octet-stream diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..43a2748 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,565 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "aho-corasick" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f" +dependencies = [ + "memchr", +] + +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi", + "libc", + "winapi", +] + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bstr" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3569f383e8f1598449f1a423e72e99569137b47740b1da11ef19af3d5c3223" +dependencies = [ + "memchr", +] + +[[package]] +name = "cbfmt" +version = "0.1.0" +dependencies = [ + "atty", + "cc", + "clap", + "futures", + "ignore", + "serde", + "termcolor", + "thiserror", + "tokio", + "toml", + "tree-sitter", + "tree-sitter-md", + "tree-sitter-org", +] + +[[package]] +name = "cc" +version = "1.0.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fff2a6927b3bb87f9595d67196a70493f627687a71d87a0d692242c33f58c11" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "clap" +version = "3.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54635806b078b7925d6e36810b1755f2a4b5b4d57560432c1ecf60bcbe10602b" +dependencies = [ + "atty", + "bitflags", + "clap_lex", + "indexmap", + "strsim", + "termcolor", + "textwrap", +] + +[[package]] +name = "clap_lex" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2850f2f5a82cbf437dd5af4d49848fbdfc27c157c3d010345776f952765261c5" +dependencies = [ + "os_str_bytes", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51887d4adc7b564537b15adcfb307936f8075dfcd5f00dde9a9f1d29383682bc" +dependencies = [ + "cfg-if", + "once_cell", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "futures" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f73fe65f54d1e12b726f517d3e2135ca3125a437b6d998caf1962961f7172d9e" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3083ce4b914124575708913bca19bfe887522d6e2e6d0952943f5eac4a74010" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c09fd04b7e4073ac7156a9539b57a484a8ea920f79c7c675d05d289ab6110d3" + +[[package]] +name = "futures-executor" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9420b90cfa29e327d0429f19be13e7ddb68fa1cccb09d65e5706b8c7a749b8a6" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc4045962a5a5e935ee2fdedaa4e08284547402885ab326734432bed5d12966b" + +[[package]] +name = "futures-macro" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33c1e13800337f4d4d7a316bf45a567dbcb6ffe087f16424852d97e97a91f512" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21163e139fa306126e6eedaf49ecdb4588f939600f0b1e770f4205ee4b7fa868" + +[[package]] +name = "futures-task" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c66a976bf5909d801bbef33416c41372779507e7a6b3a5e25e4749c58f776a" + +[[package]] +name = "futures-util" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b7abd5d659d9b90c8cba917f6ec750a74e2dc23902ef9cd4cc8c8b22e6036a" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "globset" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a1e17342619edbc21a964c2afbeb6c820c6a2560032872f397bb97ea127bd0a" +dependencies = [ + "aho-corasick", + "bstr", + "fnv", + "log", + "regex", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + +[[package]] +name = "ignore" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "713f1b139373f96a2e0ce3ac931cd01ee973c3c5dd7c40c0c2efe96ad2b6751d" +dependencies = [ + "crossbeam-utils", + "globset", + "lazy_static", + "log", + "memchr", + "regex", + "same-file", + "thread_local", + "walkdir", + "winapi-util", +] + +[[package]] +name = "indexmap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a35a97730320ffe8e2d410b5d3b69279b98d2c14bdb8b70ea89ecf7888d41e" +dependencies = [ + "autocfg", + "hashbrown", +] + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "libc" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349d5a591cd28b49e1d1037471617a32ddcda5731b99419008085f72d5a53836" + +[[package]] +name = "log" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "memchr" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" + +[[package]] +name = "num_cpus" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19e64526ebdee182341572e50e9ad03965aa510cd94427a4549448f285e957a1" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a6dbe30758c9f83eb00cbea4ac95966305f5a7772f3f42ebfc7fc7eddbd8e1" + +[[package]] +name = "os_str_bytes" +version = "6.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "648001efe5d5c0102d8cea768e348da85d90af8ba91f0bea908f157951493cd4" + +[[package]] +name = "pin-project-lite" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0a7ae3ac2f1173085d398531c705756c94a4c56843785df85a60c1a0afac116" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "proc-macro2" +version = "1.0.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc2916cde080c1876ff40292a396541241fe0072ef928cd76582e9ea5d60d2" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bcdf212e9776fbcb2d23ab029360416bb1706b1aea2d1a5ba002727cbcab804" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4eb3267174b8c6c2f654116623910a0fef09c4753f8dd83db29c48a0df988b" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.6.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3f87b73ce11b1619a3c6332f45341e0047173771e8b8b73f87bfeefb7b56244" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.140" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc855a42c7967b7c369eb5860f7164ef1f6f81c20c7cc1141f2a604e18723b03" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.140" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f2122636b9fe3b81f1cb25099fcf2d3f542cdb1d45940d56c713158884a05da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "slab" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4614a76b2a8be0058caa9dbbaf66d988527d86d003c11a94fbd335d7661edcef" +dependencies = [ + "autocfg", +] + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + +[[package]] +name = "syn" +version = "1.0.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c50aef8a904de4c23c788f104b7dddc7d6f79c647c7c8ce4cc8f73eb0ca773dd" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "termcolor" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bab24d30b911b2376f3a13cc2cd443142f0c81dda04c118693e35b3835757755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "textwrap" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1141d4d61095b28419e22cb0bbf02755f5e54e0526f97f1e3d1d160e60885fb" + +[[package]] +name = "thiserror" +version = "1.0.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd829fe32373d27f76265620b5309d0340cb8550f523c1dda251d6298069069a" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0396bc89e626244658bef819e22d0cc459e795a5ebe878e6ec336d1674a8d79a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5516c27b78311c50bf42c071425c560ac799b11c30b31f87e3081965fe5e0180" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tokio" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57aec3cfa4c296db7255446efb4928a6be304b431a806216105542a67b6ca82e" +dependencies = [ + "autocfg", + "num_cpus", + "once_cell", + "pin-project-lite", + "tokio-macros", +] + +[[package]] +name = "tokio-macros" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9724f9a975fb987ef7a3cd9be0350edcbe130698af5b8f7a631e23d42d052484" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d82e1a7758622a465f8cee077614c73484dac5b836c02ff6a40d5d1010324d7" +dependencies = [ + "serde", +] + +[[package]] +name = "tree-sitter" +version = "0.20.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "268bf3e3ca0c09e5d21b59c2638e12cb6dcf7ea2681250a696a2d0936cb57ba0" +dependencies = [ + "cc", + "regex", +] + +[[package]] +name = "tree-sitter-md" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e5ecf52a54a5438d09aeb6a91f8cfe71681e7eb813d13f9907e4576c11178d" +dependencies = [ + "cc", + "tree-sitter", +] + +[[package]] +name = "tree-sitter-org" +version = "1.0.1" +source = "git+https://github.com/milisims/tree-sitter-org#bc8a040492b56754a35b3b00a3052fdb7ba12969" +dependencies = [ + "cc", + "tree-sitter", +] + +[[package]] +name = "unicode-ident" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15c61ba63f9235225a22310255a29b806b907c9b8c964bcbd0a2c70f3f2deea7" + +[[package]] +name = "walkdir" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "808cf2735cd4b6866113f648b791c6adc5714537bc222d9347bb203386ffda56" +dependencies = [ + "same-file", + "winapi", + "winapi-util", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +dependencies = [ + "winapi", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..2ffcd65 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "cbfmt" +version = "0.1.0" +edition = "2021" +description = "A tool to format codeblocks inside markdown and org documents" +repository = "https://github.com/lukas-reineke/cbfmt" +categories = ["development-tools"] +keywords = ["format", "markdown", "org", "codeblock"] +license = "MIT" + +[[bin]] +name = "cbfmt" +doc = false + +[dependencies] +atty = "0.2.14" +clap = "3.2.8" +futures = "0.3.21" +ignore = "0.4.18" +serde = { version = "1.0.138", features = ["derive"] } +termcolor = "1.1.3" +thiserror = "1.0.31" +tokio = { version = "1.20.0", features = ["macros", "fs", "rt-multi-thread"] } +toml = "0.5.9" +tree-sitter = "~0.20" +tree-sitter-md = "0.1.1" +tree-sitter-org = "1.0.1" + +[patch.crates-io] +tree-sitter-org = { git = "https://github.com/milisims/tree-sitter-org" } + +[build-dependencies] +cc = "*" diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..7074561 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +The MIT Licence + +Copyright (c) 2022 Lukas Reineke + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a194280 --- /dev/null +++ b/README.md @@ -0,0 +1,104 @@ +# cbfmt (codeblock format) + +A tool to format codeblocks inside markdown and org documents. +It iterates over all codeblocks, and formats them with the tool(s) specified for +the language of the block. + +## Install + +### Download from GitHub + +Download the latest release binaries from [github.com/lukas-reineke/cbfmt/releases](https://github.com/lukas-reineke/cbfmt/releases) + +### Cargo [Coming Soon] + +```bash +cargo install cbfmt +``` + +### Build from source + +1. Clone this repository +2. Build with [cargo](https://github.com/rust-lang/cargo/) + +```bash +git clone https://github.com/lukas-reineke/cbfmt.git && cd cbfmt +cargo install --path . +``` + +This will install `cbfmt` in your `~/.cargo/bin`. Make sure to add `~/.cargo/bin` directory to your `PATH` variable. + +## Config + +A configuration file is required. By default the file is called +`.cbfmt.toml` + +Example: + +```toml +[languages] +rust = ["rustfmt"] +go = ["gofmt"] +lua = ["stylua -s -"] +python = ["black --fast -"] +``` + +### Sections + +#### languages + +This section specifies which commands should run for which language. +Each entry is the name of the language as the key, and a list of format commands +to run in sequence as the value. Each format command needs to read from stdin +and write to stdout. + +## Usage + +### With arguments + +You can run `cbfmt` on files and or directories by passing them as +arguments. + +```bash +cbfmt [OPTIONS] [file/dir/glob]... +``` + +The default behaviour checks formatting for all files that were passed as +arguments. If all files are formatted correctly, it exits with status code 0, +otherwise it exits with status code 1. + +When a directory is passed as an argument, `cbfmt` will recursively run on all files +in that directory which have a valid parser and are not ignored by git. + +### With stdin + +If no arguments are specified, `cbfmt` will read from stdin and write the format +result to stdout. + +```bash +cbfmt [OPTIONS] < [file] +``` + +### Without arguments and stdin + +If there are no arguments and nothing is written to stdin, `cbfmt` will print +the help text and exit. + +### Options + +These are the most important options. To see all options, please run +`cbfmt --help` + +#### check `-c|--check` + +Works the same as the default behaviour, but only prints the path to files that +fail. + +#### write `-w|--write` + +Writes the format result back into the files. + +#### parser `-p|--parser` + +Specifies which parser to use. This is inferred from the file ending when +possible. diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..1e1ed0c --- /dev/null +++ b/src/config.rs @@ -0,0 +1,13 @@ +use serde::Deserialize; +use std::collections::HashMap; + +#[derive(Debug, Deserialize)] +pub struct Conf { + pub languages: HashMap>, +} + +pub fn get(name: &str) -> Result { + let toml_string = std::fs::read_to_string(name)?; + let conf: Conf = toml::from_str(&toml_string)?; + Ok(conf) +} diff --git a/src/format.rs b/src/format.rs new file mode 100644 index 0000000..efcb0fd --- /dev/null +++ b/src/format.rs @@ -0,0 +1,319 @@ +use super::config::Conf; +use super::tree; +use super::utils; +use futures::{stream::FuturesOrdered, StreamExt}; +use std::fmt; +use std::io::{self, prelude::*, Error, ErrorKind, Write}; +use std::process::{Command, Stdio}; + +#[derive(thiserror::Error, Debug)] +pub struct FormatError { + pub msg: String, + pub filename: Option, + pub command: Option, + pub language: Option, + pub start: Option, +} + +impl fmt::Display for FormatError { + fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + if let Some(filename) = &self.filename { + write!(formatter, "{filename}")?; + } + if let Some(start) = &self.start { + write!(formatter, "{start}")?; + } + if let Some(language) = &self.language { + write!(formatter, " [{language}] ->")?; + } + if let Some(command) = &self.command { + write!(formatter, " [{command}] ")?; + } + write!(formatter, "\n{}", self.msg) + } +} + +pub enum FormatResult { + Unchanged(String), + Changed(String), + Err(FormatError), +} + +pub async fn run_file( + conf: &Conf, + filename: String, + parser: Option<&str>, + write: bool, + best_effort: bool, +) -> FormatResult { + let parser = match utils::get_parser(Some(&filename), parser) { + Ok(p) => p, + Err(e) => return FormatResult::Err(e), + }; + + let file = match tokio::fs::read(&filename).await { + Err(error) => { + return FormatResult::Err(FormatError { + msg: error.to_string(), + filename: Some(filename), + command: None, + language: None, + start: None, + }) + } + Ok(f) => f, + }; + let buf = file.lines().map(|l| l.unwrap()).collect::>(); + + match run(buf, conf, &parser, !write, best_effort).await { + FormatResult::Changed(r) => { + if write { + if let Some(error) = tokio::fs::write(&filename, r).await.err() { + return FormatResult::Err(FormatError { + msg: error.to_string(), + filename: Some(filename), + command: None, + language: None, + start: None, + }); + } + } + FormatResult::Changed(filename) + } + FormatResult::Unchanged(_) => FormatResult::Unchanged(filename), + FormatResult::Err(mut error) => { + error.filename = Some(filename); + FormatResult::Err(error) + } + } +} + +pub async fn run_stdin( + conf: &Conf, + filename: Option<&str>, + parser: Option<&str>, + best_effort: bool, +) -> FormatResult { + let parser = match utils::get_parser(filename, parser) { + Ok(p) => p, + Err(e) => return FormatResult::Err(e), + }; + + let buf = io::stdin().lines().map(|l| l.unwrap()).collect::>(); + + match run(buf, conf, &parser, false, best_effort).await { + FormatResult::Changed(r) => { + let mut stdout = io::stdout().lock(); + stdout.write_all(r.as_bytes()).unwrap(); + FormatResult::Changed("stdin".to_string()) + } + FormatResult::Unchanged(r) => { + let mut stdout = io::stdout().lock(); + stdout.write_all(r.as_bytes()).unwrap(); + FormatResult::Unchanged("stdin".to_string()) + } + FormatResult::Err(e) => FormatResult::Err(e), + } +} + +struct FormatCtx { + language: String, + codeblock_start: usize, + start: usize, + end: usize, + input_hash: u64, +} + +async fn run( + mut buf: Vec, + conf: &Conf, + parser: &str, + fail_fast: bool, + best_effort: bool, +) -> FormatResult { + let src = buf.join("\n"); + let src_bytes = src.as_bytes(); + let tree = match tree::get_tree(parser, src_bytes) { + Some(t) => t, + None => { + return FormatResult::Err(FormatError { + msg: format!("No parser found for {}.", parser), + filename: None, + command: None, + language: None, + start: None, + }) + } + }; + let query = tree::get_query(parser).unwrap(); + + let mut futures: FuturesOrdered<_> = FuturesOrdered::new(); + + let mut cursor = tree_sitter::QueryCursor::new(); + for each_match in cursor.matches(&query, tree.root_node(), src_bytes) { + let mut content = String::new(); + let mut ctx = FormatCtx { + language: String::new(), + codeblock_start: 0, + start: 0, + end: 0, + input_hash: 0, + }; + for capture in each_match.captures.iter() { + let range = capture.node.range(); + let capture_name = &query.capture_names()[capture.index as usize]; + if capture_name == "language" { + ctx.language = String::from(&src[range.start_byte..range.end_byte]); + } + if capture_name == "content" { + ctx.start = range.start_point.row; + ctx.end = range.end_point.row; + let mut end_byte = range.end_byte; + + // Workaround for bug in markdown parser when the codeblock is the last thing in a + // buffer + if parser == "markdown" && &src[(end_byte - 3)..end_byte] == "```" { + end_byte -= 3 + } + + content = String::from(&src[range.start_byte..end_byte]); + } + if capture_name == "codeblock" { + ctx.codeblock_start = range.start_point.row; + } + } + + let formatter = conf.languages.get(&ctx.language); + let formatter = match formatter { + Some(f) => f, + None => continue, + }; + let formatter = formatter.iter().map(|f| f.to_owned()).collect(); + + ctx.input_hash = utils::get_hash(&content); + futures.push(tokio::spawn(async move { + format(ctx, formatter, &content).await + })); + } + + let mut formatted = false; + let mut offset: i32 = 0; + while let Some(output) = futures.next().await { + let output = match output { + Ok(o) => o, + Err(e) => { + return FormatResult::Err(FormatError { + msg: e.to_string(), + filename: None, + command: None, + language: None, + start: None, + }); + } + }; + let (ctx, output) = match output { + Ok(o) => o, + Err(e) => { + if best_effort { + continue; + } + return FormatResult::Err(e); + } + }; + + let start_row = &buf[(ctx.codeblock_start as i32 + offset) as usize]; + let whitespace = utils::get_start_whitespace(start_row); + + let mut fixed_output = String::new(); + for line in output.lines() { + fixed_output.push_str(&whitespace); + fixed_output.push_str(line); + fixed_output.push('\n'); + } + + // trim start for the hash because treesitter ignores leading whitespace + let output_hash = utils::get_hash(fixed_output.trim_start()); + if ctx.input_hash != output_hash { + formatted = true; + if fail_fast { + break; + } + } + + buf.drain((ctx.start as i32 + offset) as usize..(ctx.end as i32 + offset) as usize); + + let mut counter = 0; + for (i, line) in fixed_output.lines().enumerate() { + buf.insert(i + (ctx.start as i32 + offset) as usize, line.to_string()); + counter += 1; + } + + offset += counter - (ctx.end as i32 - ctx.start as i32); + } + + let output = buf.join("\n") + "\n"; + if formatted { + return FormatResult::Changed(output); + } + FormatResult::Unchanged(output) +} + +async fn format( + ctx: FormatCtx, + formatter: Vec, + content: &str, +) -> Result<(FormatCtx, String), FormatError> { + let mut result = String::from(content); + let language = Some(ctx.language.to_owned()); + let start = Some(format!(":{}", ctx.start)); + + for f in formatter.iter() { + if f.is_empty() { + continue; + } + let f: Vec<_> = f.split_whitespace().collect(); + let command = f[0]; + result = match format_single(f, &result) { + Err(e) => { + return Err(FormatError { + msg: e.to_string(), + filename: None, + command: Some(command.to_string()), + language, + start, + }); + } + Ok(o) => o, + } + } + + Ok((ctx, result)) +} + +fn format_single(formatter: Vec<&str>, input: &str) -> Result { + let mut child = Command::new(formatter[0]) + .args(&formatter[1..]) + .stdin(Stdio::piped()) + .stderr(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn()?; + + let stdin = child.stdin.as_mut().ok_or_else(|| { + Error::new( + ErrorKind::Other, + String::from("Child process stdin has not been captured."), + ) + })?; + stdin.write_all(input.as_bytes())?; + + let output = child.wait_with_output()?; + + if output.status.success() { + Ok(String::from_utf8(output.stdout).unwrap()) + } else { + Err(Error::new( + ErrorKind::Other, + String::from_utf8(output.stderr).unwrap(), + )) + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..08bba46 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,225 @@ +use clap::{App, Arg, ArgMatches}; +mod config; +mod format; +use format::FormatResult; +mod tree; +mod utils; +use futures::{stream::FuturesUnordered, StreamExt}; +use std::process; +use termcolor::{ColorChoice, StandardStream}; + +#[tokio::main] +async fn main() { + let (mut color_choice, clap_color_choice) = if atty::is(atty::Stream::Stdout) { + (ColorChoice::Auto, clap::ColorChoice::Auto) + } else { + (ColorChoice::Never, clap::ColorChoice::Never) + }; + + let mut app = + App::new("cbfmt") + .version("0.1.0") + .author("Lukas Reineke ") + .about("A tool to format codeblocks inside markdown and org documents.\nIt iterates over all codeblocks, and formats them with the tool(s) specified for the language of the block.") + .arg( + Arg::with_name("config") + .long("config") + .value_name("FILE") + .help("Sets a custom config file.") + .takes_value(true), + ) + .arg( + Arg::with_name("check") + .short('c') + .long("check") + .takes_value(false) + .help("Check if the given files are formatted. Print the path to unformatted files and exit with exit code 1 if they are not.") + ) + .arg( + Arg::with_name("fail_fast") + .long("fail-fast") + .takes_value(false) + .help("Exit as soon as one file is not formatted correctly.") + ) + .arg( + Arg::with_name("write") + .short('w') + .long("write") + .takes_value(false) + .help("Edit files in-place.") + ) + .arg( + Arg::with_name("best_effort") + .long("best-effort") + .takes_value(false) + .help("Ignore formatting errors and continue with the next codeblock.") + ) + .arg( + Arg::with_name("parser") + .short('p') + .long("parser") + .value_name("markdown|org") + .help("Sets the parser to use. Required for Stdin.") + .takes_value(true), + ) + .arg( + Arg::with_name("stdin_filepath") + .long("stdin-filepath") + .help("Path to the file to pretend that stdin comes from.") + .takes_value(true), + ) + .arg( + Arg::with_name("color") + .long("color") + .value_name("never|auto|always") + .help("Use colored output.") + .default_value("auto") + .takes_value(true), + ) + .arg( + Arg::with_name("files") + .value_name("file/dir/glob") + .help("List of files to process. If no files are given cbfmt will read from Stdin.") + .index(1) + .multiple_values(true), + ) + .color(clap_color_choice); + + let matches = app.to_owned().get_matches(); + + if let Some(color) = matches.value_of("color") { + if color == "never" { + color_choice = ColorChoice::Never; + } else if color == "always" { + color_choice = ColorChoice::Always; + } + } + + if matches.values_of("files").is_none() && atty::is(atty::Stream::Stdin) { + app.print_help().unwrap(); + return; + } + + let mut stderr = StandardStream::stderr(color_choice); + + let config_path = match matches.value_of("config") { + Some(p) => p.to_owned(), + None => match utils::find_closest_config() { + Some(p) => p, + None => { + utils::print_error(&mut stderr, "Could not find config file."); + process::exit(1); + } + }, + }; + let conf = match config::get(&config_path) { + Ok(c) => c, + Err(_) => { + utils::print_error(&mut stderr, "Could not parse config file."); + process::exit(1); + } + }; + + match matches.values_of("files") { + Some(_) => use_files(matches, &conf, color_choice).await, + None => use_stdin(matches, &conf).await, + } +} + +async fn use_files(matches: ArgMatches, conf: &config::Conf, color_choice: ColorChoice) { + let mut stdout = StandardStream::stdout(color_choice); + let mut stderr = StandardStream::stderr(color_choice); + + let check = matches.is_present("check"); + let write = matches.is_present("write"); + let best_effort = matches.is_present("best_effort"); + let fail_fast = matches.is_present("fail_fast"); + let files = matches.values_of("files").unwrap(); + let parser = matches.value_of("parser"); + + let mut futures: FuturesUnordered<_> = FuturesUnordered::new(); + let files = match utils::get_files(files) { + Ok(f) => f, + Err(e) => { + utils::print_error(&mut stderr, &e.to_string()); + process::exit(1); + } + }; + for filename in files { + futures.push(format::run_file(conf, filename, parser, write, best_effort)); + } + + let mut error_count = 0; + let mut unchanged_count = 0; + let mut changed_count = 0; + + while let Some(result) = futures.next().await { + match result { + FormatResult::Unchanged(f) => { + unchanged_count += 1; + if check { + continue; + } + if write { + utils::print_unchanged(&mut stdout, &f); + } else { + utils::print_ok(&mut stdout, &f); + } + } + FormatResult::Changed(f) => { + changed_count += 1; + if check { + eprintln!("{f}") + } else if write { + utils::print_ok(&mut stdout, &f); + } else { + utils::print_fail(&mut stderr, &f); + } + if !write && fail_fast { + println!("Failed fast..."); + break; + } + } + FormatResult::Err(e) => { + error_count += 1; + if check { + let filename = match &e.filename { + Some(f) => f, + None => "Unknown", + }; + eprintln!("{filename}"); + } else { + utils::print_error(&mut stderr, &e.to_string()); + } + if fail_fast { + println!("Failed fast..."); + break; + } + } + } + } + + let total_count = unchanged_count + changed_count + error_count; + if write { + println!("\n[{changed_count}/{total_count}] files were written."); + } + + if !write && !check { + println!("\n[{unchanged_count}/{total_count}] files are formatted correctly."); + } + + if error_count > 0 || (changed_count > 0 && !write) { + process::exit(1); + } +} + +async fn use_stdin(matches: ArgMatches, conf: &config::Conf) { + let parser = matches.value_of("parser"); + let filename = matches.value_of("stdin_filepath"); + let best_effort = matches.is_present("best_effort"); + + if let FormatResult::Err(e) = format::run_stdin(conf, filename, parser, best_effort).await { + eprintln!("{e}"); + process::exit(1); + } +} diff --git a/src/tree.rs b/src/tree.rs new file mode 100644 index 0000000..281c39d --- /dev/null +++ b/src/tree.rs @@ -0,0 +1,64 @@ +use tree_sitter::Parser; + +pub fn get_tree(parser_lang: &str, text: &[u8]) -> Option { + let mut parser = Parser::new(); + + match parser_lang { + "markdown" => { + parser + .set_language(tree_sitter_md::language()) + .expect("Could not load markdown grammar"); + } + "org" => { + parser + .set_language(tree_sitter_org::language()) + .expect("Could not load org grammar"); + } + _ => { + return None; + } + } + + Some(parser.parse(text, None).expect("Could not parse input")) +} + +pub fn get_query(parser_lang: &str) -> Option { + match parser_lang { + "markdown" => Some( + tree_sitter::Query::new( + tree_sitter_md::language(), + r#" + (fenced_code_block + (info_string (language) @language) + (code_fence_content) @content) @codeblock + "#, + ) + .expect("Could not load markdown query"), + ), + "org" => Some( + tree_sitter::Query::new( + tree_sitter_org::language(), + r#" + (block + name: (expr) @_name + (#match? @_name "(SRC|src)") + parameter: (expr) @language + contents: (contents) @content) @codeblock + "#, + ) + .expect("Could not load org query"), + ), + _ => None, + } +} + +pub fn get_parser_lang_from_filename(filename: &str) -> Option<&str> { + let filename = filename.to_lowercase(); + if filename.ends_with(".md") { + return Some("markdown"); + } + if filename.ends_with(".org") { + return Some("org"); + } + None +} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..c99cec5 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,151 @@ +use super::format::FormatError; +use super::tree; +use clap::Values; +use ignore::WalkBuilder; +use std::collections::hash_map::DefaultHasher; +use std::env; +use std::fs; +use std::hash::Hash; +use std::hash::Hasher; +use std::io; +use termcolor::{Color, ColorSpec, StandardStream, WriteColor}; + +pub fn get_start_whitespace(text: &str) -> String { + let mut result = String::new(); + + for ch in text.chars() { + if ch.is_whitespace() { + result.push(ch) + } else { + break; + } + } + + result +} + +pub fn get_hash(text: &str) -> u64 { + let mut hasher = DefaultHasher::new(); + text.hash(&mut hasher); + hasher.finish() +} + +pub fn get_files(files: Values) -> Result, io::Error> { + let mut result = Vec::new(); + + for file in files { + let meta = match fs::metadata(file) { + Ok(m) => m, + Err(e) => { + return Err(io::Error::new( + e.kind(), + format!("{file}: {}", &e.to_string()), + )) + } + }; + if meta.is_file() { + result.push(file.to_string()); + } else { + for entry in WalkBuilder::new(file) + .hidden(false) + .build() + .filter_map(|e| e.ok()) + { + let path = entry.path().display().to_string(); + let meta = fs::metadata(entry.path()).unwrap(); + if meta.is_file() && tree::get_parser_lang_from_filename(&path).is_some() { + result.push(path); + } + } + } + } + result.sort(); + result.dedup(); + + Ok(result) +} + +pub fn get_parser(filename: Option<&str>, parser: Option<&str>) -> Result { + if let Some(p) = parser { + return Ok(p.to_owned()); + } + if let Some(f) = filename { + if let Some(p) = tree::get_parser_lang_from_filename(f) { + return Ok(p.to_owned()); + } + } + Err(FormatError { + msg: "Could not infer parser.".to_string(), + filename: filename.map(|f| f.to_owned()), + command: None, + language: None, + start: None, + }) +} + +pub fn find_closest_config() -> Option { + let name = ".cbfmt.toml"; + let mut current_dir = match env::current_dir() { + Ok(c) => c, + Err(_) => return None, + }; + loop { + let path = current_dir.join(name); + + if path.exists() { + return Some(path.to_str()?.to_string()); + } + match current_dir.parent() { + Some(p) => current_dir = p.to_path_buf(), + None => return None, + } + } +} + +pub fn print_ok(stdout: &mut StandardStream, text: &str) { + let mut color_spec = ColorSpec::new(); + print!("["); + stdout + .set_color(color_spec.set_fg(Some(Color::Green)).set_bold(true)) + .unwrap(); + print!("Okay"); + color_spec.clear(); + stdout.set_color(&color_spec).unwrap(); + println!("]: {text}"); +} + +pub fn print_unchanged(stdout: &mut StandardStream, text: &str) { + let mut color_spec = ColorSpec::new(); + print!("["); + stdout + .set_color(color_spec.set_fg(Some(Color::Blue)).set_bold(true)) + .unwrap(); + print!("Same"); + color_spec.clear(); + stdout.set_color(&color_spec).unwrap(); + println!("]: {text}"); +} + +pub fn print_fail(stderr: &mut StandardStream, text: &str) { + let mut color_spec = ColorSpec::new(); + eprint!("["); + stderr + .set_color(color_spec.set_fg(Some(Color::Yellow)).set_bold(true)) + .unwrap(); + eprint!("Fail"); + color_spec.clear(); + stderr.set_color(&color_spec).unwrap(); + eprintln!("]: {text}"); +} + +pub fn print_error(stderr: &mut StandardStream, text: &str) { + let mut color_spec = ColorSpec::new(); + eprint!("["); + stderr + .set_color(color_spec.set_fg(Some(Color::Red)).set_bold(true)) + .unwrap(); + eprint!("Error"); + color_spec.clear(); + stderr.set_color(&color_spec).unwrap(); + eprintln!("]: {text}"); +}