1use std::collections::BTreeMap;
10use std::fs;
11use std::path::{Path, PathBuf};
12use std::process::{Command, Stdio};
13
14use crate::util::system::escape_json;
15
16#[derive(Debug)]
22struct FnSig {
23 name: String,
24 signature: String,
25}
26
27#[derive(Debug)]
29struct ApiType {
30 name: String,
31 category: &'static str,
32 file: String,
33 doc_summary: String,
34 doc_examples: Vec<String>,
35 constructors: Vec<FnSig>,
36 methods: Vec<FnSig>,
37 builder_methods: Vec<FnSig>,
38 traits: Vec<String>,
39}
40
41struct ApiRef {
43 version: String,
44 types: Vec<ApiType>,
45}
46
47pub fn find_flodl_src(explicit: Option<&str>) -> Option<PathBuf> {
58 if let Some(p) = explicit {
59 let path = PathBuf::from(p);
60 if path.is_dir() {
61 return Some(path);
62 }
63 }
64
65 let mut dir = std::env::current_dir().ok()?;
67 for _ in 0..5 {
68 let candidate = dir.join("flodl/src");
69 if candidate.join("lib.rs").is_file() {
70 return Some(candidate);
71 }
72 if !dir.pop() {
73 break;
74 }
75 }
76
77 if let Some(home) = home_dir() {
79 let registry = home.join(".cargo/registry/src");
80 if registry.is_dir() {
81 if let Ok(entries) = fs::read_dir(®istry) {
83 for index_dir in entries.flatten() {
84 if let Ok(crates) = fs::read_dir(index_dir.path()) {
85 let mut best: Option<PathBuf> = None;
86 for entry in crates.flatten() {
87 let name = entry.file_name().to_string_lossy().to_string();
88 if name.starts_with("flodl-")
89 && !name.starts_with("flodl-sys")
90 && !name.starts_with("flodl-cli")
91 {
92 let src = entry.path().join("src");
93 if src.join("lib.rs").is_file() {
94 best = Some(src);
95 }
96 }
97 }
98 if best.is_some() {
99 return best;
100 }
101 }
102 }
103 }
104 }
105 }
106
107 if let Some(tag) = fetch_latest_tag() {
109 if let Some(cache) = cache_dir(&tag)
110 && let Some(src) = find_src_in_cache(&cache)
111 {
112 return Some(src);
113 }
114 match download_source(&tag) {
116 Ok(src) => return Some(src),
117 Err(e) => eprintln!("warning: could not download source: {}", e),
118 }
119 }
120
121 None
122}
123
124fn home_dir() -> Option<PathBuf> {
125 std::env::var_os("HOME")
126 .or_else(|| std::env::var_os("USERPROFILE"))
127 .map(PathBuf::from)
128}
129
130const REPO: &str = "flodl-labs/flodl";
135
136fn fetch_latest_tag() -> Option<String> {
138 let output = Command::new("curl")
140 .args([
141 "-sI",
142 &format!("https://github.com/{}/releases/latest", REPO),
143 ])
144 .stdout(Stdio::piped())
145 .stderr(Stdio::null())
146 .output()
147 .ok()?;
148
149 let stdout = String::from_utf8_lossy(&output.stdout);
150 for line in stdout.lines() {
151 let lower = line.to_lowercase();
152 if lower.starts_with("location:") {
153 let tag = line.rsplit('/').next()?.trim();
155 if !tag.is_empty() {
156 return Some(tag.to_string());
157 }
158 }
159 }
160 None
161}
162
163fn cache_dir(tag: &str) -> Option<PathBuf> {
165 let home = home_dir()?;
166 let flodl_home = std::env::var("FLODL_HOME")
167 .map(PathBuf::from)
168 .unwrap_or_else(|_| home.join(".flodl"));
169 Some(flodl_home.join("api-ref-cache").join(tag))
170}
171
172fn download_source(tag: &str) -> Result<PathBuf, String> {
175 let cache = cache_dir(tag).ok_or_else(|| "cannot determine home directory".to_string())?;
176
177 let src_dir = find_src_in_cache(&cache);
179 if let Some(src) = src_dir {
180 return Ok(src);
181 }
182
183 eprintln!("Downloading flodl {} source from GitHub...", tag);
184
185 let zip_url = format!("https://github.com/{}/archive/refs/tags/{}.zip", REPO, tag);
186
187 fs::create_dir_all(&cache).map_err(|e| format!("cannot create cache dir: {}", e))?;
188
189 let zip_path = cache.join("source.zip");
190 crate::util::http::download_file(&zip_url, &zip_path)?;
191
192 eprintln!("Extracting...");
193 crate::util::archive::extract_zip(&zip_path, &cache)?;
194
195 let _ = fs::remove_file(&zip_path);
197
198 find_src_in_cache(&cache)
199 .ok_or_else(|| "downloaded archive does not contain flodl/src/lib.rs".to_string())
200}
201
202fn find_src_in_cache(cache: &Path) -> Option<PathBuf> {
205 if !cache.is_dir() {
206 return None;
207 }
208 let direct = cache.join("flodl/src");
210 if direct.join("lib.rs").is_file() {
211 return Some(direct);
212 }
213 if let Ok(entries) = fs::read_dir(cache) {
215 for entry in entries.flatten() {
216 let path = entry.path();
217 if path.is_dir() {
218 let candidate = path.join("flodl/src");
219 if candidate.join("lib.rs").is_file() {
220 return Some(candidate);
221 }
222 }
223 }
224 }
225 None
226}
227
228fn categorize(rel_path: &str) -> &'static str {
234 if rel_path.contains("loss") {
235 "losses"
236 } else if rel_path.contains("optim") {
237 "optimizers"
238 } else if rel_path.contains("scheduler") {
239 "schedulers"
240 } else if rel_path.contains("nn/") || rel_path.starts_with("nn/") {
241 "modules"
242 } else if rel_path.starts_with("tensor") {
243 "tensor"
244 } else if rel_path.starts_with("autograd") {
245 "autograd"
246 } else if rel_path.starts_with("graph") {
247 "graph"
248 } else if rel_path.starts_with("distributed") {
249 "distributed"
250 } else if rel_path.starts_with("data") {
251 "data"
252 } else {
253 "other"
254 }
255}
256
257fn extract_docs(lines: &[&str], item_line: usize) -> (String, Vec<String>) {
260 let mut doc_lines = Vec::new();
262 let mut i = item_line.saturating_sub(1);
263 loop {
264 let line = lines[i].trim();
265 if line.starts_with("///") {
266 let text = line.trim_start_matches("///");
267 let text = text.strip_prefix(' ').unwrap_or(text);
269 doc_lines.push(text.to_string());
270 } else if line.starts_with("#[") || line.is_empty() {
271 if !doc_lines.is_empty() && line.is_empty() {
272 break;
273 }
274 } else {
275 break;
276 }
277 if i == 0 {
278 break;
279 }
280 i -= 1;
281 }
282 doc_lines.reverse();
283
284 let summary = doc_lines.first().cloned().unwrap_or_default();
285
286 let mut examples = Vec::new();
288 let mut in_code = false;
289 let mut current_block = String::new();
290
291 for line in &doc_lines {
292 if line.starts_with("```") {
293 if in_code {
294 if !current_block.trim().is_empty() {
296 examples.push(current_block.trim().to_string());
297 }
298 current_block.clear();
299 in_code = false;
300 } else {
301 in_code = true;
302 }
303 } else if in_code {
304 if !current_block.is_empty() {
305 current_block.push('\n');
306 }
307 current_block.push_str(line);
308 }
309 }
310
311 (summary, examples)
312}
313
314fn extract_fn_sig(line: &str) -> Option<String> {
316 let trimmed = line.trim();
317 let start = if trimmed.contains("pub fn ") {
319 trimmed.find("pub fn ")?
320 } else if trimmed.contains("pub const fn ") {
321 trimmed.find("pub const fn ")?
322 } else {
323 return None;
324 };
325
326 let sig = &trimmed[start..];
327 let sig = sig.trim_end_matches('{').trim_end_matches("where").trim();
329 Some(sig.to_string())
330}
331
332fn extract_fn_name(sig: &str) -> String {
334 let after_fn = sig.split("fn ").nth(1).unwrap_or("");
336 let name_end = after_fn.find('(').unwrap_or(after_fn.len());
337 let name_end = name_end.min(after_fn.find('<').unwrap_or(name_end));
339 after_fn[..name_end].to_string()
340}
341
342fn parse_file(src_root: &Path, path: &Path) -> Vec<ApiType> {
344 let content = match fs::read_to_string(path) {
345 Ok(c) => c,
346 Err(_) => return Vec::new(),
347 };
348
349 let rel_path = path
350 .strip_prefix(src_root)
351 .unwrap_or(path)
352 .to_string_lossy()
353 .to_string();
354
355 let category = categorize(&rel_path);
356 let lines: Vec<&str> = content.lines().collect();
357 let mut types: BTreeMap<String, ApiType> = BTreeMap::new();
358
359 for (i, line) in lines.iter().enumerate() {
361 let trimmed = line.trim();
362 if let Some(after) = trimmed.strip_prefix("pub struct ") {
363 let name_end = after
364 .find(|c: char| !c.is_alphanumeric() && c != '_')
365 .unwrap_or(after.len());
366 let name = after[..name_end].to_string();
367
368 if name.is_empty() || name.starts_with('_') {
369 continue;
370 }
371
372 if name.ends_with("Inner") || name.ends_with("State") && !name.contains("Trained") {
374 continue;
375 }
376
377 let (doc, examples) = extract_docs(&lines, i);
378
379 types.insert(
380 name.clone(),
381 ApiType {
382 name,
383 category,
384 file: rel_path.clone(),
385 doc_summary: doc,
386 doc_examples: examples,
387 constructors: Vec::new(),
388 methods: Vec::new(),
389 builder_methods: Vec::new(),
390 traits: Vec::new(),
391 },
392 );
393 }
394
395 if let Some(after) = trimmed.strip_prefix("pub enum ") {
397 let name_end = after
398 .find(|c: char| !c.is_alphanumeric() && c != '_')
399 .unwrap_or(after.len());
400 let name = after[..name_end].to_string();
401 if !name.is_empty() && !name.starts_with('_') {
402 let (doc, examples) = extract_docs(&lines, i);
403 types.insert(
404 name.clone(),
405 ApiType {
406 name,
407 category,
408 file: rel_path.clone(),
409 doc_summary: doc,
410 doc_examples: examples,
411 constructors: Vec::new(),
412 methods: Vec::new(),
413 builder_methods: Vec::new(),
414 traits: Vec::new(),
415 },
416 );
417 }
418 }
419 }
420
421 let mut current_impl: Option<(String, Option<String>)> = None; let mut brace_depth: i32 = 0;
424 let mut in_impl = false;
425 let mut in_test = false;
426
427 for line in lines.iter() {
428 let trimmed = line.trim();
429
430 if trimmed.contains("#[cfg(test)]") {
432 in_test = true;
433 }
434 if in_test {
435 if trimmed == "}" && brace_depth <= 1 {
436 in_test = false;
437 }
438 for c in trimmed.chars() {
440 if c == '{' {
441 brace_depth += 1;
442 }
443 if c == '}' {
444 brace_depth -= 1;
445 }
446 }
447 continue;
448 }
449
450 if trimmed.starts_with("impl ") || trimmed.starts_with("impl<") {
452 let impl_str = trimmed.to_string();
453
454 let (type_name, trait_name) = if impl_str.contains(" for ") {
456 let parts: Vec<&str> = impl_str.split(" for ").collect();
458 let trait_part = parts[0]
459 .trim_start_matches("impl ")
460 .trim_start_matches("impl<")
461 .split('>')
462 .next_back()
463 .unwrap_or("")
464 .trim();
465 let trait_name = trait_part.split('<').next().unwrap_or(trait_part).trim();
467 let type_part = parts.get(1).unwrap_or(&"");
468 let type_name = type_part
469 .split(|c: char| !c.is_alphanumeric() && c != '_')
470 .next()
471 .unwrap_or("")
472 .trim();
473 (type_name.to_string(), Some(trait_name.to_string()))
474 } else {
475 let after_impl = impl_str
477 .trim_start_matches("impl<")
478 .split('>')
479 .next_back()
480 .unwrap_or(impl_str.strip_prefix("impl ").unwrap_or(&impl_str));
481 let after_impl = after_impl
482 .strip_prefix("impl ")
483 .unwrap_or(after_impl.trim());
484 let type_name = after_impl
485 .split(|c: char| !c.is_alphanumeric() && c != '_')
486 .next()
487 .unwrap_or("")
488 .trim();
489 (type_name.to_string(), None)
490 };
491
492 if types.contains_key(&type_name) {
493 current_impl = Some((type_name, trait_name));
494 in_impl = true;
495 }
496 }
497
498 for c in trimmed.chars() {
500 if c == '{' {
501 brace_depth += 1;
502 }
503 if c == '}' {
504 brace_depth -= 1;
505 if brace_depth <= 0 && in_impl {
506 in_impl = false;
507 current_impl = None;
508 }
509 }
510 }
511
512 if in_impl
514 && (trimmed.starts_with("pub fn ") || trimmed.starts_with("pub const fn "))
515 && let Some((ref type_name, ref trait_name)) = current_impl
516 && let Some(sig) = extract_fn_sig(trimmed)
517 {
518 let fn_name = extract_fn_name(&sig);
519 let fn_sig = FnSig {
520 name: fn_name.clone(),
521 signature: sig,
522 };
523
524 if let Some(api_type) = types.get_mut(type_name) {
525 if let Some(t) = &trait_name
527 && !api_type.traits.contains(t)
528 {
529 api_type.traits.push(t.clone());
530 }
531
532 if fn_name == "new"
534 || fn_name == "on_device"
535 || fn_name == "no_bias"
536 || fn_name == "no_bias_on_device"
537 || fn_name == "configure"
538 || fn_name == "default"
539 {
540 api_type.constructors.push(fn_sig);
541 } else if fn_name.starts_with("with_") || fn_name == "done" || fn_name == "build" {
542 api_type.builder_methods.push(fn_sig);
543 } else {
544 api_type.methods.push(fn_sig);
545 }
546 }
547 }
548 }
549
550 let mut free_fns: Vec<FnSig> = Vec::new();
553 let mut depth: i32 = 0;
554 let mut in_test_block = false;
555
556 for (i, line) in lines.iter().enumerate() {
557 let trimmed = line.trim();
558
559 if trimmed.contains("#[cfg(test)]") {
560 in_test_block = true;
561 }
562
563 for c in trimmed.chars() {
564 if c == '{' {
565 depth += 1;
566 }
567 if c == '}' {
568 depth -= 1;
569 }
570 }
571
572 if in_test_block {
573 if depth <= 0 {
574 in_test_block = false;
575 }
576 continue;
577 }
578
579 if depth <= 1
581 && trimmed.starts_with("pub fn ")
582 && let Some(sig) = extract_fn_sig(trimmed)
583 {
584 let fn_name = extract_fn_name(&sig);
585 let (doc, _) = extract_docs(&lines, i);
586 free_fns.push(FnSig {
587 name: format!("{} -- {}", fn_name, doc),
588 signature: sig,
589 });
590 }
591 }
592
593 if !free_fns.is_empty() {
594 let file_stem = std::path::Path::new(&rel_path)
596 .file_stem()
597 .unwrap_or_default()
598 .to_string_lossy()
599 .to_string();
600
601 let label = match file_stem.as_str() {
602 "mod" => {
603 std::path::Path::new(&rel_path)
605 .parent()
606 .and_then(|p| p.file_name())
607 .unwrap_or_default()
608 .to_string_lossy()
609 .to_string()
610 }
611 other => other.to_string(),
612 };
613
614 types.insert(
615 format!("{}()", label),
616 ApiType {
617 name: format!("{} (functions)", label),
618 category: categorize(&rel_path),
619 file: rel_path,
620 doc_summary: String::new(),
621 doc_examples: Vec::new(),
622 constructors: Vec::new(),
623 methods: free_fns,
624 builder_methods: Vec::new(),
625 traits: Vec::new(),
626 },
627 );
628 }
629
630 types.into_values().collect()
631}
632
633fn parse_source_tree(src_root: &Path) -> Vec<ApiType> {
635 let mut all_types = Vec::new();
636 walk_dir(src_root, src_root, &mut all_types);
637 all_types.sort_by(|a, b| a.category.cmp(b.category).then(a.name.cmp(&b.name)));
639 all_types
640}
641
642fn walk_dir(root: &Path, dir: &Path, types: &mut Vec<ApiType>) {
643 let entries = match fs::read_dir(dir) {
644 Ok(e) => e,
645 Err(_) => return,
646 };
647 for entry in entries.flatten() {
648 let path = entry.path();
649 if path.is_dir() {
650 walk_dir(root, &path, types);
651 } else if path.extension().is_some_and(|e| e == "rs") {
652 let mut file_types = parse_file(root, &path);
653 types.append(&mut file_types);
654 }
655 }
656}
657
658fn get_version(src_root: &Path) -> String {
663 let crate_dir = src_root.parent().unwrap_or(src_root);
665 for dir in &[crate_dir, crate_dir.parent().unwrap_or(crate_dir)] {
666 let cargo_toml = dir.join("Cargo.toml");
667 if let Ok(content) = fs::read_to_string(cargo_toml) {
668 for line in content.lines() {
670 let trimmed = line.trim();
671 if trimmed.starts_with("version")
672 && trimmed.contains('"')
673 && !trimmed.contains("workspace")
674 && let Some(v) = trimmed.split('"').nth(1)
675 {
676 return v.to_string();
677 }
678 }
679 }
680 }
681 "unknown".to_string()
682}
683
684fn print_text(api: &ApiRef) {
685 println!("flodl API Reference v{}", api.version);
686 println!("{}", "=".repeat(40));
687 println!();
688
689 let mut by_category: BTreeMap<&str, Vec<&ApiType>> = BTreeMap::new();
690 for t in &api.types {
691 by_category.entry(t.category).or_default().push(t);
692 }
693
694 for (category, types) in &by_category {
695 println!("## {}", category_title(category));
696 println!();
697
698 for t in types {
699 if t.constructors.is_empty() && t.methods.is_empty() && t.builder_methods.is_empty() {
701 continue;
702 }
703
704 print!("### {}", t.name);
705 if !t.traits.is_empty() {
706 print!(" (implements: {})", t.traits.join(", "));
707 }
708 println!();
709
710 if !t.doc_summary.is_empty() {
711 println!(" {}", t.doc_summary);
712 }
713 println!(" file: {}", t.file);
714
715 if !t.constructors.is_empty() {
716 println!(" constructors:");
717 for f in &t.constructors {
718 println!(" {}", f.signature);
719 }
720 }
721 if !t.builder_methods.is_empty() {
722 println!(" builder:");
723 for f in &t.builder_methods {
724 println!(" .{}()", f.name);
725 }
726 }
727 if !t.methods.is_empty() {
728 println!(" methods:");
729 for f in &t.methods {
730 println!(" {}", f.signature);
731 }
732 }
733 if !t.doc_examples.is_empty() {
734 println!(" examples:");
735 for (ei, ex) in t.doc_examples.iter().enumerate() {
736 if ei > 0 {
737 println!();
738 }
739 for line in ex.lines() {
740 println!(" {}", line);
741 }
742 }
743 }
744 println!();
745 }
746 }
747}
748
749fn print_json(api: &ApiRef) {
750 print!(
751 "{{\"version\":\"{}\",\"types\":[",
752 escape_json(&api.version)
753 );
754
755 for (i, t) in api.types.iter().enumerate() {
756 if t.constructors.is_empty() && t.methods.is_empty() && t.builder_methods.is_empty() {
757 continue;
758 }
759
760 if i > 0 {
761 print!(",");
762 }
763
764 print!(
765 "{{\"name\":\"{}\",\"category\":\"{}\",\"file\":\"{}\",\"doc\":\"{}\",",
766 escape_json(&t.name),
767 escape_json(t.category),
768 escape_json(&t.file),
769 escape_json(&t.doc_summary),
770 );
771
772 print!(
773 "\"traits\":[{}],",
774 t.traits
775 .iter()
776 .map(|s| format!("\"{}\"", escape_json(s)))
777 .collect::<Vec<_>>()
778 .join(",")
779 );
780
781 print!(
782 "\"constructors\":[{}],",
783 t.constructors
784 .iter()
785 .map(|f| format!(
786 "{{\"name\":\"{}\",\"sig\":\"{}\"}}",
787 escape_json(&f.name),
788 escape_json(&f.signature)
789 ))
790 .collect::<Vec<_>>()
791 .join(",")
792 );
793
794 print!(
795 "\"builder_methods\":[{}],",
796 t.builder_methods
797 .iter()
798 .map(|f| format!("\"{}\"", escape_json(&f.name)))
799 .collect::<Vec<_>>()
800 .join(",")
801 );
802
803 print!(
804 "\"methods\":[{}],",
805 t.methods
806 .iter()
807 .map(|f| format!(
808 "{{\"name\":\"{}\",\"sig\":\"{}\"}}",
809 escape_json(&f.name),
810 escape_json(&f.signature)
811 ))
812 .collect::<Vec<_>>()
813 .join(",")
814 );
815
816 print!(
817 "\"examples\":[{}]",
818 t.doc_examples
819 .iter()
820 .map(|e| format!("\"{}\"", escape_json(e)))
821 .collect::<Vec<_>>()
822 .join(",")
823 );
824
825 print!("}}");
826 }
827
828 println!("]}}");
829}
830
831fn category_title(cat: &str) -> &str {
832 match cat {
833 "modules" => "Modules (nn)",
834 "losses" => "Losses",
835 "optimizers" => "Optimizers",
836 "schedulers" => "Schedulers",
837 "tensor" => "Tensor",
838 "autograd" => "Autograd",
839 "graph" => "Graph",
840 "distributed" => "Distributed",
841 "data" => "Data",
842 other => other,
843 }
844}
845
846pub fn run(json: bool, path: Option<&str>) -> Result<(), String> {
851 let src_root = find_flodl_src(path).ok_or_else(|| {
852 "Could not find flodl source. Run from a flodl checkout, \
853 or pass --path <flodl/src/>."
854 .to_string()
855 })?;
856
857 let version = get_version(&src_root);
858 let types = parse_source_tree(&src_root);
859
860 let api = ApiRef { version, types };
861
862 if json {
863 print_json(&api);
864 } else {
865 print_text(&api);
866 }
867
868 Ok(())
869}