Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435//! Diagrams linked to code (and `repomap`: the repo drawn as a board).//!//! A shape can point at code: `link = "drawgent:code/<path>#L<a>-L<b>"` (clickable in the editor)//! and `customData.drawgentCode = {path, line?, endLine?, symbol?}` (structured). This module//! browses the workspace for the editor's Code tab and checks links against the code//! (`drawgent check`, the Code tab's badges, the agent's `check_links`).pub mod repomap;
use crate::canvas::el::{Json, id, kind, set, text};use anyhow::{Context, Result, bail};use serde_json::{Value, json};use std::path::{Path, PathBuf};
pub const SCHEME: &str = "drawgent:code/";const MAX_FILE: u64 = 2 << 20;const SKIP_DIRS: [&str; 8] = [".git", "node_modules", "target", "dist", ".drawgent", ".venv", "__pycache__", "vendor"];
/// Structured code reference of an element, if any.pub fn code_of(e: &Json) -> Option<&Json> { e.get("customData")?.get("drawgentCode").filter(|c| c.get("path").is_some())}
/// Set (or clear, with `None`) the code link of an element.pub fn apply_link(e: &mut Json, code: Option<&Json>) { match code { Some(c) => { let path = c["path"].as_str().unwrap_or_default().trim_start_matches("./"); let mut link = format!("{SCHEME}{path}"); if let Some(l) = c["line"].as_u64() { link.push_str(&format!("#L{l}")); if let Some(end) = c["endLine"].as_u64().filter(|end| *end > l) { link.push_str(&format!("-L{end}")); } } let mut clean = json!({"path": path}); for k in ["line", "endLine", "symbol"] { if !c[k].is_null() { clean[k] = c[k].clone(); } } set(e, "link", link); let mut cd = e.get("customData").cloned().filter(Value::is_object).unwrap_or_else(|| json!({})); cd["drawgentCode"] = clean; set(e, "customData", cd); } None => { if text(e, "link").is_some_and(|l| l.starts_with(SCHEME)) { set(e, "link", Value::Null); } if let Some(cd) = e.get_mut("customData").and_then(Value::as_object_mut) { cd.remove("drawgentCode"); } } }}
/// Resolve a workspace-relative path, refusing anything outside the workspace.pub fn resolve(root: &Path, rel: &str) -> Result<PathBuf> { let root = root.canonicalize().context("workspace not found")?; let p = root.join(rel.trim_start_matches('/')).canonicalize().with_context(|| format!("no such file: {rel}"))?; if !p.starts_with(&root) { bail!("{rel} is outside the workspace"); } Ok(p)}
/// Files of the workspace, without what git ignores: .gitignore files at every level,/// .git/info/exclude and the global gitignore apply (with or without a git repo), even to files/// that are tracked but ignored since. Hidden files and the usual build / dependency folders are/// skipped too.pub fn list_files(root: &Path) -> Vec<String> { let mut out = vec![]; let walker = ignore::WalkBuilder::new(root) .hidden(true) .require_git(false) .max_depth(Some(12)) .filter_entry(|e| !(e.file_type().is_some_and(|t| t.is_dir()) && SKIP_DIRS.contains(&e.file_name().to_string_lossy().as_ref()))) .build(); for e in walker.flatten() { if out.len() >= 50_000 { break; } if e.file_type().is_some_and(|t| t.is_file()) { if let Ok(rel) = e.path().strip_prefix(root) { out.push(rel.to_string_lossy().replace('\\', "/")); } } } out.sort(); out}
/// Text of a workspace file (refuses binaries and very large files).pub fn read_text(root: &Path, rel: &str) -> Result<String> { let p = resolve(root, rel)?; let meta = std::fs::metadata(&p)?; if meta.len() > MAX_FILE { bail!("{rel} is too large to show ({} KB)", meta.len() / 1024); } let bytes = std::fs::read(&p)?; if bytes.iter().take(8000).any(|b| *b == 0) { bail!("{rel} is a binary file"); } Ok(String::from_utf8_lossy(&bytes).into_owned())}
/// Case-insensitive text search across the workspace.pub fn search(root: &Path, query: &str, limit: usize) -> Vec<Json> { let q = query.to_lowercase(); let mut hits = vec![]; if q.trim().is_empty() { return hits; } for f in list_files(root) { // File names match too. if f.to_lowercase().contains(&q) { hits.push(json!({"path": f, "line": null, "text": ""})); } let Ok(content) = read_text(root, &f) else { continue }; for (i, line) in content.lines().enumerate() { if line.to_lowercase().contains(&q) { hits.push(json!({"path": f, "line": i + 1, "text": line.trim().chars().take(200).collect::<String>()})); if hits.len() >= limit { return hits; } } } } hits}
/// Line (1-based) where `symbol` appears as a whole word, preferring definitions.fn find_symbol(content: &str, symbol: &str) -> Option<usize> { let re = regex::Regex::new(&format!(r"\b{}\b", regex::escape(symbol))).ok()?; let def = regex::Regex::new(&format!( r"\b(fn|func|function|def|class|struct|enum|trait|interface|type|impl|mod|module|const|let|var)\s+(\([^)]*\)\s*)?{}\b", regex::escape(symbol) )) .ok()?; let lines: Vec<&str> = content.lines().collect(); lines.iter().position(|l| def.is_match(l)).or_else(|| lines.iter().position(|l| re.is_match(l))).map(|i| i + 1)}
/// Check one element's link. Returns a report entry; `fix` holds a corrected code reference/// when the problem can be repaired automatically.pub fn check_element(root: &Path, e: &Json, files: &[String]) -> Option<Json> { let c = code_of(e)?; let path = c["path"].as_str().unwrap_or_default(); let label = e.get("drawgentLabel").and_then(Value::as_str).unwrap_or_default(); let mut entry = json!({"id": id(e), "type": kind(e), "label": label, "code": c, "status": "ok"}); let Ok(content) = read_text(root, path) else { // Missing: suggest files with the same name elsewhere. let base = Path::new(path).file_name().map(|b| b.to_string_lossy().to_string()).unwrap_or_default(); let candidates: Vec<&String> = files.iter().filter(|f| Path::new(f).file_name().is_some_and(|n| n.to_string_lossy() == base)).collect(); entry["status"] = json!("missing"); entry["message"] = json!(format!("{path} doesn't exist")); if candidates.len() == 1 { let mut fixed = c.clone(); fixed["path"] = json!(candidates[0]); entry["message"] = json!(format!("{path} doesn't exist; moved to {}?", candidates[0])); entry["fix"] = fixed; } else if !candidates.is_empty() { entry["candidates"] = json!(candidates); } return Some(entry); }; let n_lines = content.lines().count(); if let Some(sym) = c["symbol"].as_str().filter(|s| !s.is_empty()) { match find_symbol(&content, sym) { None => { entry["status"] = json!("stale"); entry["message"] = json!(format!("`{sym}` is no longer in {path}")); // Did it move to another file? let elsewhere: Vec<String> = files .iter() .filter(|f| f.as_str() != path && !f.ends_with(".excalidraw")) // diagrams mention symbols too .filter(|f| read_text(root, f).ok().and_then(|t| find_symbol(&t, sym)).is_some()) .take(5) .cloned() .collect(); if elsewhere.len() == 1 { let other = read_text(root, &elsewhere[0]).unwrap_or_default(); let mut fixed = c.clone(); fixed["path"] = json!(elsewhere[0]); fixed["line"] = json!(find_symbol(&other, sym)); entry["message"] = json!(format!("`{sym}` moved to {}", elsewhere[0])); entry["fix"] = fixed; } else if !elsewhere.is_empty() { entry["candidates"] = json!(elsewhere); } } Some(at) => { let line = c["line"].as_u64().map(|l| l as usize); if line.is_some_and(|l| l != at) { let mut fixed = c.clone(); let shift = at as i64 - line.unwrap() as i64; fixed["line"] = json!(at); if let Some(end) = c["endLine"].as_i64() { fixed["endLine"] = json!(end + shift); } entry["status"] = json!("moved"); entry["message"] = json!(format!("`{sym}` is now at line {at} (link says {})", line.unwrap())); entry["fix"] = fixed; } } } } else if let Some(l) = c["line"].as_u64().filter(|l| *l as usize > n_lines) { entry["status"] = json!("stale"); entry["message"] = json!(format!("{path} has {n_lines} lines, link points at line {l}")); } Some(entry)}
/// Check every linked element. `labels` resolves shape labels for readable reports.pub fn check_elements(root: &Path, elements: &[Json]) -> Vec<Json> { let files = list_files(root); let labels: std::collections::HashMap<&str, &str> = elements .iter() .filter(|e| kind(e) == "text") .filter_map(|t| Some((text(t, "containerId")?, text(t, "text")?))) .collect(); elements .iter() .filter(|e| !crate::canvas::el::deleted(e)) .filter_map(|e| { let mut e2 = e.clone(); if let Some(l) = labels.get(id(e)) { set(&mut e2, "drawgentLabel", *l); } check_element(root, &e2, &files) }) .collect()}
/// `drawgent check`: validate code links in diagram files (for CI); `--fix` repairs what can/// be repaired. Returns the number of problems left.pub fn check_cli(root: &Path, diagrams: &[PathBuf], fix: bool, as_json: bool) -> Result<usize> { let mut problems = 0; let mut report = vec![]; for d in diagrams { let content = std::fs::read_to_string(d).with_context(|| format!("reading {}", d.display()))?; let mut elements = crate::canvas::diagram::parse(&content)?; let results = check_elements(root, &elements); let mut fixed = 0; for r in &results { let st = r["status"].as_str().unwrap_or("ok"); if st == "ok" { continue; } if let (true, Some(f)) = (fix, r.get("fix")) { if let Some(e) = elements.iter_mut().find(|e| id(e) == r["id"].as_str().unwrap_or_default()) { apply_link(e, Some(f)); fixed += 1; continue; } } problems += 1; } if fixed > 0 { std::fs::write(d, crate::canvas::diagram::render(&elements))?; } if as_json { report.push(json!({"diagram": d, "links": results, "fixed": fixed})); } else { let linked = results.len(); let bad: Vec<&Json> = results.iter().filter(|r| r["status"] != "ok").collect(); println!("{}: {linked} linked shape(s), {} problem(s){}", d.display(), bad.len(), if fixed > 0 { format!(", {fixed} fixed") } else { String::new() }); for r in bad { let who = r["label"].as_str().filter(|l| !l.is_empty()).map(|l| format!("\"{l}\"")).unwrap_or_else(|| r["id"].as_str().unwrap_or_default().to_string()); let fixable = if r.get("fix").is_some() && !fix { " (fixable: --fix)" } else { "" }; println!(" {:<8} {who}: {}{fixable}", r["status"].as_str().unwrap_or_default(), r["message"].as_str().unwrap_or_default()); } } } if as_json { println!("{}", serde_json::to_string_pretty(&report)?); } Ok(problems)}
#[cfg(test)]mod tests { use super::*;
#[test] fn files_skip_what_git_ignores() { let dir = std::env::temp_dir().join(format!("dg-ignore-{}", std::process::id())); let w = |p: &str, s: &str| { let f = dir.join(p); std::fs::create_dir_all(f.parent().unwrap()).unwrap(); std::fs::write(f, s).unwrap(); }; w(".gitignore", "*.log\ngen/\n"); w("src/main.rs", "fn main() {}\n"); w("src/debug.log", "noise\n"); w("gen/out.rs", "// generated\n"); w("web/.gitignore", "local.js\n"); w("web/app.js", "1\n"); w("web/local.js", "2\n"); w("node_modules/x/index.js", "3\n"); // No git repo: .gitignore files still apply, at every level. assert_eq!(list_files(&dir), ["src/main.rs", "web/app.js"]); // Tracked, then ignored: still left out. let git = |args: &[&str]| std::process::Command::new("git").arg("-C").arg(&dir).args(args).output().map(|o| o.status.success()).unwrap_or(false); if git(&["init", "-q"]) && git(&["add", "-f", "src/debug.log", "src/main.rs"]) { assert_eq!(list_files(&dir), ["src/main.rs", "web/app.js"]); } std::fs::remove_dir_all(&dir).ok(); }
#[test] fn link_roundtrip() { let mut e = json!({"id": "a", "type": "rectangle"}); apply_link(&mut e, Some(&json!({"path": "./src/main.rs", "line": 10, "endLine": 20, "symbol": "main"}))); assert_eq!(e["link"], "drawgent:code/src/main.rs#L10-L20"); assert_eq!(code_of(&e).unwrap()["path"], "src/main.rs"); apply_link(&mut e, None); assert!(e["link"].is_null() && code_of(&e).is_none()); }
#[test] fn symbols_prefer_definitions() { let src = "use foo;\nlet x = run();\nfn run() {}\n"; assert_eq!(find_symbol(src, "run"), Some(3)); assert_eq!(find_symbol(src, "nothing"), None); }}
// ---------------- references: what the human pointed at ----------------
/// Layout of code/folder cards on the canvas (web/cards.jsx renders them the same way):/// a header, then one row per line (code) or entry (folder), in scene pixels.pub const CARD_HEADER: f64 = 28.0;pub const CARD_ROW: f64 = 18.0;
/// Card of an embeddable element: {kind: "code"|"folder", path, line}.pub fn card_of(e: &Json) -> Option<&Json> { e.get("customData")?.get("drawgentCard").filter(|c| c.get("path").is_some())}
/// Direct children of a folder, as the folder card lists them: sub-folders first, then files.pub fn folder_entries(files: &[String], dir: &str) -> Vec<String> { let prefix = if dir.is_empty() || dir.ends_with('/') { dir.to_string() } else { format!("{dir}/") }; let mut dirs = std::collections::BTreeSet::new(); let mut leaves = std::collections::BTreeSet::new(); for f in files.iter().filter(|f| f.starts_with(&prefix)) { let rest = &f[prefix.len()..]; match rest.split_once('/') { Some((d, _)) => { dirs.insert(format!("{prefix}{d}/")); } None => { leaves.insert(f.clone()); } } } dirs.into_iter().chain(leaves).collect()}
/// What part of a card a zone covers: code lines (as a ref) or folder entries (as refs).pub fn card_refs(files: &[String], card: &Json, card_box: (f64, f64, f64, f64), zone: (f64, f64, f64, f64)) -> Vec<Json> { let (cx, cy, cw, ch) = card_box; let (zx, zy, zw, zh) = zone; let (top, bottom) = (zy.max(cy + CARD_HEADER), (zy + zh).min(cy + ch)); if zx > cx + cw || zx + zw < cx || bottom <= top { // Only the header (or nothing): the whole card. let path = card["path"].as_str().unwrap_or_default(); return vec![if card["kind"] == "folder" { json!({"path": path, "folder": true}) } else { json!({"path": path}) }]; } let first = ((top - cy - CARD_HEADER) / CARD_ROW).floor().max(0.0) as usize; let last = ((bottom - cy - CARD_HEADER) / CARD_ROW).ceil().max(1.0) as usize - 1; let path = card["path"].as_str().unwrap_or_default(); if card["kind"] == "folder" { let start = card["line"].as_u64().unwrap_or(1).max(1) as usize - 1; folder_entries(files, path) .into_iter() .skip(start + first) .take(last - first + 1) .map(|p| if p.ends_with('/') { json!({"path": p, "folder": true}) } else { json!({"path": p}) }) .collect() } else { let start = card["line"].as_u64().unwrap_or(1).max(1) as usize; vec![json!({"path": path, "line": start + first, "endLine": start + last})] }}
/// Resolve references for the agent: code excerpts with line numbers, folder summaries.pub fn resolve_refs(root: &Path, refs: &[Json]) -> Vec<Json> { let files = list_files(root); let mut seen = std::collections::HashSet::new(); let mut out = vec![]; for r in refs { let Some(path) = r["path"].as_str() else { continue }; let key = format!("{path}:{}:{}", r["line"], r["endLine"]); if !seen.insert(key) { continue; } if r["folder"] == true || path.ends_with('/') { let dir = path.trim_end_matches('/'); let inside: Vec<&String> = files.iter().filter(|f| f.starts_with(&format!("{dir}/")) || dir.is_empty()).collect(); out.push(json!({"folder": format!("{dir}/"), "files": inside.len(), "entries": folder_entries(&files, dir).into_iter().take(40).collect::<Vec<_>>()})); continue; } let Ok(content) = read_text(root, path) else { out.push(json!({"path": path, "error": "not readable"})); continue; }; let lines: Vec<&str> = content.lines().collect(); let (from, to) = match r["line"].as_u64() { Some(l) => (l as usize, r["endLine"].as_u64().map(|e| e as usize).unwrap_or(l as usize)), None => (1, lines.len().min(40)), }; let from = from.clamp(1, lines.len().max(1)); let to = to.clamp(from, (from + 79).min(lines.len().max(1))); let excerpt: Vec<String> = (from..=to).filter_map(|n| lines.get(n - 1).map(|l| format!("{n:>5} | {l}"))).collect(); out.push(json!({"path": path, "lines": format!("{from}-{to}"), "totalLines": lines.len(), "excerpt": excerpt.join("\n")})); } out}
/// Prompt section for resolved references.pub fn refs_prompt(resolved: &[Json]) -> String { let mut s = String::from("Code the human pointed at (paths are relative to your working directory):\n"); for r in resolved { if let Some(f) = r["folder"].as_str() { s.push_str(&format!("- folder {f} ({} files): {}\n", r["files"], r["entries"].as_array().map(|e| e.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(", ")).unwrap_or_default())); } else if let Some(e) = r["excerpt"].as_str() { s.push_str(&format!("- {} lines {} of {}:\n```\n{e}\n```\n", r["path"].as_str().unwrap_or_default(), r["lines"].as_str().unwrap_or_default(), r["totalLines"])); } else { s.push_str(&format!("- {} ({})\n", r["path"].as_str().unwrap_or_default(), r["error"].as_str().unwrap_or("?"))); } } s}