A lexicon-driven AppView for ATProto.
1use regex::Regex;
2use std::sync::LazyLock;
3
4/// Matches an xrpc.query or xrpc.procedure call and captures the method name.
5static XRPC_CALL_RE: LazyLock<Regex> = LazyLock::new(|| {
6 Regex::new(r#"xrpc\.(?:query|procedure)\(\s*["']([a-zA-Z][a-zA-Z0-9]*(?:\.[a-zA-Z][a-zA-Z0-9]*)*)["']"#).unwrap()
7});
8
9/// Matches a Lua line comment at the start of the non-whitespace content on a line.
10/// Used to detect lines that are fully commented out before any code.
11static LUA_COMMENT_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*--").unwrap());
12
13/// Strips Lua block comments (`--[[ ... ]]`) from source, including multi-line ones.
14static BLOCK_COMMENT_RE: LazyLock<Regex> =
15 LazyLock::new(|| Regex::new(r"--\[\[[\s\S]*?\]\]").unwrap());
16
17pub fn extract_outbound_xrpcs(source: &str) -> Vec<String> {
18 let mut seen = std::collections::HashSet::new();
19 let mut result = Vec::new();
20
21 let stripped = BLOCK_COMMENT_RE.replace_all(source, "");
22
23 for line in stripped.lines() {
24 if LUA_COMMENT_RE.is_match(line) {
25 continue;
26 }
27
28 for cap in XRPC_CALL_RE.captures_iter(line) {
29 if let Some(method) = cap.get(1) {
30 let method = method.as_str().to_string();
31 if seen.insert(method.clone()) {
32 result.push(method);
33 }
34 }
35 }
36 }
37
38 result
39}
40
41#[cfg(test)]
42mod tests {
43 use super::*;
44
45 #[test]
46 fn empty_script_returns_empty() {
47 let result = extract_outbound_xrpcs("");
48 assert!(result.is_empty());
49 }
50
51 #[test]
52 fn no_xrpc_calls_returns_empty() {
53 let source = r#"
54 local record = params.record
55 return { records = { record } }
56 "#;
57 let result = extract_outbound_xrpcs(source);
58 assert!(result.is_empty());
59 }
60
61 #[test]
62 fn detects_xrpc_query_call() {
63 let source = r#"
64 local result = xrpc.query("games.birb.chess.getGame", { uri = params.uri })
65 return result
66 "#;
67 let result = extract_outbound_xrpcs(source);
68 assert_eq!(result, vec!["games.birb.chess.getGame"]);
69 }
70
71 #[test]
72 fn detects_xrpc_procedure_call() {
73 let source = r#"
74 xrpc.procedure("games.birb.chess.makeMove", { game = params.game, move = params.move })
75 "#;
76 let result = extract_outbound_xrpcs(source);
77 assert_eq!(result, vec!["games.birb.chess.makeMove"]);
78 }
79
80 #[test]
81 fn detects_multiple_calls() {
82 let source = r#"
83 local game = xrpc.query("games.birb.chess.getGame", { uri = params.uri })
84 xrpc.procedure("games.birb.chess.makeMove", { game = game.uri, move = params.move })
85 local games = xrpc.query("games.birb.chess.listGames", {})
86 "#;
87 let result = extract_outbound_xrpcs(source);
88 assert_eq!(
89 result,
90 vec![
91 "games.birb.chess.getGame",
92 "games.birb.chess.makeMove",
93 "games.birb.chess.listGames",
94 ]
95 );
96 }
97
98 #[test]
99 fn deduplicates_repeated_calls() {
100 let source = r#"
101 local a = xrpc.query("games.birb.chess.getGame", { uri = "a" })
102 local b = xrpc.query("games.birb.chess.getGame", { uri = "b" })
103 "#;
104 let result = extract_outbound_xrpcs(source);
105 assert_eq!(result, vec!["games.birb.chess.getGame"]);
106 }
107
108 #[test]
109 fn ignores_commented_out_calls() {
110 let source = r#"
111 -- local result = xrpc.query("games.birb.chess.getGame", { uri = params.uri })
112 return {}
113 "#;
114 let result = extract_outbound_xrpcs(source);
115 assert!(result.is_empty());
116 }
117
118 #[test]
119 fn handles_single_quotes_and_double_quotes() {
120 let source = r#"
121 local a = xrpc.query('games.birb.chess.getGame', {})
122 local b = xrpc.query("games.birb.chess.listGames", {})
123 "#;
124 let result = extract_outbound_xrpcs(source);
125 assert_eq!(
126 result,
127 vec!["games.birb.chess.getGame", "games.birb.chess.listGames",]
128 );
129 }
130
131 #[test]
132 fn handles_multiline_scripts() {
133 let source = r#"
134function handle(input, params)
135 local game = xrpc.query("games.birb.chess.getGame",
136 { uri = params.uri })
137 local result = xrpc.procedure("games.birb.chess.makeMove",
138 { game = game.uri,
139 move = params.move })
140 return result
141end
142 "#;
143 let result = extract_outbound_xrpcs(source);
144 assert_eq!(
145 result,
146 vec!["games.birb.chess.getGame", "games.birb.chess.makeMove",]
147 );
148 }
149
150 #[test]
151 fn handles_mixed_comments_and_code() {
152 let source = r#"
153function handle(input, params)
154 -- This is a comment about the next call
155 local game = xrpc.query("games.birb.chess.getGame", { uri = params.uri })
156 -- local old = xrpc.query("games.birb.chess.oldEndpoint", {})
157 -- xrpc.procedure("games.birb.chess.deprecatedMove", {})
158 xrpc.procedure("games.birb.chess.makeMove", { game = game.uri })
159 return game
160end
161 "#;
162 let result = extract_outbound_xrpcs(source);
163 assert_eq!(
164 result,
165 vec!["games.birb.chess.getGame", "games.birb.chess.makeMove",]
166 );
167 }
168
169 #[test]
170 fn ignores_block_comment_single_line() {
171 let source = r#"
172 --[[ local result = xrpc.query("games.birb.chess.getGame", { uri = params.uri }) ]]
173 return {}
174 "#;
175 let result = extract_outbound_xrpcs(source);
176 assert!(result.is_empty());
177 }
178
179 #[test]
180 fn ignores_block_comment_multiline() {
181 let source = r#"
182 --[[
183 local result = xrpc.query("games.birb.chess.getGame", { uri = params.uri })
184 xrpc.procedure("games.birb.chess.makeMove", {})
185 ]]
186 local active = xrpc.query("games.birb.chess.listGames", {})
187 "#;
188 let result = extract_outbound_xrpcs(source);
189 assert_eq!(result, vec!["games.birb.chess.listGames"]);
190 }
191
192 #[test]
193 fn dynamic_method_names_not_detected() {
194 let source = r#"
195 local method = "games.birb.chess.getGame"
196 local result = xrpc.query(method, {})
197 "#;
198 let result = extract_outbound_xrpcs(source);
199 assert!(
200 result.is_empty(),
201 "dynamically constructed method names should not be detected"
202 );
203 }
204
205 #[test]
206 fn extracts_from_complex_lua() {
207 let source = r#"
208function handle(input, params)
209 local results = {}
210
211 if params.include_profile then
212 local profile = xrpc.query("app.bsky.actor.getProfile", { actor = params.did })
213 table.insert(results, profile)
214 end
215
216 for i = 1, params.count do
217 local feed = xrpc.query("app.bsky.feed.getAuthorFeed", { actor = params.did, limit = 10 })
218 for _, post in ipairs(feed.feed) do
219 table.insert(results, post)
220 end
221 end
222
223 if params.should_notify then
224 xrpc.procedure("games.birb.chess.sendNotification", { target = params.did })
225 end
226
227 return { items = results }
228end
229 "#;
230 let result = extract_outbound_xrpcs(source);
231 assert_eq!(
232 result,
233 vec![
234 "app.bsky.actor.getProfile",
235 "app.bsky.feed.getAuthorFeed",
236 "games.birb.chess.sendNotification",
237 ]
238 );
239 }
240}