use crate::snippets::discovery::discover_snippets;
use crate::snippets::error::Result;
use crate::snippets::gap_coverage::GapCoverage;
use crate::snippets::parser;
use crate::snippets::types::{Language, Snippet, SnippetAnnotationKind};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
#[derive(Debug, Clone, Default)]
pub struct GapConfig {
pub docs_dirs: Vec<PathBuf>,
pub snippet_dirs: Vec<PathBuf>,
pub required_languages: Vec<Language>,
pub include_base_paths: Vec<PathBuf>,
pub configured_references: Vec<PathBuf>,
pub exclude: Vec<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SnippetReference {
pub source: PathBuf,
pub target: PathBuf,
pub line: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MissingLanguageVariant {
pub group: PathBuf,
pub language: Language,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SnippetLocation {
pub path: PathBuf,
pub line: usize,
pub block_index: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UnknownLanguage {
pub path: PathBuf,
pub line: usize,
pub tag: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct GapReport {
pub missing_references: Vec<SnippetReference>,
pub unreferenced_snippets: Vec<PathBuf>,
pub missing_language_variants: Vec<MissingLanguageVariant>,
pub skips_without_reason: Vec<SnippetLocation>,
pub unknown_languages: Vec<UnknownLanguage>,
#[serde(default)]
pub coverage: GapCoverage,
}
impl GapReport {
#[must_use]
pub fn has_gaps(&self) -> bool {
!self.missing_references.is_empty()
|| !self.unreferenced_snippets.is_empty()
|| !self.missing_language_variants.is_empty()
|| !self.skips_without_reason.is_empty()
|| !self.unknown_languages.is_empty()
}
}
pub fn detect_gaps(config: &GapConfig) -> Result<GapReport> {
let snippets: Vec<_> = discover_snippets(&config.snippet_dirs, None)?
.into_iter()
.filter(|snippet| !is_excluded(&snippet.path, &config.exclude))
.collect();
let (discovered, docs_pages_scanned) = discover_includes_measured(&config.docs_dirs, &config.include_base_paths)?;
let mut references: Vec<_> = discovered
.into_iter()
.filter(|reference| !is_excluded(&reference.source, &config.exclude))
.collect();
let include_references = references.len();
references.extend(config.configured_references.iter().map(|target| SnippetReference {
source: target.clone(),
target: target.clone(),
line: 1,
}));
let snippet_files = snippet_files(&snippets);
let (missing_language_variants, language_groups) = missing_language_variants(&snippets, &config.required_languages);
Ok(GapReport {
missing_references: missing_references(&references),
unreferenced_snippets: unreferenced_snippets(&snippet_files, &references),
missing_language_variants,
skips_without_reason: skips_without_reason(&snippets),
unknown_languages: unknown_languages(&config.snippet_dirs)?
.into_iter()
.filter(|unknown| !is_excluded(&unknown.path, &config.exclude))
.collect(),
coverage: GapCoverage {
snippet_roots: config.snippet_dirs.len(),
snippets_discovered: snippet_files.len(),
docs_roots: config.docs_dirs.len(),
docs_pages_scanned,
include_references,
configured_references: config.configured_references.len(),
required_languages: config.required_languages.len(),
language_groups,
include_base_paths: config.include_base_paths.len(),
},
})
}
#[must_use]
pub fn readme_snippet_references(
workspace_root: &Path,
readme: Option<&crate::core::config::ReadmeConfig>,
) -> Vec<PathBuf> {
let Some(readme) = readme else {
return Vec::new();
};
let mut references = Vec::new();
for (language, entry) in &readme.languages {
let snippets_dir = entry
.get("snippets_dir")
.and_then(serde_json::Value::as_str)
.map(PathBuf::from)
.or_else(|| readme.snippets_dir.clone());
let source_language = entry
.get("snippet_language")
.and_then(serde_json::Value::as_str)
.unwrap_or(language);
if let Some(snippets) = entry.get("snippets") {
collect_readme_snippet_mappings(snippets, &mut |path, mapping_root| {
let path = normalize_readme_snippet_path(path, language, source_language);
if let Some(root) = mapping_root.map(PathBuf::from).or_else(|| snippets_dir.clone()) {
references.push(normalize_path(&workspace_root.join(root).join(path)));
}
});
}
}
references.sort();
references.dedup();
references
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MissingCells {
Reject,
Tolerate,
}
pub fn coverage_ledger_references(snippet_dirs: &[PathBuf]) -> Result<Vec<PathBuf>> {
collect_coverage_ledger_references(snippet_dirs, MissingCells::Reject)
}
pub fn coverage_ledger_references_allowing_missing_cells(snippet_dirs: &[PathBuf]) -> Result<Vec<PathBuf>> {
collect_coverage_ledger_references(snippet_dirs, MissingCells::Tolerate)
}
fn collect_coverage_ledger_references(snippet_dirs: &[PathBuf], missing_cells: MissingCells) -> Result<Vec<PathBuf>> {
let mut references = Vec::new();
for snippet_root in snippet_dirs {
let mut manifests = WalkDir::new(snippet_root)
.follow_links(false)
.into_iter()
.map(|entry| {
entry.map_err(|error| {
crate::snippets::error::Error::Other(format!(
"walking snippet root {} for coverage ledgers: {error}",
snippet_root.display()
))
})
})
.filter_map(|entry| match entry {
Ok(entry)
if entry.file_type().is_file() && entry.file_name() == crate::e2e::snippets::COVERAGE_MANIFEST =>
{
Some(Ok(entry.into_path()))
}
Ok(_) => None,
Err(error) => Some(Err(error)),
})
.collect::<Result<Vec<_>>>()?;
manifests.sort();
for manifest in manifests {
let output_root = manifest.parent().ok_or_else(|| {
crate::snippets::error::Error::Other(format!(
"coverage ledger has no output root: {}",
manifest.display()
))
})?;
references.extend(read_coverage_ledger_references(output_root, &manifest, missing_cells)?);
}
}
references.sort();
references.dedup();
Ok(references)
}
pub fn astro_collection_references(
docs_dirs: &[PathBuf],
collections: &BTreeMap<String, PathBuf>,
) -> Result<Vec<PathBuf>> {
let mut referenced_collections = BTreeSet::new();
for docs_dir in docs_dirs {
for path in markdown_files(docs_dir) {
let content = std::fs::read_to_string(&path)?;
referenced_collections.extend(parse_astro_collection_queries(&content));
}
}
let mut references = Vec::new();
for collection in referenced_collections {
let Some(root) = collections.get(&collection) else {
continue;
};
references.extend(
WalkDir::new(root)
.follow_links(false)
.into_iter()
.filter_map(std::result::Result::ok)
.filter(|entry| entry.file_type().is_file())
.map(walkdir::DirEntry::into_path),
);
}
references.sort();
references.dedup();
Ok(references)
}
#[must_use]
pub fn parse_astro_collection_queries(content: &str) -> BTreeSet<String> {
let mut collections = BTreeSet::new();
for quote in ['"', '\''] {
let marker = format!("getCollection({quote}");
let mut remainder = content;
while let Some((_, after_marker)) = remainder.split_once(&marker) {
if let Some((name, after_name)) = after_marker.split_once(quote) {
collections.insert(name.to_string());
remainder = after_name;
} else {
break;
}
}
}
collections
}
fn read_coverage_ledger_references(
output_root: &Path,
manifest: &Path,
missing_cells: MissingCells,
) -> Result<Vec<PathBuf>> {
let content = std::fs::read_to_string(manifest)?;
let ledger: crate::e2e::snippets::SnippetCoverageLedger = serde_json::from_str(&content)?;
crate::e2e::snippets::coverage::validate(&ledger)
.map_err(|error| crate::snippets::error::Error::Other(format!("invalid coverage ledger: {error:#}")))?;
if missing_cells == MissingCells::Reject && !ledger.missing.is_empty() {
return Err(crate::snippets::error::Error::Other(format!(
"incomplete fixture-snippet coverage manifest at {}",
manifest.display()
)));
}
ledger
.generated_paths
.into_iter()
.map(|relative| {
let path = crate::e2e::snippets::ledger_paths::resolve_tracked_path(output_root, &relative)?;
if !path.is_file() {
return Err(crate::snippets::error::Error::Other(format!(
"fixture snippet recorded by the coverage ledger is missing: {}",
path.display()
)));
}
Ok(path)
})
.collect()
}
fn collect_readme_snippet_mappings(value: &serde_json::Value, collect: &mut impl FnMut(&str, Option<&str>)) {
match value {
serde_json::Value::String(path) => collect(path, None),
serde_json::Value::Array(values) => {
for value in values {
collect_readme_snippet_mappings(value, collect);
}
}
serde_json::Value::Object(values) => {
if let Some(path) = values.get("path").and_then(serde_json::Value::as_str) {
collect(path, values.get("root").and_then(serde_json::Value::as_str));
} else {
for value in values.values() {
collect_readme_snippet_mappings(value, collect);
}
}
}
_ => {}
}
}
fn normalize_readme_snippet_path(path: &str, language: &str, source_language: &str) -> PathBuf {
let path = Path::new(path);
let mut components = path.components();
let first = components.next();
let has_language_prefix = first
.and_then(|component| component.as_os_str().to_str())
.is_some_and(|component| component == language || Language::from_dir_name(component) != Language::Unknown);
let remainder = if has_language_prefix {
components.as_path()
} else {
path
};
Path::new(source_language).join(remainder)
}
fn is_excluded(path: &Path, exclude: &[PathBuf]) -> bool {
exclude.iter().any(|excluded| path.starts_with(excluded))
}
pub fn discover_includes(docs_dirs: &[PathBuf], include_base_paths: &[PathBuf]) -> Result<Vec<SnippetReference>> {
Ok(discover_includes_measured(docs_dirs, include_base_paths)?.0)
}
fn discover_includes_measured(
docs_dirs: &[PathBuf],
include_base_paths: &[PathBuf],
) -> Result<(Vec<SnippetReference>, usize)> {
let mut references = Vec::new();
let mut pages_scanned = 0;
for docs_dir in docs_dirs {
for path in markdown_files(docs_dir) {
let content = std::fs::read_to_string(&path)?;
pages_scanned += 1;
references.extend(parse_includes(&content, &path, docs_dir, include_base_paths));
references.extend(parse_mdx_content_imports(&content, &path));
}
}
references.sort_by(|left, right| left.source.cmp(&right.source).then(left.line.cmp(&right.line)));
Ok((references, pages_scanned))
}
#[must_use]
fn resolve_include_target(target: &str, docs_dir: &Path, include_base_paths: &[PathBuf]) -> PathBuf {
for base in include_base_paths {
let candidate = base.join(target);
if candidate.exists() {
return candidate;
}
}
docs_dir.join(target)
}
#[must_use]
pub fn parse_includes(
content: &str,
source: &Path,
docs_dir: &Path,
include_base_paths: &[PathBuf],
) -> Vec<SnippetReference> {
content
.lines()
.enumerate()
.filter_map(|(index, line)| parse_include_target(line).map(|target| (index, target)))
.map(|(index, target)| SnippetReference {
source: source.to_path_buf(),
target: resolve_include_target(target, docs_dir, include_base_paths),
line: index + 1,
})
.collect()
}
pub fn parse_include_target(line: &str) -> Option<&str> {
let marker = "--8<--";
let after_marker = line.trim().strip_prefix(marker)?.trim();
let quoted = after_marker.strip_prefix('"')?;
let end = quoted.find('"')?;
Some("ed[..end])
}
#[must_use]
pub fn parse_mdx_content_imports(content: &str, source: &Path) -> Vec<SnippetReference> {
let Some(source_dir) = source.parent() else {
return Vec::new();
};
content
.lines()
.enumerate()
.filter_map(|(index, line)| parse_mdx_content_import_target(line).map(|target| (index, target)))
.map(|(index, target)| SnippetReference {
source: source.to_path_buf(),
target: normalize_path(&source_dir.join(target)),
line: index + 1,
})
.collect()
}
pub fn parse_mdx_content_import_target(line: &str) -> Option<&str> {
let trimmed = line.trim();
let after_import = trimmed.strip_prefix("import")?.trim_start();
let after_brace = after_import.strip_prefix('{')?;
let close_brace = after_brace.find('}')?;
let binding = after_brace[..close_brace].trim();
let is_content_import = binding == "Content" || binding.starts_with("Content ") && binding.contains(" as ");
if !is_content_import {
return None;
}
let after_close = after_brace[close_brace + 1..].trim_start();
let after_from = after_close.strip_prefix("from")?.trim_start();
let quote = after_from.chars().next()?;
if quote != '"' && quote != '\'' {
return None;
}
let rest = &after_from[1..];
let end = rest.find(quote)?;
Some(&rest[..end])
}
fn normalize_path(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
normalized.pop();
}
other => normalized.push(other.as_os_str()),
}
}
normalized
}
fn markdown_files(base: &Path) -> Vec<PathBuf> {
if !base.exists() {
return Vec::new();
}
let mut files: Vec<PathBuf> = WalkDir::new(base)
.follow_links(true)
.into_iter()
.filter_map(std::result::Result::ok)
.filter(|entry| entry.file_type().is_file())
.map(walkdir::DirEntry::into_path)
.filter(|path| {
path.extension()
.and_then(|extension| extension.to_str())
.map(|extension| matches!(extension.to_lowercase().as_str(), "astro" | "md" | "markdown" | "mdx"))
.unwrap_or(false)
})
.collect();
files.sort();
files
}
fn snippet_files(snippets: &[Snippet]) -> BTreeSet<PathBuf> {
snippets.iter().map(|snippet| snippet.path.clone()).collect()
}
fn missing_references(references: &[SnippetReference]) -> Vec<SnippetReference> {
references
.iter()
.filter(|reference| !reference.target.exists())
.cloned()
.collect()
}
fn unreferenced_snippets(snippet_files: &BTreeSet<PathBuf>, references: &[SnippetReference]) -> Vec<PathBuf> {
let referenced: BTreeSet<PathBuf> = references
.iter()
.filter(|reference| reference.target.exists())
.map(|reference| reference.target.clone())
.collect();
snippet_files.difference(&referenced).cloned().collect()
}
fn missing_language_variants(
snippets: &[Snippet],
required_languages: &[Language],
) -> (Vec<MissingLanguageVariant>, usize) {
if required_languages.is_empty() {
return (Vec::new(), 0);
}
let mut groups: BTreeMap<PathBuf, BTreeSet<Language>> = BTreeMap::new();
for snippet in snippets {
let Some(group) = language_group(&snippet.path, snippet.language) else {
continue;
};
groups.entry(group).or_default().insert(snippet.language);
}
let group_count = groups.len();
let mut missing = Vec::new();
for (group, languages) in groups {
for language in required_languages {
if !languages.contains(language) {
missing.push(MissingLanguageVariant {
group: group.clone(),
language: *language,
});
}
}
}
(missing, group_count)
}
fn language_group(path: &Path, language: Language) -> Option<PathBuf> {
let mut group = PathBuf::new();
let mut replaced = false;
for component in path.components() {
let text = component.as_os_str().to_str()?;
if !replaced && Language::from_dir_name(text) == language {
group.push("{language}");
replaced = true;
} else {
group.push(text);
}
}
replaced.then_some(group)
}
fn skips_without_reason(snippets: &[Snippet]) -> Vec<SnippetLocation> {
snippets
.iter()
.filter(|snippet| {
snippet
.annotation
.as_ref()
.map(|annotation| {
annotation.kind == SnippetAnnotationKind::Skip
&& annotation.reason.as_deref().unwrap_or_default().is_empty()
})
.unwrap_or(false)
})
.map(|snippet| SnippetLocation {
path: snippet.path.clone(),
line: snippet.start_line,
block_index: snippet.block_index,
})
.collect()
}
fn unknown_languages(snippet_dirs: &[PathBuf]) -> Result<Vec<UnknownLanguage>> {
let mut unknown = Vec::new();
for dir in snippet_dirs {
for path in markdown_files(dir) {
for block in parser::parse_code_blocks(&path)? {
if Language::from_fence_tag(&block.lang) == Language::Unknown {
unknown.push(UnknownLanguage {
path: path.clone(),
line: block.start_line,
tag: block.lang,
});
}
}
}
}
unknown.sort_by(|left, right| left.path.cmp(&right.path).then(left.line.cmp(&right.line)));
Ok(unknown)
}
#[cfg(test)]
mod tests;