Browse Source

feat(check): adds `dx check`

At the moment this only checks the Rules of Hooks, ensuring that hook functions (i.e. `use_*`) are being called as expected.

https://dioxuslabs.com/docs/0.3/guide/en/interactivity/hooks.html

Closes #1202
Brian Donovan 1 năm trước cách đây
mục cha
commit
2c2534d1cc

+ 2 - 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" }

+ 25 - 0
packages/check/Cargo.toml

@@ -0,0 +1,25 @@
+[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"] }
+prettyplease = { package = "prettier-please", version = "0.1.16", features = [
+    "verbatim",
+] }
+owo-colors = "3.5.0"
+
+[dev-dependencies]
+pretty_assertions = "1.2.1"

+ 49 - 0
packages/check/README.md

@@ -0,0 +1,49 @@
+# dioxus-autofmt
+
+
+[![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-autofmt` provides a pretty printer for the `rsx` syntax tree.
+
+
+This is done manually with a via set of formatting rules. The output is not guaranteed to be stable between minor versions of the crate as we might tweak the output.
+
+`dioxus-autofmt` provides an API to perform precision edits as well as just spit out a block of formatted RSX from any RSX syntax tree. This is used by the `rsx-rosetta` crate which can accept various input languages and output valid RSX.
+
+
+## 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.

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

@@ -0,0 +1,226 @@
+use std::path::PathBuf;
+
+use syn::{spanned::Spanned, visit::Visit};
+
+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 {
+        path,
+        file_content: file_content.to_string(),
+        issues: 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 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_expr_if(&mut self, i: &'ast syn::ExprIf) {
+        self.context.push(Node::If(IfInfo::new(
+            i.span().into(),
+            i.if_token.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().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().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().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(),
+            i.loop_token.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();
+    }
+}

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

@@ -0,0 +1,154 @@
+use owo_colors::{
+    colors::{css::LightBlue, BrightRed},
+    OwoColorize,
+};
+use std::{
+    fmt::Display,
+    path::{Path, PathBuf},
+};
+
+use crate::metadata::{AnyLoopInfo, ClosureInfo, ConditionalInfo, HookInfo};
+
+/// The result of checking a Dioxus file for issues.
+pub struct IssueReport {
+    pub path: PathBuf,
+    pub file_content: String,
+    pub issues: Vec<Issue>,
+}
+
+impl IssueReport {
+    pub fn new<S: ToString>(path: PathBuf, file_content: S, issues: Vec<Issue>) -> Self {
+        Self {
+            path,
+            file_content: file_content.to_string(),
+            issues,
+        }
+    }
+}
+
+impl Display for IssueReport {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        let relative_file = Path::new(&self.path)
+            .strip_prefix(std::env::current_dir().unwrap())
+            .unwrap_or(Path::new(&self.path))
+            .display();
+
+        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!("{}: {}", "error".fg::<BrightRed>(), issue);
+            writeln!(f, "{}", error_line.bold())?;
+            writeln!(
+                f,
+                "  {} {}:{}:{}",
+                "-->".fg::<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$} {}", "", "|".fg::<LightBlue>())?;
+            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$} {} {}",
+                        line_num,
+                        "|".fg::<LightBlue>(),
+                        line,
+                        max_line_num_len = max_line_num_len
+                    )?;
+                    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$} {} {}",
+                            "",
+                            "|".fg::<LightBlue>(),
+                            caret.fg::<BrightRed>(),
+                            max_line_num_len = max_line_num_len
+                        )?;
+                    }
+                }
+            }
+
+            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 inside closure: `{}`", hook_info.name)
+            }
+            Issue::HookOutsideComponent(hook_info) => {
+                write!(
+                    f,
+                    "hook called outside component or hook: `{}`",
+                    hook_info.name
+                )
+            }
+        }
+    }
+}

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

@@ -0,0 +1,479 @@
+mod check;
+mod issues;
+mod metadata;
+
+pub use check::check_file;
+pub use issues::{Issue, IssueReport};
+
+#[cfg(test)]
+mod tests {
+    use crate::metadata::{
+        AnyLoopInfo, ClosureInfo, ConditionalInfo, ForInfo, HookInfo, IfInfo, LineColumn, LoopInfo,
+        MatchInfo, Span, WhileInfo,
+    };
+
+    use super::*;
+
+    #[test]
+    fn test_no_issues() {
+        let contents = 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_conditional_hook_if() {
+        let contents = 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 {
+                        start: LineColumn {
+                            line: 4,
+                            column: 36
+                        },
+                        end: LineColumn {
+                            line: 4,
+                            column: 61
+                        }
+                    },
+                    Span {
+                        start: LineColumn {
+                            line: 4,
+                            column: 36
+                        },
+                        end: LineColumn {
+                            line: 4,
+                            column: 45
+                        }
+                    },
+                    "use_state".to_string()
+                ),
+                ConditionalInfo::If(IfInfo::new(
+                    Span {
+                        start: LineColumn {
+                            line: 3,
+                            column: 16
+                        },
+                        end: LineColumn {
+                            line: 6,
+                            column: 17
+                        }
+                    },
+                    Span {
+                        start: LineColumn {
+                            line: 3,
+                            column: 16
+                        },
+                        end: LineColumn {
+                            line: 3,
+                            column: 18
+                        }
+                    }
+                ))
+            )],
+        );
+    }
+
+    #[test]
+    fn test_conditional_hook_match() {
+        let contents = 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 {
+                        start: LineColumn {
+                            line: 5,
+                            column: 40
+                        },
+                        end: LineColumn {
+                            line: 5,
+                            column: 65
+                        }
+                    },
+                    Span {
+                        start: LineColumn {
+                            line: 5,
+                            column: 40
+                        },
+                        end: LineColumn {
+                            line: 5,
+                            column: 49
+                        }
+                    },
+                    "use_state".to_string()
+                ),
+                ConditionalInfo::Match(MatchInfo::new(
+                    Span {
+                        start: LineColumn {
+                            line: 3,
+                            column: 16
+                        },
+                        end: LineColumn {
+                            line: 9,
+                            column: 17
+                        }
+                    },
+                    Span {
+                        start: LineColumn {
+                            line: 3,
+                            column: 16
+                        },
+                        end: LineColumn {
+                            line: 3,
+                            column: 21
+                        }
+                    }
+                ))
+            )]
+        );
+    }
+
+    #[test]
+    fn test_for_loop_hook() {
+        let contents = 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 {
+                        start: LineColumn {
+                            line: 4,
+                            column: 38
+                        },
+                        end: LineColumn {
+                            line: 4,
+                            column: 61
+                        }
+                    },
+                    Span {
+                        start: LineColumn {
+                            line: 4,
+                            column: 38
+                        },
+                        end: LineColumn {
+                            line: 4,
+                            column: 47
+                        }
+                    },
+                    "use_state".to_string()
+                ),
+                AnyLoopInfo::For(ForInfo::new(
+                    Span {
+                        start: LineColumn {
+                            line: 3,
+                            column: 16
+                        },
+                        end: LineColumn {
+                            line: 6,
+                            column: 17
+                        }
+                    },
+                    Span {
+                        start: LineColumn {
+                            line: 3,
+                            column: 16
+                        },
+                        end: LineColumn {
+                            line: 3,
+                            column: 19
+                        }
+                    }
+                ))
+            )]
+        );
+    }
+
+    #[test]
+    fn test_while_loop_hook() {
+        let contents = 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 {
+                        start: LineColumn {
+                            line: 4,
+                            column: 36
+                        },
+                        end: LineColumn {
+                            line: 4,
+                            column: 61
+                        }
+                    },
+                    Span {
+                        start: LineColumn {
+                            line: 4,
+                            column: 36
+                        },
+                        end: LineColumn {
+                            line: 4,
+                            column: 45
+                        }
+                    },
+                    "use_state".to_string()
+                ),
+                AnyLoopInfo::While(WhileInfo::new(
+                    Span {
+                        start: LineColumn {
+                            line: 3,
+                            column: 16
+                        },
+                        end: LineColumn {
+                            line: 6,
+                            column: 17
+                        }
+                    },
+                    Span {
+                        start: LineColumn {
+                            line: 3,
+                            column: 16
+                        },
+                        end: LineColumn {
+                            line: 3,
+                            column: 21
+                        }
+                    }
+                ))
+            )],
+        );
+    }
+
+    #[test]
+    fn test_loop_hook() {
+        let contents = 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 {
+                        start: LineColumn {
+                            line: 4,
+                            column: 36
+                        },
+                        end: LineColumn {
+                            line: 4,
+                            column: 61
+                        }
+                    },
+                    Span {
+                        start: LineColumn {
+                            line: 4,
+                            column: 36
+                        },
+                        end: LineColumn {
+                            line: 4,
+                            column: 45
+                        }
+                    },
+                    "use_state".to_string()
+                ),
+                AnyLoopInfo::Loop(LoopInfo::new(
+                    Span {
+                        start: LineColumn {
+                            line: 3,
+                            column: 16
+                        },
+                        end: LineColumn {
+                            line: 6,
+                            column: 17
+                        }
+                    },
+                    Span {
+                        start: LineColumn {
+                            line: 3,
+                            column: 16
+                        },
+                        end: LineColumn {
+                            line: 3,
+                            column: 20
+                        }
+                    }
+                ))
+            )],
+        );
+    }
+
+    #[test]
+    fn test_conditional_okay() {
+        let contents = 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 = 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 {
+                        start: LineColumn {
+                            line: 4,
+                            column: 28
+                        },
+                        end: LineColumn {
+                            line: 4,
+                            column: 47
+                        }
+                    },
+                    Span {
+                        start: LineColumn {
+                            line: 4,
+                            column: 28
+                        },
+                        end: LineColumn {
+                            line: 4,
+                            column: 37
+                        }
+                    },
+                    "use_state".to_string()
+                ),
+                ClosureInfo::new(Span {
+                    start: LineColumn {
+                        line: 3,
+                        column: 25
+                    },
+                    end: LineColumn {
+                        line: 6,
+                        column: 17
+                    }
+                })
+            )]
+        );
+    }
+
+    #[test]
+    fn test_hook_outside_component() {
+        let contents = 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 {
+                    start: LineColumn {
+                        line: 3,
+                        column: 25
+                    },
+                    end: LineColumn {
+                        line: 3,
+                        column: 44
+                    }
+                },
+                Span {
+                    start: LineColumn {
+                        line: 3,
+                        column: 25
+                    },
+                    end: LineColumn {
+                        line: 3,
+                        column: 34
+                    }
+                },
+                "use_state".to_string()
+            ))]
+        );
+    }
+
+    #[test]
+    fn test_hook_inside_hook() {
+        let contents = 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![]);
+    }
+}

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

@@ -0,0 +1,181 @@
+#[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` keyword only.
+    pub keyword_span: Span,
+}
+
+impl IfInfo {
+    pub const fn new(span: Span, keyword_span: Span) -> Self {
+        Self { span, keyword_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` keyword only.
+    pub keyword_span: Span,
+}
+
+impl MatchInfo {
+    pub const fn new(span: Span, keyword_span: Span) -> Self {
+        Self { span, keyword_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 keyword_span: Span,
+}
+
+impl ForInfo {
+    pub const fn new(span: Span, keyword_span: Span) -> Self {
+        Self { span, keyword_span }
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+/// Information about a `while` loop.
+pub struct WhileInfo {
+    pub span: Span,
+    pub keyword_span: Span,
+}
+
+impl WhileInfo {
+    pub const fn new(span: Span, keyword_span: Span) -> Self {
+        Self { span, keyword_span }
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+/// Information about a `loop` loop.
+pub struct LoopInfo {
+    pub span: Span,
+    pub keyword_span: Span,
+}
+
+impl LoopInfo {
+    pub const fn new(span: Span, keyword_span: Span) -> Self {
+        Self { span, keyword_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 start: LineColumn,
+    pub end: LineColumn,
+}
+
+impl From<proc_macro2::Span> for Span {
+    fn from(span: proc_macro2::Span) -> Self {
+        Self {
+            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"] }

+ 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);