diff --git a/.gitignore b/.gitignore index 9f97022..492cb76 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ -target/ \ No newline at end of file +target/ +out/ +playdate-docs diff --git a/.zed/settings.json b/.zed/settings.json index fcd05fc..66f71c4 100644 --- a/.zed/settings.json +++ b/.zed/settings.json @@ -1,3 +1,4 @@ { - "wrap_guides": [100] + "wrap_guides": [100], + "file_scan_inclusions": [".env*", "playdate-docs/**"], } diff --git a/src/args.rs b/src/args.rs index 4eedc61..45b6328 100644 --- a/src/args.rs +++ b/src/args.rs @@ -17,6 +17,14 @@ pub struct Args { /// Verbose logging (-v, -vv, -vvv, etc.) #[arg(short, long, action = clap::ArgAction::Count)] pub verbose: u8, + + /// Compact function stubs (---@type + local name) + #[arg(long)] + pub compact: bool, + + /// Compact output to stdout (non-segmented) + #[arg(long)] + pub llm: bool, } // CLI Action: Generate Function Stubs or full Lua with annotation comments @@ -24,6 +32,8 @@ pub struct Args { pub enum Action { Stub, Annotate, + Multi, + Md, } fn get_sdk_dir() -> PathBuf { diff --git a/src/main.rs b/src/main.rs index c8ed516..a45537d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,11 +6,17 @@ mod args; mod luars; +mod md; +mod multi; mod output; mod scraper; use args::Action; +use md::write_markdown_docs; +use multi::MultiStubOutput; use output::StubOutput; +use std::fs; +use std::path::Path; static PLAYDATE_LUARS: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/playdate.luars")); @@ -18,21 +24,49 @@ fn main() { let args = args::parse(); // Parse the .luars type definitions - let statements = - luars::parse_document(PLAYDATE_LUARS).expect("Failed to parse playdate.luars"); + let statements = luars::parse_document(PLAYDATE_LUARS).expect("Failed to parse playdate.luars"); + + if args.llm { + let output = StubOutput::from_statements(&statements, true, true); + output.print(); + return; + } match args.action { Action::Stub => { // Generate stubs without documentation - let output = StubOutput::from_statements(&statements); + let output = StubOutput::from_statements(&statements, args.compact, false); output.print(); } Action::Annotate => { // Scrape documentation and generate annotated stubs let html = args::fetch_docs(&args); let scraped = scraper::scrape(&html, &statements); - let output = StubOutput::from_statements_with_docs(&statements, &scraped); + let output = + StubOutput::from_statements_with_docs(&statements, &scraped, args.compact, false); output.print(); } + Action::Md => { + let html = args::fetch_docs(&args); + let scraped = scraper::scrape(&html, &statements); + let out_dir = Path::new("playdate-docs"); + if out_dir.exists() { + fs::remove_dir_all(out_dir).expect("Failed to clear playdate-docs/ directory"); + } + fs::create_dir_all(out_dir).expect("Failed to create playdate-docs/ directory"); + write_markdown_docs(&scraped, &statements, out_dir) + .expect("Failed to write markdown docs"); + } + Action::Multi => { + let output = MultiStubOutput::from_statements(&statements, args.compact); + let out_dir = Path::new("out"); + if out_dir.exists() { + fs::remove_dir_all(out_dir).expect("Failed to clear out/ directory"); + } + fs::create_dir_all(out_dir).expect("Failed to create out/ directory"); + output + .write_to_dir(out_dir) + .expect("Failed to write multi-file output"); + } } } diff --git a/src/md.rs b/src/md.rs new file mode 100644 index 0000000..a982b76 --- /dev/null +++ b/src/md.rs @@ -0,0 +1,577 @@ +//! Markdown documentation output + +use std::collections::{BTreeMap, HashMap}; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +use crate::luars::{Param, Statement}; +use crate::output::split_function_name; +use crate::scraper::ScrapedFunction; + +fn namespace_to_path(ns: &str) -> PathBuf { + if ns == "json" || ns.starts_with("json.") { + PathBuf::from("json.md") + } else if ns == "playdate.accelerometer" || ns.starts_with("playdate.accelerometer.") { + PathBuf::from("playdate/accelerometer.md") + } else if ns == "playdate.inputHandlers" || ns.starts_with("playdate.inputHandlers.") { + PathBuf::from("playdate/inputhandlers.md") + } else if ns == "playdate.file" + || ns == "playdate.file.file" + || ns.starts_with("playdate.file.") + { + PathBuf::from("playdate/file.md") + } else if ns == "playdate.menu" || ns.starts_with("playdate.menu.") { + PathBuf::from("playdate/menu.md") + } else if ns == "playdate.network" || ns.starts_with("playdate.network.") { + PathBuf::from("playdate/network.md") + } else if ns == "playdate.pathfinder" || ns.starts_with("playdate.pathfinder.") { + PathBuf::from("playdate/pathfinder.md") + } else if ns == "playdate.ui" || ns.starts_with("playdate.ui.") { + PathBuf::from("playdate/ui.md") + } else if ns == "playdate.graphics.animation" || ns.starts_with("playdate.graphics.animation.") + { + PathBuf::from("playdate/graphics/animation.md") + } else if ns == "playdate.graphics.font" || ns.starts_with("playdate.graphics.font.") { + PathBuf::from("playdate/graphics/font.md") + } else if ns == "playdate.math.logic" || ns.starts_with("playdate.math.logic.") { + PathBuf::from("playdate/math.md") + } else if ns == "playdate.scoreboards" || ns.starts_with("playdate.scoreboards.") { + PathBuf::from("playdate/scoreboards.md") + } else if ns == "playdate.system" || ns.starts_with("playdate.system.") { + PathBuf::from("playdate/profiling.md") + } else if ns == "playdate.time" || ns.starts_with("playdate.time.") { + PathBuf::from("playdate/time.md") + } else if ns == "playdate" { + PathBuf::from("playdate.md") + } else if ns == "Object" || ns.starts_with("Object.") { + PathBuf::from("class.md") + } else if ns.starts_with("playdate.") { + PathBuf::from(format!("{}.md", ns.replace('.', "/"))) + } else if ns.is_empty() { + PathBuf::from("other.md") + } else { + PathBuf::from(format!("{}.md", ns.replace('.', "/"))) + } +} + +fn format_signature_parts(name: &str, params: &[Param], returns: &[Param]) -> String { + let params = params + .iter() + .map(|p| format!("{}: {}", p.name.trim_end_matches('?'), p.typ)) + .collect::>() + .join(", "); + let returns = if returns.is_empty() { + "nil".to_string() + } else if returns.len() == 1 { + returns[0].typ.clone() + } else { + let types = returns + .iter() + .map(|r| r.typ.as_str()) + .collect::>() + .join(", "); + format!("({})", types) + }; + format!("{}({}): {}", name, params, returns) +} + +#[derive(Clone)] +struct MdFunction { + name: String, + params: Vec, + returns: Vec, + docs: Vec, +} + +pub fn write_markdown_docs( + scraped: &BTreeMap, + statements: &BTreeMap, + out_dir: &Path, +) -> io::Result<()> { + let mut functions_by_file: BTreeMap> = BTreeMap::new(); + let mut classes_by_file: BTreeMap> = BTreeMap::new(); + let mut local_parent: HashMap = HashMap::new(); + + for stmt in statements.values() { + match stmt { + Statement::Global(name, parent, fields) => { + if fields.is_empty() { + continue; + } + let file = if name == "kTextAlignment" { + PathBuf::from("playdate/graphics/font.md") + } else { + namespace_to_path(name) + }; + let mut lines = Vec::new(); + if parent.is_empty() { + lines.push(format!("---@class {}", name)); + } else { + lines.push(format!("---@class {} : {}", name, parent)); + } + for field in fields { + if field.value.is_empty() { + lines.push(format!("---@field {} {}", field.name, field.typ)); + } else { + lines.push(format!( + "---@field {} {} {}", + field.name, field.typ, field.value + )); + } + } + classes_by_file + .entry(file) + .or_default() + .push(lines.join("\n")); + } + Statement::Local(name, parent, fields) => { + if fields.is_empty() { + if !parent.is_empty() { + local_parent.insert(name.clone(), parent.clone()); + } + continue; + } + if !parent.is_empty() { + local_parent.insert(name.clone(), parent.clone()); + } + let file = if name == "_InputHandler" { + PathBuf::from("playdate/inputhandlers.md") + } else if name == "_DateTime" || name == "_ModTime" { + PathBuf::from("playdate/time.md") + } else if name.starts_with("_SoundTrack") { + PathBuf::from("playdate/sound/track.md") + } else if name.starts_with("_ScoreBoard") || name == "_ServerStatus" { + PathBuf::from("playdate/scoreboards.md") + } else if name == "_Metadata" { + PathBuf::from("playdate.md") + } else if name == "_PowerStatus" { + PathBuf::from("playdate/device.md") + } else if name == "_SystemInfo" { + PathBuf::from("playdate.md") + } else if name == "_SystemStats" { + PathBuf::from("playdate/profiling.md") + } else if name == "_NewClass" { + PathBuf::from("class.md") + } else if name == "_SoundControlEvent" { + PathBuf::from("playdate/sound/controlsignal.md") + } else if name == "_SpriteCollisionData" || name == "_SpriteCollisionInfo" { + PathBuf::from("playdate/graphics/sprite.md") + } else if !parent.is_empty() { + namespace_to_path(parent) + } else { + PathBuf::from("other.md") + }; + let mut lines = Vec::new(); + if parent.is_empty() { + lines.push(format!("---@class {}", name)); + } else { + lines.push(format!("---@class {} : {}", name, parent)); + } + for field in fields { + if field.value.is_empty() { + lines.push(format!("---@field {} {}", field.name, field.typ)); + } else { + lines.push(format!( + "---@field {} {} {}", + field.name, field.typ, field.value + )); + } + } + classes_by_file + .entry(file) + .or_default() + .push(lines.join("\n")); + } + Statement::Function(_, _, _) => {} + } + } + + for func in scraped.values() { + let (ns, _) = split_function_name(&func.name); + let name_lower = func.name.to_lowercase(); + let file = if name_lower.contains("button") || name_lower.contains("crank") { + PathBuf::from("playdate/input.md") + } else if name_lower.contains("accelerometer") { + PathBuf::from("playdate/accelerometer.md") + } else if name_lower.starts_with("playdate.math.logic.") { + PathBuf::from("playdate/math.md") + } else if name_lower.starts_with("playdate.graphics.") && name_lower.contains("font") + || name_lower.contains("playdate.graphics.imagewithtext") + || name_lower.contains("playdate.graphics.gettextsizeformaxwidth") + || name_lower.contains("playdate.graphics.gettextsize") + || name_lower.contains("playdate.graphics.getsystemfont") + || name_lower.contains("playdate.graphics.getlocalizedtext") + || name_lower.contains("playdate.graphics.getfonttracking") + || name_lower.contains("playdate.graphics.getfont") + || name_lower.contains("playdate.graphics.drawtext") + || name_lower.contains("playdate.graphics.drawlocalizedtext") + { + PathBuf::from("playdate/graphics/font.md") + } else if name_lower.contains("playdate.graphics.stencil") { + PathBuf::from("playdate/graphics/stencil.md") + } else if name_lower.contains("playdate.graphics.generateqrcodesync") + || name_lower.contains("playdate.graphics.generateqrcode") + { + PathBuf::from("playdate/graphics/qrcode.md") + } else if name_lower.contains("playdate.graphics.imagesizeatpath") + || name_lower.contains("playdate.graphics.checkalphacollision") + { + PathBuf::from("playdate/graphics/image.md") + } else if name_lower.contains("playdate.graphics.perlin") + || name_lower.contains("playdate.graphics.perlinarray") + { + PathBuf::from("playdate/graphics/perlin.md") + } else if func.name == "print" + || func.name == "printTable" + || name_lower.contains("setnewlineprinted") + { + PathBuf::from("print.md") + } else if func.name == "where" + || name_lower.contains("setcollectsgarbage") + || name_lower.contains("setgcscaling") + || func.name == "sample" + { + PathBuf::from("profiling.md") + } else if name_lower.contains("clearconsole") + || name_lower.contains("debugdraw") + || name_lower.contains("keypressed") + || name_lower.contains("keyreleased") + { + PathBuf::from("playdate/simulator.md") + } else if name_lower.contains("getstats") || name_lower.contains("setstatsinterval") { + PathBuf::from("playdate/profiling.md") + } else if name_lower.contains("getflipped") + || name_lower.contains("getsystemlanguage") + || name_lower.contains("getreduceflashing") + { + PathBuf::from("playdate/settings.md") + } else if name_lower.contains("getpowerstatus") + || name_lower.contains("getbatteryvoltage") + || name_lower.contains("getbatterypercentage") + || name_lower.contains("setautolockdisabled") + || name_lower.contains("devicedidunlock") + || name_lower.contains("devicewilllock") + || name_lower.contains("devicewillsleep") + || name_lower.contains("gamewillpause") + { + PathBuf::from("playdate/device.md") + } else if name_lower.contains("mirrorended") || name_lower.contains("mirrorstarted") { + PathBuf::from("playdate/mirror.md") + } else if name_lower.contains("gamewill") + || name_lower.contains("devicewill") + || name_lower.contains("devicedid") + || name_lower.contains("serialmessagereceived") + || func.name.starts_with("playdate.update") + || func.name.starts_with("playdate.stop") + || func.name.starts_with("playdate.start") + || func.name.starts_with("playdate.wait") + || func.name.starts_with("playdate.restart") + { + PathBuf::from("playdate/lifecycle.md") + } else if name_lower.contains("time") || name_lower.contains("epoch") { + PathBuf::from("playdate/time.md") + } else if name_lower.contains("menu") { + PathBuf::from("playdate/menu.md") + } else if let Some(parent) = local_parent.get(ns) { + namespace_to_path(parent) + } else { + namespace_to_path(ns) + }; + functions_by_file.entry(file).or_default().push(MdFunction { + name: func.name.clone(), + params: func.params.clone(), + returns: func.returns.clone(), + docs: func.docs.clone(), + }); + } + + for stmt in statements.values() { + let Statement::Function(name, params, returns) = stmt else { + continue; + }; + if scraped.contains_key(&stmt.lua_def()) { + continue; + } + let (ns, _) = split_function_name(name); + let name_lower = name.to_lowercase(); + let file = if name_lower.contains("button") || name_lower.contains("crank") { + PathBuf::from("playdate/input.md") + } else if name_lower.contains("accelerometer") { + PathBuf::from("playdate/accelerometer.md") + } else if name_lower.starts_with("playdate.math.logic.") { + PathBuf::from("playdate/math.md") + } else if name_lower.starts_with("playdate.graphics.") && name_lower.contains("font") + || name_lower.contains("playdate.graphics.imagewithtext") + || name_lower.contains("playdate.graphics.gettextsizeformaxwidth") + || name_lower.contains("playdate.graphics.gettextsize") + || name_lower.contains("playdate.graphics.getsystemfont") + || name_lower.contains("playdate.graphics.getlocalizedtext") + || name_lower.contains("playdate.graphics.getfonttracking") + || name_lower.contains("playdate.graphics.getfont") + || name_lower.contains("playdate.graphics.drawtext") + || name_lower.contains("playdate.graphics.drawlocalizedtext") + { + PathBuf::from("playdate/graphics/font.md") + } else if name_lower.contains("playdate.graphics.stencil") { + PathBuf::from("playdate/graphics/stencil.md") + } else if name_lower.contains("playdate.graphics.generateqrcodesync") + || name_lower.contains("playdate.graphics.generateqrcode") + { + PathBuf::from("playdate/graphics/qrcode.md") + } else if name_lower.contains("playdate.graphics.imagesizeatpath") + || name_lower.contains("playdate.graphics.checkalphacollision") + { + PathBuf::from("playdate/graphics/image.md") + } else if name_lower.contains("playdate.graphics.perlin") + || name_lower.contains("playdate.graphics.perlinarray") + { + PathBuf::from("playdate/graphics/perlin.md") + } else if name == "print" + || name == "printTable" + || name_lower.contains("setnewlineprinted") + { + PathBuf::from("print.md") + } else if name == "class" { + PathBuf::from("class.md") + } else if name == "where" + || name_lower.contains("setcollectsgarbage") + || name_lower.contains("setgcscaling") + || name == "sample" + { + PathBuf::from("profiling.md") + } else if name_lower.contains("clearconsole") + || name_lower.contains("debugdraw") + || name_lower.contains("keypressed") + || name_lower.contains("keyreleased") + { + PathBuf::from("playdate/simulator.md") + } else if name_lower.contains("getstats") || name_lower.contains("setstatsinterval") { + PathBuf::from("playdate/profiling.md") + } else if name_lower.contains("getflipped") + || name_lower.contains("getsystemlanguage") + || name_lower.contains("getreduceflashing") + { + PathBuf::from("playdate/settings.md") + } else if name_lower.contains("getpowerstatus") + || name_lower.contains("getbatteryvoltage") + || name_lower.contains("getbatterypercentage") + || name_lower.contains("setautolockdisabled") + || name_lower.contains("devicedidunlock") + || name_lower.contains("devicewilllock") + || name_lower.contains("devicewillsleep") + || name_lower.contains("gamewillpause") + { + PathBuf::from("playdate/device.md") + } else if name_lower.contains("mirrorended") || name_lower.contains("mirrorstarted") { + PathBuf::from("playdate/mirror.md") + } else if name_lower.contains("gamewill") + || name_lower.contains("devicewill") + || name_lower.contains("devicedid") + || name_lower.contains("serialmessagereceived") + || name.starts_with("playdate.update") + || name.starts_with("playdate.stop") + || name.starts_with("playdate.start") + || name.starts_with("playdate.wait") + || name.starts_with("playdate.restart") + { + PathBuf::from("playdate/lifecycle.md") + } else if name_lower.contains("time") || name_lower.contains("epoch") { + PathBuf::from("playdate/time.md") + } else if name_lower.contains("menu") { + PathBuf::from("playdate/menu.md") + } else if let Some(parent) = local_parent.get(ns) { + namespace_to_path(parent) + } else { + namespace_to_path(ns) + }; + functions_by_file.entry(file).or_default().push(MdFunction { + name: name.clone(), + params: params.clone(), + returns: returns.clone(), + docs: Vec::new(), + }); + } + + let mut all_files: BTreeMap = BTreeMap::new(); + for key in functions_by_file.keys() { + all_files.insert(key.clone(), ()); + } + for key in classes_by_file.keys() { + all_files.insert(key.clone(), ()); + } + + let all_paths: Vec = all_files.keys().cloned().collect(); + for rel_path in all_paths { + let mut lines = Vec::new(); + let title = if rel_path == PathBuf::from("other.md") { + "other".to_string() + } else { + rel_path + .with_extension("") + .to_string_lossy() + .replace('/', ".") + }; + lines.push(format!("# {}", title)); + lines.push(String::new()); + + lines.push("## Functions".to_string()); + lines.push(String::new()); + if let Some(funcs) = functions_by_file.get(&rel_path) { + let mut order: Vec = Vec::new(); + let mut grouped: BTreeMap, Vec)> = BTreeMap::new(); + + for func in funcs { + if !grouped.contains_key(&func.name) { + order.push(func.name.clone()); + } + let sig = format_signature_parts(&func.name, &func.params, &func.returns); + let entry = grouped + .entry(func.name.clone()) + .or_insert_with(|| (Vec::new(), Vec::new())); + if !entry.0.contains(&sig) { + entry.0.push(sig); + } + if entry.1.is_empty() && !func.docs.is_empty() { + entry.1 = func.docs.clone(); + } + } + + for name in order { + if let Some((sigs, docs)) = grouped.get(&name) { + lines.push(format!("### {}", name)); + lines.push(String::new()); + lines.push("```lua".to_string()); + for sig in sigs { + lines.push(sig.clone()); + } + lines.push("```".to_string()); + if docs.is_empty() { + lines.push(String::new()); + } else { + lines.push(String::new()); + for doc in docs { + lines.push(doc.clone()); + } + lines.push(String::new()); + } + } + } + } + + if let Some(classes) = classes_by_file.get(&rel_path) { + if !classes.is_empty() { + lines.push("## Classes".to_string()); + lines.push(String::new()); + for class_block in classes { + let class_name = class_block + .lines() + .find_map(|line| line.strip_prefix("---@class ")) + .map(|s| s.split(':').next().unwrap_or(s).trim().to_string()) + .unwrap_or_else(|| "Class".to_string()); + lines.push(format!("### {}", class_name)); + lines.push(String::new()); + lines.push("```lua".to_string()); + lines.push(class_block.clone()); + lines.push("```".to_string()); + lines.push(String::new()); + } + } + } + + let base_dir = rel_path.with_extension(""); + let mut sub_sections = Vec::new(); + for key in all_files.keys() { + if key.starts_with(&base_dir) && key != &rel_path { + if let Ok(stripped) = key.strip_prefix(&base_dir) { + let mut comps = stripped.components(); + if matches!(comps.next(), Some(std::path::Component::Normal(_))) + && comps.next().is_none() + { + sub_sections.push(key.clone()); + } + } + } + } + if !sub_sections.is_empty() { + let current_dir = rel_path.parent().unwrap_or(Path::new("")); + lines.push("## See Also:".to_string()); + lines.push(String::new()); + for sub in sub_sections { + let link = if current_dir.as_os_str().is_empty() { + sub.to_string_lossy().to_string() + } else if let Ok(stripped) = sub.strip_prefix(current_dir) { + stripped + .to_string_lossy() + .trim_start_matches('/') + .to_string() + } else { + sub.to_string_lossy().to_string() + }; + let label = sub + .with_extension("") + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| link.clone()); + lines.push(format!("- [{}]({})", label, link)); + } + lines.push(String::new()); + } + + let full_path = out_dir.join(rel_path); + if let Some(parent) = full_path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(full_path, lines.join("\n"))?; + } + + write_agents_index(&all_files, out_dir)?; + + Ok(()) +} + +fn write_agents_index(all_files: &BTreeMap, out_dir: &Path) -> io::Result<()> { + let mut root_files: Vec = Vec::new(); + let mut groups: BTreeMap> = BTreeMap::new(); + + for path in all_files.keys() { + let comps: Vec = path + .components() + .map(|c| c.as_os_str().to_string_lossy().to_string()) + .collect(); + match comps.len() { + 1 => root_files.push(comps[0].clone()), + 2 => { + let dir = comps[0].clone(); + let file = comps[1].clone(); + groups.entry(dir).or_default().push(file); + } + _ => { + let dir = format!("{}/{}", comps[0], comps[1]); + let file = comps[2..].join("/"); + groups.entry(dir).or_default().push(file); + } + } + } + + root_files.sort(); + for files in groups.values_mut() { + files.sort(); + } + + let mut lines = Vec::new(); + lines.push("[PlaydateSDK Docs Index]|root: ./.playdate-docs".to_string()); + lines.push( + "|IMPORTANT: Prefer retrieval-led reasoning over pre-training-led reasoning".to_string(), + ); + if !root_files.is_empty() { + lines.push(format!("|{{{}}}", root_files.join(","))); + } + for (dir, files) in groups { + lines.push(format!("|{}/{{{}}}", dir, files.join(","))); + } + + let agents_path = out_dir.join("AGENTS.md"); + fs::write(agents_path, lines.join("\n"))?; + Ok(()) +} diff --git a/src/multi.rs b/src/multi.rs new file mode 100644 index 0000000..1dcb212 --- /dev/null +++ b/src/multi.rs @@ -0,0 +1,225 @@ +//! Multi-file LuaCATS stub output + +use std::collections::{BTreeMap, HashSet}; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +use crate::luars::Statement; +use crate::output::{ + append_compact_types, format_stub, function_type, generate_class, generate_compact_global, + generate_function, split_function_name, +}; + +fn namespace(name: &str) -> &str { + if let Some(pos) = name.rfind(':') { + &name[..pos] + } else if let Some(pos) = name.rfind('.') { + &name[..pos] + } else { + "" + } +} + +fn table_file(name: &str) -> PathBuf { + if name == "json" || name.starts_with("json.") { + PathBuf::from("json.lua") + } else if name == "playdate" { + PathBuf::from("playdate.lua") + } else if name.starts_with("playdate.") { + PathBuf::from(format!("{}.lua", name.replace('.', "/"))) + } else { + PathBuf::from("other.lua") + } +} + +struct FileBlocks { + classes: Vec>, + functions: Vec>, +} + +/// Multi-file stub generator output +pub struct MultiStubOutput { + files: BTreeMap, + compact: bool, +} + +impl MultiStubOutput { + pub fn from_statements(statements: &BTreeMap, compact: bool) -> Self { + let mut files: BTreeMap = BTreeMap::new(); + let mut class_names: HashSet = HashSet::new(); + + for stmt in statements.values() { + match stmt { + Statement::Global(name, _, _) | Statement::Local(name, _, _) => { + class_names.insert(name.clone()); + } + _ => {} + } + } + + if compact { + let mut globals = HashSet::new(); + let mut functions_by_ns: BTreeMap)>> = BTreeMap::new(); + let mut top_level: Vec<(String, Vec)> = Vec::new(); + + for stmt in statements.values() { + if let Statement::Global(name, _, _) = stmt { + globals.insert(name.clone()); + } + } + + for stmt in statements.values() { + if let Statement::Function(name, params, returns) = stmt { + let (ns, field) = split_function_name(name); + if ns.is_empty() || !globals.contains(ns) { + append_compact_types(&mut top_level, name, function_type(params, returns)); + } else { + let entry = functions_by_ns.entry(ns.to_string()).or_default(); + append_compact_types(entry, field, function_type(params, returns)); + } + } + } + + for stmt in statements.values() { + match stmt { + Statement::Global(name, parent, fields) => { + let file = table_file(name); + let funcs = functions_by_ns + .get(name) + .map(|v| v.as_slice()) + .unwrap_or(&[]); + let block = generate_compact_global(name, parent, fields, funcs); + files + .entry(file) + .or_insert_with(|| FileBlocks { + classes: Vec::new(), + functions: Vec::new(), + }) + .classes + .push(block); + } + Statement::Local(name, parent, fields) => { + let file = if !parent.is_empty() && class_names.contains(parent) { + table_file(parent) + } else { + PathBuf::from("other.lua") + }; + let block = generate_class(name, parent, fields, "local "); + files + .entry(file) + .or_insert_with(|| FileBlocks { + classes: Vec::new(), + functions: Vec::new(), + }) + .classes + .push(block); + } + Statement::Function(_, _, _) => {} + } + } + for (name, types) in top_level { + let mut block = Vec::new(); + for typ in types { + block.push(format!("---@type {}", typ)); + } + block.push(format!("{} = nil,", name)); + files + .entry(PathBuf::from("other.lua")) + .or_insert_with(|| FileBlocks { + classes: Vec::new(), + functions: Vec::new(), + }) + .functions + .push(block); + } + } else { + for stmt in statements.values() { + match stmt { + Statement::Global(name, parent, fields) => { + let file = table_file(name); + let block = generate_class(name, parent, fields, ""); + files + .entry(file) + .or_insert_with(|| FileBlocks { + classes: Vec::new(), + functions: Vec::new(), + }) + .classes + .push(block); + } + Statement::Local(name, parent, fields) => { + let file = if !parent.is_empty() && class_names.contains(parent) { + table_file(parent) + } else { + PathBuf::from("other.lua") + }; + let block = generate_class(name, parent, fields, "local "); + files + .entry(file) + .or_insert_with(|| FileBlocks { + classes: Vec::new(), + functions: Vec::new(), + }) + .classes + .push(block); + } + Statement::Function(name, params, returns) => { + let ns = namespace(name); + let file = if ns.is_empty() { + PathBuf::from("other.lua") + } else { + table_file(ns) + }; + let block = generate_function(name, params, returns, None); + files + .entry(file) + .or_insert_with(|| FileBlocks { + classes: Vec::new(), + functions: Vec::new(), + }) + .functions + .push(block); + } + } + } + } + + MultiStubOutput { files, compact } + } + + pub fn write_to_dir(&self, out_dir: &Path) -> io::Result<()> { + for (rel_path, blocks) in &self.files { + let mut lines = Vec::new(); + lines.extend(blocks.classes.clone()); + lines.extend(blocks.functions.clone()); + let content = if self.compact { + format_stub_compact(&lines) + } else { + format_stub(&lines) + }; + + let full_path = out_dir.join(rel_path); + if let Some(parent) = full_path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(full_path, content)?; + } + Ok(()) + } +} + +fn format_stub_compact(lines: &[Vec]) -> String { + let mut out = Vec::new(); + out.push("---@meta".to_string()); + out.push(String::new()); + + for block in lines { + if !block.is_empty() { + out.push(block.join("\n")); + out.push(String::new()); + } + } + + out.join("\n") +} diff --git a/src/output.rs b/src/output.rs index 0ac0ced..f455686 100644 --- a/src/output.rs +++ b/src/output.rs @@ -18,6 +18,27 @@ static NOTES: std::sync::LazyLock>> = std::sync::La toml::from_str(toml_str).unwrap_or_default() }); +pub(crate) fn format_stub(lines: &[Vec]) -> String { + let mut out = Vec::new(); + out.push("---@meta".to_string()); + out.push( + "--- This file contains function stubs for autocompletion. DO NOT include it in your game." + .to_string(), + ); + out.push(String::new()); + + for block in lines { + if !block.is_empty() { + out.push(block.join("\n")); + out.push(String::new()); + } + } + + out.push("--- End of LuaCATS stubs.".to_string()); + out.push(String::new()); + out.join("\n") +} + /// Generate LuaCATS output for a class/table pub fn generate_class(name: &str, parent: &str, fields: &[Field], prefix: &str) -> Vec { let mut out = Vec::new(); @@ -91,6 +112,100 @@ pub fn generate_function( out } +pub(crate) fn split_function_name(name: &str) -> (&str, &str) { + if let Some(pos) = name.rfind(':') { + (&name[..pos], &name[pos + 1..]) + } else if let Some(pos) = name.rfind('.') { + (&name[..pos], &name[pos + 1..]) + } else { + ("", name) + } +} + +pub(crate) fn function_type(params: &[Param], returns: &[Param]) -> String { + let params_str = params + .iter() + .map(|p| format!("{}: {}", p.name.trim_end_matches('?'), p.typ)) + .collect::>() + .join(", "); + let returns_str = if returns.is_empty() { + "nil".to_string() + } else if returns.len() == 1 { + returns[0].typ.clone() + } else { + let types = returns + .iter() + .map(|r| r.typ.as_str()) + .collect::>() + .join(", "); + format!("({})", types) + }; + format!("fun({}): {}", params_str, returns_str) +} + +pub(crate) fn append_compact_types( + entries: &mut Vec<(String, Vec)>, + field: &str, + typ: String, +) { + if let Some((_, types)) = entries.iter_mut().find(|(name, _)| name == field) { + types.push(typ); + } else { + entries.push((field.to_string(), vec![typ])); + } +} + +pub(crate) fn generate_compact_global( + name: &str, + parent: &str, + fields: &[Field], + functions: &[(String, Vec)], +) -> Vec { + let mut out = Vec::new(); + + if parent.is_empty() { + out.push(format!("---@class {}", name)); + } else { + out.push(format!("---@class {} : {}", name, parent)); + } + + for field in fields { + if field.value.is_empty() { + out.push(format!("---@field {} {}", field.name, field.typ)); + } else { + out.push(format!( + "---@field {} {} {}", + field.name, field.typ, field.value + )); + } + } + + let is_playdate = name == "playdate"; + if functions.is_empty() { + if is_playdate { + out.push(format!("{} = {} or {{}}", name, name)); + } else { + out.push(format!("{} = {{}}", name)); + } + return out; + } + + if is_playdate { + out.push(format!("{} = {} or {{", name, name)); + } else { + out.push(format!("{} = {{", name)); + } + for (field, types) in functions { + for typ in types { + out.push(format!(" ---@type {}", typ)); + } + out.push(format!(" {} = nil,", field)); + } + out.push("}".to_string()); + + out +} + /// Check if a line is a list item (including nested/indented lists) fn is_list_item(line: &str) -> bool { let trimmed = line.trim_start(); @@ -140,24 +255,80 @@ fn generate_docs(docs: &[String], anchor: &str, title: &str) -> Vec { /// Full stub generator output pub struct StubOutput { lines: Vec>, + compact: bool, + compact_no_indent: bool, } impl StubOutput { /// Create stub output from parsed statements only (no docs) - pub fn from_statements(statements: &BTreeMap) -> Self { + pub fn from_statements( + statements: &BTreeMap, + compact: bool, + compact_no_indent: bool, + ) -> Self { let mut classes = Vec::new(); let mut functions = Vec::new(); - for stmt in statements.values() { - match stmt { - Statement::Global(name, parent, fields) => { - classes.push(generate_class(name, parent, fields, "")); + if compact { + let mut globals = HashSet::new(); + let mut functions_by_ns: BTreeMap)>> = BTreeMap::new(); + let mut top_level: Vec<(String, Vec)> = Vec::new(); + + for stmt in statements.values() { + if let Statement::Global(name, _, _) = stmt { + globals.insert(name.clone()); } - Statement::Local(name, parent, fields) => { - classes.push(generate_class(name, parent, fields, "local ")); + } + + for stmt in statements.values() { + if let Statement::Function(name, params, returns) = stmt { + let (ns, field) = split_function_name(name); + if ns.is_empty() || !globals.contains(ns) { + let typ = function_type(params, returns); + append_compact_types(&mut top_level, name, typ); + } else { + let typ = function_type(params, returns); + let entry = functions_by_ns.entry(ns.to_string()).or_default(); + append_compact_types(entry, field, typ); + } } - Statement::Function(name, params, returns) => { - functions.push(generate_function(name, params, returns, None)); + } + + for stmt in statements.values() { + match stmt { + Statement::Global(name, parent, fields) => { + let funcs = functions_by_ns + .get(name) + .map(|v| v.as_slice()) + .unwrap_or(&[]); + classes.push(generate_compact_global(name, parent, fields, funcs)); + } + Statement::Local(name, parent, fields) => { + classes.push(generate_class(name, parent, fields, "local ")); + } + Statement::Function(_, _, _) => {} + } + } + for (name, types) in top_level { + let mut block = Vec::new(); + for typ in types { + block.push(format!("---@type {}", typ)); + } + block.push(format!("{} = nil,", name)); + functions.push(block); + } + } else { + for stmt in statements.values() { + match stmt { + Statement::Global(name, parent, fields) => { + classes.push(generate_class(name, parent, fields, "")); + } + Statement::Local(name, parent, fields) => { + classes.push(generate_class(name, parent, fields, "local ")); + } + Statement::Function(name, params, returns) => { + functions.push(generate_function(name, params, returns, None)); + } } } } @@ -167,53 +338,130 @@ impl StubOutput { lines.extend(classes); lines.extend(functions); - StubOutput { lines } + StubOutput { + lines, + compact, + compact_no_indent, + } } /// Create stub output from statements with scraped documentation pub fn from_statements_with_docs( statements: &BTreeMap, scraped: &BTreeMap, + compact: bool, + compact_no_indent: bool, ) -> Self { let mut classes = Vec::new(); let mut functions = Vec::new(); let mut processed: HashSet = HashSet::new(); - // First, output all classes/tables from statements - for stmt in statements.values() { - match stmt { - Statement::Global(name, parent, fields) => { - classes.push(generate_class(name, parent, fields, "")); + if compact { + let mut globals = HashSet::new(); + let mut functions_by_ns: BTreeMap)>> = BTreeMap::new(); + let mut combined_functions = Vec::new(); + let mut top_level: Vec<(String, Vec)> = Vec::new(); + + for stmt in statements.values() { + if let Statement::Global(name, _, _) = stmt { + globals.insert(name.clone()); } - Statement::Local(name, parent, fields) => { - classes.push(generate_class(name, parent, fields, "local ")); + } + + for func in scraped.values() { + let key = func.lua_def(); + processed.insert(key.clone()); + + let (params, returns) = + if let Some(Statement::Function(_, p, r)) = statements.get(&key) { + (p.clone(), r.clone()) + } else { + (func.params.clone(), func.returns.clone()) + }; + combined_functions.push((func.name.clone(), params, returns)); + } + + for stmt in statements.values() { + if let Statement::Function(name, params, returns) = stmt { + let key = stmt.lua_def(); + if !processed.contains(&key) { + combined_functions.push((name.clone(), params.clone(), returns.clone())); + } } - _ => {} } - } - // Process scraped functions (they have docs) - for func in scraped.values() { - let key = func.lua_def(); - processed.insert(key.clone()); + for (name, params, returns) in combined_functions { + let (ns, field) = split_function_name(&name); + if ns.is_empty() || !globals.contains(ns) { + let typ = function_type(¶ms, &returns); + append_compact_types(&mut top_level, &name, typ); + } else { + let typ = function_type(¶ms, &returns); + let entry = functions_by_ns.entry(ns.to_string()).or_default(); + append_compact_types(entry, field, typ); + } + } - // Get types from statements if available - let (params, returns) = if let Some(Statement::Function(_, p, r)) = statements.get(&key) - { - (p.as_slice(), r.as_slice()) - } else { - (func.params.as_slice(), func.returns.as_slice()) - }; + for stmt in statements.values() { + match stmt { + Statement::Global(name, parent, fields) => { + let funcs = functions_by_ns + .get(name) + .map(|v| v.as_slice()) + .unwrap_or(&[]); + classes.push(generate_compact_global(name, parent, fields, funcs)); + } + Statement::Local(name, parent, fields) => { + classes.push(generate_class(name, parent, fields, "local ")); + } + _ => {} + } + } + for (name, types) in top_level { + let mut block = Vec::new(); + for typ in types { + block.push(format!("---@type {}", typ)); + } + block.push(format!("{} = nil,", name)); + functions.push(block); + } + } else { + // First, output all classes/tables from statements + for stmt in statements.values() { + match stmt { + Statement::Global(name, parent, fields) => { + classes.push(generate_class(name, parent, fields, "")); + } + Statement::Local(name, parent, fields) => { + classes.push(generate_class(name, parent, fields, "local ")); + } + _ => {} + } + } - functions.push(generate_function(&func.name, params, returns, Some(func))); - } + // Process scraped functions (they have docs) + for func in scraped.values() { + let key = func.lua_def(); + processed.insert(key.clone()); + + // Get types from statements if available + let (params, returns) = + if let Some(Statement::Function(_, p, r)) = statements.get(&key) { + (p.as_slice(), r.as_slice()) + } else { + (func.params.as_slice(), func.returns.as_slice()) + }; - // Add remaining functions from statements (those not in scraped docs) - for stmt in statements.values() { - if let Statement::Function(name, params, returns) = stmt { - let key = stmt.lua_def(); - if !processed.contains(&key) { - functions.push(generate_function(name, params, returns, None)); + functions.push(generate_function(&func.name, params, returns, Some(func))); + } + + // Add remaining functions from statements (those not in scraped docs) + for stmt in statements.values() { + if let Statement::Function(name, params, returns) = stmt { + let key = stmt.lua_def(); + if !processed.contains(&key) { + functions.push(generate_function(name, params, returns, None)); + } } } } @@ -222,45 +470,91 @@ impl StubOutput { lines.extend(classes); lines.extend(functions); - StubOutput { lines } + StubOutput { + lines, + compact, + compact_no_indent, + } } /// Output to stdout pub fn print(&self) { println!("---@meta"); - println!( - "--- This file contains function stubs for autocompletion. DO NOT include it in your game." - ); - println!(); + if self.compact && self.compact_no_indent { + println!(); + } + if !self.compact { + println!( + "--- This file contains function stubs for autocompletion. DO NOT include it in your game." + ); + println!(); + } for block in &self.lines { if !block.is_empty() { - println!("{}", block.join("\n")); + let rendered = render_block(block, self.compact, self.compact_no_indent); + println!("{}", rendered); println!(); } } - println!("--- End of LuaCATS stubs."); + if !self.compact { + println!("--- End of LuaCATS stubs."); + } } /// Get output as a single string #[allow(dead_code)] pub fn to_string(&self) -> String { - let mut result = String::new(); - result.push_str("---@meta\n"); - result.push_str("--- This file contains function stubs for autocompletion. DO NOT include it in your game.\n"); - result.push('\n'); - - for block in &self.lines { - if !block.is_empty() { - result.push_str(&block.join("\n")); - result.push_str("\n\n"); + if self.compact { + let mut out = Vec::new(); + out.push("---@meta".to_string()); + out.push(String::new()); + out.push(String::new()); + for block in &self.lines { + if !block.is_empty() { + let rendered = render_block(block, self.compact, self.compact_no_indent); + out.push(rendered); + out.push(String::new()); + } } + out.join("\n") + } else { + format_stub(&self.lines) } + } +} + +fn render_block(block: &[String], compact: bool, compact_no_indent: bool) -> String { + if !(compact && compact_no_indent) { + return block.join("\n"); + } - result.push_str("--- End of LuaCATS stubs.\n"); - result + let lines = block + .iter() + .map(|line| line.strip_prefix(" ").unwrap_or(line).to_string()) + .collect::>(); + + let mut out = Vec::new(); + let mut i = 0; + while i < lines.len() { + let line = &lines[i]; + if line.starts_with("---@type ") { + let next_is_assignment = i + 1 < lines.len() && lines[i + 1].ends_with(" = nil,"); + let prev_is_type = i > 0 && lines[i - 1].starts_with("---@type "); + if next_is_assignment && !prev_is_type { + let assignment = &lines[i + 1]; + let typ = line.trim_start_matches("---@type ").trim(); + out.push(format!("{} ---@type {}", assignment, typ)); + i += 2; + continue; + } + } + out.push(line.clone()); + i += 1; } + + out.join("\n") } #[cfg(test)]