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