use std::collections::BTreeSet;
use std::path::Path;
pub const DEFAULT_DOCS_DIR: &str = "tokensave-docs";
const SIDECAR_SUFFIX: &str = ".readme.md";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DocFile {
pub path: String,
pub covers: Vec<String>,
pub origin: DocOrigin,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DocOrigin {
Sidecar,
DocsDir,
}
impl DocOrigin {
pub fn as_str(self) -> &'static str {
match self {
DocOrigin::Sidecar => "sidecar",
DocOrigin::DocsDir => "docs_dir",
}
}
}
pub fn sidecar_stem(path: &str) -> Option<&str> {
let normalized = path;
let len = normalized.len();
if len <= SIDECAR_SUFFIX.len() {
return None;
}
let (stem, suffix) = normalized.split_at(len - SIDECAR_SUFFIX.len());
if !suffix.eq_ignore_ascii_case(SIDECAR_SUFFIX) {
return None;
}
if stem.is_empty() {
return None;
}
Some(stem)
}
pub fn is_in_docs_dir(path: &str, docs_dir: &str) -> bool {
let dir = docs_dir.trim_end_matches('/');
if dir.is_empty() {
return false;
}
path.strip_prefix(dir)
.is_some_and(|rest| rest.starts_with('/'))
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct FrontMatter {
pub applies_to: Vec<String>,
}
pub fn parse_front_matter(content: &str) -> Option<FrontMatter> {
let content = content.strip_prefix('\u{feff}').unwrap_or(content);
let rest = content.strip_prefix("---")?;
let rest = rest
.strip_prefix('\n')
.or_else(|| rest.strip_prefix("\r\n"))?;
let mut body = String::new();
let mut closed = false;
for line in rest.lines() {
if line.trim_end() == "---" {
closed = true;
break;
}
body.push_str(line);
body.push('\n');
}
if !closed {
return None;
}
Some(FrontMatter {
applies_to: parse_applies_to(&body),
})
}
fn parse_applies_to(body: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut in_block = false;
for raw in body.lines() {
let line = raw.trim_end();
if line.trim().is_empty() || line.trim_start().starts_with('#') {
continue;
}
if in_block {
let trimmed = line.trim_start();
if let Some(item) = trimmed.strip_prefix("- ") {
out.push(unquote(item.trim()));
continue;
}
if trimmed == "-" {
continue;
}
in_block = false;
}
let Some((key, value)) = line.split_once(':') else {
continue;
};
if key.trim() != "applies_to" {
continue;
}
let value = value.trim();
if value.is_empty() {
in_block = true;
continue;
}
if let Some(inner) = value.strip_prefix('[').and_then(|v| v.strip_suffix(']')) {
out.extend(
inner
.split(',')
.map(|item| unquote(item.trim()))
.filter(|item| !item.is_empty()),
);
} else {
out.push(unquote(value));
}
}
out.retain(|item| !item.is_empty());
out
}
fn unquote(value: &str) -> String {
let bytes = value.as_bytes();
if bytes.len() >= 2 {
let first = bytes[0];
let last = bytes[bytes.len() - 1];
if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
return value[1..value.len() - 1].to_string();
}
}
value.to_string()
}
pub fn resolve_globs(patterns: &[String], indexed_files: &[String]) -> Vec<String> {
let compiled: Vec<glob::Pattern> = patterns
.iter()
.filter_map(|p| glob::Pattern::new(p).ok())
.collect();
if compiled.is_empty() {
return Vec::new();
}
let mut matched: BTreeSet<&str> = BTreeSet::new();
for file in indexed_files {
if compiled.iter().any(|p| p.matches(file)) {
matched.insert(file.as_str());
}
}
matched.into_iter().map(ToString::to_string).collect()
}
pub fn resolve_sidecar(stem: &str, indexed_files: &[String]) -> Vec<String> {
let prefix = format!("{stem}.");
let mut matched: BTreeSet<&str> = BTreeSet::new();
for file in indexed_files {
if file.len() <= prefix.len() || !file.starts_with(&prefix) {
continue;
}
if sidecar_stem(file).is_some() {
continue;
}
if file[prefix.len()..].contains('/') {
continue;
}
matched.insert(file.as_str());
}
matched.into_iter().map(ToString::to_string).collect()
}
pub fn discover_docs<F>(
markdown_files: &[String],
indexed_files: &[String],
docs_dir: &str,
mut read: F,
) -> Vec<DocFile>
where
F: FnMut(&str) -> Option<String>,
{
let mut docs: Vec<DocFile> = Vec::new();
for path in markdown_files {
if let Some(stem) = sidecar_stem(path) {
let covers = resolve_sidecar(stem, indexed_files);
if !covers.is_empty() {
docs.push(DocFile {
path: path.clone(),
covers,
origin: DocOrigin::Sidecar,
});
}
continue;
}
if !is_in_docs_dir(path, docs_dir) {
continue;
}
let Some(content) = read(path) else { continue };
let Some(front) = parse_front_matter(&content) else {
continue;
};
let covers = resolve_globs(&front.applies_to, indexed_files);
if !covers.is_empty() {
docs.push(DocFile {
path: path.clone(),
covers,
origin: DocOrigin::DocsDir,
});
}
}
docs.sort_by(|a, b| a.path.cmp(&b.path));
docs
}
pub fn absolute_doc_path(project_root: &Path, relative: &str) -> std::path::PathBuf {
project_root.join(relative)
}
pub fn doc_summary(content: &str) -> Option<String> {
const MAX: usize = 200;
let content = content.strip_prefix('\u{feff}').unwrap_or(content);
let body = if content.starts_with("---") {
let mut lines = content.lines();
lines.next();
let mut rest = String::new();
let mut closed = false;
for line in lines.by_ref() {
if line.trim_end() == "---" {
closed = true;
break;
}
}
if closed {
for line in lines {
rest.push_str(line);
rest.push('\n');
}
rest
} else {
content.to_string()
}
} else {
content.to_string()
};
for line in body.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let summary = if trimmed.chars().count() > MAX {
let cut: String = trimmed.chars().take(MAX).collect();
format!("{cut}…")
} else {
trimmed.to_string()
};
return Some(summary);
}
None
}
pub fn build_doc_graph<S: std::hash::BuildHasher>(
docs: &[DocFile],
file_node_ids: &std::collections::HashMap<String, String, S>,
summaries: &std::collections::HashMap<String, String, S>,
) -> (Vec<crate::types::Node>, Vec<crate::types::Edge>) {
use crate::types::{generate_node_id, Edge, EdgeKind, Node, NodeKind, Visibility};
let mut nodes = Vec::with_capacity(docs.len());
let mut edges = Vec::new();
for doc in docs {
let id = generate_node_id(&doc.path, &NodeKind::Doc, &doc.path, 0);
let name = doc.path.rsplit('/').next().unwrap_or(&doc.path).to_string();
nodes.push(Node {
id: id.clone(),
kind: NodeKind::Doc,
name,
qualified_name: doc.path.clone(),
file_path: doc.path.clone(),
start_line: 0,
attrs_start_line: 0,
end_line: 0,
start_column: 0,
end_column: 0,
signature: Some(format!(
"{} doc covering {} file(s)",
doc.origin.as_str(),
doc.covers.len()
)),
docstring: summaries.get(&doc.path).cloned(),
visibility: Visibility::Pub,
is_async: false,
branches: 0,
loops: 0,
returns: 0,
max_nesting: 0,
unsafe_blocks: 0,
unchecked_calls: 0,
assertions: 0,
cognitive_complexity: 0,
distinct_operators: 0,
distinct_operands: 0,
total_operators: 0,
total_operands: 0,
updated_at: 0,
parent_id: None,
});
for covered in &doc.covers {
if let Some(target) = file_node_ids.get(covered) {
edges.push(Edge {
source: id.clone(),
target: target.clone(),
kind: EdgeKind::Documents,
line: None,
});
}
}
}
(nodes, edges)
}