├── examples └── html-to-string-macro │ ├── .gitignore │ ├── tests │ ├── ui │ │ ├── doctype.rs │ │ ├── no_open_tag.rs │ │ ├── not_closed_tag.rs │ │ ├── not_closed_tag.stderr │ │ ├── node_block_with_dot.rs │ │ ├── no_open_tag.stderr │ │ ├── node_block_with_dot.stderr │ │ ├── doctype.stderr │ │ ├── multiple_errors.rs │ │ ├── first.stderr │ │ └── multiple_errors.stderr │ ├── compiletests.rs │ └── tests.rs │ ├── README.md │ ├── build.rs │ ├── Cargo.toml │ └── src │ └── lib.rs ├── .gitignore ├── doc_imgs ├── .DS_Store ├── completion.png └── output.svg ├── rustfmt.toml ├── rstml ├── src │ ├── error.rs │ ├── node │ │ ├── node_value.rs │ │ ├── parser_ext.rs │ │ ├── raw_text.rs │ │ ├── atoms.rs │ │ ├── mod.rs │ │ ├── node_name.rs │ │ └── attribute.rs │ ├── parser │ │ ├── mod.rs │ │ └── recoverable.rs │ ├── lib.rs │ ├── config.rs │ └── rawtext_stable_hack.rs ├── build.rs ├── Cargo.toml └── tests │ ├── custom_node.rs │ └── recoverable_parser.rs ├── .tarpaulin.toml ├── generate_changelog.sh ├── .github └── workflows │ ├── bench.yml │ ├── coverage.yml │ └── ci.yml ├── rstml-control-flow ├── Cargo.toml ├── README.md └── src │ ├── lib.rs │ └── extendable.rs ├── LICENSE ├── .githooks └── pre-push ├── Cargo.toml ├── cliff.toml ├── comparsion-with-syn-rsx.md ├── README.md ├── CHANGELOG.old.md └── CHANGELOG.md /examples/html-to-string-macro/.gitignore: -------------------------------------------------------------------------------- 1 | Cargo.lock 2 | target 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /Cargo.lock 3 | .DS_Store 4 | .idea 5 | tarpaulin-report.html -------------------------------------------------------------------------------- /doc_imgs/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rs-tml/rstml/HEAD/doc_imgs/.DS_Store -------------------------------------------------------------------------------- /doc_imgs/completion.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rs-tml/rstml/HEAD/doc_imgs/completion.png -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | wrap_comments = true 2 | format_code_in_doc_comments = true 3 | imports_granularity = "Crate" 4 | group_imports = "StdExternalCrate" -------------------------------------------------------------------------------- /rstml/src/error.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug, thiserror::Error)] 2 | pub enum Error { 3 | #[error("TryFrom failed: {0}")] 4 | TryFrom(String), 5 | } 6 | -------------------------------------------------------------------------------- /examples/html-to-string-macro/tests/ui/doctype.rs: -------------------------------------------------------------------------------- 1 | use rstml_to_string_macro::html; 2 | fn main () { 3 | html! { 4 | 5 | }; 6 | } -------------------------------------------------------------------------------- /examples/html-to-string-macro/tests/ui/no_open_tag.rs: -------------------------------------------------------------------------------- 1 | use rstml_to_string_macro::html; 2 | fn main () { 3 | html! { 4 | 5 | 6 | }; 7 | } -------------------------------------------------------------------------------- /examples/html-to-string-macro/tests/ui/not_closed_tag.rs: -------------------------------------------------------------------------------- 1 | use rstml_to_string_macro::html; 2 | fn main () { 3 | html! { 4 | 5 | 6 | }; 7 | } -------------------------------------------------------------------------------- /.tarpaulin.toml: -------------------------------------------------------------------------------- 1 | [unquoted_text_on_stable] 2 | features = "rstml/rawtext-stable-hack" 3 | 4 | [custom_node_extendable] 5 | features = "rstml-control-flow/extendable" 6 | 7 | [report] 8 | out = ["Xml", "Html"] 9 | -------------------------------------------------------------------------------- /examples/html-to-string-macro/tests/ui/not_closed_tag.stderr: -------------------------------------------------------------------------------- 1 | error: open tag has no corresponding close tag 2 | --> tests/ui/not_closed_tag.rs:5:13 3 | | 4 | 5 | 5 | | ^^^^^^ 6 | -------------------------------------------------------------------------------- /examples/html-to-string-macro/tests/compiletests.rs: -------------------------------------------------------------------------------- 1 | #[test] 2 | fn ui() { 3 | let t = trybuild::TestCases::new(); 4 | if cfg!(rstml_signal_nightly) { 5 | t.compile_fail("tests/ui/*.rs") 6 | }; 7 | } 8 | -------------------------------------------------------------------------------- /generate_changelog.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | export GIT_CLIFF__CHANGELOG__FOOTER=$(cat CHANGELOG.old.md) 4 | OLD_COMMIT="149109f1420df7a11f5a69e4a9fb90bf57ec4f02" 5 | 6 | git cliff -o CHANGELOG.md -- $OLD_COMMIT..HEAD -------------------------------------------------------------------------------- /examples/html-to-string-macro/tests/ui/node_block_with_dot.rs: -------------------------------------------------------------------------------- 1 | use rstml_to_string_macro::html; 2 | fn main () { 3 | html! { 4 | 5 | 6 | {"block".} 7 | 8 | }; 9 | } -------------------------------------------------------------------------------- /examples/html-to-string-macro/tests/ui/no_open_tag.stderr: -------------------------------------------------------------------------------- 1 | error: close tag was parsed while waiting for open tag 2 | --> tests/ui/no_open_tag.rs:5:14 3 | | 4 | 5 | 5 | | ^ 6 | 7 | error: open tag has no corresponding close tag 8 | --> tests/ui/no_open_tag.rs:5:13 9 | | 10 | 5 | 11 | | ^^^^^^^ 12 | -------------------------------------------------------------------------------- /examples/html-to-string-macro/tests/ui/node_block_with_dot.stderr: -------------------------------------------------------------------------------- 1 | error: unexpected end of input, expected identifier or integer 2 | --> tests/ui/node_block_with_dot.rs:6:22 3 | | 4 | 6 | {"block".} 5 | | ^ 6 | 7 | error: unexpected token: `}` 8 | --> tests/ui/node_block_with_dot.rs:6:13 9 | | 10 | 6 | {"block".} 11 | | ^^^^^^^^^^ 12 | -------------------------------------------------------------------------------- /examples/html-to-string-macro/tests/ui/doctype.stderr: -------------------------------------------------------------------------------- 1 | error: expected DOCTYPE keyword 2 | --> tests/ui/doctype.rs:4:11 3 | | 4 | 4 | 5 | | ^^^^^^^^^^^ 6 | 7 | error: Node parse failed 8 | --> tests/ui/doctype.rs:4:23 9 | | 10 | 4 | 11 | | ^^^^ 12 | 13 | error: Tokens was skipped after incorrect parsing 14 | --> tests/ui/doctype.rs:4:23 15 | | 16 | 4 | 17 | | ^^^^^ 18 | -------------------------------------------------------------------------------- /examples/html-to-string-macro/README.md: -------------------------------------------------------------------------------- 1 | # html-to-string-macro 2 | 3 | [![crates.io page](https://img.shields.io/crates/v/html-to-string-macro.svg)](https://crates.io/crates/html-to-string-macro) 4 | [![docs.rs page](https://docs.rs/html-to-string-macro/badge.svg)](https://docs.rs/html-to-string-macro/) 5 | ![build](https://github.com/stoically/syn-rsx/workflows/ci/badge.svg) 6 | ![license: MIT](https://img.shields.io/crates/l/html-to-string-macro.svg) 7 | 8 | simple html to string macro powered by [rstml](https://crates.io/crates/rstml). 9 | -------------------------------------------------------------------------------- /rstml/build.rs: -------------------------------------------------------------------------------- 1 | use std::{env, process::Command, str::from_utf8}; 2 | 3 | fn main() { 4 | if is_rustc_nightly() { 5 | println!("cargo:rustc-cfg=rstml_signal_nightly"); 6 | } 7 | } 8 | 9 | fn is_rustc_nightly() -> bool { 10 | || -> Option { 11 | let rustc = env::var_os("RUSTC")?; 12 | let output = Command::new(rustc).arg("--version").output().ok()?; 13 | let version = from_utf8(&output.stdout).ok()?; 14 | Some(version.contains("nightly")) 15 | }() 16 | .unwrap_or_default() 17 | } 18 | -------------------------------------------------------------------------------- /examples/html-to-string-macro/build.rs: -------------------------------------------------------------------------------- 1 | use std::{env, process::Command, str::from_utf8}; 2 | 3 | fn main() { 4 | if is_rustc_nightly() { 5 | println!("cargo:rustc-cfg=rstml_signal_nightly"); 6 | } 7 | } 8 | 9 | fn is_rustc_nightly() -> bool { 10 | || -> Option { 11 | let rustc = env::var_os("RUSTC")?; 12 | let output = Command::new(rustc).arg("--version").output().ok()?; 13 | let version = from_utf8(&output.stdout).ok()?; 14 | Some(version.contains("nightly")) 15 | }() 16 | .unwrap_or_default() 17 | } 18 | -------------------------------------------------------------------------------- /.github/workflows/bench.yml: -------------------------------------------------------------------------------- 1 | name: bench 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | bench: 7 | name: bench 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v1 12 | - name: Download and install critcmp 13 | run: cargo install critcmp 14 | - name: Run bench on current branch 15 | run: cargo bench -p rstml --bench bench -- --save-baseline changes 16 | - name: Run bench on main branch 17 | run: | 18 | git checkout main 19 | cargo bench -p rstml --bench bench -- --save-baseline main 20 | - name: Compare the results 21 | run: critcmp main changes 22 | -------------------------------------------------------------------------------- /examples/html-to-string-macro/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rstml-to-string-macro" 3 | description = "simple html to string macro powered by rstml" 4 | version = "0.1.0" 5 | authors.workspace = true 6 | keywords.workspace = true 7 | edition.workspace = true 8 | repository.workspace = true 9 | readme.workspace = true 10 | license.workspace = true 11 | include.workspace = true 12 | 13 | [lib] 14 | proc-macro = true 15 | 16 | [dependencies] 17 | proc-macro2.workspace = true 18 | quote.workspace = true 19 | syn.workspace = true 20 | syn_derive.workspace = true 21 | rstml = { workspace = true, features = ["rawtext-stable-hack"] } 22 | proc-macro2-diagnostics.workspace = true 23 | derive-where.workspace = true 24 | rstml-control-flow.workspace = true 25 | 26 | [dev-dependencies] 27 | trybuild.workspace = true 28 | 29 | [lints.rust] 30 | unexpected_cfgs = { level = "allow", check-cfg = ['cfg(rstml_signal_nightly)'] } 31 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | name: coverage 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | push: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | test: 13 | name: coverage 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v3 18 | - uses: dtolnay/rust-toolchain@nightly 19 | 20 | - name: coverage nightly 21 | run: | 22 | cargo install cargo-tarpaulin 23 | cargo tarpaulin --out xml --output-dir ./nightly --workspace 24 | - uses: dtolnay/rust-toolchain@stable 25 | 26 | - name: coverage nightly 27 | run: | 28 | cargo tarpaulin --out xml --output-dir ./stable --workspace 29 | - uses: codecov/codecov-action@v3 30 | with: 31 | token: ${{ secrets.CODECOV_TOKEN }} 32 | files: ./stable/cobertura.xml,./nightly/cobertura.xml 33 | name: RSTML -------------------------------------------------------------------------------- /examples/html-to-string-macro/tests/ui/multiple_errors.rs: -------------------------------------------------------------------------------- 1 | use rstml_to_string_macro::html; 2 | 3 | fn main() { 4 | html! { 5 | 6 | 7 | 8 | "Example" 9 | 10 | 11 | 12 |
13 |
"1" { world.} "2"
14 |
15 |
"2"
16 |
some unquoted text with quotes "3".
17 |
18 |
"3"
19 | 20 |
21 |
22 | 23 | 24 | 25 | 26 | }; 27 | } -------------------------------------------------------------------------------- /rstml-control-flow/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rstml-control-flow" 3 | description = "Custom nodes with control flow implementation for rstml. Usefull when you need to implement If, For, etc." 4 | version = "0.1.1" 5 | edition.workspace = true 6 | authors.workspace = true 7 | keywords.workspace = true 8 | repository.workspace = true 9 | readme = "README.md" 10 | license.workspace = true 11 | include = ["/src", "../LICENSE"] 12 | 13 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 14 | 15 | [dependencies] 16 | proc-macro2.workspace = true 17 | quote.workspace = true 18 | syn.workspace = true 19 | syn_derive.workspace = true 20 | proc-macro2-diagnostics.workspace = true 21 | derive-where.workspace = true 22 | rstml = { workspace = true, features = ["rawtext-stable-hack"] } 23 | 24 | [features] 25 | default = [] 26 | # If feature activated Node should be parsed through `ExtendableCustomNode::parse2_with_config` 27 | extendable = [] 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020 stoically@protonmail.com 2 | Copyright 2023 vldm 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | push: 8 | branches: 9 | - main 10 | merge_group: 11 | branches: 12 | - main 13 | 14 | jobs: 15 | ci: 16 | name: ci 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - uses: actions/checkout@v3 21 | - uses: dtolnay/rust-toolchain@nightly 22 | with: 23 | components: rustfmt 24 | 25 | - name: fmt 26 | run: cargo +nightly fmt --all -- --check 27 | 28 | - uses: dtolnay/rust-toolchain@stable 29 | - name: build 30 | run: cargo build 31 | 32 | - name: clippy 33 | run: cargo clippy --workspace 34 | 35 | - name: test on Stable 36 | run: cargo test --workspace 37 | 38 | - name: Tests with rawtext hack 39 | run: cargo test -p rstml --features "rawtext-stable-hack-module" 40 | 41 | - name: Test extendable feature in rstml-control-flow 42 | run: cargo test -p rstml-control-flow --features "extendable" 43 | 44 | - uses: dtolnay/rust-toolchain@nightly 45 | 46 | - name: test on Nightly 47 | run: cargo test --workspace 48 | -------------------------------------------------------------------------------- /.githooks/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | test() { 4 | RESULT=0 5 | echo "git-hooks: Running tests before pushing ...." 6 | cargo +nightly fmt --all -- --check 7 | RESULT=$? 8 | if [[ "$RESULT" -ne 0 ]]; then 9 | echo "git-hooks: Fmt check failed, stop pushing" 10 | return $RESULT 11 | fi 12 | cargo +stable test --workspace 13 | RESULT=$? 14 | if [[ "$RESULT" -ne 0 ]]; then 15 | echo "git-hooks: Test failed, stop pushing" 16 | fi 17 | cargo +stable clippy --workspace 18 | RESULT=$? 19 | if [[ "$RESULT" -ne 0 ]]; then 20 | echo "git-hooks: clippy failed, stop pushing" 21 | fi 22 | return $RESULT 23 | } 24 | 25 | branch=`git rev-parse --abbrev-ref HEAD` 26 | if [ $branch == 'main' ]; then 27 | hasChanges=$(git diff) 28 | STASH_NAME="pre-push-$(date +%s)" 29 | if [ -n "$hasChanges" ]; then 30 | echo "git-hooks: Stashing changes, to test actual HEAD = $STASH_NAME" 31 | git stash save -q --keep-index $STASH_NAME 32 | fi 33 | test 34 | RESULT=$? 35 | STASHES=$(git stash list | HEAD -n 1) 36 | if [[ $STASHES == *"$STASH_NAME" ]]; then 37 | git stash pop -q 38 | fi 39 | [ "$RESULT" -ne 0 ] && exit 1 40 | fi 41 | 42 | exit 0 -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace.package] 2 | description = "Rust templating for XML-based formats (HTML, SVG, MathML) implemented on top of proc-macro::TokenStreams" 3 | authors = ["vldm ", "stoically "] 4 | keywords = ["syn", "jsx", "rsx", "html", "macro"] 5 | edition = "2021" 6 | repository = "https://github.com/rs-tml/rstml" 7 | license = "MIT" 8 | license-file = "LICENSE" 9 | 10 | include = ["/src", "build.rs", "LICENSE"] 11 | readme = "README.md" 12 | 13 | [workspace] 14 | resolver = "2" 15 | members = ["examples/html-to-string-macro", "rstml-control-flow"] 16 | 17 | [workspace.dependencies] 18 | # Our packages 19 | rstml = { version = "0.12.0", path = "rstml" } 20 | rstml-control-flow = { version = "0.1.0", path = "rstml-control-flow" } 21 | # external dependencies 22 | proc-macro2 = { version = "1.0.93", features = ["span-locations"] } 23 | quote = "1.0.38" 24 | syn = { version = "2.0.96", features = [ 25 | "visit-mut", 26 | "full", 27 | "parsing", 28 | "extra-traits", 29 | ] } 30 | thiserror = "2.0.11" 31 | syn_derive = "0.2.0" 32 | proc-macro2-diagnostics = { version = "0.10", default-features = false } 33 | derive-where = "1.2.7" 34 | # dev-dependencies 35 | criterion = "0.7.0" 36 | eyre = "0.6.12" 37 | trybuild = "1.0" 38 | -------------------------------------------------------------------------------- /rstml/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rstml" 3 | description.workspace = true 4 | version = "0.12.1" 5 | authors.workspace = true 6 | keywords.workspace = true 7 | edition.workspace = true 8 | repository.workspace = true 9 | readme.workspace = true 10 | license.workspace = true 11 | include.workspace = true 12 | [lib] 13 | bench = false 14 | 15 | [dependencies] 16 | proc-macro2.workspace = true 17 | quote.workspace = true 18 | syn.workspace = true 19 | thiserror.workspace = true 20 | syn_derive.workspace = true 21 | proc-macro2-diagnostics.workspace = true 22 | derive-where.workspace = true 23 | 24 | [dev-dependencies] 25 | proc-macro2 = { workspace = true, features = ["span-locations"] } 26 | criterion.workspace = true 27 | eyre.workspace = true 28 | 29 | [[bench]] 30 | name = "bench" 31 | harness = false 32 | path = "benches/bench.rs" 33 | 34 | 35 | [features] 36 | default = ["colors"] 37 | # Hack that parse input two times, using `proc-macro2::fallback` to recover spaces, and persist original spans. 38 | # It has no penalty in nightly, but in stable it parses input two times. 39 | # In order to use this feature, one should also set `ParserConfig::macro_call_pattern`. 40 | rawtext-stable-hack = ["rawtext-stable-hack-module"] 41 | # Export inters of rawtext_stable_hack. It is usefull if you need support of `UnquotedText` on stable but your macro is called from other one. 42 | rawtext-stable-hack-module = [] 43 | colors = ["proc-macro2-diagnostics/colors"] 44 | 45 | [lints.rust] 46 | unexpected_cfgs = { level = "allow", check-cfg = ['cfg(rstml_signal_nightly)'] } 47 | -------------------------------------------------------------------------------- /examples/html-to-string-macro/tests/tests.rs: -------------------------------------------------------------------------------- 1 | use rstml::node::RawText; 2 | use rstml_to_string_macro::html_ide; 3 | 4 | // Using this parser, one can write docs and link html tags to them. 5 | // if this macro would be independent, it would be nicer to have docs in 6 | // separate crate. 7 | pub mod docs { 8 | /// Element has open and close tags, content and attributes. 9 | pub fn element() {} 10 | } 11 | #[test] 12 | fn test() { 13 | let unquoted_text = " Hello world with spaces "; 14 | assert_eq!( 15 | cfg!(rstml_signal_nightly), 16 | RawText::is_source_text_available() 17 | ); 18 | let world = "planet"; 19 | assert_eq!( 20 | html_ide! { 21 | 22 | 23 | 24 | "Example" 25 | 26 | 27 | 28 |
29 | <> 30 |
"1"
31 |
Hello world with spaces
32 |
"3"
33 |
34 | 35 | 36 | 37 | }, 38 | format!( 39 | r#" 40 | 41 | 42 | 43 | Example 44 | 45 | 46 | 47 |
48 |
1
49 |
{}
50 |
3
51 |
52 | 53 | 54 | "#, 55 | unquoted_text 56 | ) 57 | .split('\n') 58 | .map(|line| line.trim()) 59 | .collect::>() 60 | .join("") 61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /rstml/src/node/node_value.rs: -------------------------------------------------------------------------------- 1 | //! 2 | //! Node value type 3 | use std::convert::TryFrom; 4 | 5 | use proc_macro2::TokenStream; 6 | use syn::{token::Brace, Block}; 7 | 8 | #[derive(Clone, Debug, syn_derive::ToTokens, syn_derive::Parse)] 9 | pub struct InvalidBlock { 10 | #[syn(braced)] 11 | pub brace: Brace, 12 | #[syn(in = brace)] 13 | pub body: TokenStream, 14 | } 15 | 16 | /// Block node. 17 | /// 18 | /// Arbitrary rust code in braced `{}` blocks. 19 | #[derive(Clone, Debug, syn_derive::ToTokens)] 20 | pub enum NodeBlock { 21 | /// The block value.. 22 | ValidBlock(Block), 23 | 24 | Invalid(InvalidBlock), 25 | } 26 | 27 | impl NodeBlock { 28 | /// 29 | /// Returns syntactically valid `syn::Block` of Rust code. 30 | /// 31 | /// Usually to make macro expansion IDE friendly, its better to use 32 | /// `ToTokens` instead. Because it also emit blocks that is invalid for 33 | /// syn, but valid for rust and rust analyzer. But if you need early 34 | /// checks that this block is valid - use this method. 35 | /// 36 | /// Example of blocks that will or will not parse: 37 | /// ```no_compile 38 | /// {x.} // Rust will parse this syntax, but for syn this is invalid Block, because after dot ident is expected. 39 | /// // Emiting code like this for rust analyzer allows it to find completion. 40 | /// // This block is parsed as NodeBlock::Invalid 41 | /// {]} // this is invalid syntax for rust compiler and rust analyzer so it will not be parsed at all. 42 | /// {x + y} // Valid syn Block, parsed as NodeBlock::Valid 43 | /// ``` 44 | pub fn try_block(&self) -> Option<&Block> { 45 | match self { 46 | Self::ValidBlock(b) => Some(b), 47 | Self::Invalid(_) => None, 48 | } 49 | } 50 | } 51 | 52 | impl TryFrom for Block { 53 | type Error = syn::Error; 54 | fn try_from(v: NodeBlock) -> Result { 55 | match v { 56 | NodeBlock::ValidBlock(v) => Ok(v), 57 | NodeBlock::Invalid(_) => Err(syn::Error::new_spanned( 58 | v, 59 | "Cant parse expression as block.", 60 | )), 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /examples/html-to-string-macro/tests/ui/first.stderr: -------------------------------------------------------------------------------- 1 | error: expected expression, found `,` 2 | --> tests/ui/first.rs:31:6 3 | | 4 | 31 | }, 5 | | ^ expected expression 6 | 7 | error: unexpected end of input, expected identifier or integer 8 | --> tests/ui/first.rs:14:42 9 | | 10 | 14 |
"1" { world.} "2"
11 | | ^ 12 | 13 | error: unexpected token: `}` 14 | --> tests/ui/first.rs:14:34 15 | | 16 | 14 |
"1" { world.} "2"
17 | | ^^^^^^^^^ 18 | 19 | error[E0425]: cannot find value `x` in this scope 20 | --> tests/ui/first.rs:13:28 21 | | 22 | 13 |
23 | | ^ not found in this scope 24 | 25 | error[E0425]: cannot find value `world` in this scope 26 | --> tests/ui/first.rs:14:36 27 | | 28 | 14 |
"1" { world.} "2"
29 | | ^^^^^ not found in this scope 30 | 31 | error[E0425]: cannot find value `a` in this scope 32 | --> tests/ui/first.rs:17:32 33 | | 34 | 17 |
"2"
35 | | ^ not found in this scope 36 | 37 | error[E0425]: cannot find value `a` in this scope 38 | --> tests/ui/first.rs:19:29 39 | | 40 | 19 |
some unquoted text with quotes "3".
41 | | ^ not found in this scope 42 | 43 | error[E0601]: `main` function not found in crate `$CRATE` 44 | --> tests/ui/first.rs:32:2 45 | | 46 | 32 | } 47 | | ^ consider adding a `main` function to `$DIR/tests/ui/first.rs` 48 | 49 | error[E0308]: mismatched types 50 | --> tests/ui/first.rs:3:5 51 | | 52 | 3 | / html! { 53 | 4 | | 54 | 5 | | 55 | 6 | | 56 | ... | 57 | 30 | | // 58 | 31 | | }, 59 | | |_____^ expected `()`, found `String` 60 | | 61 | = note: this error originates in the macro `format` which comes from the expansion of the macro `html` (in Nightly builds, run with -Z macro-backtrace for more info) 62 | -------------------------------------------------------------------------------- /cliff.toml: -------------------------------------------------------------------------------- 1 | # configuration file for git-cliff (0.1.0) 2 | 3 | [changelog] 4 | # changelog header 5 | header = """ 6 | # Changelog\n 7 | All notable changes to this project will be documented in this file.\n 8 | """ 9 | # template for the changelog body 10 | # https://tera.netlify.app/docs/#introduction 11 | body = """ 12 | {% if version %}\ 13 | ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} 14 | {% else %}\ 15 | ## [unreleased] 16 | {% endif %}\ 17 | {% for group, commits in commits | group_by(attribute="group") %} 18 | ### {{ group | upper_first }} 19 | {% for commit in commits %} 20 | - {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message | upper_first }}\ 21 | {% endfor %} 22 | {% endfor %}\n 23 | """ 24 | # remove the leading and trailing whitespace from the template 25 | trim = true 26 | # changelog footer 27 | footer = "" 28 | 29 | [git] 30 | # parse the commits based on https://www.conventionalcommits.org 31 | conventional_commits = true 32 | # filter out the commits that are not conventional 33 | filter_unconventional = false 34 | # process each line of a commit as an individual commit 35 | split_commits = false 36 | # regex for preprocessing the commit messages 37 | commit_preprocessors = [ 38 | { pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](https://github.com/rs-tml/rstml/issues/${2}))"}, 39 | ] 40 | # regex for parsing and grouping commits 41 | commit_parsers = [ 42 | { message = "^[fF]eat", group = "Features"}, 43 | { message = "^[dD]oc", group = "Documentation"}, 44 | { message = "^[pP]erf", group = "Performance"}, 45 | { message = "^[rR]efactor", group = "Refactor"}, 46 | { message = "^[sS]tyle", group = "Styling"}, 47 | { message = "^[tT]est", group = "Testing"}, 48 | { message = "^[rR]elease", skip = true}, 49 | { message = "^Merge pull request", skip = true}, 50 | { message = "^[cC]hore", group = "Chore"}, 51 | { message = "^[fF]ix", group = "Fix"}, 52 | { body = ".*[sS]ecurity", group = "Security"}, 53 | { message = ".*", group = "Other", default_scope = "other"}, 54 | ] 55 | # filter out the commits that are not matched by commit parsers 56 | filter_commits = false 57 | # glob pattern for matching git tags 58 | tag_pattern = "v[0-9]*" 59 | # regex for skipping tags 60 | skip_tags = "v0.1.0-beta.1" 61 | # regex for ignoring tags 62 | ignore_tags = "" 63 | # sort the tags chronologically 64 | date_order = false 65 | # sort the commits inside sections by oldest/newest order 66 | sort_commits = "oldest" 67 | -------------------------------------------------------------------------------- /rstml/tests/custom_node.rs: -------------------------------------------------------------------------------- 1 | use quote::{quote, TokenStreamExt}; 2 | use rstml::{ 3 | atoms::{self, OpenTag, OpenTagEnd}, 4 | node::{CustomNode, Node, NodeElement}, 5 | recoverable::{ParseRecoverable, Recoverable}, 6 | Parser, ParserConfig, 7 | }; 8 | use syn::{parse_quote, Expr, Token}; 9 | 10 | #[derive(Debug, syn_derive::ToTokens)] 11 | struct If { 12 | token_lt: Token![<], 13 | token_if: Token![if], 14 | condition: Expr, 15 | open_tag_end: OpenTagEnd, 16 | #[to_tokens(TokenStreamExt::append_all)] 17 | body: Vec, 18 | close_tag: Option, 19 | } 20 | impl ParseRecoverable for If { 21 | fn parse_recoverable( 22 | parser: &mut rstml::recoverable::RecoverableContext, 23 | input: syn::parse::ParseStream, 24 | ) -> Option { 25 | let token_lt = OpenTag::parse_start_tag(parser, input)?; 26 | let token_if = parser.parse_simple(input)?; 27 | let (condition, open_tag_end): (_, OpenTagEnd) = parser.parse_simple_until(input)?; 28 | let (body, close_tag) = if open_tag_end.token_solidus.is_none() { 29 | // Passed to allow parsing of close_tag 30 | NodeElement::parse_children( 31 | parser, 32 | input, 33 | false, 34 | &OpenTag { 35 | token_lt, 36 | name: parse_quote!(#token_if), 37 | generics: Default::default(), 38 | attributes: Default::default(), 39 | end_tag: open_tag_end.clone(), 40 | }, 41 | )? 42 | } else { 43 | (Vec::new(), None) 44 | }; 45 | Some(Self { 46 | token_lt, 47 | token_if, 48 | condition, 49 | open_tag_end, 50 | body, 51 | close_tag, 52 | }) 53 | } 54 | } 55 | 56 | impl CustomNode for If { 57 | fn peek_element(input: syn::parse::ParseStream) -> bool { 58 | input.peek(Token![<]) && input.peek2(Token![if]) 59 | } 60 | } 61 | 62 | #[test] 63 | fn custom_node() { 64 | let actual: Recoverable> = parse_quote! { 65 | 66 | 67 |
68 |
69 |
70 | }; 71 | let Node::Custom(actual) = actual.inner() else { 72 | panic!() 73 | }; 74 | 75 | assert_eq!(actual.condition, parse_quote!(just && an || expression)); 76 | } 77 | 78 | #[test] 79 | fn custom_node_using_config() { 80 | let actual = Parser::new( 81 | ParserConfig::new() 82 | .element_close_use_default_wildcard_ident(false) 83 | .custom_node::(), 84 | ) 85 | .parse_simple(quote! { 86 | 87 | 88 |
89 |
90 | 91 | }) 92 | .unwrap(); 93 | let Node::Custom(actual) = &actual[0] else { 94 | panic!() 95 | }; 96 | 97 | assert_eq!(actual.condition, parse_quote!(just && an || expression)); 98 | } 99 | -------------------------------------------------------------------------------- /rstml-control-flow/README.md: -------------------------------------------------------------------------------- 1 | # Control flow implementation for `rstml` 2 | 3 | The collection of `rstml` `CustomNode`s for the control flow. 4 | 5 | ## Motivation 6 | 7 | This crate aims to provide an example of how to extend `rstml` with custom nodes. 8 | Using custom nodes instead of using inner macro calls decreases the complexity of written templates, and allows `rstml` to parse the whole template at once. 9 | 10 | 11 | ## Custom nodes 12 | Custom nodes in `rstml` are allowing external code to extend the `Node` enum. This is useful for supporting 13 | custom syntax that is not common for HTML/XML documents. It is only allowed to be used in the context of `Node`, not in element attributes or node names. 14 | 15 | # Control flow implementation 16 | 17 | The common use case for custom nodes is implementing if/else operators and loops. This crate provides two different ways of implementing if/else control flow. 18 | 19 | ## Control flow using tags 20 | 21 | The first way is to use custom tags. This is the most native way of implementing control flow in HTML templates since control flow looks like a regular HTML element. The downside of this approach is that it is not possible to properly parse `Rust` expressions inside HTML element attributes. 22 | For example, for ` bar> ` it is hard to determine where the tag with attributes ends and where the content starts. 23 | 24 | In this crate, we force the user to use a special delimiter at the end of the tag. 25 | so instead of ` bar> ` we have to write ` bar !> `, where `!>` is a delimiter. This special syntax is used inside `` and `` tags as well. 26 | 27 | Example: 28 | ```rust 29 | use rstml::{parse2_with_config, node::*}; 30 | use rstml_controll_flow::tags::*; 31 | use quote::quote; 32 | 33 | 34 | let template = quote!{ 35 | 36 |

