1use std::path::{Path, PathBuf};
9
10use syn::visit::Visit;
11
12use crate::yui::const_name;
13
14#[derive(Debug, PartialEq, yog_data::Schema)]
16#[data(rename_all = "camelCase")]
17pub struct HandlerLocation {
18 pub path: String,
19 pub line: usize,
21 pub column: usize,
23 pub changed: bool,
25}
26
27pub fn handler_name(event: &str) -> String {
29 let name = const_name(event).to_lowercase();
30 let name = name.trim_start_matches('_');
31 if name.is_empty() {
32 "on_click".to_string()
33 } else {
34 name.to_string()
35 }
36}
37
38pub fn code_behind_path(yui: &Path) -> PathBuf {
40 yui.with_extension("rs")
41}
42
43fn stem(yui: &Path) -> String {
44 yui.file_stem()
45 .map(|s| s.to_string_lossy().into_owned())
46 .unwrap_or_else(|| "screen".into())
47}
48
49fn skeleton(yui: &Path, constant: &str, handler: &str) -> String {
50 let stem = stem(yui);
51 format!(
52 r#"//! Code-behind for `{stem}.yui` — the screen's widgets come from the
53//! generated `build()`/`render()`/`click()`/`events`/`ids` (`yog build`).
54
55yog_api::ui_include!("{stem}");
56
57/// Handles one UI event of the screen; `true` = used.
58fn on_event(event: &str) -> bool {{
59 let Some(clicked) = click(event) else {{
60 return false;
61 }};
62 match clicked.as_str() {{
63 events::{constant} => {handler}(),
64 _ => {{}}
65 }}
66 true
67}}
68
69fn {handler}() {{
70}}
71
72/// Registers the screen; open it with [`open`].
73pub fn register(registry: &mut yog_api::Registry) {{
74 registry.on_ui_render(ID, |gfx| render(gfx));
75 registry.register_ui(ID, |_ui_id, event| {{
76 on_event(event);
77 }});
78}}
79"#
80 )
81}
82
83struct FindMatch {
86 best: Option<(u8, MatchInfo)>,
87 fn_end: usize,
89}
90
91#[derive(Clone)]
92struct MatchInfo {
93 catch_all: Option<(usize, usize)>,
95 close: (usize, usize),
97 arm_col: usize,
99 fn_end: usize,
100}
101
102impl<'ast> Visit<'ast> for FindMatch {
103 fn visit_item_fn(&mut self, f: &'ast syn::ItemFn) {
104 let saved = self.fn_end;
105 self.fn_end = f.block.brace_token.span.close().end().line;
106 syn::visit::visit_item_fn(self, f);
107 self.fn_end = saved;
108 }
109
110 fn visit_expr_match(&mut self, m: &'ast syn::ExprMatch) {
111 let events_arm = m
112 .arms
113 .iter()
114 .any(|a| pat_path(&a.pat).is_some_and(|p| p.starts_with("events::")));
115 let scrutinee = quote_ish(&m.expr);
116 let score = if events_arm {
117 2
118 } else if scrutinee.contains("click") || scrutinee.contains("event") {
119 1
120 } else {
121 0
122 };
123 if score > 0 && self.best.as_ref().is_none_or(|(s, _)| *s < score) {
124 let catch_all = m
125 .arms
126 .iter()
127 .find(|a| matches!(&a.pat, syn::Pat::Wild(_) | syn::Pat::Ident(_)))
128 .and_then(|a| pat_start(&a.pat));
129 let arm_col = m
130 .arms
131 .first()
132 .and_then(|a| pat_start(&a.pat))
133 .map_or(m.match_token.span.start().column + 4, |(_, c)| c);
134 let close = m.brace_token.span.close();
135 self.best = Some((
136 score,
137 MatchInfo {
138 catch_all,
139 close: (close.start().line, close.start().column),
140 arm_col,
141 fn_end: self.fn_end,
142 },
143 ));
144 }
145 syn::visit::visit_expr_match(self, m);
146 }
147}
148
149fn pat_start(p: &syn::Pat) -> Option<(usize, usize)> {
151 let span = match p {
152 syn::Pat::Path(p) => p.path.segments.first()?.ident.span(),
153 syn::Pat::Ident(i) => i.ident.span(),
154 syn::Pat::Wild(w) => w.underscore_token.span,
155 syn::Pat::Lit(l) => match &l.lit {
156 syn::Lit::Str(s) => s.span(),
157 _ => return None,
158 },
159 _ => return None,
160 };
161 Some((span.start().line, span.start().column))
162}
163
164fn pat_path(p: &syn::Pat) -> Option<String> {
165 match p {
166 syn::Pat::Path(p) => Some(
167 p.path
168 .segments
169 .iter()
170 .map(|s| s.ident.to_string())
171 .collect::<Vec<_>>()
172 .join("::"),
173 ),
174 _ => None,
175 }
176}
177
178fn quote_ish(e: &syn::Expr) -> String {
180 struct Names(String);
181 impl<'ast> Visit<'ast> for Names {
182 fn visit_ident(&mut self, i: &'ast proc_macro2::Ident) {
183 self.0.push_str(&i.to_string());
184 self.0.push(' ');
185 }
186 }
187 let mut n = Names(String::new());
188 n.visit_expr(e);
189 n.0.to_lowercase()
190}
191
192fn find(src: &str, needle: &str) -> Option<(usize, usize)> {
194 let at = src.find(needle)?;
195 let line = src[..at].matches('\n').count() + 1;
196 let col = at - src[..at].rfind('\n').map_or(0, |i| i + 1) + 1;
197 Some((line, col))
198}
199
200fn line_start(src: &str, line: usize) -> usize {
202 if line <= 1 {
203 return 0;
204 }
205 src.match_indices('\n')
206 .nth(line - 2)
207 .map_or(src.len(), |(i, _)| i + 1)
208}
209
210pub fn add_handler(src: &str, event: &str) -> Result<(String, usize, usize, bool), String> {
214 let constant = const_name(event);
215 let arm_ref = format!("events::{constant}");
216 let handler = handler_name(event);
217 if let Some(at) = src.find(&arm_ref).filter(|&at| {
219 !src[at + arm_ref.len()..].starts_with(|c: char| c.is_alphanumeric() || c == '_')
220 }) {
221 let fn_def = format!("fn {handler}(");
222 if let Some((line, col)) = find(src, &fn_def) {
223 return Ok((src.to_string(), line + 1, col + 4, false));
224 }
225 let line = src[..at].matches('\n').count() + 1;
226 let col = at - src[..at].rfind('\n').map_or(0, |i| i + 1) + 1;
227 return Ok((src.to_string(), line, col, false));
228 }
229 let file = syn::parse_file(src).map_err(|e| format!("the code-behind doesn't parse: {e}"))?;
230 let mut finder = FindMatch {
231 best: None,
232 fn_end: 0,
233 };
234 finder.visit_file(&file);
235 let Some((_, m)) = finder.best else {
236 return Err("no `match` over the clicked event (`match clicked.as_str() { … }`) to add the handler to".into());
237 };
238 let indent = " ".repeat(m.arm_col);
239 let arm = format!("{indent}{arm_ref} => {handler}(),\n");
240 let has_fn = src.contains(&format!("fn {handler}("));
241 let arm_at = match m.catch_all {
243 Some((line, _)) => line_start(src, line),
244 None => line_start(src, m.close.0),
245 };
246 let mut edits = vec![(arm_at, arm)];
247 if !has_fn {
248 let fn_at = if m.fn_end > 0 {
250 line_start(src, m.fn_end + 1)
251 } else {
252 src.len()
253 };
254 let lead = if fn_at == src.len() && !src.ends_with('\n') {
255 "\n\n"
256 } else {
257 "\n"
258 };
259 edits.push((fn_at, format!("{lead}fn {handler}() {{\n \n}}\n")));
260 }
261 edits.sort_by_key(|(at, _)| std::cmp::Reverse(*at));
262 let mut out = src.to_string();
263 for (at, text) in edits {
264 out.insert_str(at, &text);
265 }
266 let (line, _) = find(&out, &format!("fn {handler}(")).unwrap_or((0, 1));
267 Ok((out, line + 1, 5, true))
268}
269
270pub fn ensure_handler(yui: &Path, event: &str) -> Result<HandlerLocation, String> {
273 let rs = code_behind_path(yui);
274 let path = rs.to_string_lossy().into_owned();
275 if !rs.exists() {
276 let handler = handler_name(event);
277 let text = skeleton(yui, &const_name(event), &handler);
278 std::fs::write(&rs, &text).map_err(|e| format!("{}: {e}", rs.display()))?;
279 let (line, _) = find(&text, &format!("fn {handler}() {{")).unwrap_or((1, 1));
280 return Ok(HandlerLocation {
281 path,
282 line: line + 1,
283 column: 5,
284 changed: true,
285 });
286 }
287 let src = std::fs::read_to_string(&rs).map_err(|e| format!("{}: {e}", rs.display()))?;
288 let (text, line, column, changed) = add_handler(&src, event)?;
289 if changed {
290 std::fs::write(&rs, &text).map_err(|e| format!("{}: {e}", rs.display()))?;
291 }
292 Ok(HandlerLocation {
293 path,
294 line,
295 column,
296 changed,
297 })
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303
304 const SRC: &str = "yog_api::ui_include!(\"menu\");
305
306fn on_event(event: &str) -> bool {
307 let Some(clicked) = click(event) else {
308 return false;
309 };
310 match clicked.as_str() {
311 events::DONE => close(),
312 other => {
313 let _ = other;
314 }
315 }
316 true
317}
318
319fn close() {}
320";
321
322 #[test]
323 fn names_follow_the_generator() {
324 assert_eq!(const_name("filter_all"), "FILTER_ALL");
325 assert_eq!(const_name("mymod:btn-play"), "MYMOD_BTN_PLAY");
326 assert_eq!(handler_name("filterAll"), "filter_all");
327 assert_eq!(handler_name("play_click"), "play_click");
328 }
329
330 #[test]
331 fn adds_an_arm_before_the_catch_all_and_a_fn_after() {
332 let (out, line, col, changed) = add_handler(SRC, "play_click").unwrap();
333 assert!(changed);
334 assert!(out.contains(" events::DONE => close(),\n events::PLAY_CLICK => play_click(),\n other => {"), "{out}");
335 assert!(
336 out.contains(" true\n}\n\nfn play_click() {\n \n}\n\nfn close() {}"),
337 "{out}"
338 );
339 let lines: Vec<&str> = out.lines().collect();
340 assert_eq!(lines[line - 2], "fn play_click() {");
341 assert_eq!(col, 5);
342 syn::parse_file(&out).unwrap();
343 }
344
345 #[test]
346 fn an_existing_handler_is_found_not_duplicated() {
347 let (out, line, _, changed) = add_handler(SRC, "done").unwrap();
348 assert!(!changed);
349 assert_eq!(out, SRC);
350 assert_eq!(line, 8);
351 }
352
353 #[test]
354 fn a_missing_code_behind_gets_the_skeleton() {
355 let dir = std::env::temp_dir().join(format!("yog-idle-cb-{}", std::process::id()));
356 std::fs::create_dir_all(&dir).unwrap();
357 let yui = dir.join("menu.yui");
358 let loc = ensure_handler(&yui, "play").unwrap();
359 let text = std::fs::read_to_string(dir.join("menu.rs")).unwrap();
360 assert!(text.contains("events::PLAY => play(),"));
361 assert_eq!(text.lines().nth(loc.line - 2), Some("fn play() {"));
362 syn::parse_file(&text).unwrap();
363 assert!(!ensure_handler(&yui, "play").unwrap().changed);
365 std::fs::remove_dir_all(&dir).unwrap();
366 }
367}