diff --git a/.claude/rules/code-coverage.md b/.claude/rules/code-coverage.md new file mode 100644 index 0000000..26739d6 --- /dev/null +++ b/.claude/rules/code-coverage.md @@ -0,0 +1,3 @@ +# Code Coverage Measurement + +- Use `make coverage` to measure code coverage. This is the authoritative source. diff --git a/.github/actions/setup-dev-environment/action.yml b/.github/actions/setup-dev-environment/action.yml new file mode 100644 index 0000000..04d1d70 --- /dev/null +++ b/.github/actions/setup-dev-environment/action.yml @@ -0,0 +1,10 @@ +name: setup dev environment +description: Set up the Node.js and Rust toolchains needed to build the project. +runs: + using: composite + steps: + - name: setup node + uses: ./.github/actions/setup-node + + - name: setup rust + uses: ./.github/actions/setup-rust diff --git a/.github/actions/setup-node/action.yml b/.github/actions/setup-node/action.yml new file mode 100644 index 0000000..1f4053a --- /dev/null +++ b/.github/actions/setup-node/action.yml @@ -0,0 +1,10 @@ +name: setup node +description: Set up the Node.js toolchain used by jarmuz. +runs: + using: composite + steps: + - name: setup node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm diff --git a/.github/actions/setup-rust/action.yml b/.github/actions/setup-rust/action.yml new file mode 100644 index 0000000..7ada824 --- /dev/null +++ b/.github/actions/setup-rust/action.yml @@ -0,0 +1,7 @@ +name: setup rust +description: Set up the Rust build cache. +runs: + using: composite + steps: + - name: cache rust + uses: Swatinem/rust-cache@v2 diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 0000000..41acfed --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -0,0 +1,34 @@ +name: unit tests + +on: + push: + branches: + - main + pull_request: + +jobs: + rust: + name: rust + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v4 + + - name: setup rust + uses: ./.github/actions/setup-rust + + - name: run tests + run: make test + + clippy: + name: clippy + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v4 + + - name: setup rust + uses: ./.github/actions/setup-rust + + - name: run clippy + run: make clippy diff --git a/Makefile b/Makefile index b6a94c2..d74a1cd 100644 --- a/Makefile +++ b/Makefile @@ -6,16 +6,22 @@ RUST_LOG ?= debug # Real targets # ----------------------------------------------------------------------------- -package-lock.json: package.json - npm install --package-lock-only - node_modules: package-lock.json npm install --from-lockfile touch node_modules +package-lock.json: package.json + npm install --package-lock-only + public: node_modules ./jarmuz-generate.mjs +target/debug/poet: target/debug/poet + cargo build + +test_site-x86_64.AppImage: test_site.AppDir test_site.AppDir/poet + ARCH=x86_64 appimage-run ~/bin/appimagetool-x86_64.AppImage ./test_site.AppDir + test_site.AppDir: cargo run make app-dir . \ --name test_site \ @@ -23,15 +29,9 @@ test_site.AppDir: --title "Test Site" \ --version "1.2.3" -target/debug/poet: target/debug/poet - cargo build - test_site.AppDir/poet: target/debug/poet test_site.AppDir cp target/debug/poet test_site.AppDir/poet -test_site-x86_64.AppImage: test_site.AppDir test_site.AppDir/poet - ARCH=x86_64 appimage-run ~/bin/appimagetool-x86_64.AppImage ./test_site.AppDir - # ----------------------------------------------------------------------------- # Phony targets # ----------------------------------------------------------------------------- @@ -41,14 +41,33 @@ clean: rm -rf node_modules rm -rf target +.PHONY: clippy +clippy: + cargo clippy --workspace --all-targets -- -D warnings + +.PHONY: coverage +coverage: node_modules + cargo llvm-cov clean --workspace + cargo llvm-cov --workspace --no-report + cargo llvm-cov report --json --output-path target/llvm-cov.json + cargo llvm-cov report + npx rust-coverage-check target/llvm-cov.json \ + --workspace-root $(CURDIR) \ + --gated rhai_components \ + --required-percent 100 + .PHONY: fmt fmt: node_modules ./jarmuz-fmt.mjs -.PHONY: watch -watch: node_modules - ./jarmuz-watch.mjs - .PHONY: release release: cargo build --release + +.PHONY: test +test: + cargo test --workspace + +.PHONY: watch +watch: node_modules + ./jarmuz-watch.mjs diff --git a/package-lock.json b/package-lock.json index fdbd565..cf8a85e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "license": "Apache-2.0", "devDependencies": { + "@intentee/rust-coverage-check": "0.1.0", "@types/hotwired__turbo": "^8.0.4", "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", @@ -674,6 +675,19 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@intentee/rust-coverage-check": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@intentee/rust-coverage-check/-/rust-coverage-check-0.1.0.tgz", + "integrity": "sha512-0PpHUxGda5FjSOh7ebMrR99xbFRC93PnWY1mSTeuetX2yMgqVhY8Bjrg5GqedsxXZt6z7OrQ3SOwBA2gTgDUBg==", + "dev": true, + "license": "MIT", + "bin": { + "rust-coverage-check": "src/main.mjs" + }, + "engines": { + "node": ">=24.0.0" + } + }, "node_modules/@isaacs/balanced-match": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", @@ -753,7 +767,6 @@ "integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -774,7 +787,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1209,7 +1221,6 @@ "integrity": "sha512-RNCHRX5EwdrESy3Jc9o8ie8Bog+PeYvvSR8sDGoZxNFTvZ4dlxUB3WzQ3bQMztFrSRODGrLLj8g6OFuGY/aiQg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -2191,7 +2202,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -2320,7 +2330,6 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -2790,7 +2799,6 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index e52decd..a096b5c 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "type": "module", "version": "0.1.0", "devDependencies": { + "@intentee/rust-coverage-check": "0.1.0", "@types/hotwired__turbo": "^8.0.4", "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", diff --git a/poet/src/build_project/mod.rs b/poet/src/build_project/mod.rs index 4fbb4c6..e914a20 100644 --- a/poet/src/build_project/mod.rs +++ b/poet/src/build_project/mod.rs @@ -73,7 +73,7 @@ fn render_document<'render>( let component_context = ContentDocumentComponentContext { asset_manager: AssetManager::from_esbuild_metafile(esbuild_metafile, asset_path_renderer), authors: authors.clone(), - available_authors: available_authors, + available_authors, available_collections, content_document_collections_ranked, content_document_linker, diff --git a/poet/src/eval_mdx_element.rs b/poet/src/eval_mdx_element.rs index ed4aae5..962d430 100644 --- a/poet/src/eval_mdx_element.rs +++ b/poet/src/eval_mdx_element.rs @@ -27,8 +27,7 @@ where let tag_name = TagName { name: name .clone() - .ok_or_else(|| anyhow!("MdxJsxFlowElement without a name"))? - .into(), + .ok_or_else(|| anyhow!("MdxJsxFlowElement without a name"))?, }; let props = { diff --git a/poet/src/generate_sitemap.rs b/poet/src/generate_sitemap.rs index 5e90e1a..a898956 100644 --- a/poet/src/generate_sitemap.rs +++ b/poet/src/generate_sitemap.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::path::Path; use anyhow::Result; use anyhow::anyhow; @@ -16,7 +16,7 @@ pub fn create_sitemap<'a>( for reference in content_documents { let url = reference.canonical_link().map_err(|e| anyhow!(e))?; - let priority = if reference.basename_path == PathBuf::from("index") { + let priority = if reference.basename_path == Path::new("index") { 0.8 } else { 0.5 diff --git a/poet/src/mcp/mcp_http_service/respond_to_post/handler/resources_list_handler.rs b/poet/src/mcp/mcp_http_service/respond_to_post/handler/resources_list_handler.rs index 671dba0..961ca86 100644 --- a/poet/src/mcp/mcp_http_service/respond_to_post/handler/resources_list_handler.rs +++ b/poet/src/mcp/mcp_http_service/respond_to_post/handler/resources_list_handler.rs @@ -37,10 +37,7 @@ impl Handler for ResourcesListHandler { }: Self::Request, session: Self::Session, ) -> Result> { - let list_cursor = match cursor { - Some(list_cursor) => list_cursor, - None => ListResourcesCursor::default(), - }; + let list_cursor: ListResourcesCursor = cursor.unwrap_or_default(); if list_cursor.per_page < 1 { return Ok(HttpResponse::BadRequest().json(Error::invalid_params( diff --git a/rhai_components/src/builds_engine.rs b/rhai_components/src/builds_engine.rs index 4e47af9..6995107 100644 --- a/rhai_components/src/builds_engine.rs +++ b/rhai_components/src/builds_engine.rs @@ -1,11 +1,8 @@ use std::sync::Arc; use anyhow::Result; -use dashmap::DashMap; use rhai::Engine; -use rhai::Position; -use crate::component_syntax::component_reference::ComponentReference; use crate::component_syntax::component_registry::ComponentRegistry; use crate::component_syntax::evaluator_factory::EvaluatorFactory; use crate::component_syntax::parse_component::parse_component; @@ -42,27 +39,93 @@ pub trait BuildsEngine { self.prepare_engine(&mut engine)?; - let templates: DashMap = DashMap::new(); + Ok(engine) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; - for entry in &self.component_registry().components { - let component_reference = entry.value(); + use anyhow::Result; + use anyhow::anyhow; + use rhai::Engine; + use rhai::module_resolvers::FileModuleResolver; - let module_resolver = engine.module_resolver(); - let module = module_resolver.resolve( - &engine, - None, - &component_reference.name, - Position::NONE, - )?; + use super::BuildsEngine; + use super::ComponentRegistry; + use crate::component_syntax::component_reference::ComponentReference; - engine.register_static_module(component_reference.name.clone(), module); + fn fixtures_path() -> String { + format!("{}/src/component_syntax/fixtures", env!("CARGO_MANIFEST_DIR")) + } + + struct TestEngineOk { + registry: Arc, + } - templates.insert( - component_reference.name.clone(), - component_reference.clone(), - ); + impl BuildsEngine for TestEngineOk { + fn component_registry(&self) -> Arc { + self.registry.clone() } - Ok(engine) + fn prepare_engine(&self, engine: &mut Engine) -> Result<()> { + engine.set_module_resolver(FileModuleResolver::new_with_path(fixtures_path())); + + Ok(()) + } + } + + struct TestEngineFailingPrepare { + registry: Arc, + } + + impl BuildsEngine for TestEngineFailingPrepare { + fn component_registry(&self) -> Arc { + self.registry.clone() + } + + fn prepare_engine(&self, _engine: &mut Engine) -> Result<()> { + Err(anyhow!("prepare_engine failed on purpose")) + } + } + + fn registry_with(names: &[&str]) -> Arc { + let registry = Arc::new(ComponentRegistry::default()); + + for name in names { + registry.register_component(ComponentReference { + name: (*name).to_string(), + path: (*name).to_string(), + }); + } + + registry + } + + #[test] + fn create_engine_registers_helpers_and_custom_syntax() -> Result<()> { + let builder = TestEngineOk { + registry: registry_with(&["Note"]), + }; + + assert!(builder.create_engine().is_ok_and(|engine| engine + .eval::(r#"clsx(#{ ok: true })"#) + .is_ok_and(|result| result == "ok"))); + + Ok(()) + } + + #[test] + fn create_engine_propagates_prepare_engine_error() -> Result<()> { + let builder = TestEngineFailingPrepare { + registry: registry_with(&[]), + }; + + assert!(builder.create_engine().is_err_and(|error| { + error.to_string().contains("prepare_engine failed on purpose") + })); + + Ok(()) } } diff --git a/rhai_components/src/component_syntax/combine_output_symbols.rs b/rhai_components/src/component_syntax/combine_output_symbols.rs index 88d338a..17badf6 100644 --- a/rhai_components/src/component_syntax/combine_output_symbols.rs +++ b/rhai_components/src/component_syntax/combine_output_symbols.rs @@ -14,9 +14,7 @@ use super::output_symbol::OutputSymbol; use super::tag::Tag; use crate::component_syntax::tag_name::TagName; -pub fn combine_output_symbols( - state: &Dynamic, -) -> Result, ParseError> { +fn merge_adjacent_symbols(state: &Dynamic) -> Result, ParseError> { let mut expression_index = 0; let mut combined_symbols: Vec = vec![]; @@ -24,7 +22,7 @@ pub fn combine_output_symbols( Ok(array) => array, Err(err) => { return Err( - LexError::Runtime(format!("Invalid state array {err}")).into_err(Position::NONE) + LexError::Runtime(format!("Invalid state array {err}")).into_err(Position::NONE), ); } }; @@ -123,6 +121,12 @@ pub fn combine_output_symbols( } } + Ok(combined_symbols) +} + +fn assemble_semantic_symbols( + combined_symbols: Vec, +) -> Result, ParseError> { let mut semantic_symbols: VecDeque = VecDeque::new(); for output_combined_symbol in combined_symbols { @@ -233,3 +237,213 @@ pub fn combine_output_symbols( Ok(semantic_symbols) } + +pub fn combine_output_symbols( + state: &Dynamic, +) -> Result, ParseError> { + assemble_semantic_symbols(merge_adjacent_symbols(state)?) +} + +#[cfg(test)] +mod tests { + use std::mem::discriminant; + + use anyhow::Result; + use rhai::Dynamic; + + use super::AttributeValue; + use super::OutputCombinedSymbol; + use super::OutputSemanticSymbol; + use super::OutputSymbol; + use super::combine_output_symbols; + use super::merge_adjacent_symbols; + use super::assemble_semantic_symbols; + + fn make_state(symbols: Vec) -> Dynamic { + Dynamic::from_array(symbols.into_iter().map(Dynamic::from).collect()) + } + + #[test] + fn errs_when_state_is_not_an_array() -> Result<()> { + let state = Dynamic::from(42_i64); + + assert!(combine_output_symbols(&state) + .is_err_and(|error| error.to_string().contains("Invalid state array"))); + + Ok(()) + } + + #[test] + fn errs_when_state_array_contains_non_output_symbol() -> Result<()> { + let state = Dynamic::from_array(vec![Dynamic::from(42_i64)]); + + assert!(combine_output_symbols(&state) + .is_err_and(|error| error.to_string().contains("Unable to cast"))); + + Ok(()) + } + + #[test] + fn errs_when_attribute_value_expression_has_no_prior_attribute_name() -> Result<()> { + let state = make_state(vec![OutputSymbol::TagAttributeValueExpression]); + + assert!(combine_output_symbols(&state).is_err_and(|error| { + error + .to_string() + .contains("Attribute value expression without name") + })); + + Ok(()) + } + + #[test] + fn errs_when_attribute_value_string_has_no_prior_attribute_name() -> Result<()> { + let state = make_state(vec![OutputSymbol::TagAttributeValueString("v".to_string())]); + + assert!(combine_output_symbols(&state).is_err_and(|error| { + error + .to_string() + .contains("Attribute value expression without name") + })); + + Ok(()) + } + + #[test] + fn errs_on_unexpected_tag_opening_after_unsupported_predecessor() -> Result<()> { + let state = make_state(vec![OutputSymbol::TagLeftAnglePlusWhitespace]); + + assert!(combine_output_symbols(&state) + .is_err_and(|error| error.to_string().contains("Unexpected tag opening"))); + + Ok(()) + } + + #[test] + fn errs_on_unexpected_tag_closing_with_no_open_tag() -> Result<()> { + let state = make_state(vec![OutputSymbol::TagCloseBeforeNamePlusWhitespace( + "".to_string(), + )]); + + assert!(combine_output_symbols(&state) + .is_err_and(|error| error.to_string().contains("Unexpected tag closing"))); + + Ok(()) + } + + #[test] + fn errs_on_unexpected_tag_name_with_no_open_tag() -> Result<()> { + let state = make_state(vec![OutputSymbol::TagName("d".to_string())]); + + assert!(combine_output_symbols(&state) + .is_err_and(|error| error.to_string().contains("Unexpected tag name"))); + + Ok(()) + } + + #[test] + fn errs_on_unexpected_attribute_name_with_no_open_tag() -> Result<()> { + let state = make_state(vec![OutputSymbol::TagAttributeName("c".to_string())]); + + assert!(combine_output_symbols(&state) + .is_err_and(|error| error.to_string().contains("Unexpected tag attribute name"))); + + Ok(()) + } + + #[test] + fn errs_when_attribute_value_emitted_before_attribute_name_in_assemble_semantic_symbols() -> Result<()> { + let combined = vec![ + OutputCombinedSymbol::Text("x".to_string()), + OutputCombinedSymbol::TagLeftAngle, + OutputCombinedSymbol::TagAttributeValue(AttributeValue::Text("v".to_string())), + ]; + + assert!(assemble_semantic_symbols(combined) + .is_err_and(|error| error.to_string().contains("Attribute value without name"))); + + Ok(()) + } + + #[test] + fn errs_on_unexpected_attribute_value_with_no_open_tag() -> Result<()> { + let combined = vec![OutputCombinedSymbol::TagAttributeValue( + AttributeValue::Text("v".to_string()), + )]; + + assert!(assemble_semantic_symbols(combined) + .is_err_and(|error| error.to_string().contains("Unexpected tag attribute value"))); + + Ok(()) + } + + #[test] + fn errs_on_unexpected_self_close_with_no_open_tag() -> Result<()> { + let state = make_state(vec![OutputSymbol::TagSelfClose]); + + assert!(combine_output_symbols(&state) + .is_err_and(|error| error.to_string().contains("Unexpected self-closing tag"))); + + Ok(()) + } + + #[test] + fn merge_adjacent_symbols_collapses_consecutive_same_kind_tokens() -> Result<()> { + let combined = merge_adjacent_symbols(&make_state(vec![ + OutputSymbol::Text("a".to_string()), + OutputSymbol::Text("b".to_string()), + OutputSymbol::TagLeftAnglePlusWhitespace, + OutputSymbol::TagLeftAnglePlusWhitespace, + OutputSymbol::TagCloseBeforeNamePlusWhitespace("".to_string()), + OutputSymbol::TagCloseBeforeNamePlusWhitespace("".to_string()), + OutputSymbol::TagName("d".to_string()), + OutputSymbol::TagName("iv".to_string()), + OutputSymbol::TagPadding, + OutputSymbol::TagPadding, + OutputSymbol::TagAttributeName("c".to_string()), + OutputSymbol::TagAttributeName("lass".to_string()), + ])) + .unwrap_or_default(); + + let expected_discriminants = [ + discriminant(&OutputCombinedSymbol::Text(String::new())), + discriminant(&OutputCombinedSymbol::TagLeftAngle), + discriminant(&OutputCombinedSymbol::TagCloseBeforeName), + discriminant(&OutputCombinedSymbol::TagName(String::new())), + discriminant(&OutputCombinedSymbol::TagPadding), + discriminant(&OutputCombinedSymbol::TagAttributeName(String::new())), + ]; + + assert_eq!(combined.len(), expected_discriminants.len()); + + for (actual, expected) in combined.iter().zip(expected_discriminants.iter()) { + assert_eq!(discriminant(actual), *expected); + } + + assert!(matches!(&combined[0], OutputCombinedSymbol::Text(text) if text == "ab")); + assert!(matches!(&combined[3], OutputCombinedSymbol::TagName(name) if name == "div")); + assert!(matches!( + &combined[5], + OutputCombinedSymbol::TagAttributeName(name) if name == "class" + )); + + Ok(()) + } + + #[test] + fn assemble_semantic_symbols_merges_consecutive_text() -> Result<()> { + let combined = vec![ + OutputCombinedSymbol::Text("a".to_string()), + OutputCombinedSymbol::Text("b".to_string()), + ]; + + assert!(assemble_semantic_symbols(combined).is_ok_and(|mut semantic| { + let first = semantic.pop_front(); + + matches!(first, Some(OutputSemanticSymbol::Text(text)) if text == "ab") + && semantic.is_empty() + })); + + Ok(()) + } +} diff --git a/rhai_components/src/component_syntax/combine_tag_stack.rs b/rhai_components/src/component_syntax/combine_tag_stack.rs index e3633fa..4fccd4a 100644 --- a/rhai_components/src/component_syntax/combine_tag_stack.rs +++ b/rhai_components/src/component_syntax/combine_tag_stack.rs @@ -81,24 +81,14 @@ pub fn combine_tag_stack( combine_tag_stack(current_node, opened_tags, semantic_symbols) } - None => { - if !opened_tags.is_empty() { - return Err(LexError::UnexpectedInput(format!( - "Unclosed tag: <{}>", - match opened_tags.back() { - Some(tag) => &tag.tag_name.name, - None => - return Err(LexError::UnexpectedInput( - "No opened tags found".to_string(), - ) - .into_err(Position::NONE)), - } - )) - .into_err(Position::NONE)); - } - - Ok(()) - } + None => match opened_tags.back() { + Some(tag) => Err(LexError::UnexpectedInput(format!( + "Unclosed tag: <{}>", + tag.tag_name.name + )) + .into_err(Position::NONE)), + None => Ok(()), + }, } } TagStackNode::BodyExpression(_) => Err(LexError::UnexpectedInput( @@ -111,3 +101,154 @@ pub fn combine_tag_stack( .into_err(Position::NONE)), } } + +#[cfg(test)] +mod tests { + use std::collections::VecDeque; + + use anyhow::Result; + + use super::OutputSemanticSymbol; + use super::Tag; + use super::TagStackNode; + use super::combine_tag_stack; + use crate::component_syntax::expression_reference::ExpressionReference; + use crate::component_syntax::tag_name::TagName; + + fn make_tag(name: &str, is_closing: bool, is_self_closing: bool) -> Tag { + Tag { + attributes: vec![], + is_closing, + is_self_closing, + tag_name: TagName { + name: name.to_string(), + }, + } + } + + fn empty_root() -> TagStackNode { + TagStackNode::Tag { + children: vec![], + is_closed: false, + opening_tag: None, + } + } + + #[test] + fn errs_when_root_node_is_body_expression() -> Result<()> { + let mut root = TagStackNode::BodyExpression(ExpressionReference { expression_index: 0 }); + + assert!( + combine_tag_stack(&mut root, &mut VecDeque::new(), &mut VecDeque::new()).is_err_and( + |error| error.to_string().contains("Cannot add child to body expression node") + ) + ); + + Ok(()) + } + + #[test] + fn errs_when_root_node_is_text() -> Result<()> { + let mut root = TagStackNode::Text("hi".to_string()); + + assert!( + combine_tag_stack(&mut root, &mut VecDeque::new(), &mut VecDeque::new()).is_err_and( + |error| error.to_string().contains("Cannot add child to text node") + ) + ); + + Ok(()) + } + + #[test] + fn errs_on_mismatched_closing_tag_name() -> Result<()> { + let opening = make_tag("div", false, false); + let mut root = TagStackNode::Tag { + children: vec![], + is_closed: false, + opening_tag: Some(opening), + }; + let mut symbols = VecDeque::new(); + + symbols.push_back(OutputSemanticSymbol::Tag(make_tag("span", true, false))); + + assert!( + combine_tag_stack(&mut root, &mut VecDeque::new(), &mut symbols) + .is_err_and(|error| error.to_string().contains("Mismatched closing tag")) + ); + + Ok(()) + } + + #[test] + fn errs_on_closing_tag_when_no_tag_is_open() -> Result<()> { + let mut root = empty_root(); + let mut symbols = VecDeque::new(); + + symbols.push_back(OutputSemanticSymbol::Tag(make_tag("div", true, false))); + + assert!( + combine_tag_stack(&mut root, &mut VecDeque::new(), &mut symbols) + .is_err_and(|error| error.to_string().contains("Unexpected closing tag")) + ); + + Ok(()) + } + + #[test] + fn errs_on_unclosed_tag_at_end() -> Result<()> { + let mut root = empty_root(); + let mut opened = VecDeque::new(); + + opened.push_back(make_tag("div", false, false)); + + assert!( + combine_tag_stack(&mut root, &mut opened, &mut VecDeque::new()) + .is_err_and(|error| error.to_string().contains("Unclosed tag")) + ); + + Ok(()) + } + + #[test] + fn adds_void_element_as_child_without_requiring_close() -> Result<()> { + let mut root = empty_root(); + let mut symbols = VecDeque::new(); + + symbols.push_back(OutputSemanticSymbol::Tag(make_tag("br", false, false))); + + assert!( + combine_tag_stack(&mut root, &mut VecDeque::new(), &mut symbols).is_ok() + ); + assert!(matches!( + &root, + TagStackNode::Tag { children, .. } + if children.len() == 1 + && matches!( + &children[0], + TagStackNode::Tag { opening_tag: Some(tag), is_closed: false, .. } + if tag.tag_name.name == "br" + ) + )); + + Ok(()) + } + + #[test] + fn drops_empty_text_node_without_adding_child() -> Result<()> { + let mut root = empty_root(); + let mut symbols = VecDeque::new(); + + symbols.push_back(OutputSemanticSymbol::Text(String::new())); + + assert!( + combine_tag_stack(&mut root, &mut VecDeque::new(), &mut symbols).is_ok() + ); + assert!(matches!( + &root, + TagStackNode::Tag { children, .. } if children.is_empty() + )); + + Ok(()) + } +} diff --git a/rhai_components/src/component_syntax/component_registry.rs b/rhai_components/src/component_syntax/component_registry.rs index 754c904..1aae4c7 100644 --- a/rhai_components/src/component_syntax/component_registry.rs +++ b/rhai_components/src/component_syntax/component_registry.rs @@ -20,3 +20,31 @@ impl Default for ComponentRegistry { } } } + +#[cfg(test)] +mod tests { + use anyhow::Result; + + use super::ComponentReference; + use super::ComponentRegistry; + + #[test] + fn default_starts_empty_and_register_inserts_by_name() -> Result<()> { + let registry = ComponentRegistry::default(); + + assert_eq!(registry.components.len(), 0); + + registry.register_component(ComponentReference { + name: "Note".to_string(), + path: "shortcodes/Note".to_string(), + }); + + assert_eq!(registry.components.len(), 1); + + assert!(registry.components.get("Note").is_some_and(|entry| { + entry.value().name == "Note" && entry.value().path == "shortcodes/Note" + })); + + Ok(()) + } +} diff --git a/rhai_components/src/component_syntax/eval_tag_stack_node.rs b/rhai_components/src/component_syntax/eval_tag_stack_node.rs index df74beb..8faf31f 100644 --- a/rhai_components/src/component_syntax/eval_tag_stack_node.rs +++ b/rhai_components/src/component_syntax/eval_tag_stack_node.rs @@ -10,6 +10,7 @@ use super::attribute_value::AttributeValue; use super::component_registry::ComponentRegistry; use super::eval_tag::eval_tag; use super::expression_collection::ExpressionCollection; +use super::tag::Tag; use super::tag_stack_node::TagStackNode; use crate::rhai_call_template_function::rhai_call_template_function; @@ -25,10 +26,13 @@ pub fn eval_tag_stack_node( expression_collection.eval_expression(eval_context, expression_reference)?; if body_expression_result.is_array() { - let body_expresion_array: Array = body_expression_result.as_array_ref()?.to_vec(); + let body_expression_array: Array = body_expression_result + .as_array_ref() + .map(|array| array.to_vec()) + .unwrap_or_default(); let mut combined_ret = String::new(); - for item in body_expresion_array { + for item in body_expression_array { combined_ret.push_str(&item.to_string()); } @@ -63,7 +67,17 @@ pub fn eval_tag_stack_node( && *is_closed && !opening_tag.tag_name.is_component() { - result.push_str(&format!("", opening_tag.tag_name.name)); + let closing_tag = Tag { + attributes: vec![], + is_closing: true, + is_self_closing: false, + tag_name: opening_tag.tag_name.clone(), + }; + + result.push_str( + &eval_tag(eval_context, expression_collection, &closing_tag) + .unwrap_or_default(), + ); return Ok(result); } @@ -127,3 +141,196 @@ pub fn eval_tag_stack_node( TagStackNode::Text(text) => Ok(text.clone()), } } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use anyhow::Result; + use rhai::CustomType; + use rhai::Engine; + use rhai::Func; + use rhai::TypeBuilder; + use rhai::module_resolvers::FileModuleResolver; + + use super::ComponentRegistry; + use crate::builds_engine::BuildsEngine; + use crate::component_syntax::component_reference::ComponentReference; + + fn fixtures_path() -> String { + format!("{}/src/component_syntax/fixtures", env!("CARGO_MANIFEST_DIR")) + } + + #[derive(Clone, Default)] + struct DummyContext; + + impl CustomType for DummyContext { + fn build(_builder: TypeBuilder) {} + } + + struct LocalBuilder { + registry: Arc, + } + + impl BuildsEngine for LocalBuilder { + fn component_registry(&self) -> Arc { + self.registry.clone() + } + + fn prepare_engine(&self, engine: &mut Engine) -> Result<()> { + engine.set_module_resolver(FileModuleResolver::new_with_path(fixtures_path())); + engine.build_type::(); + + Ok(()) + } + } + + fn registry_with(names: &[&str]) -> Arc { + let registry = Arc::new(ComponentRegistry::default()); + + for name in names { + registry.register_component(ComponentReference { + name: (*name).to_string(), + path: (*name).to_string(), + }); + } + + registry + } + + fn render_with(component_names: &[&str], script: &str) -> Result { + let builder = LocalBuilder { + registry: registry_with(component_names), + }; + + builder.create_engine().and_then(|engine| { + Func::<(DummyContext,), String>::create_from_script(engine, script, "template") + .map_err(anyhow::Error::from) + .and_then(|renderer| renderer(DummyContext).map_err(anyhow::Error::from)) + }) + } + + #[test] + fn returns_error_when_context_variable_missing_from_scope_for_component_tag() -> Result<()> { + let builder = LocalBuilder { + registry: registry_with(&[]), + }; + + assert!(builder.create_engine().is_ok_and(|engine| { + engine + .eval::(r#"component { }"#) + .is_err_and(|error| error.to_string().contains("'context'")) + })); + + Ok(()) + } + + #[test] + fn returns_error_when_component_template_function_fails() -> Result<()> { + let result = render_with( + &["ThrowingComponent"], + r#" + fn template(context) { + component { } + } + "#, + ); + + assert!(result + .is_err_and(|error| error.to_string().contains("Failed to call component function"))); + + Ok(()) + } + + #[test] + fn renders_component_with_expression_attribute() -> Result<()> { + assert!(render_with( + &["Note"], + r#" + fn template(context) { + component { hi } + } + "#, + ) + .is_ok_and(|rendered| rendered.contains("note--warn") && rendered.contains("hi"))); + + Ok(()) + } + + #[test] + fn renders_component_with_no_value_attribute() -> Result<()> { + assert!(render_with( + &["Bare"], + r#" + fn template(context) { + component { hi } + } + "#, + ) + .is_ok_and(|rendered| { + rendered.contains("data-disabled=\"yes\"") && rendered.contains("hi") + })); + + Ok(()) + } + + #[test] + fn renders_array_body_expression_by_concatenating_items() -> Result<()> { + assert!(render_with( + &[], + r#" + fn template(context) { + component {
{["a", "b", "c"]}
} + } + "#, + ) + .is_ok_and(|rendered| rendered.contains("
abc
"))); + + Ok(()) + } + + #[test] + fn returns_error_when_body_expression_evaluation_fails() -> Result<()> { + assert!(render_with( + &[], + r#" + fn template(context) { + component {
{nonexistent_variable}
} + } + "#, + ) + .is_err()); + + Ok(()) + } + + #[test] + fn returns_error_when_attribute_expression_evaluation_fails() -> Result<()> { + assert!(render_with( + &[], + r#" + fn template(context) { + component {
hi
} + } + "#, + ) + .is_err()); + + Ok(()) + } + + #[test] + fn returns_error_when_component_attribute_expression_evaluation_fails() -> Result<()> { + assert!(render_with( + &["Bare"], + r#" + fn template(context) { + component { hi } + } + "#, + ) + .is_err()); + + Ok(()) + } +} diff --git a/rhai_components/src/component_syntax/evaluator_factory.rs b/rhai_components/src/component_syntax/evaluator_factory.rs index 5faa5fd..1c75d6a 100644 --- a/rhai_components/src/component_syntax/evaluator_factory.rs +++ b/rhai_components/src/component_syntax/evaluator_factory.rs @@ -11,6 +11,15 @@ use super::eval_tag_stack_node::eval_tag_stack_node; use super::expression_collection::ExpressionCollection; use super::tag_stack_node::TagStackNode; +fn cast_state_to_tag_stack_node(state: &Dynamic) -> Result> { + state.clone().try_cast::().ok_or_else(|| { + Box::new(EvalAltResult::ErrorRuntime( + "Expected TagStackNode in tag state".into(), + Position::NONE, + )) + }) +} + pub struct EvaluatorFactory { pub component_registry: Arc, } @@ -29,15 +38,12 @@ impl EvaluatorFactory { expressions: inputs.to_vec(), }; + let tag_stack_node = cast_state_to_tag_stack_node(state)?; + let rendered_tag_stack = eval_tag_stack_node( component_registry_clone.clone(), eval_context, - &state.clone().try_cast::().ok_or_else(|| { - EvalAltResult::ErrorRuntime( - "Expected TagStackNode in tag state".into(), - Position::NONE, - ) - })?, + &tag_stack_node, &mut expression_collection, )?; @@ -45,3 +51,54 @@ impl EvaluatorFactory { } } } + +#[cfg(test)] +mod tests { + use std::mem::discriminant; + use std::sync::Arc; + + use anyhow::Result; + use rhai::Dynamic; + use rhai::Engine; + use rhai::EvalAltResult; + + use super::ComponentRegistry; + use super::EvaluatorFactory; + use super::Position; + use super::cast_state_to_tag_stack_node; + + #[test] + fn cast_state_to_tag_stack_node_returns_runtime_error_for_non_tag_stack_node_dynamic() + -> Result<()> { + let state = Dynamic::from(42_i64); + let reference = discriminant(&EvalAltResult::ErrorRuntime(Dynamic::UNIT, Position::NONE)); + + assert!(cast_state_to_tag_stack_node(&state).is_err_and(|boxed| { + discriminant(boxed.as_ref()) == reference + && boxed.to_string().contains("Expected TagStackNode in tag state") + })); + + Ok(()) + } + + #[test] + fn evaluator_closure_returns_error_when_state_is_not_a_tag_stack_node() -> Result<()> { + let factory = EvaluatorFactory { + component_registry: Arc::new(ComponentRegistry::default()), + }; + let mut engine = Engine::new(); + + engine.register_custom_syntax_without_look_ahead_raw( + "bad_syntax", + |_symbols, _state| Ok(None), + false, + factory.create_component_evaluator(), + ); + + assert!(engine + .eval::("bad_syntax") + .is_err_and(|error| error.to_string().contains("Expected TagStackNode"))); + + Ok(()) + } +} diff --git a/rhai_components/src/component_syntax/expression_collection.rs b/rhai_components/src/component_syntax/expression_collection.rs index f378535..1e9a687 100644 --- a/rhai_components/src/component_syntax/expression_collection.rs +++ b/rhai_components/src/component_syntax/expression_collection.rs @@ -5,6 +5,18 @@ use rhai::Expression; use super::expression_reference::ExpressionReference; +fn lookup_expression<'collection, 'expression>( + expressions: &'collection [Expression<'expression>], + expression_index: usize, +) -> Result<&'collection Expression<'expression>, Box> { + expressions.get(expression_index).ok_or_else(|| { + Box::new(EvalAltResult::ErrorRuntime( + "Expression index out of bounds".into(), + rhai::Position::NONE, + )) + }) +} + pub struct ExpressionCollection<'expression> { pub expressions: Vec>, } @@ -15,13 +27,33 @@ impl<'expression> ExpressionCollection<'expression> { eval_context: &mut EvalContext, ExpressionReference { expression_index }: &ExpressionReference, ) -> Result> { - let expression = self.expressions.get(*expression_index).ok_or_else(|| { - Box::new(EvalAltResult::ErrorRuntime( - "Expression index out of bounds".into(), - rhai::Position::NONE, - )) - })?; - - eval_context.eval_expression_tree(expression) + lookup_expression(&self.expressions, *expression_index) + .and_then(|expression| eval_context.eval_expression_tree(expression)) + } +} + +#[cfg(test)] +mod tests { + use std::mem::discriminant; + + use anyhow::Result; + use rhai::Dynamic; + use rhai::EvalAltResult; + use rhai::Expression; + use rhai::Position; + + use super::lookup_expression; + + #[test] + fn lookup_expression_returns_runtime_error_when_index_is_out_of_bounds() -> Result<()> { + let expressions: Vec> = Vec::new(); + let reference = discriminant(&EvalAltResult::ErrorRuntime(Dynamic::UNIT, Position::NONE)); + + assert!(lookup_expression(&expressions, 0).is_err_and(|boxed| { + discriminant(boxed.as_ref()) == reference + && boxed.to_string().contains("Expression index out of bounds") + })); + + Ok(()) } } diff --git a/rhai_components/src/component_syntax/fixtures/Bare.rhai b/rhai_components/src/component_syntax/fixtures/Bare.rhai new file mode 100644 index 0000000..1895afc --- /dev/null +++ b/rhai_components/src/component_syntax/fixtures/Bare.rhai @@ -0,0 +1,5 @@ +fn template(context, props, content) { + component { + {content} + } +} diff --git a/rhai_components/src/component_syntax/fixtures/NoTemplate.rhai b/rhai_components/src/component_syntax/fixtures/NoTemplate.rhai new file mode 100644 index 0000000..d99abc7 --- /dev/null +++ b/rhai_components/src/component_syntax/fixtures/NoTemplate.rhai @@ -0,0 +1 @@ +let placeholder = 1; diff --git a/rhai_components/src/component_syntax/fixtures/ThrowingComponent.rhai b/rhai_components/src/component_syntax/fixtures/ThrowingComponent.rhai new file mode 100644 index 0000000..ec9161a --- /dev/null +++ b/rhai_components/src/component_syntax/fixtures/ThrowingComponent.rhai @@ -0,0 +1,3 @@ +fn template(context, props, content) { + error("boom from ThrowingComponent") +} diff --git a/rhai_components/src/component_syntax/mod.rs b/rhai_components/src/component_syntax/mod.rs index 493c00f..d6e40c7 100644 --- a/rhai_components/src/component_syntax/mod.rs +++ b/rhai_components/src/component_syntax/mod.rs @@ -75,8 +75,31 @@ mod tests { } } - #[tokio::test] - async fn test_docs_parser() -> Result<()> { + fn build_minimal_engine() -> Engine { + let component_registry = Arc::new(ComponentRegistry::default()); + let evaluator_factory = EvaluatorFactory { + component_registry, + }; + let mut engine = Engine::new(); + + engine.set_fail_on_invalid_map_property(true); + engine.set_max_expr_depths(256, 256); + engine.set_module_resolver(FileModuleResolver::new_with_path(format!( + "{}/src/component_syntax/fixtures", + env!("CARGO_MANIFEST_DIR") + ))); + engine.register_custom_syntax_without_look_ahead_raw( + "component", + parse_component, + true, + evaluator_factory.create_component_evaluator(), + ); + + engine + } + + #[test] + fn renders_full_document_with_components_attributes_and_body_expressions() -> Result<()> { let component_context = DummyContext::default(); let component_registry = Arc::new(ComponentRegistry::default()); @@ -113,77 +136,113 @@ mod tests { engine.build_type::(); engine.build_type::(); - let renderer = Func::<(DummyContext, Dynamic, Dynamic), String>::create_from_script( - engine, - r#" - import "LayoutHomepage" as LayoutHomepage; - import "Note" as Note; - - fn template(context, props, content) { - context.assets.add("resouces/controller_foo.tsx"); - - component { - - - < button - class="myclass" - data-foo={props.bar} - data-fooz={`${props.bar}`} - data-gooz={if true { - component { -
- } - } else { - ":)" - }} - disabled - > - test - Hello! :D - {" - "} -
- - - {if content.is_empty() { + let mut props = Map::new(); + + props.insert("bar".into(), "baz".into()); + + let props_dynamic = Dynamic::from_map(props); + let content_dynamic = Dynamic::from(""); + let context = component_context.clone(); + let rendered_matches = + Func::<(DummyContext, Dynamic, Dynamic), String>::create_from_script( + engine, + r#" + import "LayoutHomepage" as LayoutHomepage; + import "Note" as Note; + + fn template(context, props, content) { + context.assets.add("resouces/controller_foo.tsx"); + + component { + + + < button + class="myclass" + data-foo={props.bar} + data-fooz={`${props.bar}`} + data-gooz={if true { component { -
- NOTE EMPTY CONTENT -
+
} } else { - content + ":)" }} - - - + disabled + > + test + Hello! :D + {" - "} +
+ + + {if content.is_empty() { + component { +
+ NOTE EMPTY CONTENT +
+ } + } else { + content + }} +
+ + + } } - } - "#, - "template", - )?; - - println!( - "{}", - renderer( - component_context.clone(), - Dynamic::from_map({ - let mut props = Map::new(); - - props.insert("bar".into(), "baz".into()); - - props - }), - Dynamic::from(""), - )? - ); - + "#, + "template", + ) + .is_ok_and(|renderer| { + renderer(context, props_dynamic, content_dynamic).is_ok_and(|rendered| { + rendered.contains("") + && rendered.contains("") + && rendered.contains("Poet") + && rendered.contains("test") + && rendered.contains("Hello! :D") + && rendered.contains(" - ") + && rendered.contains("
") + && rendered.contains("class=\"note note--warn\"") + && rendered.contains("NOTE EMPTY CONTENT") + && rendered.contains("") + && rendered.contains("") + && rendered.contains("") + }) + }); + + assert!(rendered_matches); assert!( component_context .assets .assets .contains("resouces/controller_foo.tsx") ); - // assert!(false); + + Ok(()) + } + + #[test] + fn parses_mismatched_closing_tag_as_parse_error() -> Result<()> { + let engine = build_minimal_engine(); + + assert!(engine + .compile(r#"component {
}"#) + .is_err_and(|error| error.to_string().contains("Mismatched closing tag"))); + + Ok(()) + } + + #[test] + fn parses_self_close_after_attribute_name_renders_self_closing_tag() -> Result<()> { + let engine = build_minimal_engine(); + + assert!(engine + .eval::(r#"component { }"#) + .is_ok_and(|rendered| rendered.contains(" { - array.push(Dynamic::from(value)); + let current_state = ParserState::try_from(state.tag()).map_err(|()| { + LexError::ImproperSymbol(last_symbol.to_string(), "Invalid parser state".to_string()) + .into_err(Position::NONE) + })?; + + if !matches!( + current_state, + ParserState::Start | ParserState::OpeningBracket + ) { + state.as_array_ref().map_err(|err| { + LexError::Runtime(format!("Invalid state array {err} at token: {last_symbol}")) + .into_err(Position::NONE) + })?; + } + + match current_state { + ParserState::Start => { + *state = Dynamic::from_array(vec![]); + state.set_tag(ParserState::OpeningBracket as i32); - Ok(()) + Ok(Some("{".into())) } - Err(err) => Err(LexError::Runtime(format!( - "Invalid state array {err} at token: {last_symbol}" - )) - .into_err(Position::NONE)), - }; - - match ParserState::try_from(state.tag()) { - Ok(current_state) => match current_state { - ParserState::Start => { - *state = Dynamic::from_array(vec![]); - state.set_tag(ParserState::OpeningBracket as i32); - - Ok(Some("{".into())) + ParserState::OpeningBracket => { + state.set_tag(ParserState::Body as i32); + + Ok(Some("$raw$".into())) + } + ParserState::Body => match last_symbol { + "{" => { + state.set_tag(ParserState::BodyExpression as i32); + + Ok(Some("$inner$".into())) + } + "}" => { + let mut semantic_symbols = combine_output_symbols(state)?; + + let mut tag_stack = TagStackNode::Tag { + children: vec![], + is_closed: false, + opening_tag: None, + }; + + combine_tag_stack( + &mut tag_stack, + &mut Default::default(), + &mut semantic_symbols, + )?; + + *state = Dynamic::from(tag_stack); + + Ok(None) + } + "<" => { + push_to_state(state, OutputSymbol::TagLeftAnglePlusWhitespace); + state.set_tag(ParserState::TagLeftAnglePlusWhitespace as i32); + + Ok(Some("$raw$".into())) + } + _ => { + push_to_state(state, OutputSymbol::Text(last_symbol.to_string())); + state.set_tag(ParserState::Body as i32); + + Ok(Some("$raw$".into())) + } + }, + ParserState::BodyExpression => match last_symbol { + "$inner$" => { + push_to_state(state, OutputSymbol::BodyExpression); + + state.set_tag(ParserState::Body as i32); + + Ok(Some("$raw$".into())) + } + _ => Err(LexError::ImproperSymbol( + last_symbol.to_string(), + "Invalid expression block end".to_string(), + ) + .into_err(Position::NONE)), + }, + ParserState::TagLeftAnglePlusWhitespace => match last_symbol { + _ if last_symbol.trim().is_empty() => { + push_to_state(state, OutputSymbol::TagLeftAnglePlusWhitespace); + state.set_tag(ParserState::TagLeftAnglePlusWhitespace as i32); + + Ok(Some("$raw$".into())) + } + "/" => { + push_to_state( + state, + OutputSymbol::TagCloseBeforeNamePlusWhitespace(last_symbol.to_string()), + ); + state.set_tag(ParserState::TagCloseBeforeNamePlusWhitespace as i32); + + Ok(Some("$raw$".into())) + } + _ => { + push_to_state(state, OutputSymbol::TagName(last_symbol.to_string())); + state.set_tag(ParserState::TagName as i32); + + Ok(Some("$raw$".into())) + } + }, + ParserState::TagCloseBeforeNamePlusWhitespace => match last_symbol { + _ if last_symbol.trim().is_empty() => { + push_to_state( + state, + OutputSymbol::TagCloseBeforeNamePlusWhitespace(last_symbol.to_string()), + ); + state.set_tag(ParserState::TagCloseBeforeNamePlusWhitespace as i32); + + Ok(Some("$raw$".into())) + } + _ => { + push_to_state(state, OutputSymbol::TagName(last_symbol.to_string())); + state.set_tag(ParserState::TagName as i32); + + Ok(Some("$raw$".into())) + } + }, + ParserState::TagName => match last_symbol { + ">" => { + push_to_state(state, OutputSymbol::TagRightAngle); + state.set_tag(ParserState::Body as i32); + + Ok(Some("$raw$".into())) + } + _ if last_symbol.trim().is_empty() => { + push_to_state(state, OutputSymbol::TagPadding); + state.set_tag(ParserState::TagContent as i32); + + Ok(Some("$raw$".into())) + } + _ => { + push_to_state(state, OutputSymbol::TagName(last_symbol.to_string())); + state.set_tag(ParserState::TagName as i32); + + Ok(Some("$raw$".into())) + } + }, + ParserState::TagContent => match last_symbol { + ">" => { + push_to_state(state, OutputSymbol::TagRightAngle); + state.set_tag(ParserState::Body as i32); + + Ok(Some("$raw$".into())) + } + "{" => Err(LexError::ImproperSymbol( + last_symbol.to_string(), + "Invalid expression block start".to_string(), + ) + .into_err(Position::NONE)), + _ if last_symbol.trim().is_empty() => { + push_to_state(state, OutputSymbol::TagPadding); + state.set_tag(ParserState::TagContent as i32); + + Ok(Some("$raw$".into())) + } + "/" => { + push_to_state(state, OutputSymbol::TagSelfClose); + state.set_tag(ParserState::TagSelfClose as i32); + + Ok(Some(">".into())) + } + _ => { + push_to_state(state, OutputSymbol::TagAttributeName(last_symbol.to_string())); + state.set_tag(ParserState::TagAttributeName as i32); + + Ok(Some("$raw$".into())) + } + }, + ParserState::TagAttributeName => match last_symbol { + "=" => { + state.set_tag(ParserState::TagAttributeValue as i32); + + Ok(Some("$raw$".into())) + } + ">" => { + push_to_state(state, OutputSymbol::TagRightAngle); + state.set_tag(ParserState::Body as i32); + + Ok(Some("$raw$".into())) + } + "/" => { + push_to_state(state, OutputSymbol::TagSelfClose); + state.set_tag(ParserState::TagSelfClose as i32); + + Ok(Some(">".into())) + } + _ if last_symbol.trim().is_empty() => { + push_to_state(state, OutputSymbol::TagPadding); + state.set_tag(ParserState::TagContent as i32); + + Ok(Some("$raw$".into())) + } + _ => { + push_to_state(state, OutputSymbol::TagAttributeName(last_symbol.to_string())); + state.set_tag(ParserState::TagAttributeName as i32); + + Ok(Some("$raw$".into())) } - ParserState::OpeningBracket => { + }, + ParserState::TagAttributeValue => match last_symbol { + "$inner$" => { + state.set_tag(ParserState::TagContent as i32); + + Ok(Some("$raw$".into())) + } + "\"" => { + state.set_tag(ParserState::TagAttributeValueString as i32); + + Ok(Some("$raw$".into())) + } + "{" => { + push_to_state(state, OutputSymbol::TagAttributeValueExpression); + state.set_tag(ParserState::TagAttributeValue as i32); + + Ok(Some("$inner$".into())) + } + _ => { + push_to_state(state, OutputSymbol::TagAttributeName(last_symbol.to_string())); + state.set_tag(ParserState::TagContent as i32); + + Ok(Some("$raw$".into())) + } + }, + ParserState::TagAttributeValueString => match last_symbol { + "\"" => { + state.set_tag(ParserState::TagContent as i32); + + Ok(Some("$raw$".into())) + } + _ => { + push_to_state( + state, + OutputSymbol::TagAttributeValueString(last_symbol.to_string()), + ); + state.set_tag(ParserState::TagAttributeValueString as i32); + + Ok(Some("$raw$".into())) + } + }, + ParserState::TagSelfClose => match last_symbol { + ">" => { + push_to_state(state, OutputSymbol::TagRightAngle); state.set_tag(ParserState::Body as i32); Ok(Some("$raw$".into())) } - ParserState::Body => match last_symbol { - "{" => { - state.set_tag(ParserState::BodyExpression as i32); - - Ok(Some("$inner$".into())) - } - // This is where the expression ends, so lets optimize the internal state now - "}" => { - let mut semantic_symbols = combine_output_symbols(state)?; - - let mut tag_stack = TagStackNode::Tag { - children: vec![], - is_closed: false, - opening_tag: None, - }; - - combine_tag_stack( - &mut tag_stack, - &mut Default::default(), - &mut semantic_symbols, - )?; - - *state = Dynamic::from(tag_stack); - - Ok(None) - } - "<" => { - push_to_state(state, OutputSymbol::TagLeftAnglePlusWhitespace)?; - state.set_tag(ParserState::TagLeftAnglePlusWhitespace as i32); - - Ok(Some("$raw$".into())) - } - _ => { - push_to_state(state, OutputSymbol::Text(last_symbol.to_string()))?; - state.set_tag(ParserState::Body as i32); - - Ok(Some("$raw$".into())) - } - }, - ParserState::BodyExpression => match last_symbol { - "$inner$" => { - push_to_state(state, OutputSymbol::BodyExpression)?; - - state.set_tag(ParserState::Body as i32); - - Ok(Some("$raw$".into())) - } - _ => Err(LexError::ImproperSymbol( - last_symbol.to_string(), - "Invalid expression block end".to_string(), - ) - .into_err(Position::NONE)), - }, - ParserState::TagLeftAnglePlusWhitespace => match last_symbol { - _ if last_symbol.trim().is_empty() => { - push_to_state(state, OutputSymbol::TagLeftAnglePlusWhitespace)?; - state.set_tag(ParserState::TagLeftAnglePlusWhitespace as i32); - - Ok(Some("$raw$".into())) - } - "/" => { - push_to_state( - state, - OutputSymbol::TagCloseBeforeNamePlusWhitespace(last_symbol.to_string()), - )?; - state.set_tag(ParserState::TagCloseBeforeNamePlusWhitespace as i32); - - Ok(Some("$raw$".into())) - } - _ => { - push_to_state(state, OutputSymbol::TagName(last_symbol.to_string()))?; - state.set_tag(ParserState::TagName as i32); - - Ok(Some("$raw$".into())) - } - }, - ParserState::TagCloseBeforeNamePlusWhitespace => match last_symbol { - _ if last_symbol.trim().is_empty() => { - push_to_state( - state, - OutputSymbol::TagCloseBeforeNamePlusWhitespace(last_symbol.to_string()), - )?; - state.set_tag(ParserState::TagCloseBeforeNamePlusWhitespace as i32); - - Ok(Some("$raw$".into())) - } - _ => { - push_to_state(state, OutputSymbol::TagName(last_symbol.to_string()))?; - state.set_tag(ParserState::TagName as i32); - - Ok(Some("$raw$".into())) - } - }, - ParserState::TagName => match last_symbol { - ">" => { - push_to_state(state, OutputSymbol::TagRightAngle)?; - state.set_tag(ParserState::Body as i32); - - Ok(Some("$raw$".into())) - } - _ if last_symbol.trim().is_empty() => { - push_to_state(state, OutputSymbol::TagPadding)?; - state.set_tag(ParserState::TagContent as i32); - - Ok(Some("$raw$".into())) - } - _ => { - push_to_state(state, OutputSymbol::TagName(last_symbol.to_string()))?; - state.set_tag(ParserState::TagName as i32); - - Ok(Some("$raw$".into())) - } - }, - ParserState::TagContent => match last_symbol { - ">" => { - push_to_state(state, OutputSymbol::TagRightAngle)?; - state.set_tag(ParserState::Body as i32); - - Ok(Some("$raw$".into())) - } - "{" => Err(LexError::ImproperSymbol( - last_symbol.to_string(), - "Invalid expression block start".to_string(), - ) - .into_err(Position::NONE)), - _ if last_symbol.trim().is_empty() => { - push_to_state(state, OutputSymbol::TagPadding)?; - state.set_tag(ParserState::TagContent as i32); - - Ok(Some("$raw$".into())) - } - "/" => { - push_to_state(state, OutputSymbol::TagSelfClose)?; - state.set_tag(ParserState::TagSelfClose as i32); - - Ok(Some(">".into())) - } - _ => { - push_to_state( - state, - OutputSymbol::TagAttributeName(last_symbol.to_string()), - )?; - state.set_tag(ParserState::TagAttributeName as i32); - - Ok(Some("$raw$".into())) - } - }, - ParserState::TagAttributeName => match last_symbol { - "=" => { - state.set_tag(ParserState::TagAttributeValue as i32); - - Ok(Some("$raw$".into())) - } - ">" => { - push_to_state(state, OutputSymbol::TagRightAngle)?; - state.set_tag(ParserState::Body as i32); - - Ok(Some("$raw$".into())) - } - "/" => { - push_to_state(state, OutputSymbol::TagSelfClose)?; - state.set_tag(ParserState::TagSelfClose as i32); - - Ok(Some(">".into())) - } - _ if last_symbol.trim().is_empty() => { - push_to_state(state, OutputSymbol::TagPadding)?; - state.set_tag(ParserState::TagContent as i32); - - Ok(Some("$raw$".into())) - } - _ => { - push_to_state( - state, - OutputSymbol::TagAttributeName(last_symbol.to_string()), - )?; - state.set_tag(ParserState::TagAttributeName as i32); - - Ok(Some("$raw$".into())) - } - }, - ParserState::TagAttributeValue => match last_symbol { - "$inner$" => { - state.set_tag(ParserState::TagContent as i32); - - Ok(Some("$raw$".into())) - } - "\"" => { - state.set_tag(ParserState::TagAttributeValueString as i32); - - Ok(Some("$raw$".into())) - } - "{" => { - push_to_state(state, OutputSymbol::TagAttributeValueExpression)?; - state.set_tag(ParserState::TagAttributeValue as i32); - - Ok(Some("$inner$".into())) - } - _ => { - push_to_state( - state, - OutputSymbol::TagAttributeName(last_symbol.to_string()), - )?; - state.set_tag(ParserState::TagContent as i32); - - Ok(Some("$raw$".into())) - } - }, - ParserState::TagAttributeValueString => match last_symbol { - "\"" => { - state.set_tag(ParserState::TagContent as i32); - - Ok(Some("$raw$".into())) - } - _ => { - push_to_state( - state, - OutputSymbol::TagAttributeValueString(last_symbol.to_string()), - )?; - state.set_tag(ParserState::TagAttributeValueString as i32); - - Ok(Some("$raw$".into())) - } - }, - ParserState::TagSelfClose => match last_symbol { - ">" => { - push_to_state(state, OutputSymbol::TagRightAngle)?; - state.set_tag(ParserState::Body as i32); - - Ok(Some("$raw$".into())) - } - _ => Err(LexError::ImproperSymbol( - last_symbol.to_string(), - "Invalid self-closing tag end".to_string(), - ) - .into_err(Position::NONE)), - }, + _ => Err(LexError::ImproperSymbol( + last_symbol.to_string(), + "Invalid self-closing tag end".to_string(), + ) + .into_err(Position::NONE)), }, - Err(_) => Err(LexError::ImproperSymbol( - last_symbol.to_string(), - "Invalid parser state".to_string(), - ) - .into_err(Position::NONE)), + } +} + +#[cfg(test)] +mod tests { + use anyhow::Result; + use rhai::Dynamic; + use rhai::ImmutableString; + + use super::ParserState; + use super::parse_component; + + fn symbols(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).into()).collect() + } + + fn fresh_state() -> Dynamic { + let mut state = Dynamic::from_array(Vec::new()); + + state.set_tag(ParserState::Body as i32); + + state + } + + #[test] + fn errs_when_symbols_slice_is_empty() -> Result<()> { + let mut state: Dynamic = Dynamic::UNIT; + + assert!(parse_component(&[], &mut state) + .is_err_and(|error| error.to_string().contains("No symbols found"))); + + Ok(()) + } + + #[test] + fn errs_on_invalid_parser_state_tag() -> Result<()> { + let inputs = symbols(&["x"]); + let mut state = fresh_state(); + + state.set_tag(99); + + assert!(parse_component(&inputs, &mut state) + .is_err_and(|error| error.to_string().contains("Invalid parser state"))); + + Ok(()) + } + + #[test] + fn errs_on_invalid_expression_block_end_in_body_expression() -> Result<()> { + let inputs = symbols(&["x"]); + let mut state = fresh_state(); + + state.set_tag(ParserState::BodyExpression as i32); + + assert!(parse_component(&inputs, &mut state) + .is_err_and(|error| error.to_string().contains("Invalid expression block end"))); + + Ok(()) + } + + #[test] + fn errs_on_invalid_expression_block_start_in_tag_content() -> Result<()> { + let inputs = symbols(&["{"]); + let mut state = fresh_state(); + + state.set_tag(ParserState::TagContent as i32); + + assert!(parse_component(&inputs, &mut state) + .is_err_and(|error| error.to_string().contains("Invalid expression block start"))); + + Ok(()) + } + + #[test] + fn errs_on_invalid_self_close_end() -> Result<()> { + let inputs = symbols(&["x"]); + let mut state = fresh_state(); + + state.set_tag(ParserState::TagSelfClose as i32); + + assert!(parse_component(&inputs, &mut state) + .is_err_and(|error| error.to_string().contains("Invalid self-closing tag end"))); + + Ok(()) + } + + #[test] + fn errs_when_push_to_state_finds_non_array_state() -> Result<()> { + let inputs = symbols(&["<"]); + let mut state = Dynamic::from(42_i64); + + state.set_tag(ParserState::Body as i32); + + assert!(parse_component(&inputs, &mut state) + .is_err_and(|error| error.to_string().contains("Invalid state array"))); + + Ok(()) + } + + #[test] + fn keeps_collecting_whitespace_after_close_slash() -> Result<()> { + let inputs = symbols(&[" "]); + let mut state = fresh_state(); + + state.set_tag(ParserState::TagCloseBeforeNamePlusWhitespace as i32); + + assert!(parse_component(&inputs, &mut state) + .is_ok_and(|next| next.as_deref() == Some("$raw$"))); + assert_eq!(state.tag(), ParserState::TagCloseBeforeNamePlusWhitespace as i32); + + Ok(()) + } + + #[test] + fn switches_to_self_close_when_slash_follows_attribute_name() -> Result<()> { + let inputs = symbols(&["/"]); + let mut state = fresh_state(); + + state.set_tag(ParserState::TagAttributeName as i32); + + assert!(parse_component(&inputs, &mut state) + .is_ok_and(|next| next.as_deref() == Some(">"))); + assert_eq!(state.tag(), ParserState::TagSelfClose as i32); + + Ok(()) + } + + #[test] + fn treats_unquoted_attribute_value_as_new_attribute_name() -> Result<()> { + let inputs = symbols(&["y"]); + let mut state = fresh_state(); + + state.set_tag(ParserState::TagAttributeValue as i32); + + assert!(parse_component(&inputs, &mut state) + .is_ok_and(|next| next.as_deref() == Some("$raw$"))); + assert_eq!(state.tag(), ParserState::TagContent as i32); + + Ok(()) + } + + #[test] + fn push_to_state_silently_skips_non_array_state() -> Result<()> { + let mut state = Dynamic::from(42_i64); + + super::push_to_state(&mut state, super::OutputSymbol::Text("x".to_string())); + + assert!(state.as_array_ref().is_err()); + + Ok(()) + } + + #[test] + fn propagates_combine_output_symbols_error_when_body_closes() -> Result<()> { + let inputs = symbols(&["}"]); + let mut state = Dynamic::from_array(vec![Dynamic::from(super::OutputSymbol::TagName( + "loose-name".to_string(), + ))]); + + state.set_tag(ParserState::Body as i32); + + assert!(parse_component(&inputs, &mut state) + .is_err_and(|error| error.to_string().contains("Unexpected tag name"))); + + Ok(()) } } diff --git a/rhai_components/src/component_syntax/parser_state.rs b/rhai_components/src/component_syntax/parser_state.rs index aa28127..1460cda 100644 --- a/rhai_components/src/component_syntax/parser_state.rs +++ b/rhai_components/src/component_syntax/parser_state.rs @@ -35,3 +35,44 @@ impl TryFrom for ParserState { } } } + +#[cfg(test)] +mod tests { + use anyhow::Result; + + use super::ParserState; + + #[test] + fn try_from_returns_the_expected_variant_for_each_valid_value() -> Result<()> { + let expected = [ + (0, ParserState::Start as i32), + (1, ParserState::OpeningBracket as i32), + (2, ParserState::Body as i32), + (3, ParserState::BodyExpression as i32), + (4, ParserState::TagLeftAnglePlusWhitespace as i32), + (5, ParserState::TagCloseBeforeNamePlusWhitespace as i32), + (6, ParserState::TagName as i32), + (7, ParserState::TagContent as i32), + (8, ParserState::TagAttributeName as i32), + (9, ParserState::TagAttributeValue as i32), + (10, ParserState::TagAttributeValueString as i32), + (11, ParserState::TagSelfClose as i32), + ]; + + for (input, expected_discriminant) in expected { + assert!( + ParserState::try_from(input) + .is_ok_and(|state| state as i32 == expected_discriminant) + ); + } + + Ok(()) + } + + #[test] + fn try_from_returns_err_for_unknown_value() -> Result<()> { + assert!(ParserState::try_from(99).is_err()); + + Ok(()) + } +} diff --git a/rhai_components/src/component_syntax/tag_name.rs b/rhai_components/src/component_syntax/tag_name.rs index 9a8d8d4..9829642 100644 --- a/rhai_components/src/component_syntax/tag_name.rs +++ b/rhai_components/src/component_syntax/tag_name.rs @@ -29,3 +29,60 @@ impl TagName { || self.name == "wbr" } } + +#[cfg(test)] +mod tests { + use anyhow::Result; + + use super::TagName; + + #[test] + fn is_component_returns_true_for_uppercase_first_character() -> Result<()> { + let tag_name = TagName { + name: "Button".to_string(), + }; + + assert!(tag_name.is_component()); + + Ok(()) + } + + #[test] + fn is_component_returns_false_for_lowercase_and_for_empty_name() -> Result<()> { + let lowercase = TagName { + name: "div".to_string(), + }; + let empty = TagName { + name: String::new(), + }; + + assert!(!lowercase.is_component()); + assert!(!empty.is_component()); + + Ok(()) + } + + #[test] + fn is_void_element_recognises_all_void_names_and_rejects_normal_name() -> Result<()> { + let void_names = [ + "!DOCTYPE", "area", "base", "br", "col", "embed", "hr", "img", "input", "link", + "meta", "param", "source", "track", "wbr", + ]; + + for void_name in void_names { + let tag_name = TagName { + name: void_name.to_string(), + }; + + assert!(tag_name.is_void_element(), "expected {void_name} to be void"); + } + + let non_void = TagName { + name: "div".to_string(), + }; + + assert!(!non_void.is_void_element()); + + Ok(()) + } +} diff --git a/rhai_components/src/escape_html.rs b/rhai_components/src/escape_html.rs index 69010c9..977ed06 100644 --- a/rhai_components/src/escape_html.rs +++ b/rhai_components/src/escape_html.rs @@ -16,3 +16,20 @@ pub fn escape_html(input: &str) -> String { output } + +#[cfg(test)] +mod tests { + use anyhow::Result; + + use super::escape_html; + + #[test] + fn escapes_each_special_character_and_preserves_other() -> Result<()> { + assert_eq!( + escape_html("&<>\"'/x"), + "&<>"'/x" + ); + + Ok(()) + } +} diff --git a/rhai_components/src/escape_html_attribute.rs b/rhai_components/src/escape_html_attribute.rs index d0554d3..cb185c8 100644 --- a/rhai_components/src/escape_html_attribute.rs +++ b/rhai_components/src/escape_html_attribute.rs @@ -16,3 +16,17 @@ pub fn escape_html_attribute(input: &str) -> String { output } + +#[cfg(test)] +mod tests { + use anyhow::Result; + + use super::escape_html_attribute; + + #[test] + fn escapes_only_double_quote() -> Result<()> { + assert_eq!(escape_html_attribute("\"&<>'/x"), ""&<>'/x"); + + Ok(()) + } +} diff --git a/rhai_components/src/rhai_call_template_function.rs b/rhai_components/src/rhai_call_template_function.rs index 91482f9..589d600 100644 --- a/rhai_components/src/rhai_call_template_function.rs +++ b/rhai_components/src/rhai_call_template_function.rs @@ -18,3 +18,53 @@ pub fn rhai_call_template_function( Ok(engine.call_fn(&mut Scope::new(), &tmp_ast, "template", args)?) } + +#[cfg(test)] +mod tests { + use anyhow::Result; + use rhai::Dynamic; + use rhai::Engine; + use rhai::module_resolvers::FileModuleResolver; + + use super::rhai_call_template_function; + + fn fixtures_path() -> String { + format!("{}/src/component_syntax/fixtures", env!("CARGO_MANIFEST_DIR")) + } + + fn engine_with_fixtures() -> Engine { + let mut engine = Engine::new(); + + engine.set_module_resolver(FileModuleResolver::new_with_path(fixtures_path())); + + engine + } + + #[test] + fn returns_error_when_component_module_cannot_be_resolved() -> Result<()> { + let engine = engine_with_fixtures(); + let result = rhai_call_template_function( + &engine, + "DoesNotExist", + (Dynamic::UNIT, Dynamic::UNIT, Dynamic::UNIT), + ); + + assert!(result.is_err()); + + Ok(()) + } + + #[test] + fn returns_error_when_module_has_no_template_function() -> Result<()> { + let engine = engine_with_fixtures(); + let result = rhai_call_template_function( + &engine, + "NoTemplate", + (Dynamic::UNIT, Dynamic::UNIT, Dynamic::UNIT), + ); + + assert!(result.is_err()); + + Ok(()) + } +} diff --git a/rhai_components/src/rhai_helpers/clsx.rs b/rhai_components/src/rhai_helpers/clsx.rs index d757be4..07d878a 100644 --- a/rhai_components/src/rhai_helpers/clsx.rs +++ b/rhai_components/src/rhai_helpers/clsx.rs @@ -9,12 +9,60 @@ pub fn clsx(message: Map) -> Result> { return Err(format!("Expected only boolean map values, got: {value}").into()); } - let value_bool = value.as_bool()?; - - if value_bool { + if value.as_bool().unwrap_or(false) { glued_class.push_str(&format!(" {key}")); } } Ok(glued_class.trim().to_string()) } + +#[cfg(test)] +mod tests { + use anyhow::Result; + use rhai::Dynamic; + use rhai::Map; + + use super::clsx; + + fn make_map(entries: &[(&str, Dynamic)]) -> Map { + let mut map = Map::new(); + + for (key, value) in entries { + map.insert((*key).into(), value.clone()); + } + + map + } + + #[test] + fn joins_truthy_keys_with_single_space_and_drops_falsy_keys() -> Result<()> { + let map = make_map(&[ + ("a", Dynamic::from(true)), + ("b", Dynamic::from(false)), + ("c", Dynamic::from(true)), + ]); + + assert!(clsx(map).is_ok_and(|joined| joined == "a c")); + + Ok(()) + } + + #[test] + fn returns_empty_string_for_empty_map() -> Result<()> { + assert!(clsx(Map::new()).is_ok_and(|joined| joined.is_empty())); + + Ok(()) + } + + #[test] + fn returns_error_when_value_is_not_bool() -> Result<()> { + let map = make_map(&[("a", Dynamic::from(1_i64))]); + + assert!(clsx(map).is_err_and(|error| { + error.to_string().contains("Expected only boolean map values") + })); + + Ok(()) + } +} diff --git a/rhai_components/src/rhai_helpers/error.rs b/rhai_components/src/rhai_helpers/error.rs index 3f59a9e..8ddb4f3 100644 --- a/rhai_components/src/rhai_helpers/error.rs +++ b/rhai_components/src/rhai_helpers/error.rs @@ -4,3 +4,20 @@ use rhai::EvalAltResult; pub fn error(message: Dynamic) -> Result> { Err(message.to_string().into()) } + +#[cfg(test)] +mod tests { + use anyhow::Result; + use rhai::Dynamic; + + use super::error; + + #[test] + fn always_returns_runtime_error_with_stringified_message() -> Result<()> { + assert!(error(Dynamic::from("boom")).is_err_and(|boxed| { + boxed.to_string().contains("boom") + })); + + Ok(()) + } +} diff --git a/rhai_components/src/rhai_helpers/has.rs b/rhai_components/src/rhai_helpers/has.rs index 6680865..284eb2e 100644 --- a/rhai_components/src/rhai_helpers/has.rs +++ b/rhai_components/src/rhai_helpers/has.rs @@ -2,12 +2,85 @@ use rhai::Dynamic; use rhai::EvalAltResult; pub fn has(value: Dynamic) -> Result> { - match value.type_name() { - "()" => Ok(false), - "array" => Ok(value.as_array_ref()?.len() > 0), - "bool" => Ok(value.as_bool()?), - "map" => Ok(value.as_map_ref()?.len() > 0), - "string" => Ok(!value.into_string()?.is_empty()), - _ => Ok(true), + Ok(match value.type_name() { + "()" => false, + "array" => value + .as_array_ref() + .map(|array| !array.is_empty()) + .unwrap_or(false), + "bool" => value.as_bool().unwrap_or(false), + "map" => value + .as_map_ref() + .map(|map| !map.is_empty()) + .unwrap_or(false), + "string" => value + .into_string() + .map(|string| !string.is_empty()) + .unwrap_or(false), + _ => true, + }) +} + +#[cfg(test)] +mod tests { + use anyhow::Result; + use rhai::Array; + use rhai::Dynamic; + use rhai::Map; + + use super::has; + + #[test] + fn has_returns_false_for_unit() -> Result<()> { + assert!(has(Dynamic::UNIT).is_ok_and(|present| !present)); + + Ok(()) + } + + #[test] + fn has_returns_non_emptiness_for_array() -> Result<()> { + let empty: Array = Vec::new(); + let non_empty: Array = vec![Dynamic::from(1_i64)]; + + assert!(has(Dynamic::from(empty)).is_ok_and(|present| !present)); + assert!(has(Dynamic::from(non_empty)).is_ok_and(|present| present)); + + Ok(()) + } + + #[test] + fn has_returns_its_value_for_bool() -> Result<()> { + assert!(has(Dynamic::from(true)).is_ok_and(|present| present)); + assert!(has(Dynamic::from(false)).is_ok_and(|present| !present)); + + Ok(()) + } + + #[test] + fn has_returns_non_emptiness_for_map() -> Result<()> { + let empty = Map::new(); + let mut non_empty = Map::new(); + + non_empty.insert("a".into(), Dynamic::from(1_i64)); + + assert!(has(Dynamic::from_map(empty)).is_ok_and(|present| !present)); + assert!(has(Dynamic::from_map(non_empty)).is_ok_and(|present| present)); + + Ok(()) + } + + #[test] + fn has_returns_non_emptiness_for_string() -> Result<()> { + assert!(has(Dynamic::from(String::new())).is_ok_and(|present| !present)); + assert!(has(Dynamic::from("x".to_string())).is_ok_and(|present| present)); + + Ok(()) + } + + #[test] + fn has_returns_true_for_other_types() -> Result<()> { + assert!(has(Dynamic::from(42_i64)).is_ok_and(|present| present)); + + Ok(()) } } diff --git a/rhai_components/src/rhai_template_renderer.rs b/rhai_components/src/rhai_template_renderer.rs index 398039e..366e9f0 100644 --- a/rhai_components/src/rhai_template_renderer.rs +++ b/rhai_components/src/rhai_template_renderer.rs @@ -92,3 +92,145 @@ impl RhaiTemplateRenderer { .context(format!("Expression failed: '{expression}'")) } } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use anyhow::Result; + use rhai::CustomType; + use rhai::Dynamic; + use rhai::Engine; + use rhai::Map; + use rhai::TypeBuilder; + use rhai::module_resolvers::FileModuleResolver; + + use super::ComponentReference; + use super::RhaiTemplateRenderer; + use super::RhaiTemplateRendererParams; + use crate::builds_engine::BuildsEngine; + use crate::component_syntax::component_registry::ComponentRegistry; + + fn fixtures_path() -> String { + format!("{}/src/component_syntax/fixtures", env!("CARGO_MANIFEST_DIR")) + } + + #[derive(Clone)] + struct DummyContext; + + impl CustomType for DummyContext { + fn build(_builder: TypeBuilder) {} + } + + struct LocalBuilder { + registry: Arc, + } + + impl BuildsEngine for LocalBuilder { + fn component_registry(&self) -> Arc { + self.registry.clone() + } + + fn prepare_engine(&self, engine: &mut Engine) -> Result<()> { + engine.set_module_resolver(FileModuleResolver::new_with_path(fixtures_path())); + engine.build_type::(); + + Ok(()) + } + } + + fn registry_with(names: &[&str]) -> Arc { + let registry = Arc::new(ComponentRegistry::default()); + + for name in names { + registry.register_component(ComponentReference { + name: (*name).to_string(), + path: (*name).to_string(), + }); + } + + registry + } + + fn build_renderer(names: &[&str]) -> Result { + let builder = LocalBuilder { + registry: registry_with(names), + }; + + builder.create_engine().and_then(|engine| { + RhaiTemplateRenderer::build(RhaiTemplateRendererParams { + component_registry: builder.registry.clone(), + expression_engine: engine, + }) + }) + } + + #[test] + fn build_succeeds_when_every_registered_component_resolves() -> Result<()> { + assert!(build_renderer(&["Note"]).is_ok()); + + Ok(()) + } + + #[test] + fn build_returns_error_when_a_registered_component_cannot_be_resolved() -> Result<()> { + assert!(build_renderer(&["NopeComponent"]).is_err()); + + Ok(()) + } + + #[test] + fn render_returns_template_not_found_error_for_unknown_component_name() -> Result<()> { + assert!(build_renderer(&[]).is_ok_and(|renderer| { + renderer + .render("Unknown", DummyContext, Dynamic::UNIT, Dynamic::UNIT) + .is_err_and(|error| error.to_string().contains("Template 'Unknown' not found")) + })); + + Ok(()) + } + + #[test] + fn render_invokes_template_function_for_known_component() -> Result<()> { + let mut props = Map::new(); + + props.insert("type".into(), "warn".into()); + + assert!(build_renderer(&["Note"]).is_ok_and(|renderer| { + renderer + .render( + "Note", + DummyContext, + Dynamic::from_map(props), + Dynamic::from(String::new()), + ) + .is_ok_and(|rendered| rendered.contains("note--warn")) + })); + + Ok(()) + } + + #[test] + fn render_expression_evaluates_simple_expression() -> Result<()> { + assert!(build_renderer(&[]).is_ok_and(|renderer| { + renderer + .render_expression(DummyContext, "40 + 2") + .is_ok_and(|value| value.as_int().is_ok_and(|as_int| as_int == 42)) + })); + + Ok(()) + } + + #[test] + fn render_expression_wraps_evaluation_failure_with_context_message() -> Result<()> { + assert!(build_renderer(&[]).is_ok_and(|renderer| { + renderer + .render_expression(DummyContext, "not valid @ rhai!") + .is_err_and(|error| { + error.to_string().contains("Expression failed: 'not valid @ rhai!'") + }) + })); + + Ok(()) + } +} diff --git a/rust-toolchain b/rust-toolchain deleted file mode 100644 index bf867e0..0000000 --- a/rust-toolchain +++ /dev/null @@ -1 +0,0 @@ -nightly diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..c0b9b8e --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.93.0" +components = ["clippy", "rust-analyzer", "rustfmt"]