use std::collections::HashMap;
use std::path::{Path, PathBuf};
pub mod forces;
pub mod graph;
pub mod layout;
pub mod scene;
pub mod symbols;
pub mod thumbs;
pub mod workspace;
pub use forces::Forces;
pub use graph::{Graph, Id, Kind, Node, Relation};
pub use layout::Layout;
pub use scene::CodeScene;
const TEXT: &[&str] = &[
"rs", "toml", "md", "txt", "json", "yaml", "yml", "wgsl", "glsl", "hlsl", "py", "js", "ts",
"tsx", "jsx", "html", "css", "c", "h", "cpp", "hpp", "sh", "ps1", "lock", "xml", "svg", "csv",
"cfg", "ini", "ron", "go", "java", "kt", "swift", "rb", "php", "lua", "zig",
];
const BYTES_PER_LINE: u64 = 40;
const LARGEST_READ: u64 = 4_000_000;
pub fn index(root: &Path) -> Result<Graph, String> {
let root = normalize(root).map_err(|error| format!("{}: {error}", root.display()))?;
if !root.is_dir() {
return Err(format!("{} is not a folder", root.display()));
}
let name = root
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| root.display().to_string());
let workspace = workspace::read(&root).ok();
let crates: HashMap<PathBuf, &workspace::Crate> = workspace
.iter()
.flat_map(|w| w.crates.iter())
.filter_map(|krate| normalize(&krate.dir).ok().map(|dir| (dir, krate)))
.collect();
let mut graph = Graph::new(name);
let mut dirs: HashMap<PathBuf, Id> = HashMap::from([(root.clone(), graph.root())]);
let mut crate_ids: HashMap<String, Id> = HashMap::new();
let mut pending = symbols::Pending::default();
let walker = ignore::WalkBuilder::new(&root)
.hidden(false)
.require_git(false)
.filter_entry(|entry| {
let name = entry.file_name().to_string_lossy();
!matches!(name.as_ref(), ".git" | "target" | "node_modules")
})
.sort_by_file_name(|a, b| a.cmp(b))
.build();
for entry in walker.flatten() {
let path = entry.path().to_path_buf();
if path == root {
continue;
}
let Some(&parent) = path.parent().and_then(|p| dirs.get(p)) else {
continue;
};
let file_name = entry.file_name().to_string_lossy().into_owned();
if entry.file_type().is_some_and(|t| t.is_dir()) {
let krate = crates.get(&path);
let node = match krate {
Some(krate) => Node::new(Kind::Crate, &krate.name),
None => Node::new(Kind::Dir, file_name),
};
let id = graph.add(parent, node.at(path.clone(), 0, 0));
if let Some(krate) = krate {
crate_ids.insert(krate.name.clone(), id);
}
dirs.insert(path, id);
} else {
let bytes = entry.metadata().map(|m| m.len()).unwrap_or(0);
let lines = lines_of(&path, bytes);
let node = Node::new(Kind::File, file_name).at(path.clone(), 1, lines);
let id = graph.add(parent, node);
if path.extension().is_some_and(|e| e == "rs") {
pending.extend(symbols::index_file(&mut graph, id, &path));
}
}
}
for krate in workspace.iter().flat_map(|w| w.crates.iter()) {
for dep in &krate.dependencies {
if let (Some(&from), Some(&to)) = (crate_ids.get(&krate.name), crate_ids.get(dep)) {
graph.relate(from, to, Relation::DependsOn);
}
}
}
symbols::link(&mut graph, pending);
Ok(graph)
}
fn normalize(path: &Path) -> std::io::Result<PathBuf> {
use std::path::Component;
let mut out = PathBuf::new();
for component in std::path::absolute(path)?.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
out.pop();
}
other => out.push(other),
}
}
Ok(out)
}
fn lines_of(path: &Path, bytes: u64) -> usize {
let text = path
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| TEXT.contains(&e.to_ascii_lowercase().as_str()));
if text
&& bytes < LARGEST_READ
&& let Ok(source) = std::fs::read_to_string(path)
{
return source.lines().count().max(1);
}
(bytes / BYTES_PER_LINE).max(1) as usize
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_path_with_dots_folds_to_the_folder_it_means() {
let here = Path::new(env!("CARGO_MANIFEST_DIR"));
let workspace = normalize(&here.join("../../crates/./codecraft/../..")).unwrap();
assert_eq!(workspace, normalize(&here.join("../..")).unwrap());
assert!(
!workspace
.components()
.any(|c| c == std::path::Component::ParentDir)
);
assert_eq!(normalize(here).unwrap(), here);
}
#[test]
fn this_workspace_indexes_itself() {
let graph = index(
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.as_path(),
)
.expect("indexes");
let engine = graph.crate_named("codecraft").expect("the engine crate");
let tanks = graph.crate_named("tanks").expect("the tanks example");
assert!(graph.dependencies(tanks).contains(&engine));
assert_eq!(graph.node(engine).path, "crates/codecraft");
assert!(
graph.find("README.md").is_some(),
"plain files are in the tree"
);
assert!(
graph
.find("crates/codecraft/src/code/graph.rs::Graph")
.is_some()
);
assert!(
graph
.find("crates/codecraft/src/code/graph.rs::tests")
.is_none(),
"test modules are skipped"
);
let scene = graph
.find("crates/codecraft/src/scene/scene.rs::Scene")
.expect("the Scene trait");
let implementors = graph.relations(scene);
assert!(
implementors.iter().any(|&(other, relation, outgoing)| {
relation == Relation::Implements
&& !outgoing
&& graph.node(other).name == "SceneGame"
}),
"tanks' SceneGame implements Scene across crates: {implementors:?}"
);
}
#[test]
fn any_folder_indexes_even_without_cargo() {
let dir = std::env::temp_dir().join(format!("codecraft-plain-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("notes")).unwrap();
std::fs::write(dir.join("notes/a.md"), "one\ntwo\nthree\n").unwrap();
std::fs::write(dir.join("blob.bin"), vec![0u8; 4000]).unwrap();
std::fs::write(dir.join(".gitignore"), "ignored.txt\n").unwrap();
std::fs::write(dir.join("ignored.txt"), "x\n").unwrap();
let graph = index(&dir).expect("a plain folder indexes");
let notes = graph.find("notes").expect("the folder");
assert_eq!(graph.node(notes).kind, Kind::Dir);
let a = graph.find("notes/a.md").expect("the file");
assert_eq!((graph.node(a).kind, graph.node(a).lines), (Kind::File, 3));
assert_eq!(graph.node(graph.find("blob.bin").unwrap()).lines, 100);
assert!(
graph.find("ignored.txt").is_none(),
".gitignore is honoured"
);
assert!(graph.crates().is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
}