Skip to main content

sdivi_patterns/queries/
framework_hooks.rs

1//! Node kinds classified as framework-hook calls.
2//!
3//! Detects React, Preact, Vue (composables), and Svelte-style hook calls
4//! in TypeScript and JavaScript. Identified by callee text (`^use[A-Z]`).
5//! No tree-sitter node-kind matching — `call_expression` is already collected
6//! by the TS/JS adapters; this module provides callee-text discrimination only.
7
8use std::sync::LazyLock;
9
10use regex::Regex;
11
12/// Tree-sitter node kinds for framework-hook calls.
13///
14/// Empty — this category is detected entirely via callee-text inspection in
15/// [`matches_callee`]. The `call_expression` node kind is already collected
16/// by the TypeScript/JavaScript adapters; classification happens in
17/// `classify_hint`'s `CALL_DISPATCH` loop at slot P6.
18pub const NODE_KINDS: &[&str] = &[];
19
20// TypeScript / JavaScript:
21//   ^use[A-Z]  — any callee starting with `use` followed immediately by an
22// uppercase letter. Covers the entire React / Vue / Svelte hook ecosystem:
23// built-in hooks (useState, useEffect, useMemo, useCallback, useRef,
24// useContext, useReducer, useLayoutEffect) and the full custom-hook ecosystem.
25// The uppercase second character is mandatory — `user(x)`, `useful(x)` do NOT
26// match; only `useX…` where X ∈ [A-Z] matches.
27static TS_JS_RE: LazyLock<Regex> =
28    LazyLock::new(|| Regex::new(r"^use[A-Z]").expect("framework_hooks TS/JS regex is valid"));
29
30/// Return `true` when `text` looks like a framework-hook callee for `language`.
31///
32/// Matches any callee starting with `use` followed by an uppercase letter
33/// (`useState`, `useEffect`, `useMemo`, `useAuth`, etc.) in TypeScript or
34/// JavaScript. All other languages return `false` — hook conventions are
35/// specific to the JS/TS ecosystem.
36///
37/// # Examples
38///
39/// ```rust
40/// use sdivi_patterns::queries::framework_hooks::matches_callee;
41///
42/// assert!(matches_callee("useState(0)", "typescript"));
43/// assert!(matches_callee("useEffect(fn, [])", "typescript"));
44/// assert!(matches_callee("useAuth()", "javascript"));
45/// assert!(matches_callee("useCustomHook(opts)", "typescript"));
46/// assert!(!matches_callee("user()", "typescript"));     // lowercase second char
47/// assert!(!matches_callee("getUser()", "typescript"));  // doesn't start with `use`
48/// assert!(!matches_callee("useState(0)", "rust"));      // wrong language
49/// ```
50pub fn matches_callee(text: &str, language: &str) -> bool {
51    match language {
52        "typescript" | "javascript" => TS_JS_RE.is_match(text),
53        _ => false,
54    }
55}
56
57#[cfg(test)]
58mod tests {
59    use super::*;
60
61    #[test]
62    fn built_in_hooks_match_typescript() {
63        for callee in [
64            "useState(0)",
65            "useEffect(fn, [])",
66            "useMemo(() => val, [])",
67            "useCallback(fn, [])",
68            "useRef(null)",
69            "useContext(MyCtx)",
70            "useReducer(reducer, state)",
71            "useLayoutEffect(fn, [])",
72        ] {
73            assert!(
74                matches_callee(callee, "typescript"),
75                "{callee:?} should match for typescript"
76            );
77        }
78    }
79
80    #[test]
81    fn custom_hooks_match() {
82        assert!(matches_callee("useAuth()", "typescript"));
83        assert!(matches_callee("useStore()", "javascript"));
84        assert!(matches_callee("useCustomThing(opts)", "typescript"));
85        assert!(matches_callee("useMutation(fn)", "javascript"));
86    }
87
88    #[test]
89    fn lowercase_second_char_does_not_match() {
90        assert!(!matches_callee("user()", "typescript"));
91        assert!(!matches_callee("useful(x)", "javascript"));
92        assert!(!matches_callee("username()", "typescript"));
93        assert!(!matches_callee("fuse(x)", "typescript"));
94    }
95
96    #[test]
97    fn non_use_prefix_does_not_match() {
98        assert!(!matches_callee("getUser()", "typescript"));
99        assert!(!matches_callee("setState(x)", "typescript"));
100        assert!(!matches_callee("Math.max(a, b)", "typescript"));
101        assert!(!matches_callee("console.log(x)", "typescript"));
102        assert!(!matches_callee("fetch(url)", "typescript"));
103    }
104
105    #[test]
106    fn other_languages_return_false() {
107        for lang in ["rust", "python", "go", "java"] {
108            assert!(
109                !matches_callee("useState(0)", lang),
110                "useState should not match for {lang}"
111            );
112        }
113    }
114
115    #[test]
116    fn node_kinds_is_empty() {
117        // NODE_KINDS is intentionally empty: this category is callee-only (classified
118        // via classify_hint). The assertion guards that contract against regressions.
119        #[allow(clippy::const_is_empty)]
120        let empty = NODE_KINDS.is_empty();
121        assert!(empty);
122    }
123}