use std::path::{Path, PathBuf};
use serde::Serialize;
use syn::visit::Visit;
#[derive(Debug, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct HandlerLocation {
pub path: String,
pub line: usize,
pub column: usize,
pub changed: bool,
}
pub fn const_name(value: &str) -> String {
let mut out = String::new();
let mut prev_lower = false;
for c in value.chars() {
if c.is_ascii_alphanumeric() {
if c.is_ascii_uppercase() && prev_lower {
out.push('_');
}
prev_lower = c.is_ascii_lowercase() || c.is_ascii_digit();
out.push(c.to_ascii_uppercase());
} else {
if !out.ends_with('_') {
out.push('_');
}
prev_lower = false;
}
}
let out = out.trim_matches('_').to_string();
if out.is_empty() || out.starts_with(|c: char| c.is_ascii_digit()) {
format!("_{out}")
} else {
out
}
}
pub fn handler_name(event: &str) -> String {
let name = const_name(event).to_lowercase();
let name = name.trim_start_matches('_');
if name.is_empty() {
"on_click".to_string()
} else {
name.to_string()
}
}
pub fn code_behind_path(yui: &Path) -> PathBuf {
yui.with_extension("rs")
}
fn stem(yui: &Path) -> String {
yui.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "screen".into())
}
fn skeleton(yui: &Path, constant: &str, handler: &str) -> String {
let stem = stem(yui);
format!(
r#"//! Code-behind for `{stem}.yui` — the screen's widgets come from the
//! generated `build()`/`render()`/`click()`/`events`/`ids` (`yog build`).
yog_api::ui_include!("{stem}");
/// Handles one UI event of the screen; `true` = used.
fn on_event(event: &str) -> bool {{
let Some(clicked) = click(event) else {{
return false;
}};
match clicked.as_str() {{
events::{constant} => {handler}(),
_ => {{}}
}}
true
}}
fn {handler}() {{
}}
/// Registers the screen; open it with [`open`].
pub fn register(registry: &mut yog_api::Registry) {{
registry.on_ui_render(ID, |gfx| render(gfx));
registry.register_ui(ID, |_ui_id, event| {{
on_event(event);
}});
}}
"#
)
}
struct FindMatch {
best: Option<(u8, MatchInfo)>,
fn_end: usize,
}
#[derive(Clone)]
struct MatchInfo {
catch_all: Option<(usize, usize)>,
close: (usize, usize),
arm_col: usize,
fn_end: usize,
}
impl<'ast> Visit<'ast> for FindMatch {
fn visit_item_fn(&mut self, f: &'ast syn::ItemFn) {
let saved = self.fn_end;
self.fn_end = f.block.brace_token.span.close().end().line;
syn::visit::visit_item_fn(self, f);
self.fn_end = saved;
}
fn visit_expr_match(&mut self, m: &'ast syn::ExprMatch) {
let events_arm = m
.arms
.iter()
.any(|a| pat_path(&a.pat).is_some_and(|p| p.starts_with("events::")));
let scrutinee = quote_ish(&m.expr);
let score = if events_arm {
2
} else if scrutinee.contains("click") || scrutinee.contains("event") {
1
} else {
0
};
if score > 0 && self.best.as_ref().is_none_or(|(s, _)| *s < score) {
let catch_all = m
.arms
.iter()
.find(|a| matches!(&a.pat, syn::Pat::Wild(_) | syn::Pat::Ident(_)))
.and_then(|a| pat_start(&a.pat));
let arm_col = m
.arms
.first()
.and_then(|a| pat_start(&a.pat))
.map_or(m.match_token.span.start().column + 4, |(_, c)| c);
let close = m.brace_token.span.close();
self.best = Some((
score,
MatchInfo {
catch_all,
close: (close.start().line, close.start().column),
arm_col,
fn_end: self.fn_end,
},
));
}
syn::visit::visit_expr_match(self, m);
}
}
fn pat_start(p: &syn::Pat) -> Option<(usize, usize)> {
let span = match p {
syn::Pat::Path(p) => p.path.segments.first()?.ident.span(),
syn::Pat::Ident(i) => i.ident.span(),
syn::Pat::Wild(w) => w.underscore_token.span,
syn::Pat::Lit(l) => match &l.lit {
syn::Lit::Str(s) => s.span(),
_ => return None,
},
_ => return None,
};
Some((span.start().line, span.start().column))
}
fn pat_path(p: &syn::Pat) -> Option<String> {
match p {
syn::Pat::Path(p) => Some(
p.path
.segments
.iter()
.map(|s| s.ident.to_string())
.collect::<Vec<_>>()
.join("::"),
),
_ => None,
}
}
fn quote_ish(e: &syn::Expr) -> String {
struct Names(String);
impl<'ast> Visit<'ast> for Names {
fn visit_ident(&mut self, i: &'ast proc_macro2::Ident) {
self.0.push_str(&i.to_string());
self.0.push(' ');
}
}
let mut n = Names(String::new());
n.visit_expr(e);
n.0.to_lowercase()
}
fn find(src: &str, needle: &str) -> Option<(usize, usize)> {
let at = src.find(needle)?;
let line = src[..at].matches('\n').count() + 1;
let col = at - src[..at].rfind('\n').map_or(0, |i| i + 1) + 1;
Some((line, col))
}
fn line_start(src: &str, line: usize) -> usize {
if line <= 1 {
return 0;
}
src.match_indices('\n')
.nth(line - 2)
.map_or(src.len(), |(i, _)| i + 1)
}
pub fn add_handler(src: &str, event: &str) -> Result<(String, usize, usize, bool), String> {
let constant = const_name(event);
let arm_ref = format!("events::{constant}");
let handler = handler_name(event);
if let Some(at) = src.find(&arm_ref).filter(|&at| {
!src[at + arm_ref.len()..].starts_with(|c: char| c.is_alphanumeric() || c == '_')
}) {
let fn_def = format!("fn {handler}(");
if let Some((line, col)) = find(src, &fn_def) {
return Ok((src.to_string(), line + 1, col + 4, false));
}
let line = src[..at].matches('\n').count() + 1;
let col = at - src[..at].rfind('\n').map_or(0, |i| i + 1) + 1;
return Ok((src.to_string(), line, col, false));
}
let file = syn::parse_file(src).map_err(|e| format!("the code-behind doesn't parse: {e}"))?;
let mut finder = FindMatch {
best: None,
fn_end: 0,
};
finder.visit_file(&file);
let Some((_, m)) = finder.best else {
return Err("no `match` over the clicked event (`match clicked.as_str() { … }`) to add the handler to".into());
};
let indent = " ".repeat(m.arm_col);
let arm = format!("{indent}{arm_ref} => {handler}(),\n");
let has_fn = src.contains(&format!("fn {handler}("));
let arm_at = match m.catch_all {
Some((line, _)) => line_start(src, line),
None => line_start(src, m.close.0),
};
let mut edits = vec![(arm_at, arm)];
if !has_fn {
let fn_at = if m.fn_end > 0 {
line_start(src, m.fn_end + 1)
} else {
src.len()
};
let lead = if fn_at == src.len() && !src.ends_with('\n') {
"\n\n"
} else {
"\n"
};
edits.push((fn_at, format!("{lead}fn {handler}() {{\n \n}}\n")));
}
edits.sort_by_key(|(at, _)| std::cmp::Reverse(*at));
let mut out = src.to_string();
for (at, text) in edits {
out.insert_str(at, &text);
}
let (line, _) = find(&out, &format!("fn {handler}(")).unwrap_or((0, 1));
Ok((out, line + 1, 5, true))
}
pub fn ensure_handler(yui: &Path, event: &str) -> Result<HandlerLocation, String> {
let rs = code_behind_path(yui);
let path = rs.to_string_lossy().into_owned();
if !rs.exists() {
let handler = handler_name(event);
let text = skeleton(yui, &const_name(event), &handler);
std::fs::write(&rs, &text).map_err(|e| format!("{}: {e}", rs.display()))?;
let (line, _) = find(&text, &format!("fn {handler}() {{")).unwrap_or((1, 1));
return Ok(HandlerLocation {
path,
line: line + 1,
column: 5,
changed: true,
});
}
let src = std::fs::read_to_string(&rs).map_err(|e| format!("{}: {e}", rs.display()))?;
let (text, line, column, changed) = add_handler(&src, event)?;
if changed {
std::fs::write(&rs, &text).map_err(|e| format!("{}: {e}", rs.display()))?;
}
Ok(HandlerLocation {
path,
line,
column,
changed,
})
}
#[cfg(test)]
mod tests {
use super::*;
const SRC: &str = "yog_api::ui_include!(\"menu\");
fn on_event(event: &str) -> bool {
let Some(clicked) = click(event) else {
return false;
};
match clicked.as_str() {
events::DONE => close(),
other => {
let _ = other;
}
}
true
}
fn close() {}
";
#[test]
fn names_follow_the_generator() {
assert_eq!(const_name("filter_all"), "FILTER_ALL");
assert_eq!(const_name("mymod:btn-play"), "MYMOD_BTN_PLAY");
assert_eq!(handler_name("filterAll"), "filter_all");
assert_eq!(handler_name("play_click"), "play_click");
}
#[test]
fn adds_an_arm_before_the_catch_all_and_a_fn_after() {
let (out, line, col, changed) = add_handler(SRC, "play_click").unwrap();
assert!(changed);
assert!(out.contains(" events::DONE => close(),\n events::PLAY_CLICK => play_click(),\n other => {"), "{out}");
assert!(
out.contains(" true\n}\n\nfn play_click() {\n \n}\n\nfn close() {}"),
"{out}"
);
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines[line - 2], "fn play_click() {");
assert_eq!(col, 5);
syn::parse_file(&out).unwrap();
}
#[test]
fn an_existing_handler_is_found_not_duplicated() {
let (out, line, _, changed) = add_handler(SRC, "done").unwrap();
assert!(!changed);
assert_eq!(out, SRC);
assert_eq!(line, 8);
}
#[test]
fn a_missing_code_behind_gets_the_skeleton() {
let dir = std::env::temp_dir().join(format!("yog-idle-cb-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let yui = dir.join("menu.yui");
let loc = ensure_handler(&yui, "play").unwrap();
let text = std::fs::read_to_string(dir.join("menu.rs")).unwrap();
assert!(text.contains("events::PLAY => play(),"));
assert_eq!(text.lines().nth(loc.line - 2), Some("fn play() {"));
syn::parse_file(&text).unwrap();
assert!(!ensure_handler(&yui, "play").unwrap().changed);
std::fs::remove_dir_all(&dir).unwrap();
}
}