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}