use serde::Serialize;
use crate::iri::ONTOLOGY_NS;
use crate::types::Chunk;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum CandidateKind {
Symbol,
Path,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct CandidateEntity {
pub name: String,
pub kind: CandidateKind,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct CandidateRelation {
pub chunk_iri: String,
pub predicate: String,
pub entity_name: String,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct Extraction {
pub entities: Vec<CandidateEntity>,
pub relations: Vec<CandidateRelation>,
}
impl Extraction {
pub fn merge(&mut self, other: Extraction) {
for e in other.entities {
if !self.entities.contains(&e) {
self.entities.push(e);
}
}
for r in other.relations {
if !self.relations.contains(&r) {
self.relations.push(r);
}
}
}
}
pub fn refers_to_predicate() -> String {
format!("{ONTOLOGY_NS}refersTo")
}
pub trait InferredExtractor {
fn id(&self) -> &str;
fn params(&self) -> serde_json::Value;
fn extract(&self, chunks: &[Chunk], repo: &str) -> Extraction;
}
#[derive(Debug, Clone)]
pub struct BacktickCoderefExtractor {
pub min_len: usize,
}
impl Default for BacktickCoderefExtractor {
fn default() -> Self {
Self { min_len: 3 }
}
}
impl BacktickCoderefExtractor {
fn classify(&self, span: &str) -> Option<CandidateKind> {
if span.len() < self.min_len
|| span.chars().any(char::is_whitespace)
|| span.chars().all(|c| c.is_ascii_digit())
{
return None;
}
if span.contains('/') && !span.contains('(') {
return Some(CandidateKind::Path);
}
let identifierish = span.contains("::")
|| span.contains('_')
|| span.contains("()")
|| span.contains('.')
|| (span.chars().any(|c| c.is_ascii_uppercase())
&& span.chars().any(|c| c.is_ascii_lowercase()));
let clean = span
.chars()
.all(|c| c.is_ascii_alphanumeric() || "_:.()#'-".contains(c));
(identifierish && clean).then_some(CandidateKind::Symbol)
}
}
impl InferredExtractor for BacktickCoderefExtractor {
fn id(&self) -> &str {
"backtick-coderef/v1"
}
fn params(&self) -> serde_json::Value {
serde_json::json!({ "min_len": self.min_len })
}
fn extract(&self, chunks: &[Chunk], repo: &str) -> Extraction {
let mut out = Extraction::default();
let mut seen_entities = std::collections::BTreeSet::new();
let mut seen_relations = std::collections::BTreeSet::new();
for chunk in chunks
.iter()
.filter(|c| c.language == "markdown" && c.start_line > 0)
{
let chunk_iri = crate::iri::chunk_iri(repo, &chunk.file_path, chunk.start_line);
for span in backtick_spans(&chunk.content) {
let Some(kind) = self.classify(span) else {
continue;
};
if seen_entities.insert(span.to_string()) {
out.entities.push(CandidateEntity {
name: span.to_string(),
kind,
});
}
if seen_relations.insert((chunk_iri.clone(), span.to_string())) {
out.relations.push(CandidateRelation {
chunk_iri: chunk_iri.clone(),
predicate: refers_to_predicate(),
entity_name: span.to_string(),
});
}
}
}
out
}
}
fn backtick_spans(text: &str) -> Vec<&str> {
let mut spans = Vec::new();
let mut in_fence = false;
for line in text.lines() {
if line.trim_start().starts_with("```") {
in_fence = !in_fence;
continue;
}
if in_fence {
continue;
}
let mut rest = line;
while let Some(open) = rest.find('`') {
let after = &rest[open + 1..];
let Some(close) = after.find('`') else { break };
let span = &after[..close];
if !span.is_empty() {
spans.push(span);
}
rest = &after[close + 1..];
}
}
spans
}