├── .config ├── insta.yaml └── nextest.toml ├── .github ├── FUNDING.yml ├── build-setup.yml ├── images │ ├── favicon.ico │ ├── yolk_banner.svg │ ├── yolk_banner_animated.svg │ ├── yolk_banner_animated_nobg.svg │ ├── yolk_logo.png │ └── yolk_logo.svg ├── oranda_theme.css └── workflows │ ├── build-man.yml │ ├── check.yml │ ├── release-plz.yml │ ├── release.yml │ └── web.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE.md ├── README.md ├── dist-workspace.toml ├── docs ├── .gitignore ├── book.toml ├── src │ ├── SUMMARY.md │ ├── conditional_templates.md │ ├── custom_template_functions.md │ ├── eggs.md │ ├── getting_started.md │ ├── git_concepts.md │ ├── rhai_docs │ │ ├── global.md │ │ ├── index.md │ │ ├── io.md │ │ ├── template.md │ │ └── utils.md │ ├── templates.md │ ├── yolk_on_windows.md │ └── yolk_rhai.md └── theme │ ├── css │ ├── chrome.css │ ├── general.css │ ├── print.css │ └── variables.css │ ├── favicon.png │ ├── favicon.svg │ ├── highlight.css │ └── tabs.js ├── fuzz ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── dictionary └── fuzz_targets │ ├── comment_style.rs │ ├── parser.rs │ └── render.rs ├── oranda.json ├── release-plz.toml └── src ├── deploy.rs ├── doc_generator.rs ├── eggs_config.rs ├── git_utils.rs ├── lib.rs ├── main.rs ├── multi_error.rs ├── script ├── eval_ctx.rs ├── mod.rs ├── rhai_error.rs ├── stdlib.rs └── sysinfo.rs ├── snapshots ├── yolk__yolk__test__deployment_error.snap └── yolk__yolk__test__syntax_error_in_yolk_rhai.snap ├── templating ├── comment_style.rs ├── document.rs ├── element.rs ├── error.rs ├── mod.rs ├── parser.rs ├── snapshots │ ├── yolk__templating__parser__p_text_segment-2.snap │ ├── yolk__templating__parser__p_text_segment-3.snap │ ├── yolk__templating__parser__p_text_segment.snap │ ├── yolk__templating__parser__test__blank_lines_get_combined.snap │ ├── yolk__templating__parser__test__blanklines_around_tag-2.snap │ ├── yolk__templating__parser__test__blanklines_around_tag.snap │ ├── yolk__templating__parser__test__conditional.snap │ ├── yolk__templating__parser__test__error_empty_tag.snap │ ├── yolk__templating__parser__test__error_if_without_expression.snap │ ├── yolk__templating__parser__test__error_incomplete_multiline.snap │ ├── yolk__templating__parser__test__error_incomplete_multiline_long.snap │ ├── yolk__templating__parser__test__error_incomplete_nextline.snap │ ├── yolk__templating__parser__test__error_multiline_with_else.snap │ ├── yolk__templating__parser__test__error_newline_in_tag.snap │ ├── yolk__templating__parser__test__incomplete_next_line.snap │ ├── yolk__templating__parser__test__inline_conditional_tag-2.snap │ ├── yolk__templating__parser__test__inline_conditional_tag.snap │ ├── yolk__templating__parser__test__inline_tag.snap │ ├── yolk__templating__parser__test__multiline_block.snap │ ├── yolk__templating__parser__test__nested_conditional.snap │ ├── yolk__templating__parser__test__newline_in_inline.snap │ ├── yolk__templating__parser__test__newlines_in_multiline.snap │ ├── yolk__templating__parser__test__nextline_tag.snap │ ├── yolk__templating__parser__test__nextline_tag_document.snap │ ├── yolk__templating__parser__test__parse_end.snap │ ├── yolk__templating__parser__test__regular_lines_get_combined.snap │ ├── yolk__templating__test__render_inline.snap │ ├── yolk__templating__test__test_render_inline.snap │ ├── yolk__templating__test__test_render_next_line.snap │ ├── yolk_dots__templating__parser__p_text_segment-2.snap │ ├── yolk_dots__templating__parser__p_text_segment-3.snap │ ├── yolk_dots__templating__parser__p_text_segment.snap │ ├── yolk_dots__templating__parser__test__conditional.snap │ ├── yolk_dots__templating__parser__test__error_if_without_expression.snap │ ├── yolk_dots__templating__parser__test__error_incomplete_multiline.snap │ ├── yolk_dots__templating__parser__test__error_incomplete_nextline.snap │ ├── yolk_dots__templating__parser__test__error_multiline_with_else.snap │ ├── yolk_dots__templating__parser__test__inline_tag.snap │ ├── yolk_dots__templating__parser__test__multiline_block.snap │ ├── yolk_dots__templating__parser__test__nested_conditional.snap │ ├── yolk_dots__templating__parser__test__nextline_tag.snap │ ├── yolk_dots__templating__parser__test__nextline_tag_document.snap │ └── yolk_dots__templating__parser__test__parse_end.snap └── test.rs ├── tests ├── git_tests.rs ├── mod.rs ├── snapshots │ ├── yolk__tests__yolk_tests__deployment_error.snap │ └── yolk__tests__yolk_tests__syntax_error_in_yolk_rhai.snap └── yolk_tests.rs ├── util.rs ├── yolk.rs └── yolk_paths.rs /.config/insta.yaml: -------------------------------------------------------------------------------- 1 | test: 2 | runner: "nextest" 3 | -------------------------------------------------------------------------------- /.config/nextest.toml: -------------------------------------------------------------------------------- 1 | [profile.ci] 2 | # Do not cancel the test run on the first failure. 3 | fail-fast = false 4 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [elkowar] 2 | ko_fi: elkowar 3 | -------------------------------------------------------------------------------- /.github/build-setup.yml: -------------------------------------------------------------------------------- 1 | # - name: Install cargo-binstall 2 | # uses: cargo-bins/cargo-binstall@v1.10.16 3 | # - name: Install mdbook, mdbook-man 4 | # run: cargo binstall mdbook mdbook-man 5 | # - name: Run mdbook build 6 | # run: | 7 | # echo '[output.man]' > docs/book.toml 8 | # echo 'output-dir = "book-man"' >> docs/book.toml 9 | # echo 'filename = "book.man"' >> docs/book.toml 10 | # mdbook build docs 11 | # mv docs/book/book-man/book.man yolk.man 12 | -------------------------------------------------------------------------------- /.github/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elkowar/yolk/4a73edd962cdff0e88191d868ac6b6b96ba0ae54/.github/images/favicon.ico -------------------------------------------------------------------------------- /.github/images/yolk_banner.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /.github/images/yolk_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elkowar/yolk/4a73edd962cdff0e88191d868ac6b6b96ba0ae54/.github/images/yolk_logo.png -------------------------------------------------------------------------------- /.github/images/yolk_logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /.github/oranda_theme.css: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/css2?family=Comfortaa:wght@400;700&display=swap"); 2 | @import url("https://fonts.googleapis.com/css2?family=Quicksand:wght@300..700&display=swap"); 3 | 4 | @layer overrides { 5 | .repo_banner, 6 | footer { 7 | background: var(--highlight-color) !important; 8 | } 9 | } 10 | :root, 11 | * { 12 | --highlight-color: #f7d654 !important; 13 | --link-color: #fd8041 !important; 14 | --title-fg: #faaa4a !important; 15 | --font-face: "Comfortaa", sans-serif; 16 | --main-font: Comfortaa, Comfortaa override, ui-sans-serif, system-ui, 17 | -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, 18 | Arial, Noto Sans, sans-serif, Apple Color Emoji, Segoe UI Emoji, 19 | Segoe UI Symbol, Noto Color Emoji !important; 20 | } 21 | 22 | h1, 23 | h2, 24 | h3, 25 | h4, 26 | h5 { 27 | font-family: "Quicksand", sans-serif; 28 | } 29 | .artifacts { 30 | background-color: unset !important; 31 | } 32 | .logo { 33 | max-width: 30rem !important; 34 | } 35 | h1.title { 36 | display: none; 37 | } 38 | 39 | #menu-bar { 40 | background-color: red !important; 41 | } 42 | -------------------------------------------------------------------------------- /.github/workflows/build-man.yml: -------------------------------------------------------------------------------- 1 | name: Build man page 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | plan: 7 | required: true 8 | type: string 9 | 10 | jobs: 11 | build-manpage: 12 | runs-on: ubuntu-latest 13 | env: 14 | PLAN: ${{ inputs.plan }} 15 | steps: 16 | - uses: actions/checkout@v4 17 | with: 18 | submodules: recursive 19 | - name: Install cargo-binstall 20 | uses: cargo-bins/cargo-binstall@v1.10.16 21 | - name: Install mdbook, mdbook-man 22 | run: cargo binstall mdbook mdbook-man 23 | - name: Run mdbook build 24 | run: | 25 | echo '[output.man]' > docs/book.toml 26 | echo 'output-dir = "book-man"' >> docs/book.toml 27 | echo 'filename = "book.man"' >> docs/book.toml 28 | mdbook build docs 29 | mv docs/book/book-man/book.man yolk.man 30 | - name: "Upload artifacts" 31 | uses: actions/upload-artifact@v4 32 | with: 33 | name: artifacts-build-manpage 34 | path: | 35 | yolk.man 36 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: Check 2 | 3 | on: 4 | push: 5 | tags-ignore: 6 | - "v*.*.*" 7 | branches: 8 | - "*" 9 | pull_request: 10 | branches: 11 | - "*" 12 | tags-ignore: 13 | - "v*.*.*" 14 | workflow_call: {} 15 | env: 16 | CARGO_TERM_COLOR: always 17 | 18 | jobs: 19 | mdbook-check: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v4 23 | - name: Install cargo-binstall 24 | uses: cargo-bins/cargo-binstall@v1.10.16 25 | - name: Install mdbook, mdbook-linkcheck 26 | run: cargo binstall mdbook mdbook-linkcheck 27 | - name: mdbook linkcheck 28 | run: | 29 | echo '[output.linkcheck]' > docs/book.toml 30 | mdbook build docs 31 | 32 | build: 33 | runs-on: ubuntu-latest 34 | steps: 35 | - uses: actions/checkout@v4 36 | - name: Setup rust 37 | uses: dtolnay/rust-toolchain@stable 38 | with: 39 | components: clippy,rustfmt 40 | - uses: rui314/setup-mold@v1 41 | name: Setup mold 42 | - name: Install nextest 43 | uses: taiki-e/install-action@nextest 44 | - name: Load rust cache 45 | uses: Swatinem/rust-cache@v2 46 | 47 | - name: Setup problem matchers 48 | uses: r7kamura/rust-problem-matchers@v1 49 | 50 | - name: Check formatting 51 | run: cargo fmt -- --check 52 | - name: Cargo check 53 | run: cargo check 54 | - name: Run tests 55 | run: | 56 | cargo build 57 | cargo nextest run 58 | -------------------------------------------------------------------------------- /.github/workflows/release-plz.yml: -------------------------------------------------------------------------------- 1 | name: Release-plz 2 | 3 | permissions: 4 | pull-requests: write 5 | contents: write 6 | 7 | on: 8 | push: 9 | branches: 10 | - main 11 | 12 | jobs: 13 | release-plz-release: 14 | name: Release-plz release 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout repository 18 | uses: actions/checkout@v4 19 | with: 20 | fetch-depth: 0 21 | token: ${{ secrets.RELEASE_PLZ_TOKEN }} 22 | - name: Install Rust toolchain 23 | uses: dtolnay/rust-toolchain@stable 24 | - name: Run release-plz 25 | uses: release-plz/action@v0.5 26 | with: 27 | command: release 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.RELEASE_PLZ_TOKEN }} 30 | CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} 31 | 32 | release-plz-pr: 33 | name: Release-plz PR 34 | runs-on: ubuntu-latest 35 | concurrency: 36 | group: release-plz-${{ github.ref }} 37 | cancel-in-progress: false 38 | steps: 39 | - name: Checkout repository 40 | uses: actions/checkout@v4 41 | with: 42 | fetch-depth: 0 43 | token: ${{ secrets.RELEASE_PLZ_TOKEN }} 44 | - name: Install Rust toolchain 45 | uses: dtolnay/rust-toolchain@stable 46 | - name: Run release-plz 47 | uses: release-plz/action@v0.5 48 | with: 49 | command: release-pr 50 | env: 51 | GITHUB_TOKEN: ${{ secrets.RELEASE_PLZ_TOKEN }} 52 | CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} 53 | -------------------------------------------------------------------------------- /.github/workflows/web.yml: -------------------------------------------------------------------------------- 1 | # Workflow to build your docs with oranda (and mdbook) 2 | # and deploy them to Github Pages 3 | name: Web 4 | 5 | # We're going to push to the gh-pages branch, so we need that permission 6 | permissions: 7 | contents: write 8 | 9 | # What situations do we want to build docs in? 10 | # All of these work independently and can be removed / commented out 11 | # if you don't want oranda/mdbook running in that situation 12 | on: 13 | # Check that a PR didn't break docs! 14 | # 15 | # Note that the "Deploy to Github Pages" step won't run in this mode, 16 | # so this won't have any side-effects. But it will tell you if a PR 17 | # completely broke oranda/mdbook. Sadly we don't provide previews (yet)! 18 | pull_request: 19 | 20 | # Whenever something gets pushed to main, update the docs! 21 | # This is great for getting docs changes live without cutting a full release. 22 | # 23 | # Note that if you're using cargo-dist, this will "race" the Release workflow 24 | # that actually builds the Github Release that oranda tries to read (and 25 | # this will almost certainly complete first). As a result you will publish 26 | # docs for the latest commit but the oranda landing page won't know about 27 | # the latest release. The workflow_run trigger below will properly wait for 28 | # cargo-dist, and so this half-published state will only last for ~10 minutes. 29 | # 30 | # If you only want docs to update with releases, disable this, or change it to 31 | # a "release" branch. You can, of course, also manually trigger a workflow run 32 | # when you want the docs to update. 33 | push: 34 | branches: 35 | - main 36 | 37 | # Whenever a workflow called "Release" completes, update the docs! 38 | # 39 | # If you're using cargo-dist, this is recommended, as it will ensure that 40 | # oranda always sees the latest release right when it's available. Note 41 | # however that Github's UI is wonky when you use workflow_run, and won't 42 | # show this workflow as part of any commit. You have to go to the "actions" 43 | # tab for your repo to see this one running (the gh-pages deploy will also 44 | # only show up there). 45 | workflow_run: 46 | workflows: [ "Release" ] 47 | types: 48 | - completed 49 | 50 | # Alright, let's do it! 51 | jobs: 52 | web: 53 | name: Build and deploy site and docs 54 | runs-on: ubuntu-latest 55 | steps: 56 | # Setup 57 | - uses: actions/checkout@v3 58 | with: 59 | fetch-depth: 0 60 | - uses: dtolnay/rust-toolchain@stable 61 | - uses: swatinem/rust-cache@v2 62 | 63 | # If you use any mdbook plugins, here's the place to install them! 64 | 65 | # Install and run oranda (and mdbook)! 66 | # 67 | # This will write all output to ./public/ (including copying mdbook's output to there). 68 | - name: Install and run oranda 69 | run: | 70 | curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/oranda/releases/download/v0.6.1/oranda-installer.sh | sh 71 | oranda build 72 | 73 | # Deploy to our gh-pages branch (creating it if it doesn't exist). 74 | # The "public" dir that oranda made above will become the root dir 75 | # of this branch. 76 | # 77 | # Note that once the gh-pages branch exists, you must 78 | # go into repo's settings > pages and set "deploy from branch: gh-pages". 79 | # The other defaults work fine. 80 | - name: Deploy to Github Pages 81 | uses: JamesIves/github-pages-deploy-action@v4.4.1 82 | # ONLY if we're on main (so no PRs or feature branches allowed!) 83 | if: ${{ github.ref == 'refs/heads/main' }} 84 | with: 85 | branch: gh-pages 86 | # Gotta tell the action where to find oranda's output 87 | folder: public 88 | token: ${{ secrets.GITHUB_TOKEN }} 89 | single-commit: true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | 3 | /tmp 4 | /test_home 5 | 6 | # Generated by `oranda generate ci` 7 | public/ 8 | # Generated by `oranda generate ci` 9 | public/ -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [0.3.4](https://github.com/elkowar/yolk/compare/v0.3.3...v0.3.4) - 2025-05-10 11 | 12 | ### Fixed 13 | 14 | - paths are now just stored as strings (fixes #55) 15 | 16 | ## [0.3.3](https://github.com/elkowar/yolk/compare/v0.3.2...v0.3.3) - 2025-04-25 17 | 18 | ### Added 19 | 20 | - only evaluate the active block of a conditional 21 | 22 | ### Fixed 23 | 24 | - Remove broken git tests 25 | 26 | ### Added 27 | 28 | - Only evaluate true branch of conditional blocks 29 | 30 | ## [0.3.2](https://github.com/elkowar/yolk/compare/v0.3.1...v0.3.2) - 2025-02-12 31 | 32 | ### Added 33 | 34 | - Add unsafe_shell_hooks to egg configuration 35 | - Allow for explicit priviledge escalation in root files 36 | 37 | ## [0.3.1](https://github.com/elkowar/yolk/compare/v0.2.3...v0.3.1) - 2025-02-11 38 | 39 | ### BREAKING 40 | - Move back to `yolk git` git wrapper based solution, because git filters sadly don't quite work out for all of our needs. See #42, for example. 41 | To ensure your repository is compatible with the latest version of yolk, simply rerun `yolk init` once. 42 | 43 | ## [0.2.3](https://github.com/elkowar/yolk/compare/v0.2.2...v0.2.3) - 2025-02-06 44 | 45 | ### Added 46 | 47 | - Open egg specific dir and open single files in yolk edit 48 | 49 | ### Fixed 50 | - properly handle badly behaved git-filter-client implementations. 51 | 52 | ## [0.2.2](https://github.com/elkowar/yolk/compare/v0.2.1...v0.2.2) - 2025-02-04 53 | 54 | ### Fixed 55 | 56 | - handle non-unicode files in git filter properly 57 | 58 | ## [0.2.1](https://github.com/elkowar/yolk/compare/v0.2.0...v0.2.1) - 2025-02-02 59 | 60 | ### Added 61 | 62 | - Allow empty targets map in egg config 63 | - Add further validation of yolk.rhai 64 | 65 | ### Fixed 66 | 67 | - Fix path handling on windows when interacting with git 68 | - Allow accessing variables and imports in template tags 69 | 70 | ## [0.2.0](https://github.com/elkowar/yolk/compare/v0.1.0...v0.2.0) - 2025-01-26 71 | 72 | ### BREAKING 73 | 74 | - [**breaking**] run canonicalization for git through git filters: 75 | Yolk no longer expects a `.yolk_git` directory rather 76 | than the typical `.git` dir. Instead, yolk now installs a git filter in 77 | the `.git/config` file, and specifies it to run through 78 | `.gitattributes`. To automatically apply these changes to your local 79 | dotfile repository, simply run `yolk init` again, it will automatically 80 | update the file structure. 81 | 82 | ## [0.1.0](https://github.com/elkowar/yolk/compare/v0.0.16...v0.1.0) - 2025-01-06 83 | 84 | ### Added 85 | 86 | - Clean up stale symlinks by caching deployment targets 87 | - Allow for both .config and standard ~/Library/... dir on mac 88 | 89 | ### Fixed 90 | 91 | - Fix windows symlink deletion again 92 | - simplify multi error output 93 | - inconsistent tests, failing symlink deletion on windows 94 | - compile error on windows 95 | 96 | ## [0.0.16](https://github.com/elkowar/yolk/compare/v0.0.15...v0.0.16) - 2024-12-22 97 | 98 | ### Fixed 99 | 100 | - yolk git --force-canonical flag being bad 101 | 102 | ## [0.0.15](https://github.com/elkowar/yolk/compare/v0.0.14...v0.0.15) - 2024-12-22 103 | 104 | ### Added 105 | 106 | - Sync to canonical mode on git pull as well 107 | 108 | ## [0.0.14](https://github.com/elkowar/yolk/compare/v0.0.13...v0.0.14) - 2024-12-22 109 | 110 | ### Added 111 | 112 | - Add support for importing files in yolk.rhai 113 | - support multiline tags 114 | - Add a few more comment symbols 115 | 116 | ### Fixed 117 | 118 | - Yolk not removing dead symlinks when deploying in put mode 119 | - Prevent yolk from comitting inconsistent state when syncing fails 120 | 121 | ## [0.0.13](https://github.com/elkowar/yolk/compare/v0.0.12...v0.0.13) - 2024-12-18 122 | 123 | ### Added 124 | 125 | - [**breaking**] Add explicit deployment strategies, default to put 126 | - add main_file config for smarter yolk edit command 127 | - Add more flexible loglevel configuration 128 | 129 | ### Fixed 130 | 131 | - Yolk not removing dead symlinks when deploying eggs 132 | 133 | ## [0.0.12](https://github.com/elkowar/yolk/compare/v0.0.11...v0.0.12) - 2024-12-16 134 | 135 | ### Added 136 | 137 | - [**breaking**] Add --no-sync to yolk watch 138 | - don't canonicalize templates when running yolk git push 139 | - support globs in templates-declaration 140 | - [**breaking**] Rename yolk.lua to yolk.luau 141 | 142 | ### Other 143 | 144 | - Add link to docs to readme 145 | - Update cargo dist, fix clippy warnings 146 | - Update dependencies 147 | - Fix autodocs being local path dependency 148 | - Add test for default rhai file 149 | - Fix is_deployed() not working 150 | - Add TODO comment 151 | - Cleanup 152 | - Load yolk.rhai as module 153 | - Generate documentation for rhai API via rhai-autodocs 154 | - Add `yolk docs` command to generate documentation 155 | - Fix clippy warnings 156 | - Fix systeminfo getters 157 | - Fix watch not properly reading templates 158 | - Move back to rhai 159 | - move build-setup.yaml out of workflows dir 160 | - Various cleanups 161 | - Start work on declarative egg deployment config 162 | - Move back to global-artifacts-jobs for man 163 | 164 | ## [0.0.11](https://github.com/elkowar/yolk/compare/v0.0.10...v0.0.11) - 2024-12-13 165 | 166 | ### Added 167 | 168 | - Implement `yolk watch` command 169 | - add a few hex color utility functions 170 | 171 | ### Fixed 172 | 173 | - Improve parser error message for missing end tag 174 | 175 | ### Other 176 | 177 | - Try harder to make @druskus20 happy 178 | - Slightly clean up parser code 179 | - Improve error message for empty tag 180 | - Update cargo dist to 0.26, try to use include and build-setup for man ([#9](https://github.com/elkowar/yolk/pull/9)) 181 | - Use different font for docs headings to make @druskus20 happy 182 | - he animated now 183 | - Try to fix theme 184 | - Setup matching mdbook theme 185 | - *(release)* build man page as part of release process 186 | 187 | ## [0.0.10](https://github.com/elkowar/yolk/compare/v0.0.9...v0.0.10) - 2024-12-09 188 | 189 | ### Added 190 | 191 | - add yolk edit command 192 | - Add to_json and from_json lua functions 193 | - ensure template expressions are sandboxed 194 | - add contains_key, contains_value, regex_captures functions 195 | 196 | ### Fixed 197 | 198 | - join lines in parser where possible 199 | 200 | ### Other 201 | 202 | - fix clippy warnings 203 | - ensure that yolk_templates can deal with missing files 204 | - Document inspect.lua library 205 | - Add new utility functions to docs 206 | - document yolk safeguard and how to clone 207 | - simplify parser slightly 208 | - enable tagging in release-plz 209 | - Update references to replace function 210 | 211 | ## [0.0.9](https://github.com/elkowar/yolk/compare/v0.0.8...v0.0.9) - 2024-12-09 212 | 213 | ### Added 214 | 215 | - [**breaking**] rename replace to replace_re (r -> rr) 216 | - add replace_quoted, replace_value functions 217 | - add replace_number tag function 218 | 219 | ### Fixed 220 | 221 | - rename mktmpl to make-template 222 | - show proper errors for yolk eval 223 | - show source in errors in yolk.rhai 224 | - parser not preserving newline after conditional end tag 225 | 226 | ### Other 227 | 228 | - add more tests to lua functions 229 | - disable dependency updates in release-plz config 230 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "yolk_dots" 3 | authors = ["ElKowar "] 4 | description = "Templated dotfile management without template files" 5 | version = "0.3.4" 6 | edition = "2021" 7 | repository = "https://github.com/elkowar/yolk" 8 | homepage = "https://elkowar.github.io/yolk" 9 | license = "MIT OR Apache-2.0" 10 | documentation = "https://elkowar.github.io/yolk/book" 11 | categories = ["config", "command-line-utilities", "template-engine"] 12 | 13 | [[bin]] 14 | name = "yolk" 15 | path = "src/main.rs" 16 | 17 | [lib] 18 | name = "yolk" 19 | path = "src/lib.rs" 20 | 21 | [dependencies] 22 | cached = { version = "0.54.0", default-features = false } 23 | clap = { version = "4.5.28", features = ["derive", "env"] } 24 | dirs = "6.0.0" 25 | dunce = "1.0.5" 26 | edit = "0.1.5" 27 | extend = "1.2.0" 28 | fs-err = "3.1.0" 29 | glob = "0.3.2" 30 | indoc = "2.0.5" 31 | maplit = "1.0.2" 32 | miette = { version = "7.5.0", features = ["fancy"] } 33 | normalize-path = "0.2.1" 34 | notify = "8.0.0" 35 | notify-debouncer-full = "0.5.0" 36 | owo-colors = { version = "4.1.0", features = ["supports-colors"] } 37 | regex = "1.11.1" 38 | rhai = { version = "1.21.0", features = [ 39 | "std", 40 | "internals", 41 | "no_custom_syntax", 42 | "sync", 43 | ], default-features = false } 44 | tracing = "0.1.41" 45 | rhai-autodocs = { version = "0.8.0", optional = true } 46 | thiserror = "2.0.11" 47 | tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } 48 | which = "7.0.2" 49 | whoami = "1.5.2" 50 | winnow = { version = "0.6.20", features = ["unstable-recover"] } 51 | cov-mark = "2.0.0" 52 | arbitrary = { version = "1.4.1", features = ["derive"] } 53 | symlink = "0.1.0" 54 | hex = "0.4.3" 55 | walkdir = "2.5.0" 56 | tracing-tree = "0.4.0" 57 | # rhai-autodocs = { version = "0.7.0", path = "../../clones/rhai-autodocs" } 58 | 59 | [dev-dependencies] 60 | pretty_assertions = "1.4.1" 61 | rstest = { version = "0.24.0", default-features = false } 62 | # tracing-tree = "0.4.0" 63 | assert_fs = "1.1.2" 64 | insta = { version = "1.42.1", default-features = false, features = [ 65 | "colors", 66 | "redactions", 67 | "filters", 68 | ] } 69 | predicates = "3.1.3" 70 | test-log = { version = "0.2.17", default-features = false, features = [ 71 | "color", 72 | "trace", 73 | ] } 74 | assert_cmd = "2.0.16" 75 | 76 | [profile.dev.package] 77 | insta = { opt-level = 3 } 78 | 79 | # The profile that 'cargo dist' will build with 80 | [profile.dist] 81 | inherits = "release" 82 | lto = "thin" 83 | 84 | [features] 85 | docgen = ["rhai-autodocs", "rhai/metadata"] 86 | 87 | # [workspace.metadata.dist.dependencies.apt] 88 | # "musl-tools" = "*" 89 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2024 ElKowar 2 | 3 | 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: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | 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. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | # Yolk – Painfree Templated Dotfile Management 5 | 6 | 7 | Packaging status 8 | 9 | 10 |
11 | 12 | Yolk is a cross platform dotfile management tool with a unique spin on templating, 13 | sitting somewhere in between [GNU Stow](https://www.gnu.org/software/stow/) and [chezmoi](https://www.chezmoi.io/). 14 | 15 | Have a look at our [documentation](https://elkowar.github.io/yolk/book) for more information on how to get started! 16 | 17 | ## The Concept 18 | 19 | Yolk allows you to use simple templates in your configuration files without having to worry about keeping a separate template file and the generated config file in sync. 20 | This is achieved through a design that allows all templates to be included inside comments in your actual configuration file. 21 | 22 | Let's demonstrate: 23 | 24 | ```toml 25 | # Use a different font on one host 26 | # {% if SYSTEM.hostname == "epic-desktop" %} 27 | font="Fira Code" 28 | # {% else %} 29 | # font="Iosevka" 30 | # {% end %} 31 | 32 | [colors] 33 | # Load your colors from your yolk configuration 34 | background="#282828" # {< replace_color(data.colors.background) >} 35 | foreground="#ebdbb2" # {< replace_color(data.colors.foreground) >} 36 | ``` 37 | 38 | Yolk will now be able to run the corresponding modifications on the file itself, allowing you to set 39 | templated values while keeping the template directly in the same file. 40 | 41 | ### User Configuration 42 | 43 | Yolk template expressions and configuration are written in the [Rhai](https://rhai.rs/) scripting language. 44 | You can provide custom data to use in your templates via the `yolk.rhai` file in your yolk directory, 45 | which allows you to fetch data dynamically from your system, or reference different static data depending on your system. 46 | 47 | ### Version Control 48 | 49 | How does this work with git? 50 | Given that the concrete files in use on your system may be different across machines, 51 | adding those to version control directly would result in a lot of merge conflicts frequently. 52 | Yolk solves this by only commiting a "canonical" version of your templates, generated right before you commit. 53 | This means that the version of your configuration seen in git will always be generated from a consistent, stable context. 54 | -------------------------------------------------------------------------------- /dist-workspace.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["cargo:."] 3 | 4 | [dist] 5 | windows-archive = ".zip" 6 | fail-fast = true 7 | github-attestations = true 8 | global-artifacts-jobs = ["./build-man"] 9 | # github-build-setup = "build-setup.yml" 10 | cargo-dist-version = "0.28.4" 11 | # pr-run-mode = "upload" 12 | pr-run-mode = "plan" 13 | ci = "github" 14 | installers = [ 15 | "shell", 16 | # "homebrew", 17 | "powershell", 18 | ] 19 | # tap = "elkowar/homebrew-tap" 20 | # publish-jobs = ["homebrew"] 21 | formula = "yolk" 22 | targets = [ 23 | # "aarch64-apple-darwin", 24 | "x86_64-apple-darwin", 25 | "x86_64-unknown-linux-gnu", 26 | "x86_64-pc-windows-msvc", 27 | "x86_64-unknown-linux-musl", 28 | ] 29 | install-path = "CARGO_HOME" 30 | install-updater = false 31 | 32 | # include = ["yolk.man"] 33 | 34 | allow-dirty = ["ci"] 35 | 36 | [dist.github-custom-runners] 37 | # Use an `ubuntu-latest` runner for all "global" steps of the release process, 38 | # rather than cargo-dist's default of using the oldest possible Linux runner. 39 | # This includes `plan`, `build-global-artifacts`, `host`, and `announce`, none 40 | # of which actually rely on the specific Linux version. 41 | global = "ubuntu-latest" 42 | local = "ubuntu-latest" 43 | x86_64-unknown-linux-gnu = "ubuntu-latest" 44 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | book 2 | -------------------------------------------------------------------------------- /docs/book.toml: -------------------------------------------------------------------------------- 1 | [book] 2 | authors = ["elkowar"] 3 | language = "en" 4 | multilingual = false 5 | src = "src" 6 | title = "Yolk" 7 | [output.html] 8 | theme = "theme" 9 | default-theme = "dark" 10 | preferred-dark-theme = "theme" 11 | additional-js = ["./theme/tabs.js"] 12 | 13 | 14 | smart-punctuation = true 15 | git-repository-url = "https://github.com/elkowar/yolk" 16 | edit-url-template = "https://github.com/elkowar/yolk/edit/main/docs/{path}" 17 | -------------------------------------------------------------------------------- /docs/src/SUMMARY.md: -------------------------------------------------------------------------------- 1 | # Summary 2 | 3 | - [Getting started](./getting_started.md) 4 | - [Git concepts](./git_concepts.md) 5 | - [Eggs](./eggs.md) 6 | - [Templates](./templates.md) 7 | - [The yolk.rhai file](./yolk_rhai.md) 8 | - [Conditionals](./conditional_templates.md) 9 | - [Custom Template Functions](./custom_template_functions.md) 10 | - [Yolk on Windows](./yolk_on_windows.md) 11 | # Rhai reference 12 | - [Function Reference](./rhai_docs/index.md) 13 | - [Template functions](./rhai_docs/template.md) 14 | - [Utils](./rhai_docs/utils.md) 15 | - [IO Functions](./rhai_docs/io.md) 16 | - [Rhai stdlib](./rhai_docs/global.md) 17 | -------------------------------------------------------------------------------- /docs/src/conditional_templates.md: -------------------------------------------------------------------------------- 1 | # Conditionals 2 | 3 | Yolk allows you to conditionally include parts of your configuration based on the state of your system. 4 | It achieves this by commenting or un-commenting blocks of your file. 5 | 6 | Conditionals use special template tags that start with the keyword `if`, 7 | which instructs Yolk to treat the following expression as a conditional, 8 | rather than a regular template tag function. 9 | 10 | ## Multiline conditionals 11 | 12 | The most common form of conditional block is using the multiline template tag syntax. 13 | Let's type out a simple example: 14 | 15 | ```toml 16 | # {% if SYSTEM.hostname == "epic-desktop" %} 17 | displays = ["DP-1", "DP-2"] 18 | # {% if SYSTEM.hostname == "business-desktop" %} 19 | displays = ["HDMI-1", "HDMI-2"] 20 | # {% else %} 21 | displays = ["eDP-1"] 22 | # {% end %} 23 | ``` 24 | 25 | Now, this is of course not a valid configuration just yet, as we're setting the `displays` variable thrice. 26 | 27 | However, once you run `yolk sync`, yolk will evaluate the condition and comment out the blocks that don't apply. 28 | For example, on your laptop, this config might be turned into: 29 | 30 | ```toml 31 | # {% if SYSTEM.hostname == "epic-desktop" %} 32 | # displays = ["DP-1", "DP-2"] 33 | # {% if SYSTEM.hostname == "business-desktop" %} 34 | # displays = ["HDMI-1", "HDMI-2"] 35 | # {% else %} 36 | displays = ["eDP-1"] 37 | # {% end %} 38 | ``` 39 | 40 | Note that yolk added a special `` prefix to the comments. 41 | Yolk conditionals will only ever add or remove comments with this prefix, 42 | which means that you can still have regular comments in those conditional blocks. 43 | 44 | ## Inline and Next-line conditionals 45 | 46 | A more simplified version of this is also supported in inline and next-line tags: 47 | 48 | ```kdl 49 | enable_variable_refreshrate // {< if data.is_desktop() >} 50 | 51 | // {# if data.enable_xwayland #} 52 | spawn-at-startup "xwayland-satellite" 53 | ``` 54 | -------------------------------------------------------------------------------- /docs/src/custom_template_functions.md: -------------------------------------------------------------------------------- 1 | # Custom Template Functions 2 | 3 | Given that all of the template functions are just regular Rhai code, you might ask yourself if you can define your own template tag functions. 4 | The answer is YES! 5 | 6 | ## How do template tag functions work 7 | 8 | Template tag functions are executed in a special context, in which a function called `get_yolk_text()` is available. 9 | This variable contains the text block that the template tag operates on. 10 | A template tag function then returns a string which yolk will replace the old text block with. 11 | 12 | ## Example 13 | 14 | Let's define a simple, useless template tag function in your `yolk.rhai`. 15 | 16 | ```rust,ignore 17 | fn scream_or_not(should_scream) { 18 | if should_scream { 19 | get_yolk_text().to_upper() 20 | } else { 21 | get_yolk_text().to_lower() 22 | } 23 | } 24 | ``` 25 | 26 | That's it! 27 | Now, we can go into any templated file, and use our new template tag function. 28 | 29 | ```toml 30 | # {# scream_or_not(SYSTEM.hostname == "loud-host") #} 31 | screaming = "HELLO" 32 | ``` 33 | -------------------------------------------------------------------------------- /docs/src/eggs.md: -------------------------------------------------------------------------------- 1 | # Eggs 2 | 3 | An egg is one package of configuration, typically for one single application. 4 | For example, your editor configuration `~/.config/nvim` would likely be one egg called `nvim`. 5 | 6 | According to your `yolk.rhai`, these get deployed on your system, and may contain some templated files. 7 | -------------------------------------------------------------------------------- /docs/src/getting_started.md: -------------------------------------------------------------------------------- 1 | # Getting started 2 | 3 |
4 | 5 | **Remember: Always have a good backup of your files before using any tool that modifies your files. Nothing bad should happen here, but better be careful.** 6 | 7 |
8 | 9 | ## How dotfiles are stored 10 | 11 | Yolk manages your dotfiles by storing them in a separate directory, typically inside `~/.config/yolk`. 12 | This allows you to keep your dotfiles in version control easily, and lets you manage your configuration from one central location. 13 | 14 | Yolk groups dotfiles into so-called ["eggs"](eggs.md), which are packages of configuration, 15 | typically for one single application (although you can group them however you want, or even just have one egg for all your configuration files). 16 | 17 | When an egg is "deployed", Yolk creates symlinks in the target location pointing towards the egg directory. 18 | This way, the configured applications will see the configuration files as they expect to see them. 19 | 20 | To define where a set of configuration files should be deployed to, you declare each of your eggs in your [main yolk configuration file](./yolk_rhai.md). 21 | This allows you, among other things, to define a different target directory per system. 22 | 23 | ## Initial setup 24 | 25 | To get started with Yolk, you'll first need to set up the Yolk file structure. 26 | 27 | ```bash 28 | $ yolk init 29 | ``` 30 | 31 | This will create the yolk directory, with a default `yolk.rhai` file, and an `eggs` directory. 32 | 33 | ### Adding your first egg 34 | 35 | let's say we want to manage the configuration for the `alacritty` terminal emulator. 36 | To do this, we first move our alacritty configuration into the `eggs` directory: 37 | 38 | ```bash 39 | $ mv ~/.config/alacritty ~/.config/yolk/eggs/ 40 | ``` 41 | 42 | And then configure the corresponding [egg deployment](./yolk_rhai.md#basic-structure): 43 | 44 | ```rust,ignore 45 | export let eggs = #{ 46 | alacritty: #{ 47 | targets: "~/.config/alacritty", 48 | templates: ["alacritty.yml"], 49 | enabled: true, 50 | } 51 | } 52 | ``` 53 | 54 | Now we can run `yolk sync`! 55 | This will set up a symlink from the target location `~/.config/alacritty` 56 | back to the alacritty egg directory `~/.config/yolk/eggs/alacritty`. 57 | 58 | ### Committing your dots to git 59 | 60 | Now, we want to make sure our dotfiles are in version control and pushed to our git host of choice. 61 | Every interaction with git should be done through the `yolk git` command. 62 | This ensures that git sees the canonical (stable) representation of your files, and automatically performs them from within the yolk directory. 63 | 64 | ```bash 65 | $ yolk safeguard # you only need to run this once. 66 | $ yolk git add --all 67 | $ yolk git commit -m "Setup alacritty" 68 | ``` 69 | 70 | To understand what `yolk safeguard` does, see [safeguarding git](./git_concepts.md#safeguarding-git). 71 | 72 | You can now set up your git remote and use git as usual -- just remember to always use `yolk git`, especially when you're committing your files. 73 | 74 | ### Baby's first template 75 | 76 | Because you too are very indecisive about your terminal colors, 77 | you now decide you want to use yolk to manage your color theme for alacritty, and any other applications that you might add later. 78 | You also decide that you want to use a different color scheme on your desktop and your laptop. 79 | 80 | To achieve this, let's first add a declaration of your color theme in your `~/.config/yolk/yolk.rhai` file: 81 | 82 | ```rust,ignore 83 | // ... snip ... 84 | 85 | const themes = #{ 86 | gruvbox: #{ 87 | background: "#282828", 88 | foreground: "#ebdbb2", 89 | }, 90 | mono: #{ 91 | background: "#000000", 92 | foreground: "#ffffff", 93 | }, 94 | } 95 | 96 | export const data = #{ 97 | colors: if SYSTEM.hostname == "laptop" { themes.gruvbox } else { themes.mono } 98 | } 99 | ``` 100 | 101 | Beautiful! 102 | What we're doing here is setting up an *exported* table called `data`, which will store any user-data we might want to refer to in our templates in the future. 103 | We set up a field `colors`, which we then set to a different color scheme depending on the hostname of the system. 104 | 105 | **Don't forget to `export` any variables you might want to reference in your template tags!** 106 | 107 | Now, let's set up a template in our alacritty config file: 108 | 109 | ```toml 110 | #... 111 | [colors.primary] 112 | background = "#ff0000" # {< replace_color(data.colors.background) >} 113 | foreground = "#ff0000" # {< replace_color(data.colors.foreground) >} 114 | # ... 115 | ``` 116 | 117 | Let's break down what's happening here: 118 | Inside the comments after the color declarations, we're using "inline template tags", as indicated by the `{< ... >}` syntax. 119 | These inline tags transform whatever is before them in the line. 120 | The tag calls the built-in `replace_color` function, which looks for a hex-code and replaces it with the value from the `data.colors` table. 121 | 122 | **Let's try it**! 123 | Run 124 | 125 | ```bash 126 | $ yolk sync 127 | ``` 128 | 129 | You will see that, your `alacritty.toml` has changed, and the colors from your `yolk.rhai` file have been applied, depending on your hostname. 130 | -------------------------------------------------------------------------------- /docs/src/git_concepts.md: -------------------------------------------------------------------------------- 1 | # Git concepts 2 | 3 | Basic familiarity with git is assumed. 4 | 5 | ## Safeguarding git 6 | 7 | Yolk wraps the git CLI to ensure that git only ever interacts with your dotfiles in their canonical state. 8 | If it didn't do that, you would end up committing the local state of your dotfiles, 9 | which would conflict with their state from another machine -- which is what yolk is trying to solve. 10 | 11 | To ensure that you're not accidentally using the regular git CLI for your dotfiles, it is recommended to "safeguard" your dotfiles' git directory. 12 | To do this, simply run 13 | 14 | ```bash 15 | $ yolk safeguard 16 | ``` 17 | 18 | after cloning or initializing your dotfiles. 19 | 20 | This simply renames the `.git` directory to `.yolk_git`, which means the regular git CLI won't see the repository anymore. 21 | You are now more or less forced to use the `yolk git` command instead -- which conveniently doesn't just ensure consistency of the git state, 22 | but also works from anywhere in your filesystem! 23 | 24 | ## Cloning your dotfiles 25 | 26 | To clone your dotfiles on a new machine, simply clone the repository to `.config/yolk`, and safeguard your git directory. 27 | 28 | ```bash 29 | $ git clone "$XDG_CONFIG_HOME/yolk" 30 | $ yolk safeguard 31 | ``` 32 | 33 | After that, you can start `yolk sync`ing your eggs! 34 | 35 | ## Interacting with git 36 | 37 | To stage or commit changes, get the git diff or status, you can use the `yolk git` command, which behaves just like the `git` CLI. 38 | So, instead of 39 | 40 | - `git status`, you run `yolk git status`, 41 | - `git add .`, you run `yolk git add --all`, 42 | - `git commit -m "cool changes"`, you run `yolk git commit -m "cool changes`, 43 | 44 | and so on. 45 | This ensures the files are always in the correct canonical state, and makes it possible to interact with a safeguarded git repository. 46 | -------------------------------------------------------------------------------- /docs/src/rhai_docs/index.md: -------------------------------------------------------------------------------- 1 | # Function Reference 2 | 3 | Here you can find the different functions available for use in yolk. 4 | This documentation is generated through the code, 5 | so while it should be very accurate, 6 | the type signatures might look a bit confusing in some places. 7 | -------------------------------------------------------------------------------- /docs/src/rhai_docs/io.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # IO Functions 4 | 5 | A collection of functions that can read the environment and filesystem. 6 | These return standardized values in canonical mode. 7 | 8 | --- 9 | 10 | **namespace**: `io` 11 | 12 | --- 13 | 14 | 15 | 16 |
17 | 18 | ## command_available 19 | 20 |
21 | 22 | ```rust,ignore 23 | command_available(name: &str) -> Result 24 | ``` 25 | 26 | Check if a given command is available 27 | 28 |
29 |
30 | 31 | 32 | 33 | 34 |
35 | 36 | ## env 37 | 38 |
39 | 40 | ```rust,ignore 41 | env(name: &str, def: &str) -> Result 42 | ``` 43 | 44 | Read an environment variable, or return the given default 45 | 46 |
47 |
48 | 49 | 50 | 51 | 52 |
53 | 54 | ## path_exists 55 | 56 |
57 | 58 | ```rust,ignore 59 | path_exists(p: &str) -> Result 60 | ``` 61 | 62 | Check if something exists at a given path 63 | 64 |
65 |
66 | 67 | 68 | 69 | 70 |
71 | 72 | ## path_is_dir 73 | 74 |
75 | 76 | ```rust,ignore 77 | path_is_dir(p: &str) -> Result 78 | ``` 79 | 80 | Check if the given path is a directory 81 | 82 |
83 |
84 | 85 | 86 | 87 | 88 |
89 | 90 | ## path_is_file 91 | 92 |
93 | 94 | ```rust,ignore 95 | path_is_file(p: &str) -> Result 96 | ``` 97 | 98 | Check if the given path is a file 99 | 100 |
101 |
102 | 103 | 104 | 105 | 106 |
107 | 108 | ## read_dir 109 | 110 |
111 | 112 | ```rust,ignore 113 | read_dir(p: &str) -> Result> 114 | ``` 115 | 116 | Read the children of a given dir 117 | 118 |
119 |
120 | 121 | 122 | 123 | 124 |
125 | 126 | ## read_file 127 | 128 |
129 | 130 | ```rust,ignore 131 | read_file(p: &str) -> Result 132 | ``` 133 | 134 | Read the contents of a given file 135 | 136 |
137 |
138 | 139 | 140 | 141 | 142 |
-------------------------------------------------------------------------------- /docs/src/rhai_docs/template.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # Template tag functions 4 | 5 | Yolk template tags simply execute rhai functions that transform the block of text the tag operates on. 6 | 7 | Quick reminder: Yolk has three different types of tags, that differ only in what text they operate on: 8 | 9 | - Next-line tags (`{# ... #}`): These tags operate on the line following the tag. 10 | - Inline tags (`{< ... >}`): These tags operate on everything before the tag within the same line. 11 | - Block tags (`{% ... %} ... {% end %}`): These tags operate on everything between the tag and the corresponding `{% end %}` tag. 12 | 13 | Inside these tags, you can call any of Yolks template tag functions (Or, in fact, any rhai expression that returns a string). 14 | 15 | --- 16 | 17 | **namespace**: `template` 18 | 19 | --- 20 | 21 | 22 | 23 |
24 | 25 | ## replace_between 26 | 27 |
28 | 29 | ```rust,ignore 30 | replace_between(left: &str, right: &str, replacement: &str) -> Result 31 | ``` 32 | 33 | **shorthand**: `rbet`. 34 | 35 | Replaces the text between two delimiters with the `replacement`. 36 | 37 | #### Example 38 | 39 | ```handlebars 40 | ui_font = (Arial) # {< replace_between(`(`, `)`, data.font.ui) >} 41 | ``` 42 | 43 | Note: we don't need to include the quotes in the replacement here. 44 | 45 |
46 |
47 | 48 | 49 | 50 | 51 |
52 | 53 | ## replace_color 54 | 55 |
56 | 57 | ```rust,ignore 58 | replace_color(replacement: &str) -> Result 59 | ``` 60 | 61 | **shorthand**: `rcol`. 62 | 63 | Replaces a hex color value with a new hex color. 64 | 65 | #### Example 66 | 67 | ```handlebars 68 | background_color = "#282828" # {< replace_color(data.colors.bg) >} 69 | ``` 70 | 71 |
72 |
73 | 74 | 75 | 76 | 77 |
78 | 79 | ## replace_in 80 | 81 |
82 | 83 | ```rust,ignore 84 | replace_in(between: &str, replacement: &str) -> Result 85 | ``` 86 | 87 | **shorthand**: `rin`. 88 | 89 | Replaces the text between two delimiters with the `replacement`. 90 | 91 | #### Example 92 | 93 | ```toml 94 | ui_font = "Arial" # {< replace_in(`"`, data.font.ui) >} 95 | ``` 96 | 97 | Note: we don't need to include the quotes in the replacement here. 98 | 99 |
100 |
101 | 102 | 103 | 104 | 105 |
106 | 107 | ## replace_number 108 | 109 |
110 | 111 | ```rust,ignore 112 | replace_number(replacement: Dynamic) -> Result 113 | ``` 114 | 115 | **shorthand**: `rnum`. 116 | 117 | Replaces a number with another number. 118 | 119 | #### Example 120 | 121 | ```handlebars 122 | cursor_size = 32 # {< replace_number(data.cursor_size) >} 123 | ``` 124 | 125 |
126 |
127 | 128 | 129 | 130 | 131 |
132 | 133 | ## replace_quoted 134 | 135 |
136 | 137 | ```rust,ignore 138 | replace_quoted(replacement: &str) -> Result 139 | ``` 140 | 141 | **shorthand**: `rq`. 142 | 143 | Replaces a value between quotes with another value 144 | 145 | #### Example 146 | 147 | ```handlebars 148 | ui_font = "Arial" # {< replace_quoted(data.font.ui) >} 149 | ``` 150 | 151 |
152 |
153 | 154 | 155 | 156 | 157 |
158 | 159 | ## replace_re 160 | 161 |
162 | 163 | ```rust,ignore 164 | replace_re(regex: &str, replacement: &str) -> Result 165 | ``` 166 | 167 | **shorthand**: `rr`. 168 | 169 | Replaces all occurrences of a Regex `pattern` with `replacement` in the text. 170 | 171 | #### Example 172 | 173 | ```handlebars 174 | ui_font = "Arial" # {< replace_re(`".*"`, `"{data.font.ui}"`) >} 175 | ``` 176 | 177 | Note that the replacement value needs to contain the quotes, as those are also matched against in the regex pattern. 178 | Otherwise, we would end up with invalid toml. 179 | 180 |
181 |
182 | 183 | 184 | 185 | 186 |
187 | 188 | ## replace_value 189 | 190 |
191 | 192 | ```rust,ignore 193 | replace_value(replacement: &str) -> Result 194 | ``` 195 | 196 | **shorthand**: `rv`. 197 | 198 | Replaces a value (without spaces) after a `:` or a `=` with another value 199 | 200 | #### Example 201 | 202 | ```handlebars 203 | ui_font = Arial # {< replace_value(data.font.ui) >} 204 | ``` 205 | 206 |
207 |
208 | 209 | 210 | 211 | 212 |
-------------------------------------------------------------------------------- /docs/src/rhai_docs/utils.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # Utility functions 4 | 5 | A collection of utility functions 6 | 7 | --- 8 | 9 | **namespace**: `utils` 10 | 11 | --- 12 | 13 | 14 | 15 |
16 | 17 | ## color_hex_to_rgb 18 | 19 |
20 | 21 | ```rust,ignore 22 | color_hex_to_rgb(hex_string: &str) -> Result 23 | ``` 24 | 25 | Convert a hex color string to an RGB map. 26 | 27 |
28 |
29 | 30 | 31 | 32 | 33 |
34 | 35 | ## color_hex_to_rgb_str 36 | 37 |
38 | 39 | ```rust,ignore 40 | color_hex_to_rgb_str(hex_string: &str) -> Result 41 | ``` 42 | 43 | Convert a hex color string to an RGB string. 44 | 45 |
46 |
47 | 48 | 49 | 50 | 51 |
52 | 53 | ## color_hex_to_rgba_str 54 | 55 |
56 | 57 | ```rust,ignore 58 | color_hex_to_rgba_str(hex_string: &str) -> Result 59 | ``` 60 | 61 | Convert a hex color string to an RGBA string. 62 | 63 |
64 |
65 | 66 | 67 | 68 | 69 |
70 | 71 | ## color_rgb_to_hex 72 | 73 |
74 | 75 | ```rust,ignore 76 | color_rgb_to_hex(rgb_table: Map) -> Result 77 | ``` 78 | 79 | Convert an RGB map to a hex color string. 80 | 81 |
82 |
83 | 84 | 85 | 86 | 87 |
88 | 89 | ## regex_captures 90 | 91 |
92 | 93 | ```rust,ignore 94 | regex_captures(pattern: &str, s: &str) -> Result>> 95 | ``` 96 | 97 | Match a string against a regex pattern and return the capture groups as a list. 98 | 99 |
100 |
101 | 102 | 103 | 104 | 105 |
106 | 107 | ## regex_match 108 | 109 |
110 | 111 | ```rust,ignore 112 | regex_match(pattern: &str, haystack: &str) -> Result 113 | ``` 114 | 115 | Check if a given string matches a given regex pattern. 116 | 117 |
118 |
119 | 120 | 121 | 122 | 123 |
124 | 125 | ## regex_replace 126 | 127 |
128 | 129 | ```rust,ignore 130 | regex_replace(pattern: &str, haystack: &str, replacement: &str) -> Result 131 | ``` 132 | 133 | Replace a regex pattern in a string with a replacement. 134 | 135 |
136 |
137 | 138 | 139 | 140 | 141 |
-------------------------------------------------------------------------------- /docs/src/templates.md: -------------------------------------------------------------------------------- 1 | # Templates in Yolk 2 | 3 | Yolk allows you to use simple templates directly within your config files. 4 | Those templates will be evaluated whenever you run `yolk sync` or interact with git (see [Git concepts](./git_concepts.md)). 5 | 6 | Expressions within these templates are written in the [Rhai](https://rhai.rs) scripting language, 7 | and have access to a couple special variables that allow you to reference your configuration and system state. 8 | 9 | There are two main kinds of templates in Yolk: [conditional templates](./conditional_templates.md) and [template tag functions](#template-tag-functions). 10 | 11 | ## Preparation 12 | To make yolk evaluate your file as a template, you need to explicitly tell yolk about it. 13 | To do this, make sure you include it in the `templates` list of your eggs deployment configuration in your `yolk.rhai`: 14 | 15 | ```rust,ignore 16 | export let eggs = #{ 17 | foo: { 18 | targets: "~/.config/foo", 19 | templates: ["foo.toml"], 20 | } 21 | } 22 | ``` 23 | 24 | 25 | ## Conditionals 26 | 27 | You can use [Conditional template elements](./conditional_templates.md) to conditionally include or exclude parts of your configuration. 28 | Let's take a look at a simple example: 29 | 30 | ```toml 31 | # {% if SYSTEM.hostname == "epic-desktop" %} 32 | displays = ["DP-1", "DP-2"] 33 | # {% else %} 34 | displays = ["eDP-1"] 35 | # {% end %} 36 | ``` 37 | 38 | Once you run `yolk sync`, yolk will evaluate the condition and comment out the block that doesn't apply. 39 | For example, on your laptop, this config would be turned into: 40 | ```toml 41 | # {% if SYSTEM.hostname == "epic-desktop" %} 42 | # displays = ["DP-1", "DP-2"] 43 | # {% else %} 44 | displays = ["eDP-1"] 45 | # {% end %} 46 | ``` 47 | 48 | ## Template tag functions 49 | 50 | In addition to conditionals, Yolk provides a wide variety of functions to _edit_ your configuration, such as to insert values from, say, your color theme. 51 | These are regular rhai functions that modify their attached block of text (see below). 52 | 53 | All of the built-in template tag functions are documented in the [Rhai reference](./rhai_docs/template.md), 54 | and you can also [define your own!](./custom_template_functions.md) 55 | 56 | ### Different types of tags 57 | 58 | Yolk supports three different types of tags: 59 | - Next-line tags (`{# ... #}`): These tags operate on the line following the tag. 60 | - Inline tags (`{< ... >}`): These tags operate on everything before the tag within the same line. 61 | - Block tags (`{% ... %} ... {% end %}`): These tags operate on everything between the tag and the corresponding `{% end %}` tag. 62 | 63 | You can use whichever of these you want, wherever you want. For example, all of these do the same: 64 | ```toml 65 | background_color = "#000000" # {< replace_color(colors.background) >} 66 | 67 | # {# replace_color(colors.background) #} 68 | background_color = "#000000" 69 | 70 | # {% replace_color(colors.background) %} 71 | background_color = "#000000" 72 | # {% end %} 73 | ``` 74 | 75 | ### Example: Templating your color scheme 76 | In many cases, you'll want to make specific values, such as colors or paths, be set through one central source, rather than specifying them in every config file. 77 | Yolk allows you to do this (and more) by using various template functions. 78 | For example, the `replace_quoted` directive takes any value and replaces whatever is in quotes with the result of the expression. 79 | ```toml 80 | # {# replace_quoted(colors.background) #} 81 | background_color = "#000000" 82 | # {# replace_quoted(colors.foreground) #} 83 | foreground_color = "#ffffff" 84 | ``` 85 | After running `yolk sync`, yolk will replace the regex patterns with the corresponding result of the Lua expression. 86 | For example, depending on how you configured your `colors` in your `yolk.rhai`, this could turn into: 87 | ```toml 88 | # {# replace_quoted(colors.background) #} 89 | background_color = "#282828" 90 | # {# replace_quoted(colors.foreground) #} 91 | foreground_color = "#ebdbb2" 92 | ``` 93 | Yolk will refuse to evaluate directives that are non-reversible (i.e. if you `replace_re`d `".*"` with `foo`, as `foo` will no longer match that regex pattern). 94 | -------------------------------------------------------------------------------- /docs/src/yolk_on_windows.md: -------------------------------------------------------------------------------- 1 | # Yolk on Windows 2 | 3 | Yolk can be used on all platforms, and allows you to declare different file locations for your configuration files per platform. 4 | 5 | However, there are a few special things to keep in mind when using yolk on windows. 6 | Primarily, Windows does not allow users to create symlinks by default. As yolk relies on symlinks to deploy dotfiles, you will need to apply one of the following solutions to this problem: 7 | 8 | 1. [Activate developer mode](https://learn.microsoft.com/en-us/windows/apps/get-started/enable-your-device-for-development): 9 | Simply activate the Developer Mode in the windows settings menu. This is the easiest solution. 10 | 2. Always run yolk with administrator permissions. This is not recommended. 11 | 3. Explicitly allow creating symlinks for your user through the windows security policy. 12 | -------------------------------------------------------------------------------- /docs/src/yolk_rhai.md: -------------------------------------------------------------------------------- 1 | # The yolk.rhai file 2 | 3 | The `yolk.rhai` file is the heart of your Yolk configuration. 4 | 5 | It's where you define all of your [eggs](eggs.md) (packages), as well as export any variables and functionality you will then refer to inside your [templates](templates.md). 6 | 7 | If you're familiar with [Rhai](https://rhai.rs/), this is loaded as a module, imported into the global scope of your template tags. 8 | 9 | ## Basic structure 10 | 11 | The `yolk.rhai` file is a Rhai script that is run by Yolk to generate your configuration. 12 | Everything you declare in `yolk.rhai` will be available to use in your templates. 13 | 14 | Your `yolk.rhai` needs to define one special variable called `eggs`. 15 | This is a map of all the [eggs](./eggs.md) you have in your egg directory, 16 | which describes where their files should be deployed to, 17 | and which files should be treated as template files. 18 | 19 | Let's look at an example: 20 | 21 | ```rust,ignore 22 | export let eggs = #{ 23 | foot: #{ 24 | targets: "~/.config/foot", 25 | templates: ["foot.ini"], 26 | }, 27 | zsh: #{ 28 | targets: #{ 29 | ".zshrc": "~/.config/zsh/.zshrc", 30 | ".zshenv": "~/.zshenv", 31 | }, 32 | main_file: ".zshrc" 33 | }, 34 | nvim: #{ 35 | targets: "~/.config/nvim", 36 | // Note that you can use shell-style glob patterns for the templates list 37 | templates: ["**/*.lua"], 38 | }, 39 | niri: #{ 40 | targets: "~/.config/niri", 41 | templates: ["config.kdl"], 42 | enabled: SYSTEM.hostname == "cool-desktop", 43 | }, 44 | alacritty: "~/.config/alacritty", 45 | // This uses the `merge` deployment strategy, which 46 | // will merge the directory structures during deployment, 47 | // allowing a stow-style pattern 48 | // of mirroring your home directory structure in your egg dir 49 | zed: #{ targets: "~", strategy: "merge" } 50 | 51 | another_example: #{ 52 | targets: "~", 53 | strategy: "merge", 54 | unsafe_shell_hooks: #{ 55 | post_deploy: "some shellscript", 56 | post_undeploy: "another shellscript", 57 | } 58 | } 59 | } 60 | ``` 61 | 62 | now, let's break this down. 63 | For every entry in our `~/.config/yolk/eggs` directory, we have a corresponding egg configuration here in the eggs object. 64 | This configuration can either just be the path where the eggs files should be deployed, 65 | or an object. 66 | 67 | If it's an object, it can contain the following fields: 68 | 69 | #### `enabled` 70 | a boolean, describing whether this egg should be deployed or not. 71 | This is useful if you only want to deploy an egg on some systems, or depending on some other condition. 72 | 73 | #### `targets` 74 | Either the path where to deploy the egg, or an object with mappings from file inside the egg directory to the target path. 75 | 76 | Providing the string `"~/.config/foot"` is a short for `#{ ".": "~/.config/foot"}`. 77 | 78 | #### `strategy` 79 | Either `"put"` or `"merge"`. Defaults to `put`. 80 | 81 | - In **put** mode, yolk will create a symlink for each mapping from egg directory entry to target path. 82 | If a directory or file already exists, Yolk will refuse to create the symlink. 83 | 84 | - In **merge** mode, yolk will merge the directory structures during deployment. 85 | This means, if you want to use a stow-style approach, and have the egg directory mirror your home directory structure, you can use 86 | `"~"` (or `#{".": "~"}`) as the targets value. 87 | 88 | #### `templates` 89 | A list of files that should be treated as templates. 90 | This list can contain shell-style glob patterns, so `*.lua` will expand to all lua files in the egg directory. 91 | Files that are not listed here will not be edited by yolk during `yolk sync`! 92 | 93 | 94 | #### `main_file` 95 | A path, relative to the egg directory, that will be opened when you run `yolk edit `. 96 | 97 | #### `unsafe_shell_hooks` 98 | An object that may declare two scripts, which get ran when the egg is deployed or un-deployed. 99 | The `post_deploy` script gets executed after the egg has been deployed, the `post_undeploy` script gets executed after the egg has been un-deployed. 100 | 101 | Note that these scripts should optimally be idempodent, so running them twice should not change anything compared to running them once. 102 | 103 | ## Available variables 104 | 105 | To generate your configuration depending on your system, there are a couple global variables that you can reference inside the `yolk.rhai` file. 106 | The `SYSTEM` variable is a table containing data about your local system. 107 | If the config is being executed in canonical mode, the `SYSTEM` table will instead contain a fixed set of values that will be the same across all systems. 108 | 109 | To know if you're currently in local or canonical mode, you can check the `LOCAL` variable. 110 | 111 | **Tip:** 112 | To look at the contents of those variables or try out your logic, you can always use the `yolk eval` command. 113 | 114 | ```bash 115 | $ yolk eval 'print(SYSTEM)' 116 | ``` 117 | 118 | ## Splitting up into multiple files 119 | 120 | Rhai allows you to import other files into your scripts. 121 | For example, let's say you want to keep your color theme definition in a separate file. 122 | Simply create a new `colors.rhai` file next to your `yolk.rhai`, and make sure to explicitly declare exported variables as `export`: 123 | 124 | ```rust 125 | export let gruvbox = #{ 126 | background: "#282828", 127 | foreground: "#ebdbb2", 128 | }; 129 | 130 | fn some_function() { 131 | print("hi") 132 | } 133 | ``` 134 | 135 | Note that functions are exported by default. 136 | 137 | Now, in your `yolk.rhai`, import this script, giving the module an explict name: 138 | 139 | ```rs 140 | import "colors" as colors; 141 | ``` 142 | 143 | Now you can refer to anything exported from that file as `colors::thing`, i.e.: 144 | 145 | ```rs 146 | let theme = colors::gruvbox; 147 | colors::some_function(); 148 | ``` 149 | -------------------------------------------------------------------------------- /docs/theme/css/general.css: -------------------------------------------------------------------------------- 1 | /* Base styles and content styles */ 2 | 3 | /* my stuff */ 4 | 5 | .content a, 6 | #searchresults a { 7 | color: #fd8041 !important; 8 | } 9 | a.header { 10 | color: #f7d654 !important; 11 | } 12 | h1.menu-title { 13 | /* color: #f7d654 !important; */ 14 | color: transparent; /* make the text invisible */ 15 | user-select: none; /* prevent selection of the text */ 16 | background: url("https://github.com/elkowar/yolk/raw/main/.github/images/yolk_banner_animated_nobg.svg") 17 | center no-repeat; 18 | margin: 3px; 19 | } 20 | 21 | .header { 22 | font-family: "Quicksand", sans-serif; 23 | } 24 | 25 | hr { 26 | border: unset; 27 | border-top: 1px solid #555555; 28 | } 29 | 30 | .rhai-doc .doc-block { 31 | background-color: #181818; 32 | margin-bottom: 22px; 33 | padding: 16px; 34 | 35 | h2 { 36 | margin-block-start: unset; 37 | margin-block-end: unset; 38 | } 39 | 40 | .doc-content { 41 | /* margin-left: 1em; */ 42 | 43 | h4 { 44 | margin-block-start: 21px; 45 | } 46 | } 47 | } 48 | 49 | /* Inline code */ 50 | *:not(pre) code.hljs { 51 | padding-top: 2px; 52 | padding-bottom: 2px; 53 | background-color: #191919; 54 | border-radius: 5px; 55 | } 56 | 57 | /* Code block */ 58 | pre code.hljs { 59 | padding: 0.8em !important; 60 | background-color: #212121; 61 | border-radius: 5px; 62 | box-shadow: 0 0 7px 0 rgba(0, 0, 0, 0.1); 63 | } 64 | 65 | /* From rhai_autodocs example dir */ 66 | 67 | .func-name { 68 | /* override mdbook default margin for h2/h3 */ 69 | margin-top: 0em; 70 | } 71 | 72 | /* Annotations for a diagram or a snippet */ 73 | .ann { 74 | text-align: center; 75 | font-size: 0.8em; 76 | font-style: italic; 77 | } 78 | 79 | /* custom css for the rust SectionFormat::Tabs option */ 80 | /* NOTE: variables used here are from mdbook's css */ 81 | 82 | .tab { 83 | overflow: hidden; 84 | border-left: 1px solid var(--theme-hover); 85 | border-right: 1px solid var(--theme-hover); 86 | } 87 | 88 | .tab button { 89 | color: var(--icons); 90 | background-color: var(--theme-hover); 91 | float: left; 92 | border: none; 93 | outline: none; 94 | cursor: pointer; 95 | padding: 14px 16px; 96 | transition: 0.3s; 97 | } 98 | 99 | .tab button:hover { 100 | color: var(--icons-hover); 101 | background-color: var(--theme-hover); 102 | } 103 | 104 | .tab button.active { 105 | background-color: var(--theme-bg); 106 | } 107 | 108 | .tabcontent { 109 | display: none; 110 | padding: 6px 12px; 111 | border: 1px solid var(--theme-hover); 112 | border-top: none; 113 | } 114 | 115 | /*default stuff */ 116 | 117 | :root { 118 | /* Browser default font-size is 16px, this way 1 rem = 10px */ 119 | font-size: 62.5%; 120 | color-scheme: var(--color-scheme); 121 | } 122 | 123 | html { 124 | font-family: "Open Sans", sans-serif; 125 | color: var(--fg); 126 | background-color: var(--bg); 127 | text-size-adjust: none; 128 | -webkit-text-size-adjust: none; 129 | } 130 | 131 | body { 132 | margin: 0; 133 | font-size: 1.6rem; 134 | overflow-x: hidden; 135 | } 136 | 137 | code { 138 | font-family: var(--mono-font) !important; 139 | font-size: var(--code-font-size); 140 | direction: ltr !important; 141 | } 142 | 143 | /* make long words/inline code not x overflow */ 144 | main { 145 | overflow-wrap: break-word; 146 | } 147 | 148 | /* make wide tables scroll if they overflow */ 149 | .table-wrapper { 150 | overflow-x: auto; 151 | } 152 | 153 | /* Don't change font size in headers. */ 154 | h1 code, 155 | h2 code, 156 | h3 code, 157 | h4 code, 158 | h5 code, 159 | h6 code { 160 | font-size: unset; 161 | } 162 | 163 | .left { 164 | float: left; 165 | } 166 | .right { 167 | float: right; 168 | } 169 | .boring { 170 | opacity: 0.6; 171 | } 172 | .hide-boring .boring { 173 | display: none; 174 | } 175 | .hidden { 176 | display: none !important; 177 | } 178 | 179 | h2, 180 | h3 { 181 | margin-block-start: 2.5em; 182 | } 183 | h4, 184 | h5 { 185 | margin-block-start: 2em; 186 | } 187 | 188 | .header + .header h3, 189 | .header + .header h4, 190 | .header + .header h5 { 191 | margin-block-start: 1em; 192 | } 193 | 194 | h1:target::before, 195 | h2:target::before, 196 | h3:target::before, 197 | h4:target::before, 198 | h5:target::before, 199 | h6:target::before { 200 | display: inline-block; 201 | content: "»"; 202 | margin-inline-start: -30px; 203 | width: 30px; 204 | } 205 | 206 | /* This is broken on Safari as of version 14, but is fixed 207 | in Safari Technology Preview 117 which I think will be Safari 14.2. 208 | https://bugs.webkit.org/show_bug.cgi?id=218076 209 | */ 210 | :target { 211 | /* Safari does not support logical properties */ 212 | scroll-margin-top: calc(var(--menu-bar-height) + 0.5em); 213 | } 214 | 215 | .page { 216 | outline: 0; 217 | padding: 0 var(--page-padding); 218 | margin-block-start: calc( 219 | 0px - var(--menu-bar-height) 220 | ); /* Compensate for the #menu-bar-hover-placeholder */ 221 | } 222 | .page-wrapper { 223 | box-sizing: border-box; 224 | background-color: var(--bg); 225 | } 226 | .no-js .page-wrapper, 227 | .js:not(.sidebar-resizing) .page-wrapper { 228 | transition: 229 | margin-left 0.3s ease, 230 | transform 0.3s ease; /* Animation: slide away */ 231 | } 232 | [dir="rtl"] .js:not(.sidebar-resizing) .page-wrapper { 233 | transition: 234 | margin-right 0.3s ease, 235 | transform 0.3s ease; /* Animation: slide away */ 236 | } 237 | 238 | .content { 239 | overflow-y: auto; 240 | padding: 0 5px 50px 5px; 241 | } 242 | .content main { 243 | margin-inline-start: auto; 244 | margin-inline-end: auto; 245 | max-width: var(--content-max-width); 246 | } 247 | .content p { 248 | line-height: 1.45em; 249 | } 250 | .content ol { 251 | line-height: 1.45em; 252 | } 253 | .content ul { 254 | line-height: 1.45em; 255 | } 256 | .content a { 257 | text-decoration: none; 258 | } 259 | .content a:hover { 260 | text-decoration: underline; 261 | } 262 | .content img, 263 | .content video { 264 | max-width: 100%; 265 | } 266 | .content .header:link, 267 | .content .header:visited { 268 | color: var(--fg); 269 | } 270 | .content .header:link, 271 | .content .header:visited:hover { 272 | text-decoration: none; 273 | } 274 | 275 | table { 276 | margin: 0 auto; 277 | border-collapse: collapse; 278 | } 279 | table td { 280 | padding: 3px 20px; 281 | border: 1px var(--table-border-color) solid; 282 | } 283 | table thead { 284 | background: var(--table-header-bg); 285 | } 286 | table thead td { 287 | font-weight: 700; 288 | border: none; 289 | } 290 | table thead th { 291 | padding: 3px 20px; 292 | } 293 | table thead tr { 294 | border: 1px var(--table-header-bg) solid; 295 | } 296 | /* Alternate background colors for rows */ 297 | table tbody tr:nth-child(2n) { 298 | background: var(--table-alternate-bg); 299 | } 300 | 301 | blockquote { 302 | margin: 20px 0; 303 | padding: 0 20px; 304 | color: var(--fg); 305 | background-color: var(--quote-bg); 306 | border-block-start: 0.1em solid var(--quote-border); 307 | border-block-end: 0.1em solid var(--quote-border); 308 | } 309 | 310 | .warning { 311 | margin: 20px; 312 | padding: 0 20px; 313 | border-inline-start: 2px solid var(--warning-border); 314 | } 315 | 316 | .warning:before { 317 | position: absolute; 318 | width: 3rem; 319 | height: 3rem; 320 | margin-inline-start: calc(-1.5rem - 21px); 321 | content: "ⓘ"; 322 | text-align: center; 323 | background-color: var(--bg); 324 | color: var(--warning-border); 325 | font-weight: bold; 326 | font-size: 2rem; 327 | } 328 | 329 | blockquote .warning:before { 330 | background-color: var(--quote-bg); 331 | } 332 | 333 | kbd { 334 | background-color: var(--table-border-color); 335 | border-radius: 4px; 336 | border: solid 1px var(--theme-popup-border); 337 | box-shadow: inset 0 -1px 0 var(--theme-hover); 338 | display: inline-block; 339 | font-size: var(--code-font-size); 340 | font-family: var(--mono-font); 341 | line-height: 10px; 342 | padding: 4px 5px; 343 | vertical-align: middle; 344 | } 345 | 346 | sup { 347 | /* Set the line-height for superscript and footnote references so that there 348 | isn't an awkward space appearing above lines that contain the footnote. 349 | 350 | See https://github.com/rust-lang/mdBook/pull/2443#discussion_r1813773583 351 | for an explanation. 352 | */ 353 | line-height: 0; 354 | } 355 | 356 | :not(.footnote-definition) + .footnote-definition, 357 | .footnote-definition + :not(.footnote-definition) { 358 | margin-block-start: 2em; 359 | } 360 | .footnote-definition { 361 | font-size: 0.9em; 362 | margin: 0.5em 0; 363 | } 364 | .footnote-definition p { 365 | display: inline; 366 | } 367 | 368 | .tooltiptext { 369 | position: absolute; 370 | visibility: hidden; 371 | color: #fff; 372 | background-color: #333; 373 | transform: translateX( 374 | -50% 375 | ); /* Center by moving tooltip 50% of its width left */ 376 | left: -8px; /* Half of the width of the icon */ 377 | top: -35px; 378 | font-size: 0.8em; 379 | text-align: center; 380 | border-radius: 6px; 381 | padding: 5px 8px; 382 | margin: 5px; 383 | z-index: 1000; 384 | } 385 | .tooltipped .tooltiptext { 386 | visibility: visible; 387 | } 388 | 389 | .chapter li.part-title { 390 | color: var(--sidebar-fg); 391 | margin: 5px 0px; 392 | font-weight: bold; 393 | } 394 | 395 | .result-no-output { 396 | font-style: italic; 397 | } 398 | -------------------------------------------------------------------------------- /docs/theme/css/print.css: -------------------------------------------------------------------------------- 1 | 2 | #sidebar, 3 | #menu-bar, 4 | .nav-chapters, 5 | .mobile-nav-chapters { 6 | display: none; 7 | } 8 | 9 | #page-wrapper.page-wrapper { 10 | transform: none !important; 11 | margin-inline-start: 0px; 12 | overflow-y: initial; 13 | } 14 | 15 | #content { 16 | max-width: none; 17 | margin: 0; 18 | padding: 0; 19 | } 20 | 21 | .page { 22 | overflow-y: initial; 23 | } 24 | 25 | code { 26 | direction: ltr !important; 27 | } 28 | 29 | pre > .buttons { 30 | z-index: 2; 31 | } 32 | 33 | a, a:visited, a:active, a:hover { 34 | color: #4183c4; 35 | text-decoration: none; 36 | } 37 | 38 | h1, h2, h3, h4, h5, h6 { 39 | page-break-inside: avoid; 40 | page-break-after: avoid; 41 | } 42 | 43 | pre, code { 44 | page-break-inside: avoid; 45 | white-space: pre-wrap; 46 | } 47 | 48 | .fa { 49 | display: none !important; 50 | } 51 | -------------------------------------------------------------------------------- /docs/theme/css/variables.css: -------------------------------------------------------------------------------- 1 | /* Globals */ 2 | @import url("https://fonts.googleapis.com/css2?family=Quicksand:wght@300..700&display=swap"); 3 | 4 | :root { 5 | --sidebar-width: 300px; 6 | --sidebar-resize-indicator-width: 8px; 7 | --sidebar-resize-indicator-space: 2px; 8 | --page-padding: 15px; 9 | --content-max-width: 750px; 10 | --menu-bar-height: 50px; 11 | --mono-font: "Source Code Pro", Consolas, "Ubuntu Mono", Menlo, 12 | "DejaVu Sans Mono", monospace, monospace; 13 | --code-font-size: 0.875em 14 | /* please adjust the ace font size accordingly in editor.js */; 15 | } 16 | 17 | /* Themes */ 18 | /* .yolk { */ 19 | :root { 20 | --font-face: "Comfortaa", sans-serif; 21 | --main-font: Comfortaa, Comfortaa override, ui-sans-serif, system-ui, 22 | -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, 23 | Arial, Noto Sans, sans-serif, Apple Color Emoji, Segoe UI Emoji, 24 | Segoe UI Symbol, Noto Color Emoji !important; 25 | font-family: var(--main-font); 26 | font-size: 18px; 27 | 28 | --bg: rgb(20, 20, 20); 29 | --fg: #ffffff; 30 | 31 | --sidebar-bg: var(--bg); 32 | --sidebar-fg: var(--fg); 33 | --sidebar-non-existant: #5c6773; 34 | --sidebar-active: #ffb454; 35 | --sidebar-spacer: #2d334f; 36 | 37 | --scrollbar: var(--sidebar-fg); 38 | 39 | --icons: #737480; 40 | --icons-hover: #b7b9cc; 41 | 42 | --links: #0096cf; 43 | 44 | --inline-code-color: #ffb454; 45 | 46 | --theme-popup-bg: #14191f; 47 | --theme-popup-border: #5c6773; 48 | --theme-hover: #191f26; 49 | 50 | --quote-bg: hsl(226, 15%, 17%); 51 | --quote-border: hsl(226, 15%, 22%); 52 | 53 | --warning-border: #ff8e00; 54 | 55 | --table-border-color: hsl(210, 25%, 13%); 56 | --table-header-bg: hsl(210, 25%, 28%); 57 | --table-alternate-bg: hsl(210, 25%, 11%); 58 | 59 | --searchbar-border-color: #303030; 60 | --searchbar-bg: #101010; 61 | --searchbar-fg: #fff; 62 | --searchbar-shadow-color: #d4c89f; 63 | --searchresults-header-fg: #666; 64 | --searchresults-border-color: #888; 65 | --searchresults-li-bg: #252932; 66 | --search-mark-bg: #e3b171; 67 | 68 | --color-scheme: dark; 69 | 70 | /* Same as `--icons` */ 71 | --copy-button-filter: invert(45%) sepia(6%) saturate(621%) 72 | hue-rotate(198deg) brightness(99%) contrast(85%); 73 | /* Same as `--sidebar-active` */ 74 | --copy-button-filter-hover: invert(68%) sepia(55%) saturate(531%) 75 | hue-rotate(341deg) brightness(104%) contrast(101%); 76 | } 77 | -------------------------------------------------------------------------------- /docs/theme/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elkowar/yolk/4a73edd962cdff0e88191d868ac6b6b96ba0ae54/docs/theme/favicon.png -------------------------------------------------------------------------------- /docs/theme/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /docs/theme/highlight.css: -------------------------------------------------------------------------------- 1 | /*! 2 | Theme: Material 3 | Author: Nate Peterson 4 | License: ~ MIT (or more permissive) [via base16-schemes-source] 5 | Maintainer: @highlightjs/core-team 6 | Version: 2021.09.0 7 | */ 8 | 9 | /* 10 | WARNING: DO NOT EDIT THIS FILE DIRECTLY. 11 | 12 | This theme file was auto-generated from the Base16 scheme material 13 | by the Highlight.js Base16 template builder. 14 | 15 | - https://github.com/highlightjs/base16-highlightjs 16 | */ 17 | 18 | /* 19 | base00 #263238 Default Background 20 | base01 #2E3C43 Lighter Background (Used for status bars, line number and folding marks) 21 | base02 #314549 Selection Background 22 | base03 #546E7A Comments, Invisibles, Line Highlighting 23 | base04 #B2CCD6 Dark Foreground (Used for status bars) 24 | base05 #EEFFFF Default Foreground, Caret, Delimiters, Operators 25 | base06 #EEFFFF Light Foreground (Not often used) 26 | base07 #FFFFFF Light Background (Not often used) 27 | base08 #F07178 Variables, XML Tags, Markup Link Text, Markup Lists, Diff Deleted 28 | base09 #F78C6C Integers, Boolean, Constants, XML Attributes, Markup Link Url 29 | base0A #FFCB6B Classes, Markup Bold, Search Text Background 30 | base0B #C3E88D Strings, Inherited Class, Markup Code, Diff Inserted 31 | base0C #89DDFF Support, Regular Expressions, Escape Characters, Markup Quotes 32 | base0D #82AAFF Functions, Methods, Attribute IDs, Headings 33 | base0E #C792EA Keywords, Storage, Selector, Markup Italic, Diff Changed 34 | base0F #FF5370 Deprecated, Opening/Closing Embedded Language Tags, e.g. 35 | */ 36 | 37 | pre code.hljs { 38 | display: block; 39 | overflow-x: auto; 40 | padding: 1em; 41 | } 42 | 43 | code.hljs { 44 | padding: 3px 5px; 45 | } 46 | 47 | .hljs { 48 | color: #eeffff; 49 | background: #232323; 50 | /* background: #263238; */ 51 | } 52 | 53 | .hljs::selection, 54 | .hljs ::selection { 55 | background-color: #314549; 56 | color: #eeffff; 57 | } 58 | 59 | /* purposely do not highlight these things */ 60 | .hljs-formula, 61 | .hljs-params, 62 | .hljs-property { 63 | } 64 | 65 | /* base03 - #546E7A - Comments, Invisibles, Line Highlighting */ 66 | .hljs-comment { 67 | color: #a4aeaa; 68 | } 69 | 70 | /* base04 - #B2CCD6 - Dark Foreground (Used for status bars) */ 71 | .hljs-tag { 72 | color: #b2ccd6; 73 | } 74 | 75 | /* base05 - #EEFFFF - Default Foreground, Caret, Delimiters, Operators */ 76 | .hljs-subst, 77 | .hljs-punctuation, 78 | .hljs-operator { 79 | color: #eeffff; 80 | } 81 | 82 | .hljs-operator { 83 | opacity: 0.7; 84 | } 85 | 86 | /* base08 - Variables, XML Tags, Markup Link Text, Markup Lists, Diff Deleted */ 87 | .hljs-bullet, 88 | .hljs-variable, 89 | .hljs-template-variable, 90 | .hljs-selector-tag, 91 | .hljs-name, 92 | .hljs-deletion { 93 | color: #f07178; 94 | } 95 | 96 | /* base09 - Integers, Boolean, Constants, XML Attributes, Markup Link Url */ 97 | .hljs-symbol, 98 | .hljs-number, 99 | .hljs-link, 100 | .hljs-attr, 101 | .hljs-variable.constant_, 102 | .hljs-literal { 103 | color: #f78c6c; 104 | } 105 | 106 | /* base0A - Classes, Markup Bold, Search Text Background */ 107 | .hljs-title, 108 | .hljs-class .hljs-title, 109 | .hljs-title.class_ { 110 | color: #ffcb6b; 111 | } 112 | 113 | .hljs-strong { 114 | font-weight: bold; 115 | color: #ffcb6b; 116 | } 117 | 118 | /* base0B - Strings, Inherited Class, Markup Code, Diff Inserted */ 119 | .hljs-code, 120 | .hljs-addition, 121 | .hljs-title.class_.inherited__, 122 | .hljs-string { 123 | color: #c3e88d; 124 | } 125 | 126 | /* base0C - Support, Regular Expressions, Escape Characters, Markup Quotes */ 127 | .hljs-built_in, 128 | .hljs-doctag, /* guessing */ 129 | .hljs-quote, 130 | .hljs-keyword.hljs-atrule, 131 | .hljs-regexp { 132 | color: #89ddff; 133 | } 134 | 135 | /* base0D - Functions, Methods, Attribute IDs, Headings */ 136 | .hljs-function .hljs-title, 137 | .hljs-attribute, 138 | .ruby .hljs-property, 139 | .hljs-title.function_, 140 | .hljs-section { 141 | color: #82aaff; 142 | } 143 | 144 | /* base0E - Keywords, Storage, Selector, Markup Italic, Diff Changed */ 145 | .hljs-type, 146 | /* .hljs-selector-id, */ 147 | /* .hljs-selector-class, */ 148 | /* .hljs-selector-attr, */ 149 | /* .hljs-selector-pseudo, */ 150 | .hljs-template-tag, 151 | .diff .hljs-meta, 152 | .hljs-keyword { 153 | color: #c792ea; 154 | } 155 | .hljs-emphasis { 156 | color: #c792ea; 157 | font-style: italic; 158 | } 159 | 160 | /* base0F - Deprecated, Opening/Closing Embedded Language Tags, e.g. */ 161 | .hljs-meta, 162 | /* 163 | prevent top level .keyword and .string scopes 164 | from leaking into meta by accident 165 | */ 166 | .hljs-meta .hljs-keyword, 167 | .hljs-meta .hljs-string { 168 | color: #ff5370; 169 | } 170 | 171 | .hljs-meta .hljs-keyword, 172 | /* for v10 compatible themes */ 173 | .hljs-meta-keyword { 174 | font-weight: bold; 175 | } 176 | -------------------------------------------------------------------------------- /docs/theme/tabs.js: -------------------------------------------------------------------------------- 1 | function openTab(evt, group, tab) { 2 | const tabcontent = document.getElementsByClassName("tabcontent"); 3 | 4 | for (let i = 0; i < tabcontent.length; i++) { 5 | if (tabcontent[i].getAttribute("group") === group) { 6 | tabcontent[i].style.display = "none"; 7 | } 8 | } 9 | 10 | const tablinks = document.getElementsByClassName("tablinks"); 11 | 12 | for (let i = 0; i < tablinks.length; i++) { 13 | if (tabcontent[i].getAttribute("group") === group) { 14 | tablinks[i].className = tablinks[i].className.replace(" active", ""); 15 | } 16 | } 17 | 18 | document.getElementById(`${group}-${tab}`).style.display = "block"; 19 | evt.currentTarget.className += " active"; 20 | } 21 | -------------------------------------------------------------------------------- /fuzz/.gitignore: -------------------------------------------------------------------------------- 1 | /artifacts 2 | /corpus 3 | /target 4 | /cov 5 | -------------------------------------------------------------------------------- /fuzz/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "yolk-fuzz" 3 | version = "0.0.0" 4 | publish = false 5 | edition = "2021" 6 | 7 | [package.metadata] 8 | cargo-fuzz = true 9 | 10 | [dependencies] 11 | libfuzzer-sys = "0.4.8" 12 | yolk = { path = "..", package = "yolk_dots" } 13 | arbitrary = { version = "1.3.2", features = ["derive"] } 14 | 15 | # Prevent this from interfering with workspaces 16 | [workspace] 17 | members = ["."] 18 | 19 | [profile.release] 20 | debug = 1 21 | 22 | [[bin]] 23 | name = "parser" 24 | path = "fuzz_targets/parser.rs" 25 | test = false 26 | doc = false 27 | 28 | [[bin]] 29 | name = "render" 30 | path = "fuzz_targets/render.rs" 31 | test = false 32 | doc = false 33 | 34 | [[bin]] 35 | name = "comment_style" 36 | path = "fuzz_targets/comment_style.rs" 37 | test = false 38 | doc = false 39 | -------------------------------------------------------------------------------- /fuzz/dictionary: -------------------------------------------------------------------------------- 1 | "{%" 2 | "%}" 3 | "{#" 4 | "#}" 5 | "{<" 6 | ">}" 7 | "`" 8 | "``" 9 | "if" 10 | "else" 11 | "end" 12 | "elif" 13 | "/*" 14 | "--" 15 | "*/" 16 | "#" 17 | "{% get_yolk_text().to_upper() %}" 18 | "{% else %}" 19 | "{% end %}" 20 | "{% elif 1==1 %}" 21 | "{# get_yolk_text().to_upper() %}" 22 | "{< get_yolk_text().to_upper() >}" 23 | "{# bruh( %}" 24 | "{< bruh >}" 25 | "{% if 1==2 %}" 26 | "{# if 1==2 %}" 27 | "{< if 1==2 >}" 28 | "{% if 1==1 %}" 29 | "{# if 1==1 %}" 30 | "{< if 1==1 >}" 31 | 32 | "//" 33 | "#" 34 | "/*" 35 | "foo" 36 | "bar" 37 | "\\n" 38 | "foo\\n" 39 | "\\t" 40 | " " 41 | "test" 42 | -------------------------------------------------------------------------------- /fuzz/fuzz_targets/comment_style.rs: -------------------------------------------------------------------------------- 1 | #![no_main] 2 | 3 | use libfuzzer_sys::fuzz_target; 4 | 5 | fuzz_target!(|x: (yolk::templating::element::Element, String)| { 6 | let (element, data) = x; 7 | let comment_style = yolk::templating::comment_style::CommentStyle::try_infer(&element); 8 | if let Some(comment_style) = comment_style { 9 | let _ = comment_style.toggle_string(&data, true); 10 | let _ = comment_style.toggle_string(&data, false); 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /fuzz/fuzz_targets/parser.rs: -------------------------------------------------------------------------------- 1 | #![no_main] 2 | 3 | use libfuzzer_sys::fuzz_target; 4 | 5 | fuzz_target!(|data: String| { 6 | let result = yolk::templating::document::Document::parse_string_named("fuzz-input", &data); 7 | if let Ok(result) = result { 8 | let mut eval_ctx = yolk::script::eval_ctx::EvalCtx::new_empty(); 9 | let _ = result.render(&mut eval_ctx); 10 | } 11 | }); 12 | -------------------------------------------------------------------------------- /fuzz/fuzz_targets/render.rs: -------------------------------------------------------------------------------- 1 | #![no_main] 2 | 3 | use libfuzzer_sys::fuzz_target; 4 | 5 | fuzz_target!(|data: yolk::templating::document::Document| { 6 | let mut eval_ctx = yolk::script::eval_ctx::EvalCtx::new_empty(); 7 | let _ = data.render(&mut eval_ctx); 8 | }); 9 | -------------------------------------------------------------------------------- /oranda.json: -------------------------------------------------------------------------------- 1 | { 2 | "styles": { 3 | "theme": "dark", 4 | "logo": ".github/images/yolk_banner_animated.svg", 5 | "favicon": ".github/images/favicon.ico", 6 | "additional_css": [".github/oranda_theme.css"] 7 | }, 8 | "components": { 9 | "artifacts": { 10 | "package_managers": { 11 | "preferred": { 12 | "crates.io": "cargo install yolk_dots --locked --profile=dist" 13 | }, 14 | "additional": { 15 | "binstall": "cargo binstall yolk_dots" 16 | } 17 | }, 18 | "cargo_dist": true, 19 | "auto": true 20 | }, 21 | "mdbook": { 22 | "theme": false 23 | } 24 | }, 25 | "build": { 26 | "path_prefix": "yolk" 27 | }, 28 | "marketing": { 29 | "social": { 30 | "image": ".github/images/yolk_banner.svg", 31 | "image_alt": "yolk" 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /release-plz.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | allow_dirty = true # allow updating repositories with uncommitted changes 3 | # changelog_config = "config/git-cliff.toml" # use a custom git-cliff configuration 4 | changelog_update = true # disable changelog updates 5 | dependencies_update = false # update dependencies with `cargo update` 6 | git_release_enable = false # disable GitHub/Gitea releases 7 | pr_branch_prefix = "release-plz-" # PR branch prefix 8 | pr_name = "Release v{{ version }}" # template for the PR name 9 | pr_labels = ["release"] # add the `release` label to the release Pull Request 10 | publish_allow_dirty = true # add `--allow-dirty` to `cargo publish` 11 | semver_check = false # disable API breaking changes checks 12 | publish_timeout = "10m" # set a timeout for `cargo publish` 13 | release_commits = "^feat:|^fix:|^chore:" # prepare release only if at least one commit matches a regex 14 | publish = true 15 | git_tag_enable = true 16 | git_tag_name = "v{{ version }}" 17 | 18 | [changelog] 19 | protect_breaking_commits = true 20 | commit_parsers = [ 21 | { message = "^feat", group = "added" }, 22 | { message = "^changed|^ux", group = "changed" }, 23 | { message = "^deprecated", group = "deprecated" }, 24 | { message = "^fix", group = "fixed" }, 25 | { message = "^security", group = "security" }, 26 | # { message = "^.*", group = "other" }, 27 | ] 28 | link_parsers = [ 29 | { pattern = "#(\\d+)", href = "https://github.com/elkowar/yolk/issues/$1" }, 30 | ] 31 | -------------------------------------------------------------------------------- /src/doc_generator.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, sync::Arc}; 2 | 3 | use miette::IntoDiagnostic; 4 | use rhai_autodocs::{item::Item, module::Documentation}; 5 | 6 | use crate::{ 7 | util::create_regex, 8 | yolk::{EvalMode, Yolk}, 9 | }; 10 | 11 | pub fn generate_docs(yolk: Yolk) -> miette::Result> { 12 | let mut eval_ctx = yolk.prepare_eval_ctx_for_templates(EvalMode::Canonical)?; 13 | 14 | let docs = rhai_autodocs::export::options() 15 | .include_standard_packages(false) 16 | .format_sections_with(rhai_autodocs::export::SectionFormat::Rust) 17 | .export(eval_ctx.engine_mut()) 18 | .into_diagnostic()?; 19 | 20 | let mut docs = render_docs(&docs); 21 | 22 | let mut empty_module = rhai::Module::new(); 23 | empty_module.set_doc(indoc::indoc! { " 24 | # Rhai Standard Library builtins 25 | Rhai standard library functions. 26 | 27 | Note that the typesignatures here do look a bit weird. 28 | This is simply a result of how we generate the documentation, 29 | and can't easily be improved. 30 | 31 | Just try your best to ignore it... 32 | " }); 33 | let mut empty_engine = rhai::Engine::new(); 34 | empty_engine.register_global_module(Arc::new(empty_module)); 35 | 36 | let mut stdlib_docs = rhai_autodocs::export::options() 37 | .include_standard_packages(true) 38 | .format_sections_with(rhai_autodocs::export::SectionFormat::Rust) 39 | .export(&empty_engine) 40 | .into_diagnostic()?; 41 | 42 | stdlib_docs.items.retain(|x| match x { 43 | Item::Function { 44 | root_metadata: _, 45 | metadata: _, 46 | name, 47 | index: _, 48 | } => { 49 | name.starts_with(|x| char::is_ascii_alphanumeric(&x) && x != '?') 50 | && !name.starts_with("i8.") 51 | && !name.starts_with("i16.") 52 | && !name.starts_with("i32.") 53 | && !name.starts_with("i64.") 54 | && !name.starts_with("i128.") 55 | && !name.starts_with("u8.") 56 | && !name.starts_with("u16.") 57 | && !name.starts_with("u32.") 58 | && !name.starts_with("u64.") 59 | && !name.starts_with("u128.") 60 | && !name.starts_with("Range.") 61 | && !name.starts_with("RangeInclusive.") 62 | && !name.starts_with("FnPtr.") 63 | } 64 | 65 | Item::CustomType { .. } => true, 66 | }); 67 | 68 | docs.extend(render_docs(&stdlib_docs)); 69 | Ok(docs) 70 | } 71 | 72 | fn render_docs(docs: &Documentation) -> HashMap { 73 | let mut map = HashMap::new(); 74 | for module in &docs.sub_modules { 75 | let entries = render_docs(module); 76 | map.extend(entries); 77 | } 78 | 79 | let mut mine = format!( 80 | "
\n\n{}\n\n---\n\n**namespace**: `{}`\n\n---\n\n", 81 | docs.documentation, 82 | docs.namespace.trim_start_matches("global/") 83 | ); 84 | 85 | for item in &docs.items { 86 | mine.push_str(&format!("\n\n{}\n\n", render_item_docs(item))) 87 | } 88 | mine.push_str("\n\n
"); 89 | 90 | map.insert(docs.name.to_string(), mine); 91 | 92 | map 93 | } 94 | 95 | fn render_item_docs(item: &Item) -> String { 96 | match item { 97 | Item::Function { 98 | ref root_metadata, 99 | ref metadata, 100 | name, 101 | index: _, 102 | } => { 103 | // dbg!(&metadata); 104 | let docs = root_metadata 105 | .doc_comments 106 | .clone() 107 | .unwrap_or_default() 108 | .join("\n\n") 109 | .lines() 110 | .map(|x| x.trim().trim_start_matches("///").trim().to_string()) 111 | .map(|x| { 112 | create_regex("^# ") 113 | .unwrap() 114 | .replace_all(&x, "#### ") 115 | .to_string() 116 | }) 117 | // .map(|x| format!("> {x}")) 118 | .collect::>() 119 | .join("\n"); 120 | indoc::formatdoc! {r#" 121 |
122 | 123 | ## {name} 124 | 125 |
126 | 127 | ```rust,ignore 128 | {} 129 | ``` 130 | 131 | {} 132 | 133 |
134 |
135 | "#, 136 | metadata.iter().map(|x| x.signature.to_string()).collect::>().join("\n"), 137 | docs 138 | // docs.lines().map(|x| format!("> {x}")).collect::>().join("\n"), 139 | } 140 | } 141 | // TODO: render these, once we have some 142 | Item::CustomType { .. } => "custom type".to_string(), 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/eggs_config.rs: -------------------------------------------------------------------------------- 1 | use normalize_path::NormalizePath; 2 | use std::{ 3 | collections::{HashMap, HashSet}, 4 | path::{Path, PathBuf}, 5 | process::Command, 6 | str::FromStr, 7 | }; 8 | 9 | use miette::{miette, IntoDiagnostic as _}; 10 | use rhai::Dynamic; 11 | 12 | use crate::{script::rhai_error::RhaiError, util::PathExt as _}; 13 | 14 | macro_rules! rhai_error { 15 | ($($tt:tt)*) => { 16 | RhaiError::Other(miette!($($tt)*)) 17 | }; 18 | } 19 | 20 | /// How the contents of an egg should be deployed. 21 | #[derive(Debug, PartialEq, Eq, Clone, Copy, Default)] 22 | pub enum DeploymentStrategy { 23 | /// Recursively traverse the directory structure until a directory / file doesn't exist yet, then symlink there. 24 | /// This allows stow-like behavior. 25 | Merge, 26 | /// Simply deploy to the given target, or fail. 27 | #[default] 28 | Put, 29 | } 30 | 31 | impl FromStr for DeploymentStrategy { 32 | type Err = miette::Error; 33 | 34 | fn from_str(s: &str) -> Result { 35 | match s { 36 | "merge" => Ok(DeploymentStrategy::Merge), 37 | "put" => Ok(DeploymentStrategy::Put), 38 | _ => miette::bail!( 39 | help = "strategy must be one of 'merge' or 'put'", 40 | "Invalid deployment strategy {}", 41 | s 42 | ), 43 | } 44 | } 45 | } 46 | 47 | #[derive(Debug, Clone, PartialEq, Eq, Default)] 48 | pub struct ShellHooks { 49 | pub post_deploy: Option, 50 | pub post_undeploy: Option, 51 | // pub post_sync: Option, 52 | } 53 | 54 | impl ShellHooks { 55 | pub fn run_post_deploy(&self) -> miette::Result<()> { 56 | if let Some(command) = &self.post_deploy { 57 | tracing::debug!("Running post-deploy script"); 58 | run_hook(command)?; 59 | } 60 | Ok(()) 61 | } 62 | 63 | pub fn run_post_undeploy(&self) -> miette::Result<()> { 64 | if let Some(command) = &self.post_undeploy { 65 | tracing::debug!("Running post-undeploy script"); 66 | run_hook(command)?; 67 | } 68 | Ok(()) 69 | } 70 | 71 | // pub fn run_post_sync(&self) -> miette::Result<()> { 72 | // if let Some(command) = &self.post_sync { 73 | // tracing::debug!("Running post-sync script"); 74 | // run_hook(command)?; 75 | // } 76 | // Ok(()) 77 | // } 78 | } 79 | 80 | fn run_hook(command: &str) -> miette::Result<()> { 81 | let status = Command::new("sh") 82 | .arg("-c") 83 | .arg(command) 84 | .status() 85 | .map_err(|e| miette::miette!("Failed to run post-deploy hook: {}", e))?; 86 | 87 | if !status.success() { 88 | miette::bail!( 89 | "Post-deploy hook failed with status {}", 90 | status.code().unwrap_or(-1) 91 | ); 92 | } 93 | Ok(()) 94 | } 95 | 96 | #[derive(Debug, PartialEq, Eq, Clone)] 97 | pub struct EggConfig { 98 | /// The targets map is a map from `path-relative-to-egg-dir` -> `path-where-it-should-go`. 99 | pub targets: HashMap, 100 | pub enabled: bool, 101 | pub templates: HashSet, 102 | /// The "main" file of this egg -- currently used to determine which path should be opened by `yolk edit`. 103 | pub main_file: Option, 104 | pub strategy: DeploymentStrategy, 105 | pub unsafe_shell_hooks: ShellHooks, 106 | } 107 | 108 | impl Default for EggConfig { 109 | fn default() -> Self { 110 | EggConfig { 111 | enabled: true, 112 | targets: HashMap::new(), 113 | templates: HashSet::new(), 114 | main_file: None, 115 | strategy: Default::default(), 116 | unsafe_shell_hooks: ShellHooks { 117 | post_deploy: None, 118 | post_undeploy: None, 119 | }, 120 | } 121 | } 122 | } 123 | 124 | impl EggConfig { 125 | pub fn new(in_egg: impl AsRef, deployed_to: impl AsRef) -> Self { 126 | let in_egg = in_egg.as_ref(); 127 | EggConfig { 128 | enabled: true, 129 | targets: maplit::hashmap! { 130 | in_egg.to_path_buf() => deployed_to.as_ref().to_path_buf() 131 | }, 132 | templates: HashSet::new(), 133 | main_file: None, 134 | strategy: DeploymentStrategy::default(), 135 | unsafe_shell_hooks: ShellHooks { 136 | post_deploy: None, 137 | post_undeploy: None, 138 | }, 139 | } 140 | } 141 | 142 | pub fn new_merge(in_egg: impl AsRef, deployed_to: impl AsRef) -> Self { 143 | Self::new(in_egg, deployed_to).with_strategy(DeploymentStrategy::Merge) 144 | } 145 | 146 | pub fn with_unsafe_hooks(mut self, unsafe_shell_hooks: ShellHooks) -> Self { 147 | self.unsafe_shell_hooks = unsafe_shell_hooks; 148 | self 149 | } 150 | 151 | pub fn with_enabled(mut self, enabled: bool) -> Self { 152 | self.enabled = enabled; 153 | self 154 | } 155 | 156 | pub fn with_template(mut self, template: impl AsRef) -> Self { 157 | self.templates.insert(template.as_ref().to_path_buf()); 158 | self 159 | } 160 | 161 | pub fn with_strategy(mut self, strategy: DeploymentStrategy) -> Self { 162 | self.strategy = strategy; 163 | self 164 | } 165 | 166 | pub fn with_main_file(mut self, main_file: impl AsRef) -> Self { 167 | self.main_file = Some(main_file.as_ref().to_path_buf()); 168 | self 169 | } 170 | 171 | /// Add a new target from a path inside the egg dir to the path it should be deployed as. 172 | pub fn with_target(mut self, in_egg: impl AsRef, deploy_to: impl AsRef) -> Self { 173 | self.targets.insert( 174 | in_egg.as_ref().to_path_buf(), 175 | deploy_to.as_ref().to_path_buf(), 176 | ); 177 | self 178 | } 179 | 180 | /// Returns the targets map, but with any `~` expanded to the home directory. 181 | /// 182 | /// The targets map is a map from `path-relative-to-egg-dir` -> `path-where-it-should-go`. 183 | pub fn targets_expanded( 184 | &self, 185 | home: impl AsRef, 186 | egg_root: impl AsRef, 187 | ) -> miette::Result> { 188 | let egg_root = egg_root.as_ref(); 189 | self.targets 190 | .iter() 191 | .map(|(source, target)| { 192 | let source = egg_root.canonical()?.join(source); 193 | let target = target.expanduser(); 194 | let target = if target.is_absolute() { 195 | target 196 | } else { 197 | home.as_ref().join(target) 198 | }; 199 | Ok((source.normalize(), target.normalize())) 200 | }) 201 | .collect() 202 | } 203 | 204 | /// Expand the glob patterns in the `templates` field to a list of paths. 205 | /// The globbed paths are considered relative to `in_dir`. The resulting list of paths will contain absolute paths. 206 | pub fn templates_globexpanded(&self, in_dir: impl AsRef) -> miette::Result> { 207 | let in_dir = in_dir.as_ref(); 208 | let mut paths = Vec::new(); 209 | for globbed in &self.templates { 210 | let expanded = glob::glob(&in_dir.join(globbed).to_string_lossy()).into_diagnostic()?; 211 | for path in expanded { 212 | paths.push(path.into_diagnostic()?); 213 | } 214 | } 215 | Ok(paths) 216 | } 217 | 218 | pub fn from_dynamic(value: Dynamic) -> Result { 219 | if let Ok(target_path) = value.as_immutable_string_ref() { 220 | return Ok(EggConfig::new(".", target_path.to_string())); 221 | } 222 | let Ok(map) = value.as_map_ref() else { 223 | return Err(rhai_error!("egg value must be a string or a map")); 224 | }; 225 | let empty_map = Dynamic::from(rhai::Map::new()); 226 | let targets = map.get("targets").unwrap_or(&empty_map); 227 | 228 | let targets = if let Ok(targets) = targets.as_immutable_string_ref() { 229 | maplit::hashmap! { PathBuf::from(".") => PathBuf::from(targets.to_string()) } 230 | } else if let Ok(targets) = targets.as_map_ref() { 231 | targets 232 | .clone() 233 | .into_iter() 234 | .map(|(k, v)| { 235 | Ok::<_, RhaiError>(( 236 | PathBuf::from(&*k), 237 | PathBuf::from(&v.into_string().map_err(|e| { 238 | rhai_error!("target file value must be a path, but got {e}") 239 | })?), 240 | )) 241 | }) 242 | .collect::>()? 243 | } else { 244 | return Err(rhai_error!("egg `targets` must be a string or a map")); 245 | }; 246 | 247 | let main_file = match map.get("main_file") { 248 | Some(path) => Some( 249 | path.as_immutable_string_ref() 250 | .map_err(|e| rhai_error!("main_file must be a path, but got {e}"))? 251 | .to_string() 252 | .into(), 253 | ), 254 | None => None, 255 | }; 256 | 257 | let strategy = match map.get("strategy") { 258 | Some(strategy) => { 259 | DeploymentStrategy::from_str(&strategy.to_string()).map_err(RhaiError::Other)? 260 | } 261 | None => DeploymentStrategy::default(), 262 | }; 263 | 264 | let templates = 265 | if let Some(templates) = map.get("templates") { 266 | templates 267 | .as_array_ref() 268 | .map_err(|t| rhai_error!("`templates` must be a list, but got {t}"))? 269 | .iter() 270 | .map(|x| { 271 | Ok::<_, RhaiError>(PathBuf::from(x.clone().into_string().map_err(|e| { 272 | rhai_error!("template entry must be a path, but got {e}") 273 | })?)) 274 | }) 275 | .collect::, _>>()? 276 | } else { 277 | HashSet::new() 278 | }; 279 | 280 | let enabled = if let Some(x) = map.get("enabled") { 281 | x.as_bool() 282 | .map_err(|t| rhai_error!("`enabled` must be a list, but got {t}"))? 283 | } else { 284 | true 285 | }; 286 | 287 | let unsafe_shell_hooks = if let Some(x) = map.get("unsafe_shell_hooks") { 288 | let shell_hooks = x 289 | .as_map_ref() 290 | .map_err(|t| rhai_error!("`unsafe_shell_hooks` must be a map, but got {t}"))?; 291 | ShellHooks { 292 | post_deploy: shell_hooks.get("post_deploy").map(|v| v.to_string()), 293 | post_undeploy: shell_hooks.get("post_undeploy").map(|v| v.to_string()), 294 | } 295 | } else { 296 | ShellHooks::default() 297 | }; 298 | 299 | Ok(EggConfig { 300 | targets, 301 | enabled, 302 | templates, 303 | main_file, 304 | strategy, 305 | unsafe_shell_hooks, 306 | }) 307 | } 308 | } 309 | 310 | #[cfg(test)] 311 | mod test { 312 | use std::collections::HashSet; 313 | 314 | use assert_fs::{ 315 | prelude::{FileWriteStr as _, PathChild as _}, 316 | TempDir, 317 | }; 318 | use maplit::hashset; 319 | use miette::IntoDiagnostic as _; 320 | use pretty_assertions::assert_eq; 321 | 322 | use crate::{ 323 | eggs_config::{DeploymentStrategy, EggConfig, ShellHooks}, 324 | util::test_util::TestResult, 325 | }; 326 | 327 | use rstest::rstest; 328 | #[rstest] 329 | #[case( 330 | indoc::indoc! {r#" 331 | #{ 332 | enabled: false, 333 | targets: #{ "foo": "~/bar" }, 334 | templates: ["foo"], 335 | main_file: "foo", 336 | strategy: "merge", 337 | unsafe_shell_hooks: #{ 338 | post_deploy: "run after deploy", 339 | post_undeploy: "run after undeploy", 340 | } 341 | } 342 | "#}, 343 | EggConfig::new_merge("foo", "~/bar") 344 | .with_enabled(false) 345 | .with_template("foo") 346 | .with_strategy(DeploymentStrategy::Merge) 347 | .with_main_file("foo") 348 | .with_unsafe_hooks(ShellHooks { 349 | post_deploy: Some("run after deploy".to_string()), 350 | post_undeploy: Some("run after undeploy".to_string()), 351 | }) 352 | )] 353 | #[case(r#"#{ targets: "~/bar" }"#, EggConfig::new(".", "~/bar"))] 354 | #[case(r#""~/bar""#, EggConfig::new(".", "~/bar"))] 355 | fn test_read_eggs_config(#[case] input: &str, #[case] expected: EggConfig) -> TestResult { 356 | let result = rhai::Engine::new().eval(input)?; 357 | assert_eq!(EggConfig::from_dynamic(result)?, expected); 358 | Ok(()) 359 | } 360 | 361 | #[test] 362 | fn test_template_globbed() -> TestResult { 363 | let home = TempDir::new().into_diagnostic()?; 364 | let config = EggConfig::new_merge(home.to_str().unwrap(), ".") 365 | .with_template("foo") 366 | .with_template("**/*.foo"); 367 | home.child("foo").write_str("a")?; 368 | home.child("bar/baz/a.foo").write_str("a")?; 369 | home.child("bar/a.foo").write_str("a")?; 370 | home.child("bar/foo").write_str("a")?; 371 | let result = config.templates_globexpanded(&home)?; 372 | 373 | assert_eq!( 374 | result.into_iter().collect::>(), 375 | hashset![ 376 | home.child("foo").path().to_path_buf(), 377 | home.child("bar/baz/a.foo").path().to_path_buf(), 378 | home.child("bar/a.foo").path().to_path_buf(), 379 | ] 380 | ); 381 | Ok(()) 382 | } 383 | } 384 | -------------------------------------------------------------------------------- /src/git_utils.rs: -------------------------------------------------------------------------------- 1 | use miette::{Context as _, IntoDiagnostic, Result}; 2 | use std::{ 3 | path::{Path, PathBuf}, 4 | process::Stdio, 5 | }; 6 | 7 | pub struct Git { 8 | root_path: PathBuf, 9 | git_dir_path: PathBuf, 10 | } 11 | impl Git { 12 | pub fn new(root_path: impl Into, git_dir_path: impl Into) -> Self { 13 | Self { 14 | root_path: root_path.into(), 15 | git_dir_path: git_dir_path.into(), 16 | } 17 | } 18 | 19 | pub fn start_git_command_builder(&self) -> std::process::Command { 20 | let mut cmd = std::process::Command::new("git"); 21 | cmd.current_dir(&self.root_path).args([ 22 | "--git-dir", 23 | &self.git_dir_path.to_string_lossy(), 24 | "--work-tree", 25 | &self.root_path.to_string_lossy(), 26 | ]); 27 | cmd 28 | } 29 | 30 | pub fn add(&self, path: impl AsRef) -> Result<()> { 31 | let output = self 32 | .start_git_command_builder() 33 | .args(["add", &path.as_ref().display().to_string()]) 34 | .stdin(Stdio::null()) 35 | .stdout(Stdio::inherit()) 36 | .stderr(Stdio::inherit()) 37 | .output() 38 | .into_diagnostic() 39 | .wrap_err("git add failed to run")?; 40 | miette::ensure!(output.status.success(), "git add failed"); 41 | Ok(()) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "docgen")] 2 | mod doc_generator; 3 | 4 | pub mod deploy; 5 | pub mod eggs_config; 6 | pub mod git_utils; 7 | pub mod multi_error; 8 | pub mod script; 9 | pub mod templating; 10 | #[cfg(test)] 11 | pub mod tests; 12 | pub mod util; 13 | pub mod yolk; 14 | pub mod yolk_paths; 15 | -------------------------------------------------------------------------------- /src/multi_error.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug, thiserror::Error, miette::Diagnostic)] 2 | #[error("{message}")] 3 | #[diagnostic()] 4 | pub struct MultiError { 5 | message: String, 6 | #[related] 7 | errors: Vec, 8 | } 9 | 10 | impl MultiError { 11 | pub fn new(message: impl Into, errors: Vec) -> Self { 12 | Self { 13 | message: message.into(), 14 | errors, 15 | } 16 | } 17 | } 18 | impl From for MultiError { 19 | fn from(report: miette::Report) -> Self { 20 | Self { 21 | message: "Something went wrong".to_string(), 22 | errors: vec![report], 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/script/eval_ctx.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | use std::sync::Arc; 3 | 4 | use miette::Result; 5 | use rhai::module_resolvers::FileModuleResolver; 6 | use rhai::Engine; 7 | use rhai::Module; 8 | use rhai::Scope; 9 | use rhai::Variant; 10 | 11 | use crate::yolk::EvalMode; 12 | 13 | use super::rhai_error::RhaiError; 14 | use super::stdlib; 15 | 16 | pub const YOLK_TEXT_NAME: &str = "YOLK_TEXT"; 17 | 18 | #[derive(Debug)] 19 | pub struct EvalCtx { 20 | engine: Engine, 21 | scope: Scope<'static>, 22 | yolk_file_module: Option<(rhai::AST, Arc)>, 23 | } 24 | 25 | impl Default for EvalCtx { 26 | fn default() -> Self { 27 | Self::new_empty() 28 | } 29 | } 30 | 31 | impl EvalCtx { 32 | pub fn new_empty() -> Self { 33 | let mut engine = Engine::new(); 34 | engine.set_optimization_level(rhai::OptimizationLevel::Simple); 35 | 36 | engine.build_type::(); 37 | engine.build_type::(); 38 | Self { 39 | engine, 40 | scope: Scope::new(), 41 | yolk_file_module: None, 42 | } 43 | } 44 | 45 | /// Initialize a new [`EvalCtx`] with set up modules and module resolver. 46 | /// 47 | /// The given mode is used when initializing the `io` module, 48 | /// to determine whether to actually perform any IO or to just simulate it. 49 | pub fn new_in_mode(mode: EvalMode) -> Result { 50 | let mut ctx = Self::new_empty(); 51 | ctx.engine 52 | .register_global_module(Arc::new(stdlib::global_stuff())); 53 | ctx.engine 54 | .register_static_module("utils", Arc::new(stdlib::utils_module())); 55 | ctx.engine 56 | .register_static_module("io", Arc::new(stdlib::io_module(mode))); 57 | let template_module = Arc::new(stdlib::tag_module()); 58 | ctx.engine 59 | .register_static_module("template", template_module); 60 | 61 | Ok(ctx) 62 | } 63 | 64 | /// Set the directory to look for imports in. 65 | /// 66 | /// The given `path` is used as the path for the a [`FileModuleResolver`], 67 | /// such that `import` statements can be used in rhai code relative to this path. 68 | pub fn set_module_path(&mut self, path: &Path) { 69 | self.engine 70 | .set_module_resolver(FileModuleResolver::new_with_path(path)); 71 | } 72 | 73 | /// Load a given rhai string as a global module, and store it as the `yolk_file_module`. 74 | pub fn load_rhai_file_to_module(&mut self, content: &str) -> Result<(), RhaiError> { 75 | let ast = self.compile(content)?; 76 | let module = Module::eval_ast_as_new(self.scope.clone(), &ast, &self.engine) 77 | .map_err(|e| RhaiError::from_rhai(content, *e))?; 78 | let module = Arc::new(module); 79 | self.engine.register_global_module(module.clone()); 80 | self.yolk_file_module = Some((ast, module.clone())); 81 | Ok(()) 82 | } 83 | 84 | /// Eval a given string of rhai and return the result. Execute in the scope of this [`EvalCtx`]. 85 | pub fn eval_rhai(&mut self, content: &str) -> Result { 86 | let mut ast = self.compile(content)?; 87 | if let Some((yolk_file_ast, _)) = self.yolk_file_module.as_ref() { 88 | ast = yolk_file_ast.merge(&ast); 89 | } 90 | self.engine 91 | .eval_ast_with_scope(&mut self.scope, &ast) 92 | .map_err(|e| RhaiError::from_rhai(content, *e)) 93 | } 94 | 95 | /// Eval a rhai expression in a scope that has a special `get_yolk_text()` function that returns the given `text`. 96 | /// After the expression is evaluated, the scope is rewound to its state before the function was added. 97 | pub fn eval_text_transformation( 98 | &mut self, 99 | text: &str, 100 | expr: &str, 101 | ) -> Result { 102 | let scope_before = self.scope.len(); 103 | let text = text.to_string(); 104 | self.engine 105 | .register_fn("get_yolk_text", move || text.clone()); 106 | let result = self.eval_rhai::(expr)?; 107 | self.scope.rewind(scope_before); 108 | Ok(result.to_string()) 109 | } 110 | 111 | pub fn set_global(&mut self, name: &str, value: T) { 112 | self.scope.set_or_push(name, value); 113 | } 114 | 115 | pub fn engine_mut(&mut self) -> &mut Engine { 116 | &mut self.engine 117 | } 118 | 119 | pub fn yolk_file_module(&self) -> Option<&(rhai::AST, Arc)> { 120 | self.yolk_file_module.as_ref() 121 | } 122 | 123 | fn compile(&mut self, text: &str) -> Result { 124 | self.engine 125 | .compile(text) 126 | .map_err(|e| RhaiError::from_rhai_compile(text, e)) 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/script/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod eval_ctx; 2 | pub mod rhai_error; 3 | pub mod stdlib; 4 | pub mod sysinfo; 5 | -------------------------------------------------------------------------------- /src/script/rhai_error.rs: -------------------------------------------------------------------------------- 1 | use std::ops::Range; 2 | 3 | use miette::Diagnostic; 4 | 5 | #[derive(Debug, thiserror::Error, Diagnostic)] 6 | pub enum RhaiError { 7 | #[error("{origin}")] 8 | #[diagnostic(forward(origin))] 9 | SourceError { 10 | #[label("here")] 11 | span: Range, 12 | origin: Box, 13 | }, 14 | #[error(transparent)] 15 | RhaiError(#[from] rhai::EvalAltResult), 16 | #[error("{}", .0)] 17 | #[diagnostic(transparent)] 18 | Other(miette::Report), 19 | } 20 | 21 | impl RhaiError { 22 | pub fn new_other(err: E) -> Self 23 | where 24 | E: std::error::Error + Send + Sync + 'static, 25 | { 26 | Self::Other(miette::miette!(err)) 27 | } 28 | pub fn other(err: E) -> Self 29 | where 30 | E: Diagnostic + Send + Sync + 'static, 31 | { 32 | Self::Other(miette::Report::from(err)) 33 | } 34 | 35 | pub fn from_rhai_compile(source_code: &str, err: rhai::ParseError) -> Self { 36 | Self::from_rhai(source_code, err.into()) 37 | } 38 | 39 | pub fn from_rhai(source_code: &str, err: rhai::EvalAltResult) -> Self { 40 | let position = err.position(); 41 | let mut span = 0..0; 42 | if let Some(line_nr) = position.line() { 43 | // TODO: this won't work with \r\n, _or will it_? *vsauce music starts playing* 44 | let offset_start = source_code 45 | .split_inclusive('\n') 46 | .take(line_nr - 1) 47 | .map(|x| x.len()) 48 | .sum::(); 49 | span = if let Some(within_line) = position.position() { 50 | offset_start + within_line..offset_start + within_line + 1 51 | } else { 52 | let offset_end = offset_start 53 | + source_code 54 | .lines() 55 | .nth(line_nr - 1) 56 | .map(|x| x.len()) 57 | .unwrap_or_default(); 58 | let indent = source_code[offset_start..] 59 | .chars() 60 | .take_while(|x| x.is_whitespace()) 61 | .count(); 62 | offset_start + indent..offset_end 63 | }; 64 | } 65 | if span.start >= source_code.len() { 66 | span = source_code.len() - 1..source_code.len(); 67 | } 68 | Self::SourceError { 69 | span, 70 | origin: Box::new(RhaiError::RhaiError(err)), 71 | } 72 | } 73 | 74 | /// Convert this error into a [`miette::Report`] with the given name and source code attached as a rust source. 75 | pub fn into_report(self, name: impl ToString, source: impl ToString) -> miette::Report { 76 | miette::Report::from(self).with_source_code( 77 | miette::NamedSource::new(name.to_string(), source.to_string()).with_language("Rust"), 78 | ) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/script/sysinfo.rs: -------------------------------------------------------------------------------- 1 | use rhai::{CustomType, TypeBuilder}; 2 | 3 | #[derive(Debug, Clone, CustomType)] 4 | pub struct SystemInfo { 5 | #[rhai_type(readonly)] 6 | hostname: String, 7 | #[rhai_type(readonly)] 8 | username: String, 9 | #[rhai_type(readonly)] 10 | distro: String, 11 | #[rhai_type(readonly)] 12 | device_name: String, 13 | #[rhai_type(readonly)] 14 | arch: String, 15 | #[rhai_type(readonly)] 16 | desktop_env: String, 17 | #[rhai_type(readonly)] 18 | platform: String, 19 | #[rhai_type(readonly)] 20 | paths: SystemInfoPaths, 21 | } 22 | 23 | #[derive(Debug, Clone, CustomType)] 24 | pub struct SystemInfoPaths { 25 | #[rhai_type(readonly)] 26 | cache_dir: String, 27 | #[rhai_type(readonly)] 28 | config_dir: String, 29 | #[rhai_type(readonly)] 30 | home_dir: String, 31 | } 32 | 33 | impl SystemInfo { 34 | pub fn generate() -> Self { 35 | #[cfg(test)] 36 | return Self::canonical(); 37 | #[cfg(not(test))] 38 | Self { 39 | hostname: whoami::fallible::hostname().unwrap_or_else(|_| "unknown".to_string()), 40 | username: whoami::username(), 41 | distro: whoami::distro().to_string(), 42 | device_name: whoami::fallible::devicename().unwrap_or_else(|_| "unknown".to_string()), 43 | arch: whoami::arch().to_string(), 44 | desktop_env: whoami::desktop_env().to_string(), 45 | platform: whoami::platform().to_string(), 46 | paths: SystemInfoPaths { 47 | cache_dir: dirs::cache_dir() 48 | .map(|x| x.to_string_lossy().to_string()) 49 | .unwrap_or_else(|| "unknown".into()), 50 | config_dir: dirs::config_dir() 51 | .map(|x| x.to_string_lossy().to_string()) 52 | .unwrap_or_else(|| "unknown".into()), 53 | home_dir: dirs::home_dir() 54 | .map(|x| x.to_string_lossy().to_string()) 55 | .unwrap_or_else(|| "unknown".into()), 56 | }, 57 | } 58 | } 59 | 60 | pub fn canonical() -> Self { 61 | Self { 62 | hostname: "canonical-hostname".to_string(), 63 | username: "canonical-username".to_string(), 64 | paths: SystemInfoPaths { 65 | cache_dir: "/canonical/cache".to_string(), 66 | config_dir: "/canonical/config".to_string(), 67 | home_dir: "/canonical/home".to_string(), 68 | }, 69 | distro: "distro".to_string(), 70 | device_name: "devicename".to_string(), 71 | arch: "x86_64".to_string(), 72 | desktop_env: "gnome".to_string(), 73 | platform: "linux".to_string(), 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/snapshots/yolk__yolk__test__deployment_error.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/yolk.rs 3 | expression: render_error(yolk.sync_egg_deployment(&egg).unwrap_err()) 4 | --- 5 | × Failed to deploy egg bar 6 | 7 | Error: 8 | × Failed to deploy /tmp/[tmp-dir]/yolk/eggs/bar/[filename] 9 | ├─▶ Failed to create symlink at /tmp/[tmp-dir]/[filename] -> /tmp/[tmp-dir]/yolk/eggs/bar/[filename] 10 | ╰─▶ File exists (os error 17) 11 | 12 | Error: 13 | × Failed to deploy /tmp/[tmp-dir]/yolk/eggs/bar/[filename] 14 | ├─▶ Failed to create symlink at /tmp/[tmp-dir]/[filename] -> /tmp/[tmp-dir]/yolk/eggs/bar/[filename] 15 | ╰─▶ File exists (os error 17) 16 | -------------------------------------------------------------------------------- /src/snapshots/yolk__yolk__test__syntax_error_in_yolk_rhai.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/yolk.rs 3 | expression: "yolk.prepare_eval_ctx_for_templates(crate::yolk::EvalMode::Local).map_err(|e|\ncreate_regex(r\"\\[.*.rhai:\\d+:\\d+]\").unwrap().replace(&render_report(e),\n\"[no-filename-in-test]\").to_string()).unwrap_err()" 4 | --- 5 | × Failed to execute yolk.rhai 6 | ╰─▶ Syntax error: Expecting ')' to close the parameters list of function 'foo' (line 2, position 1) 7 | ╭─[no-filename-in-test] 8 | 1 │ fn foo( 9 | · ┬ 10 | · ╰── here 11 | ╰──── 12 | -------------------------------------------------------------------------------- /src/templating/comment_style.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | 3 | pub const COMMENT_START: &str = " "; 4 | 5 | use crate::util::create_regex; 6 | 7 | use super::element::{Block, Element}; 8 | 9 | const PREFIX_COMMENT_SYMBOLS: [&str; 8] = ["//", "#", "--", ";", "%", "\"", "'", "rem"]; 10 | const CIRCUMFIX_COMMENT_SYMBOLS: [(&str, &str); 5] = [ 11 | ("/*", "*/"), 12 | (""), 13 | ("{-", "-}"), 14 | ("--[[", "]]"), 15 | ("(", ")"), 16 | ]; 17 | 18 | #[derive(Debug, Clone, Eq, PartialEq, arbitrary::Arbitrary)] 19 | pub enum CommentStyle { 20 | Prefix(String), 21 | Circumfix(String, String), 22 | } 23 | 24 | impl Default for CommentStyle { 25 | fn default() -> Self { 26 | CommentStyle::Prefix("#".to_string()) 27 | } 28 | } 29 | 30 | // TODO: Technically, a lot of this could already be done in the parser 31 | // We could parse the indent, and yolk-comment-start and end stuff during the main parsing phase already 32 | // That would allow us to avoid having to regex here, which would potentially be noticably more performant, 33 | // and maybe even more elegant. 34 | // However, that would require the parser to be a lot more complex, 35 | // as well as requiring us to add a lot more information into the parsed data structure. 36 | // The performance benefits are likely more than negligible, so not worth it for now. 37 | 38 | impl CommentStyle { 39 | /// Try to infer the CommentStyle from a line 40 | pub fn try_infer(element: &Element<'_>) -> Option { 41 | let line = match &element { 42 | Element::Inline { line, .. } 43 | | Element::NextLine { 44 | tagged_line: line, .. 45 | } 46 | | Element::MultiLine { 47 | block: Block { 48 | tagged_line: line, .. 49 | }, 50 | .. 51 | } => line, 52 | Element::Conditional { blocks, .. } => &blocks.first()?.tagged_line, 53 | Element::Plain(_) => return None, 54 | }; 55 | let (left, right) = (line.left, line.right); 56 | 57 | for (prefix, postfix) in &CIRCUMFIX_COMMENT_SYMBOLS { 58 | if left.trim_end().ends_with(prefix) && right.trim_start().starts_with(postfix) { 59 | return Some(CommentStyle::Circumfix( 60 | prefix.to_string(), 61 | postfix.to_string(), 62 | )); 63 | } 64 | } 65 | for prefix in &PREFIX_COMMENT_SYMBOLS { 66 | if left.trim_end().ends_with(prefix) { 67 | return Some(CommentStyle::Prefix(prefix.to_string())); 68 | } 69 | } 70 | None 71 | } 72 | 73 | pub fn try_infer_from_elements(elements: &[Element<'_>]) -> Option { 74 | for e in elements { 75 | if let Some(style) = Self::try_infer(e) { 76 | return Some(style); 77 | } 78 | } 79 | None 80 | } 81 | 82 | #[allow(unused)] 83 | pub fn prefix(left: &str) -> Self { 84 | CommentStyle::Prefix(left.to_string()) 85 | } 86 | #[allow(unused)] 87 | pub fn circumfix(left: &str, right: &str) -> Self { 88 | CommentStyle::Circumfix(left.to_string(), right.to_string()) 89 | } 90 | pub fn left(&self) -> &str { 91 | match self { 92 | CommentStyle::Prefix(left) => left, 93 | CommentStyle::Circumfix(left, _) => left, 94 | } 95 | } 96 | 97 | pub fn toggle_string(&self, s: &str, enable: bool) -> String { 98 | // TODO: Technically this could return Cow<'_, str> instead, but that's hard 99 | s.split('\n') 100 | .map(|x| self.toggle_line(x, enable)) 101 | .collect::>() 102 | .join("\n") 103 | } 104 | 105 | pub fn toggle_line<'a>(&self, line: &'a str, enable: bool) -> Cow<'a, str> { 106 | if enable { 107 | self.enable_line(line) 108 | } else { 109 | self.disable_line(line) 110 | } 111 | } 112 | 113 | pub fn enable_line<'a>(&self, line: &'a str) -> Cow<'a, str> { 114 | let left = self.left(); 115 | let re = create_regex(format!( 116 | "{}{}", 117 | regex::escape(left), 118 | regex::escape(COMMENT_START) 119 | )) 120 | .unwrap(); 121 | let left_done = re.replace_all(line, ""); 122 | if let CommentStyle::Circumfix(_, right) = self { 123 | let re_right = create_regex(regex::escape(right)).unwrap(); 124 | Cow::Owned(re_right.replace_all(&left_done, "").to_string()) 125 | } else { 126 | left_done 127 | } 128 | } 129 | 130 | pub fn is_disabled(&self, line: &str) -> bool { 131 | let re = match self { 132 | CommentStyle::Prefix(left) => { 133 | format!("^.*{}{}", regex::escape(left), regex::escape(COMMENT_START)) 134 | } 135 | CommentStyle::Circumfix(left, right) => format!( 136 | "^.*{}{}.*{}", 137 | regex::escape(left), 138 | regex::escape(COMMENT_START), 139 | regex::escape(right) 140 | ), 141 | }; 142 | create_regex(re).unwrap().is_match(line) 143 | } 144 | 145 | pub fn disable_line<'a>(&self, line: &'a str) -> Cow<'a, str> { 146 | if self.is_disabled(line) || line.trim().is_empty() { 147 | return line.into(); 148 | } 149 | let left = self.left(); 150 | let re = create_regex("^(\\s*)(.*)$").unwrap(); 151 | let (indent, remaining_line) = re 152 | .captures(line) 153 | .and_then(|x| (x.get(1).zip(x.get(2)))) 154 | .map(|(a, b)| (a.as_str(), b.as_str())) 155 | .unwrap_or_default(); 156 | let right = match self { 157 | CommentStyle::Prefix(_) => "".to_string(), 158 | CommentStyle::Circumfix(_, right) => right.to_string(), 159 | }; 160 | format!("{indent}{left}{COMMENT_START}{remaining_line}{right}",).into() 161 | } 162 | } 163 | 164 | #[cfg(test)] 165 | mod test { 166 | use crate::util::test_util::TestResult; 167 | 168 | use crate::templating::element::Element; 169 | 170 | use super::CommentStyle; 171 | 172 | use pretty_assertions::assert_eq; 173 | use rstest::rstest; 174 | 175 | #[rstest] 176 | #[case(" foo", " # foo", CommentStyle::prefix("#"))] 177 | #[case(" foo", " /* foo*/", CommentStyle::circumfix("/*", "*/"))] 178 | #[case("foo", "# foo", CommentStyle::prefix("#"))] 179 | #[case("foo", "/* foo*/", CommentStyle::circumfix("/*", "*/"))] 180 | fn test_disable_enable_roundtrip( 181 | #[case] start: &str, 182 | #[case] expected_disabled: &str, 183 | #[case] comment_style: CommentStyle, 184 | ) { 185 | let disabled = comment_style.disable_line(start); 186 | let enabled = comment_style.enable_line(disabled.as_ref()); 187 | assert_eq!(expected_disabled, disabled); 188 | assert_eq!(start, enabled); 189 | } 190 | 191 | #[rstest] 192 | #[case(CommentStyle::prefix("#"), "\tfoo")] 193 | #[case(CommentStyle::prefix("#"), "foo ")] 194 | #[case(CommentStyle::circumfix("/*", "*/"), " foo ")] 195 | fn test_enable_idempotent(#[case] comment_style: CommentStyle, #[case] line: &str) { 196 | let enabled = comment_style.enable_line(line); 197 | let enabled_again = comment_style.enable_line(enabled.as_ref()); 198 | assert_eq!(enabled, enabled_again); 199 | } 200 | 201 | #[rstest] 202 | #[case(CommentStyle::prefix("#"), "\tfoo")] 203 | #[case(CommentStyle::prefix("#"), "foo ")] 204 | #[case(CommentStyle::circumfix("/*", "*/"), " foo ")] 205 | fn test_disable_idempotent(#[case] comment_style: CommentStyle, #[case] line: &str) { 206 | let disabled = comment_style.disable_line(line); 207 | let disabled_again = comment_style.disable_line(disabled.as_ref()); 208 | assert_eq!(disabled, disabled_again); 209 | } 210 | 211 | #[rstest] 212 | #[case("# {< foo >}", Some(CommentStyle::prefix("#")))] 213 | #[case("/* {< foo >} */", Some(CommentStyle::circumfix("/*", "*/")))] 214 | fn test_infer_comment_syntax( 215 | #[case] input: &str, 216 | #[case] expected: Option, 217 | ) -> TestResult { 218 | assert_eq!( 219 | CommentStyle::try_infer(&Element::try_from_str(input)?), 220 | expected 221 | ); 222 | Ok(()) 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /src/templating/document.rs: -------------------------------------------------------------------------------- 1 | use crate::script::eval_ctx::EvalCtx; 2 | 3 | use super::{ 4 | comment_style::CommentStyle, 5 | element::{self, render_elements}, 6 | parser, 7 | }; 8 | 9 | use miette::{NamedSource, Result}; 10 | 11 | #[derive(Debug, arbitrary::Arbitrary)] 12 | pub struct Document<'a> { 13 | comment_style: CommentStyle, 14 | elements: Vec>, 15 | source: &'a str, 16 | source_name: String, 17 | } 18 | 19 | impl<'a> Document<'a> { 20 | pub fn render(&self, eval_ctx: &mut EvalCtx) -> Result { 21 | let output = render_elements(&self.comment_style, eval_ctx, &self.elements) 22 | .map_err(|e| e.into_report(&self.source_name, self.source))?; 23 | Ok(output) 24 | } 25 | 26 | #[cfg(test)] 27 | pub fn parse_string(s: &'a str) -> Result { 28 | Self::parse_string_named("unnamed", s) 29 | } 30 | 31 | pub fn parse_string_named(name: &str, s: &'a str) -> Result { 32 | let elements = parser::parse_document(s).map_err(|e| { 33 | miette::Report::from(e).with_source_code(NamedSource::new(name, s.to_string())) 34 | })?; 35 | let comment_style = CommentStyle::try_infer_from_elements(&elements).unwrap_or_default(); 36 | Ok(Self { 37 | elements, 38 | comment_style, 39 | source: s, 40 | source_name: name.to_string(), 41 | }) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/templating/element.rs: -------------------------------------------------------------------------------- 1 | use crate::script::eval_ctx::EvalCtx; 2 | use miette::Result; 3 | 4 | use super::{comment_style::CommentStyle, error::TemplateError, parser::Sp}; 5 | 6 | /// A single, full line with a tag in it. Contains the span of the entire line. 7 | #[derive(Debug, Eq, PartialEq, arbitrary::Arbitrary)] 8 | pub struct TaggedLine<'a> { 9 | pub left: &'a str, 10 | pub tag: &'a str, 11 | pub right: &'a str, 12 | pub full_line: Sp<&'a str>, 13 | } 14 | 15 | /// The starting line and body of a block, such as a multiline tag or part of a conditional. 16 | /// 17 | /// `Expr` should either be `Sp<&'a str>` or `()`. 18 | #[derive(Debug, Eq, PartialEq, arbitrary::Arbitrary)] 19 | pub struct Block<'a, Expr = Sp<&'a str>> { 20 | /// The full line including the tag 21 | pub tagged_line: TaggedLine<'a>, 22 | pub expr: Expr, 23 | pub body: Vec>, 24 | } 25 | 26 | impl<'a, Expr> Block<'a, Expr> { 27 | pub fn map_expr(self, f: impl FnOnce(Expr) -> T) -> Block<'a, T> { 28 | Block { 29 | tagged_line: self.tagged_line, 30 | expr: f(self.expr), 31 | body: self.body, 32 | } 33 | } 34 | } 35 | 36 | #[derive(Debug, Eq, PartialEq, arbitrary::Arbitrary)] 37 | pub enum Element<'a> { 38 | Plain(Sp<&'a str>), 39 | Inline { 40 | /// The full line including the tag 41 | line: TaggedLine<'a>, 42 | expr: Sp<&'a str>, 43 | is_if: bool, 44 | }, 45 | NextLine { 46 | /// The full line including the tag 47 | tagged_line: TaggedLine<'a>, 48 | expr: Sp<&'a str>, 49 | next_line: Sp<&'a str>, 50 | is_if: bool, 51 | full_span: Sp<&'a str>, 52 | }, 53 | MultiLine { 54 | block: Block<'a, Sp<&'a str>>, 55 | end: TaggedLine<'a>, 56 | full_span: Sp<&'a str>, 57 | }, 58 | Conditional { 59 | blocks: Vec>>, 60 | else_block: Option>, 61 | end: TaggedLine<'a>, 62 | full_span: Sp<&'a str>, 63 | }, 64 | } 65 | 66 | impl<'a> Element<'a> { 67 | #[allow(unused)] 68 | pub fn try_from_str(s: &'a str) -> Result { 69 | use crate::templating::parser; 70 | use miette::IntoDiagnostic as _; 71 | parser::parse_element(s).into_diagnostic() 72 | } 73 | 74 | pub fn full_span(&self) -> &Sp<&str> { 75 | match self { 76 | Element::Plain(sp) => &sp, 77 | Element::Inline { line, .. } => &line.full_line, 78 | Element::NextLine { full_span, .. } => full_span, 79 | Element::MultiLine { full_span, .. } => full_span, 80 | Element::Conditional { full_span, .. } => full_span, 81 | } 82 | } 83 | 84 | pub fn render( 85 | &self, 86 | comment_style: &CommentStyle, 87 | eval_ctx: &mut EvalCtx, 88 | ) -> Result { 89 | match self { 90 | Element::Plain(s) => Ok(s.as_str().to_string()), 91 | Element::Inline { line, expr, is_if } => match is_if { 92 | true => { 93 | let eval_result = eval_ctx 94 | .eval_rhai::(expr.as_str()) 95 | .map_err(|e| TemplateError::from_rhai(e, expr.range()))?; 96 | Ok(comment_style.toggle_string(line.full_line.as_str(), eval_result)) 97 | } 98 | false => Ok(format!( 99 | "{}{}{}", 100 | run_transformation_expr(eval_ctx, line.left, expr)?, 101 | line.tag, 102 | line.right 103 | )), 104 | }, 105 | Element::NextLine { 106 | tagged_line: line, 107 | expr, 108 | next_line, 109 | is_if, 110 | .. 111 | } => match is_if { 112 | true => Ok(format!( 113 | "{}{}", 114 | line.full_line.as_str(), 115 | &comment_style.toggle_string( 116 | next_line.as_str(), 117 | eval_ctx 118 | .eval_rhai::(expr.as_str()) 119 | .map_err(|e| TemplateError::from_rhai(e, expr.range()))? 120 | ) 121 | )), 122 | false => Ok(format!( 123 | "{}{}", 124 | line.full_line.as_str(), 125 | run_transformation_expr(eval_ctx, next_line.as_str(), expr)? 126 | )), 127 | }, 128 | Element::MultiLine { block, end, .. } => { 129 | let rendered_body = render_elements(comment_style, eval_ctx, &block.body)?; 130 | Ok(format!( 131 | "{}{}{}", 132 | block.tagged_line.full_line.as_str(), 133 | &run_transformation_expr(eval_ctx, &rendered_body, &block.expr)?, 134 | end.full_line.as_str(), 135 | )) 136 | } 137 | Element::Conditional { 138 | blocks, 139 | else_block, 140 | end, 141 | .. 142 | } => { 143 | let mut output = String::new(); 144 | let mut had_true = false; 145 | for block in blocks { 146 | // If we've already had a true block, we want to return false for every other one. 147 | // If we haven't, and there's an expression, evaluate it. 148 | // If there isn't, we're on the else block, which should be true iff we haven't had a true block yet. 149 | let expr_true = !had_true 150 | && eval_ctx 151 | .eval_rhai::(block.expr.as_str()) 152 | .map_err(|e| TemplateError::from_rhai(e, block.expr.range()))?; 153 | had_true = had_true || expr_true; 154 | 155 | let rendered_body = if expr_true { 156 | render_elements(comment_style, eval_ctx, &block.body)? 157 | } else { 158 | render_no_eval(&block.body) 159 | }; 160 | output.push_str(block.tagged_line.full_line.as_str()); 161 | output.push_str(&comment_style.toggle_string(&rendered_body, expr_true)); 162 | } 163 | if let Some(block) = else_block { 164 | let expr_true = !had_true; 165 | let rendered_body = render_elements(comment_style, eval_ctx, &block.body)?; 166 | output.push_str(block.tagged_line.full_line.as_str()); 167 | output.push_str(&comment_style.toggle_string(&rendered_body, expr_true)); 168 | } 169 | output.push_str(end.full_line.as_str()); 170 | Ok(output) 171 | } 172 | } 173 | } 174 | } 175 | 176 | pub fn render_elements( 177 | comment_style: &CommentStyle, 178 | eval_ctx: &mut EvalCtx, 179 | elements: &[Element<'_>], 180 | ) -> Result { 181 | let mut errs = Vec::new(); 182 | let mut output = String::new(); 183 | for element in elements { 184 | match element.render(comment_style, eval_ctx) { 185 | Ok(rendered) => output.push_str(&rendered), 186 | Err(e) => errs.push(e), 187 | } 188 | } 189 | if errs.is_empty() { 190 | Ok(output) 191 | } else { 192 | Err(TemplateError::Multiple(errs)) 193 | } 194 | } 195 | 196 | pub fn render_no_eval(elements: &[Element<'_>]) -> String { 197 | elements.iter().map(|x| x.full_span().as_str()).collect() 198 | } 199 | 200 | fn run_transformation_expr( 201 | eval_ctx: &mut EvalCtx, 202 | text: &str, 203 | expr: &Sp<&str>, 204 | ) -> Result { 205 | let result = eval_ctx 206 | .eval_text_transformation(text, expr.as_str()) 207 | .map_err(|e| TemplateError::from_rhai(e, expr.range()))?; 208 | let second_pass = eval_ctx 209 | .eval_text_transformation(&result, expr.as_str()) 210 | .map_err(|e| TemplateError::from_rhai(e, expr.range()))?; 211 | if result != second_pass { 212 | cov_mark::hit!(refuse_nonidempodent_transformation); 213 | println!( 214 | "Warning: Refusing to apply transformation that is not idempodent: `{}`", 215 | expr.as_str() 216 | ); 217 | Ok(text.to_string()) 218 | } else { 219 | Ok(result) 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /src/templating/error.rs: -------------------------------------------------------------------------------- 1 | #![allow(deprecated)] 2 | use std::sync::Arc; 3 | 4 | use miette::{Diagnostic, NamedSource, Severity, SourceSpan}; 5 | use winnow::{ 6 | error::{AddContext, ErrorKind, FromRecoverableError, ParserError}, 7 | stream::{Location, Stream}, 8 | }; 9 | 10 | use crate::script::rhai_error::RhaiError; 11 | 12 | #[derive(Debug, thiserror::Error, miette::Diagnostic)] 13 | pub enum TemplateError { 14 | #[error("Error evaluating rhai")] 15 | Rhai { 16 | #[source] 17 | error: RhaiError, 18 | #[label(primary, "here")] 19 | error_span: Option, 20 | }, 21 | #[error("Failed to evaluate template")] 22 | Multiple(#[related] Vec), 23 | } 24 | 25 | impl TemplateError { 26 | pub fn from_rhai(error: RhaiError, expr_span: impl Into) -> Self { 27 | match error { 28 | RhaiError::SourceError { ref span, .. } => { 29 | let expr_span = expr_span.into(); 30 | let start = expr_span.offset() + span.start; 31 | let end = expr_span.offset() + span.end; 32 | Self::Rhai { 33 | error, 34 | error_span: Some((start..end).into()), 35 | } 36 | } 37 | error => Self::Rhai { 38 | error, 39 | error_span: None, 40 | }, 41 | } 42 | } 43 | 44 | /// Convert this error into a [`miette::Report`] with the given name and source code attached. 45 | pub fn into_report(self, name: impl ToString, source: impl ToString) -> miette::Report { 46 | miette::Report::from(self) 47 | .with_source_code(NamedSource::new(name.to_string(), source.to_string())) 48 | } 49 | } 50 | 51 | #[derive(Debug, Diagnostic, Clone, Eq, PartialEq, thiserror::Error)] 52 | #[error("Failed to parse yolk template file")] 53 | pub struct YolkParseFailure { 54 | #[source_code] 55 | pub input: Arc>, 56 | #[related] 57 | pub diagnostics: Vec, 58 | } 59 | 60 | impl YolkParseFailure { 61 | pub fn from_errs(errs: Vec, input: &str) -> YolkParseFailure { 62 | let src = Arc::new(NamedSource::new("file", input.to_string())); 63 | YolkParseFailure { 64 | input: src.clone(), 65 | diagnostics: errs 66 | .into_iter() 67 | .map(|e| YolkParseDiagnostic { 68 | message: e.message, 69 | input: src.clone(), 70 | span: e.span.unwrap_or_else(|| (0usize..0usize).into()), 71 | label: e.label, 72 | help: e.help, 73 | severity: Severity::Error, 74 | }) 75 | .collect(), 76 | } 77 | } 78 | } 79 | 80 | #[derive(Debug, Diagnostic, Clone, Eq, PartialEq, thiserror::Error)] 81 | #[error("{}", message.unwrap_or_else(|| "An unspecified parse error occurred."))] 82 | pub struct YolkParseDiagnostic { 83 | #[source_code] 84 | pub input: Arc>, 85 | 86 | /// Offset in chars of the error. 87 | #[label("{}", label.unwrap_or_else(|| "here"))] 88 | pub span: SourceSpan, 89 | 90 | /// Message 91 | pub message: Option<&'static str>, 92 | 93 | /// Label text for this span. Defaults to `"here"`. 94 | pub label: Option<&'static str>, 95 | 96 | /// Suggestion for fixing the parser error. 97 | #[help] 98 | pub help: Option<&'static str>, 99 | 100 | /// Severity level for the Diagnostic. 101 | #[diagnostic(severity)] 102 | pub severity: miette::Severity, 103 | } 104 | 105 | #[derive(Debug, Clone, Eq, PartialEq)] 106 | pub struct YolkParseError { 107 | pub message: Option<&'static str>, 108 | pub span: Option, 109 | pub label: Option<&'static str>, 110 | pub help: Option<&'static str>, 111 | } 112 | 113 | impl ParserError for YolkParseError { 114 | fn from_error_kind(_input: &I, _kind: ErrorKind) -> Self { 115 | Self { 116 | span: None, 117 | label: None, 118 | help: None, 119 | message: None, 120 | } 121 | } 122 | 123 | fn append( 124 | self, 125 | _input: &I, 126 | _token_start: &::Checkpoint, 127 | _kind: ErrorKind, 128 | ) -> Self { 129 | self 130 | } 131 | } 132 | 133 | impl AddContext for YolkParseError { 134 | fn add_context( 135 | mut self, 136 | _input: &I, 137 | _token_start: &::Checkpoint, 138 | ctx: YolkParseContext, 139 | ) -> Self { 140 | self.message = ctx.message.or(self.message); 141 | self.label = ctx.label.or(self.label); 142 | self.help = ctx.help.or(self.help); 143 | self 144 | } 145 | } 146 | 147 | impl FromRecoverableError for YolkParseError { 148 | #[inline] 149 | fn from_recoverable_error( 150 | token_start: &::Checkpoint, 151 | _err_start: &::Checkpoint, 152 | input: &I, 153 | mut e: Self, 154 | ) -> Self { 155 | e.span = e 156 | .span 157 | .or_else(|| Some(span_from_checkpoint(input, token_start))); 158 | e 159 | } 160 | } 161 | 162 | impl FromRecoverableError for YolkParseError { 163 | #[inline] 164 | fn from_recoverable_error( 165 | token_start: &::Checkpoint, 166 | _err_start: &::Checkpoint, 167 | input: &I, 168 | e: YolkParseContext, 169 | ) -> Self { 170 | YolkParseError { 171 | span: Some((input.offset_from(token_start).saturating_sub(1)..input.location()).into()), 172 | label: e.label, 173 | help: e.help, 174 | message: e.message, 175 | } 176 | } 177 | } 178 | 179 | #[derive(Debug, Clone, Default, Eq, PartialEq)] 180 | pub(super) struct YolkParseContext { 181 | message: Option<&'static str>, 182 | label: Option<&'static str>, 183 | help: Option<&'static str>, 184 | } 185 | 186 | impl YolkParseContext { 187 | pub(super) fn msg(mut self, txt: &'static str) -> Self { 188 | self.message = Some(txt); 189 | self 190 | } 191 | 192 | pub(super) fn lbl(mut self, txt: &'static str) -> Self { 193 | self.label = Some(txt); 194 | self 195 | } 196 | 197 | pub(super) fn hlp(mut self, txt: &'static str) -> Self { 198 | self.help = Some(txt); 199 | self 200 | } 201 | } 202 | 203 | pub(super) fn cx() -> YolkParseContext { 204 | Default::default() 205 | } 206 | 207 | fn span_from_checkpoint( 208 | input: &I, 209 | start: &::Checkpoint, 210 | ) -> SourceSpan { 211 | let offset = input.offset_from(start); 212 | ((input.location() - offset)..input.location()).into() 213 | } 214 | -------------------------------------------------------------------------------- /src/templating/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod comment_style; 2 | pub mod document; 3 | pub mod element; 4 | pub mod error; 5 | mod parser; 6 | 7 | #[cfg(test)] 8 | mod test; 9 | -------------------------------------------------------------------------------- /src/templating/snapshots/yolk__templating__parser__p_text_segment-2.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/templating/parser.rs 3 | expression: "p_text_segment.parse_peek(new_input(\"foo\"))?" 4 | snapshot_kind: text 5 | --- 6 | ( 7 | "", 8 | ( 9 | "", 10 | "foo", 11 | ), 12 | ) 13 | -------------------------------------------------------------------------------- /src/templating/snapshots/yolk__templating__parser__p_text_segment-3.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/templating/parser.rs 3 | expression: "p_text_segment.parse_peek(new_input(\"{< bar >}\"))" 4 | snapshot_kind: text 5 | --- 6 | Err( 7 | Backtrack( 8 | YolkParseError { 9 | context: None, 10 | span: None, 11 | label: None, 12 | help: None, 13 | kind: None, 14 | }, 15 | ), 16 | ) 17 | -------------------------------------------------------------------------------- /src/templating/snapshots/yolk__templating__parser__p_text_segment.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/templating/parser.rs 3 | expression: "p_text_segment.parse_peek(new_input(\"foo {% bar %} baz\"))?" 4 | snapshot_kind: text 5 | --- 6 | ( 7 | "{% bar %} baz", 8 | ( 9 | "{%", 10 | "foo ", 11 | ), 12 | ) 13 | -------------------------------------------------------------------------------- /src/templating/snapshots/yolk__templating__parser__test__blank_lines_get_combined.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/templating/parser.rs 3 | expression: "parse_document(\"\\n\\n\\n\\n\")" 4 | --- 5 | Ok( 6 | [ 7 | Plain( 8 | [0..4]"\n\n\n\n", 9 | ), 10 | ], 11 | ) 12 | -------------------------------------------------------------------------------- /src/templating/snapshots/yolk__templating__parser__test__blanklines_around_tag-2.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/templating/parser.rs 3 | expression: "parse_document(\"a\\n\\n{%if a%}\\n{%end%}\\n\\na\")" 4 | --- 5 | Ok( 6 | [ 7 | Plain( 8 | [0..3]"a\n\n", 9 | ), 10 | Conditional { 11 | blocks: [ 12 | Block { 13 | tagged_line: TaggedLine { 14 | left: "", 15 | tag: "{%if a%}", 16 | right: "\n", 17 | full_line: [3..12]"{%if a%}\n", 18 | }, 19 | expr: [5..9]"a", 20 | body: [], 21 | }, 22 | ], 23 | else_block: None, 24 | end: TaggedLine { 25 | left: "", 26 | tag: "{%end%}", 27 | right: "\n", 28 | full_line: [12..20]"{%end%}\n", 29 | }, 30 | full_span: [3..20]"{%if a%}\n{%end%}\n", 31 | }, 32 | Plain( 33 | [20..22]"\na", 34 | ), 35 | ], 36 | ) 37 | -------------------------------------------------------------------------------- /src/templating/snapshots/yolk__templating__parser__test__blanklines_around_tag.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/templating/parser.rs 3 | expression: "parse_document(\"a\\n\\n{%a%}\\n{%end%}\\n\\na\")" 4 | --- 5 | Ok( 6 | [ 7 | Plain( 8 | [0..3]"a\n\n", 9 | ), 10 | MultiLine { 11 | block: Block { 12 | tagged_line: TaggedLine { 13 | left: "", 14 | tag: "{%a%}", 15 | right: "\n", 16 | full_line: [3..9]"{%a%}\n", 17 | }, 18 | expr: [5..6]"a", 19 | body: [], 20 | }, 21 | end: TaggedLine { 22 | left: "", 23 | tag: "{%end%}", 24 | right: "\n", 25 | full_line: [9..17]"{%end%}\n", 26 | }, 27 | full_span: [3..17]"{%a%}\n{%end%}\n", 28 | }, 29 | Plain( 30 | [17..19]"\na", 31 | ), 32 | ], 33 | ) 34 | -------------------------------------------------------------------------------- /src/templating/snapshots/yolk__templating__parser__test__conditional.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/templating/parser.rs 3 | expression: "p_conditional_element(&mut\nnew_input(indoc::indoc!\n{\n r#\"\n // {% if a %}\n a\n b\n // {% elif b %}\n // {% elif c %}\n // {% else %}\n c\n // {% end %}\n \"#\n}))" 4 | --- 5 | Ok( 6 | Conditional { 7 | blocks: [ 8 | Block { 9 | tagged_line: TaggedLine { 10 | left: "// ", 11 | tag: "{% if a %}", 12 | right: "\n", 13 | full_line: [0..14]"// {% if a %}\n", 14 | }, 15 | expr: [6..11]"a ", 16 | body: [ 17 | Plain( 18 | [14..18]"a\nb\n", 19 | ), 20 | ], 21 | }, 22 | Block { 23 | tagged_line: TaggedLine { 24 | left: "// ", 25 | tag: "{% elif b %}", 26 | right: "\n", 27 | full_line: [18..34]"// {% elif b %}\n", 28 | }, 29 | expr: [24..31]"b ", 30 | body: [], 31 | }, 32 | Block { 33 | tagged_line: TaggedLine { 34 | left: "// ", 35 | tag: "{% elif c %}", 36 | right: "\n", 37 | full_line: [34..50]"// {% elif c %}\n", 38 | }, 39 | expr: [40..47]"c ", 40 | body: [], 41 | }, 42 | ], 43 | else_block: Some( 44 | Block { 45 | tagged_line: TaggedLine { 46 | left: "// ", 47 | tag: "{% else %}", 48 | right: "\n", 49 | full_line: [50..64]"// {% else %}\n", 50 | }, 51 | expr: (), 52 | body: [ 53 | Plain( 54 | [64..66]"c\n", 55 | ), 56 | ], 57 | }, 58 | ), 59 | end: TaggedLine { 60 | left: "// ", 61 | tag: "{% end %}", 62 | right: "\n", 63 | full_line: [66..79]"// {% end %}\n", 64 | }, 65 | full_span: [0..79]"// {% if a %}\na\nb\n// {% elif b %}\n// {% elif c %}\n// {% else %}\nc\n// {% end %}\n", 66 | }, 67 | ) 68 | -------------------------------------------------------------------------------- /src/templating/snapshots/yolk__templating__parser__test__error_empty_tag.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/templating/parser.rs 3 | expression: "render_error(parse_document(\"{%%}\").unwrap_err())" 4 | snapshot_kind: text 5 | --- 6 | × Failed to parse yolk template file 7 | 8 | Error: 9 | × Failed to parse tag 10 | ╭─[file:1:1] 11 | 1 │ {%%} 12 | · ─┬ 13 | · ╰── tag 14 | ╰──── 15 | -------------------------------------------------------------------------------- /src/templating/snapshots/yolk__templating__parser__test__error_if_without_expression.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/templating/parser.rs 3 | expression: "render_error(parse_document(\"{}\").unwrap_err())" 4 | snapshot_kind: text 5 | --- 6 | × Failed to parse yolk template file 7 | 8 | Error: 9 | × Failed to parse tag 10 | ╭─[file:1:1] 11 | 1 │ {} 12 | · ──┬─ 13 | · ╰── tag 14 | ╰──── 15 | -------------------------------------------------------------------------------- /src/templating/snapshots/yolk__templating__parser__test__error_incomplete_multiline.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/templating/parser.rs 3 | expression: "render_error(parse_document(\"{%f%}\").unwrap_err())" 4 | snapshot_kind: text 5 | --- 6 | × Failed to parse yolk template file 7 | 8 | Error: 9 | × Expected newline 10 | ╭─[file:1:1] 11 | 1 │ {%f%} 12 | · ──┬── 13 | · ╰── here 14 | ╰──── 15 | -------------------------------------------------------------------------------- /src/templating/snapshots/yolk__templating__parser__test__error_incomplete_multiline_long.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/templating/parser.rs 3 | expression: "render_error(parse_document(\"\\nfoo\\n{%f%}\\nbar\\n\").unwrap_err())" 4 | snapshot_kind: text 5 | --- 6 | × Failed to parse yolk template file 7 | 8 | Error: 9 | × Expected block to end here 10 | ╭─[file:1:1] 11 | 1 │ ╭─▶ 12 | 2 │ │ foo 13 | 3 │ │ {%f%} 14 | 4 │ ├─▶ bar 15 | · ╰──── block end 16 | ╰──── 17 | help: Did you forget an `{% end %}` tag? 18 | -------------------------------------------------------------------------------- /src/templating/snapshots/yolk__templating__parser__test__error_incomplete_nextline.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/templating/parser.rs 3 | expression: "render_error(parse_document(\"{#f#}\").unwrap_err())" 4 | snapshot_kind: text 5 | --- 6 | × Failed to parse yolk template file 7 | 8 | Error: 9 | × Expected newline 10 | ╭─[file:1:1] 11 | 1 │ {#f#} 12 | · ──┬── 13 | · ╰── here 14 | ╰──── 15 | -------------------------------------------------------------------------------- /src/templating/snapshots/yolk__templating__parser__test__error_multiline_with_else.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/templating/parser.rs 3 | expression: "render_error(parse_document(\"{%f%}\\n{%else%}\\n{%end%}\").unwrap_err())" 4 | snapshot_kind: text 5 | --- 6 | × Failed to parse yolk template file 7 | 8 | Error: 9 | × Expected block to end here 10 | ╭─[file:1:1] 11 | 1 │ {%f%} 12 | · ───┬── 13 | · ╰── block end 14 | 2 │ {%else%} 15 | 3 │ {%end%} 16 | ╰──── 17 | help: Did you forget an `{% end %}` tag? 18 | -------------------------------------------------------------------------------- /src/templating/snapshots/yolk__templating__parser__test__error_newline_in_tag.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/templating/parser.rs 3 | expression: "render_error(parse_document(\"{}\").unwrap_err())" 4 | snapshot_kind: text 5 | --- 6 | × Failed to parse yolk template file 7 | 8 | Error: 9 | × Failed to parse tag 10 | ╭─[file:1:1] 11 | 1 │ {} 15 | ╰──── 16 | help: Line endings are forbidden within tags 17 | -------------------------------------------------------------------------------- /src/templating/snapshots/yolk__templating__parser__test__incomplete_next_line.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/templating/parser.rs 3 | expression: "parse_document(\"{#f#}\")" 4 | snapshot_kind: text 5 | --- 6 | Err( 7 | YolkParseFailure { 8 | input: NamedSource { 9 | name: "file", 10 | source: "", 11 | language: None, 12 | , 13 | diagnostics: [ 14 | YolkParseDiagnostic { 15 | input: NamedSource { 16 | name: "file", 17 | source: "", 18 | language: None, 19 | , 20 | span: SourceSpan { 21 | offset: SourceOffset( 22 | 4, 23 | ), 24 | length: 1, 25 | }, 26 | label: None, 27 | help: None, 28 | severity: Error, 29 | kind: Context( 30 | "newline", 31 | ), 32 | }, 33 | ], 34 | }, 35 | ) 36 | -------------------------------------------------------------------------------- /src/templating/snapshots/yolk__templating__parser__test__inline_conditional_tag-2.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/templating/parser.rs 3 | expression: "p_inline_element.parse(new_input(\"foo /* {< iftest >} */\"))" 4 | snapshot_kind: text 5 | --- 6 | Ok( 7 | Inline { 8 | line: TaggedLine { 9 | left: "foo /* ", 10 | tag: "{< iftest >}", 11 | right: " */", 12 | full_line: [0..22]"foo /* {< iftest >} */", 13 | }, 14 | expr: [10..17]"iftest ", 15 | is_if: false, 16 | }, 17 | ) 18 | -------------------------------------------------------------------------------- /src/templating/snapshots/yolk__templating__parser__test__inline_conditional_tag.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/templating/parser.rs 3 | expression: "p_inline_element.parse(new_input(\"foo /* {< if test >} */\"))" 4 | snapshot_kind: text 5 | --- 6 | Ok( 7 | Inline { 8 | line: TaggedLine { 9 | left: "foo /* ", 10 | tag: "{< if test >}", 11 | right: " */", 12 | full_line: [0..23]"foo /* {< if test >} */", 13 | }, 14 | expr: [10..18]"test ", 15 | is_if: true, 16 | }, 17 | ) 18 | -------------------------------------------------------------------------------- /src/templating/snapshots/yolk__templating__parser__test__inline_tag.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/templating/parser.rs 3 | expression: "p_inline_element.parse(new_input(\"foo /* {< test >} */\"))" 4 | snapshot_kind: text 5 | --- 6 | Ok( 7 | Inline { 8 | line: TaggedLine { 9 | left: "foo /* ", 10 | tag: "{< test >}", 11 | right: " */", 12 | full_line: [0..20]"foo /* {< test >} */", 13 | }, 14 | expr: [10..15]"test ", 15 | is_if: false, 16 | }, 17 | ) 18 | -------------------------------------------------------------------------------- /src/templating/snapshots/yolk__templating__parser__test__multiline_block.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/templating/parser.rs 3 | expression: "p_multiline_element(&mut new_input(\"/* {% test %} */\\nfoo\\n/* {% end %} */\"))?" 4 | --- 5 | MultiLine { 6 | block: Block { 7 | tagged_line: TaggedLine { 8 | left: "/* ", 9 | tag: "{% test %}", 10 | right: " */\n", 11 | full_line: [0..17]"/* {% test %} */\n", 12 | }, 13 | expr: [6..11]"test ", 14 | body: [ 15 | Plain( 16 | [17..21]"foo\n", 17 | ), 18 | ], 19 | }, 20 | end: TaggedLine { 21 | left: "/* ", 22 | tag: "{% end %}", 23 | right: " */", 24 | full_line: [21..36]"/* {% end %} */", 25 | }, 26 | full_span: [0..36]"/* {% test %} */\nfoo\n/* {% end %} */", 27 | } 28 | -------------------------------------------------------------------------------- /src/templating/snapshots/yolk__templating__parser__test__nested_conditional.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/templating/parser.rs 3 | expression: "p_conditional_element(&mut\nnew_input(indoc::indoc!\n{\n r#\"\n // {% if foo %}\n // {% if foo %}\n // {% end %}\n // {% end %}\n \"#\n}))" 4 | --- 5 | Ok( 6 | Conditional { 7 | blocks: [ 8 | Block { 9 | tagged_line: TaggedLine { 10 | left: "// ", 11 | tag: "{% if foo %}", 12 | right: "\n", 13 | full_line: [0..16]"// {% if foo %}\n", 14 | }, 15 | expr: [6..13]"foo ", 16 | body: [ 17 | Conditional { 18 | blocks: [ 19 | Block { 20 | tagged_line: TaggedLine { 21 | left: "// ", 22 | tag: "{% if foo %}", 23 | right: "\n", 24 | full_line: [16..32]"// {% if foo %}\n", 25 | }, 26 | expr: [22..29]"foo ", 27 | body: [], 28 | }, 29 | ], 30 | else_block: None, 31 | end: TaggedLine { 32 | left: "// ", 33 | tag: "{% end %}", 34 | right: "\n", 35 | full_line: [32..45]"// {% end %}\n", 36 | }, 37 | full_span: [16..45]"// {% if foo %}\n// {% end %}\n", 38 | }, 39 | ], 40 | }, 41 | ], 42 | else_block: None, 43 | end: TaggedLine { 44 | left: "// ", 45 | tag: "{% end %}", 46 | right: "\n", 47 | full_line: [45..58]"// {% end %}\n", 48 | }, 49 | full_span: [0..58]"// {% if foo %}\n// {% if foo %}\n// {% end %}\n// {% end %}\n", 50 | }, 51 | ) 52 | -------------------------------------------------------------------------------- /src/templating/snapshots/yolk__templating__parser__test__newline_in_inline.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/templating/parser.rs 3 | expression: "parse_document(\"foo /* {< test\\ntest >} */\")" 4 | snapshot_kind: text 5 | --- 6 | Ok( 7 | [ 8 | Inline { 9 | line: TaggedLine { 10 | left: "foo /* ", 11 | tag: "{< test\ntest >}", 12 | right: " */", 13 | full_line: [0..25]"foo /* {< test\ntest >} */", 14 | }, 15 | expr: [10..20]"test\ntest ", 16 | is_if: false, 17 | }, 18 | ], 19 | ) 20 | -------------------------------------------------------------------------------- /src/templating/snapshots/yolk__templating__parser__test__newlines_in_multiline.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/templating/parser.rs 3 | expression: "parse_document(\"foo \\n/* \\n{#\\ntest\\ntest\\n#} */\\nbar\")" 4 | --- 5 | Ok( 6 | [ 7 | Plain( 8 | [0..9]"foo \n/* \n", 9 | ), 10 | NextLine { 11 | tagged_line: TaggedLine { 12 | left: "", 13 | tag: "{#\ntest\ntest\n#}", 14 | right: " */\n", 15 | full_line: [9..28]"{#\ntest\ntest\n#} */\n", 16 | }, 17 | expr: [12..22]"test\ntest\n", 18 | next_line: [28..31]"bar", 19 | is_if: false, 20 | full_span: [9..31]"{#\ntest\ntest\n#} */\nbar", 21 | }, 22 | ], 23 | ) 24 | -------------------------------------------------------------------------------- /src/templating/snapshots/yolk__templating__parser__test__nextline_tag.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/templating/parser.rs 3 | expression: "p_nextline_element(&mut new_input(\"/* {# x #} */\\nfoo\"))?" 4 | --- 5 | NextLine { 6 | tagged_line: TaggedLine { 7 | left: "/* ", 8 | tag: "{# x #}", 9 | right: " */\n", 10 | full_line: [0..14]"/* {# x #} */\n", 11 | }, 12 | expr: [6..8]"x ", 13 | next_line: [14..17]"foo", 14 | is_if: false, 15 | full_span: [0..17]"/* {# x #} */\nfoo", 16 | } 17 | -------------------------------------------------------------------------------- /src/templating/snapshots/yolk__templating__parser__test__nextline_tag_document.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/templating/parser.rs 3 | expression: "parse_document(indoc::indoc!\n{\n r#\"\n # {# replace_re(`'.*'`, `'{data.value}'`) #}\n value = 'foo'\n \"#\n})" 4 | --- 5 | Ok( 6 | [ 7 | NextLine { 8 | tagged_line: TaggedLine { 9 | left: "# ", 10 | tag: "{# replace_re(`'.*'`, `'{data.value}'`) #}", 11 | right: "\n", 12 | full_line: [0..45]"# {# replace_re(`'.*'`, `'{data.value}'`) #}\n", 13 | }, 14 | expr: [5..42]"replace_re(`'.*'`, `'{data.value}'`) ", 15 | next_line: [45..58]"value = 'foo'", 16 | is_if: false, 17 | full_span: [0..58]"# {# replace_re(`'.*'`, `'{data.value}'`) #}\nvalue = 'foo'", 18 | }, 19 | Plain( 20 | [58..59]"\n", 21 | ), 22 | ], 23 | ) 24 | -------------------------------------------------------------------------------- /src/templating/snapshots/yolk__templating__parser__test__parse_end.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/templating/parser.rs 3 | expression: "p_tag_line(\"{%\", \"end\", \"%}\", false).parse(new_input(\"a{% end %}b\"))" 4 | snapshot_kind: text 5 | --- 6 | Ok( 7 | ( 8 | TaggedLine { 9 | left: "a", 10 | tag: "{% end %}", 11 | right: "b", 12 | full_line: [0..11]"a{% end %}b", 13 | }, 14 | [4..7]"end", 15 | ), 16 | ) 17 | -------------------------------------------------------------------------------- /src/templating/snapshots/yolk__templating__parser__test__regular_lines_get_combined.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/templating/parser.rs 3 | expression: "parse_document(indoc::indoc!\n{\n r#\"\n foo\n bar\n // {% if a %}\n foo\n bar\n baz\n // {% end %}\n foo\n bar\n \"#\n})" 4 | --- 5 | Ok( 6 | [ 7 | Plain( 8 | [0..8]"foo\nbar\n", 9 | ), 10 | Conditional { 11 | blocks: [ 12 | Block { 13 | tagged_line: TaggedLine { 14 | left: "// ", 15 | tag: "{% if a %}", 16 | right: "\n", 17 | full_line: [8..22]"// {% if a %}\n", 18 | }, 19 | expr: [14..19]"a ", 20 | body: [ 21 | Plain( 22 | [22..34]"foo\nbar\nbaz\n", 23 | ), 24 | ], 25 | }, 26 | ], 27 | else_block: None, 28 | end: TaggedLine { 29 | left: "// ", 30 | tag: "{% end %}", 31 | right: "\n", 32 | full_line: [34..47]"// {% end %}\n", 33 | }, 34 | full_span: [8..47]"// {% if a %}\nfoo\nbar\nbaz\n// {% end %}\n", 35 | }, 36 | Plain( 37 | [47..55]"foo\nbar\n", 38 | ), 39 | ], 40 | ) 41 | -------------------------------------------------------------------------------- /src/templating/snapshots/yolk__templating__test__render_inline.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/templating/mod.rs 3 | expression: "Document::parse_string(\"foo /* {< string.upper(YOLK_TEXT) >} */\")?.render(&mut eval_ctx)?" 4 | snapshot_kind: text 5 | --- 6 | FOO /* {< string.upper(YOLK_TEXT) >} */ 7 | -------------------------------------------------------------------------------- /src/templating/snapshots/yolk__templating__test__test_render_inline.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/templating/mod.rs 3 | expression: "foo /* {< string.upper(YOLK_TEXT) >} */" 4 | snapshot_kind: text 5 | --- 6 | FOO /* {< string.upper(YOLK_TEXT) >} */ 7 | -------------------------------------------------------------------------------- /src/templating/snapshots/yolk__templating__test__test_render_next_line.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/templating/mod.rs 3 | expression: "/* {# string.upper(YOLK_TEXT) #} */\nfoo\n" 4 | snapshot_kind: text 5 | --- 6 | /* {# string.upper(YOLK_TEXT) #} */ 7 | FOO 8 | -------------------------------------------------------------------------------- /src/templating/snapshots/yolk_dots__templating__parser__p_text_segment-2.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/templating/parser.rs 3 | expression: "p_text_segment.parse_peek(new_input(\"foo\"))?" 4 | snapshot_kind: text 5 | --- 6 | ( 7 | "", 8 | ( 9 | "", 10 | "foo", 11 | ), 12 | ) 13 | -------------------------------------------------------------------------------- /src/templating/snapshots/yolk_dots__templating__parser__p_text_segment-3.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/templating/parser.rs 3 | expression: "p_text_segment.parse_peek(new_input(\"{< bar >}\"))" 4 | snapshot_kind: text 5 | --- 6 | Err( 7 | Backtrack( 8 | YolkParseError { 9 | context: None, 10 | span: None, 11 | label: None, 12 | help: None, 13 | kind: None, 14 | }, 15 | ), 16 | ) 17 | -------------------------------------------------------------------------------- /src/templating/snapshots/yolk_dots__templating__parser__p_text_segment.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/templating/parser.rs 3 | expression: "p_text_segment.parse_peek(new_input(\"foo {% bar %} baz\"))?" 4 | snapshot_kind: text 5 | --- 6 | ( 7 | "{% bar %} baz", 8 | ( 9 | "{%", 10 | "foo ", 11 | ), 12 | ) 13 | -------------------------------------------------------------------------------- /src/templating/snapshots/yolk_dots__templating__parser__test__conditional.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/templating/parser.rs 3 | expression: "p_conditional_element(&mut new_input(indoc::indoc! {\n r#\"\n // {% if a %}\n a\n b\n // {% elif b %}\n // {% elif c %}\n // {% else %}\n c\n // {% end %}\n \"#\n }))" 4 | snapshot_kind: text 5 | --- 6 | Ok( 7 | Conditional { 8 | blocks: [ 9 | Block { 10 | tagged_line: TaggedLine { 11 | left: "// ", 12 | tag: "{% if a %}", 13 | right: "\n", 14 | full_line: [0..14]"// {% if a %}\n", 15 | }, 16 | expr: [6..11]"a ", 17 | body: [ 18 | Plain( 19 | [14..16]"a\n", 20 | ), 21 | Plain( 22 | [16..18]"b\n", 23 | ), 24 | ], 25 | }, 26 | Block { 27 | tagged_line: TaggedLine { 28 | left: "// ", 29 | tag: "{% elif b %}", 30 | right: "\n", 31 | full_line: [18..34]"// {% elif b %}\n", 32 | }, 33 | expr: [24..31]"b ", 34 | body: [], 35 | }, 36 | Block { 37 | tagged_line: TaggedLine { 38 | left: "// ", 39 | tag: "{% elif c %}", 40 | right: "\n", 41 | full_line: [34..50]"// {% elif c %}\n", 42 | }, 43 | expr: [40..47]"c ", 44 | body: [], 45 | }, 46 | ], 47 | else_block: Some( 48 | Block { 49 | tagged_line: TaggedLine { 50 | left: "// ", 51 | tag: "{% else %}", 52 | right: "\n", 53 | full_line: [50..64]"// {% else %}\n", 54 | }, 55 | expr: (), 56 | body: [ 57 | Plain( 58 | [64..66]"c\n", 59 | ), 60 | ], 61 | }, 62 | ), 63 | end: TaggedLine { 64 | left: "// ", 65 | tag: "{% end %}", 66 | right: "\n", 67 | full_line: [66..79]"// {% end %}\n", 68 | }, 69 | }, 70 | ) 71 | -------------------------------------------------------------------------------- /src/templating/snapshots/yolk_dots__templating__parser__test__error_if_without_expression.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/templating/parser.rs 3 | expression: "render_error(parse_document(\"{}\").unwrap_err())" 4 | snapshot_kind: text 5 | --- 6 | × Failed to parse yolk template file 7 | 8 | Error: 9 | × Expected expression. 10 | ╭─[file:1:4] 11 | 1 │ {} 12 | · ┬ 13 | · ╰── here 14 | ╰──── 15 | -------------------------------------------------------------------------------- /src/templating/snapshots/yolk_dots__templating__parser__test__error_incomplete_multiline.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/templating/parser.rs 3 | expression: "render_error(parse_document(\"{%f%}\").unwrap_err())" 4 | snapshot_kind: text 5 | --- 6 | × Failed to parse yolk template file 7 | 8 | Error: 9 | × Expected newline. 10 | ╭─[file:1:5] 11 | 1 │ {%f%} 12 | · ┬ 13 | · ╰── here 14 | ╰──── 15 | -------------------------------------------------------------------------------- /src/templating/snapshots/yolk_dots__templating__parser__test__error_incomplete_nextline.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/templating/parser.rs 3 | expression: "render_error(parse_document(\"{#f#}\").unwrap_err())" 4 | snapshot_kind: text 5 | --- 6 | × Failed to parse yolk template file 7 | 8 | Error: 9 | × Expected newline. 10 | ╭─[file:1:5] 11 | 1 │ {#f#} 12 | · ┬ 13 | · ╰── here 14 | ╰──── 15 | -------------------------------------------------------------------------------- /src/templating/snapshots/yolk_dots__templating__parser__test__error_multiline_with_else.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/templating/parser.rs 3 | expression: "render_error(parse_document(\"{%f%}\\n{%else%}\\n{%end%}\").unwrap_err())" 4 | snapshot_kind: text 5 | --- 6 | × Failed to parse yolk template file 7 | 8 | Error: 9 | × Expected valid element. 10 | ╭─[file:2:3] 11 | 1 │ {%f%} 12 | 2 │ {%else%} 13 | · ───┬─── 14 | · ╰── here 15 | 3 │ {%end%} 16 | ╰──── 17 | -------------------------------------------------------------------------------- /src/templating/snapshots/yolk_dots__templating__parser__test__inline_tag.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/templating/parser.rs 3 | expression: "p_inline_element.parse(new_input(\"foo /* {< test >} */\"))?" 4 | snapshot_kind: text 5 | --- 6 | Inline { 7 | line: TaggedLine { 8 | left: "foo /* ", 9 | tag: "{< test >}", 10 | right: " */", 11 | full_line: [0..20]"foo /* {< test >} */", 12 | }, 13 | expr: [10..15]"test ", 14 | is_if: false, 15 | } 16 | -------------------------------------------------------------------------------- /src/templating/snapshots/yolk_dots__templating__parser__test__multiline_block.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/templating/parser.rs 3 | expression: "p_multiline_element(&mut new_input(\"/* {% test %} */\\nfoo\\n/* {% end %} */\"))?" 4 | snapshot_kind: text 5 | --- 6 | MultiLine { 7 | block: Block { 8 | tagged_line: TaggedLine { 9 | left: "/* ", 10 | tag: "{% test %}", 11 | right: " */\n", 12 | full_line: [0..17]"/* {% test %} */\n", 13 | }, 14 | expr: [6..11]"test ", 15 | body: [ 16 | Plain( 17 | [17..21]"foo\n", 18 | ), 19 | ], 20 | }, 21 | end: TaggedLine { 22 | left: "/* ", 23 | tag: "{% end %}", 24 | right: " */", 25 | full_line: [21..36]"/* {% end %} */", 26 | }, 27 | } 28 | -------------------------------------------------------------------------------- /src/templating/snapshots/yolk_dots__templating__parser__test__nested_conditional.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/templating/parser.rs 3 | expression: "p_conditional_element(&mut new_input(indoc::indoc! {\n r#\"\n // {% if foo %}\n // {% if foo %}\n // {% end %}\n // {% end %}\n \"#\n }))" 4 | snapshot_kind: text 5 | --- 6 | Ok( 7 | Conditional { 8 | blocks: [ 9 | Block { 10 | tagged_line: TaggedLine { 11 | left: "// ", 12 | tag: "{% if foo %}", 13 | right: "\n", 14 | full_line: [0..16]"// {% if foo %}\n", 15 | }, 16 | expr: [6..13]"foo ", 17 | body: [ 18 | Conditional { 19 | blocks: [ 20 | Block { 21 | tagged_line: TaggedLine { 22 | left: "// ", 23 | tag: "{% if foo %}", 24 | right: "\n", 25 | full_line: [16..32]"// {% if foo %}\n", 26 | }, 27 | expr: [22..29]"foo ", 28 | body: [], 29 | }, 30 | ], 31 | else_block: None, 32 | end: TaggedLine { 33 | left: "// ", 34 | tag: "{% end %}", 35 | right: "\n", 36 | full_line: [32..45]"// {% end %}\n", 37 | }, 38 | }, 39 | ], 40 | }, 41 | ], 42 | else_block: None, 43 | end: TaggedLine { 44 | left: "// ", 45 | tag: "{% end %}", 46 | right: "\n", 47 | full_line: [45..58]"// {% end %}\n", 48 | }, 49 | }, 50 | ) 51 | -------------------------------------------------------------------------------- /src/templating/snapshots/yolk_dots__templating__parser__test__nextline_tag.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/templating/parser.rs 3 | expression: "p_nextline_element(&mut new_input(\"/* {# x #} */\\nfoo\"))?" 4 | snapshot_kind: text 5 | --- 6 | NextLine { 7 | tagged_line: TaggedLine { 8 | left: "/* ", 9 | tag: "{# x #}", 10 | right: " */\n", 11 | full_line: [0..14]"/* {# x #} */\n", 12 | }, 13 | expr: [6..8]"x ", 14 | next_line: [14..17]"foo", 15 | is_if: false, 16 | } 17 | -------------------------------------------------------------------------------- /src/templating/snapshots/yolk_dots__templating__parser__test__nextline_tag_document.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/templating/parser.rs 3 | expression: "parse_document(&mut new_input(indoc::indoc! {\n r#\"\n # {# replace(`'.*'`, `'{data.value}'`) #}\n value = 'foo'\n \"#\n }))" 4 | snapshot_kind: text 5 | --- 6 | Ok( 7 | [ 8 | NextLine { 9 | tagged_line: TaggedLine { 10 | left: "# ", 11 | tag: "{# replace(`'.*'`, `'{data.value}'`) #}", 12 | right: "\n", 13 | full_line: [0..42]"# {# replace(`'.*'`, `'{data.value}'`) #}\n", 14 | }, 15 | expr: [5..39]"replace(`'.*'`, `'{data.value}'`) ", 16 | next_line: [42..55]"value = 'foo'", 17 | is_if: false, 18 | }, 19 | Plain( 20 | [55..56]"\n", 21 | ), 22 | ], 23 | ) 24 | -------------------------------------------------------------------------------- /src/templating/snapshots/yolk_dots__templating__parser__test__parse_end.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/templating/parser.rs 3 | expression: "p_tag_line(\"{%\", \"end\", \"%}\", false).parse(new_input(\"a{% end %}b\"))" 4 | snapshot_kind: text 5 | --- 6 | Ok( 7 | ( 8 | TaggedLine { 9 | left: "a", 10 | tag: "{% end %}", 11 | right: "b", 12 | full_line: [0..11]"a{% end %}b", 13 | }, 14 | [4..7]"end", 15 | ), 16 | ) 17 | -------------------------------------------------------------------------------- /src/templating/test.rs: -------------------------------------------------------------------------------- 1 | use rstest::{fixture, rstest}; 2 | 3 | use crate::util::test_util::TestResult; 4 | 5 | use crate::script::eval_ctx::EvalCtx; 6 | use crate::templating::document::Document; 7 | use crate::yolk::EvalMode; 8 | use indoc::indoc; 9 | 10 | #[fixture] 11 | pub fn eval_ctx() -> EvalCtx { 12 | EvalCtx::new_in_mode(EvalMode::Local).unwrap() 13 | } 14 | 15 | #[rstest] 16 | #[case::inline_tag( 17 | "foo /* {< get_yolk_text().to_upper() >} */", 18 | "FOO /* {< get_yolk_text().to_upper() >} */" 19 | )] 20 | #[case::nextline_tag( 21 | "/* {# get_yolk_text().to_upper() #} */\nfoo\n", 22 | "/* {# get_yolk_text().to_upper() #} */\nFOO\n" 23 | )] 24 | #[case::multiline( 25 | indoc!{r#" 26 | /* {% get_yolk_text().to_upper() %} */ 27 | foo 28 | /* {% end %} */ 29 | "#}, 30 | indoc! {r#" 31 | /* {% get_yolk_text().to_upper() %} */ 32 | FOO 33 | /* {% end %} */ 34 | "#}, 35 | )] 36 | #[case::inline_conditional("foo/* {< if false >} */", "/* foo/* {< if false >} */*/")] 37 | #[case::nextline_conditional( 38 | "/* {# if false #} */\nfoo\n", 39 | "/* {# if false #} */\n/* foo*/\n" 40 | )] 41 | #[case::multiline_conditional( 42 | indoc!{r#" 43 | /* {% if false %} */ 44 | foo 45 | /* {% elif false %} */ 46 | foo 47 | /* {% elif true %} */ 48 | bar 49 | /* {% else %} */ 50 | bar 51 | /* {% end %} */ 52 | "#}, 53 | indoc!{r#" 54 | /* {% if false %} */ 55 | /* foo*/ 56 | /* {% elif false %} */ 57 | /* foo*/ 58 | /* {% elif true %} */ 59 | bar 60 | /* {% else %} */ 61 | /* bar*/ 62 | /* {% end %} */ 63 | "#} 64 | )] 65 | #[case::nextline_conditional_with_newlines( 66 | indoc!{" 67 | /* {# 68 | if false 69 | #} */ 70 | foo 71 | "}, 72 | indoc!{" 73 | /* {# 74 | if false 75 | #} */ 76 | /* foo*/ 77 | "}, 78 | )] 79 | #[case::replace( 80 | indoc!{r#" 81 | {# replace_re(`'.*'`, `'new'`) #} 82 | foo: 'original' 83 | "#}, 84 | indoc!{r#" 85 | {# replace_re(`'.*'`, `'new'`) #} 86 | foo: 'new' 87 | "#} 88 | )] 89 | pub fn test_render( 90 | mut eval_ctx: EvalCtx, 91 | #[case] input: &str, 92 | #[case] expected: &str, 93 | ) -> TestResult { 94 | let doc = Document::parse_string(input)?; 95 | let actual = doc.render(&mut eval_ctx)?; 96 | pretty_assertions::assert_eq!(expected, actual); 97 | Ok(()) 98 | } 99 | 100 | #[rstest] 101 | #[case::regression_keep_indents(indoc!{r#" 102 | # foo 103 | indented 104 | indented more 105 | foo // {< if true >} 106 | indented 107 | not 108 | "#} )] 109 | #[case::regression_blank_lines_around_conditional(indoc!{" 110 | foo 111 | 112 | {% if true %} 113 | foo 114 | {% end %} 115 | 116 | foo 117 | "})] 118 | pub fn test_render_noop(mut eval_ctx: EvalCtx, #[case] input: &str) -> TestResult { 119 | let doc = Document::parse_string(input)?; 120 | let actual = doc.render(&mut eval_ctx)?; 121 | pretty_assertions::assert_eq!(input, actual); 122 | Ok(()) 123 | } 124 | 125 | #[test] 126 | pub fn test_render_replace_refuse_non_idempodent() -> TestResult { 127 | let element = Document::parse_string("{# replace(`'.*'`, `a'a'`) #}\nfoo: 'original'")?; 128 | let mut eval_ctx = EvalCtx::new_in_mode(EvalMode::Local)?; 129 | assert!(element.render(&mut eval_ctx).is_err()); 130 | Ok(()) 131 | } 132 | -------------------------------------------------------------------------------- /src/tests/git_tests.rs: -------------------------------------------------------------------------------- 1 | use assert_cmd::{assert, Command}; 2 | use assert_fs::prelude::{FileWriteStr as _, PathChild}; 3 | 4 | use crate::{ 5 | util::test_util::{setup_and_init_test_yolk, TestResult}, 6 | yolk::{EvalMode, Yolk}, 7 | }; 8 | 9 | struct TestEnv { 10 | home: assert_fs::TempDir, 11 | eggs: assert_fs::fixture::ChildPath, 12 | yolk: Yolk, 13 | } 14 | 15 | impl TestEnv { 16 | pub fn init() -> miette::Result { 17 | let (home, yolk, eggs) = setup_and_init_test_yolk()?; 18 | 19 | Ok(Self { home, yolk, eggs }) 20 | } 21 | pub fn yolk_root(&self) -> assert_fs::fixture::ChildPath { 22 | self.home.child("yolk") 23 | } 24 | 25 | // pub fn config_git(&self) { 26 | // self.start_git_command() 27 | // .args(["config", "--local", "user.name", "test"]) 28 | // .assert() 29 | // .success(); 30 | // self.start_git_command() 31 | // .args(["config", "--local", "user.email", "test@test.test"]) 32 | // .assert() 33 | // .success(); 34 | // } 35 | 36 | pub fn start_git_command(&self) -> Command { 37 | let mut cmd = Command::new("git"); 38 | cmd.env("HOME", self.home.path()) 39 | .current_dir(self.yolk_root().path()) 40 | .args(&[ 41 | "--git-dir", 42 | &self 43 | .yolk 44 | .paths() 45 | .active_yolk_git_dir() 46 | .unwrap() 47 | .to_string_lossy(), 48 | "--work-tree", 49 | &self.yolk_root().to_string_lossy(), 50 | ]); 51 | cmd 52 | } 53 | 54 | pub fn git_cmd(&self, args: &[&str]) -> assert::Assert { 55 | let mut cmd = self.start_git_command(); 56 | cmd.args(args); 57 | cmd.assert() 58 | } 59 | 60 | pub fn yolk_cmd(&self) -> assert_cmd::Command { 61 | let mut yolk_command = assert_cmd::Command::cargo_bin("yolk").unwrap(); 62 | yolk_command.current_dir(self.yolk_root()).args([ 63 | "--yolk-dir", 64 | &self.yolk_root().to_string_lossy(), 65 | "--home-dir", 66 | &self.yolk.paths().home_path().to_string_lossy(), 67 | ]); 68 | yolk_command 69 | } 70 | 71 | pub fn yolk_git(&self, args: &[&str]) -> assert_cmd::assert::Assert { 72 | let mut yolk_command = self.yolk_cmd(); 73 | yolk_command.arg("git").args(args); 74 | yolk_command 75 | .timeout(std::time::Duration::from_secs(1)) 76 | .assert() 77 | } 78 | } 79 | 80 | #[test] 81 | fn test_git_add_with_error() -> TestResult { 82 | let env = TestEnv::init()?; 83 | 84 | env.home 85 | .child("yolk/yolk.rhai") 86 | .write_str(indoc::indoc! {r#" 87 | export let eggs = #{ 88 | foo: #{ targets: `~/foo`, strategy: "put", templates: ["fine", "broken"]}, 89 | }; 90 | "#})?; 91 | env.eggs 92 | .child("foo/fine") 93 | .write_str(r#"{<(1+1).to_string()>}"#)?; 94 | env.eggs.child("foo/broken").write_str(r#"{< foo >}"#)?; 95 | assert!(env.yolk.sync_to_mode(EvalMode::Local, false).is_err()); 96 | env.yolk_git(&["add", "--all"]).failure(); 97 | env.git_cmd(&["show", ":eggs/foo/fine"]) 98 | .stdout("") 99 | .stderr("fatal: path \'eggs/foo/fine\' exists on disk, but not in the index\n"); 100 | env.git_cmd(&["show", ":eggs/foo/broken"]) 101 | .stdout("") 102 | .stderr("fatal: path \'eggs/foo/broken\' exists on disk, but not in the index\n"); 103 | Ok(()) 104 | } 105 | -------------------------------------------------------------------------------- /src/tests/mod.rs: -------------------------------------------------------------------------------- 1 | mod git_tests; 2 | mod yolk_tests; 3 | -------------------------------------------------------------------------------- /src/tests/snapshots/yolk__tests__yolk_tests__deployment_error.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/tests/yolk_tests.rs 3 | expression: "test_util::render_error(yolk.sync_egg_deployment(&egg).unwrap_err())" 4 | --- 5 | × Failed to deploy egg bar 6 | 7 | Error: 8 | × Failed to deploy /tmp/[tmp-dir]/yolk/eggs/bar/[filename] 9 | ├─▶ Failed to create symlink at /tmp/[tmp-dir]/[filename] -> /tmp/[tmp-dir]/yolk/eggs/bar/[filename] 10 | ╰─▶ File exists (os error 17) 11 | 12 | Error: 13 | × Failed to deploy /tmp/[tmp-dir]/yolk/eggs/bar/[filename] 14 | ├─▶ Failed to create symlink at /tmp/[tmp-dir]/[filename] -> /tmp/[tmp-dir]/yolk/eggs/bar/[filename] 15 | ╰─▶ File exists (os error 17) 16 | -------------------------------------------------------------------------------- /src/tests/snapshots/yolk__tests__yolk_tests__syntax_error_in_yolk_rhai.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/tests/yolk_tests.rs 3 | expression: "yolk.prepare_eval_ctx_for_templates(crate::yolk::EvalMode::Local).map_err(|e|\ncreate_regex(r\"\\[.*.rhai:\\d+:\\d+]\").unwrap().replace(&test_util::render_report(e),\n\"[no-filename-in-test]\").to_string()).unwrap_err()" 4 | --- 5 | × Failed to execute yolk.rhai 6 | ╰─▶ Syntax error: Expecting ')' to close the parameters list of function 'foo' (line 2, position 1) 7 | ╭─[no-filename-in-test] 8 | 1 │ fn foo( 9 | · ┬ 10 | · ╰── here 11 | ╰──── 12 | -------------------------------------------------------------------------------- /src/util.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::HashSet, 3 | io::Write, 4 | path::{Path, PathBuf}, 5 | }; 6 | 7 | use cached::UnboundCache; 8 | use fs_err::OpenOptions; 9 | use miette::{Context as _, IntoDiagnostic as _}; 10 | use regex::Regex; 11 | 12 | use crate::yolk_paths::default_yolk_dir; 13 | 14 | /// Rename or move a file, but only if the destination doesn't exist. 15 | /// This is a safer verison of [`std::fs::rename`] that doesn't overwrite files. 16 | pub fn rename_safely(original: impl AsRef, new: impl AsRef) -> miette::Result<()> { 17 | let original = original.as_ref(); 18 | let new = new.as_ref(); 19 | tracing::trace!("Renaming {} -> {}", original.abbr(), new.abbr()); 20 | miette::ensure!( 21 | !new.exists(), 22 | "Failed to move file {} to {}: File already exists.", 23 | original.abbr(), 24 | new.abbr() 25 | ); 26 | fs_err::rename(original, new) 27 | .into_diagnostic() 28 | .wrap_err("Failed to rename file")?; 29 | Ok(()) 30 | } 31 | 32 | pub fn file_entries_recursive( 33 | path: impl AsRef, 34 | ) -> impl Iterator> { 35 | walkdir::WalkDir::new(path) 36 | .into_iter() 37 | .filter(|x| x.as_ref().map_or(true, |x| !x.path().is_dir())) 38 | .map(|x| x.map(|x| x.into_path())) 39 | .map(|x| x.into_diagnostic()) 40 | } 41 | 42 | /// Ensure that a file contains the given lines, appending them if they are missing. If the file does not yet exist, it will be created. 43 | pub fn ensure_file_contains_lines(path: impl AsRef, lines: &[&str]) -> miette::Result<()> { 44 | let path = path.as_ref(); 45 | 46 | let mut trailing_newline_exists = true; 47 | 48 | let existing_lines = if path.exists() { 49 | let content = fs_err::read_to_string(path).into_diagnostic()?; 50 | trailing_newline_exists = content.ends_with('\n'); 51 | content.lines().map(|x| x.to_string()).collect() 52 | } else { 53 | HashSet::new() 54 | }; 55 | if lines.iter().all(|x| existing_lines.contains(*x)) { 56 | return Ok(()); 57 | } 58 | let mut file = OpenOptions::new() 59 | .append(true) 60 | .create(true) 61 | .open(path) 62 | .into_diagnostic()?; 63 | let missing_lines = lines.iter().filter(|x| !existing_lines.contains(**x)); 64 | if !trailing_newline_exists { 65 | writeln!(file).into_diagnostic()?; 66 | } 67 | for line in missing_lines { 68 | writeln!(file, "{}", line).into_diagnostic()?; 69 | } 70 | Ok(()) 71 | } 72 | 73 | /// Ensure that a file does not contain the given lines, removing them if they are present. 74 | pub fn ensure_file_doesnt_contain_lines( 75 | path: impl AsRef, 76 | lines: &[&str], 77 | ) -> miette::Result<()> { 78 | let path = path.as_ref(); 79 | if !path.exists() { 80 | return Ok(()); 81 | } 82 | let content = fs_err::read_to_string(path).into_diagnostic()?; 83 | let trailing_newline_exists = content.ends_with('\n'); 84 | let remaining_lines = content 85 | .lines() 86 | .filter(|x| !lines.contains(x)) 87 | .collect::>(); 88 | if remaining_lines.len() == content.lines().count() { 89 | return Ok(()); 90 | } 91 | let new_content = format!( 92 | "{}{}", 93 | remaining_lines.join("\n"), 94 | if trailing_newline_exists { "\n" } else { "" } 95 | ); 96 | fs_err::write(path, new_content).into_diagnostic()?; 97 | Ok(()) 98 | } 99 | 100 | #[extend::ext(pub)] 101 | impl Path { 102 | /// [`fs_err::canonicalize`] but on windows it doesn't return UNC paths. 103 | fn canonical(&self) -> miette::Result { 104 | Ok(dunce::simplified(&fs_err::canonicalize(self).into_diagnostic()?).to_path_buf()) 105 | } 106 | 107 | /// Stringify the path into an abbreviated form. 108 | /// 109 | /// This replaces the home path with `~`, as well as reducing paths that point into the eggs directory to `eggs/rest/of/path`. 110 | fn abbr(&self) -> String { 111 | let eggs = default_yolk_dir().join("eggs"); 112 | match dirs::home_dir() { 113 | Some(home) => self 114 | .strip_prefix(&eggs) 115 | .map(|x| PathBuf::from("eggs").join(x)) 116 | .or_else(|_| self.strip_prefix(&home).map(|x| PathBuf::from("~").join(x))) 117 | .unwrap_or_else(|_| self.into()) 118 | .display() 119 | .to_string(), 120 | _ => self.display().to_string(), 121 | } 122 | } 123 | 124 | /// Expands `~` in a path to the home directory. 125 | fn expanduser(&self) -> PathBuf { 126 | #[cfg(not(test))] 127 | let Some(home) = dirs::home_dir() else { 128 | return self.to_path_buf(); 129 | }; 130 | #[cfg(test)] 131 | let home = test_util::get_home_dir(); 132 | 133 | if let Some(first) = self.components().next() { 134 | if first.as_os_str().to_string_lossy() == "~" { 135 | return home.join(self.strip_prefix("~").unwrap()); 136 | } 137 | } 138 | self.to_path_buf() 139 | } 140 | 141 | #[track_caller] 142 | fn assert_absolute(&self, name: &str) { 143 | assert!( 144 | self.is_absolute(), 145 | "Path {} must be absolute, but was: {}", 146 | name, 147 | self.display() 148 | ); 149 | } 150 | 151 | #[track_caller] 152 | fn assert_starts_with(&self, start: impl AsRef, name: &str) { 153 | assert!( 154 | self.starts_with(start.as_ref()), 155 | "Path {} must be inside {}, but was: {}", 156 | name, 157 | start.as_ref().display(), 158 | self.display() 159 | ); 160 | } 161 | } 162 | 163 | pub fn create_regex(s: impl AsRef) -> miette::Result { 164 | cached::cached_key! { 165 | REGEXES: UnboundCache> = UnboundCache::new(); 166 | Key = { s.to_string() }; 167 | fn create_regex_cached(s: &str) -> Result = { 168 | Regex::new(s) 169 | } 170 | } 171 | create_regex_cached(s.as_ref()).into_diagnostic() 172 | } 173 | 174 | #[cfg(test)] 175 | pub mod test_util { 176 | use std::cell::RefCell; 177 | use std::path::PathBuf; 178 | use std::thread_local; 179 | 180 | use miette::IntoDiagnostic as _; 181 | 182 | thread_local! { 183 | static HOME_DIR: RefCell> = const { RefCell::new(None) }; 184 | } 185 | 186 | pub fn set_home_dir(path: PathBuf) { 187 | HOME_DIR.with(|home_dir| { 188 | *home_dir.borrow_mut() = Some(path); 189 | }); 190 | } 191 | 192 | pub fn get_home_dir() -> PathBuf { 193 | HOME_DIR.with_borrow(|x| x.clone()).expect( 194 | "Home directory not set in this test. Use `set_home_dir` to set the home directory.", 195 | ) 196 | } 197 | 198 | /// like , but shows the debug output instead of display. 199 | pub type TestResult = std::result::Result; 200 | 201 | #[derive(Debug)] 202 | pub enum TestError {} 203 | 204 | impl From for TestError { 205 | #[track_caller] // Will show the location of the caller in test failure messages 206 | fn from(error: T) -> Self { 207 | // Use alternate format for rich error message for anyhow 208 | // See: https://docs.rs/anyhow/latest/anyhow/struct.Error.html#display-representations 209 | panic!("error: {} - {:?}", std::any::type_name::(), error); 210 | } 211 | } 212 | 213 | pub fn setup_and_init_test_yolk() -> miette::Result<( 214 | assert_fs::TempDir, 215 | crate::yolk::Yolk, 216 | assert_fs::fixture::ChildPath, 217 | )> { 218 | use assert_fs::prelude::PathChild as _; 219 | 220 | let home = assert_fs::TempDir::new().into_diagnostic()?; 221 | let paths = crate::yolk_paths::YolkPaths::new(home.join("yolk"), home.to_path_buf()); 222 | let yolk = crate::yolk::Yolk::new(paths); 223 | std::env::set_var("HOME", "/tmp/TEST_HOMEDIR_SHOULD_NOT_BE_USED"); 224 | set_home_dir(home.to_path_buf()); 225 | 226 | let eggs = home.child("yolk/eggs"); 227 | let yolk_binary_path = assert_cmd::cargo::cargo_bin("yolk"); 228 | yolk.init_yolk(Some(yolk_binary_path.to_string_lossy().as_ref()))?; 229 | Ok((home, yolk, eggs)) 230 | } 231 | 232 | pub fn render_error(e: impl miette::Diagnostic) -> String { 233 | use miette::GraphicalReportHandler; 234 | 235 | let mut out = String::new(); 236 | GraphicalReportHandler::new() 237 | .with_theme(miette::GraphicalTheme::unicode_nocolor()) 238 | .render_report(&mut out, &e) 239 | .unwrap(); 240 | out 241 | } 242 | 243 | pub fn render_report(e: miette::Report) -> String { 244 | use miette::GraphicalReportHandler; 245 | 246 | let mut out = String::new(); 247 | GraphicalReportHandler::new() 248 | .with_theme(miette::GraphicalTheme::unicode_nocolor()) 249 | .render_report(&mut out, e.as_ref()) 250 | .unwrap(); 251 | out 252 | } 253 | } 254 | --------------------------------------------------------------------------------