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