diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 926020dddab9..d0c994303804 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -25,7 +25,7 @@ env: jobs: format: - name: Format and Lint Rust Files + name: Format project runs-on: ubuntu-latest steps: - name: Checkout PR branch @@ -44,7 +44,7 @@ jobs: taplo format --check lint: - name: Lint Rust Files + name: Lint project runs-on: ubuntu-latest steps: - name: Checkout PR Branch @@ -57,7 +57,9 @@ jobs: components: clippy cache-base: main - name: Run clippy - run: cargo lint + run: | + cargo lint + cargo run -p rules_check check-dependencies: name: Check Dependencies diff --git a/Cargo.lock b/Cargo.lock index 770cd03cf989..e10c9a89846b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1805,6 +1805,15 @@ dependencies = [ "slab", ] +[[package]] +name = "getopts" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5" +dependencies = [ + "unicode-width", +] + [[package]] name = "getrandom" version = "0.1.16" @@ -2632,6 +2641,25 @@ dependencies = [ "unicase", ] +[[package]] +name = "pulldown-cmark" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76979bea66e7875e7509c4ec5300112b316af87fa7a252ca91c448b32dfe3993" +dependencies = [ + "bitflags 2.5.0", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd348ff538bc9caeda7ee8cad2d1d48236a1f443c1fa3913c6a02fe0043b1dd3" + [[package]] name = "qp-trie" version = "0.8.2" @@ -2901,6 +2929,27 @@ dependencies = [ "byteorder", ] +[[package]] +name = "rules_check" +version = "0.0.0" +dependencies = [ + "anyhow", + "biome_analyze", + "biome_console", + "biome_css_analyze", + "biome_css_parser", + "biome_css_syntax", + "biome_diagnostics", + "biome_js_analyze", + "biome_js_parser", + "biome_js_syntax", + "biome_json_analyze", + "biome_json_parser", + "biome_json_syntax", + "biome_service", + "pulldown-cmark 0.10.3", +] + [[package]] name = "rust-lapper" version = "1.1.0" @@ -4257,7 +4306,7 @@ dependencies = [ "bpaf", "git2", "proc-macro2", - "pulldown-cmark", + "pulldown-cmark 0.9.2", "quote", "schemars", "serde", diff --git a/Cargo.toml b/Cargo.toml index 795a2c4ae469..f180c154bfa1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [workspace] # Use the newer version of the cargo resolver # https://doc.rust-lang.org/cargo/reference/resolver.html#resolver-versions -members = ["crates/*", "xtask/bench", "xtask/codegen", "xtask/coverage", "xtask/libs_bench"] +members = ["crates/*", "xtask/bench", "xtask/codegen", "xtask/coverage", "xtask/libs_bench", "xtask/rules_check"] resolver = "2" [workspace.lints.rust] diff --git a/justfile b/justfile index b93d63014dd6..0f7f92d894cf 100644 --- a/justfile +++ b/justfile @@ -37,6 +37,7 @@ gen-lint: cargo codegen-configuration cargo run -p xtask_codegen --features configuration -- migrate-eslint just gen-bindings + cargo run -p rules_check just format # Generates the initial files for all formatter crates @@ -121,7 +122,7 @@ test-quick package: # Alias for `cargo lint`, it runs clippy on the whole codebase lint: - cargo lint + cargo lint # When you finished coding, run this command to run the same commands in the CI. ready: diff --git a/xtask/rules_check/Cargo.toml b/xtask/rules_check/Cargo.toml new file mode 100644 index 000000000000..6a56f1980e97 --- /dev/null +++ b/xtask/rules_check/Cargo.toml @@ -0,0 +1,26 @@ +[package] +edition = "2021" +name = "rules_check" +publish = false +version = "0.0.0" + +[dependencies] +anyhow = { workspace = true } +biome_analyze = { workspace = true } +biome_console = { workspace = true } +biome_css_analyze = { workspace = true } +biome_css_parser = { workspace = true } +biome_css_syntax = { workspace = true } +biome_diagnostics = { workspace = true } +biome_js_analyze = { workspace = true } +biome_js_parser = { workspace = true } +biome_js_syntax = { workspace = true } +biome_json_analyze = { workspace = true } +biome_json_parser = { workspace = true } +biome_json_syntax = { workspace = true } +biome_service = { workspace = true } +pulldown-cmark = "0.10.3" + + +[lints] +workspace = true diff --git a/xtask/rules_check/src/lib.rs b/xtask/rules_check/src/lib.rs new file mode 100644 index 000000000000..86758b75eb78 --- /dev/null +++ b/xtask/rules_check/src/lib.rs @@ -0,0 +1,740 @@ +//! This module is in charge of checking if the documentation and tests cases inside the Analyzer rules are correct. +//! +//! +use anyhow::{bail, ensure}; +use biome_analyze::options::JsxRuntime; +use biome_analyze::{ + AnalysisFilter, AnalyzerOptions, FixKind, GroupCategory, Queryable, RegistryVisitor, Rule, + RuleCategory, RuleFilter, RuleGroup, RuleMetadata, +}; +use biome_console::{markup, Console}; +use biome_css_parser::CssParserOptions; +use biome_css_syntax::CssLanguage; +use biome_diagnostics::{Diagnostic, DiagnosticExt, PrintDiagnostic}; +use biome_js_parser::JsParserOptions; +use biome_js_syntax::{EmbeddingKind, JsFileSource, JsLanguage, Language, ModuleKind}; +use biome_json_parser::JsonParserOptions; +use biome_json_syntax::JsonLanguage; +use biome_service::settings::WorkspaceSettings; +use pulldown_cmark::{CodeBlockKind, Event, LinkType, Parser, Tag, TagEnd}; +use std::collections::BTreeMap; +use std::fmt::Write; +use std::io::Write as _; +use std::ops::ControlFlow; +use std::path::PathBuf; +use std::slice; +use std::str::FromStr; + +pub fn check_rules() -> anyhow::Result<()> { + #[derive(Default)] + struct LintRulesVisitor { + groups: BTreeMap<&'static str, BTreeMap<&'static str, RuleMetadata>>, + } + + impl RegistryVisitor for LintRulesVisitor { + fn record_category>(&mut self) { + if matches!(C::CATEGORY, RuleCategory::Lint) { + C::record_groups(self); + } + } + + fn record_rule(&mut self) + where + R: Rule + 'static, + R::Query: Queryable, + ::Output: Clone, + { + self.groups + .entry(::NAME) + .or_default() + .insert(R::METADATA.name, R::METADATA); + } + } + + impl RegistryVisitor for LintRulesVisitor { + fn record_category>(&mut self) { + if matches!(C::CATEGORY, RuleCategory::Lint) { + C::record_groups(self); + } + } + + fn record_rule(&mut self) + where + R: Rule + 'static, + R::Query: Queryable, + ::Output: Clone, + { + self.groups + .entry(::NAME) + .or_default() + .insert(R::METADATA.name, R::METADATA); + } + } + + impl RegistryVisitor for LintRulesVisitor { + fn record_category>(&mut self) { + if matches!(C::CATEGORY, RuleCategory::Lint) { + C::record_groups(self); + } + } + + fn record_rule(&mut self) + where + R: Rule + 'static, + R::Query: Queryable, + ::Output: Clone, + { + self.groups + .entry(::NAME) + .or_default() + .insert(R::METADATA.name, R::METADATA); + } + } + + let mut visitor = LintRulesVisitor::default(); + biome_js_analyze::visit_registry(&mut visitor); + biome_json_analyze::visit_registry(&mut visitor); + biome_css_analyze::visit_registry(&mut visitor); + + let LintRulesVisitor { groups } = visitor; + + for (group, rules) in groups { + for (_, meta) in rules { + let mut content = vec![]; + parse_documentation( + group, + meta.name, + meta.docs, + &mut content, + !matches!(meta.fix_kind, FixKind::None), + )?; + } + } + + Ok(()) +} + +enum BlockType { + Js(JsFileSource), + Json, + Css, + Foreign(String), +} + +struct CodeBlockTest { + block_type: BlockType, + expect_diagnostic: bool, + ignore: bool, +} + +impl FromStr for CodeBlockTest { + type Err = anyhow::Error; + + fn from_str(input: &str) -> anyhow::Result { + // This is based on the parsing logic for code block languages in `rustdoc`: + // https://github.com/rust-lang/rust/blob/6ac8adad1f7d733b5b97d1df4e7f96e73a46db42/src/librustdoc/html/markdown.rs#L873 + let tokens = input + .split(|c| c == ',' || c == ' ' || c == '\t') + .map(str::trim) + .filter(|token| !token.is_empty()); + + let mut test = CodeBlockTest { + block_type: BlockType::Foreign(String::new()), + expect_diagnostic: false, + ignore: false, + }; + + for token in tokens { + match token { + // Determine the language, using the same list of extensions as `compute_source_type_from_path_or_extension` + "cjs" => { + test.block_type = BlockType::Js( + JsFileSource::js_module().with_module_kind(ModuleKind::Script), + ); + } + "js" | "mjs" | "jsx" => { + test.block_type = BlockType::Js(JsFileSource::jsx()); + } + "ts" | "mts" | "cts" => { + test.block_type = BlockType::Js(JsFileSource::ts()); + } + "tsx" => { + test.block_type = BlockType::Js(JsFileSource::tsx()); + } + "svelte" => { + test.block_type = BlockType::Js(JsFileSource::svelte()); + } + "astro" => { + test.block_type = BlockType::Js(JsFileSource::astro()); + } + "vue" => { + test.block_type = BlockType::Js(JsFileSource::vue()); + } + "json" => { + test.block_type = BlockType::Json; + } + "css" => { + test.block_type = BlockType::Css; + } + // Other attributes + "expect_diagnostic" => { + test.expect_diagnostic = true; + } + "ignore" => { + test.ignore = true; + } + // A catch-all to regard unknown tokens as foreign languages, + // and do not run tests on these code blocks. + _ => { + test.block_type = BlockType::Foreign(token.into()); + test.ignore = true; + } + } + } + + Ok(test) + } +} + +/// Parse and analyze the provided code block, and asserts that it emits +/// exactly zero or one diagnostic depending on the value of `expect_diagnostic`. +/// That diagnostic is then emitted as text into the `content` buffer +fn assert_lint( + group: &'static str, + rule: &'static str, + test: &CodeBlockTest, + code: &str, + has_fix_kind: bool, +) -> anyhow::Result<()> { + let file = format!("{group}/{rule}.js"); + + let mut diagnostic_count = 0; + + let mut all_diagnostics = vec![]; + + let mut write_diagnostic = |code: &str, diag: biome_diagnostics::Error| { + let category = diag.category().map_or("", |code| code.name()); + + all_diagnostics.push(diag); + // Fail the test if the analysis returns more diagnostics than expected + if test.expect_diagnostic { + // Print all diagnostics to help the user + if all_diagnostics.len() > 1 { + let mut console = biome_console::EnvConsole::default(); + for diag in all_diagnostics.iter() { + console.println( + biome_console::LogLevel::Error, + markup! { + {PrintDiagnostic::verbose(diag)} + }, + ); + } + } + + ensure!( + diagnostic_count == 0, + "analysis returned multiple diagnostics, code snippet: \n\n{}", + code + ); + } else { + // Print all diagnostics to help the user + let mut console = biome_console::EnvConsole::default(); + for diag in all_diagnostics.iter() { + console.println( + biome_console::LogLevel::Error, + markup! { + {PrintDiagnostic::verbose(diag)} + }, + ); + } + + bail!(format!( + "analysis returned an unexpected diagnostic, code `snippet:\n\n{:?}\n\n{}", + category, code + )); + } + + diagnostic_count += 1; + Ok(()) + }; + if test.ignore { + return Ok(()); + } + let mut rule_has_code_action = false; + let mut settings = WorkspaceSettings::default(); + let key = settings.insert_project(PathBuf::new()); + settings.register_current_project(key); + match &test.block_type { + BlockType::Js(source_type) => { + // Temporary support for astro, svelte and vue code blocks + let (code, source_type) = match source_type.as_embedding_kind() { + EmbeddingKind::Astro => ( + biome_service::file_handlers::AstroFileHandler::input(code), + JsFileSource::ts(), + ), + EmbeddingKind::Svelte => ( + biome_service::file_handlers::SvelteFileHandler::input(code), + biome_service::file_handlers::SvelteFileHandler::file_source(code), + ), + EmbeddingKind::Vue => ( + biome_service::file_handlers::VueFileHandler::input(code), + biome_service::file_handlers::VueFileHandler::file_source(code), + ), + _ => (code, *source_type), + }; + + let parse = biome_js_parser::parse(code, source_type, JsParserOptions::default()); + + if parse.has_errors() { + for diag in parse.into_diagnostics() { + let error = diag + .with_file_path(file.clone()) + .with_file_source_code(code); + write_diagnostic(code, error)?; + } + } else { + let root = parse.tree(); + + let rule_filter = RuleFilter::Rule(group, rule); + let filter = AnalysisFilter { + enabled_rules: Some(slice::from_ref(&rule_filter)), + ..AnalysisFilter::default() + }; + + let mut options = AnalyzerOptions::default(); + options.configuration.jsx_runtime = Some(JsxRuntime::default()); + let (_, diagnostics) = biome_js_analyze::analyze( + &root, + filter, + &options, + source_type, + None, + |signal| { + if let Some(mut diag) = signal.diagnostic() { + let category = diag.category().expect("linter diagnostic has no code"); + let severity = settings.get_current_settings().expect("project").get_severity_from_rule_code(category).expect( + "If you see this error, it means you need to run cargo codegen-configuration", + ); + + for action in signal.actions() { + if !action.is_suppression() { + rule_has_code_action = true; + diag = diag.add_code_suggestion(action.into()); + } + } + + let error = diag + .with_severity(severity) + .with_file_path(file.clone()) + .with_file_source_code(code); + let res = write_diagnostic(code, error); + + // Abort the analysis on error + if let Err(err) = res { + return ControlFlow::Break(err); + } + } + + ControlFlow::Continue(()) + }, + ); + + // Result is Some(_) if analysis aborted with an error + for diagnostic in diagnostics { + write_diagnostic(code, diagnostic)?; + } + } + + if test.expect_diagnostic && rule_has_code_action && !has_fix_kind { + bail!("The rule '{}' emitted code actions via `action` function, but you didn't mark rule with `fix_kind`.", rule) + } + + if test.expect_diagnostic { + // Fail the test if the analysis didn't emit any diagnostic + ensure!( + diagnostic_count == 1, + "analysis of {}/{} returned no diagnostics.\n code snippet:\n {}", + group, + rule, + code + ); + } + } + BlockType::Json => { + let parse = biome_json_parser::parse_json(code, JsonParserOptions::default()); + + if parse.has_errors() { + for diag in parse.into_diagnostics() { + let error = diag + .with_file_path(file.clone()) + .with_file_source_code(code); + write_diagnostic(code, error)?; + } + } else { + let root = parse.tree(); + + let rule_filter = RuleFilter::Rule(group, rule); + let filter = AnalysisFilter { + enabled_rules: Some(slice::from_ref(&rule_filter)), + ..AnalysisFilter::default() + }; + + let options = AnalyzerOptions::default(); + let (_, diagnostics) = biome_json_analyze::analyze( + &root, + filter, + &options, + |signal| { + if let Some(mut diag) = signal.diagnostic() { + let category = diag.category().expect("linter diagnostic has no code"); + let severity = settings.get_current_settings().expect("project").get_severity_from_rule_code(category).expect( + "If you see this error, it means you need to run cargo codegen-configuration", + ); + + for action in signal.actions() { + if !action.is_suppression() { + rule_has_code_action = true; + diag = diag.add_code_suggestion(action.into()); + } + } + + let error = diag + .with_severity(severity) + .with_file_path(file.clone()) + .with_file_source_code(code); + let res = write_diagnostic(code, error); + + // Abort the analysis on error + if let Err(err) = res { + return ControlFlow::Break(err); + } + } + + ControlFlow::Continue(()) + }, + ); + + // Result is Some(_) if analysis aborted with an error + for diagnostic in diagnostics { + write_diagnostic(code, diagnostic)?; + } + + if test.expect_diagnostic && rule_has_code_action && !has_fix_kind { + bail!("The rule '{}' emitted code actions via `action` function, but you didn't mark rule with `fix_kind`.", rule) + } + } + } + BlockType::Css => { + let parse = biome_css_parser::parse_css(code, CssParserOptions::default()); + + if parse.has_errors() { + for diag in parse.into_diagnostics() { + let error = diag + .with_file_path(file.clone()) + .with_file_source_code(code); + write_diagnostic(code, error)?; + } + } else { + let root = parse.tree(); + + let rule_filter = RuleFilter::Rule(group, rule); + let filter = AnalysisFilter { + enabled_rules: Some(slice::from_ref(&rule_filter)), + ..AnalysisFilter::default() + }; + + let options = AnalyzerOptions::default(); + let (_, diagnostics) = biome_css_analyze::analyze( + &root, + filter, + &options, + |signal| { + if let Some(mut diag) = signal.diagnostic() { + let category = diag.category().expect("linter diagnostic has no code"); + let severity = settings.get_current_settings().expect("project").get_severity_from_rule_code(category).expect( + "If you see this error, it means you need to run cargo codegen-configuration", + ); + + for action in signal.actions() { + if !action.is_suppression() { + rule_has_code_action = true; + diag = diag.add_code_suggestion(action.into()); + } + } + + let error = diag + .with_severity(severity) + .with_file_path(file.clone()) + .with_file_source_code(code); + let res = write_diagnostic(code, error); + + // Abort the analysis on error + if let Err(err) = res { + return ControlFlow::Break(err); + } + } + + ControlFlow::Continue(()) + }, + ); + + // Result is Some(_) if analysis aborted with an error + for diagnostic in diagnostics { + write_diagnostic(code, diagnostic)?; + } + + if test.expect_diagnostic && rule_has_code_action && !has_fix_kind { + bail!("The rule '{}' emitted code actions via `action` function, but you didn't mark rule with `fix_kind`.", rule) + } + } + } + // Foreign code blocks should be already ignored by tests + BlockType::Foreign(block) => { + bail!("Unrecognised block type {}", &block) + } + } + + Ok(()) +} + +/// Parse the documentation fragment for a lint rule (in markdown) and generates +/// the content for the corresponding documentation page +fn parse_documentation( + group: &'static str, + rule: &'static str, + docs: &'static str, + content: &mut Vec, + has_fix_kind: bool, +) -> anyhow::Result<()> { + let parser = Parser::new(docs); + + // Parser events for the first paragraph of documentation in the resulting + // content, used as a short summary of what the rule does in the rules page + let mut summary = Vec::new(); + let mut is_summary = false; + + // Tracks the content of the current code block if it's using a + // language supported for analysis + let mut language = None; + let mut list_order = None; + let mut list_indentation = 0; + + // Tracks the type and metadata of the link + let mut start_link_tag: Option = None; + + for event in parser { + if is_summary { + if matches!(event, Event::End(TagEnd::Paragraph)) { + is_summary = false; + } else { + summary.push(event.clone()); + } + } + + match event { + // CodeBlock-specific handling + Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(meta))) => { + // Track the content of code blocks to pass them through the analyzer + let test = CodeBlockTest::from_str(meta.as_ref())?; + + // Erase the lintdoc-specific attributes in the output by + // re-generating the language ID from the source type + write!(content, "```")?; + if !meta.is_empty() { + match test.block_type { + BlockType::Js(source_type) => match source_type.as_embedding_kind() { + EmbeddingKind::Astro => write!(content, "astro")?, + EmbeddingKind::Svelte => write!(content, "svelte")?, + EmbeddingKind::Vue => write!(content, "vue")?, + _ => { + match source_type.language() { + Language::JavaScript => write!(content, "js")?, + Language::TypeScript { .. } => write!(content, "ts")?, + }; + if source_type.variant().is_jsx() { + write!(content, "x")?; + } + } + }, + BlockType::Json => write!(content, "json")?, + BlockType::Css => write!(content, "css")?, + BlockType::Foreign(ref lang) => write!(content, "{}", lang)?, + } + } + writeln!(content)?; + + language = Some((test, String::new())); + } + + Event::End(TagEnd::CodeBlock) => { + writeln!(content, "```")?; + writeln!(content)?; + + if let Some((test, block)) = language.take() { + if test.expect_diagnostic { + write!( + content, + "
"
+                        )?;
+                    }
+
+                    assert_lint(group, rule, &test, &block, has_fix_kind)?;
+
+                    if test.expect_diagnostic {
+                        writeln!(content, "
")?; + writeln!(content)?; + } + } + } + + Event::Text(text) => { + if let Some((_, block)) = &mut language { + write!(block, "{text}")?; + } + + write!(content, "{text}")?; + } + + // Other markdown events are emitted as-is + Event::Start(Tag::Heading { level, .. }) => { + write!(content, "{} ", "#".repeat(level as usize))?; + } + Event::End(TagEnd::Heading { .. }) => { + writeln!(content)?; + writeln!(content)?; + } + + Event::Start(Tag::Paragraph) => { + if summary.is_empty() && !is_summary { + is_summary = true; + } + } + Event::End(TagEnd::Paragraph) => { + writeln!(content)?; + writeln!(content)?; + } + + Event::Code(text) => { + write!(content, "`{text}`")?; + } + Event::Start(ref link_tag @ Tag::Link { link_type, .. }) => { + start_link_tag = Some(link_tag.clone()); + match link_type { + LinkType::Autolink => { + write!(content, "<")?; + } + LinkType::Inline | LinkType::Reference | LinkType::Shortcut => { + write!(content, "[")?; + } + _ => { + panic!("unimplemented link type") + } + } + } + Event::End(TagEnd::Link) => { + if let Some(Tag::Link { + link_type, + dest_url, + title, + .. + }) = start_link_tag + { + match link_type { + LinkType::Autolink => { + write!(content, ">")?; + } + LinkType::Inline | LinkType::Reference | LinkType::Shortcut => { + write!(content, "]({dest_url}")?; + if !title.is_empty() { + write!(content, " \"{title}\"")?; + } + write!(content, ")")?; + } + _ => { + panic!("unimplemented link type") + } + } + start_link_tag = None; + } else { + panic!("missing start link tag"); + } + } + + Event::SoftBreak => { + writeln!(content)?; + } + + Event::HardBreak => { + writeln!(content, "
")?; + } + + Event::Start(Tag::List(num)) => { + list_indentation += 1; + if let Some(num) = num { + list_order = Some(num); + } + if list_indentation > 1 { + writeln!(content)?; + } + } + + Event::End(TagEnd::List(_)) => { + list_order = None; + list_indentation -= 1; + writeln!(content)?; + } + Event::Start(Tag::Item) => { + write!(content, "{}", " ".repeat(list_indentation - 1))?; + if let Some(num) = list_order { + write!(content, "{num}. ")?; + } else { + write!(content, "- ")?; + } + } + + Event::End(TagEnd::Item) => { + list_order = list_order.map(|item| item + 1); + writeln!(content)?; + } + + Event::Start(Tag::Strong) => { + write!(content, "**")?; + } + + Event::End(TagEnd::Strong) => { + write!(content, "**")?; + } + + Event::Start(Tag::Emphasis) => { + write!(content, "_")?; + } + + Event::End(TagEnd::Emphasis) => { + write!(content, "_")?; + } + + Event::Start(Tag::Strikethrough) => { + write!(content, "~")?; + } + + Event::End(TagEnd::Strikethrough) => { + write!(content, "~")?; + } + + Event::Start(Tag::BlockQuote) => { + write!(content, ">")?; + } + + Event::End(TagEnd::BlockQuote) => { + writeln!(content)?; + } + + _ => { + // TODO: Implement remaining events as required + bail!("unimplemented event {event:?}") + } + } + } + + Ok(()) +} diff --git a/xtask/rules_check/src/main.rs b/xtask/rules_check/src/main.rs new file mode 100644 index 000000000000..1de34236f479 --- /dev/null +++ b/xtask/rules_check/src/main.rs @@ -0,0 +1,5 @@ +use rules_check::check_rules; + +fn main() -> anyhow::Result<()> { + check_rules() +}