pub mod db;
pub mod fetch;
pub mod format;
pub mod lsp;
pub mod model;
pub mod parse;
pub mod provider;
pub mod spec_registry;
use anyhow::Result;
pub fn parse_spec_anchor(input: &str) -> Result<(String, String)> {
if input.starts_with("http://") || input.starts_with("https://") {
let registry = spec_registry::SpecRegistry::new();
if let Some((spec, anchor)) = registry.resolve_url(input) {
return Ok((spec, anchor));
}
anyhow::bail!("URL not recognized as a known spec: {input}");
}
let parts: Vec<&str> = input.split('#').collect();
if parts.len() != 2 {
anyhow::bail!("Invalid format. Expected SPEC#anchor or a full spec URL");
}
Ok((parts[0].to_string(), parts[1].to_string()))
}
pub fn spec_urls() -> Vec<model::SpecUrlEntry> {
let registry = spec_registry::SpecRegistry::new();
registry
.list_all_specs()
.into_iter()
.map(|s| model::SpecUrlEntry {
spec: s.name.to_string(),
base_url: s.base_url.to_string(),
})
.collect()
}
pub async fn query_section(spec_anchor: &str) -> Result<model::QueryResult> {
let (spec_name, anchor) = parse_spec_anchor(spec_anchor)?;
let conn = db::open_or_create_db()?;
let registry = spec_registry::SpecRegistry::new();
let spec = registry
.find_spec(&spec_name)
.ok_or_else(|| anyhow::anyhow!("Unknown spec: {}", spec_name))?;
let provider = registry.get_provider(spec)?;
let snapshot_id = fetch::ensure_indexed(&conn, spec, provider).await?;
let snapshot_sha: String = conn.query_row(
"SELECT sha FROM snapshots WHERE id = ?1",
[snapshot_id],
|row| row.get(0),
)?;
let section = db::queries::get_section(&conn, snapshot_id, &anchor)?
.ok_or_else(|| anyhow::anyhow!("Section not found: {}#{}", spec_name, anchor))?;
let children = db::queries::get_children(&conn, snapshot_id, &anchor)?
.iter()
.map(|(child_anchor, title)| model::NavEntry {
anchor: child_anchor.clone(),
title: title.clone(),
})
.collect();
let navigation = model::Navigation {
parent: section.parent_anchor.as_ref().and_then(|p| {
db::queries::get_section(&conn, snapshot_id, p)
.ok()?
.map(|s| model::NavEntry {
anchor: s.anchor,
title: s.title,
})
}),
prev: section.prev_anchor.as_ref().and_then(|p| {
db::queries::get_section(&conn, snapshot_id, p)
.ok()?
.map(|s| model::NavEntry {
anchor: s.anchor,
title: s.title,
})
}),
next: section.next_anchor.as_ref().and_then(|n| {
db::queries::get_section(&conn, snapshot_id, n)
.ok()?
.map(|s| model::NavEntry {
anchor: s.anchor,
title: s.title,
})
}),
children,
};
let out_refs = db::queries::get_outgoing_refs(&conn, snapshot_id, &anchor)?;
let outgoing = out_refs
.iter()
.map(|(to_spec, to_anchor)| model::RefEntry {
spec: to_spec.clone(),
anchor: to_anchor.clone(),
})
.collect();
let in_refs = db::queries::get_incoming_refs(&conn, &spec_name, &anchor)?;
let incoming = in_refs
.iter()
.map(|(from_spec, from_anchor)| model::RefEntry {
spec: from_spec.clone(),
anchor: from_anchor.clone(),
})
.collect();
Ok(model::QueryResult {
spec: spec_name,
sha: snapshot_sha,
anchor: section.anchor,
title: section.title,
section_type: section.section_type.as_str().to_string(),
content: section.content_text,
navigation,
outgoing_refs: outgoing,
incoming_refs: incoming,
})
}
pub async fn check_exists(spec_anchor: &str) -> Result<model::ExistsResult> {
let (spec_name, anchor) = parse_spec_anchor(spec_anchor)?;
let conn = db::open_or_create_db()?;
let registry = spec_registry::SpecRegistry::new();
let spec = registry
.find_spec(&spec_name)
.ok_or_else(|| anyhow::anyhow!("Unknown spec: {}", spec_name))?;
let provider = registry.get_provider(spec)?;
let snapshot_id = fetch::ensure_indexed(&conn, spec, provider).await?;
let section = db::queries::get_section(&conn, snapshot_id, &anchor)?;
let exists = section.is_some();
let section_type = section
.as_ref()
.map(|s| s.section_type.as_str().to_string());
Ok(model::ExistsResult {
exists,
spec: spec_name,
anchor,
section_type,
})
}
pub fn find_anchors(
pattern: &str,
spec: Option<&str>,
limit: usize,
) -> Result<model::AnchorsResult> {
let conn = db::open_or_create_db()?;
let sql_pattern = pattern.replace('*', "%");
let sql = if spec.is_some() {
"SELECT s.anchor, sp.name, s.title, s.section_type FROM sections s
JOIN snapshots sn ON s.snapshot_id = sn.id
JOIN specs sp ON sn.spec_id = sp.id
WHERE s.anchor LIKE ?1 AND sp.name = ?2 LIMIT ?3"
} else {
"SELECT s.anchor, sp.name, s.title, s.section_type FROM sections s
JOIN snapshots sn ON s.snapshot_id = sn.id
JOIN specs sp ON sn.spec_id = sp.id
WHERE s.anchor LIKE ?1 LIMIT ?2"
};
let mut stmt = conn.prepare(sql)?;
let results: Vec<(String, String, Option<String>, String)> = if let Some(spec_name) = spec {
stmt.query_map((&sql_pattern, spec_name, limit), |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
})?
.collect::<Result<Vec<_>, _>>()?
} else {
stmt.query_map((&sql_pattern, limit), |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
})?
.collect::<Result<Vec<_>, _>>()?
};
let entries: Vec<model::AnchorEntry> = results
.iter()
.map(
|(anchor, spec_name, title, section_type)| model::AnchorEntry {
spec: spec_name.clone(),
anchor: anchor.clone(),
title: title.clone(),
section_type: section_type.clone(),
},
)
.collect();
Ok(model::AnchorsResult {
pattern: pattern.to_string(),
results: entries,
})
}
pub fn search_sections(
query: &str,
spec: Option<&str>,
limit: usize,
) -> Result<model::SearchResult> {
let conn = db::open_or_create_db()?;
let sql = if spec.is_some() {
"SELECT s.anchor, sp.name, s.title, s.section_type, snippet(sections_fts, 2, '<mark>', '</mark>', '...', 64)
FROM sections_fts
JOIN sections s ON sections_fts.rowid = s.id
JOIN snapshots sn ON s.snapshot_id = sn.id
JOIN specs sp ON sn.spec_id = sp.id
WHERE sections_fts MATCH ?1 AND sp.name = ?2 LIMIT ?3"
} else {
"SELECT s.anchor, sp.name, s.title, s.section_type, snippet(sections_fts, 2, '<mark>', '</mark>', '...', 64)
FROM sections_fts
JOIN sections s ON sections_fts.rowid = s.id
JOIN snapshots sn ON s.snapshot_id = sn.id
JOIN specs sp ON sn.spec_id = sp.id
WHERE sections_fts MATCH ?1 LIMIT ?2"
};
let mut stmt = conn.prepare(sql)?;
let map_row = |row: &rusqlite::Row| -> rusqlite::Result<model::SearchEntry> {
Ok(model::SearchEntry {
anchor: row.get(0)?,
spec: row.get(1)?,
title: row.get(2)?,
section_type: row.get(3)?,
snippet: row.get::<_, Option<String>>(4)?.unwrap_or_default(),
})
};
let entries: Vec<model::SearchEntry> = if let Some(spec_name) = spec {
stmt.query_map((query, spec_name, limit), map_row)?
.collect::<Result<Vec<_>, _>>()?
} else {
stmt.query_map((query, limit), map_row)?
.collect::<Result<Vec<_>, _>>()?
};
Ok(model::SearchResult {
query: query.to_string(),
results: entries,
})
}
pub async fn list_headings(spec: &str) -> Result<Vec<model::ListEntry>> {
let conn = db::open_or_create_db()?;
let registry = spec_registry::SpecRegistry::new();
let spec_info = registry
.find_spec(spec)
.ok_or_else(|| anyhow::anyhow!("Unknown spec: {}", spec))?;
let provider = registry.get_provider(spec_info)?;
let snapshot_id = fetch::ensure_indexed(&conn, spec_info, provider).await?;
let headings = db::queries::list_headings(&conn, snapshot_id)?;
let entries: Vec<model::ListEntry> = headings
.iter()
.map(|h| model::ListEntry {
anchor: h.anchor.clone(),
title: h.title.clone(),
depth: h.depth.unwrap_or(0),
parent: h.parent_anchor.clone(),
})
.collect();
Ok(entries)
}
pub async fn get_references(spec_anchor: &str, direction: &str) -> Result<model::RefsResult> {
let (spec_name, anchor) = parse_spec_anchor(spec_anchor)?;
let conn = db::open_or_create_db()?;
let registry = spec_registry::SpecRegistry::new();
let spec = registry
.find_spec(&spec_name)
.ok_or_else(|| anyhow::anyhow!("Unknown spec: {}", spec_name))?;
let provider = registry.get_provider(spec)?;
let snapshot_id = fetch::ensure_indexed(&conn, spec, provider).await?;
let outgoing = if direction == "outgoing" || direction == "both" {
let out_refs = db::queries::get_outgoing_refs(&conn, snapshot_id, &anchor)?;
Some(
out_refs
.iter()
.map(|(to_spec, to_anchor)| model::RefEntry {
spec: to_spec.clone(),
anchor: to_anchor.clone(),
})
.collect(),
)
} else {
None
};
let incoming = if direction == "incoming" || direction == "both" {
let in_refs = db::queries::get_incoming_refs(&conn, &spec_name, &anchor)?;
Some(
in_refs
.iter()
.map(|(from_spec, from_anchor)| model::RefEntry {
spec: from_spec.clone(),
anchor: from_anchor.clone(),
})
.collect(),
)
} else {
None
};
Ok(model::RefsResult {
anchor,
direction: direction.to_string(),
outgoing,
incoming,
})
}
pub async fn update_specs(spec: Option<&str>, force: bool) -> Result<Vec<(String, Option<i64>)>> {
let conn = db::open_or_create_db()?;
let registry = spec_registry::SpecRegistry::new();
let mut results = Vec::new();
if let Some(spec_name) = spec {
let spec_info = registry
.find_spec(spec_name)
.ok_or_else(|| anyhow::anyhow!("Unknown spec: {}", spec_name))?;
let provider = registry.get_provider(spec_info)?;
let snapshot_id = fetch::update_if_needed(&conn, spec_info, provider, force).await?;
results.push((spec_name.to_string(), snapshot_id));
} else {
let all_results = fetch::update_all_specs(&conn, ®istry, force).await;
for (spec_name, result) in all_results {
match result {
Ok(snapshot_id) => results.push((spec_name, snapshot_id)),
Err(e) => {
eprintln!("Failed to update {}: {}", spec_name, e);
results.push((spec_name, None));
}
}
}
}
Ok(results)
}
pub fn clear_database() -> Result<String> {
let db_path = db::get_db_path();
if !db_path.exists() {
anyhow::bail!("Database does not exist: {}", db_path.display());
}
std::fs::remove_file(&db_path)?;
Ok(db_path.display().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_spec_anchor_classic_format() {
let (spec, anchor) = parse_spec_anchor("HTML#navigate").unwrap();
assert_eq!(spec, "HTML");
assert_eq!(anchor, "navigate");
}
#[test]
fn parse_spec_anchor_url_format() {
let (spec, anchor) = parse_spec_anchor("https://html.spec.whatwg.org/#navigate").unwrap();
assert_eq!(spec, "HTML");
assert_eq!(anchor, "navigate");
}
#[test]
fn parse_spec_anchor_url_dom() {
let (spec, anchor) =
parse_spec_anchor("https://dom.spec.whatwg.org/#concept-tree").unwrap();
assert_eq!(spec, "DOM");
assert_eq!(anchor, "concept-tree");
}
#[test]
fn parse_spec_anchor_unknown_url() {
let result = parse_spec_anchor("https://example.com/#foo");
assert!(result.is_err());
}
#[test]
fn parse_spec_anchor_invalid() {
let result = parse_spec_anchor("no-hash");
assert!(result.is_err());
}
#[test]
fn spec_urls_returns_entries() {
let urls = spec_urls();
assert!(!urls.is_empty());
let html = urls.iter().find(|e| e.spec == "HTML");
assert!(html.is_some());
assert_eq!(html.unwrap().base_url, "https://html.spec.whatwg.org");
}
}