foo is true

37 | 38 |

bar is true

39 | 40 | 41 |

foo and bar are false

42 | 43 |
44 | } 45 | 46 | let nodes = parse2_with_config(template, Default::default().with_custom_nodes::()) 47 | .unwrap(); 48 | ``` 49 | Note: that `else if` and `else` tags are optional and their content is moved to the fields of the `IfNode`. Other nodes inside the `if` tag are all collected into the `IfNode::body` field, even if they were between `` and `` tags in the example above. 50 | 51 | 52 | ## Controll flow using escape symbol in unquoted text. 53 | 54 | The second way is to use the escape symbol in unquoted text. 55 | This approach is more native for `Rust` since it is declared in the same way as in `Rust` code. 56 | The only difference is that the block inside `{}` is not `Rust` code, but `rstml` template. 57 | 58 | Example: 59 | ```rust 60 | use rstml::{parse2_with_config, node::*}; 61 | use rstml_controll_flow::escape::*; 62 | use quote::quote; 63 | 64 | 65 | let template = quote!{ 66 |

67 | @if foo { 68 |

foo is true

69 | } else if bar { 70 |

bar is true

71 | } else { 72 |

foo and bar are false

73 | } 74 |

75 | }; 76 | 77 | let nodes = parse2_with_config(template, Default::default().with_custom_nodes::()) 78 | ``` 79 | 80 | `EscapedCode` escape character is configurable, and by default uses the "@" symbol. 81 | 82 | 83 | ## Using multiple `CustomNode`s at once 84 | It is also possible to use more than one `CustomNode` at once. 85 | For example, if you want to use both `Conditions` and `EscapedCode` custom nodes. 86 | `rstml-control-flow` crate provides an `ExtendableCustomNode` struct that can be used to combine multiple `CustomNode`s into one. Check out `extendable.rs` docs and tests in `lib.rs` for more details. 87 | 88 | 89 | ```rust -------------------------------------------------------------------------------- /examples/html-to-string-macro/tests/ui/multiple_errors.stderr: -------------------------------------------------------------------------------- 1 | error: unexpected end of input, expected identifier or integer 2 | --> tests/ui/multiple_errors.rs:13:42 3 | | 4 | 13 |
"1" { world.} "2"
5 | | ^ 6 | 7 | error: unexpected end of input, expected identifier or integer 8 | --> tests/ui/multiple_errors.rs:18:63 9 | | 10 | 18 |
"3"
11 | | ^ 12 | 13 | error: wrong close tag found 14 | --> tests/ui/multiple_errors.rs:21:17 15 | | 16 | 21 |
17 | | ^^^^^ 18 | | 19 | help: open tag that should be closed; it's started here 20 | --> tests/ui/multiple_errors.rs:20:17 21 | | 22 | 20 |
23 | | ^^^^^^^^^^^^^^^^^^^^^^ 24 | 25 | error: wrong close tag found 26 | --> tests/ui/multiple_errors.rs:22:17 27 | | 28 | 22 | 29 | | ^^^^ 30 | | 31 | help: open tag that should be closed; it's started here 32 | --> tests/ui/multiple_errors.rs:10:13 33 | | 34 | 10 | 35 | | ^^^^^^ 36 | 37 | error: wrong close tag found 38 | --> tests/ui/multiple_errors.rs:23:13 39 | | 40 | 23 | 41 | | ^^^^^^^ 42 | | 43 | help: open tag that should be closed; it's started here 44 | --> tests/ui/multiple_errors.rs:6:9 45 | | 46 | 6 | 47 | | ^^^^^^ 48 | 49 | error: close tag was parsed while waiting for open tag 50 | --> tests/ui/multiple_errors.rs:24:10 51 | | 52 | 24 | 53 | | ^ 54 | 55 | error: open tag has no corresponding close tag 56 | --> tests/ui/multiple_errors.rs:24:9 57 | | 58 | 24 | 59 | | ^^^^^^^ 60 | 61 | error: unexpected token: `}` 62 | --> tests/ui/multiple_errors.rs:13:34 63 | | 64 | 13 |
"1" { world.} "2"
65 | | ^^^^^^^^^ 66 | 67 | error: unexpected token: `}` 68 | --> tests/ui/multiple_errors.rs:18:26 69 | | 70 | 18 |
"3"
71 | | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 72 | 73 | error[E0425]: cannot find value `x` in this scope 74 | --> tests/ui/multiple_errors.rs:12:28 75 | | 76 | 12 |
77 | | ^ not found in this scope 78 | 79 | error[E0425]: cannot find value `world` in this scope 80 | --> tests/ui/multiple_errors.rs:13:36 81 | | 82 | 13 |
"1" { world.} "2"
83 | | ^^^^^ not found in this scope 84 | 85 | error[E0425]: cannot find value `a` in this scope 86 | --> tests/ui/multiple_errors.rs:15:32 87 | | 88 | 15 |
"2"
89 | | ^ not found in this scope 90 | 91 | error[E0425]: cannot find value `a` in this scope 92 | --> tests/ui/multiple_errors.rs:16:29 93 | | 94 | 16 |
some unquoted text with quotes "3".
95 | | ^ not found in this scope 96 | 97 | error[E0425]: cannot find value `world` in this scope 98 | --> tests/ui/multiple_errors.rs:20:28 99 | | 100 | 20 |
101 | | ^^^^^ not found in this scope 102 | 103 | error[E0425]: cannot find value `x` in this scope 104 | --> tests/ui/multiple_errors.rs:20:36 105 | | 106 | 20 |
107 | | ^ not found in this scope 108 | -------------------------------------------------------------------------------- /rstml/tests/recoverable_parser.rs: -------------------------------------------------------------------------------- 1 | use std::{convert::TryFrom, str::FromStr}; 2 | 3 | use eyre::Result; 4 | use proc_macro2::TokenStream; 5 | use quote::quote; 6 | use rstml::{ 7 | node::{Node, NodeAttribute, NodeBlock}, 8 | Parser, ParserConfig, 9 | }; 10 | use syn::Block; 11 | 12 | #[test] 13 | fn test_recover_incorrect_closing_tags() { 14 | let stream = quote!(
); 15 | 16 | let config = ParserConfig::new().recover_block(true); 17 | let parser = Parser::new(config); 18 | // by default parse return error 19 | assert!(parser.parse_simple(stream.clone()).is_err()); 20 | 21 | let (nodes, _errors) = parser.parse_recoverable(stream).split_vec(); 22 | assert_eq!(nodes.len(), 1); 23 | let Node::Element(e) = &nodes[0] else { 24 | panic!("Not element") 25 | }; 26 | assert_eq!(e.children.len(), 2); 27 | let Node::Element(c) = &e.children[0] else { 28 | panic!("No child") 29 | }; 30 | assert_eq!(c.open_tag.name.to_string(), "open"); 31 | assert_eq!(c.close_tag.as_ref().unwrap().name.to_string(), "close"); 32 | 33 | let Node::Element(c) = &e.children[1] else { 34 | panic!("No child") 35 | }; 36 | assert_eq!(c.open_tag.name, c.close_tag.as_ref().unwrap().name); 37 | assert_eq!(c.open_tag.name.to_string(), "foo") 38 | } 39 | 40 | #[test] 41 | fn test_parse_invalid_block() -> Result<()> { 42 | let tokens = TokenStream::from_str( 43 | "{x.}", // dot is not allowed 44 | ) 45 | .unwrap(); 46 | let config = ParserConfig::new().recover_block(true); 47 | let (nodes, errors) = Parser::new(config).parse_recoverable(tokens).split_vec(); 48 | assert!(!errors.is_empty()); 49 | 50 | let Node::Block(block) = &nodes[0].children().unwrap()[0] else { 51 | panic!("expected block") 52 | }; 53 | 54 | assert!(block.try_block().is_none()); 55 | 56 | assert!(Block::try_from(block.clone()).is_err()); 57 | Ok(()) 58 | } 59 | 60 | #[test] 61 | fn test_parse_invalid_attr_block() -> Result<()> { 62 | let tokens = TokenStream::from_str( 63 | "", // dot is not allowed 64 | ) 65 | .unwrap(); 66 | let config = ParserConfig::new().recover_block(true); 67 | let (nodes, errors) = Parser::new(config).parse_recoverable(tokens).split_vec(); 68 | 69 | assert!(!errors.is_empty()); 70 | 71 | let Node::Element(f) = &nodes[0] else { 72 | panic!("expected element") 73 | }; 74 | let NodeAttribute::Block(NodeBlock::Invalid { .. }) = f.attributes()[0] else { 75 | panic!("expected attribute") 76 | }; 77 | Ok(()) 78 | } 79 | 80 | #[test] 81 | fn test_parse_closed_tag_without_open() -> Result<()> { 82 | let tokens = TokenStream::from_str("").unwrap(); 83 | let config = ParserConfig::new().recover_block(true); 84 | let (nodes, errors) = Parser::new(config).parse_recoverable(tokens).split_vec(); 85 | 86 | assert!(!errors.is_empty()); 87 | 88 | let Node::Element(f) = &nodes[0] else { 89 | panic!("expected element") 90 | }; 91 | assert_eq!(f.open_tag.name.to_string(), "foo"); 92 | Ok(()) 93 | } 94 | 95 | #[test] 96 | fn test_parse_open_tag_without_close() -> Result<()> { 97 | let tokens = TokenStream::from_str(" ").unwrap(); 98 | let config = ParserConfig::new().recover_block(true); 99 | let (nodes, errors) = Parser::new(config).parse_recoverable(tokens).split_vec(); 100 | 101 | assert!(!errors.is_empty()); 102 | 103 | let Node::Element(f) = &nodes[0] else { 104 | panic!("expected element") 105 | }; 106 | assert_eq!(f.open_tag.name.to_string(), "foo"); 107 | 108 | let Node::Element(f) = &f.children[0] else { 109 | panic!("expected element") 110 | }; 111 | assert_eq!(f.open_tag.name.to_string(), "bar"); 112 | Ok(()) 113 | } 114 | 115 | // TODO: keyed attribute 116 | -------------------------------------------------------------------------------- /rstml-control-flow/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! Example of controll flow implementations: 2 | //! 1. One variant is based on tags `` `` `` `` 3 | //! 2. another variand is based on escape character inside unquoted texts `@if 4 | //! {}` `@for foo in array {}` 5 | use std::marker::PhantomData; 6 | 7 | use quote::ToTokens; 8 | use syn::parse::{Parse, ParseStream}; 9 | 10 | pub mod escape; 11 | #[cfg(feature = "extendable")] 12 | pub mod extendable; 13 | pub mod tags; 14 | 15 | #[cfg(feature = "extendable")] 16 | pub use extendable::ExtendableCustomNode; 17 | 18 | // Either variant, with Parse/ToTokens implementation 19 | #[derive(Copy, Clone, Debug)] 20 | pub enum Either { 21 | A(A), 22 | B(B), 23 | } 24 | impl Parse for Either { 25 | fn parse(input: ParseStream) -> syn::Result { 26 | if Self::peek_a(input) { 27 | input.parse().map(Self::A) 28 | } else { 29 | input.parse().map(Self::B) 30 | } 31 | } 32 | } 33 | impl ToTokens for Either { 34 | fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { 35 | match self { 36 | Self::A(a) => a.to_tokens(tokens), 37 | Self::B(b) => b.to_tokens(tokens), 38 | } 39 | } 40 | } 41 | 42 | #[allow(dead_code)] 43 | impl Either { 44 | pub fn peek_a(stream: ParseStream) -> bool 45 | where 46 | A: Parse, 47 | B: Parse, 48 | { 49 | stream.fork().parse::
().is_ok() 50 | } 51 | pub fn to_b(self) -> Option { 52 | match self { 53 | Self::A(_) => None, 54 | Self::B(b) => Some(b), 55 | } 56 | } 57 | pub fn to_a(self) -> Option { 58 | match self { 59 | Self::A(a) => Some(a), 60 | Self::B(_) => None, 61 | } 62 | } 63 | pub fn is_b(self) -> bool { 64 | match self { 65 | Self::A(_) => false, 66 | Self::B(_) => true, 67 | } 68 | } 69 | pub fn is_a(self) -> bool { 70 | match self { 71 | Self::A(_) => true, 72 | Self::B(_) => false, 73 | } 74 | } 75 | } 76 | 77 | pub struct EitherA(pub A, pub PhantomData); 78 | pub struct EitherB(pub PhantomData, pub B); 79 | 80 | impl TryFrom> for EitherA { 81 | type Error = Either; 82 | fn try_from(value: Either) -> Result { 83 | match value { 84 | Either::A(a) => Ok(EitherA(a, PhantomData)), 85 | rest => Err(rest), 86 | } 87 | } 88 | } 89 | 90 | impl TryFrom> for EitherB { 91 | type Error = Either; 92 | fn try_from(value: Either) -> Result { 93 | match value { 94 | Either::B(b) => Ok(EitherB(PhantomData, b)), 95 | rest => Err(rest), 96 | } 97 | } 98 | } 99 | 100 | impl From> for Either { 101 | fn from(value: EitherA) -> Self { 102 | Self::A(value.0) 103 | } 104 | } 105 | 106 | impl From> for Either { 107 | fn from(value: EitherB) -> Self { 108 | Self::B(value.1) 109 | } 110 | } 111 | 112 | pub trait TryIntoOrCloneRef: Sized { 113 | fn try_into_or_clone_ref(self) -> Either; 114 | fn new_from_value(value: T) -> Self; 115 | } 116 | 117 | impl TryIntoOrCloneRef for T { 118 | fn try_into_or_clone_ref(self) -> Either { 119 | Either::A(self) 120 | } 121 | fn new_from_value(value: T) -> Self { 122 | value 123 | } 124 | } 125 | 126 | #[cfg(test)] 127 | mod tests { 128 | 129 | #[test] 130 | #[cfg(feature = "extendable")] 131 | fn test_mixed_tags_and_escape() { 132 | use quote::ToTokens; 133 | use rstml::node::Node; 134 | 135 | use crate::{escape, tags, ExtendableCustomNode}; 136 | 137 | let tokens = quote::quote! { 138 | @if true { 139 |

