Browse Source

Merge pull request #1215 from eventualbuddha/feat/check/rules-of-hooks

feat(check): adds `dx check`
Jonathan Kelley 1 year ago
parent
commit
6751d5941b

+ 5 - 0
Cargo.toml

@@ -17,6 +17,7 @@ members = [
     "packages/fermi",
     "packages/liveview",
     "packages/autofmt",
+    "packages/check",
     "packages/rsx",
     "packages/dioxus-tui",
     "packages/rink",
@@ -63,6 +64,7 @@ dioxus-interpreter-js = { path = "packages/interpreter" }
 fermi = { path = "packages/fermi" }
 dioxus-liveview = { path = "packages/liveview" }
 dioxus-autofmt = { path = "packages/autofmt" }
+dioxus-check = { path = "packages/check" }
 dioxus-rsx = { path = "packages/rsx" }
 dioxus-tui = { path = "packages/dioxus-tui" }
 rink = { path = "packages/rink" }
@@ -82,6 +84,9 @@ rustc-hash = "1.1.0"
 wasm-bindgen = "0.2.87"
 html_parser = "0.7.0"
 thiserror = "1.0.40"
+prettyplease = { package = "prettier-please", version = "0.2", features = [
+    "verbatim",
+] }
 
 # This is a "virtual package"
 # It is not meant to be published, but is used so "cargo run --example XYZ" works properly

+ 1 - 3
packages/autofmt/Cargo.toml

@@ -16,9 +16,7 @@ proc-macro2 = { version = "1.0.6", features = ["span-locations"] }
 quote = "1.0"
 syn = { version = "2.0", features = ["full", "extra-traits", "visit"] }
 serde = { version = "1.0.136", features = ["derive"] }
-prettyplease = { package = "prettier-please", version = "0.2", features = [
-    "verbatim",
-] }
+prettyplease = { workspace = true }
 
 [dev-dependencies]
 pretty_assertions = "1.2.1"

+ 24 - 0
packages/check/Cargo.toml

@@ -0,0 +1,24 @@
+[package]
+name = "dioxus-check"
+version = "0.1.0"
+edition = "2021"
+authors = ["Dioxus Labs"]
+description = "Checks Dioxus RSX files for issues"
+license = "MIT/Apache-2.0"
+repository = "https://github.com/DioxusLabs/dioxus/"
+homepage = "https://dioxuslabs.com"
+keywords = ["dom", "ui", "gui", "react"]
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[dependencies]
+dioxus-rsx = { workspace = true }
+proc-macro2 = { version = "1.0.6", features = ["span-locations"] }
+quote = "1.0"
+syn = { version = "1.0.11", features = ["full", "extra-traits", "visit"] }
+serde = { version = "1.0.136", features = ["derive"] }
+owo-colors = { version = "3.5.0", features = ["supports-colors"] }
+prettyplease = { workspace = true }
+
+[dev-dependencies]
+indoc = "2.0.3"
+pretty_assertions = "1.2.1"

+ 43 - 0
packages/check/README.md

@@ -0,0 +1,43 @@
+# dioxus-check
+
+
+[![Crates.io][crates-badge]][crates-url]
+[![MIT licensed][mit-badge]][mit-url]
+[![Build Status][actions-badge]][actions-url]
+[![Discord chat][discord-badge]][discord-url]
+
+[crates-badge]: https://img.shields.io/crates/v/dioxus-autofmt.svg
+[crates-url]: https://crates.io/crates/dioxus-autofmt
+
+[mit-badge]: https://img.shields.io/badge/license-MIT-blue.svg
+[mit-url]: https://github.com/dioxuslabs/dioxus/blob/master/LICENSE
+
+[actions-badge]: https://github.com/dioxuslabs/dioxus/actions/workflows/main.yml/badge.svg
+[actions-url]: https://github.com/dioxuslabs/dioxus/actions?query=workflow%3ACI+branch%3Amaster
+
+[discord-badge]: https://img.shields.io/discord/899851952891002890.svg?logo=discord&style=flat-square
+[discord-url]: https://discord.gg/XgGxMSkvUM
+
+[Website](https://dioxuslabs.com) |
+[Guides](https://dioxuslabs.com/docs/0.3/guide/en/) |
+[API Docs](https://docs.rs/dioxus-autofmt/latest/dioxus_autofmt) |
+[Chat](https://discord.gg/XgGxMSkvUM)
+
+
+## Overview
+
+`dioxus-check` analyzes Dioxus source code and reports errors and warnings. Primarily, it enforces the [Rules of Hooks](https://dioxuslabs.com/docs/0.3/guide/en/interactivity/hooks.html#no-hooks-in-conditionals).
+
+## Contributing
+
+- Report issues on our [issue tracker](https://github.com/dioxuslabs/dioxus/issues).
+- Join the discord and ask questions!
+
+## License
+This project is licensed under the [MIT license].
+
+[mit license]: https://github.com/DioxusLabs/dioxus/blob/master/LICENSE-MIT
+
+Unless you explicitly state otherwise, any contribution intentionally submitted
+for inclusion in Dioxus by you shall be licensed as MIT without any additional
+terms or conditions.

+ 639 - 0
packages/check/src/check.rs

@@ -0,0 +1,639 @@
+use std::path::PathBuf;
+
+use syn::{spanned::Spanned, visit::Visit, Pat};
+
+use crate::{
+    issues::{Issue, IssueReport},
+    metadata::{
+        AnyLoopInfo, ClosureInfo, ComponentInfo, ConditionalInfo, FnInfo, ForInfo, HookInfo,
+        IfInfo, LoopInfo, MatchInfo, Span, WhileInfo,
+    },
+};
+
+struct VisitHooks {
+    issues: Vec<Issue>,
+    context: Vec<Node>,
+}
+
+impl VisitHooks {
+    const fn new() -> Self {
+        Self {
+            issues: vec![],
+            context: vec![],
+        }
+    }
+}
+
+/// Checks a Dioxus file for issues.
+pub fn check_file(path: PathBuf, file_content: &str) -> IssueReport {
+    let file = syn::parse_file(file_content).unwrap();
+    let mut visit_hooks = VisitHooks::new();
+    visit_hooks.visit_file(&file);
+    IssueReport::new(
+        path,
+        std::env::current_dir().unwrap_or_default(),
+        file_content.to_string(),
+        visit_hooks.issues,
+    )
+}
+
+#[derive(Debug, Clone)]
+enum Node {
+    If(IfInfo),
+    Match(MatchInfo),
+    For(ForInfo),
+    While(WhileInfo),
+    Loop(LoopInfo),
+    Closure(ClosureInfo),
+    ComponentFn(ComponentInfo),
+    HookFn(HookInfo),
+    OtherFn(FnInfo),
+}
+
+fn returns_element(ty: &syn::ReturnType) -> bool {
+    match ty {
+        syn::ReturnType::Default => false,
+        syn::ReturnType::Type(_, ref ty) => {
+            if let syn::Type::Path(ref path) = **ty {
+                if let Some(segment) = path.path.segments.last() {
+                    if segment.ident == "Element" {
+                        return true;
+                    }
+                }
+            }
+            false
+        }
+    }
+}
+
+fn is_hook_ident(ident: &syn::Ident) -> bool {
+    ident.to_string().starts_with("use_")
+}
+
+fn is_component_fn(item_fn: &syn::ItemFn) -> bool {
+    returns_element(&item_fn.sig.output)
+}
+
+fn get_closure_hook_body(local: &syn::Local) -> Option<&syn::Expr> {
+    if let Pat::Ident(ident) = &local.pat {
+        if is_hook_ident(&ident.ident) {
+            if let Some((_, expr)) = &local.init {
+                if let syn::Expr::Closure(closure) = &**expr {
+                    return Some(&closure.body);
+                }
+            }
+        }
+    }
+
+    None
+}
+
+fn fn_name_and_name_span(item_fn: &syn::ItemFn) -> (String, Span) {
+    let name = item_fn.sig.ident.to_string();
+    let name_span = item_fn.sig.ident.span().into();
+    (name, name_span)
+}
+
+impl<'ast> syn::visit::Visit<'ast> for VisitHooks {
+    fn visit_expr_call(&mut self, i: &'ast syn::ExprCall) {
+        if let syn::Expr::Path(ref path) = *i.func {
+            if let Some(segment) = path.path.segments.last() {
+                if is_hook_ident(&segment.ident) {
+                    let hook_info = HookInfo::new(
+                        i.span().into(),
+                        segment.ident.span().into(),
+                        segment.ident.to_string(),
+                    );
+                    let mut container_fn: Option<Node> = None;
+                    for node in self.context.iter().rev() {
+                        match node {
+                            Node::If(if_info) => {
+                                let issue = Issue::HookInsideConditional(
+                                    hook_info.clone(),
+                                    ConditionalInfo::If(if_info.clone()),
+                                );
+                                self.issues.push(issue);
+                            }
+                            Node::Match(match_info) => {
+                                let issue = Issue::HookInsideConditional(
+                                    hook_info.clone(),
+                                    ConditionalInfo::Match(match_info.clone()),
+                                );
+                                self.issues.push(issue);
+                            }
+                            Node::For(for_info) => {
+                                let issue = Issue::HookInsideLoop(
+                                    hook_info.clone(),
+                                    AnyLoopInfo::For(for_info.clone()),
+                                );
+                                self.issues.push(issue);
+                            }
+                            Node::While(while_info) => {
+                                let issue = Issue::HookInsideLoop(
+                                    hook_info.clone(),
+                                    AnyLoopInfo::While(while_info.clone()),
+                                );
+                                self.issues.push(issue);
+                            }
+                            Node::Loop(loop_info) => {
+                                let issue = Issue::HookInsideLoop(
+                                    hook_info.clone(),
+                                    AnyLoopInfo::Loop(loop_info.clone()),
+                                );
+                                self.issues.push(issue);
+                            }
+                            Node::Closure(closure_info) => {
+                                let issue = Issue::HookInsideClosure(
+                                    hook_info.clone(),
+                                    closure_info.clone(),
+                                );
+                                self.issues.push(issue);
+                            }
+                            Node::ComponentFn(_) | Node::HookFn(_) | Node::OtherFn(_) => {
+                                container_fn = Some(node.clone());
+                                break;
+                            }
+                        }
+                    }
+
+                    if let Some(Node::OtherFn(_)) = container_fn {
+                        let issue = Issue::HookOutsideComponent(hook_info);
+                        self.issues.push(issue);
+                    }
+                }
+            }
+        }
+    }
+
+    fn visit_item_fn(&mut self, i: &'ast syn::ItemFn) {
+        let (name, name_span) = fn_name_and_name_span(i);
+        if is_component_fn(i) {
+            self.context.push(Node::ComponentFn(ComponentInfo::new(
+                i.span().into(),
+                name,
+                name_span,
+            )));
+        } else if is_hook_ident(&i.sig.ident) {
+            self.context.push(Node::HookFn(HookInfo::new(
+                i.span().into(),
+                i.sig.ident.span().into(),
+                name,
+            )));
+        } else {
+            self.context
+                .push(Node::OtherFn(FnInfo::new(i.span().into(), name, name_span)));
+        }
+        syn::visit::visit_item_fn(self, i);
+        self.context.pop();
+    }
+
+    fn visit_local(&mut self, i: &'ast syn::Local) {
+        if let Some(body) = get_closure_hook_body(i) {
+            // if the closure is a hook, we only visit the body of the closure.
+            // this prevents adding a ClosureInfo node to the context
+            syn::visit::visit_expr(self, body);
+        } else {
+            // otherwise visit the whole local
+            syn::visit::visit_local(self, i);
+        }
+    }
+
+    fn visit_expr_if(&mut self, i: &'ast syn::ExprIf) {
+        self.context.push(Node::If(IfInfo::new(
+            i.span().into(),
+            i.if_token
+                .span()
+                .join(i.cond.span())
+                .unwrap_or_else(|| i.span())
+                .into(),
+        )));
+        syn::visit::visit_expr_if(self, i);
+        self.context.pop();
+    }
+
+    fn visit_expr_match(&mut self, i: &'ast syn::ExprMatch) {
+        self.context.push(Node::Match(MatchInfo::new(
+            i.span().into(),
+            i.match_token
+                .span()
+                .join(i.expr.span())
+                .unwrap_or_else(|| i.span())
+                .into(),
+        )));
+        syn::visit::visit_expr_match(self, i);
+        self.context.pop();
+    }
+
+    fn visit_expr_for_loop(&mut self, i: &'ast syn::ExprForLoop) {
+        self.context.push(Node::For(ForInfo::new(
+            i.span().into(),
+            i.for_token
+                .span()
+                .join(i.expr.span())
+                .unwrap_or_else(|| i.span())
+                .into(),
+        )));
+        syn::visit::visit_expr_for_loop(self, i);
+        self.context.pop();
+    }
+
+    fn visit_expr_while(&mut self, i: &'ast syn::ExprWhile) {
+        self.context.push(Node::While(WhileInfo::new(
+            i.span().into(),
+            i.while_token
+                .span()
+                .join(i.cond.span())
+                .unwrap_or_else(|| i.span())
+                .into(),
+        )));
+        syn::visit::visit_expr_while(self, i);
+        self.context.pop();
+    }
+
+    fn visit_expr_loop(&mut self, i: &'ast syn::ExprLoop) {
+        self.context
+            .push(Node::Loop(LoopInfo::new(i.span().into())));
+        syn::visit::visit_expr_loop(self, i);
+        self.context.pop();
+    }
+
+    fn visit_expr_closure(&mut self, i: &'ast syn::ExprClosure) {
+        self.context
+            .push(Node::Closure(ClosureInfo::new(i.span().into())));
+        syn::visit::visit_expr_closure(self, i);
+        self.context.pop();
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use crate::metadata::{
+        AnyLoopInfo, ClosureInfo, ConditionalInfo, ForInfo, HookInfo, IfInfo, LineColumn, LoopInfo,
+        MatchInfo, Span, WhileInfo,
+    };
+    use indoc::indoc;
+    use pretty_assertions::assert_eq;
+
+    use super::*;
+
+    #[test]
+    fn test_no_hooks() {
+        let contents = indoc! {r#"
+            fn App(cx: Scope) -> Element {
+                rsx! {
+                    p { "Hello World" }
+                }
+            }
+        "#};
+
+        let report = check_file("app.rs".into(), contents);
+
+        assert_eq!(report.issues, vec![]);
+    }
+
+    #[test]
+    fn test_hook_correctly_used_inside_component() {
+        let contents = indoc! {r#"
+            fn App(cx: Scope) -> Element {
+                let count = use_state(cx, || 0);
+                rsx! {
+                    p { "Hello World: {count}" }
+                }
+            }
+        "#};
+
+        let report = check_file("app.rs".into(), contents);
+
+        assert_eq!(report.issues, vec![]);
+    }
+
+    #[test]
+    fn test_hook_correctly_used_inside_hook_fn() {
+        let contents = indoc! {r#"
+            fn use_thing(cx: Scope) -> UseState<i32> {
+                use_state(cx, || 0)
+            }
+        "#};
+
+        let report = check_file("use_thing.rs".into(), contents);
+
+        assert_eq!(report.issues, vec![]);
+    }
+
+    #[test]
+    fn test_hook_correctly_used_inside_hook_closure() {
+        let contents = indoc! {r#"
+            fn App(cx: Scope) -> Element {
+                let use_thing = || {
+                    use_state(cx, || 0)
+                };
+                let count = use_thing();
+                rsx! {
+                    p { "Hello World: {count}" }
+                }
+            }
+        "#};
+
+        let report = check_file("app.rs".into(), contents);
+
+        assert_eq!(report.issues, vec![]);
+    }
+
+    #[test]
+    fn test_conditional_hook_if() {
+        let contents = indoc! {r#"
+            fn App(cx: Scope) -> Element {
+                if you_are_happy && you_know_it {
+                    let something = use_state(cx, || "hands");
+                    println!("clap your {something}")
+                }
+            }
+        "#};
+
+        let report = check_file("app.rs".into(), contents);
+
+        assert_eq!(
+            report.issues,
+            vec![Issue::HookInsideConditional(
+                HookInfo::new(
+                    Span::new_from_str(
+                        r#"use_state(cx, || "hands")"#,
+                        LineColumn { line: 3, column: 24 },
+                    ),
+                    Span::new_from_str(
+                        r#"use_state"#,
+                        LineColumn { line: 3, column: 24 },
+                    ),
+                    "use_state".to_string()
+                ),
+                ConditionalInfo::If(IfInfo::new(
+                    Span::new_from_str(
+                        "if you_are_happy && you_know_it {\n        let something = use_state(cx, || \"hands\");\n        println!(\"clap your {something}\")\n    }",
+                        LineColumn { line: 2, column: 4 },
+                    ),
+                    Span::new_from_str(
+                        "if you_are_happy && you_know_it",
+                        LineColumn { line: 2, column: 4 }
+                    )
+                ))
+            )],
+        );
+    }
+
+    #[test]
+    fn test_conditional_hook_match() {
+        let contents = indoc! {r#"
+            fn App(cx: Scope) -> Element {
+                match you_are_happy && you_know_it {
+                    true => {
+                        let something = use_state(cx, || "hands");
+                        println!("clap your {something}")
+                    }
+                    false => {}
+                }
+            }
+        "#};
+
+        let report = check_file("app.rs".into(), contents);
+
+        assert_eq!(
+            report.issues,
+            vec![Issue::HookInsideConditional(
+                HookInfo::new(
+                    Span::new_from_str(r#"use_state(cx, || "hands")"#, LineColumn { line: 4, column: 28 }),
+                    Span::new_from_str(r#"use_state"#, LineColumn { line: 4, column: 28 }),
+                    "use_state".to_string()
+                ),
+                ConditionalInfo::Match(MatchInfo::new(
+                    Span::new_from_str(
+                        "match you_are_happy && you_know_it {\n        true => {\n            let something = use_state(cx, || \"hands\");\n            println!(\"clap your {something}\")\n        }\n        false => {}\n    }",
+                        LineColumn { line: 2, column: 4 },
+                    ),
+                    Span::new_from_str("match you_are_happy && you_know_it", LineColumn { line: 2, column: 4 })
+                ))
+            )]
+        );
+    }
+
+    #[test]
+    fn test_for_loop_hook() {
+        let contents = indoc! {r#"
+            fn App(cx: Scope) -> Element {
+                for _name in &names {
+                    let is_selected = use_state(cx, || false);
+                    println!("selected: {is_selected}");
+                }
+            }
+        "#};
+
+        let report = check_file("app.rs".into(), contents);
+
+        assert_eq!(
+            report.issues,
+            vec![Issue::HookInsideLoop(
+                HookInfo::new(
+                    Span::new_from_str(
+                        "use_state(cx, || false)",
+                        LineColumn { line: 3, column: 26 },
+                    ),
+                    Span::new_from_str(
+                        "use_state",
+                        LineColumn { line: 3, column: 26 },
+                    ),
+                    "use_state".to_string()
+                ),
+                AnyLoopInfo::For(ForInfo::new(
+                    Span::new_from_str(
+                        "for _name in &names {\n        let is_selected = use_state(cx, || false);\n        println!(\"selected: {is_selected}\");\n    }",
+                        LineColumn { line: 2, column: 4 },
+                    ),
+                    Span::new_from_str(
+                        "for _name in &names",
+                        LineColumn { line: 2, column: 4 },
+                    )
+                ))
+            )]
+        );
+    }
+
+    #[test]
+    fn test_while_loop_hook() {
+        let contents = indoc! {r#"
+            fn App(cx: Scope) -> Element {
+                while true {
+                    let something = use_state(cx, || "hands");
+                    println!("clap your {something}")
+                }
+            }
+        "#};
+
+        let report = check_file("app.rs".into(), contents);
+
+        assert_eq!(
+            report.issues,
+            vec![Issue::HookInsideLoop(
+                HookInfo::new(
+                    Span::new_from_str(
+                        r#"use_state(cx, || "hands")"#,
+                        LineColumn { line: 3, column: 24 },
+                    ),
+                    Span::new_from_str(
+                        "use_state",
+                        LineColumn { line: 3, column: 24 },
+                    ),
+                    "use_state".to_string()
+                ),
+                AnyLoopInfo::While(WhileInfo::new(
+                    Span::new_from_str(
+                        "while true {\n        let something = use_state(cx, || \"hands\");\n        println!(\"clap your {something}\")\n    }",
+                        LineColumn { line: 2, column: 4 },
+                    ),
+                    Span::new_from_str(
+                        "while true",
+                        LineColumn { line: 2, column: 4 },
+                    )
+                ))
+            )],
+        );
+    }
+
+    #[test]
+    fn test_loop_hook() {
+        let contents = indoc! {r#"
+            fn App(cx: Scope) -> Element {
+                loop {
+                    let something = use_state(cx, || "hands");
+                    println!("clap your {something}")
+                }
+            }
+        "#};
+
+        let report = check_file("app.rs".into(), contents);
+
+        assert_eq!(
+            report.issues,
+            vec![Issue::HookInsideLoop(
+                HookInfo::new(
+                    Span::new_from_str(
+                        r#"use_state(cx, || "hands")"#,
+                        LineColumn { line: 3, column: 24 },
+                    ),
+                    Span::new_from_str(
+                        "use_state",
+                        LineColumn { line: 3, column: 24 },
+                    ),
+                    "use_state".to_string()
+                ),
+                AnyLoopInfo::Loop(LoopInfo::new(Span::new_from_str(
+                    "loop {\n        let something = use_state(cx, || \"hands\");\n        println!(\"clap your {something}\")\n    }",
+                    LineColumn { line: 2, column: 4 },
+                )))
+            )],
+        );
+    }
+
+    #[test]
+    fn test_conditional_okay() {
+        let contents = indoc! {r#"
+            fn App(cx: Scope) -> Element {
+                let something = use_state(cx, || "hands");
+                if you_are_happy && you_know_it {
+                    println!("clap your {something}")
+                }
+            }
+        "#};
+
+        let report = check_file("app.rs".into(), contents);
+
+        assert_eq!(report.issues, vec![]);
+    }
+
+    #[test]
+    fn test_closure_hook() {
+        let contents = indoc! {r#"
+            fn App(cx: Scope) -> Element {
+                let _a = || {
+                    let b = use_state(cx, || 0);
+                    b.get()
+                };
+            }
+        "#};
+
+        let report = check_file("app.rs".into(), contents);
+
+        assert_eq!(
+            report.issues,
+            vec![Issue::HookInsideClosure(
+                HookInfo::new(
+                    Span::new_from_str(
+                        "use_state(cx, || 0)",
+                        LineColumn {
+                            line: 3,
+                            column: 16
+                        },
+                    ),
+                    Span::new_from_str(
+                        "use_state",
+                        LineColumn {
+                            line: 3,
+                            column: 16
+                        },
+                    ),
+                    "use_state".to_string()
+                ),
+                ClosureInfo::new(Span::new_from_str(
+                    "|| {\n        let b = use_state(cx, || 0);\n        b.get()\n    }",
+                    LineColumn {
+                        line: 2,
+                        column: 13
+                    },
+                ))
+            )]
+        );
+    }
+
+    #[test]
+    fn test_hook_outside_component() {
+        let contents = indoc! {r#"
+            fn not_component_or_hook(cx: Scope) {
+                let _a = use_state(cx, || 0);
+            }
+        "#};
+
+        let report = check_file("app.rs".into(), contents);
+
+        assert_eq!(
+            report.issues,
+            vec![Issue::HookOutsideComponent(HookInfo::new(
+                Span::new_from_str(
+                    "use_state(cx, || 0)",
+                    LineColumn {
+                        line: 2,
+                        column: 13
+                    }
+                ),
+                Span::new_from_str(
+                    "use_state",
+                    LineColumn {
+                        line: 2,
+                        column: 13
+                    },
+                ),
+                "use_state".to_string()
+            ))]
+        );
+    }
+
+    #[test]
+    fn test_hook_inside_hook() {
+        let contents = indoc! {r#"
+            fn use_thing(cx: Scope) {
+                let _a = use_state(cx, || 0);
+            }
+        "#};
+
+        let report = check_file("app.rs".into(), contents);
+
+        assert_eq!(report.issues, vec![]);
+    }
+}

+ 427 - 0
packages/check/src/issues.rs

@@ -0,0 +1,427 @@
+use owo_colors::{
+    colors::{css::LightBlue, BrightRed},
+    OwoColorize, Stream,
+};
+use std::{
+    fmt::Display,
+    path::{Path, PathBuf},
+};
+
+use crate::metadata::{
+    AnyLoopInfo, ClosureInfo, ConditionalInfo, ForInfo, HookInfo, IfInfo, MatchInfo, WhileInfo,
+};
+
+/// The result of checking a Dioxus file for issues.
+pub struct IssueReport {
+    pub path: PathBuf,
+    pub crate_root: PathBuf,
+    pub file_content: String,
+    pub issues: Vec<Issue>,
+}
+
+impl IssueReport {
+    pub fn new<S: ToString>(
+        path: PathBuf,
+        crate_root: PathBuf,
+        file_content: S,
+        issues: Vec<Issue>,
+    ) -> Self {
+        Self {
+            path,
+            crate_root,
+            file_content: file_content.to_string(),
+            issues,
+        }
+    }
+}
+
+fn lightblue(text: &str) -> String {
+    text.if_supports_color(Stream::Stderr, |text| text.fg::<LightBlue>())
+        .to_string()
+}
+
+fn brightred(text: &str) -> String {
+    text.if_supports_color(Stream::Stderr, |text| text.fg::<BrightRed>())
+        .to_string()
+}
+
+fn bold(text: &str) -> String {
+    text.if_supports_color(Stream::Stderr, |text| text.bold())
+        .to_string()
+}
+
+impl Display for IssueReport {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        let relative_file = Path::new(&self.path)
+            .strip_prefix(&self.crate_root)
+            .unwrap_or(Path::new(&self.path))
+            .display();
+
+        let pipe_char = lightblue("|");
+
+        for (i, issue) in self.issues.iter().enumerate() {
+            let hook_info = issue.hook_info();
+            let hook_span = hook_info.span;
+            let hook_name_span = hook_info.name_span;
+            let error_line = format!("{}: {}", brightred("error"), issue);
+            writeln!(f, "{}", bold(&error_line))?;
+            writeln!(
+                f,
+                "  {} {}:{}:{}",
+                lightblue("-->"),
+                relative_file,
+                hook_span.start.line,
+                hook_span.start.column + 1
+            )?;
+            let max_line_num_len = hook_span.end.line.to_string().len();
+            writeln!(f, "{:>max_line_num_len$} {}", "", pipe_char)?;
+            for (i, line) in self.file_content.lines().enumerate() {
+                let line_num = i + 1;
+                if line_num >= hook_span.start.line && line_num <= hook_span.end.line {
+                    writeln!(
+                        f,
+                        "{:>max_line_num_len$} {} {}",
+                        lightblue(&line_num.to_string()),
+                        pipe_char,
+                        line,
+                    )?;
+                    if line_num == hook_span.start.line {
+                        let mut caret = String::new();
+                        for _ in 0..hook_name_span.start.column {
+                            caret.push(' ');
+                        }
+                        for _ in hook_name_span.start.column..hook_name_span.end.column {
+                            caret.push('^');
+                        }
+                        writeln!(
+                            f,
+                            "{:>max_line_num_len$} {} {}",
+                            "",
+                            pipe_char,
+                            brightred(&caret),
+                        )?;
+                    }
+                }
+            }
+
+            let note_text_prefix = format!(
+                "{:>max_line_num_len$} {}\n{:>max_line_num_len$} {} note:",
+                "",
+                pipe_char,
+                "",
+                lightblue("=")
+            );
+
+            match issue {
+                Issue::HookInsideConditional(
+                    _,
+                    ConditionalInfo::If(IfInfo { span: _, head_span }),
+                )
+                | Issue::HookInsideConditional(
+                    _,
+                    ConditionalInfo::Match(MatchInfo { span: _, head_span }),
+                ) => {
+                    if let Some(source_text) = &head_span.source_text {
+                        writeln!(
+                            f,
+                            "{} `{} {{ … }}` is the conditional",
+                            note_text_prefix, source_text,
+                        )?;
+                    }
+                }
+                Issue::HookInsideLoop(_, AnyLoopInfo::For(ForInfo { span: _, head_span }))
+                | Issue::HookInsideLoop(_, AnyLoopInfo::While(WhileInfo { span: _, head_span })) => {
+                    if let Some(source_text) = &head_span.source_text {
+                        writeln!(
+                            f,
+                            "{} `{} {{ … }}` is the loop",
+                            note_text_prefix, source_text,
+                        )?;
+                    }
+                }
+                Issue::HookInsideLoop(_, AnyLoopInfo::Loop(_)) => {
+                    writeln!(f, "{} `loop {{ … }}` is the loop", note_text_prefix,)?;
+                }
+                Issue::HookOutsideComponent(_) | Issue::HookInsideClosure(_, _) => {}
+            }
+
+            if i < self.issues.len() - 1 {
+                writeln!(f)?;
+            }
+        }
+
+        Ok(())
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+#[allow(clippy::enum_variant_names)] // we'll add non-hook ones in the future
+/// Issues that might be found via static analysis of a Dioxus file.
+pub enum Issue {
+    /// https://dioxuslabs.com/docs/0.3/guide/en/interactivity/hooks.html#no-hooks-in-conditionals
+    HookInsideConditional(HookInfo, ConditionalInfo),
+    /// https://dioxuslabs.com/docs/0.3/guide/en/interactivity/hooks.html#no-hooks-in-loops
+    HookInsideLoop(HookInfo, AnyLoopInfo),
+    /// https://dioxuslabs.com/docs/0.3/guide/en/interactivity/hooks.html#no-hooks-in-closures
+    HookInsideClosure(HookInfo, ClosureInfo),
+    HookOutsideComponent(HookInfo),
+}
+
+impl Issue {
+    pub fn hook_info(&self) -> HookInfo {
+        match self {
+            Issue::HookInsideConditional(hook_info, _)
+            | Issue::HookInsideLoop(hook_info, _)
+            | Issue::HookInsideClosure(hook_info, _)
+            | Issue::HookOutsideComponent(hook_info) => hook_info.clone(),
+        }
+    }
+}
+
+impl std::fmt::Display for Issue {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            Issue::HookInsideConditional(hook_info, conditional_info) => {
+                write!(
+                    f,
+                    "hook called conditionally: `{}` (inside `{}`)",
+                    hook_info.name,
+                    match conditional_info {
+                        ConditionalInfo::If(_) => "if",
+                        ConditionalInfo::Match(_) => "match",
+                    }
+                )
+            }
+            Issue::HookInsideLoop(hook_info, loop_info) => {
+                write!(
+                    f,
+                    "hook called in a loop: `{}` (inside {})",
+                    hook_info.name,
+                    match loop_info {
+                        AnyLoopInfo::For(_) => "`for` loop",
+                        AnyLoopInfo::While(_) => "`while` loop",
+                        AnyLoopInfo::Loop(_) => "`loop`",
+                    }
+                )
+            }
+            Issue::HookInsideClosure(hook_info, _) => {
+                write!(f, "hook called in a closure: `{}`", hook_info.name)
+            }
+            Issue::HookOutsideComponent(hook_info) => {
+                write!(
+                    f,
+                    "hook called outside component or hook: `{}`",
+                    hook_info.name
+                )
+            }
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use crate::check_file;
+    use indoc::indoc;
+    use pretty_assertions::assert_eq;
+
+    #[test]
+    fn test_issue_report_display_conditional_if() {
+        owo_colors::set_override(false);
+        let issue_report = check_file(
+            "src/main.rs".into(),
+            indoc! {r#"
+                fn App(cx: Scope) -> Element {
+                    if you_are_happy && you_know_it {
+                        let something = use_state(cx, || "hands");
+                        println!("clap your {something}")
+                    }
+                }
+            "#},
+        );
+
+        let expected = indoc! {r#"
+            error: hook called conditionally: `use_state` (inside `if`)
+              --> src/main.rs:3:25
+              |
+            3 |         let something = use_state(cx, || "hands");
+              |                         ^^^^^^^^^
+              |
+              = note: `if you_are_happy && you_know_it { … }` is the conditional
+        "#};
+
+        assert_eq!(expected, issue_report.to_string());
+    }
+
+    #[test]
+    fn test_issue_report_display_conditional_match() {
+        owo_colors::set_override(false);
+        let issue_report = check_file(
+            "src/main.rs".into(),
+            indoc! {r#"
+                fn App(cx: Scope) -> Element {
+                    match you_are_happy && you_know_it {
+                        true => {
+                            let something = use_state(cx, || "hands");
+                            println!("clap your {something}")
+                        }
+                        _ => {}
+                    }
+                }
+            "#},
+        );
+
+        let expected = indoc! {r#"
+            error: hook called conditionally: `use_state` (inside `match`)
+              --> src/main.rs:4:29
+              |
+            4 |             let something = use_state(cx, || "hands");
+              |                             ^^^^^^^^^
+              |
+              = note: `match you_are_happy && you_know_it { … }` is the conditional
+        "#};
+
+        assert_eq!(expected, issue_report.to_string());
+    }
+
+    #[test]
+    fn test_issue_report_display_for_loop() {
+        owo_colors::set_override(false);
+        let issue_report = check_file(
+            "src/main.rs".into(),
+            indoc! {r#"
+                fn App(cx: Scope) -> Element {
+                    for i in 0..10 {
+                        let something = use_state(cx, || "hands");
+                        println!("clap your {something}")
+                    }
+                }
+            "#},
+        );
+
+        let expected = indoc! {r#"
+            error: hook called in a loop: `use_state` (inside `for` loop)
+              --> src/main.rs:3:25
+              |
+            3 |         let something = use_state(cx, || "hands");
+              |                         ^^^^^^^^^
+              |
+              = note: `for i in 0..10 { … }` is the loop
+        "#};
+
+        assert_eq!(expected, issue_report.to_string());
+    }
+
+    #[test]
+    fn test_issue_report_display_while_loop() {
+        owo_colors::set_override(false);
+        let issue_report = check_file(
+            "src/main.rs".into(),
+            indoc! {r#"
+                fn App(cx: Scope) -> Element {
+                    while check_thing() {
+                        let something = use_state(cx, || "hands");
+                        println!("clap your {something}")
+                    }
+                }
+            "#},
+        );
+
+        let expected = indoc! {r#"
+            error: hook called in a loop: `use_state` (inside `while` loop)
+              --> src/main.rs:3:25
+              |
+            3 |         let something = use_state(cx, || "hands");
+              |                         ^^^^^^^^^
+              |
+              = note: `while check_thing() { … }` is the loop
+        "#};
+
+        assert_eq!(expected, issue_report.to_string());
+    }
+
+    #[test]
+    fn test_issue_report_display_loop() {
+        owo_colors::set_override(false);
+        let issue_report = check_file(
+            "src/main.rs".into(),
+            indoc! {r#"
+                fn App(cx: Scope) -> Element {
+                    loop {
+                        let something = use_state(cx, || "hands");
+                        println!("clap your {something}")
+                    }
+                }
+            "#},
+        );
+
+        let expected = indoc! {r#"
+            error: hook called in a loop: `use_state` (inside `loop`)
+              --> src/main.rs:3:25
+              |
+            3 |         let something = use_state(cx, || "hands");
+              |                         ^^^^^^^^^
+              |
+              = note: `loop { … }` is the loop
+        "#};
+
+        assert_eq!(expected, issue_report.to_string());
+    }
+
+    #[test]
+    fn test_issue_report_display_closure() {
+        owo_colors::set_override(false);
+        let issue_report = check_file(
+            "src/main.rs".into(),
+            indoc! {r#"
+                fn App(cx: Scope) -> Element {
+                    let something = || {
+                        let something = use_state(cx, || "hands");
+                        println!("clap your {something}")
+                    };
+                }
+            "#},
+        );
+
+        let expected = indoc! {r#"
+            error: hook called in a closure: `use_state`
+              --> src/main.rs:3:25
+              |
+            3 |         let something = use_state(cx, || "hands");
+              |                         ^^^^^^^^^
+        "#};
+
+        assert_eq!(expected, issue_report.to_string());
+    }
+
+    #[test]
+    fn test_issue_report_display_multiline_hook() {
+        owo_colors::set_override(false);
+        let issue_report = check_file(
+            "src/main.rs".into(),
+            indoc! {r#"
+                fn App(cx: Scope) -> Element {
+                    if you_are_happy && you_know_it {
+                        let something = use_state(cx, || {
+                            "hands"
+                        });
+                        println!("clap your {something}")
+                    }
+                }
+            "#},
+        );
+
+        let expected = indoc! {r#"
+            error: hook called conditionally: `use_state` (inside `if`)
+              --> src/main.rs:3:25
+              |
+            3 |         let something = use_state(cx, || {
+              |                         ^^^^^^^^^
+            4 |             "hands"
+            5 |         });
+              |
+              = note: `if you_are_happy && you_know_it { … }` is the conditional
+        "#};
+
+        assert_eq!(expected, issue_report.to_string());
+    }
+}

+ 6 - 0
packages/check/src/lib.rs

@@ -0,0 +1,6 @@
+mod check;
+mod issues;
+mod metadata;
+
+pub use check::check_file;
+pub use issues::{Issue, IssueReport};

+ 202 - 0
packages/check/src/metadata.rs

@@ -0,0 +1,202 @@
+#[derive(Debug, Clone, PartialEq, Eq)]
+/// Information about a hook call or function.
+pub struct HookInfo {
+    /// The name of the hook, e.g. `use_state`.
+    pub name: String,
+    /// The span of the hook, e.g. `use_state(cx, || 0)`.
+    pub span: Span,
+    /// The span of the name, e.g. `use_state`.
+    pub name_span: Span,
+}
+
+impl HookInfo {
+    pub const fn new(span: Span, name_span: Span, name: String) -> Self {
+        Self {
+            span,
+            name_span,
+            name,
+        }
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum ConditionalInfo {
+    If(IfInfo),
+    Match(MatchInfo),
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct IfInfo {
+    /// The span of the `if` statement, e.g. `if true { ... }`.
+    pub span: Span,
+    /// The span of the `if true` part only.
+    pub head_span: Span,
+}
+
+impl IfInfo {
+    pub const fn new(span: Span, head_span: Span) -> Self {
+        Self { span, head_span }
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct MatchInfo {
+    /// The span of the `match` statement, e.g. `match true { ... }`.
+    pub span: Span,
+    /// The span of the `match true` part only.
+    pub head_span: Span,
+}
+
+impl MatchInfo {
+    pub const fn new(span: Span, head_span: Span) -> Self {
+        Self { span, head_span }
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+/// Information about one of the possible loop types.
+pub enum AnyLoopInfo {
+    For(ForInfo),
+    While(WhileInfo),
+    Loop(LoopInfo),
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+/// Information about a `for` loop.
+pub struct ForInfo {
+    pub span: Span,
+    pub head_span: Span,
+}
+
+impl ForInfo {
+    pub const fn new(span: Span, head_span: Span) -> Self {
+        Self { span, head_span }
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+/// Information about a `while` loop.
+pub struct WhileInfo {
+    pub span: Span,
+    pub head_span: Span,
+}
+
+impl WhileInfo {
+    pub const fn new(span: Span, head_span: Span) -> Self {
+        Self { span, head_span }
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+/// Information about a `loop` loop.
+pub struct LoopInfo {
+    pub span: Span,
+}
+
+impl LoopInfo {
+    pub const fn new(span: Span) -> Self {
+        Self { span }
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+/// Information about a closure.
+pub struct ClosureInfo {
+    pub span: Span,
+}
+
+impl ClosureInfo {
+    pub const fn new(span: Span) -> Self {
+        Self { span }
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+/// Information about a component function.
+pub struct ComponentInfo {
+    pub span: Span,
+    pub name: String,
+    pub name_span: Span,
+}
+
+impl ComponentInfo {
+    pub const fn new(span: Span, name: String, name_span: Span) -> Self {
+        Self {
+            span,
+            name,
+            name_span,
+        }
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+/// Information about a non-component, non-hook function.
+pub struct FnInfo {
+    pub span: Span,
+    pub name: String,
+    pub name_span: Span,
+}
+
+impl FnInfo {
+    pub const fn new(span: Span, name: String, name_span: Span) -> Self {
+        Self {
+            span,
+            name,
+            name_span,
+        }
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+/// A span of text in a source code file.
+pub struct Span {
+    pub source_text: Option<String>,
+    pub start: LineColumn,
+    pub end: LineColumn,
+}
+
+impl Span {
+    pub fn new_from_str(source_text: &str, start: LineColumn) -> Self {
+        let mut lines = source_text.lines();
+        let first_line = lines.next().unwrap_or_default();
+        let mut end = LineColumn {
+            line: start.line,
+            column: start.column + first_line.len(),
+        };
+        for line in lines {
+            end.line += 1;
+            end.column = line.len();
+        }
+        Self {
+            source_text: Some(source_text.to_string()),
+            start,
+            end,
+        }
+    }
+}
+
+impl From<proc_macro2::Span> for Span {
+    fn from(span: proc_macro2::Span) -> Self {
+        Self {
+            source_text: span.source_text(),
+            start: span.start().into(),
+            end: span.end().into(),
+        }
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+/// A location in a source code file.
+pub struct LineColumn {
+    pub line: usize,
+    pub column: usize,
+}
+
+impl From<proc_macro2::LineColumn> for LineColumn {
+    fn from(lc: proc_macro2::LineColumn) -> Self {
+        Self {
+            line: lc.line,
+            column: lc.column,
+        }
+    }
+}

+ 1 - 0
packages/cli/Cargo.toml

@@ -77,6 +77,7 @@ toml_edit = "0.19.11"
 # dioxus-rsx = "0.0.1"
 
 dioxus-autofmt = { workspace = true }
+dioxus-check = { workspace = true }
 rsx-rosetta = { workspace = true }
 dioxus-rsx = { workspace = true }
 dioxus-html = { workspace = true, features = ["hot-reload-context"] }

+ 18 - 15
packages/cli/docs/src/cmd/README.md

@@ -5,22 +5,25 @@ In this chapter we will introduce all `dioxus-cli` commands.
 > You can also use `dx --help` to get cli help info.
 
 ```
-dx
-Build, bundle, & ship your Dioxus app
+Build, Bundle & Ship Dioxus Apps
 
-USAGE:
-    dx [OPTIONS] <SUBCOMMAND>
+Usage: dx [OPTIONS] <COMMAND>
 
-OPTIONS:
-    -h, --help    Print help information
-    -v            Enable verbose logging
+Commands:
+  build      Build the Rust WASM app and all of its assets
+  translate  Translate some source file into Dioxus code
+  serve      Build, watch & serve the Rust WASM app and all of its assets
+  create     Init a new project for Dioxus
+  clean      Clean output artifacts
+  version    Print the version of this extension
+  fmt        Format some rsx
+  check      Check the Rust files in the project for issues
+  config     Dioxus config file controls
+  help       Print this message or the help of the given subcommand(s)
 
-SUBCOMMANDS:
-    build        Build the Dioxus application and all of its assets
-    clean        Clean output artifacts
-    config       Dioxus config file controls
-    create       Init a new project for Dioxus
-    help         Print this message or the help of the given subcommand(s)
-    serve        Build, watch & serve the Rust WASM app and all of its assets
-    translate    Translate some html file into a Dioxus component
+Options:
+  -v               Enable verbose logging
+      --bin <BIN>  Specify bin target
+  -h, --help       Print help
+  -V, --version    Print version
 ```

+ 128 - 0
packages/cli/src/cli/check/mod.rs

@@ -0,0 +1,128 @@
+use futures::{stream::FuturesUnordered, StreamExt};
+use std::{path::Path, process::exit};
+
+use super::*;
+
+// For reference, the rustfmt main.rs file
+// https://github.com/rust-lang/rustfmt/blob/master/src/bin/main.rs
+
+/// Check the Rust files in the project for issues.
+#[derive(Clone, Debug, Parser)]
+pub struct Check {
+    /// Input file
+    #[clap(short, long)]
+    pub file: Option<PathBuf>,
+}
+
+impl Check {
+    // Todo: check the entire crate
+    pub async fn check(self) -> Result<()> {
+        match self.file {
+            // Default to checking the project
+            None => {
+                if let Err(e) = check_project_and_report().await {
+                    eprintln!("error checking project: {}", e);
+                    exit(1);
+                }
+            }
+            Some(file) => {
+                if let Err(e) = check_file_and_report(file).await {
+                    eprintln!("failed to check file: {}", e);
+                    exit(1);
+                }
+            }
+        }
+
+        Ok(())
+    }
+}
+
+async fn check_file_and_report(path: PathBuf) -> Result<()> {
+    check_files_and_report(vec![path]).await
+}
+
+/// Read every .rs file accessible when considering the .gitignore and check it
+///
+/// Runs using Tokio for multithreading, so it should be really really fast
+///
+/// Doesn't do mod-descending, so it will still try to check unreachable files. TODO.
+async fn check_project_and_report() -> Result<()> {
+    let crate_config = crate::CrateConfig::new(None)?;
+
+    let mut files_to_check = vec![];
+    collect_rs_files(&crate_config.crate_dir, &mut files_to_check);
+    check_files_and_report(files_to_check).await
+}
+
+/// Check a list of files and report the issues.
+async fn check_files_and_report(files_to_check: Vec<PathBuf>) -> Result<()> {
+    let issue_reports = files_to_check
+        .into_iter()
+        .filter(|file| file.components().all(|f| f.as_os_str() != "target"))
+        .map(|path| async move {
+            let _path = path.clone();
+            let res = tokio::spawn(async move {
+                tokio::fs::read_to_string(&_path)
+                    .await
+                    .map(|contents| dioxus_check::check_file(_path, &contents))
+            })
+            .await;
+
+            if res.is_err() {
+                eprintln!("error checking file: {}", path.display());
+            }
+
+            res
+        })
+        .collect::<FuturesUnordered<_>>()
+        .collect::<Vec<_>>()
+        .await;
+
+    // remove error results which we've already printed
+    let issue_reports = issue_reports
+        .into_iter()
+        .flatten()
+        .flatten()
+        .collect::<Vec<_>>();
+
+    let total_issues = issue_reports.iter().map(|r| r.issues.len()).sum::<usize>();
+
+    for report in issue_reports.into_iter() {
+        if !report.issues.is_empty() {
+            println!("{}", report);
+        }
+    }
+
+    match total_issues {
+        0 => println!("No issues found."),
+        1 => println!("1 issue found."),
+        _ => println!("{} issues found.", total_issues),
+    }
+
+    match total_issues {
+        0 => exit(0),
+        _ => exit(1),
+    }
+}
+
+fn collect_rs_files(folder: &Path, files: &mut Vec<PathBuf>) {
+    let Ok(folder) = folder.read_dir() else { return };
+
+    // load the gitignore
+
+    for entry in folder {
+        let Ok(entry) = entry else { continue; };
+
+        let path = entry.path();
+
+        if path.is_dir() {
+            collect_rs_files(&path, files);
+        }
+
+        if let Some(ext) = path.extension() {
+            if ext == "rs" {
+                files.push(path);
+            }
+        }
+    }
+}

+ 5 - 0
packages/cli/src/cli/mod.rs

@@ -1,6 +1,7 @@
 pub mod autoformat;
 pub mod build;
 pub mod cfg;
+pub mod check;
 pub mod clean;
 pub mod config;
 pub mod create;
@@ -67,6 +68,9 @@ pub enum Commands {
     #[clap(name = "fmt")]
     Autoformat(autoformat::Autoformat),
 
+    #[clap(name = "check")]
+    Check(check::Check),
+
     /// Dioxus config file controls.
     #[clap(subcommand)]
     Config(config::Config),
@@ -88,6 +92,7 @@ impl Display for Commands {
             Commands::Config(_) => write!(f, "config"),
             Commands::Version(_) => write!(f, "version"),
             Commands::Autoformat(_) => write!(f, "fmt"),
+            Commands::Check(_) => write!(f, "check"),
 
             #[cfg(feature = "plugin")]
             Commands::Plugin(_) => write!(f, "plugin"),

+ 5 - 0
packages/cli/src/main.rs

@@ -103,6 +103,11 @@ async fn main() -> anyhow::Result<()> {
             .await
             .map_err(|e| anyhow!("🚫 Error autoformatting RSX: {}", e)),
 
+        Check(opts) => opts
+            .check()
+            .await
+            .map_err(|e| anyhow!("🚫 Error checking RSX: {}", e)),
+
         Version(opt) => {
             let version = opt.version();
             println!("{}", version);