From 24f2ba44596d5be48e678580bff579ae639e503a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Claud=C3=A9ric=20Demers?= Date: Tue, 20 Oct 2026 15:11:20 -0400 Subject: [PATCH 1/2] Add a Rust port of theme-check Theme Check in Rust, in three crates under crates/: - liquid-ast: the syntax tree of @shopify/liquid-html-parser. - liquid-parser: the parser. - theme-check: the checks, the runner, config loading, the docs cache, the theme graph and a CLI like theme-check-node's. It reports the same offenses as the TypeScript, with the same messages, positions and fixes. `cargo test` runs the specs in specs/checks and specs/graph, and it passes all of them, and runs the CLI on small themes. A new Rust job in CI runs it and checks the formatting. The crate has copies of theme-check-node's configs and of the checks' metadata. The code quality job checks that they match the TypeScript. Co-Authored-By: Claude Opus 5.5 --- .cargo/config.toml | 2 + .github/workflows/ci.yml | 18 + .gitignore | 1 + Cargo.lock | 218 +++ Cargo.toml | 20 + crates/liquid-ast/Cargo.toml | 8 + crates/liquid-ast/src/lib.rs | 707 ++++++++ crates/liquid-parser/Cargo.toml | 19 + crates/liquid-parser/src/html.rs | 654 +++++++ crates/liquid-parser/src/lib.rs | 291 ++++ crates/liquid-parser/src/lines.rs | 370 ++++ crates/liquid-parser/src/liquid_doc.rs | 505 ++++++ crates/liquid-parser/src/markup.rs | 682 ++++++++ crates/liquid-parser/src/parser.rs | 1027 +++++++++++ crates/liquid-parser/src/tags.rs | 436 +++++ crates/liquid-parser/src/tokenizer.rs | 372 ++++ crates/liquid-parser/tests/specs.rs | 393 +++++ crates/theme-check/Cargo.toml | 21 + crates/theme-check/README.md | 50 + crates/theme-check/src/bin/theme-check.rs | 59 + crates/theme-check/src/block_parameters.rs | 136 ++ .../src/checks/app_block_missing_schema.rs | 47 + .../src/checks/app_block_valid_tags.rs | 60 + crates/theme-check/src/checks/args_utils.rs | 415 +++++ .../theme-check/src/checks/asset_preload.rs | 64 + crates/theme-check/src/checks/asset_size.rs | 187 ++ .../src/checks/asset_size_app_block.rs | 110 ++ .../src/checks/block_content_usage.rs | 56 + .../theme-check/src/checks/block_id_usage.rs | 61 + .../src/checks/block_tag_syntax.rs | 20 + .../theme-check/src/checks/cdn_preconnect.rs | 70 + .../checks/content_for_header_modification.rs | 70 + .../src/checks/deprecate_bgsizes.rs | 51 + .../src/checks/deprecate_lazysizes.rs | 62 + .../src/checks/deprecated_filter.rs | 368 ++++ .../src/checks/deprecated_fonts_data.rs | 1350 +++++++++++++++ ...deprecated_fonts_on_sections_and_blocks.rs | 232 +++ .../deprecated_fonts_on_settings_data.rs | 45 + .../deprecated_fonts_on_settings_schema.rs | 63 + .../theme-check/src/checks/deprecated_tag.rs | 50 + .../src/checks/duplicate_block_arguments.rs | 56 + .../checks/duplicate_content_for_arguments.rs | 44 + .../duplicate_render_snippet_arguments.rs | 42 + .../src/checks/empty_block_content.rs | 81 + .../src/checks/excessive_settings_count.rs | 63 + crates/theme-check/src/checks/file_utils.rs | 173 ++ .../src/checks/hardcoded_routes.rs | 110 ++ .../src/checks/img_width_and_height.rs | 49 + .../src/checks/json_missing_block.rs | 221 +++ .../src/checks/json_missing_section.rs | 57 + .../src/checks/json_syntax_error.rs | 60 + .../src/checks/liquid_complexity.rs | 105 ++ .../src/checks/liquid_free_settings.rs | 87 + .../invalid_boolean_expressions.rs | 25 + .../invalid_conditional_node.rs | 174 ++ .../invalid_conditional_node_parenthesis.rs | 46 + .../invalid_echo_value.rs | 47 + .../invalid_filter_name.rs | 173 ++ .../invalid_loop_arguments.rs | 105 ++ .../invalid_loop_range.rs | 85 + .../invalid_pipe_syntax.rs | 73 + .../checks/liquid_html_syntax_error/mod.rs | 251 +++ .../multiple_assign_values.rs | 77 + .../checks/liquid_html_syntax_error/utils.rs | 142 ++ .../src/checks/liquid_nesting_depth.rs | 65 + .../src/checks/liquid_syntax_error/assign.rs | 51 + .../src/checks/liquid_syntax_error/base.rs | 72 + .../src/checks/liquid_syntax_error/block.rs | 157 ++ .../src/checks/liquid_syntax_error/branch.rs | 81 + .../src/checks/liquid_syntax_error/capture.rs | 22 + .../src/checks/liquid_syntax_error/case.rs | 31 + .../src/checks/liquid_syntax_error/comment.rs | 213 +++ .../checks/liquid_syntax_error/content_for.rs | 112 ++ .../src/checks/liquid_syntax_error/cycle.rs | 35 + .../checks/liquid_syntax_error/decrement.rs | 22 + .../src/checks/liquid_syntax_error/doc.rs | 86 + .../src/checks/liquid_syntax_error/echo.rs | 43 + .../src/checks/liquid_syntax_error/for_tag.rs | 34 + .../src/checks/liquid_syntax_error/form.rs | 53 + .../src/checks/liquid_syntax_error/if_tag.rs | 45 + .../src/checks/liquid_syntax_error/include.rs | 40 + .../checks/liquid_syntax_error/increment.rs | 18 + .../liquid_syntax_error/inline_comment.rs | 19 + .../checks/liquid_syntax_error/javascript.rs | 76 + .../src/checks/liquid_syntax_error/layout.rs | 26 + .../src/checks/liquid_syntax_error/mod.rs | 140 ++ .../checks/liquid_syntax_error/paginate.rs | 45 + .../src/checks/liquid_syntax_error/partial.rs | 64 + .../src/checks/liquid_syntax_error/raw.rs | 50 + .../src/checks/liquid_syntax_error/render.rs | 50 + .../src/checks/liquid_syntax_error/schema.rs | 14 + .../src/checks/liquid_syntax_error/section.rs | 33 + .../checks/liquid_syntax_error/sections.rs | 17 + .../src/checks/liquid_syntax_error/style.rs | 17 + .../checks/liquid_syntax_error/stylesheet.rs | 18 + .../checks/liquid_syntax_error/tablerow.rs | 41 + .../src/checks/liquid_syntax_error/unless.rs | 9 + .../src/checks/liquid_syntax_error/utils.rs | 641 +++++++ .../checks/liquid_syntax_error/variable.rs | 58 + .../src/checks/matching_translations.rs | 222 +++ .../theme-check/src/checks/max_file_size.rs | 191 +++ .../theme-check/src/checks/missing_asset.rs | 62 + .../src/checks/missing_block_arguments.rs | 59 + .../checks/missing_content_for_arguments.rs | 50 + .../missing_render_snippet_arguments.rs | 53 + .../src/checks/missing_template.rs | 98 ++ crates/theme-check/src/checks/mod.rs | 207 +++ .../src/checks/orphaned_snippet.rs | 39 + .../theme-check/src/checks/pagination_size.rs | 141 ++ .../src/checks/parser_blocking_script.rs | 121 ++ crates/theme-check/src/checks/raw_tags.rs | 157 ++ crates/theme-check/src/checks/remote_asset.rs | 386 +++++ .../checks/required_layout_theme_object.rs | 86 + .../src/checks/reserved_doc_param_names.rs | 41 + .../src/checks/schema_presets_block_order.rs | 176 ++ .../checks/schema_presets_static_blocks.rs | 162 ++ crates/theme-check/src/checks/schema_utils.rs | 573 +++++++ .../static_stylesheet_and_javascript_tags.rs | 53 + crates/theme-check/src/checks/styles.rs | 1245 ++++++++++++++ .../src/checks/translation_key_exists.rs | 169 ++ .../src/checks/unclosed_html_element.rs | 306 ++++ .../src/checks/undefined_object.rs | 299 ++++ .../src/checks/unique_doc_param_names.rs | 44 + .../src/checks/unique_setting_id.rs | 66 + .../src/checks/unique_static_block_id.rs | 99 ++ .../theme-check/src/checks/unknown_filter.rs | 45 + .../checks/unrecognized_block_arguments.rs | 50 + .../unrecognized_content_for_arguments.rs | 56 + .../unrecognized_render_snippet_arguments.rs | 76 + .../src/checks/unsupported_doc_tag.rs | 48 + .../checks/unsupported_filter_arguments.rs | 72 + .../theme-check/src/checks/unused_assign.rs | 160 ++ .../src/checks/unused_doc_param.rs | 81 + crates/theme-check/src/checks/utils.rs | 107 ++ .../src/checks/valid_block_argument_types.rs | 177 ++ .../valid_block_content_setting_type.rs | 97 ++ .../src/checks/valid_block_tag_placement.rs | 54 + .../src/checks/valid_block_target.rs | 168 ++ .../valid_content_for_argument_types.rs | 40 + .../src/checks/valid_content_for_arguments.rs | 119 ++ .../src/checks/valid_doc_param_types.rs | 83 + .../src/checks/valid_html_translation.rs | 47 + crates/theme-check/src/checks/valid_json.rs | 36 + .../src/checks/valid_local_blocks.rs | 172 ++ .../valid_render_snippet_argument_types.rs | 79 + crates/theme-check/src/checks/valid_schema.rs | 49 + .../src/checks/valid_schema_name.rs | 97 ++ .../src/checks/valid_schema_translations.rs | 82 + .../src/checks/valid_scoped_css_class.rs | 171 ++ .../src/checks/valid_settings_key.rs | 161 ++ .../src/checks/valid_standard_event_data.rs | 159 ++ .../src/checks/valid_static_block_type.rs | 48 + .../src/checks/valid_visible_if.rs | 374 ++++ .../theme-check/src/checks/variable_name.rs | 241 +++ crates/theme-check/src/context.rs | 732 ++++++++ crates/theme-check/src/docs.rs | 365 ++++ crates/theme-check/src/docset.rs | 185 ++ crates/theme-check/src/fixes.rs | 658 +++++++ crates/theme-check/src/graph/mod.rs | 1029 +++++++++++ .../theme-check/src/graph/web_components.rs | 393 +++++ crates/theme-check/src/js.rs | 243 +++ crates/theme-check/src/json.rs | 1410 +++++++++++++++ .../theme-check/src/json_validator/jsregex.rs | 373 ++++ .../src/json_validator/meta_schemas.json | 1 + crates/theme-check/src/json_validator/mod.rs | 337 ++++ .../theme-check/src/json_validator/parser.rs | 447 +++++ .../theme-check/src/json_validator/service.rs | 894 ++++++++++ crates/theme-check/src/json_validator/url.rs | 486 ++++++ .../src/json_validator/validate.rs | 1309 ++++++++++++++ crates/theme-check/src/lib.rs | 30 + crates/theme-check/src/liquid_doc.rs | 196 +++ crates/theme-check/src/minimatch.json | 1 + crates/theme-check/src/minimatch.rs | 1514 +++++++++++++++++ crates/theme-check/src/node/checks-meta.json | 1 + crates/theme-check/src/node/config.rs | 789 +++++++++ crates/theme-check/src/node/configs/all.yml | 283 +++ .../theme-check/src/node/configs/nothing.yml | 3 + .../src/node/configs/recommended.yml | 261 +++ .../src/node/configs/theme-app-extension.yml | 24 + crates/theme-check/src/node/mod.rs | 586 +++++++ crates/theme-check/src/node_fs.rs | 58 + crates/theme-check/src/path.rs | 454 +++++ crates/theme-check/src/runner.rs | 495 ++++++ crates/theme-check/src/schema.rs | 202 +++ crates/theme-check/src/source.rs | 211 +++ crates/theme-check/src/visible_if.rs | 79 + crates/theme-check/src/visitor.rs | 86 + crates/theme-check/src/yaml.rs | 330 ++++ crates/theme-check/tests/cli.rs | 310 ++++ crates/theme-check/tests/specs.rs | 675 ++++++++ specs/parser/README.md | 17 +- 191 files changed, 37927 insertions(+), 8 deletions(-) create mode 100644 .cargo/config.toml create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 crates/liquid-ast/Cargo.toml create mode 100644 crates/liquid-ast/src/lib.rs create mode 100644 crates/liquid-parser/Cargo.toml create mode 100644 crates/liquid-parser/src/html.rs create mode 100644 crates/liquid-parser/src/lib.rs create mode 100644 crates/liquid-parser/src/lines.rs create mode 100644 crates/liquid-parser/src/liquid_doc.rs create mode 100644 crates/liquid-parser/src/markup.rs create mode 100644 crates/liquid-parser/src/parser.rs create mode 100644 crates/liquid-parser/src/tags.rs create mode 100644 crates/liquid-parser/src/tokenizer.rs create mode 100644 crates/liquid-parser/tests/specs.rs create mode 100644 crates/theme-check/Cargo.toml create mode 100644 crates/theme-check/README.md create mode 100644 crates/theme-check/src/bin/theme-check.rs create mode 100644 crates/theme-check/src/block_parameters.rs create mode 100644 crates/theme-check/src/checks/app_block_missing_schema.rs create mode 100644 crates/theme-check/src/checks/app_block_valid_tags.rs create mode 100644 crates/theme-check/src/checks/args_utils.rs create mode 100644 crates/theme-check/src/checks/asset_preload.rs create mode 100644 crates/theme-check/src/checks/asset_size.rs create mode 100644 crates/theme-check/src/checks/asset_size_app_block.rs create mode 100644 crates/theme-check/src/checks/block_content_usage.rs create mode 100644 crates/theme-check/src/checks/block_id_usage.rs create mode 100644 crates/theme-check/src/checks/block_tag_syntax.rs create mode 100644 crates/theme-check/src/checks/cdn_preconnect.rs create mode 100644 crates/theme-check/src/checks/content_for_header_modification.rs create mode 100644 crates/theme-check/src/checks/deprecate_bgsizes.rs create mode 100644 crates/theme-check/src/checks/deprecate_lazysizes.rs create mode 100644 crates/theme-check/src/checks/deprecated_filter.rs create mode 100644 crates/theme-check/src/checks/deprecated_fonts_data.rs create mode 100644 crates/theme-check/src/checks/deprecated_fonts_on_sections_and_blocks.rs create mode 100644 crates/theme-check/src/checks/deprecated_fonts_on_settings_data.rs create mode 100644 crates/theme-check/src/checks/deprecated_fonts_on_settings_schema.rs create mode 100644 crates/theme-check/src/checks/deprecated_tag.rs create mode 100644 crates/theme-check/src/checks/duplicate_block_arguments.rs create mode 100644 crates/theme-check/src/checks/duplicate_content_for_arguments.rs create mode 100644 crates/theme-check/src/checks/duplicate_render_snippet_arguments.rs create mode 100644 crates/theme-check/src/checks/empty_block_content.rs create mode 100644 crates/theme-check/src/checks/excessive_settings_count.rs create mode 100644 crates/theme-check/src/checks/file_utils.rs create mode 100644 crates/theme-check/src/checks/hardcoded_routes.rs create mode 100644 crates/theme-check/src/checks/img_width_and_height.rs create mode 100644 crates/theme-check/src/checks/json_missing_block.rs create mode 100644 crates/theme-check/src/checks/json_missing_section.rs create mode 100644 crates/theme-check/src/checks/json_syntax_error.rs create mode 100644 crates/theme-check/src/checks/liquid_complexity.rs create mode 100644 crates/theme-check/src/checks/liquid_free_settings.rs create mode 100644 crates/theme-check/src/checks/liquid_html_syntax_error/invalid_boolean_expressions.rs create mode 100644 crates/theme-check/src/checks/liquid_html_syntax_error/invalid_conditional_node.rs create mode 100644 crates/theme-check/src/checks/liquid_html_syntax_error/invalid_conditional_node_parenthesis.rs create mode 100644 crates/theme-check/src/checks/liquid_html_syntax_error/invalid_echo_value.rs create mode 100644 crates/theme-check/src/checks/liquid_html_syntax_error/invalid_filter_name.rs create mode 100644 crates/theme-check/src/checks/liquid_html_syntax_error/invalid_loop_arguments.rs create mode 100644 crates/theme-check/src/checks/liquid_html_syntax_error/invalid_loop_range.rs create mode 100644 crates/theme-check/src/checks/liquid_html_syntax_error/invalid_pipe_syntax.rs create mode 100644 crates/theme-check/src/checks/liquid_html_syntax_error/mod.rs create mode 100644 crates/theme-check/src/checks/liquid_html_syntax_error/multiple_assign_values.rs create mode 100644 crates/theme-check/src/checks/liquid_html_syntax_error/utils.rs create mode 100644 crates/theme-check/src/checks/liquid_nesting_depth.rs create mode 100644 crates/theme-check/src/checks/liquid_syntax_error/assign.rs create mode 100644 crates/theme-check/src/checks/liquid_syntax_error/base.rs create mode 100644 crates/theme-check/src/checks/liquid_syntax_error/block.rs create mode 100644 crates/theme-check/src/checks/liquid_syntax_error/branch.rs create mode 100644 crates/theme-check/src/checks/liquid_syntax_error/capture.rs create mode 100644 crates/theme-check/src/checks/liquid_syntax_error/case.rs create mode 100644 crates/theme-check/src/checks/liquid_syntax_error/comment.rs create mode 100644 crates/theme-check/src/checks/liquid_syntax_error/content_for.rs create mode 100644 crates/theme-check/src/checks/liquid_syntax_error/cycle.rs create mode 100644 crates/theme-check/src/checks/liquid_syntax_error/decrement.rs create mode 100644 crates/theme-check/src/checks/liquid_syntax_error/doc.rs create mode 100644 crates/theme-check/src/checks/liquid_syntax_error/echo.rs create mode 100644 crates/theme-check/src/checks/liquid_syntax_error/for_tag.rs create mode 100644 crates/theme-check/src/checks/liquid_syntax_error/form.rs create mode 100644 crates/theme-check/src/checks/liquid_syntax_error/if_tag.rs create mode 100644 crates/theme-check/src/checks/liquid_syntax_error/include.rs create mode 100644 crates/theme-check/src/checks/liquid_syntax_error/increment.rs create mode 100644 crates/theme-check/src/checks/liquid_syntax_error/inline_comment.rs create mode 100644 crates/theme-check/src/checks/liquid_syntax_error/javascript.rs create mode 100644 crates/theme-check/src/checks/liquid_syntax_error/layout.rs create mode 100644 crates/theme-check/src/checks/liquid_syntax_error/mod.rs create mode 100644 crates/theme-check/src/checks/liquid_syntax_error/paginate.rs create mode 100644 crates/theme-check/src/checks/liquid_syntax_error/partial.rs create mode 100644 crates/theme-check/src/checks/liquid_syntax_error/raw.rs create mode 100644 crates/theme-check/src/checks/liquid_syntax_error/render.rs create mode 100644 crates/theme-check/src/checks/liquid_syntax_error/schema.rs create mode 100644 crates/theme-check/src/checks/liquid_syntax_error/section.rs create mode 100644 crates/theme-check/src/checks/liquid_syntax_error/sections.rs create mode 100644 crates/theme-check/src/checks/liquid_syntax_error/style.rs create mode 100644 crates/theme-check/src/checks/liquid_syntax_error/stylesheet.rs create mode 100644 crates/theme-check/src/checks/liquid_syntax_error/tablerow.rs create mode 100644 crates/theme-check/src/checks/liquid_syntax_error/unless.rs create mode 100644 crates/theme-check/src/checks/liquid_syntax_error/utils.rs create mode 100644 crates/theme-check/src/checks/liquid_syntax_error/variable.rs create mode 100644 crates/theme-check/src/checks/matching_translations.rs create mode 100644 crates/theme-check/src/checks/max_file_size.rs create mode 100644 crates/theme-check/src/checks/missing_asset.rs create mode 100644 crates/theme-check/src/checks/missing_block_arguments.rs create mode 100644 crates/theme-check/src/checks/missing_content_for_arguments.rs create mode 100644 crates/theme-check/src/checks/missing_render_snippet_arguments.rs create mode 100644 crates/theme-check/src/checks/missing_template.rs create mode 100644 crates/theme-check/src/checks/mod.rs create mode 100644 crates/theme-check/src/checks/orphaned_snippet.rs create mode 100644 crates/theme-check/src/checks/pagination_size.rs create mode 100644 crates/theme-check/src/checks/parser_blocking_script.rs create mode 100644 crates/theme-check/src/checks/raw_tags.rs create mode 100644 crates/theme-check/src/checks/remote_asset.rs create mode 100644 crates/theme-check/src/checks/required_layout_theme_object.rs create mode 100644 crates/theme-check/src/checks/reserved_doc_param_names.rs create mode 100644 crates/theme-check/src/checks/schema_presets_block_order.rs create mode 100644 crates/theme-check/src/checks/schema_presets_static_blocks.rs create mode 100644 crates/theme-check/src/checks/schema_utils.rs create mode 100644 crates/theme-check/src/checks/static_stylesheet_and_javascript_tags.rs create mode 100644 crates/theme-check/src/checks/styles.rs create mode 100644 crates/theme-check/src/checks/translation_key_exists.rs create mode 100644 crates/theme-check/src/checks/unclosed_html_element.rs create mode 100644 crates/theme-check/src/checks/undefined_object.rs create mode 100644 crates/theme-check/src/checks/unique_doc_param_names.rs create mode 100644 crates/theme-check/src/checks/unique_setting_id.rs create mode 100644 crates/theme-check/src/checks/unique_static_block_id.rs create mode 100644 crates/theme-check/src/checks/unknown_filter.rs create mode 100644 crates/theme-check/src/checks/unrecognized_block_arguments.rs create mode 100644 crates/theme-check/src/checks/unrecognized_content_for_arguments.rs create mode 100644 crates/theme-check/src/checks/unrecognized_render_snippet_arguments.rs create mode 100644 crates/theme-check/src/checks/unsupported_doc_tag.rs create mode 100644 crates/theme-check/src/checks/unsupported_filter_arguments.rs create mode 100644 crates/theme-check/src/checks/unused_assign.rs create mode 100644 crates/theme-check/src/checks/unused_doc_param.rs create mode 100644 crates/theme-check/src/checks/utils.rs create mode 100644 crates/theme-check/src/checks/valid_block_argument_types.rs create mode 100644 crates/theme-check/src/checks/valid_block_content_setting_type.rs create mode 100644 crates/theme-check/src/checks/valid_block_tag_placement.rs create mode 100644 crates/theme-check/src/checks/valid_block_target.rs create mode 100644 crates/theme-check/src/checks/valid_content_for_argument_types.rs create mode 100644 crates/theme-check/src/checks/valid_content_for_arguments.rs create mode 100644 crates/theme-check/src/checks/valid_doc_param_types.rs create mode 100644 crates/theme-check/src/checks/valid_html_translation.rs create mode 100644 crates/theme-check/src/checks/valid_json.rs create mode 100644 crates/theme-check/src/checks/valid_local_blocks.rs create mode 100644 crates/theme-check/src/checks/valid_render_snippet_argument_types.rs create mode 100644 crates/theme-check/src/checks/valid_schema.rs create mode 100644 crates/theme-check/src/checks/valid_schema_name.rs create mode 100644 crates/theme-check/src/checks/valid_schema_translations.rs create mode 100644 crates/theme-check/src/checks/valid_scoped_css_class.rs create mode 100644 crates/theme-check/src/checks/valid_settings_key.rs create mode 100644 crates/theme-check/src/checks/valid_standard_event_data.rs create mode 100644 crates/theme-check/src/checks/valid_static_block_type.rs create mode 100644 crates/theme-check/src/checks/valid_visible_if.rs create mode 100644 crates/theme-check/src/checks/variable_name.rs create mode 100644 crates/theme-check/src/context.rs create mode 100644 crates/theme-check/src/docs.rs create mode 100644 crates/theme-check/src/docset.rs create mode 100644 crates/theme-check/src/fixes.rs create mode 100644 crates/theme-check/src/graph/mod.rs create mode 100644 crates/theme-check/src/graph/web_components.rs create mode 100644 crates/theme-check/src/js.rs create mode 100644 crates/theme-check/src/json.rs create mode 100644 crates/theme-check/src/json_validator/jsregex.rs create mode 100644 crates/theme-check/src/json_validator/meta_schemas.json create mode 100644 crates/theme-check/src/json_validator/mod.rs create mode 100644 crates/theme-check/src/json_validator/parser.rs create mode 100644 crates/theme-check/src/json_validator/service.rs create mode 100644 crates/theme-check/src/json_validator/url.rs create mode 100644 crates/theme-check/src/json_validator/validate.rs create mode 100644 crates/theme-check/src/lib.rs create mode 100644 crates/theme-check/src/liquid_doc.rs create mode 100644 crates/theme-check/src/minimatch.json create mode 100644 crates/theme-check/src/minimatch.rs create mode 100644 crates/theme-check/src/node/checks-meta.json create mode 100644 crates/theme-check/src/node/config.rs create mode 100644 crates/theme-check/src/node/configs/all.yml create mode 100644 crates/theme-check/src/node/configs/nothing.yml create mode 100644 crates/theme-check/src/node/configs/recommended.yml create mode 100644 crates/theme-check/src/node/configs/theme-app-extension.yml create mode 100644 crates/theme-check/src/node/mod.rs create mode 100644 crates/theme-check/src/node_fs.rs create mode 100644 crates/theme-check/src/path.rs create mode 100644 crates/theme-check/src/runner.rs create mode 100644 crates/theme-check/src/schema.rs create mode 100644 crates/theme-check/src/source.rs create mode 100644 crates/theme-check/src/visible_if.rs create mode 100644 crates/theme-check/src/visitor.rs create mode 100644 crates/theme-check/src/yaml.rs create mode 100644 crates/theme-check/tests/cli.rs create mode 100644 crates/theme-check/tests/specs.rs diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 000000000..4a6a1abdd --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[resolver] +incompatible-rust-versions = "fallback" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7e6d9521..d17ed3912 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,24 @@ jobs: - name: Check formatting run: pnpm format:check + - name: Check the Rust crate's copies of TypeScript data + run: | + diff -r packages/theme-check-node/configs crates/theme-check/src/node/configs + node -e "console.log(JSON.stringify(require('./packages/theme-check-common').allChecks.map((c) => c.meta)))" \ + | diff - crates/theme-check/src/node/checks-meta.json + + rust: + runs-on: ubuntu-latest + name: Rust + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Check formatting + run: cargo fmt --all --check + + - name: Run the unit tests and the specs + run: cargo test --workspace --locked + e2e: runs-on: ubuntu-latest name: E2E tests diff --git a/.gitignore b/.gitignore index 4712a799e..652832f3a 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ TODO tsconfig.builinfo tsconfig.tsbuildinfo pnpm-debug.log* +/target package-lock.json .npmrc packages/theme-check-docs-updater/data diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 000000000..8a5bf6ab8 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,218 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "indexmap" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "liquid-ast" +version = "0.1.0" + +[[package]] +name = "liquid-parser" +version = "0.1.0" +dependencies = [ + "liquid-ast", + "serde_json", + "yaml-rust2", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "foldhash", + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "theme-check" +version = "0.1.0" +dependencies = [ + "liquid-ast", + "liquid-parser", + "regex", + "serde_json", + "yaml-rust2", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "yaml-rust2" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b36710ce3a279cfce8465dbab826f161675a262950b922cb2c3663852dfe9eb0" +dependencies = [ + "arraydeque", + "hashlink", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 000000000..0ed7042b9 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,20 @@ +[workspace] +resolver = "2" +members = ["crates/liquid-ast", "crates/liquid-parser", "crates/theme-check"] + +[workspace.package] +version = "0.1.0" +edition = "2021" +rust-version = "1.84" +license = "MIT" +repository = "https://github.com/Shopify/theme-tools" + +[workspace.dependencies] +liquid-ast = { path = "crates/liquid-ast", version = "0.1.0" } +liquid-parser = { path = "crates/liquid-parser", version = "0.1.0" } + +[profile.release] +lto = "fat" +codegen-units = 1 +panic = "abort" +debug = 1 diff --git a/crates/liquid-ast/Cargo.toml b/crates/liquid-ast/Cargo.toml new file mode 100644 index 000000000..aa25845ae --- /dev/null +++ b/crates/liquid-ast/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "liquid-ast" +description = "The syntax tree of Liquid HTML templates, as @shopify/liquid-html-parser builds it" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true diff --git a/crates/liquid-ast/src/lib.rs b/crates/liquid-ast/src/lib.rs new file mode 100644 index 000000000..ab2469062 --- /dev/null +++ b/crates/liquid-ast/src/lib.rs @@ -0,0 +1,707 @@ +//! Rust mirror of `packages/liquid-html-parser/src/ast.ts`. +//! +//! Conventions: +//! - Positions are UTF-8 byte offsets into the document source (TS uses UTF-16 indices; convert +//! at output boundaries only). `Position::MISSING` stands in for TS's `{ start: -1, end: -1 }`. +//! - Struct fields are declared in the same order as the TS object keys are created, because +//! theme-check's visitor walks keys in insertion order (see `push_children`). +//! - TS unions (`LiquidHtmlNode`, `LiquidExpression`, `AttributeNode`, ...) are `Node`; fields +//! whose TS type is one concrete node type hold that struct directly. +//! - TS `undefined` fields are `Option` (omitted from JSON); TS `null` fields are `Option` too and +//! the serializer knows which ones print `null`. +//! - String fields borrow the source when they can (`Cow::Borrowed`). + +use std::borrow::Cow; + +pub type Str<'a> = Cow<'a, str>; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)] +pub struct Position { + pub start: usize, + pub end: usize, +} + +impl Position { + /// TS `{ start: -1, end: -1 }` (unclosed branch end). + pub const MISSING: Position = Position { + start: usize::MAX, + end: usize::MAX, + }; + pub fn new(start: usize, end: usize) -> Self { + Position { start, end } + } + pub fn is_missing(&self) -> bool { + self.start == usize::MAX + } +} + +/// TS `markup` of LiquidTag / LiquidBranch / LiquidVariableOutput: a string when the base case +/// was used, else a node, an array of nodes (`{% liquid %}` statements, `form` args, `when` +/// expressions), or `null` (`ifchanged`). +#[derive(Clone, Debug)] +pub enum Markup<'a> { + Str(Str<'a>), + Node(Box>), + Nodes(Vec>), + Null, +} + +impl<'a> Markup<'a> { + pub fn as_str(&self) -> Option<&str> { + match self { + Markup::Str(s) => Some(s), + _ => None, + } + } + pub fn node(&self) -> Option<&Node<'a>> { + match self { + Markup::Node(n) => Some(n), + _ => None, + } + } + pub fn nodes(&self) -> Option<&[Node<'a>]> { + match self { + Markup::Nodes(n) => Some(n), + _ => None, + } + } + /// TS `typeof node.markup === 'string'` + pub fn is_str(&self) -> bool { + matches!(self, Markup::Str(_)) + } +} + +#[derive(Clone, Debug)] +pub struct Document<'a> { + pub children: Vec>, + pub position: Position, +} + +#[derive(Clone, Debug)] +pub struct YamlFrontmatter<'a> { + pub body: Str<'a>, + pub position: Position, +} + +#[derive(Clone, Debug)] +pub struct LiquidRawTag<'a> { + pub name: Str<'a>, + pub markup: Str<'a>, + pub markup_position: Position, + pub body: RawMarkup<'a>, + pub whitespace_start: bool, + pub whitespace_end: bool, + pub delimiter_whitespace_start: bool, + pub delimiter_whitespace_end: bool, + pub block_start_position: Position, + pub block_end_position: Position, + pub position: Position, +} + +#[derive(Clone, Debug)] +pub struct LiquidTag<'a> { + pub name: Str<'a>, + pub markup: Markup<'a>, + pub children: Option>>, + pub whitespace_start: bool, + pub whitespace_end: bool, + pub delimiter_whitespace_start: Option, + pub delimiter_whitespace_end: Option, + pub block_start_position: Position, + pub block_end_position: Option, + pub markup_position: Position, + pub position: Position, + /// Only on base-case tags (`markup` is a string), and only when set. + pub reason: Option>, +} + +#[derive(Clone, Debug)] +pub struct LiquidBranch<'a> { + /// `None` for the first (unnamed) branch. + pub name: Option>, + pub markup: Markup<'a>, + pub children: Vec>, + pub whitespace_start: bool, + pub whitespace_end: bool, + pub markup_position: Position, + pub block_start_position: Position, + pub block_end_position: Position, + pub position: Position, +} + +#[derive(Clone, Debug)] +pub struct LiquidVariableOutput<'a> { + /// `Markup::Str` or `Markup::Node(LiquidVariable)`. + pub markup: Markup<'a>, + pub whitespace_start: bool, + pub whitespace_end: bool, + pub markup_position: Position, + pub position: Position, +} + +#[derive(Clone, Debug)] +pub struct HtmlElement<'a> { + pub name: Vec>, + pub attributes: Vec>, + pub children: Vec>, + pub block_start_position: Position, + pub block_end_position: Position, + pub position: Position, +} + +#[derive(Clone, Debug)] +pub struct HtmlVoidElement<'a> { + pub name: Str<'a>, + pub attributes: Vec>, + pub block_start_position: Position, + pub position: Position, +} + +#[derive(Clone, Debug)] +pub struct HtmlSelfClosingElement<'a> { + pub name: Vec>, + pub attributes: Vec>, + pub block_start_position: Position, + pub position: Position, +} + +#[derive(Clone, Debug)] +pub struct HtmlRawNode<'a> { + pub name: Str<'a>, + pub attributes: Vec>, + pub body: RawMarkup<'a>, + pub block_start_position: Position, + pub block_end_position: Position, + pub position: Position, +} + +#[derive(Clone, Debug)] +pub struct HtmlComment<'a> { + pub body: Str<'a>, + pub position: Position, +} + +#[derive(Clone, Debug)] +pub struct HtmlDoctype<'a> { + pub legacy_doctype_string: Option>, + pub position: Position, +} + +#[derive(Clone, Debug)] +pub struct HtmlDanglingMarkerClose<'a> { + pub name: Vec>, + pub block_start_position: Position, + pub position: Position, +} + +/// AttrSingleQuoted / AttrDoubleQuoted / AttrUnquoted (the variant says which). +#[derive(Clone, Debug)] +pub struct Attr<'a> { + pub name: Vec>, + pub value: Vec>, + pub attribute_position: Position, + pub position: Position, +} + +#[derive(Clone, Debug)] +pub struct AttrEmpty<'a> { + pub name: Vec>, + pub position: Position, +} + +#[derive(Clone, Debug)] +pub struct TextNode<'a> { + pub value: Str<'a>, + pub position: Position, +} + +#[derive(Clone, Debug)] +pub struct RawMarkup<'a> { + /// css, html, javascript, json, markdown, typescript, text + pub kind: &'static str, + pub value: Str<'a>, + pub nodes: Vec>, + pub position: Position, +} + +#[derive(Clone, Debug)] +pub struct LiquidVariable<'a> { + /// ComplexLiquidExpression: BooleanExpression or a LiquidExpression. + pub expression: Box>, + pub filters: Vec>, + pub raw_source: Str<'a>, + pub position: Position, +} + +#[derive(Clone, Debug)] +pub struct LiquidFilter<'a> { + pub name: Str<'a>, + /// LiquidExpression or NamedArgument nodes. + pub args: Vec>, + pub position: Position, +} + +#[derive(Clone, Debug)] +pub struct NamedArgument<'a> { + pub name: Str<'a>, + /// A LiquidExpression, or a BlockArrayLiteral for block/section tag args. + pub value: Box>, + pub position: Position, +} + +#[derive(Clone, Debug)] +pub struct BooleanExpression<'a> { + pub condition: Box>, + pub position: Position, +} + +#[derive(Clone, Debug)] +pub struct LiquidString<'a> { + /// TS omits `single` on some lookup-segment strings; `None` there. + pub single: Option, + pub value: Str<'a>, + pub position: Position, +} + +#[derive(Clone, Debug)] +pub struct LiquidNumber<'a> { + pub value: Str<'a>, + pub position: Position, +} + +#[derive(Clone, Debug)] +pub struct LiquidLiteral { + /// nil, null, true, false, blank, empty + pub keyword: &'static str, + pub position: Position, +} + +#[derive(Clone, Debug)] +pub struct LiquidRange<'a> { + pub start: Box>, + pub end: Box>, + pub position: Position, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LookupMode { + Bareword, + Subscript, +} + +#[derive(Clone, Debug)] +pub struct VariableLookup<'a> { + pub name: Option>, + pub lookups: Vec>, + /// Absent in TS on some constructions; `None` there. + pub lookup_modes: Option>, + pub position: Position, +} + +#[derive(Clone, Debug)] +pub struct Comparison<'a> { + /// contains, ==, >, >=, <, <=, != + pub comparator: &'static str, + pub left: Box>, + pub right: Box>, + pub position: Position, +} + +#[derive(Clone, Debug)] +pub struct LogicalExpression<'a> { + /// and, or + pub relation: &'static str, + pub left: Box>, + pub right: Box>, + pub position: Position, +} + +#[derive(Clone, Debug)] +pub struct AssignMarkup<'a> { + pub name: Str<'a>, + pub value: LiquidVariable<'a>, + pub position: Position, +} + +/// BlockMarkup and SectionMarkup. +#[derive(Clone, Debug)] +pub struct BlockMarkup<'a> { + pub name: LiquidString<'a>, + pub args: Vec>, + pub position: Position, +} + +#[derive(Clone, Debug)] +pub struct ContentForMarkup<'a> { + pub content_for_type: LiquidString<'a>, + pub args: Vec>, + pub position: Position, +} + +#[derive(Clone, Debug)] +pub struct CycleMarkup<'a> { + pub group_name: Option>>, + pub args: Vec>, + pub position: Position, +} + +#[derive(Clone, Debug)] +pub struct ForMarkup<'a> { + pub variable_name: Str<'a>, + pub collection: Box>, + pub reversed: bool, + pub args: Vec>, + pub position: Position, +} + +#[derive(Clone, Debug)] +pub struct PaginateMarkup<'a> { + pub collection: Box>, + pub page_size: Box>, + pub args: Vec>, + pub position: Position, +} + +#[derive(Clone, Debug)] +pub struct RenderMarkup<'a> { + pub snippet: Box>, + pub variable: Option>, + pub alias: Option>, + pub args: Vec>, + pub position: Position, +} + +#[derive(Clone, Debug)] +pub struct RenderVariableExpression<'a> { + /// for, with + pub kind: &'static str, + pub name: Box>, + pub position: Position, +} + +#[derive(Clone, Debug)] +pub struct RenderAliasExpression<'a> { + pub value: Str<'a>, + pub position: Position, +} + +#[derive(Clone, Debug)] +pub struct BlockArrayLiteral<'a> { + pub elements: Vec>, + pub position: Position, +} + +#[derive(Clone, Debug)] +pub struct LiquidDocParamNode<'a> { + pub param_name: TextNode<'a>, + pub param_type: Option>, + pub param_description: Option>, + pub required: bool, + pub position: Position, +} + +#[derive(Clone, Debug)] +pub struct LiquidDocDescriptionNode<'a> { + pub content: TextNode<'a>, + pub is_implicit: bool, + pub is_inline: bool, + pub position: Position, +} + +#[derive(Clone, Debug)] +pub struct LiquidDocExampleNode<'a> { + pub content: TextNode<'a>, + pub is_inline: bool, + pub position: Position, +} + +#[derive(Clone, Debug)] +pub struct LiquidDocPromptNode<'a> { + pub content: TextNode<'a>, + pub position: Position, +} + +#[derive(Clone, Debug)] +pub struct LiquidErrorNode<'a> { + pub message: Str<'a>, + pub found: Option>, + pub position: Position, +} + +macro_rules! node_types { + ($($variant:ident($ty:ty) = $ts:literal,)*) => { + /// An owned AST node. Variant names are the TS `NodeTypes` (plus `BlockArrayLiteral`). + #[derive(Clone, Debug)] + pub enum Node<'a> { $($variant($ty),)* } + + /// A borrowed node, as handed to checks by the walker. Covers struct-typed fields + /// (e.g. `LiquidRawTag.body`) as well as `Node`s. + #[derive(Clone, Copy, Debug)] + pub enum NodeRef<'a> { $($variant(&'a $ty),)* } + + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + #[repr(u8)] + pub enum NodeKind { $($variant,)* } + + impl NodeKind { + pub const ALL: &'static [NodeKind] = &[$(NodeKind::$variant,)*]; + /// The TS `type` string. + pub fn type_name(self) -> &'static str { + match self { $(NodeKind::$variant => $ts,)* } + } + pub fn bit(self) -> u64 { 1u64 << (self as u8) } + } + + impl<'a> Node<'a> { + pub fn to_ref(&'a self) -> NodeRef<'a> { + match self { $(Node::$variant(n) => NodeRef::$variant(n),)* } + } + pub fn kind(&self) -> NodeKind { + match self { $(Node::$variant(_) => NodeKind::$variant,)* } + } + pub fn position(&self) -> Position { + match self { $(Node::$variant(n) => n.position,)* } + } + pub fn type_name(&self) -> &'static str { self.kind().type_name() } + } + + impl<'a> NodeRef<'a> { + pub fn kind(&self) -> NodeKind { + match self { $(NodeRef::$variant(_) => NodeKind::$variant,)* } + } + pub fn position(&self) -> Position { + match self { $(NodeRef::$variant(n) => n.position,)* } + } + pub fn type_name(&self) -> &'static str { self.kind().type_name() } + /// Identity comparison (same node in memory). + pub fn ptr_eq(&self, other: &NodeRef<'a>) -> bool { + self.addr() == other.addr() && self.kind() == other.kind() + } + pub fn addr(&self) -> usize { + match self { $(NodeRef::$variant(n) => *n as *const $ty as usize,)* } + } + } + }; +} + +node_types! { + Document(Document<'a>) = "Document", + YAMLFrontmatter(YamlFrontmatter<'a>) = "YAMLFrontmatter", + LiquidRawTag(LiquidRawTag<'a>) = "LiquidRawTag", + LiquidTag(LiquidTag<'a>) = "LiquidTag", + LiquidBranch(LiquidBranch<'a>) = "LiquidBranch", + LiquidVariableOutput(LiquidVariableOutput<'a>) = "LiquidVariableOutput", + HtmlSelfClosingElement(HtmlSelfClosingElement<'a>) = "HtmlSelfClosingElement", + HtmlVoidElement(HtmlVoidElement<'a>) = "HtmlVoidElement", + HtmlDoctype(HtmlDoctype<'a>) = "HtmlDoctype", + HtmlComment(HtmlComment<'a>) = "HtmlComment", + HtmlElement(HtmlElement<'a>) = "HtmlElement", + HtmlDanglingMarkerClose(HtmlDanglingMarkerClose<'a>) = "HtmlDanglingMarkerClose", + HtmlRawNode(HtmlRawNode<'a>) = "HtmlRawNode", + AttrSingleQuoted(Attr<'a>) = "AttrSingleQuoted", + AttrDoubleQuoted(Attr<'a>) = "AttrDoubleQuoted", + AttrUnquoted(Attr<'a>) = "AttrUnquoted", + AttrEmpty(AttrEmpty<'a>) = "AttrEmpty", + TextNode(TextNode<'a>) = "TextNode", + LiquidVariable(LiquidVariable<'a>) = "LiquidVariable", + LiquidFilter(LiquidFilter<'a>) = "LiquidFilter", + NamedArgument(NamedArgument<'a>) = "NamedArgument", + LiquidLiteral(LiquidLiteral) = "LiquidLiteral", + BooleanExpression(BooleanExpression<'a>) = "BooleanExpression", + String(LiquidString<'a>) = "String", + Number(LiquidNumber<'a>) = "Number", + Range(LiquidRange<'a>) = "Range", + VariableLookup(VariableLookup<'a>) = "VariableLookup", + Comparison(Comparison<'a>) = "Comparison", + LogicalExpression(LogicalExpression<'a>) = "LogicalExpression", + AssignMarkup(AssignMarkup<'a>) = "AssignMarkup", + BlockMarkup(BlockMarkup<'a>) = "BlockMarkup", + ContentForMarkup(ContentForMarkup<'a>) = "ContentForMarkup", + CycleMarkup(CycleMarkup<'a>) = "CycleMarkup", + ForMarkup(ForMarkup<'a>) = "ForMarkup", + PaginateMarkup(PaginateMarkup<'a>) = "PaginateMarkup", + RawMarkup(RawMarkup<'a>) = "RawMarkup", + RenderMarkup(RenderMarkup<'a>) = "RenderMarkup", + SectionMarkup(BlockMarkup<'a>) = "SectionMarkup", + RenderVariableExpression(RenderVariableExpression<'a>) = "RenderVariableExpression", + RenderAliasExpression(RenderAliasExpression<'a>) = "RenderAliasExpression", + LiquidDocDescriptionNode(LiquidDocDescriptionNode<'a>) = "LiquidDocDescriptionNode", + LiquidDocParamNode(LiquidDocParamNode<'a>) = "LiquidDocParamNode", + LiquidDocExampleNode(LiquidDocExampleNode<'a>) = "LiquidDocExampleNode", + LiquidDocPromptNode(LiquidDocPromptNode<'a>) = "LiquidDocPromptNode", + LiquidErrorNode(LiquidErrorNode<'a>) = "LiquidErrorNode", + BlockArrayLiteral(BlockArrayLiteral<'a>) = "BlockArrayLiteral", +} + +/// Pushes a node list the way theme-check's `visitLiquid` does: last item first, so the first +/// item pops first. +fn push_all<'a>(stack: &mut Vec<(NodeRef<'a>, usize)>, nodes: &'a [Node<'a>], depth: usize) { + for n in nodes.iter().rev() { + stack.push((n.to_ref(), depth)); + } +} + +fn push_markup<'a>(stack: &mut Vec<(NodeRef<'a>, usize)>, m: &'a Markup<'a>, depth: usize) { + match m { + Markup::Node(n) => stack.push((n.to_ref(), depth)), + Markup::Nodes(ns) => push_all(stack, ns, depth), + Markup::Str(_) | Markup::Null => {} + } +} + +impl<'a> NodeRef<'a> { + /// Pushes the child nodes onto `stack` exactly as `visitLiquid` pushes them: object keys in + /// insertion order, each array's items last-to-first. + pub fn push_children(self, stack: &mut Vec<(NodeRef<'a>, usize)>, depth: usize) { + use NodeRef as R; + match self { + R::Document(n) => push_all(stack, &n.children, depth), + R::YAMLFrontmatter(_) => {} + R::LiquidRawTag(n) => stack.push((R::RawMarkup(&n.body), depth)), + R::LiquidTag(n) => { + push_markup(stack, &n.markup, depth); + if let Some(c) = &n.children { + push_all(stack, c, depth); + } + } + R::LiquidBranch(n) => { + push_markup(stack, &n.markup, depth); + push_all(stack, &n.children, depth); + } + R::LiquidVariableOutput(n) => push_markup(stack, &n.markup, depth), + R::HtmlSelfClosingElement(n) => { + push_all(stack, &n.name, depth); + push_all(stack, &n.attributes, depth); + } + R::HtmlVoidElement(n) => push_all(stack, &n.attributes, depth), + R::HtmlDoctype(_) | R::HtmlComment(_) => {} + R::HtmlElement(n) => { + push_all(stack, &n.name, depth); + push_all(stack, &n.attributes, depth); + push_all(stack, &n.children, depth); + } + R::HtmlDanglingMarkerClose(n) => push_all(stack, &n.name, depth), + R::HtmlRawNode(n) => { + push_all(stack, &n.attributes, depth); + stack.push((R::RawMarkup(&n.body), depth)); + } + R::AttrSingleQuoted(n) | R::AttrDoubleQuoted(n) | R::AttrUnquoted(n) => { + push_all(stack, &n.name, depth); + push_all(stack, &n.value, depth); + } + R::AttrEmpty(n) => push_all(stack, &n.name, depth), + R::TextNode(_) => {} + R::LiquidVariable(n) => { + stack.push((n.expression.to_ref(), depth)); + for f in n.filters.iter().rev() { + stack.push((R::LiquidFilter(f), depth)); + } + } + R::LiquidFilter(n) => push_all(stack, &n.args, depth), + R::NamedArgument(n) => stack.push((n.value.to_ref(), depth)), + R::LiquidLiteral(_) => {} + R::BooleanExpression(n) => stack.push((n.condition.to_ref(), depth)), + R::String(_) | R::Number(_) => {} + R::Range(n) => { + stack.push((n.start.to_ref(), depth)); + stack.push((n.end.to_ref(), depth)); + } + R::VariableLookup(n) => push_all(stack, &n.lookups, depth), + R::Comparison(n) => { + stack.push((n.left.to_ref(), depth)); + stack.push((n.right.to_ref(), depth)); + } + R::LogicalExpression(n) => { + stack.push((n.left.to_ref(), depth)); + stack.push((n.right.to_ref(), depth)); + } + R::AssignMarkup(n) => stack.push((R::LiquidVariable(&n.value), depth)), + R::BlockMarkup(n) | R::SectionMarkup(n) => { + stack.push((R::String(&n.name), depth)); + for a in n.args.iter().rev() { + stack.push((R::NamedArgument(a), depth)); + } + } + R::ContentForMarkup(n) => { + stack.push((R::String(&n.content_for_type), depth)); + for a in n.args.iter().rev() { + stack.push((R::NamedArgument(a), depth)); + } + } + R::CycleMarkup(n) => { + if let Some(g) = &n.group_name { + stack.push((g.to_ref(), depth)); + } + push_all(stack, &n.args, depth); + } + R::ForMarkup(n) => { + stack.push((n.collection.to_ref(), depth)); + for a in n.args.iter().rev() { + stack.push((R::NamedArgument(a), depth)); + } + } + R::PaginateMarkup(n) => { + stack.push((n.collection.to_ref(), depth)); + stack.push((n.page_size.to_ref(), depth)); + for a in n.args.iter().rev() { + stack.push((R::NamedArgument(a), depth)); + } + } + R::RawMarkup(n) => push_all(stack, &n.nodes, depth), + R::RenderMarkup(n) => { + stack.push((n.snippet.to_ref(), depth)); + if let Some(v) = &n.variable { + stack.push((R::RenderVariableExpression(v), depth)); + } + if let Some(a) = &n.alias { + stack.push((R::RenderAliasExpression(a), depth)); + } + for a in n.args.iter().rev() { + stack.push((R::NamedArgument(a), depth)); + } + } + R::RenderVariableExpression(n) => stack.push((n.name.to_ref(), depth)), + R::RenderAliasExpression(_) => {} + R::LiquidDocDescriptionNode(n) => stack.push((R::TextNode(&n.content), depth)), + R::LiquidDocParamNode(n) => { + stack.push((R::TextNode(&n.param_name), depth)); + if let Some(t) = &n.param_type { + stack.push((R::TextNode(t), depth)); + } + if let Some(d) = &n.param_description { + stack.push((R::TextNode(d), depth)); + } + } + R::LiquidDocExampleNode(n) => stack.push((R::TextNode(&n.content), depth)), + R::LiquidDocPromptNode(n) => stack.push((R::TextNode(&n.content), depth)), + R::LiquidErrorNode(_) => {} + R::BlockArrayLiteral(n) => push_all(stack, &n.elements, depth), + } + } + + /// Child nodes in the order `visit_liquid` visits them. + pub fn children_visit_order(self) -> Vec> { + let mut v = Vec::new(); + self.push_children(&mut v, 0); + v.reverse(); + v.into_iter().map(|(n, _)| n).collect() + } +} + +/// theme-check's `visitLiquid`: preorder over an explicit stack. `visit(node, ancestors, exit)` is +/// called with `exit = false` when a node pops, then with `exit = true` right after its children +/// are queued (before they are visited), exactly like the TS walker. +pub fn visit_liquid<'a>( + root: NodeRef<'a>, + mut visit: impl FnMut(NodeRef<'a>, &[NodeRef<'a>], bool), +) { + let mut stack: Vec<(NodeRef<'a>, usize)> = vec![(root, 0)]; + let mut path: Vec> = Vec::new(); + while let Some((node, depth)) = stack.pop() { + path.truncate(depth); + visit(node, &path, false); + node.push_children(&mut stack, depth + 1); + visit(node, &path, true); + path.push(node); + } +} diff --git a/crates/liquid-parser/Cargo.toml b/crates/liquid-parser/Cargo.toml new file mode 100644 index 000000000..b173ae602 --- /dev/null +++ b/crates/liquid-parser/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "liquid-parser" +description = "A parser for Liquid HTML templates, ported from @shopify/liquid-html-parser" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +liquid-ast.workspace = true + +[dev-dependencies] +serde_json = "1" +yaml-rust2 = { version = "0.11", default-features = false } + +[[test]] +name = "specs" +harness = false diff --git a/crates/liquid-parser/src/html.rs b/crates/liquid-parser/src/html.rs new file mode 100644 index 000000000..83bae8b1c --- /dev/null +++ b/crates/liquid-parser/src/html.rs @@ -0,0 +1,654 @@ +//! Port of `document/html.ts` (strict paths). + +use std::borrow::Cow; + +use liquid_ast::*; + +use crate::markup::PResult; +use crate::parser::{ + compound_names_match, error, filter_children, slice, text_node, FilterMode, Parser, +}; +use crate::tokenizer::{Token, TokenType as TT}; +use crate::{first_word, is_js_space, js_lower_eq, js_trim, starts_with_js_space, ParseError}; + +const VOID_ELEMENTS: &[&str] = &[ + "area", "base", "br", "col", "command", "embed", "hr", "img", "input", "keygen", "link", + "meta", "param", "source", "track", "wbr", +]; +const HTML_RAW_TAGS: &[&str] = &["script", "style", "svg"]; +const BLOCKS: &[&str] = &[ + "if", + "unless", + "for", + "case", + "tablerow", + "capture", + "form", + "paginate", + "block", + "ifchanged", + "partial", +]; + +/// `extractPlainName` +fn plain_name<'n, 'a>(name: &'n [Node<'a>]) -> Option<&'n str> { + match name { + [Node::TextNode(t)] => Some(&t.value), + _ => None, + } +} + +/// `plainName || 'compound'` +fn display_name(name: Option<&str>) -> &str { + match name { + Some(n) if !n.is_empty() => n, + _ => "compound", + } +} + +fn is_raw_or_void(name: &str, list: &[&'static str]) -> bool { + list.iter().any(|n| js_lower_eq(name, n)) +} + +/// `rawMarkupKindForHtmlTag` +fn raw_markup_kind_for_html_tag(lower_name: &str, attributes: &[Node], body: &str) -> &'static str { + match lower_name { + "script" => match plain_attribute_value(attributes, "type") { + None => "javascript", + Some("text/html") => "html", + Some("text/markdown") => "markdown", + Some(t) + if t.ends_with("json") || t.ends_with("importmap") || t == "speculationrules" => + { + "json" + } + Some(_) => "javascript", + }, + "style" if crate::has_liquid_open(body) => "text", + "style" => "css", + _ => "text", + } +} + +/// `extractPlainAttributeValue` +fn plain_attribute_value<'n>(attributes: &'n [Node], target: &str) -> Option<&'n str> { + for attr in attributes { + let (Node::AttrDoubleQuoted(a) | Node::AttrSingleQuoted(a) | Node::AttrUnquoted(a)) = attr + else { + continue; + }; + match plain_name(&a.name) { + Some(n) if js_lower_eq(n, target) => {} + _ => continue, + } + return match a.value.as_slice() { + [Node::TextNode(t)] => Some(&t.value), + _ => None, + }; + } + None +} + +impl<'a> Parser<'a> { + pub(crate) fn parse_html_element(&mut self) -> PResult> { + let source = self.source; + let open_start = self.peek().start; + self.consume(TT::HtmlTagOpen)?; + let name = self.parse_compound_name()?; + let attributes = self.parse_attributes()?; + if self.accept(TT::HtmlSelfClose).is_some() { + let bsp = Position::new(open_start, self.tokens[self.p - 1].end); + return Ok(Node::HtmlSelfClosingElement(HtmlSelfClosingElement { + name, + attributes, + block_start_position: bsp, + position: bsp, + })); + } + self.consume(TT::HtmlTagClose)?; + let open_end = self.tokens[self.p - 1].end; + let bsp = Position::new(open_start, open_end); + let plain = plain_name(&name); + if let Some(plain) = plain { + if is_raw_or_void(plain, VOID_ELEMENTS) { + let name = match name.into_iter().next() { + Some(Node::TextNode(t)) => t.value, + _ => unreachable!(), + }; + return Ok(Node::HtmlVoidElement(HtmlVoidElement { + name, + attributes, + block_start_position: bsp, + position: bsp, + })); + } + if is_raw_or_void(plain, HTML_RAW_TAGS) { + let name = match name.into_iter().next() { + Some(Node::TextNode(t)) => t.value, + _ => unreachable!(), + }; + return Ok(Node::HtmlRawNode( + self.parse_html_raw_body(name, attributes, bsp)?, + )); + } + } + + let mut children = Vec::new(); + while !self.is_at_end() { + if self.check(TT::HtmlCloseTagOpen, 0) { + break; + } + if self.is_block_terminator() { + if !self.allow_unclosed_html { + let tag_name = self.peek_tag_name().unwrap(); + return Err(error( + format!( + "Attempting to close LiquidTag '{tag_name}' before HtmlElement '{}' was closed", + display_name(plain_name(&name)) + ), + open_start, + open_end, + )); + } + break; + } + children.push(self.nested(Self::parse_node)?); + } + + if self.is_at_end() || (self.allow_unclosed_html && self.is_block_terminator()) { + if !self.allow_unclosed_html { + return Err(error( + format!( + "Attempting to end parsing before HtmlElement '{}' was closed", + display_name(plain_name(&name)) + ), + open_start, + open_end, + )); + } + let end = children.last().map_or(open_end, |c| c.position().end); + return Ok(Node::HtmlElement(HtmlElement { + name, + attributes, + children: filter_children(source, FilterMode::Syntactic, children), + block_start_position: bsp, + block_end_position: Position::new(end, end), + position: Position::new(open_start, end), + })); + } + + let close_start = self.peek().start; + self.consume(TT::HtmlCloseTagOpen)?; + let close_name = self.parse_compound_name()?; + self.skip_blank_text(); + self.consume(TT::HtmlTagClose)?; + let close_end = self.tokens[self.p - 1].end; + if !compound_names_match(source, &name, &close_name) { + return Err(error( + format!( + "Attempting to close HtmlElement '{}' before HtmlElement '{}' was closed", + display_name(plain_name(&close_name)), + display_name(plain_name(&name)) + ), + close_start, + close_end, + )); + } + Ok(Node::HtmlElement(HtmlElement { + name, + attributes, + children: filter_children(source, FilterMode::Syntactic, children), + block_start_position: bsp, + block_end_position: Position::new(close_start, close_end), + position: Position::new(open_start, close_end), + })) + } + + /// Skips Text tokens matching `/^\s*$/`. + fn skip_blank_text(&mut self) { + while self.check(TT::Text, 0) { + let t = self.peek(); + if !self.source[t.start..t.end].chars().all(is_js_space) { + break; + } + self.p += 1; + } + } + + fn parse_html_raw_body( + &mut self, + name: Str<'a>, + attributes: Vec>, + bsp: Position, + ) -> PResult> { + let source = self.source; + let lower = name.to_lowercase(); + let Some(close_idx) = self.scan_for_html_close_tag(&lower) else { + return Err(error( + format!("Attempting to end parsing before HtmlRawNode '{name}' was closed"), + bsp.start, + bsp.end, + )); + }; + let (body_start, body_end) = (bsp.end, self.tokens[close_idx].start); + let body = slice(source, body_start, body_end); + let nodes = self.parse_liquid_in_range(body_end)?; + let kind = raw_markup_kind_for_html_tag(&lower, &attributes, body); + let raw = RawMarkup { + kind, + value: Cow::Borrowed(body), + nodes, + position: Position::new(body_start, body_end), + }; + self.seek_to_source_offset(body_end); + let close_start = self.peek().start; + self.consume(TT::HtmlCloseTagOpen)?; + while self.check(TT::Text, 0) { + self.p += 1; + } + let close_end = self.consume(TT::HtmlTagClose)?.end; + Ok(HtmlRawNode { + name, + attributes, + body: raw, + block_start_position: bsp, + block_end_position: Position::new(close_start, close_end), + position: Position::new(bsp.start, close_end), + }) + } + + /// `scanForHtmlCloseTag`: index of the matching close tag's ` Option { + let (source, tokens) = (self.source, &self.tokens); + let mut depth = 0usize; + for i in self.p..tokens.len() { + let t = tokens[i]; + if t.kind == TT::HtmlTagOpen { + if let Some(text) = tokens.get(i + 1).filter(|n| n.kind == TT::Text) { + if js_lower_eq(first_word(&source[text.start..text.end]), lower) { + depth += 1; + } + } + continue; + } + if t.kind != TT::HtmlCloseTagOpen { + continue; + } + let texts = tokens[i + 1..] + .iter() + .take_while(|n| n.kind == TT::Text) + .count(); + if texts == 0 { + continue; + } + let text = &source[t.end..tokens[i + texts].end]; + if js_lower_eq(js_trim(text), lower) { + if depth == 0 { + return Some(i); + } + depth -= 1; + } + } + None + } + + pub(crate) fn parse_html_comment(&mut self) -> PResult> { + let open = self.consume(TT::HtmlCommentOpen)?; + let Some(close) = crate::find_ascii(&self.source[open.end..], "-->").map(|i| i + open.end) + else { + return Err(error( + "Attempting to end parsing before HtmlComment '") { + emit!(TokenType::HtmlCommentClose, 3); + continue; + } + if at(pos, b" { + scan_liquid_open!(); + if at(pos, b"/>") { + emit!(TokenType::HtmlSelfClose, 2); + pop_mode!(); + continue; + } + if b[pos] == b'>' { + emit!(TokenType::HtmlTagClose, 1); + pop_mode!(); + continue; + } + if b[pos] == b'=' { + emit!(TokenType::HtmlEquals, 1); + continue; + } + if let Some(q) = quote_at(b, pos) { + quote_char = q; + emit!(TokenType::HtmlQuoteOpen, q.len_utf8()); + push_mode!(Mode::QuotedValue); + continue; + } + let c = source[pos..].chars().next().unwrap(); + let is_space = is_js_space(c); + if text_start.is_some() && is_space != text_is_space { + flush_text!(); + } + if text_start.is_none() { + text_is_space = is_space; + } + start_text!(); + pos += c.len_utf8(); + } + Mode::QuotedValue => { + scan_liquid_open!(); + if let Some(q) = quote_at(b, pos) { + if q == closing_quote_for(quote_char) || q == quote_char { + emit!(TokenType::HtmlQuoteClose, q.len_utf8()); + pop_mode!(); + continue; + } + } + text_until!(b'{' | b'"' | b'\'' | 0xE2); + } + } + } + + flush_text!(); + tokens.push(Token { + kind: TokenType::EndOfInput, + start: len, + end: len, + }); + tokens +} + +#[cfg(test)] +mod tests { + use super::*; + + fn kinds(s: &str) -> Vec<(TokenType, &str)> { + tokenize(s) + .into_iter() + .map(|t| (t.kind, &s[t.start..t.end])) + .collect() + } + + #[test] + fn tokenizes_tags_and_curly_quotes() { + use TokenType::*; + assert_eq!( + kinds("{{- z }}"), + vec![ + (HtmlTagOpen, "<"), + (Text, "a"), + (Text, " "), + (Text, "b"), + (HtmlEquals, "="), + (HtmlQuoteOpen, "“"), + (Text, "x y"), + (HtmlQuoteClose, "”"), + (HtmlTagClose, ">"), + (LiquidVariableOutputOpen, "{{-"), + (Text, " z "), + (LiquidVariableOutputClose, "}}"), + (EndOfInput, ""), + ] + ); + } +} diff --git a/crates/liquid-parser/tests/specs.rs b/crates/liquid-parser/tests/specs.rs new file mode 100644 index 000000000..015745edb --- /dev/null +++ b/crates/liquid-parser/tests/specs.rs @@ -0,0 +1,393 @@ +//! Runs the language-neutral parser specs in specs/parser at the repo root, as +//! packages/liquid-html-parser does in TypeScript. Prints each failure, and fails when there's one. + +use liquid_parser::ast::*; +use liquid_parser::{to_liquid_ast, to_liquid_html_ast, LineColumn, ParseError}; +use serde_json::{json, Value}; +use std::path::Path; +use yaml_rust2::{Yaml, YamlLoader}; + +/// A tree as the specs give it: TypeScript's properties, without the source on every node, and +/// with positions as text. +struct Tree<'s> { + source: &'s str, +} + +fn ws(b: bool) -> Value { + json!(if b { "-" } else { "" }) +} + +impl Tree<'_> { + /// `{ start: [line, character], text }`, the character in UTF-16 code units. + fn pos(&self, p: Position) -> Value { + let Some(text) = self.source.get(p.start..p.end) else { + return json!(format!("{}..{} outside the source", p.start, p.end)); + }; + let before = &self.source[..p.start]; + let line_start = before.rfind('\n').map_or(0, |i| i + 1); + json!({ + "start": [before.matches('\n').count(), before[line_start..].encode_utf16().count()], + "text": text, + }) + } + + fn nodes(&self, nodes: &[Node]) -> Value { + nodes.iter().map(|n| self.node(n.to_ref())).collect() + } + + fn args(&self, args: &[NamedArgument]) -> Value { + args.iter() + .map(|a| self.node(NodeRef::NamedArgument(a))) + .collect() + } + + fn markup(&self, m: &Markup) -> Value { + match m { + Markup::Str(s) => json!(s), + Markup::Node(n) => self.node(n.to_ref()), + Markup::Nodes(ns) => self.nodes(ns), + Markup::Null => Value::Null, + } + } + + fn opt_node(&self, n: Option) -> Value { + n.map_or(Value::Null, |n| self.node(n)) + } + + fn node(&self, n: NodeRef) -> Value { + use NodeRef as R; + let mut v = match n { + R::Document(n) => json!({ "name": "#document", "children": self.nodes(&n.children) }), + R::YAMLFrontmatter(n) => json!({ "body": n.body }), + R::LiquidRawTag(n) => json!({ + "name": n.name, + "markup": n.markup, + "markupPosition": self.pos(n.markup_position), + "body": self.node(R::RawMarkup(&n.body)), + "whitespaceStart": ws(n.whitespace_start), + "whitespaceEnd": ws(n.whitespace_end), + "delimiterWhitespaceStart": ws(n.delimiter_whitespace_start), + "delimiterWhitespaceEnd": ws(n.delimiter_whitespace_end), + "blockStartPosition": self.pos(n.block_start_position), + "blockEndPosition": self.pos(n.block_end_position), + }), + R::LiquidTag(n) => { + let mut v = json!({ + "name": n.name, + "markup": self.markup(&n.markup), + "whitespaceStart": ws(n.whitespace_start), + "whitespaceEnd": ws(n.whitespace_end), + "blockStartPosition": self.pos(n.block_start_position), + "markupPosition": self.pos(n.markup_position), + }); + if let Some(c) = &n.children { + v["children"] = self.nodes(c); + } + if let Some(b) = n.delimiter_whitespace_start { + v["delimiterWhitespaceStart"] = ws(b); + } + if let Some(b) = n.delimiter_whitespace_end { + v["delimiterWhitespaceEnd"] = ws(b); + } + if let Some(p) = n.block_end_position { + v["blockEndPosition"] = self.pos(p); + } + if let Some(r) = &n.reason { + v["reason"] = json!(r); + } + v + } + R::LiquidBranch(n) => json!({ + "name": n.name, + "markup": self.markup(&n.markup), + "children": self.nodes(&n.children), + "whitespaceStart": ws(n.whitespace_start), + "whitespaceEnd": ws(n.whitespace_end), + "markupPosition": self.pos(n.markup_position), + "blockStartPosition": self.pos(n.block_start_position), + "blockEndPosition": self.pos(n.block_end_position), + }), + R::LiquidVariableOutput(n) => json!({ + "markup": self.markup(&n.markup), + "whitespaceStart": ws(n.whitespace_start), + "whitespaceEnd": ws(n.whitespace_end), + "markupPosition": self.pos(n.markup_position), + }), + R::HtmlSelfClosingElement(n) => json!({ + "name": self.nodes(&n.name), + "attributes": self.nodes(&n.attributes), + "blockStartPosition": self.pos(n.block_start_position), + }), + R::HtmlVoidElement(n) => json!({ + "name": n.name, + "attributes": self.nodes(&n.attributes), + "blockStartPosition": self.pos(n.block_start_position), + }), + R::HtmlDoctype(n) => json!({ "legacyDoctypeString": n.legacy_doctype_string }), + R::HtmlComment(n) => json!({ "body": n.body }), + R::HtmlElement(n) => json!({ + "name": self.nodes(&n.name), + "attributes": self.nodes(&n.attributes), + "children": self.nodes(&n.children), + "blockStartPosition": self.pos(n.block_start_position), + "blockEndPosition": self.pos(n.block_end_position), + }), + R::HtmlDanglingMarkerClose(n) => json!({ + "name": self.nodes(&n.name), + "blockStartPosition": self.pos(n.block_start_position), + }), + R::HtmlRawNode(n) => json!({ + "name": n.name, + "attributes": self.nodes(&n.attributes), + "body": self.node(R::RawMarkup(&n.body)), + "blockStartPosition": self.pos(n.block_start_position), + "blockEndPosition": self.pos(n.block_end_position), + }), + R::AttrSingleQuoted(n) | R::AttrDoubleQuoted(n) | R::AttrUnquoted(n) => json!({ + "name": self.nodes(&n.name), + "value": self.nodes(&n.value), + "attributePosition": self.pos(n.attribute_position), + }), + R::AttrEmpty(n) => json!({ "name": self.nodes(&n.name) }), + R::TextNode(n) => json!({ "value": n.value }), + R::LiquidVariable(n) => json!({ + "expression": self.node(n.expression.to_ref()), + "filters": n.filters.iter().map(|f| self.node(R::LiquidFilter(f))).collect::(), + "rawSource": n.raw_source, + }), + R::LiquidFilter(n) => json!({ "name": n.name, "args": self.nodes(&n.args) }), + R::NamedArgument(n) => json!({ "name": n.name, "value": self.node(n.value.to_ref()) }), + R::LiquidLiteral(n) => json!({ + "keyword": n.keyword, + "value": match n.keyword { + "true" => json!(true), + "false" => json!(false), + "blank" | "empty" => json!(""), + _ => Value::Null, + }, + }), + R::BooleanExpression(n) => json!({ "condition": self.node(n.condition.to_ref()) }), + R::String(n) => { + let mut v = json!({ "value": n.value }); + if let Some(single) = n.single { + v["single"] = json!(single); + } + v + } + R::Number(n) => json!({ "value": n.value }), + R::Range(n) => json!({ + "start": self.node(n.start.to_ref()), + "end": self.node(n.end.to_ref()), + }), + R::VariableLookup(n) => { + let mut v = json!({ "name": n.name, "lookups": self.nodes(&n.lookups) }); + if let Some(modes) = &n.lookup_modes { + v["lookupModes"] = modes + .iter() + .map(|m| match m { + LookupMode::Bareword => json!("bareword"), + LookupMode::Subscript => json!("subscript"), + }) + .collect(); + } + v + } + R::Comparison(n) => json!({ + "comparator": n.comparator, + "left": self.node(n.left.to_ref()), + "right": self.node(n.right.to_ref()), + }), + R::LogicalExpression(n) => json!({ + "relation": n.relation, + "left": self.node(n.left.to_ref()), + "right": self.node(n.right.to_ref()), + }), + R::AssignMarkup(n) => json!({ + "name": n.name, + "value": self.node(R::LiquidVariable(&n.value)), + }), + R::BlockMarkup(n) | R::SectionMarkup(n) => json!({ + "name": self.node(R::String(&n.name)), + "args": self.args(&n.args), + }), + R::ContentForMarkup(n) => json!({ + "contentForType": self.node(R::String(&n.content_for_type)), + "args": self.args(&n.args), + }), + R::CycleMarkup(n) => json!({ + "groupName": self.opt_node(n.group_name.as_deref().map(Node::to_ref)), + "args": self.nodes(&n.args), + }), + R::ForMarkup(n) => json!({ + "variableName": n.variable_name, + "collection": self.node(n.collection.to_ref()), + "reversed": n.reversed, + "args": self.args(&n.args), + }), + R::PaginateMarkup(n) => json!({ + "collection": self.node(n.collection.to_ref()), + "pageSize": self.node(n.page_size.to_ref()), + "args": self.args(&n.args), + }), + R::RawMarkup(n) => json!({ + "kind": n.kind, + "value": n.value, + "nodes": self.nodes(&n.nodes), + }), + R::RenderMarkup(n) => json!({ + "snippet": self.node(n.snippet.to_ref()), + "variable": self.opt_node(n.variable.as_ref().map(R::RenderVariableExpression)), + "alias": self.opt_node(n.alias.as_ref().map(R::RenderAliasExpression)), + "args": self.args(&n.args), + }), + R::RenderVariableExpression(n) => json!({ + "kind": n.kind, + "name": self.node(n.name.to_ref()), + }), + R::RenderAliasExpression(n) => json!({ "value": n.value }), + R::LiquidDocDescriptionNode(n) => json!({ + "name": "description", + "content": self.node(R::TextNode(&n.content)), + "isImplicit": n.is_implicit, + "isInline": n.is_inline, + }), + R::LiquidDocParamNode(n) => json!({ + "name": "param", + "paramName": self.node(R::TextNode(&n.param_name)), + "paramType": self.opt_node(n.param_type.as_ref().map(R::TextNode)), + "paramDescription": self.opt_node(n.param_description.as_ref().map(R::TextNode)), + "required": n.required, + }), + R::LiquidDocExampleNode(n) => json!({ + "name": "example", + "content": self.node(R::TextNode(&n.content)), + "isInline": n.is_inline, + }), + R::LiquidDocPromptNode(n) => json!({ + "name": "prompt", + "content": self.node(R::TextNode(&n.content)), + }), + R::LiquidErrorNode(_) => unreachable!("strict parsing never builds LiquidErrorNode"), + R::BlockArrayLiteral(n) => json!({ "elements": self.nodes(&n.elements) }), + }; + v["type"] = json!(n.type_name()); + v["position"] = self.pos(n.position()); + v + } +} + +/// `{ message, start, text }` at the error's `loc`, which TypeScript clamps to the template's +/// last character. +fn spec_error(source: &str, e: &ParseError) -> Value { + let Some(loc) = e.loc else { + // TypeScript throws a plain Error here, which no spec has. + return json!(e.to_string()); + }; + let units: Vec = source.encode_utf16().collect(); + let line_starts: Vec = std::iter::once(0) + .chain( + (0..units.len()) + .filter(|&i| units[i] == b'\n' as u16) + .map(|i| i + 1), + ) + .collect(); + let offset = |lc: LineColumn| Some(line_starts.get(lc.line - 1)? + lc.column - 1); + let text = offset(loc.start) + .zip(offset(loc.end)) + .and_then(|(a, b)| units.get(a..b)) + .map_or_else( + || format!("{loc:?} outside the source"), + String::from_utf16_lossy, + ); + json!({ + "message": e.message, + "start": [loc.start.line - 1, loc.start.column - 1], + "text": text, + }) +} + +fn to_json(yaml: &Yaml) -> Value { + match yaml { + Yaml::String(s) => json!(s), + Yaml::Integer(i) => json!(i), + Yaml::Boolean(b) => json!(b), + Yaml::Null => Value::Null, + Yaml::Array(items) => items.iter().map(to_json).collect(), + Yaml::Hash(map) => map + .iter() + .map(|(k, v)| (k.as_str().expect("string keys").to_string(), to_json(v))) + .collect(), + other => panic!("unexpected YAML {other:?}"), + } +} + +/// Where `actual` differs from `expected`, one line per property. Object keys are unordered. +fn diff(path: &str, expected: &Value, actual: &Value, out: &mut Vec) { + match (expected, actual) { + (Value::Object(e), Value::Object(a)) => { + for (k, v) in e { + match a.get(k) { + Some(av) => diff(&format!("{path}.{k}"), v, av, out), + None => out.push(format!("{path}.{k}: missing")), + } + } + for (k, v) in a.iter().filter(|(k, _)| !e.contains_key(*k)) { + out.push(format!("{path}.{k}: unexpected {v}")); + } + } + (Value::Array(e), Value::Array(a)) if e.len() == a.len() => { + for (i, (e, a)) in e.iter().zip(a).enumerate() { + diff(&format!("{path}.{i}"), e, a, out); + } + } + _ if expected != actual => out.push(format!("{path}: expected {expected}, got {actual}")), + _ => {} + } +} + +/// specs/parser/*.yml, format in its README.md. +fn main() { + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../specs/parser"); + let mut paths: Vec<_> = std::fs::read_dir(&dir) + .unwrap_or_else(|e| panic!("{}: {e}", dir.display())) + .map(|e| e.unwrap().path()) + .filter(|p| p.extension().is_some_and(|x| x == "yml")) + .collect(); + paths.sort(); + let (mut total, mut failed) = (0, 0); + for path in paths { + let file = path.file_name().unwrap().to_string_lossy().into_owned(); + let docs = YamlLoader::load_from_str(&std::fs::read_to_string(&path).unwrap()) + .unwrap_or_else(|e| panic!("{file}: {e}")); + let spec = to_json(&docs[0]); + for case in spec["cases"].as_array().expect("a spec has cases") { + total += 1; + let source = case["source"].as_str().expect("a case has a source"); + let expected = match (case.get("ast"), case.get("error")) { + (Some(ast), None) => json!({ "ast": ast }), + (None, Some(error)) => json!({ "error": error }), + _ => panic!("{file}: {} needs one of ast and error", case["name"]), + }; + let mut problems = Vec::new(); + for parse in case["parse"].as_array().expect("a case has parses") { + let result = match parse.as_str() { + Some("html") => to_liquid_html_ast(source), + Some("liquid") => to_liquid_ast(source), + _ => panic!("{file}: unknown parse {parse}"), + }; + let actual = match result { + Ok(doc) => json!({ "ast": Tree { source }.node(NodeRef::Document(&doc)) }), + Err(e) => json!({ "error": spec_error(source, &e) }), + }; + diff(parse.as_str().unwrap(), &expected, &actual, &mut problems); + } + if !problems.is_empty() { + failed += 1; + println!("FAIL parser/{file}: {}", case["name"].as_str().unwrap()); + problems.iter().for_each(|p| println!(" {p}")); + } + } + } + println!("specs/parser: {} of {total} passing", total - failed); + std::process::exit(i32::from(failed > 0)); +} diff --git a/crates/theme-check/Cargo.toml b/crates/theme-check/Cargo.toml new file mode 100644 index 000000000..a12176ff3 --- /dev/null +++ b/crates/theme-check/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "theme-check" +description = "The linter for Shopify themes, ported from @shopify/theme-check-node" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +# The specs it runs are at the repo root, outside the crate. +exclude = ["tests"] + +[dependencies] +liquid-ast.workspace = true +liquid-parser.workspace = true +serde_json = { version = "1", features = ["preserve_order"] } +regex = "1" +yaml-rust2 = { version = "0.11", default-features = false } + +[[test]] +name = "specs" +harness = false diff --git a/crates/theme-check/README.md b/crates/theme-check/README.md new file mode 100644 index 000000000..1740fa226 --- /dev/null +++ b/crates/theme-check/README.md @@ -0,0 +1,50 @@ +# theme-check + +Theme Check, the linter for Shopify themes, in Rust. It's a port of +[`@shopify/theme-check-node`](https://github.com/Shopify/theme-tools/tree/main/packages/theme-check-node) +and the packages it uses: the checks, their config, the theme graph and the Liquid HTML parser. +It reports the same offenses, with the same messages, positions and fixes. + +Both implementations run the same [executable specs](https://github.com/Shopify/theme-tools/tree/main/specs): +every check spec in `specs/checks`, every graph spec in `specs/graph` and every parser spec in +`specs/parser`. + +## Crates + +- `liquid-ast`: the syntax tree, as `@shopify/liquid-html-parser` builds it. +- `liquid-parser`: the parser. +- `theme-check`: the checks, the runner, config loading, the theme graph and the CLI. + +They're released together, with the same version. + +## The CLI + +```sh +cargo install theme-check +theme-check [configPath] +theme-check [configPath] --autofix +``` + +Like theme-check-node's `cli.ts`, it prints the offenses, the config and the theme's files as +JSON on stdout. `--autofix` fixes the files instead, and prints nothing. + +## Development + +```sh +cargo test +``` + +It runs the unit tests and the specs. To change what a check or the parser does, change its spec +first, then the TypeScript and the Rust. + +Ported code keeps the TypeScript's behavior, quirks included, with a few rules: + +- AST positions are UTF-8 byte offsets. Offenses and the theme graph report UTF-16 positions, as + TypeScript does. +- JS semantics that offenses depend on are in `js.rs`: truthiness, `key in v`, `Object.entries` + and string conversion. JSON objects keep their key order (`preserve_order`), and + `json::js_insert` puts integer-like keys first, as JS does. +- A `Map` or `Set` whose iteration order matters is a `Vec` with a `HashMap` or `HashSet`. +- Where TypeScript throws, a check returns `Err(CheckError)`. The runner stops that check on that + file, as TypeScript's `onError` does. +- A simplification with a known limit is marked with a `ponytail:` comment. diff --git a/crates/theme-check/src/bin/theme-check.rs b/crates/theme-check/src/bin/theme-check.rs new file mode 100644 index 000000000..dac92bf27 --- /dev/null +++ b/crates/theme-check/src/bin/theme-check.rs @@ -0,0 +1,59 @@ +//! `theme-check [configPath]`: theme-check-node's `cli.ts` (offenses, config and theme +//! uris as JSON on stdout, logs on stderr). `--autofix` runs `checkAndAutofix` instead and prints +//! nothing. A thrown error prints as `Name: message` on stderr and exits 1, like Node's +//! unhandled rejection (minus the stack). + +use std::io::Write; +use theme_check::node::{ + check_and_autofix, config_json, offense_json, posix_resolve, stringify, theme_check_run, + JsError, +}; + +fn main() { + let mut autofix = false; + let mut args = Vec::new(); + for a in std::env::args().skip(1) { + if a == "--autofix" { + autofix = true; + } else { + args.push(a); + } + } + let Some(root) = args.first() else { + eprintln!("usage: theme-check [configPath] [--autofix]"); + std::process::exit(2); + }; + // `path.resolve(arg)` + let cwd = std::env::current_dir() + .map(|d| d.to_string_lossy().into_owned()) + .unwrap_or_else(|_| "/".into()); + let resolve = |p: &str| posix_resolve(&cwd, p); + let root = resolve(root); + let config_path = args.get(1).filter(|p| !p.is_empty()).map(|p| resolve(p)); + let result: Result<(), JsError> = if autofix { + check_and_autofix(&root, config_path.as_deref()) + } else { + theme_check_run( + &root, + config_path.as_deref(), + Box::new(|m| eprintln!("{m}")), + ) + .map(|run| { + let offenses: Vec<_> = run.offenses.iter().map(offense_json).collect(); + let out = format!( + "{}\n{}\n{}\n", + stringify(&offenses.into()), + stringify(&config_json(&run.config)), + stringify(&run.theme.into()) + ); + std::io::stdout() + .lock() + .write_all(out.as_bytes()) + .expect("stdout"); + }) + }; + if let Err(e) = result { + eprintln!("{e}"); + std::process::exit(1); + } +} diff --git a/crates/theme-check/src/block_parameters.rs b/crates/theme-check/src/block_parameters.rs new file mode 100644 index 000000000..abe4c951c --- /dev/null +++ b/crates/theme-check/src/block_parameters.rs @@ -0,0 +1,136 @@ +//! `block-parameters.ts` and `schema-settings.ts`. + +use crate::js; +use crate::liquid_doc::{ + normalize_named_param_type, parse_string_literals, DocDefinition, LiquidDocParameter, +}; +use serde_json::Value; + +pub const BLOCK_CONTENT_PARAMETER: &str = "content"; + +#[derive(Clone, Debug)] +pub struct BlockParameter { + pub name: String, + pub type_: Option, + pub required: bool, + /// The schema setting object. + pub schema_setting: Option, + pub liquid_doc: Option, +} + +/// TS `Map`, in insertion order. ponytail: linear lookups, blocks have a +/// handful of parameters. +#[derive(Clone, Debug, Default)] +pub struct BlockParameters(pub Vec); + +impl BlockParameters { + pub fn get(&self, name: &str) -> Option<&BlockParameter> { + self.0.iter().find(|p| p.name == name) + } + pub fn iter(&self) -> impl Iterator { + self.0.iter() + } + /// `map.set(name, p)`: replaces in place or appends. + fn set(&mut self, p: BlockParameter) { + match self.0.iter_mut().find(|q| q.name == p.name) { + Some(q) => *q = p, + None => self.0.push(p), + } + } +} + +/// `resolveBlockParameters(schema, docDefinition)`. ponytail: settings that aren't objects are +/// skipped where TS would throw. +pub fn resolve_block_parameters( + schema: Option<&Value>, + doc: Option<&DocDefinition>, +) -> BlockParameters { + let mut params = BlockParameters(vec![BlockParameter { + name: BLOCK_CONTENT_PARAMETER.into(), + type_: Some("string".into()), + required: false, + schema_setting: None, + liquid_doc: None, + }]); + let settings = schema + .and_then(|s| s.get("settings")) + .and_then(Value::as_array); + for setting in settings.into_iter().flatten() { + // isInputSetting + let Some(id) = setting.get("id").and_then(Value::as_str) else { + continue; + }; + let ty = setting.get("type"); + if ty + .and_then(Value::as_str) + .is_some_and(|t| t == "header" || t == "paragraph") + { + continue; + } + params.set(BlockParameter { + name: id.into(), + type_: if id == BLOCK_CONTENT_PARAMETER { + Some("string".into()) + } else { + ty.and_then(Value::as_str) + .and_then(schema_setting_liquid_type) + .map(str::to_string) + }, + required: false, + schema_setting: Some(setting.clone()), + liquid_doc: None, + }); + } + for doc_param in doc + .and_then(|d| d.liquid_doc.as_ref()) + .map_or(&[][..], |d| &d.parameters) + { + let p = match params.get(&doc_param.name) { + Some(existing) => BlockParameter { + required: doc_param.required, + liquid_doc: Some(doc_param.clone()), + ..existing.clone() + }, + None => BlockParameter { + name: doc_param.name.clone(), + type_: match doc_param.type_.as_deref() { + Some(t) if !t.is_empty() && parse_string_literals(t).is_some() => { + Some(js::trim(t).to_string()) + } + t => liquid_doc_type(t), + }, + required: doc_param.required, + schema_setting: None, + liquid_doc: Some(doc_param.clone()), + }, + }; + params.set(p); + } + params +} + +/// `liquidDocType(type)` +pub fn liquid_doc_type(ty: Option<&str>) -> Option { + ty.filter(|t| !t.is_empty()) + .and_then(normalize_named_param_type) +} + +/// `schemaSettingLiquidType(settingType)` +pub fn schema_setting_liquid_type(setting_type: &str) -> Option<&str> { + Some(match setting_type { + "checkbox" => "boolean", + "number" | "range" => "number", + "color_background" | "color_palette" | "html" | "inline_richtext" | "liquid" | "radio" + | "richtext" | "select" | "text" | "text_alignment" | "textarea" | "url" | "video_url" => { + "string" + } + "article" | "blog" | "collection" | "color" | "color_scheme" | "page" | "product" + | "video" => setting_type, + "font_picker" => "font", + "image_picker" => "image", + "link_list" => "linklist", + "collection_list" => "collection[]", + "product_list" => "product[]", + _ => return None, + }) +} diff --git a/crates/theme-check/src/checks/app_block_missing_schema.rs b/crates/theme-check/src/checks/app_block_missing_schema.rs new file mode 100644 index 000000000..334cea9f9 --- /dev/null +++ b/crates/theme-check/src/checks/app_block_missing_schema.rs @@ -0,0 +1,47 @@ +use crate::context::{CheckDef, CheckResult, Context, Create, LiquidCheck, Severity}; +use liquid_ast::{NodeKind, NodeRef}; + +pub static APP_BLOCK_MISSING_SCHEMA: CheckDef = CheckDef { + code: "AppBlockMissingSchema", + name: "Missing schema definitions in theme app extensions app blocks should be avoided", + aliases: &[], + severity: Severity::Error, + // docs.recommended is true, but it only targets ThemeAppExtension, so `recommended` skips it. + recommended: false, + deprecated: false, + schema: &[], + create: Create::Liquid(create), +}; + +#[derive(Default)] +struct Check { + found_schema: bool, +} + +fn create<'a>(cx: &mut Context<'a>) -> Option + 'a>> { + // Theme app extension blocks are the only files that can define a schema. + if !cx.to_relative_path(cx.uri()).starts_with("blocks/") { + return None; + } + Some(Box::::default()) +} + +impl<'a> LiquidCheck<'a> for Check { + fn node_mask(&self) -> u64 { + NodeKind::LiquidRawTag.bit() + } + + fn node(&mut self, node: NodeRef<'a>, _: &[NodeRef<'a>], _: &mut Context<'a>) -> CheckResult { + if matches!(node, NodeRef::LiquidRawTag(t) if t.name == "schema") { + self.found_schema = true; + } + Ok(()) + } + + fn on_code_path_end(&mut self, cx: &mut Context<'a>) -> CheckResult { + if !self.found_schema { + cx.report("The schema does not exist", 0, 0); + } + Ok(()) + } +} diff --git a/crates/theme-check/src/checks/app_block_valid_tags.rs b/crates/theme-check/src/checks/app_block_valid_tags.rs new file mode 100644 index 000000000..2c2e10a65 --- /dev/null +++ b/crates/theme-check/src/checks/app_block_valid_tags.rs @@ -0,0 +1,60 @@ +use crate::context::{CheckDef, CheckResult, Context, Create, LiquidCheck, Severity}; +use liquid_ast::{NodeKind, NodeRef}; + +pub static APP_BLOCK_VALID_TAGS: CheckDef = CheckDef { + code: "AppBlockValidTags", + name: "App Block Valid Tags", + aliases: &[], + severity: Severity::Error, + recommended: false, + deprecated: false, + schema: &[], + create: Create::Liquid(create), +}; + +/// `ForbiddenTag` +const FORBIDDEN_TAGS: &[&str] = &[ + "javascript", + "stylesheet", + "include", + "layout", + "section", + "sections", +]; + +struct Check; + +fn create<'a>(_cx: &mut Context<'a>) -> Option + 'a>> { + Some(Box::new(Check)) +} + +impl<'a> LiquidCheck<'a> for Check { + fn node_mask(&self) -> u64 { + NodeKind::LiquidRawTag.bit() | NodeKind::LiquidTag.bit() + } + + fn node(&mut self, node: NodeRef<'a>, _: &[NodeRef<'a>], cx: &mut Context<'a>) -> CheckResult { + // A forbidden tag with an end tag highlights the whole block. + let (name, start, end) = match node { + NodeRef::LiquidRawTag(t) => ( + &t.name, + t.block_start_position.start, + t.block_end_position.end, + ), + NodeRef::LiquidTag(t) => ( + &t.name, + t.block_start_position.start, + t.block_end_position.unwrap_or(t.position).end, + ), + _ => return Ok(()), + }; + if FORBIDDEN_TAGS.contains(&name.as_ref()) { + cx.report( + format!("Theme app extension blocks cannot contain '{name}' tags"), + start, + end, + ); + } + Ok(()) + } +} diff --git a/crates/theme-check/src/checks/args_utils.rs b/crates/theme-check/src/checks/args_utils.rs new file mode 100644 index 000000000..f3f54e47f --- /dev/null +++ b/crates/theme-check/src/checks/args_utils.rs @@ -0,0 +1,415 @@ +//! `liquid-doc/arguments.ts`, the argument parts of `liquid-doc/utils.ts` and `tags/content-for.ts`, +//! shared by the render, content_for and block argument checks. + +use crate::context::Context; +use crate::docset::ThemeDocset; +use crate::fixes::{Fix, Suggestion}; +use crate::js; +use crate::liquid_doc::{ + normalize_named_param_type, parse_param_type_syntax, parse_string_literals, LiquidDocParameter, +}; +use liquid_ast::{ContentForMarkup, NamedArgument, Node, Position, RenderMarkup}; +use serde_json::Value; + +pub const RESERVED_CONTENT_FOR_ARGUMENTS: &[&str] = &[ + "attributes", + "block", + "blocks", + "class", + "context", + "inherit", + "resource", + "resources", + "schema", + "section", + "sections", + "settings", + "snippet", + "snippets", + "src", + "style", + "styles", + "template", + "templates", +]; +pub const REQUIRED_CONTENT_FOR_ARGUMENTS: &[&str] = &["id", "type"]; +pub const CLOSEST_ARGUMENT: &str = "closest."; + +/// `BasicParamTypes` +const BASIC_PARAM_TYPES: &[&str] = &["string", "number", "boolean", "object"]; + +/// The tag whose arguments are reported (TS checks `node.type`). +#[derive(Clone, Copy)] +pub enum ArgsTag { + Render, + ContentFor, +} + +impl ArgsTag { + /// `errorOwnerMessage` + fn owner(self, name: &str) -> String { + match self { + ArgsTag::Render => format!(" in render tag for snippet '{name}'"), + ArgsTag::ContentFor => format!(" in content_for tag for static block '{name}'"), + } + } +} + +/// `new Map(parameters.map((p) => [p.name, p]))`, without building it: a name keeps the slot of +/// its first occurrence and the value of its last. ponytail: linear lookups, a snippet or block has +/// a handful of params; index them if that stops being true. +#[derive(Clone, Copy)] +pub struct DocParams<'a>(&'a [LiquidDocParameter]); + +impl<'a> DocParams<'a> { + pub fn get(&self, name: &str) -> Option<&'a LiquidDocParameter> { + self.0.iter().rev().find(|p| p.name == name) + } + pub fn has(&self, name: &str) -> bool { + self.0.iter().any(|p| p.name == name) + } + /// `map.values()` + pub fn values(self) -> impl Iterator { + let all = self.0; + all.iter() + .enumerate() + .filter(move |(i, p)| !all[..*i].iter().any(|q| q.name == p.name)) + .filter_map(move |(_, p)| self.get(&p.name)) + } +} + +/// `getLiquidDocParams(context, relativePath)` +pub fn get_liquid_doc_params<'a>(cx: &Context<'a>, relative_path: &str) -> Option> { + let params = &cx + .get_doc_definition(relative_path)? + .liquid_doc + .as_ref()? + .parameters; + // `liquidDoc.parameters` is only set when there are some. + (!params.is_empty()).then_some(DocParams(params)) +} + +/// `getSnippetName(node)`, `None` when falsy (every caller returns early on `''`). +pub fn get_snippet_name<'a>(node: &'a RenderMarkup<'a>) -> Option<&'a str> { + match node.snippet.as_ref() { + Node::String(s) if !s.value.is_empty() => Some(&s.value), + _ => None, + } +} + +/// `getBlockName(node)`, `None` when falsy (every caller returns early on `''`). +pub fn get_block_name<'a>(node: &'a ContentForMarkup<'a>) -> Option<&'a str> { + if node.content_for_type.value != "block" { + return None; + } + match node.args.iter().find(|a| a.name == "type")?.value.as_ref() { + Node::String(s) if !s.value.is_empty() => Some(&s.value), + _ => None, + } +} + +/// `reportUnknownArguments`. `pos` and `node_args` are the tag markup's. +pub fn report_unknown_arguments<'a>( + cx: &mut Context, + tag: ArgsTag, + pos: Position, + node_args: &[NamedArgument], + args: impl IntoIterator>, + name: &str, +) { + for arg in args { + let suggest = vec![Suggestion::new( + format!("Remove '{}'", arg.name), + remove_argument_fix(cx.source(), pos, node_args, arg), + )]; + let message = format!("Unknown argument '{}'{}.", arg.name, tag.owner(name)); + cx.report_with( + message, + arg.position.start, + arg.position.end, + None, + Some(suggest), + ); + } +} + +/// `reportMissingArguments`: at the tag markup's position. +pub fn report_missing_arguments<'a>( + cx: &mut Context, + tag: ArgsTag, + position: Position, + node_args: &[NamedArgument], + params: impl IntoIterator, + name: &str, +) { + for p in params { + let suggest = vec![Suggestion::new( + format!("Add required argument '{}'", p.name), + add_argument_fix(cx.source(), position, node_args, p), + )]; + let message = format!("Missing required argument '{}'{}.", p.name, tag.owner(name)); + cx.report_with(message, position.start, position.end, None, Some(suggest)); + } +} + +/// `reportDuplicateArguments` over the `encounteredArgNames` scan. `seen` seeds the set (the +/// render alias). ponytail: scans the earlier args instead of a set, tags have a handful of args. +pub fn report_duplicate_arguments( + cx: &mut Context, + tag: ArgsTag, + pos: Position, + seen: Option<&str>, + args: &[NamedArgument], + name: &str, +) { + for (i, arg) in args.iter().enumerate() { + if seen == Some(&*arg.name) || args[..i].iter().any(|a| a.name == arg.name) { + let fix = remove_argument_fix(cx.source(), pos, args, arg); + let suggest = vec![Suggestion::new( + format!("Remove duplicate argument '{}'", arg.name), + fix, + )]; + let message = format!("Duplicate argument '{}'{}.", arg.name, tag.owner(name)); + cx.report_with( + message, + arg.position.start, + arg.position.end, + None, + Some(suggest), + ); + } + } +} + +/// `findTypeMismatchParams` + `reportTypeMismatches`. +pub fn report_type_mismatches(cx: &mut Context, params: DocParams, args: &[NamedArgument]) { + for arg in args { + let Some(ty) = params + .get(&arg.name) + .and_then(|p| p.type_.as_deref()) + .filter(|t| !t.is_empty()) + else { + continue; + }; + if check_argument_type(ty, &arg.value) == ArgumentTypeCheck::Incompatible { + let p = arg.value.position(); + let message = + get_argument_type_mismatch_message(&arg.name, ty, &arg.value, cx.source()); + cx.report_with( + message, + p.start, + p.end, + None, + Some(generate_type_mismatch_suggestions(ty, p.start, p.end)), + ); + } + } +} + +/// `generateTypeMismatchSuggestions(expectedType, start, end)` +pub fn generate_type_mismatch_suggestions( + expected: &str, + start: usize, + end: usize, +) -> Vec { + if let Some(literals) = parse_string_literals(expected) { + return literals + .iter() + .map(|l| { + Suggestion::new( + format!("Replace with {}", l.raw), + Fix::replace(start, end, l.raw), + ) + }) + .collect(); + } + let expected = expected.to_lowercase(); + let default = get_default_value_for_type(Some(&expected)); + let mut out = Vec::new(); + if !default.is_empty() { + out.push(Suggestion::new( + format!("Replace with default value '{default}' for {expected}"), + Fix::replace(start, end, default), + )); + } + out.push(Suggestion::new("Remove value", Fix::remove(start, end))); + out +} + +/// `getDefaultValueForType(type)` +pub fn get_default_value_for_type(ty: Option<&str>) -> &str { + if let Some(l) = ty.and_then(parse_string_literals) { + return l[0].raw; + } + match ty.map(str::to_lowercase).as_deref() { + Some("string") => "''", + Some("number") => "0", + Some("boolean") => "false", + _ => "", + } +} + +/// `/,\s*$/` matched against `s`: the match length. +fn trailing_comma_len(s: &str) -> Option { + let t = js::trim_end(s); + t.ends_with(',').then(|| s.len() - t.len() + 1) +} + +/// `/\s*,\s*/` matched at the start of `s` (`anchored`) or at its first comma: the lengths of the +/// match and of its `\s*,` part. +fn comma_match(s: &str, anchored: bool) -> Option<(usize, usize)> { + let start = if anchored { + 0 + } else { + let c = s.find(',')?; + js::trim_end(&s[..c]).len() + }; + let rest = js::trim_start(&s[start..]).strip_prefix(',')?; + let head = s.len() - start - rest.len(); + Some((head + rest.len() - js::trim_start(rest).len(), head)) +} + +/// `makeRemoveArgumentCorrector(node, arg)`, over the markup at `pos` with `args`. +fn remove_argument_fix( + source: &str, + pos: Position, + args: &[NamedArgument], + arg: &NamedArgument, +) -> Fix { + let (start, end) = (arg.position.start, arg.position.end); + let leading = || trailing_comma_len(&source[pos.start..start]).map_or(start, |n| start - n); + match comma_match(&source[end..pos.end], true) { + Some((all, head)) => { + let is_last = args.len() == 1 || args.last().is_some_and(|a| a.position.start == start); + if is_last { + Fix::remove(leading(), end + head) + } else { + Fix::remove(start, end + all) + } + } + None => Fix::remove(leading(), end), + } +} + +/// `makeAddArgumentCorrector(node, arg)`, over the markup at `pos` with `args`. +fn add_argument_fix( + source: &str, + pos: Position, + args: &[NamedArgument], + param: &LiquidDocParameter, +) -> Fix { + let add = format!( + ", {}: {}", + param.name, + get_default_value_for_type(param.type_.as_deref()) + ); + let Some(last) = args.last() else { + // `node.position.end - 1`. ponytail: lands before the last char, where TS splits an astral one. + let at = source[..pos.end] + .char_indices() + .next_back() + .map_or(pos.end, |(i, _)| i); + return Fix::insert(at, add); + }; + let end = last.position.end; + match comma_match(&source[end..pos.end], false) { + Some((all, _)) => Fix::replace(end, end + all, format!("{add} ")), + None => Fix::insert(end, add), + } +} + +/// TS `ArgumentTypeCheck` (`named-type` drops its `type`, which no caller reads). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ArgumentTypeCheck { + Compatible, + Incompatible, + Unchecked, + NamedType, +} + +fn literal_check(matches: bool) -> ArgumentTypeCheck { + if matches { + ArgumentTypeCheck::Compatible + } else { + ArgumentTypeCheck::Incompatible + } +} + +/// `inferArgumentType(arg)`. Arguments are only ever these expression types (TS `assertNever` +/// guards the rest), so anything else counts as `object`. +pub fn infer_argument_type(arg: &Node) -> &'static str { + match arg { + Node::String(_) => "string", + Node::Number(_) => "number", + Node::LiquidLiteral(_) => "boolean", + _ => "object", + } +} + +/// `isTypeCompatible(expectedType, actualType)`. ASCII case folding is exact here: the only +/// non-ASCII char that lowercases into ASCII is the Kelvin sign, and no basic type has a `k`. +pub fn is_type_compatible(expected: &str, actual: &str) -> bool { + expected.eq_ignore_ascii_case("boolean") || expected.eq_ignore_ascii_case(actual) +} + +/// `checkArgumentType(expectedType, argument)` +pub fn check_argument_type(expected: &str, arg: &Node) -> ArgumentTypeCheck { + if matches!(arg, Node::VariableLookup(_)) { + return ArgumentTypeCheck::Unchecked; + } + if let Some(literals) = parse_string_literals(expected) { + return literal_check( + matches!(arg, Node::String(s) if literals.iter().any(|l| l.value == s.value)), + ); + } + if matches!(arg, Node::BlockArrayLiteral(_)) { + return ArgumentTypeCheck::Unchecked; + } + let Some(ty) = normalize_named_param_type(expected) else { + return ArgumentTypeCheck::Unchecked; + }; + if !BASIC_PARAM_TYPES.contains(&ty.as_str()) { + return ArgumentTypeCheck::NamedType; + } + literal_check(is_type_compatible(&ty, infer_argument_type(arg))) +} + +/// `getArgumentTypeMismatchMessage(name, expectedType, argument)` +pub fn get_argument_type_mismatch_message( + name: &str, + expected: &str, + arg: &Node, + source: &str, +) -> String { + if parse_string_literals(expected).is_some() { + let p = arg.position(); + return format!( + "Invalid value for argument '{name}': expected {}, got {}", + js::trim(expected), + &source[p.start..p.end] + ); + } + let actual = if matches!(arg, Node::BlockArrayLiteral(_)) { + "array" + } else { + infer_argument_type(arg) + }; + format!( + "Type mismatch for argument '{name}': expected {}, got {actual}", + expected.to_lowercase() + ) +} + +/// `getValidParamTypes(liquidDrops).has(name)` +fn is_valid_param_type(docset: &ThemeDocset, name: &str) -> bool { + BASIC_PARAM_TYPES.contains(&name) + || docset + .liquid_drops + .iter() + .any(|o| o.get("name").and_then(Value::as_str) == Some(name)) +} + +/// `parseParamType(validParamTypes, value) !== undefined` +pub fn is_supported_named_param_type(docset: &ThemeDocset, value: &str) -> bool { + parse_param_type_syntax(value).is_some_and(|(base, _)| is_valid_param_type(docset, base)) +} diff --git a/crates/theme-check/src/checks/asset_preload.rs b/crates/theme-check/src/checks/asset_preload.rs new file mode 100644 index 000000000..6c7965f3d --- /dev/null +++ b/crates/theme-check/src/checks/asset_preload.rs @@ -0,0 +1,64 @@ +use super::utils::{has_attribute_value_of, is_attr, valued_html_attribute}; +use crate::context::{CheckDef, CheckResult, Context, Create, LiquidCheck, Severity}; +use liquid_ast::{Attr, Node, NodeKind, NodeRef}; + +pub static ASSET_PRELOAD: CheckDef = CheckDef { + code: "AssetPreload", + name: "Prevent Manual Preloading of Assets", + aliases: &[], + severity: Severity::Warning, + recommended: true, + deprecated: false, + schema: &[], + create: Create::Liquid(create), +}; + +struct Check; + +fn create<'a>(_cx: &mut Context<'a>) -> Option + 'a>> { + Some(Box::new(Check)) +} + +fn is_preload(attr: &Attr) -> bool { + is_attr(&attr.name, "rel") + && attr + .value + .iter() + .any(|n| matches!(n, Node::TextNode(t) if t.value == "preload")) +} + +impl<'a> LiquidCheck<'a> for Check { + fn node_mask(&self) -> u64 { + NodeKind::HtmlVoidElement.bit() + } + + fn node(&mut self, node: NodeRef<'a>, _: &[NodeRef<'a>], cx: &mut Context<'a>) -> CheckResult { + let NodeRef::HtmlVoidElement(n) = node else { + return Ok(()); + }; + let valued = || n.attributes.iter().filter_map(valued_html_attribute); + if n.name != "link" || !valued().any(is_preload) { + return Ok(()); + } + let high_priority_image = valued() + .any(|a| is_attr(&a.name, "as") && has_attribute_value_of(a, "image")) + && valued() + .any(|a| is_attr(&a.name, "fetchpriority") && has_attribute_value_of(a, "high")); + if high_priority_image { + return Ok(()); + } + let asset_type = valued().find(|a| is_attr(&a.name, "as")).and_then(|a| { + a.value.iter().find_map(|v| match v { + Node::TextNode(t) => Some(t.value.as_ref()), + _ => None, + }) + }); + let message = match asset_type { + Some("style") => "For better performance, prefer using the preload argument of the stylesheet_tag filter", + Some("image") => "For better performance, prefer using the preload argument of the image_tag filter", + _ => "For better performance, prefer using the preload_tag filter", + }; + cx.report(message, n.position.start, n.position.end); + Ok(()) + } +} diff --git a/crates/theme-check/src/checks/asset_size.rs b/crates/theme-check/src/checks/asset_size.rs new file mode 100644 index 000000000..5da3ff98d --- /dev/null +++ b/crates/theme-check/src/checks/asset_size.rs @@ -0,0 +1,187 @@ +//! AssetSizeCSS and AssetSizeJavaScript: the same check on stylesheets and scripts. + +use super::file_utils::{ + has_local_asset_size_exceeded_threshold, has_remote_asset_size_exceeded_threshold, +}; +use super::remote_asset::liquid_variable; +use super::utils::{is_attr, value_includes, valued_html_attribute}; +use crate::context::{CheckDef, CheckError, CheckResult, Context, Create, LiquidCheck, Severity}; +use crate::js; +use liquid_ast::{Attr, Node, NodeKind, NodeRef, Position}; +use serde_json::Value; + +pub static ASSET_SIZE_CSS: CheckDef = CheckDef { + code: "AssetSizeCSS", + name: "Prevent Large CSS bundles", + aliases: &["AssetSizeCSSStylesheetTag"], + severity: Severity::Error, + recommended: false, + deprecated: false, + schema: &[("thresholdInBytes", "100000")], + create: Create::Liquid(create_css), +}; + +pub static ASSET_SIZE_JAVASCRIPT: CheckDef = CheckDef { + code: "AssetSizeJavaScript", + name: "Prevent Large JavaScript bundles", + aliases: &[], + schema: &[("thresholdInBytes", "10000")], + create: Create::Liquid(create_js), + ..ASSET_SIZE_CSS +}; + +#[derive(Clone, Copy, PartialEq)] +enum Asset { + Css, + JavaScript, +} + +struct Check { + asset: Asset, + threshold: Value, +} + +fn create_css<'a>(cx: &mut Context<'a>) -> Option + 'a>> { + create(cx, Asset::Css) +} + +fn create_js<'a>(cx: &mut Context<'a>) -> Option + 'a>> { + create(cx, Asset::JavaScript) +} + +fn create<'a>(cx: &mut Context<'a>, asset: Asset) -> Option + 'a>> { + let threshold = cx + .settings + .get("thresholdInBytes") + .cloned() + .unwrap_or(Value::Null); + Some(Box::new(Check { asset, threshold })) +} + +/// `/(https?:)?\/\//.test(url)`: unanchored, so any `//`. +fn is_url(value: &str) -> bool { + value.contains("//") +} + +impl Check { + fn message(&self) -> String { + let t = js::to_string(&self.threshold); + match self.asset { + Asset::Css => format!("The CSS file size exceeds the threshold of {t} bytes"), + Asset::JavaScript => format!( + "JavaScript on every page load exceeds compressed size threshold ({t} Bytes), consider using the import on interaction pattern." + ), + } + } + + fn tag_filter(&self) -> &'static str { + match self.asset { + Asset::Css => "stylesheet_tag", + Asset::JavaScript => "script_tag", + } + } + + fn check_remote_asset_size(&self, url: &str, p: Position, cx: &mut Context) -> CheckResult { + if has_remote_asset_size_exceeded_threshold(cx, url, &self.threshold)? { + cx.report(self.message(), p.start, p.end); + } + Ok(()) + } + + fn check_theme_asset_size(&self, src: &str, p: Position, cx: &mut Context) { + if has_local_asset_size_exceeded_threshold(cx, &format!("assets/{src}"), &self.threshold) { + cx.report(self.message(), p.start, p.end); + } + } + + /// `href="..."` / `src="..."` with a single value: a URL, or `{{ 'name' | asset_url }}`. + fn check_url_attribute(&self, attr: &Attr, cx: &mut Context) -> CheckResult { + let [value] = attr.value.as_slice() else { + return Ok(()); + }; + let p = attr.attribute_position; + if let Node::TextNode(t) = value { + if is_url(&t.value) { + self.check_remote_asset_size(&t.value, p, cx)?; + } + } + if let Some(v) = liquid_variable(value) { + if let (Node::String(s), [filter]) = (v.expression.as_ref(), v.filters.as_slice()) { + if filter.name == "asset_url" { + self.check_theme_asset_size(&s.value, p, cx); + } + } + } + Ok(()) + } +} + +impl<'a> LiquidCheck<'a> for Check { + fn node_mask(&self) -> u64 { + let html = match self.asset { + Asset::Css => NodeKind::HtmlVoidElement, + Asset::JavaScript => NodeKind::HtmlRawNode, + }; + html.bit() | NodeKind::LiquidFilter.bit() + } + + fn node( + &mut self, + node: NodeRef<'a>, + ancestors: &[NodeRef<'a>], + cx: &mut Context<'a>, + ) -> CheckResult { + let valued = |attrs: &'a [Node<'a>], name| { + attrs + .iter() + .filter_map(valued_html_attribute) + .find(|a| is_attr(&a.name, name)) + }; + match node { + // + NodeRef::HtmlVoidElement(n) if n.name == "link" => { + let rel_is_stylesheet = n + .attributes + .iter() + .filter_map(valued_html_attribute) + .any(|a| is_attr(&a.name, "rel") && value_includes(a, "stylesheet")); + if let (true, Some(href)) = (rel_is_stylesheet, valued(&n.attributes, "href")) { + self.check_url_attribute(href, cx)?; + } + } + // "); + Suggestion::new( + suggestion_message(attr), + Fix::replace(output.position.start, output.position.end, script), + ) + }; + Some(vec![fix("defer"), fix("async")]) + } + _ => None, + }; + cx.report_with( + "The script_tag filter is parser-blocking. Use a "#, + ), + ("theme/extra/e.liquid", bad), + ("theme/extra/sub/f.liquid", bad), + ], + ); + // A linked directory's files are listed, its directories aren't. + std::os::unix::fs::symlink("../../extra", dir.join("theme/dist/snippets/link")).unwrap(); + + let (offenses, config, theme) = parse(&theme_check(&dir, &["theme"])); + assert_eq!( + offenses, + [ + "OrphanedSnippet 1 snippets/a.liquid 0:0-0:1", + "OrphanedSnippet 1 snippets/b.liquid 0:0-0:1", + "OrphanedSnippet 1 snippets/script.liquid 0:0-0:1", + "RemoteAsset 1 snippets/script.liquid 0:8-0:38", + "UnknownFilter 1 snippets/a.liquid 0:7-0:13", + "UnknownFilter 1 snippets/link/e.liquid 0:7-0:13", + "ValidJSON 0 config/x.json 0:6-0:10", + ] + ); + assert_eq!( + theme, + [ + "config/x.json", + "sections/s.liquid", + "snippets/a.liquid", + "snippets/b.liquid", + "snippets/link/e.liquid", + "snippets/script.liquid", + ] + ); + assert_eq!(config["context"], "theme"); + assert!(config["rootUri"] + .as_str() + .unwrap() + .ends_with("/cli/theme/theme/dist")); + assert_eq!( + config["ignore"], + json!([ + "node_modules/**", + "snippets/ignored.liquid", + "snippets/ignored-too.liquid" + ]) + ); + assert_eq!( + config["settings"]["UnknownFilter"], + json!({ "enabled": true, "severity": 1 }) + ); + let codes = check_codes(&config); + assert!(codes.contains(&"UnknownFilter") && !codes.contains(&"ParserBlockingScript")); +} + +#[test] +fn uses_the_app_config_in_an_app() { + let dir = fixture( + "app", + &[ + // The search for third-party checks stops at the repo, so this isn't one. + ("app/.git/HEAD", ""), + ("node_modules/theme-check-extra/package.json", "{}"), + ("app/shopify.app.toml", ""), + // The app schemas don't cover it. + ("app/config/x.json", r#"{"a": "no"}"#), + ("legacy.yml", "extends: :theme_app_extensions\n"), + ], + ); + for args in [&["app"][..], &["app", "legacy.yml"]] { + let (offenses, config, theme) = parse(&theme_check(&dir, args)); + assert_eq!(offenses, [""; 0], "{args:?}"); + assert_eq!(theme, ["config/x.json"]); + assert_eq!(config["context"], "app", "{args:?}"); + assert!( + check_codes(&config).contains(&"AppBlockValidTags"), + "{args:?}" + ); + } +} + +#[test] +fn fails_on_a_bad_config() { + let dir = fixture( + "bad", + &[ + ("theme/.git/HEAD", ""), + ("absolute.yml", "root: /theme\n"), + ("type.yml", "LiquidNestingDepth:\n maxDepth: '10'\n"), + ("theme/snippets/a.liquid", ""), + ], + ); + let cwd = std::fs::canonicalize(&dir).unwrap(); + assert_eq!( + failure(&theme_check(&dir, &["theme", "absolute.yml"])), + ( + Some(1), + "Error: the \"root\" property can only be relative\n".into() + ) + ); + assert_eq!( + failure(&theme_check(&dir, &["theme", "type.yml"])), + ( + Some(1), + "AssertionError [ERR_ASSERTION]: Invalid type for setting maxDepth: expected number, got string\n\n'string' !== 'number'\n\n".into() + ) + ); + assert_eq!( + failure(&theme_check(&dir, &["theme", "missing.yml"])), + ( + Some(1), + format!( + "Error: ENOENT: no such file or directory, open '{}/missing.yml'\n", + cwd.display() + ) + ) + ); + assert_eq!(failure(&theme_check(&dir, &[])).0, Some(2)); +} + +#[test] +fn autofixes() { + let dir = fixture( + "autofix", + &[ + ("theme/.git/HEAD", ""), + ( + "theme/snippets/a.liquid", + "{{ 'hello' | upcase | | downcase }}\n", + ), + ], + ); + let out = theme_check(&dir, &["theme", "--autofix"]); + assert!(out.status.success() && out.stdout.is_empty(), "{out:?}"); + assert_eq!( + std::fs::read_to_string(dir.join("theme/snippets/a.liquid")).unwrap(), + "{{ 'hello' | upcase | downcase }}\n" + ); +} + +#[test] +fn checks_deep_files() { + // Under TS's limit, but deeper than a debug build parses on a default thread's stack. + let n = 500; + let deep = "{% if a %}".repeat(n) + &"{% endif %}".repeat(n); + let dir = fixture( + "deep", + &[("theme/.git/HEAD", ""), ("theme/snippets/a.liquid", &deep)], + ); + let (offenses, _, _) = parse(&theme_check(&dir, &["theme"])); + let too_deep = offenses + .iter() + .filter(|o| o.starts_with("LiquidNestingDepth ")); + assert_eq!(too_deep.count(), n - 10); + assert_eq!( + offenses.len(), + n - 10 + 2, + "and LiquidComplexity, OrphanedSnippet" + ); +} diff --git a/crates/theme-check/tests/specs.rs b/crates/theme-check/tests/specs.rs new file mode 100644 index 000000000..18f736c60 --- /dev/null +++ b/crates/theme-check/tests/specs.rs @@ -0,0 +1,675 @@ +//! Runs the language-neutral specs in specs/ at the repo root, as packages/theme-check-common +//! and packages/theme-graph do in TypeScript. Prints each failure, and fails when there's one. + +use serde_json::{json, Map, Value}; +use std::path::Path; +use theme_check::context::{FileStat, FileType, Providers, RecordedCall}; +use theme_check::docset::ThemeDocset; +use theme_check::fixes::{self, Fix}; +use theme_check::graph::{build_theme_graph, get_web_component_map, RefType, Reference}; +use theme_check::json::js_insert; +use theme_check::source::{to_source_code, Position, SourceCode}; +use theme_check::{path, CheckDef, Config, Deps, FileSystem, Mode, Offense}; + +/// test/MockFileSystem.ts over `{ '.theme-check.yml': '', ...themeDesc }`, rooted at `file:///`. +struct MockFs { + files: Map, + /// Files are strings, directories objects (JS key order). + tree: Value, +} + +const ROOT: &str = "file:///"; + +impl MockFs { + fn new(theme_desc: &Map) -> Self { + let mut files = Map::new(); + files.insert(".theme-check.yml".into(), json!("")); + for (k, v) in theme_desc { + js_insert(&mut files, k.clone(), v.clone()); + } + let mut tree = Value::Object(Map::new()); + for (rel, source) in &files { + let segs: Vec<&str> = rel.split('/').collect(); + let mut cur = &mut tree; + for seg in &segs[..segs.len() - 1] { + let Value::Object(m) = cur else { break }; + if !m.contains_key(*seg) { + js_insert(m, seg.to_string(), Value::Object(Map::new())); + } + cur = m.get_mut(*seg).unwrap(); + } + if let Value::Object(m) = cur { + js_insert(m, segs[segs.len() - 1].to_string(), source.clone()); + } + } + MockFs { files, tree } + } + + fn root_relative(uri: &str) -> String { + path::relative(&path::normalize(uri), ROOT) + } +} + +impl FileSystem for MockFs { + fn read_file(&self, uri: &str) -> Result { + match self.files.get(&Self::root_relative(uri)) { + Some(Value::String(s)) => Ok(s.clone()), + _ => Err("File not found".into()), + } + } + + fn read_directory(&self, uri: &str) -> Result, String> { + let uri = uri.strip_suffix('/').unwrap_or(uri); + let tree = if path::normalize(uri) == ROOT { + Some(&self.tree) + } else { + // deepGet(fileTree, relativePath.split('/')) + Self::root_relative(uri) + .split('/') + .try_fold(&self.tree, |acc, k| acc.get(k)) + }; + match tree { + None | Some(Value::Null) => Err(format!("Directory not found: {uri} for {ROOT}")), + Some(Value::String(_)) => Ok(vec![(uri.to_string(), FileType::File)]), + Some(Value::Object(m)) => Ok(m + .iter() + .map(|(name, v)| { + ( + format!("{uri}/{name}"), + if v.is_string() { + FileType::File + } else { + FileType::Directory + }, + ) + }) + .collect()), + Some(_) => Ok(Vec::new()), + } + } + + fn stat(&self, uri: &str) -> Result { + if let Some(Value::String(s)) = self.files.get(&Self::root_relative(uri)) { + if !s.is_empty() { + return Ok(FileStat { + file_type: FileType::File, + size: theme_check::js::len16(s), + }); + } + } + self.read_directory(uri)?; + Ok(FileStat { + file_type: FileType::Directory, + size: 0, + }) + } +} + +/// An offense as a spec states it: `check` when the case lists `checks`, `file`, `message`, +/// `start`, the `highlight` text from start to end, and the fixed file for `fix` and each `suggest`. +fn spec_offense(o: &Offense, theme: &[SourceCode], with_check: bool) -> Value { + let file = theme + .iter() + .find(|f| f.uri == o.uri) + .expect("offense on a file outside the theme"); + let apply = |fix: &Fix| match fixes::corrector_fix(file, [fix]) + .and_then(|edits| fixes::apply_fix_to_string(file.source, &edits)) + { + Ok(fixed) => json!(fixed), + Err(e) => json!({ "error": e }), + }; + let mut m = Map::new(); + if with_check { + m.insert("check".into(), json!(o.check)); + } + m.insert("file".into(), json!(MockFs::root_relative(&o.uri))); + m.insert("message".into(), json!(o.message)); + m.insert("start".into(), json!([o.start.line, o.start.character])); + let highlight = file.source.get(o.start_byte..o.end_byte); + m.insert( + "highlight".into(), + highlight.map_or_else( + || json!({ "error": "not a range of the file" }), + |h| json!(h), + ), + ); + if let Some(fix) = &o.fix { + m.insert("fix".into(), apply(fix)); + } + if let Some(suggest) = &o.suggest { + m.insert( + "suggest".into(), + suggest + .iter() + .map(|s| json!({ "message": s.message, "result": apply(&s.fix) })) + .collect(), + ); + } + Value::Object(m) +} + +/// What a case doesn't state must be right too: the check's severity, and the positions. +fn offense_problems(o: &Offense, theme: &[SourceCode], defs: &[&CheckDef]) -> Vec { + let mut problems = Vec::new(); + match defs.iter().find(|d| d.code == o.check) { + None => problems.push(format!("an offense of {}, which didn't run", o.check)), + Some(d) if d.severity != o.severity => problems.push(format!( + "a {} offense with severity {:?}", + o.check, o.severity + )), + Some(_) => {} + } + let source = theme.iter().find(|f| f.uri == o.uri).unwrap().source; + for (name, byte, at) in [("start", o.start_byte, o.start), ("end", o.end_byte, o.end)] { + let Some(before) = source.get(..byte) else { + problems.push(format!( + "a {} offense's {name}, {byte}, isn't in its file", + o.check + )); + continue; + }; + // The index, line and character in UTF-16 units. + let expected = Position { + index: theme_check::js::len16(before) as i64, + line: before.matches('\n').count() as i64, + character: theme_check::js::len16(before.rsplit('\n').next().unwrap()) as i64, + }; + if at != expected { + problems.push(format!( + "a {} offense's {name} is {at:?}, not {expected:?}", + o.check + )); + } + } + if o.end_byte < o.start_byte { + problems.push(format!("a {} offense ends before it starts", o.check)); + } + problems +} + +/// A file's content: a string, or parts to join, each a string or `{ repeat, times }`. +fn spec_text(text: &Value) -> String { + if let Some(s) = text.as_str() { + return s.to_string(); + } + let parts = text + .as_array() + .expect("a file is a string or a list of parts"); + parts + .iter() + .map(|part| match part.as_str() { + Some(s) => s.to_string(), + None => part["repeat"] + .as_str() + .expect("a part is a string or { repeat, times }") + .repeat(part["times"].as_u64().expect("times is a count") as usize), + }) + .collect() +} + +/// The spec files in `dir`, parsed, by file name. +fn spec_files(dir: &Path) -> Vec<(String, Value)> { + let mut paths: Vec<_> = std::fs::read_dir(dir) + .unwrap_or_else(|e| panic!("{}: {e}", dir.display())) + .map(|e| e.unwrap().path()) + .filter(|p| p.extension().is_some_and(|x| x == "yml")) + .collect(); + paths.sort(); + paths + .iter() + .map(|p| { + let spec = theme_check::yaml::parse(&std::fs::read_to_string(p).unwrap()) + .unwrap_or_else(|e| panic!("{}: {e:?}", p.display())); + (p.file_name().unwrap().to_string_lossy().into_owned(), spec) + }) + .collect() +} + +/// Unordered: drops each expected item from the actual ones. What's left of each is missing and +/// unexpected. +fn unordered_diff(expected: Vec, mut unexpected: Vec) -> (Vec, Vec) { + let missing = expected + .into_iter() + .filter(|e| { + unexpected + .iter() + .position(|a| a == e) + .map(|i| unexpected.remove(i)) + .is_none() + }) + .collect(); + (missing, unexpected) +} + +fn mode(case: &Value) -> Mode { + match case["mode"].as_str() { + None | Some("theme") => Mode::Theme, + Some("app") => Mode::App, + Some(other) => panic!("unknown mode {other}"), + } +} + +/// specs/checks/*.yml, format in its README.md. Returns the number of specs and of failures. +fn run_check_specs(dir: &Path) -> (usize, usize) { + let (mut total, mut failed) = (0, 0); + for (spec_file, spec) in spec_files(dir) { + let code = spec["check"].as_str().unwrap_or_default(); + let defs: Vec<&'static CheckDef> = theme_check::checks::ALL_CHECKS + .iter() + .copied() + .filter(|d| d.code == code) + .collect(); + let severity = ["error", "warning", "info"] + .iter() + .position(|s| spec["severity"] == *s); + // Without `check`, a spec tests a feature, and each case lists its `checks`. + if spec.get("check").is_some() { + total += 1; + if defs.is_empty() || defs.iter().any(|d| Some(d.severity as usize) != severity) { + failed += 1; + println!( + "FAIL checks/{spec_file}: {code} isn't ported or doesn't have severity {}", + spec["severity"] + ); + continue; + } + } + for case in spec["cases"].as_array().into_iter().flatten() { + total += 1; + // A file at a URL is a remote file: the check only ever sees its size. + let is_remote = + |file: &str| file.starts_with("http://") || file.starts_with("https://"); + let (remote, files): (Map, Map) = case["files"] + .as_object() + .expect("a case needs files") + .iter() + .map(|(rel, text)| (rel.clone(), json!(spec_text(text)))) + .partition(|(file, _)| is_remote(file)); + let fs = MockFs::new(&files); + let sources: Vec<(String, &str)> = files + .iter() + .map(|(rel, src)| (path::join(ROOT, &[rel]), src.as_str().unwrap())) + .collect(); + let theme: Vec<_> = sources + .iter() + .map(|(uri, src)| to_source_code(uri, src)) + .collect(); + let docset = case.get("docset").map(|d| { + // A name is a file in docsets/. + let named = d.as_str().map(|name| { + let file = dir.join("docsets").join(format!("{name}.yml")); + theme_check::yaml::parse( + &std::fs::read_to_string(&file) + .unwrap_or_else(|e| panic!("{}: {e}", file.display())), + ) + .expect("bad docset YAML") + }); + let d = named.as_ref().unwrap_or(d); + let list = |k: &str| d[k].as_array().cloned().unwrap_or_default(); + ThemeDocset::augmented( + list("filters"), + list("objects"), + list("tags"), + d["systemTranslations"] + .as_object() + .cloned() + .unwrap_or_default(), + ) + }); + let mut problems = Vec::new(); + let with_check = case["checks"].is_array(); + let defs: Vec<&'static CheckDef> = match case["checks"].as_array() { + Some(codes) => { + let codes: Vec<&str> = codes + .iter() + .map(|c| c.as_str().expect("a check is its code")) + .collect(); + problems.extend( + codes + .iter() + .filter(|c| { + !theme_check::checks::ALL_CHECKS + .iter() + .any(|d| d.code == **c) + }) + .map(|c| format!("unknown check {c}")), + ); + theme_check::checks::ALL_CHECKS + .iter() + .copied() + .filter(|d| codes.contains(&d.code)) + .collect() + } + None => defs.clone(), + }; + if defs.is_empty() { + problems.push("no checks to run".to_string()); + } + // An option the check doesn't have would be silently ignored. + for (code, options) in case["config"].as_object().into_iter().flatten() { + let def = defs.iter().find(|d| d.code == code); + for key in options.as_object().into_iter().flatten().map(|(k, _)| k) { + if !def.is_some_and(|d| d.schema.iter().any(|(k, _)| k == key)) { + problems.push(format!("unknown config option {code}.{key}")); + } + } + } + let config = Config::new( + defs.clone(), + case["config"].as_object().cloned().unwrap_or_default(), + Vec::new(), + ); + let mut deps = Deps::new( + &theme, + ROOT, + mode(case), + &fs, + docset, + Providers { + theme_schemas: true, + }, + ); + // Which files render which, as production gets it from the theme graph. + let mut edges = Vec::new(); + for r in case["references"].as_array().into_iter().flatten() { + let ref_type = r + .get("type") + .map_or("direct", |t| t.as_str().unwrap_or_default()); + if !["direct", "indirect", "preset"].contains(&ref_type) { + problems.push(format!("unknown reference type {ref_type}")); + } + let mut uri = |end: &str| { + let rel = r[end].as_str().unwrap_or_default(); + if !files.contains_key(rel) { + problems.push(format!("reference {end} {rel} isn't in files")); + } + json!({ "uri": path::join(ROOT, &[rel]) }) + }; + edges.push( + json!({ "source": uri("source"), "target": uri("target"), "type": ref_type }), + ); + } + let answers = |dep: &str, end: &str| -> Vec { + edges + .iter() + .map(|e| RecordedCall { + dep: dep.into(), + method: None, + args: json!([e[end]["uri"]]), + result: Ok(edges + .iter() + .filter(|other| other[end] == e[end]) + .cloned() + .collect()), + }) + .collect() + }; + deps.recorded = answers("getReferences", "target"); + deps.recorded.extend(answers("getDependencies", "source")); + deps.recorded + .extend(remote.iter().map(|(url, text)| RecordedCall { + dep: "remoteFileSize".into(), + method: None, + args: json!([url]), + result: Ok(json!(text.as_str().unwrap().len())), + })); + deps.mocked = vec![ + "getReferences".into(), + "getDependencies".into(), + "remoteFileSize".into(), + ]; + let out = theme_check::check(&config, &deps, 1); + + let mut expected = case["offenses"].as_array().cloned().unwrap_or_default(); + for e in &mut expected { + e["highlight"] = json!(spec_text(&e["highlight"])); + } + let actual = out + .offenses + .iter() + .map(|o| spec_offense(o, &theme, with_check)) + .collect(); + problems.extend( + out.offenses + .iter() + .flat_map(|o| offense_problems(o, &theme, &defs)), + ); + let (missing, unexpected) = unordered_diff(expected, actual); + // Each file with several fixes, after applying them all at once. + let autofix: Map = theme + .iter() + .filter_map(|file| { + let fixes: Vec<&Fix> = out + .offenses + .iter() + .filter(|o| o.uri == file.uri) + .filter_map(|o| o.fix.as_ref()) + .collect(); + (fixes.len() > 1).then(|| { + let fixed = fixes::corrector_fix(file, fixes) + .and_then(|edits| fixes::apply_fix_to_string(file.source, &edits)); + ( + MockFs::root_relative(&file.uri), + fixed.map_or_else(|e| json!({ "error": e }), Value::String), + ) + }) + }) + .collect(); + let expected_autofix = case["autofix"].as_object().cloned().unwrap_or_default(); + if autofix != expected_autofix { + problems.push(format!( + "autofix: expected {}, got {}", + Value::Object(expected_autofix), + Value::Object(autofix) + )); + } + if missing.is_empty() + && unexpected.is_empty() + && out.errors.is_empty() + && problems.is_empty() + { + continue; + } + failed += 1; + println!( + "FAIL checks/{spec_file}: {}", + case["name"].as_str().unwrap_or("?") + ); + missing.iter().for_each(|o| println!(" - {o}")); + unexpected.iter().for_each(|o| println!(" + {o}")); + out.errors + .iter() + .for_each(|e| println!(" ! {} threw: {}", e.check, e.message)); + problems.iter().for_each(|k| println!(" ! {k}")); + } + } + (total, failed) +} + +const GRAPH_CASE_KEYS: &[&str] = &["name", "mode", "files", "entryPoints", "modules", "edges"]; + +/// specs/graph/*.yml, format in its README.md. Returns the number of cases and of failures. +fn run_graph_specs(dir: &Path) -> (usize, usize) { + let (mut total, mut failed) = (0, 0); + for (spec_file, spec) in spec_files(dir) { + for case in spec["cases"].as_array().into_iter().flatten() { + total += 1; + let files: Map = case["files"] + .as_object() + .expect("a case needs files") + .clone(); + // A misspelled optional key would silently check nothing. + let mut problems: Vec = case + .as_object() + .into_iter() + .flatten() + .map(|(k, _)| k) + .filter(|k| !GRAPH_CASE_KEYS.contains(&k.as_str())) + .map(|k| format!("unknown case key {k}")) + .collect(); + let fs = MockFs::new(&files); + let sources: Vec<(String, &str)> = files + .iter() + .filter(|(rel, _)| rel.ends_with(".liquid") || rel.ends_with(".json")) + .map(|(rel, src)| { + ( + path::join(ROOT, &[rel]), + src.as_str().expect("a file is a string"), + ) + }) + .collect(); + let theme: Vec<_> = sources + .iter() + .map(|(uri, src)| to_source_code(uri, src)) + .collect(); + let deps = Deps::new( + &theme, + ROOT, + mode(case), + &fs, + None, + Providers { + theme_schemas: true, + }, + ); + let web_components = get_web_component_map(ROOT, &fs) + .unwrap_or_else(|e| panic!("getWebComponentMap threw: {e}")); + let graph = build_theme_graph(&deps, &|name| web_components.get(name).cloned(), None); + let (mut missing, mut unexpected) = (Vec::new(), Vec::new()); + match &graph { + Err(e) => problems.push(format!("buildThemeGraph threw: {e}")), + Ok(graph) => { + // The [line, character] of a UTF-16 range's start in its file, and its text. + let at = |uri: &str, (start, end): (usize, usize)| { + let units: Vec = files[&MockFs::root_relative(uri)] + .as_str() + .unwrap() + .encode_utf16() + .collect(); + let before = &units[..start.min(units.len())]; + let line_start = before + .iter() + .rposition(|&u| u == u16::from(b'\n')) + .map_or(0, |i| i + 1); + let line = before.iter().filter(|&&u| u == u16::from(b'\n')).count(); + ( + json!([line, start - line_start]), + json!(units.get(start..end).map(String::from_utf16_lossy)), + ) + }; + let spec_edge = |r: &Reference| { + let mut e = Map::new(); + e.insert("source".into(), json!(MockFs::root_relative(&r.source.uri))); + if let Some(range) = r.source.range { + let (start, text) = at(&r.source.uri, range); + e.insert("start".into(), start); + e.insert("text".into(), text); + } + e.insert("target".into(), json!(MockFs::root_relative(&r.target.uri))); + if let Some(range) = r.target.range { + let (start, text) = at(&r.target.uri, range); + e.insert("targetStart".into(), start); + e.insert("targetText".into(), text); + } + if r.ref_type != RefType::Direct { + e.insert("type".into(), json!(r.ref_type.as_str())); + } + Value::Object(e) + }; + // Each edge is a dependency of its source and a reference of its target. + for m in graph.modules() { + let wrong_source = m.dependencies.iter().filter(|r| r.source.uri != m.uri); + let wrong_target = m.references.iter().filter(|r| r.target.uri != m.uri); + problems.extend(wrong_source.chain(wrong_target).map(|r| { + format!( + "{} has the edge {}", + MockFs::root_relative(&m.uri), + spec_edge(r) + ) + })); + } + let edges: Vec = graph + .modules() + .flat_map(|m| &m.dependencies) + .map(spec_edge) + .collect(); + let references = graph + .modules() + .flat_map(|m| &m.references) + .map(spec_edge) + .collect(); + let (m, u) = unordered_diff(edges.clone(), references); + problems.extend( + m.iter() + .map(|e| format!("not a reference: {e}")) + .chain(u.iter().map(|e| format!("only a reference: {e}"))), + ); + (missing, unexpected) = unordered_diff( + case["edges"].as_array().cloned().unwrap_or_default(), + edges, + ); + if let Some(expected) = case.get("entryPoints") { + let actual = graph + .entry_points() + .map(|m| json!(MockFs::root_relative(&m.uri))) + .collect(); + let (m, u) = unordered_diff( + expected.as_array().cloned().unwrap_or_default(), + actual, + ); + problems.extend( + m.iter() + .map(|e| format!("missing entry point {e}")) + .chain(u.iter().map(|e| format!("unexpected entry point {e}"))), + ); + } + if let Some(expected) = case.get("modules") { + let actual = graph + .modules() + .map(|m| { + let mut module = json!({ "file": MockFs::root_relative(&m.uri), "type": m.module_type.as_str(), "kind": m.kind.as_str() }); + if m.exists == Some(false) { + module["exists"] = json!(false); + } + module + }) + .collect(); + let (m, u) = unordered_diff( + expected.as_array().cloned().unwrap_or_default(), + actual, + ); + problems.extend( + m.iter() + .map(|e| format!("missing module {e}")) + .chain(u.iter().map(|e| format!("unexpected module {e}"))), + ); + } + } + } + if missing.is_empty() && unexpected.is_empty() && problems.is_empty() { + continue; + } + failed += 1; + println!( + "FAIL graph/{spec_file}: {}", + case["name"].as_str().unwrap_or("?") + ); + missing.iter().for_each(|e| println!(" - {e}")); + unexpected.iter().for_each(|e| println!(" + {e}")); + problems.iter().for_each(|p| println!(" ! {p}")); + } + } + (total, failed) +} + +fn main() { + let specs = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../specs"); + let (checks, checks_failed) = run_check_specs(&specs.join("checks")); + println!( + "specs/checks: {} of {checks} passing", + checks - checks_failed + ); + let (graph, graph_failed) = run_graph_specs(&specs.join("graph")); + println!("specs/graph: {} of {graph} passing", graph - graph_failed); + std::process::exit(i32::from(checks_failed + graph_failed > 0)); +} diff --git a/specs/parser/README.md b/specs/parser/README.md index 660512684..9eb4d6cf2 100644 --- a/specs/parser/README.md +++ b/specs/parser/README.md @@ -2,8 +2,9 @@ Executable, language-neutral specs for the Liquid HTML parser: the tree it builds for a template, or the error it throws. Every implementation runs them. In TypeScript that's -`packages/liquid-html-parser/src/specs.test.ts`, which runs with the rest of the vitest suite. To -change what the parser does, change its spec first. +`packages/liquid-html-parser/src/specs.test.ts`, which runs with the rest of the vitest suite, and +in Rust `crates/liquid-parser/tests/specs.rs`, which `cargo test` runs. To change what the parser +does, change its spec first. ## Format @@ -37,10 +38,10 @@ cases: `parse` lists the parses the case is for. Both use their default options: -| `parse` | What it parses | TypeScript | -| -------- | ------------------------- | ----------------- | -| `html` | Liquid and HTML | `toLiquidHtmlAST` | -| `liquid` | Liquid, with HTML as text | `toLiquidAST` | +| `parse` | What it parses | TypeScript | Rust | +| -------- | ------------------------- | ----------------- | -------------------- | +| `html` | Liquid and HTML | `toLiquidHtmlAST` | `to_liquid_html_ast` | +| `liquid` | Liquid, with HTML as text | `toLiquidAST` | `to_liquid_ast` | When the two give different results for a template, they're two cases. @@ -73,8 +74,8 @@ cases: text:
``` -The parse throws a parse error, `LiquidHTMLASTParsingError` in TypeScript, with that `message`. -`start` and `text` are its location (`loc`), as for positions. +The parse throws a parse error, `LiquidHTMLASTParsingError` in TypeScript and `ParseError` in +Rust, with that `message`. `start` and `text` are its location (`loc`), as for positions. A location never goes past the template's last character, so an error at its end covers one character less than the tag: an unclosed `{% if a %}` covers `{% if a %`. From 418a43cc18601da5d1d06af3c83a1c2a2436bc98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Claud=C3=A9ric=20Demers?= Date: Tue, 20 Oct 2026 15:11:39 -0400 Subject: [PATCH 2/2] Version and publish the Rust crates Changesets only versions packages that have a package.json, so the crates get one: crates/theme-check/package.json, a private package named theme-check-rs. Its name keeps it out of the @shopify/theme-check-* group, so the crates have their own version, from 0.1.0. A changeset for theme-check-rs bumps it, and the release orchestrator then copies it into Cargo.toml and Cargo.lock. The three crates share it, and CI checks that they have it. CI also dry-runs the publish, which builds each crate from the files it would upload. The release workflow publishes each crate whose version isn't on crates.io yet, with trusted publishing, so a run that fails partway is finished by the next. It does this in a job of its own, in the crates-io environment, so no npm package runs where it can get a crates.io token. Until theme-check is on crates.io, it skips them: the first publish is by hand, and so is adding the trusted publisher. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 8 +++ .github/workflows/release.yml | 53 +++++++++++++++++++ Cargo.toml | 1 + crates/liquid-parser/Cargo.toml | 2 + crates/theme-check/Cargo.toml | 2 +- crates/theme-check/README.md | 3 ++ crates/theme-check/package.json | 6 +++ package.json | 3 +- packages/release-orchestrator/src/index.ts | 2 + .../src/steps/syncCrateVersion.spec.ts | 19 +++++++ .../src/steps/syncCrateVersion.ts | 32 +++++++++++ pnpm-lock.yaml | 2 + pnpm-workspace.yaml | 1 + 13 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 crates/theme-check/package.json create mode 100644 packages/release-orchestrator/src/steps/syncCrateVersion.spec.ts create mode 100644 packages/release-orchestrator/src/steps/syncCrateVersion.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d17ed3912..a1b1e3c57 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,6 +62,14 @@ jobs: - name: Run the unit tests and the specs run: cargo test --workspace --locked + - name: Check that the crates package and build on their own + run: cargo publish --dry-run --workspace --locked + + - name: Check that the crates have the version of crates/theme-check/package.json + run: | + cargo metadata --no-deps --format-version 1 \ + | jq -e --arg version "$(jq -r .version crates/theme-check/package.json)" 'all(.packages[]; .version == $version)' + e2e: runs-on: ubuntu-latest name: E2E tests diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 26283f415..49817b2a7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,6 +13,8 @@ env: jobs: publish-packages: runs-on: ubuntu-latest + outputs: + hasChangesets: ${{ steps.changesets.outputs.hasChangesets }} permissions: contents: write pull-requests: write @@ -93,3 +95,54 @@ jobs: run: pnpm publish:ovsx env: OVSX_PAT: ${{ secrets.OPENVSX_TOKEN }} + + # Its own job, so no npm package runs where a crates.io token can be had. + publish-crates: + needs: publish-packages + if: needs.publish-packages.outputs.hasChangesets == 'false' + runs-on: ubuntu-latest + environment: crates-io + permissions: + contents: read + id-token: write + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # Until theme-check is on crates.io, published once by hand with a trusted publisher for + # this workflow and environment, this publishes nothing. After a partial publish, the next + # run does the rest. + - name: List the crates whose version isn't on crates.io + id: crates + run: | + version=$(jq -r .version crates/theme-check/package.json) + on_crates_io() { + code=$(curl -s --retry 3 -o /dev/null -w '%{http_code}' -A 'Shopify/theme-tools release' "https://crates.io/api/v1/crates/$1") + case $code in + 200) return 0 ;; + 404) return 1 ;; + *) echo "::error::crates.io answered $code for $1"; exit 1 ;; + esac + } + list= + if on_crates_io theme-check; then + for crate in liquid-ast liquid-parser theme-check; do + on_crates_io "$crate/$version" || list="$list $crate" + done + fi + echo "list=$list" >> $GITHUB_OUTPUT + + - name: Exchange the Actions identity token for a crates.io token + id: crates-io-auth + if: steps.crates.outputs.list != '' + uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.5 + + # In dependency order. + - name: Publish the crates to crates.io + if: steps.crates.outputs.list != '' + run: for crate in $CRATES; do cargo publish -p "$crate" --locked; done + env: + CRATES: ${{ steps.crates.outputs.list }} + CARGO_REGISTRY_TOKEN: ${{ steps.crates-io-auth.outputs.token }} diff --git a/Cargo.toml b/Cargo.toml index 0ed7042b9..cb2b664b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ resolver = "2" members = ["crates/liquid-ast", "crates/liquid-parser", "crates/theme-check"] +# The release sets the version from crates/theme-check/package.json, which changesets bumps. [workspace.package] version = "0.1.0" edition = "2021" diff --git a/crates/liquid-parser/Cargo.toml b/crates/liquid-parser/Cargo.toml index b173ae602..32d1c78d6 100644 --- a/crates/liquid-parser/Cargo.toml +++ b/crates/liquid-parser/Cargo.toml @@ -6,6 +6,8 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true +# The specs it runs are at the repo root, outside the crate. +exclude = ["/tests"] [dependencies] liquid-ast.workspace = true diff --git a/crates/theme-check/Cargo.toml b/crates/theme-check/Cargo.toml index a12176ff3..c7ec0ddb4 100644 --- a/crates/theme-check/Cargo.toml +++ b/crates/theme-check/Cargo.toml @@ -7,7 +7,7 @@ rust-version.workspace = true license.workspace = true repository.workspace = true # The specs it runs are at the repo root, outside the crate. -exclude = ["tests"] +exclude = ["/package.json", "/tests"] [dependencies] liquid-ast.workspace = true diff --git a/crates/theme-check/README.md b/crates/theme-check/README.md index 1740fa226..f072a17c4 100644 --- a/crates/theme-check/README.md +++ b/crates/theme-check/README.md @@ -48,3 +48,6 @@ Ported code keeps the TypeScript's behavior, quirks included, with a few rules: - Where TypeScript throws, a check returns `Err(CheckError)`. The runner stops that check on that file, as TypeScript's `onError` does. - A simplification with a known limit is marked with a `ponytail:` comment. + +Release versions come from changesets. Add a changeset for `theme-check-rs`, the private +package in `crates/theme-check/package.json`, and the release sets that version in `Cargo.toml`. diff --git a/crates/theme-check/package.json b/crates/theme-check/package.json new file mode 100644 index 000000000..7eff5855d --- /dev/null +++ b/crates/theme-check/package.json @@ -0,0 +1,6 @@ +{ + "name": "theme-check-rs", + "version": "0.1.0", + "description": "Versions the Rust crates with changesets. The release copies this version into Cargo.toml.", + "private": true +} diff --git a/package.json b/package.json index 8820cb91d..ae08b9265 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "packages/theme-graph", "packages/theme-language-server-*", "packages/vscode-extension", - "packages/release-orchestrator" + "packages/release-orchestrator", + "crates/theme-check" ], "scripts": { "admin:build": "sh ./scripts/dev-admin-build.sh", diff --git a/packages/release-orchestrator/src/index.ts b/packages/release-orchestrator/src/index.ts index 793420d60..1bce1a581 100644 --- a/packages/release-orchestrator/src/index.ts +++ b/packages/release-orchestrator/src/index.ts @@ -1,6 +1,7 @@ import { changesetVersion } from './steps/changesetVersion'; import { getPackageJsonPaths } from './steps/getPackageJsonPaths'; import { getPackageJsonRecord } from './steps/getPackageJsonRecord'; +import { syncCrateVersion } from './steps/syncCrateVersion'; import { writeDependentPatchChangesets } from './steps/writeDependentPatchChangesets'; import type { StepFunction } from './types'; import { flow } from './utils'; @@ -11,6 +12,7 @@ const main = async () => { getPackageJsonRecord, writeDependentPatchChangesets, changesetVersion, + syncCrateVersion, ]; const startRelease = flow(steps); diff --git a/packages/release-orchestrator/src/steps/syncCrateVersion.spec.ts b/packages/release-orchestrator/src/steps/syncCrateVersion.spec.ts new file mode 100644 index 000000000..ec97c532e --- /dev/null +++ b/packages/release-orchestrator/src/steps/syncCrateVersion.spec.ts @@ -0,0 +1,19 @@ +import { readFileSync } from 'node:fs'; +import path from 'path'; +import { describe, expect, it } from 'vitest'; +import { setCrateVersion } from './syncCrateVersion'; + +describe('setCrateVersion', () => { + it.each(['Cargo.toml', 'Cargo.lock'])( + 'sets the version of the three crates in %s, and nothing else', + (file) => { + const source = readFileSync(path.resolve(__dirname, '../../../..', file), 'utf8'); + const lines = source.split('\n'); + const changed = setCrateVersion(file, source, '9.8.7') + .split('\n') + .filter((line, i) => line !== lines[i]); + expect(changed).toHaveLength(3); + for (const line of changed) expect(line).toMatch(/version = "9\.8\.7"/); + }, + ); +}); diff --git a/packages/release-orchestrator/src/steps/syncCrateVersion.ts b/packages/release-orchestrator/src/steps/syncCrateVersion.ts new file mode 100644 index 000000000..6f47b23ab --- /dev/null +++ b/packages/release-orchestrator/src/steps/syncCrateVersion.ts @@ -0,0 +1,32 @@ +import fs from 'fs/promises'; +import path from 'path'; + +import { getRepoRoot } from '../utils'; + +/** Where each file has the version of the Rust crates, which they all share. */ +const versionPatterns: Record = { + 'Cargo.toml': /^((?:version|liquid-(?:ast|parser) = \{.*\bversion) = )"[^"]*"/gm, + 'Cargo.lock': /^(name = "(?:liquid-ast|liquid-parser|theme-check)"\nversion = )"[^"]*"/gm, +}; + +export const setCrateVersion = (file: string, source: string, version: string) => + source.replace(versionPatterns[file], `$1"${version}"`); + +/** + * Changesets versions the Rust crates as the private theme-check-rs package, + * in crates/theme-check/package.json. This copies its version into Cargo. + */ +export const syncCrateVersion = async () => { + const root = await getRepoRoot(); + const packageJson = await fs.readFile(path.join(root, 'crates/theme-check/package.json'), 'utf8'); + const { version } = JSON.parse(packageJson); + + console.log(`Setting the Rust crates' version to ${version}...`); + for (const file of Object.keys(versionPatterns)) { + const filePath = path.join(root, file); + await fs.writeFile( + filePath, + setCrateVersion(file, await fs.readFile(filePath, 'utf8'), version), + ); + } +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8c82c71cd..e3111cf25 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,6 +65,8 @@ importers: specifier: ^5.2.6 version: 5.2.6(tslib@2.8.1)(webpack-cli@5.1.4)(webpack@5.94.0) + crates/theme-check: {} + packages/codemirror-language-client: dependencies: vscode-languageserver-protocol: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index fdd97cdcb..11cab0007 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,5 @@ packages: + - crates/theme-check - packages/codemirror-language-client - packages/lang-* - packages/liquid-html-parser