True

140 | 141 |

Foo

142 |
143 | } 144 | else { 145 |

False

146 | } 147 | @for foo in array { 148 | 149 |

Foo

150 |
151 |

Foo

152 | } 153 | }; 154 | 155 | let result = ExtendableCustomNode::parse2_with_config::<( 156 | tags::Conditions, 157 | escape::EscapeCode, 158 | )>(Default::default(), tokens); 159 | let ok = result.into_result().unwrap(); 160 | assert_eq!(ok.len(), 2); 161 | 162 | let Node::Custom(c) = &ok[0] else { 163 | unreachable!() 164 | }; 165 | let escape_if = c.try_downcast_ref::().unwrap(); 166 | let escape::EscapedExpr::If(if_) = &escape_if.expression else { 167 | unreachable!() 168 | }; 169 | assert_eq!(if_.condition.to_token_stream().to_string(), "true"); 170 | let for_tag = &if_.then_branch.body[1]; 171 | let Node::Custom(c) = &for_tag else { 172 | unreachable!() 173 | }; 174 | let for_tag = c.try_downcast_ref::().unwrap(); 175 | let tags::Conditions::For(for_) = for_tag else { 176 | unreachable!() 177 | }; 178 | 179 | assert_eq!(for_.pat.to_token_stream().to_string(), "foo"); 180 | 181 | let Node::Custom(c) = &ok[1] else { 182 | unreachable!() 183 | }; 184 | 185 | let escape_for = c.try_downcast_ref::().unwrap(); 186 | let escape::EscapedExpr::For(for_) = &escape_for.expression else { 187 | unreachable!() 188 | }; 189 | assert_eq!(for_.pat.to_token_stream().to_string(), "foo"); 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /rstml/src/parser/mod.rs: -------------------------------------------------------------------------------- 1 | //! RSX Parser 2 | 3 | use std::vec; 4 | 5 | use proc_macro2::TokenStream; 6 | use proc_macro2_diagnostics::Diagnostic; 7 | use syn::{parse::ParseStream, spanned::Spanned, Result}; 8 | 9 | pub mod recoverable; 10 | 11 | #[cfg(feature = "rawtext-stable-hack")] 12 | use {proc_macro2::Span, std::str::FromStr}; 13 | 14 | use self::recoverable::{ParseRecoverable, ParsingResult, RecoverableContext}; 15 | #[cfg(feature = "rawtext-stable-hack")] 16 | use crate::rawtext_stable_hack; 17 | use crate::{node::*, ParserConfig}; 18 | /// 19 | /// Primary library interface to RSX Parser 20 | /// 21 | /// Allows customization through `ParserConfig`. 22 | /// Support recovery after parsing invalid token. 23 | 24 | pub struct Parser { 25 | config: ParserConfig, 26 | } 27 | 28 | impl Default for Parser { 29 | fn default() -> Self { 30 | Self { 31 | config: ParserConfig::default(), 32 | } 33 | } 34 | } 35 | 36 | impl Parser { 37 | /// Create a new parser with the given [`ParserConfig`]. 38 | pub fn new(config: ParserConfig) -> Self { 39 | Parser { config } 40 | } 41 | 42 | /// Parse the given [`proc-macro2::TokenStream`] or 43 | /// [`proc-macro::TokenStream`] into a [`Node`] tree. 44 | /// 45 | /// [`proc-macro2::TokenStream`]: https://docs.rs/proc-macro2/latest/proc_macro2/struct.TokenStream.html 46 | /// [`proc-macro::TokenStream`]: https://doc.rust-lang.org/proc_macro/struct.TokenStream.html 47 | /// [`Node`]: struct.Node.html 48 | pub fn parse_simple(&self, v: impl Into) -> Result>> { 49 | self.parse_recoverable(v).into_result() 50 | } 51 | 52 | /// Advance version of `parse_simple` that returns array of errors in case 53 | /// of partial parsing. 54 | pub fn parse_recoverable(&self, v: impl Into) -> ParsingResult>> { 55 | use syn::parse::Parser as _; 56 | 57 | let parser = move |input: ParseStream| Ok(self.parse_syn_stream(input)); 58 | let source = parser.parse2(v.into()).expect("No errors from parser"); 59 | 60 | #[cfg(feature = "rawtext-stable-hack")] 61 | // re-parse using proc_macro2::fallback, only if output without error 62 | let source = Self::reparse_raw_text(&self, parser, source); 63 | source 64 | } 65 | #[cfg(feature = "rawtext-stable-hack")] 66 | fn reparse_raw_text( 67 | &self, 68 | parser: Parser, 69 | mut source: ParsingResult>>, 70 | ) -> ParsingResult>> 71 | where 72 | Parser: FnOnce(ParseStream) -> syn::Result>>>, 73 | { 74 | use syn::parse::Parser as _; 75 | // in case we already have valid raw_text, we can skip re-parsing 76 | if rawtext_stable_hack::is_join_span_available() { 77 | return source; 78 | } 79 | // Source is err, so we need to use fallback. 80 | if !source.is_ok() { 81 | let (mut source, errors) = source.split_vec(); 82 | rawtext_stable_hack::inject_raw_text_default(&mut source); 83 | return ParsingResult::from_parts_vec(source, errors); 84 | } 85 | // return error, if macro source_text is not available. 86 | if !rawtext_stable_hack::is_macro_args_recoverable() { 87 | source.push_diagnostic(Diagnostic::new( 88 | proc_macro2_diagnostics::Level::Warning, 89 | "Failed to retrive source text of macro call, maybe macro was called from other macro?", 90 | )); 91 | return source; 92 | } 93 | // Feature is additive, this mean that top-level crate can activate 94 | // "rawtext-stable-hack", but other crates will not use macro_pattern 95 | if self.config.macro_pattern.is_empty() { 96 | return source; 97 | } 98 | let text = Span::call_site() 99 | .source_text() 100 | .expect("Source text should be available"); 101 | 102 | proc_macro2::fallback::force(); 103 | let stream = TokenStream::from_str(&text).unwrap(); 104 | let stream = self 105 | .config 106 | .macro_pattern 107 | .match_content(stream) 108 | .expect("Cannot find macro pattern inside Span::call_site"); 109 | let hacked = parser.parse2(stream).expect("No errors from parser"); 110 | 111 | let mut source = source.into_result().expect("was checked"); 112 | let hacked = hacked.into_result().expect("was checked"); 113 | proc_macro2::fallback::unforce(); 114 | rawtext_stable_hack::inject_raw_text(&mut source, &hacked); 115 | 116 | return ParsingResult::Ok(source); 117 | } 118 | 119 | /// Parse a given [`ParseStream`]. 120 | pub fn parse_syn_stream(&self, input: ParseStream) -> ParsingResult>> { 121 | let mut nodes = vec![]; 122 | let mut top_level_nodes = 0; 123 | 124 | let mut parser = RecoverableContext::new(self.config.clone().into()); 125 | while !input.is_empty() { 126 | let Some(parsed_node) = Node::parse_recoverable(&mut parser, input) else { 127 | parser.push_diagnostic(input.error("Node parse failed".to_string())); 128 | break; 129 | }; 130 | 131 | if let Some(type_of_top_level_nodes) = &self.config.type_of_top_level_nodes { 132 | if &parsed_node.r#type() != type_of_top_level_nodes { 133 | parser.push_diagnostic(input.error(format!( 134 | "top level nodes need to be of type {}", 135 | type_of_top_level_nodes 136 | ))); 137 | break; 138 | } 139 | } 140 | 141 | top_level_nodes += 1; 142 | nodes.push(parsed_node) 143 | } 144 | 145 | // its important to skip tokens, to avoid Unexpected tokens errors. 146 | if !input.is_empty() { 147 | let tts = input 148 | .parse::() 149 | .expect("No error in parsing token stream"); 150 | parser.push_diagnostic(Diagnostic::spanned( 151 | tts.span(), 152 | proc_macro2_diagnostics::Level::Error, 153 | "Tokens was skipped after incorrect parsing", 154 | )); 155 | } 156 | 157 | if let Some(number_of_top_level_nodes) = &self.config.number_of_top_level_nodes { 158 | if &top_level_nodes != number_of_top_level_nodes { 159 | parser.push_diagnostic(input.error(format!( 160 | "saw {} top level nodes but exactly {} are required", 161 | top_level_nodes, number_of_top_level_nodes 162 | ))) 163 | } 164 | } 165 | 166 | let nodes = if self.config.flat_tree { 167 | nodes.into_iter().flat_map(Node::flatten).collect() 168 | } else { 169 | nodes 170 | }; 171 | 172 | let errors = parser.diagnostics; 173 | 174 | let nodes = if nodes.is_empty() { 175 | Some(vec![]) 176 | } else { 177 | Some(nodes) 178 | }; 179 | ParsingResult::from_parts(nodes, errors) 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /rstml-control-flow/src/extendable.rs: -------------------------------------------------------------------------------- 1 | //! 2 | //! Extendable `CustomNode`. 3 | //! Provide a way to mix different `CustomNode` implementation and parse them in 4 | //! mixed context. Uses `thread_local` to save parsing routines, therefore it 5 | //! should be initialized before usage. 6 | //! 7 | //! If implementors of custom node, want to support mixed context, and allow 8 | //! using their code in mixed with other custom nodes, they should follow next 9 | //! steps: 10 | //! 11 | //! 1. type Node 12 | //! Type alias that change it's meaning depending on feature flag `extendable`. 13 | //! Example: 14 | //! ```no_compile 15 | //! use rstml::node::Node as RNode; 16 | //! 17 | //! #[cfg(not(feature = "extendable"))] 18 | //! type Node = RNode; 19 | //! #[cfg(feature = "extendable")] 20 | //! type Node = RNode; 21 | //! ``` 22 | //! This allows custom node implementation to be used in both contexts. 23 | //! 24 | //! 2. (optional) Trait that allows working with both contexts (can be used one 25 | //! from) 26 | //! ```no_compile 27 | //! pub trait TryIntoOrCloneRef: Sized { 28 | //! fn try_into_or_clone_ref(self) -> Either; 29 | //! fn new_from_value(value: T) -> Self; 30 | //! } 31 | //! ``` 32 | //! And implementation of TryIntoOrCloneRef for both: 33 | //! 2.1. `impl TryIntoOrCloneRef for T` in order to work with custom nodes 34 | //! without extending. 35 | //! 2.2. `impl TryIntoOrCloneRef for 36 | //! crate::ExtendableCustomNode` in order to work with custom nodes with 37 | //! extending. 38 | //! 39 | //! 3. (optional) Implement trait `CustomNode` for `MyCustomNode` that will use 40 | //! trait defined in 2. 41 | 42 | use std::{any::Any, cell::RefCell, rc::Rc}; 43 | 44 | use proc_macro2_diagnostics::Diagnostic; 45 | use quote::ToTokens; 46 | use rstml::{ 47 | node::{CustomNode, Node}, 48 | recoverable::{ParseRecoverable, RecoverableContext}, 49 | Parser, ParserConfig, ParsingResult, 50 | }; 51 | use syn::parse::ParseStream; 52 | 53 | type ToTokensHandler = Box; 54 | type ParseRecoverableHandler = 55 | Box Option>; 56 | type PeekHandler = Box bool>; 57 | 58 | thread_local! { 59 | static TO_TOKENS: RefCell > = RefCell::new(None); 60 | static PARSE_RECOVERABLE: RefCell > = RefCell::new(None); 61 | static PEEK: RefCell > = RefCell::new(None); 62 | } 63 | 64 | #[derive(Clone, Debug)] 65 | pub struct ExtendableCustomNode { 66 | value: Rc, 67 | } 68 | 69 | impl ToTokens for ExtendableCustomNode { 70 | fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { 71 | TO_TOKENS.with_borrow(|f| f.as_ref().unwrap()(self, tokens)) 72 | } 73 | } 74 | 75 | impl ParseRecoverable for ExtendableCustomNode { 76 | fn parse_recoverable(ctx: &mut RecoverableContext, input: ParseStream) -> Option { 77 | PARSE_RECOVERABLE.with_borrow(|f| f.as_ref().unwrap()(ctx, input)) 78 | } 79 | } 80 | 81 | impl CustomNode for ExtendableCustomNode { 82 | fn peek_element(input: ParseStream) -> bool { 83 | PEEK.with_borrow(|f| f.as_ref().unwrap()(input)) 84 | } 85 | } 86 | trait Sealed {} 87 | 88 | #[allow(private_bounds)] 89 | pub trait Tuple: Sealed { 90 | fn to_tokens(this: &ExtendableCustomNode, tokens: &mut proc_macro2::TokenStream); 91 | fn parse_recoverable( 92 | ctx: &mut RecoverableContext, 93 | input: ParseStream, 94 | ) -> Option; 95 | fn peek(input: ParseStream) -> bool; 96 | } 97 | 98 | macro_rules! impl_tuple { 99 | ($($name:ident),*) => { 100 | impl<$($name: CustomNode + 'static),*> Sealed for ($($name,)*) {} 101 | impl<$($name: CustomNode + 'static+ std::fmt::Debug),*> Tuple for ($($name,)*) { 102 | fn to_tokens(this: &ExtendableCustomNode, tokens: &mut proc_macro2::TokenStream) { 103 | $(if let Some(v) = this.try_downcast_ref::<$name>() { 104 | v.to_tokens(tokens); 105 | })* 106 | } 107 | fn parse_recoverable(ctx: &mut RecoverableContext, input: ParseStream) -> Option { 108 | 109 | $(if $name::peek_element(&input.fork()) { 110 | $name::parse_recoverable(ctx, input).map(ExtendableCustomNode::from_value) 111 | })else* 112 | else { 113 | ctx.push_diagnostic(Diagnostic::new(proc_macro2_diagnostics::Level::Error, "Parsing invalid custom node")); 114 | None 115 | } 116 | } 117 | fn peek(input: ParseStream) -> bool { 118 | $($name::peek_element(&input.fork()))||* 119 | } 120 | } 121 | }; 122 | } 123 | 124 | impl_tuple!(A); 125 | impl_tuple!(A, B); 126 | impl_tuple!(A, B, C); 127 | impl_tuple!(A, B, C, D); 128 | impl_tuple!(A, B, C, D, E); 129 | impl_tuple!(A, B, C, D, E, F); 130 | impl_tuple!(A, B, C, D, E, F, G); 131 | impl_tuple!(A, B, C, D, E, F, G, H); 132 | 133 | fn init_extendable_node() { 134 | assert_context_empty(); 135 | TO_TOKENS.with_borrow_mut(|f| *f = Some(Box::new(E::to_tokens))); 136 | PARSE_RECOVERABLE.with_borrow_mut(|f| *f = Some(Box::new(E::parse_recoverable))); 137 | PEEK.with_borrow_mut(|f| *f = Some(Box::new(E::peek))); 138 | } 139 | 140 | fn assert_context_empty() { 141 | TO_TOKENS.with_borrow(|f| { 142 | assert!( 143 | f.is_none(), 144 | "Cannot init ExtendableCustomNode context multiple times" 145 | ) 146 | }); 147 | PARSE_RECOVERABLE.with_borrow(|f| { 148 | assert!( 149 | f.is_none(), 150 | "Cannot init ExtendableCustomNode context multiple times" 151 | ) 152 | }); 153 | PEEK.with_borrow(|f| { 154 | assert!( 155 | f.is_none(), 156 | "Cannot init ExtendableCustomNode context multiple times" 157 | ) 158 | }); 159 | } 160 | 161 | pub fn clear_context() { 162 | TO_TOKENS.with_borrow_mut(|f| *f = None); 163 | PARSE_RECOVERABLE.with_borrow_mut(|f| *f = None); 164 | PEEK.with_borrow_mut(|f| *f = None); 165 | } 166 | 167 | impl ExtendableCustomNode { 168 | pub fn from_value(value: T) -> Self { 169 | Self { 170 | value: Rc::new(value), 171 | } 172 | } 173 | pub fn try_downcast_ref(&self) -> Option<&T> { 174 | self.value.downcast_ref::() 175 | } 176 | 177 | /// Parses token stream into `Vec>` 178 | /// 179 | /// Note: This function are using context from thread local storage, 180 | /// after call it lefts context initiliazed, so to_tokens implementation can 181 | /// be used. But second call to parse2_with_config will fail, because 182 | /// context is already initialized. 183 | /// You can use `clear_context` to clear context manually. 184 | pub fn parse2_with_config( 185 | config: ParserConfig, 186 | tokens: proc_macro2::TokenStream, 187 | ) -> ParsingResult>> { 188 | init_extendable_node::(); 189 | let result = 190 | Parser::new(config.custom_node::()).parse_recoverable(tokens); 191 | result 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /comparsion-with-syn-rsx.md: -------------------------------------------------------------------------------- 1 | This fork has a lot of refactoring and features in it, to summarize differences this file was created: 2 | 3 | 4 | ## Syn_v2 released 5 | 6 | In [March syn v2 was released]( https://github.com/dtolnay/syn/releases/tag/2.0.0). 7 | Rust language evolves and parsing library should adopt to this changes. 8 | And updating dependencies generally sounds like a good idea. 9 | 10 | ## Lossless parser v1 11 | 12 | One of the ideas behind this fork was to implement all types in [lossless form](https://github.com/stoically/syn-rsx/issues/53), 13 | and to provide single `syn::Parse` implementation for them. 14 | In short example it looks like Parse implementation on types: 15 | 16 | ```rust 17 | pub struct OpenTag { 18 | pub less_sign: Token![<], 19 | pub tag_name: NodeName, 20 | pub attributes: Vec, 21 | pub solidus: Option, 22 | pub great_sign: Token![>], 23 | } 24 | pub struct CloseTag { 25 | pub less_sign: Token![<], 26 | pub solidus: Token![/], 27 | pub tag_name: NodeName, 28 | pub great_sign: Token![>], 29 | } 30 | 31 | pub struct NodeElement { 32 | pub open_tag: OpenTag, 33 | pub close_tag: Option, 34 | } 35 | ``` 36 | 37 | This was done in: https://github.com/vldm/syn-rsx/commit/ff2fb40692e149a2f6112d82bed31965119b6305 38 | And after it refactored to use syn_derive instead of handwritten implementation in: https://github.com/vldm/syn-rsx/commit/6806a71997dc3fcce09069d597da7cadce92896a 39 | 40 | 41 | Since that, everybody can reuse this parse to write write custom routine and parse mixed content, something like this, that is hard to achieve with custom entrypoint,: 42 | `parse_expr_and_few_nodes!(x+1 =>
$val
, x+2
$val2
)` 43 | 44 | As one of benefit of lossless representation - is ability to compute full `Span` even if `Span::join` is not available. 45 | Also now all types implement ToTokens, so it's now easier to implement macro that will process some input, modify it and save it for future use in inner macro. All this with saving original spans. 46 | 47 | 48 | ## Unquoted text 49 | 50 | One of the most demanded feature in syn-rsx was ["unquoted text"](https://github.com/stoically/syn-rsx/issues/2). 51 | Its the ability to write text without quotes inside html tags. 52 | 53 | ``` 54 |
Some text inside div
55 | 56 | 62 | 69 | ``` 70 | 71 | This feature is crucial for parsing `