codecraft 0.2.0

A minimalist 3D game engine built on parts of Bevy (ECS, color) with wgpu and winit: OpenPBR materials, clustered lighting, a yakui-drawn UI, audio and gamepad haptics; its binary maps any folder, and the symbols of its Rust files, as a 3D wall of boxes
Documentation
//! A 3D map of a folder: its folders and files as nested boxes, and inside any Rust file its
//! symbols, with crates and their dependencies picked out when the folder is a Cargo workspace.
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;

/// Files whose lines are counted; anything else is sized by its bytes.
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;

/// Reads the folder at `root` into a graph, honouring `.gitignore` and skipping build output.
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)
}

/// Absolute with `.` and `..` folded away, which `std::path::absolute` only does on Windows;
/// crate folders from cargo come clean, and must match the walked paths exactly.
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)
}

/// Lines in a text file, or a guess from the size of anything else.
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);
    }
}