#![cfg(feature = "documents")]
use ahash::AHashSet;
use memchr::memmem;
use crate::config::Config;
use crate::extract::doc::FileMapDoc;
use crate::lance::DocLinkRow;
use crate::scanner_docs::{PendingDocBatch, preset_dim};
use crate::store::Store;
const MIN_PATH_TOKEN_LEN: usize = 3;
const MAX_PATH_TOKEN_LEN: usize = 200;
const PATH_EXTENSIONS: &[&str] = &[
"rs", "py", "ts", "tsx", "js", "jsx", "go", "java", "c", "h", "cpp", "hpp", "rb", "md", "txt", "toml", "yaml",
"yml", "json", "sql", "sh",
];
fn looks_like_path(tok: &str) -> bool {
if !(MIN_PATH_TOKEN_LEN..=MAX_PATH_TOKEN_LEN).contains(&tok.len()) {
return false;
}
if tok.contains("//") || tok.starts_with("http:") || tok.starts_with("https:") {
return false;
}
if !tok
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'/' | b'.' | b'_' | b'-'))
{
return false;
}
let has_slash = tok.contains('/');
let has_known_ext = tok.rsplit_once('.').is_some_and(|(stem, ext)| {
!stem.is_empty() && PATH_EXTENSIONS.iter().any(|allowed| ext.eq_ignore_ascii_case(allowed))
});
(has_slash || has_known_ext) && tok.bytes().any(|b| b.is_ascii_alphanumeric())
}
fn path_tokens(text: &str) -> impl Iterator<Item = &str> {
text.split(|c: char| {
c.is_whitespace()
|| matches!(
c,
'`' | '"' | '\'' | '(' | ')' | '[' | ']' | '{' | '}' | ',' | ';' | ':' | '<' | '>' | '|' | '='
)
})
.map(|raw| raw.trim_matches(|c: char| matches!(c, '.' | '!' | '?')))
.filter(|tok| looks_like_path(tok))
}
pub(crate) fn doc_links_for(doc: &FileMapDoc, rel: &str, scope: &str) -> Vec<DocLinkRow> {
let name_finders: Vec<(memmem::Finder<'_>, &str)> = doc
.keywords
.iter()
.map(|k| k.text.as_str())
.chain(doc.entities.iter().map(|e| e.text.as_str()))
.filter(|t| !t.is_empty())
.map(|t| (memmem::Finder::new(t.as_bytes()), t))
.collect();
let mut rows: Vec<DocLinkRow> = Vec::new();
for (idx, chunk) in doc.chunks.iter().enumerate() {
let chunk_idx = u32::try_from(idx).unwrap_or(u32::MAX);
let haystack = chunk.text.as_bytes();
let mut seen: AHashSet<(u8, &str)> = AHashSet::new();
for (finder, needle) in &name_finders {
if finder.find(haystack).is_some() && seen.insert((0, needle)) {
rows.push(DocLinkRow {
scope: scope.to_string(),
doc_path: rel.to_string(),
chunk_idx,
mention_kind: "name".to_string(),
mention_value: (*needle).to_string(),
});
}
}
for tok in path_tokens(&chunk.text) {
if seen.insert((1, tok)) {
rows.push(DocLinkRow {
scope: scope.to_string(),
doc_path: rel.to_string(),
chunk_idx,
mention_kind: "path".to_string(),
mention_value: tok.to_string(),
});
}
}
}
rows
}
pub(crate) fn flush_doc_links(store: &mut Store, config: &Config, batches: &[PendingDocBatch]) {
if batches.is_empty() {
return;
}
let model = &config.documents.embedding_preset;
let dim = match preset_dim(model) {
Ok(dim) => dim,
Err(error) => {
tracing::warn!(?error, preset = %model, "doc links: unknown preset; skipping lance write");
return;
}
};
let lance = match store.lance_or_open(dim, model) {
Ok(lance) => lance.clone(),
Err(error) => {
tracing::warn!(?error, "doc links: open LanceStore failed; skipping");
return;
}
};
for batch in batches {
let doc = match store.read_doc_by_hex(&batch.blob_hash) {
Ok(Some(doc)) => doc,
Ok(None) => continue,
Err(error) => {
tracing::warn!(rel = %batch.rel_path, ?error, "doc links: re-read blob failed; skipping");
continue;
}
};
let rows = doc_links_for(&doc, &batch.rel_path, &batch.doc_scope);
if let Err(error) = lance.replace_doc_links(&batch.doc_scope, &batch.rel_path, rows) {
tracing::warn!(rel = %batch.rel_path, ?error, "doc links: replace failed; doc↔code edges may be stale");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn looks_like_path_accepts_filenames_and_paths_only() {
assert!(looks_like_path("core.rs"), "bare filename with an allowlisted ext");
assert!(looks_like_path("src/lib.rs"), "slash-separated path");
assert!(looks_like_path("mod_a.py"), "underscore stem");
assert!(looks_like_path("a/b/c.ts"), "multi-segment path");
assert!(looks_like_path("main.c"), "single-char allowlisted ext");
assert!(looks_like_path("notes.md"), "doc extension");
assert!(looks_like_path("src/parser"), "extensionless path with a separator");
assert!(!looks_like_path("engine"), "a plain word is not a path");
assert!(!looks_like_path("e.g"), "non-allowlisted single-char ext is rejected");
assert!(!looks_like_path("a/b c"), "embedded space rejects the token");
assert!(!looks_like_path("github.com"), "domain name is not a path");
assert!(!looks_like_path("example.com"), "domain name is not a path");
assert!(!looks_like_path("basemind.dev"), "domain name is not a path");
assert!(!looks_like_path("3.14"), "a decimal is not a path");
assert!(!looks_like_path("1.5"), "a decimal is not a path");
assert!(!looks_like_path("v2.0"), "a version string is not a path");
assert!(!looks_like_path("https://github.com/foo"), "a URL is not a path");
assert!(
!looks_like_path("http://example.com/a.rs"),
"a URL is not a path even with a real ext"
);
assert!(
!looks_like_path("//github.com/foo/bar"),
"a `//`-bearing URL remnant is not a path"
);
}
#[test]
fn path_tokens_extracts_citations_from_prose() {
let toks: Vec<&str> = path_tokens("The engine lives in `core.rs` and src/lib.rs, right?").collect();
assert_eq!(toks, vec!["core.rs", "src/lib.rs"]);
}
#[test]
fn path_tokens_rejects_urls_domains_and_versions() {
let toks: Vec<&str> =
path_tokens("See https://github.com/foo/bar and github.com, version 3.14, but core.rs counts.").collect();
assert_eq!(toks, vec!["core.rs"]);
}
}