From db4f8ef5dcf9161522c39669070fcd6fdd4c9aad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Mazzucotelli?= Date: Thu, 13 Aug 2026 12:11:48 +0000 Subject: [PATCH] fix: avoid unresolved reference false-positive for autorefs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit fixes validation reporting false unresolved-reference errors for Markdown links that are successfully resolved by autorefs. Validation now runs after pages render, so it uses the autorefs results from the current build state and emits one complete issue report for that state. **Validation implementation summary:** - Page rendering now returns the identifiers that autorefs resolved, alongside rendered output. - Validation combines those resolutions with the source-page references and anchors; a reference resolved by autorefs is no longer reported as unresolved. - The workflow tracks a generation for page content and navigation, so it waits for matching render results from every page before creating issues. This prevents validation from using stale render data or printing duplicate reports during incremental updates. - Reference collection now follows the configured documentation directory, and tests cover reporting after rendering and a subsequent clean rebuild. Signed-off-by: Timothée Mazzucotelli --- .../src/python/collector/reference.rs | 2 +- crates/zensical/src/python/issues.rs | 125 ++++++++- crates/zensical/src/structure/markdown.rs | 2 +- .../src/structure/markdown/autorefs.rs | 63 ++++- crates/zensical/src/structure/page.rs | 9 +- crates/zensical/src/workflow.rs | 259 +++++++++++++++--- python/tests/integration/test_validation.py | 76 +++++ 7 files changed, 486 insertions(+), 50 deletions(-) create mode 100644 python/tests/integration/test_validation.py diff --git a/crates/zensical/src/python/collector/reference.rs b/crates/zensical/src/python/collector/reference.rs index f5ba8d3..60acb6e 100644 --- a/crates/zensical/src/python/collector/reference.rs +++ b/crates/zensical/src/python/collector/reference.rs @@ -35,7 +35,7 @@ mod footnote; mod link; pub use footnote::{FootnoteDefinition, FootnoteReference}; -pub use link::{Link, LinkDefinition, LinkReference}; +pub use link::{Link, LinkDefinition, LinkReference, LinkReferenceKind}; // ---------------------------------------------------------------------------- // Enums diff --git a/crates/zensical/src/python/issues.rs b/crates/zensical/src/python/issues.rs index c3e5cf8..a1a3389 100644 --- a/crates/zensical/src/python/issues.rs +++ b/crates/zensical/src/python/issues.rs @@ -35,8 +35,11 @@ use zrx::id::Id; use zrx::scheduler::{Key, Value}; use crate::config::validation::Validation; +use crate::structure::markdown::AutorefResolutions; -use super::collector::reference::Reference; +use super::collector::reference::{ + LinkReference, LinkReferenceKind, Reference, +}; use super::collector::{Anchors, References}; use super::span::Span; @@ -156,7 +159,9 @@ impl Issues { #[allow(clippy::too_many_lines)] pub fn new(iter: T) -> Self where - T: IntoIterator, (References, Anchors))>, + T: IntoIterator< + Item = (Key, (References, Anchors, AutorefResolutions)), + >, { let mut issues = Vec::new(); let mut contents = HashMap::default(); @@ -164,7 +169,7 @@ impl Issues { // Create link map and anchor map and find inner-page issues let mut link_map = HashMap::default(); let mut anchor_map = HashMap::default(); - for (key, (references, anchors)) in iter { + for (key, (references, anchors, autorefs)) in iter { let id = key.try_as_id().expect("invariant"); let path = id.location().into_owned(); @@ -243,6 +248,12 @@ impl Issues { let id = &markdown[link.id.start..link.id.end]; if link_defs.contains_key(&to_id(id)) { used_link_defs.insert(to_id(id)); + } else if autoref_id(markdown, link) + .is_some_and(|id| autorefs.is_resolved(id)) + { + // Autorefs resolved this reference while rendering + // the page, so it is valid despite having no link + // definition in the Markdown source. } else { issues.push(Issue::UnresolvedReference { path: path.clone().into(), @@ -564,6 +575,58 @@ impl<'a> IntoIterator for &'a Issues { // Functions // ---------------------------------------------------------------------------- +/// Returns the identifier passed to autorefs for a link reference. +fn autoref_id<'a>(markdown: &'a str, link: &LinkReference) -> Option<&'a str> { + if link.kind != LinkReferenceKind::Link { + return None; + } + + let id = &markdown[link.id.start..link.id.end]; + if link.id != link.text { + return Some(id); + } + + // Autorefs only processes collapsed references (`[text][]`), not + // shortcut references (`[text]`). For an implicit identifier consisting + // of one code span, it uses the code content as the exact identifier. + if markdown.get(link.text.end..link.end) != Some("][]") { + return None; + } + Some(code_span_contents(id).unwrap_or(id)) +} + +/// Returns the contents when a value consists of exactly one code span. +fn code_span_contents(value: &str) -> Option<&str> { + let delimiter_len = value.bytes().take_while(|byte| *byte == b'`').count(); + if delimiter_len == 0 || value.len() <= delimiter_len * 2 { + return None; + } + + let trailing_len = + value.bytes().rev().take_while(|byte| *byte == b'`').count(); + if trailing_len != delimiter_len { + return None; + } + + let contents = &value[delimiter_len..value.len() - delimiter_len]; + let mut bytes = contents.bytes().peekable(); + while let Some(byte) = bytes.next() { + if byte != b'`' { + continue; + } + + let mut run_len = 1; + while bytes.next_if_eq(&b'`').is_some() { + run_len += 1; + } + if run_len == delimiter_len { + return None; + } + } + + Some(contents.trim()) +} + /// Converts an id to a normalized form for comparison. fn to_id(id: &str) -> String { let iter = id.split_whitespace(); @@ -676,8 +739,62 @@ fn to_slash(path: &str) -> String { #[cfg(test)] mod tests { use super::{ - decode_markdown_href, is_invalid_markdown_path, is_markdown_path, + autoref_id, code_span_contents, decode_markdown_href, + is_invalid_markdown_path, is_markdown_path, }; + use crate::python::collector::reference::{ + LinkReference, LinkReferenceKind, + }; + + #[test] + fn implicit_code_span_is_unwrapped_for_autorefs() { + let markdown = "[`warnings.deprecated`][]"; + let id_end = markdown.len() - 3; + let link = LinkReference { + start: 0, + end: markdown.len(), + kind: LinkReferenceKind::Link, + text: (1..id_end).into(), + id: (1..id_end).into(), + }; + + assert_eq!(autoref_id(markdown, &link), Some("warnings.deprecated")); + } + + #[test] + fn only_implicit_code_spans_are_unwrapped_for_autorefs() { + let shortcut = "[`identifier`]"; + let id_end = shortcut.len() - 1; + let shortcut_link = LinkReference { + start: 0, + end: shortcut.len(), + kind: LinkReferenceKind::Link, + text: (1..id_end).into(), + id: (1..id_end).into(), + }; + assert_eq!(autoref_id(shortcut, &shortcut_link), None); + + let explicit = "[label][`identifier`]"; + let explicit_link = LinkReference { + start: 0, + end: explicit.len(), + kind: LinkReferenceKind::Link, + text: (1..6).into(), + id: (8..explicit.len() - 1).into(), + }; + assert_eq!(autoref_id(explicit, &explicit_link), Some("`identifier`")); + } + + #[test] + fn code_span_delimiters_must_match() { + assert_eq!( + code_span_contents("``identifier`value``"), + Some("identifier`value") + ); + assert_eq!(code_span_contents("` identifier `"), Some("identifier")); + assert_eq!(code_span_contents("`identifier``"), None); + assert_eq!(code_span_contents("`one` and `two`"), None); + } #[test] fn markdown_path_must_end_in_md_file() { diff --git a/crates/zensical/src/structure/markdown.rs b/crates/zensical/src/structure/markdown.rs index a8a4d98..c791397 100644 --- a/crates/zensical/src/structure/markdown.rs +++ b/crates/zensical/src/structure/markdown.rs @@ -41,7 +41,7 @@ use crate::structure::toc::Section; mod autorefs; -pub use autorefs::Autorefs; +pub use autorefs::{AutorefResolutions, Autorefs}; // ---------------------------------------------------------------------------- // Constants diff --git a/crates/zensical/src/structure/markdown/autorefs.rs b/crates/zensical/src/structure/markdown/autorefs.rs index d76485b..6ac3099 100644 --- a/crates/zensical/src/structure/markdown/autorefs.rs +++ b/crates/zensical/src/structure/markdown/autorefs.rs @@ -25,7 +25,7 @@ //! Autorefs (mkdocstrings). -use ahash::HashMap; +use ahash::{HashMap, HashSet}; use pyo3::FromPyObject; use regex::{Captures, Regex}; use serde::{Deserialize, Serialize}; @@ -33,6 +33,7 @@ use std::path::Path; use std::string::ToString; use std::sync::LazyLock; use zrx::path::PathExt; +use zrx::stream::Value; // ---------------------------------------------------------------------------- // Constants @@ -201,6 +202,15 @@ fn is_relative_url(url: &str) -> bool { // Structs // ---------------------------------------------------------------------------- +/// Resolution results for autoreferences in a single page. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct AutorefResolutions { + /// Identifiers that were resolved successfully. + resolved: HashSet, +} + +// ---------------------------------------------------------------------------- + /// Autorefs (mkdocstrings). /// /// We use three URL maps, one for "primary" URLs, one for "secondary" URLs, @@ -448,8 +458,12 @@ impl Autorefs { )) } - /// Replaces autorefs in the given content. - pub fn replace_in(&self, content: String, from_url: &str) -> String { + /// Replaces autorefs and collects their resolution results. + #[allow(clippy::single_match_else)] + pub fn replace_in( + &self, content: String, from_url: &str, + ) -> (String, AutorefResolutions) { + let mut resolutions = AutorefResolutions::default(); let output = AUTOREF_RE.replace_all(&content, |captures: &Captures| { let attrs_str = captures.name("attrs").map_or("", |m| m.as_str()); @@ -471,6 +485,8 @@ impl Autorefs { match self.get_url_and_title_from_ids(&identifiers, from_url) { Ok((url, original_title)) => { + resolutions.resolved.insert(identifier.clone()); + // Check if URL is external (not relative) let external = !is_relative_url(&url); @@ -554,7 +570,24 @@ impl Autorefs { } }); - output.to_string() + (output.to_string(), resolutions) + } +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl Value for AutorefResolutions {} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl AutorefResolutions { + /// Returns whether an identifier was resolved. + pub fn is_resolved(&self, identifier: &str) -> bool { + self.resolved.contains(identifier) } } @@ -625,4 +658,26 @@ mod tests { ); } } + + #[test] + fn autoref_resolutions_are_collected_while_replacing() { + let mut autorefs = Autorefs::new(); + autorefs + .primary + .insert("known".to_string(), vec!["reference/#known".to_string()]); + + let (output, resolutions) = autorefs.replace_in( + concat!( + "Known", + "Missing", + ) + .to_string(), + "guide/", + ); + + assert!(resolutions.is_resolved("known")); + assert!(!resolutions.is_resolved("missing")); + assert!(output.contains("href=\"../reference/#known\"")); + assert!(output.contains("[Missing][missing]")); + } } diff --git a/crates/zensical/src/structure/page.rs b/crates/zensical/src/structure/page.rs index 047132b..cf96f93 100644 --- a/crates/zensical/src/structure/page.rs +++ b/crates/zensical/src/structure/page.rs @@ -38,7 +38,7 @@ use crate::config::Config; use crate::template::{Output, Template, GENERATOR}; use super::dynamic::Dynamic; -use super::markdown::Markdown; +use super::markdown::{AutorefResolutions, Markdown}; use super::nav::{Navigation, NavigationItem}; use super::search::SearchItem; use super::tag::Tag; @@ -197,7 +197,7 @@ impl Page { )] pub fn render( &mut self, config: &Config, nav: Navigation, - ) -> Result { + ) -> Result<(Output, AutorefResolutions), Error> { let name = self.meta.get("template").map(ToString::to_string); let template = Template::new( name.unwrap_or(String::from("main.html")), @@ -223,8 +223,9 @@ impl Page { page => self, })?; - // Replace autorefs, if any - Ok(Output::from(nav.autorefs.replace_in(output, &self.url))) + // Replace autorefs and retain their resolution results for validation + let (output, autorefs) = nav.autorefs.replace_in(output, &self.url); + Ok((Output::from(output), autorefs)) } /// Returns the tags of the page. diff --git a/crates/zensical/src/workflow.rs b/crates/zensical/src/workflow.rs index d58ba59..85f20c8 100644 --- a/crates/zensical/src/workflow.rs +++ b/crates/zensical/src/workflow.rs @@ -25,13 +25,15 @@ //! Workflow definitions +use ahash::{HashMap, HashSet}; use pyo3::types::PyAnyMethods; use pyo3::Python; use regex::Regex; use std::hash::{DefaultHasher, Hash, Hasher}; use std::path::{Path, PathBuf}; use std::str::FromStr; -use std::sync::{Arc, LazyLock}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, LazyLock, Mutex}; use std::{fs, io}; use zrx::id::matcher::Matcher; use zrx::id::{id, Id}; @@ -41,7 +43,7 @@ use zrx::stream::{Barrier, Stream, Workflow}; use super::config::Config; use super::python::{Anchors, Issues, References}; -use super::structure::markdown::Markdown; +use super::structure::markdown::{AutorefResolutions, Markdown}; use super::structure::nav::Navigation; use super::structure::page::Page; use super::structure::search::SearchIndex; @@ -80,6 +82,62 @@ pub struct Main { strict: bool, } +/// Page-content generation used to align validation inputs. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) struct PageGeneration(u64); + +impl zrx::scheduler::Value for PageGeneration {} + +/// Render generation used to align validation with rendered autoreferences. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) struct ValidationGeneration(u64, u64); + +impl zrx::scheduler::Value for ValidationGeneration {} + +/// Navigation generation used to construct validation sites. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct ValidationNavigation(u64); + +impl zrx::scheduler::Value for ValidationNavigation {} + +/// Site-wide validation pages shared across workflow products. +#[derive(Clone, Debug, PartialEq, Eq)] +struct SharedValidationPages(Arc); + +impl zrx::scheduler::Value for SharedValidationPages {} + +/// Site-wide validation inputs shared across rendered pages. +#[derive(Clone, Debug, PartialEq, Eq)] +struct ValidationSite { + /// Monotonically increasing site snapshot sequence. + sequence: u64, + /// Navigation hash used when pages were rendered. + nav_hash: u64, + /// References, anchors, and generations for all pages. + pages: Arc, +} + +impl zrx::scheduler::Value for ValidationSite {} + +/// Validation state collected while pages are rendered. +#[derive(Debug, Default)] +struct ValidationState { + /// Current site snapshot sequence. + sequence: u64, + /// Whether issues for the current site snapshot were printed. + reported: bool, + /// Pages expected in the current site snapshot. + expected: HashSet<(Id, ValidationGeneration)>, + /// Pages that have not rendered for the current site snapshot. + missing: HashSet<(Id, ValidationGeneration)>, + /// Current site snapshot, if validation inputs are ready. + site: Option, + /// Autoref resolutions indexed by source page and generation. + resolutions: HashMap<(Id, ValidationGeneration), AutorefResolutions>, +} + +type ValidationPages = Vec<(Key, (References, (Anchors, PageGeneration)))>; + // ---------------------------------------------------------------------------- // Implementations // ---------------------------------------------------------------------------- @@ -100,13 +158,6 @@ impl Module for Main { let page = generate_page(&self.config, &markdown); let pages = page.select([wait_for_markdown(&self.config)]); - // Collect all anchors and references from pages, to validate links - if self.config.project.validation.is_enabled() { - let references = collect_references(&files); - let anchors = collect_anchors(&page); - validate(&self.config, self.strict, references, anchors); - } - // Generate navigation and search index let nav = generate_nav(&self.config, &pages); generate_search_index(&self.config, &nav, &pages); @@ -114,9 +165,23 @@ impl Module for Main { // Generate object inventory generate_object_inventory(&self.config, &pages); - // // Render static and extra templates, as well as pages + // Render static and extra templates, as well as pages. render_templates(&self.config, &files, &nav); - render_pages(&self.config, &page, &nav); + let autorefs = render_pages(&self.config, &page, &nav); + + // Validate and print issues after all page autorefs have resolved. + if self.config.project.validation.is_enabled() { + let references = collect_references(&self.config, &files); + let anchors = collect_anchors(&page); + validate( + &self.config, + self.strict, + references, + anchors, + autorefs, + &nav, + ); + } Ok(()) } } @@ -148,10 +213,15 @@ pub fn wait_for_markdown(config: &Config) -> (Key, Barrier) { /// Create a stream to collect references from all Markdown files. pub fn collect_references( - files: &Stream, + config: &Config, files: &Stream, ) -> Stream { - let matcher = - Arc::new(Matcher::from_str("zrs:::::**/*.md:").expect("invariant")); + let matcher = Arc::new( + Matcher::from_str(&format!( + "zrs::::{}:**/*.md:", + config.project.docs_dir + )) + .expect("invariant"), + ); // Create pipeline to collect references files @@ -159,21 +229,135 @@ pub fn collect_references( .map(|Source { path }| fs::read_to_string(&*path)?.parse()) } -/// Create a stream to collect anchors from pages. -pub fn collect_anchors(pages: &Stream) -> Stream { - pages.map(move |page: Page| page.content.parse()) +/// Create a stream to collect anchors and generations from pages. +pub fn collect_anchors( + pages: &Stream, +) -> Stream { + pages.map(move |page: Page| { + let generation = page_generation(&page); + Ok::<_, anyhow::Error>((page.content.parse()?, generation)) + }) } /// Create a stream to validate references against anchors. pub fn validate( config: &Config, strict: bool, refs: Stream, - anchors: Stream, + anchors: Stream, + autorefs: Stream, + nav: &Stream, ) { - let combined = refs.join(&anchors).select([wait_for_markdown(config)]); let validation = config.project.validation.clone(); - combined - .map(Issues::new) - .inspect(move |issues: &Issues| issues.print(&validation, strict)); + let pages = refs + .join(&anchors) + .select([wait_for_markdown(config)]) + .map(|pages: ValidationPages| SharedValidationPages(Arc::new(pages))); + let nav = nav.map(|nav: Navigation| ValidationNavigation(nav.hash)); + let sequence = Arc::new(AtomicU64::new(0)); + let pages = pages.join(&nav).map( + move |pages: SharedValidationPages, nav: ValidationNavigation| { + ValidationSite { + sequence: sequence.fetch_add(1, Ordering::Relaxed) + 1, + nav_hash: nav.0, + pages: pages.0, + } + }, + ); + let state = Arc::new(Mutex::new(ValidationState::default())); + + let site_state = Arc::clone(&state); + let site_validation = validation.clone(); + pages.map(move |site: ValidationSite| { + let issues = { + let mut state = site_state.lock().expect("invariant"); + update_validation_site(&mut state, site); + validation_issues(&mut state) + }; + if let Some(issues) = issues { + issues.print(&site_validation, strict)?; + } + Ok::<_, anyhow::Error>(()) + }); + + autorefs.map( + move |source: Id, + generation: ValidationGeneration, + autorefs: AutorefResolutions| { + let issues = { + let mut state = state.lock().expect("invariant"); + let current = (source, generation); + state.resolutions.insert(current.clone(), autorefs); + if state.expected.contains(¤t) { + state.missing.remove(¤t); + } + validation_issues(&mut state) + }; + if let Some(issues) = issues { + issues.print(&validation, strict)?; + } + Ok::<_, anyhow::Error>(()) + }, + ); +} + +/// Update validation state with a new site snapshot. +fn update_validation_site(state: &mut ValidationState, site: ValidationSite) { + if site.sequence <= state.sequence { + return; + } + + let expected = site + .pages + .iter() + .map(|(key, (_, (_, page_generation)))| { + let id = key.try_as_id().expect("invariant"); + let generation = + ValidationGeneration(page_generation.0, site.nav_hash); + (id.clone(), generation) + }) + .collect::>(); + state.resolutions.retain(|key, _| expected.contains(key)); + state.missing = expected + .iter() + .filter(|key| !state.resolutions.contains_key(*key)) + .cloned() + .collect(); + state.sequence = site.sequence; + state.reported = false; + state.expected = expected; + state.site = Some(site); +} + +/// Create issues once every page in the current snapshot has rendered. +fn validation_issues(state: &mut ValidationState) -> Option { + if state.reported || !state.missing.is_empty() { + return None; + } + + let site = state.site.as_ref()?; + let iter = site.pages.iter().cloned().map( + |(key, (references, (anchors, page_generation)))| { + let id = key.try_as_id().expect("invariant"); + let generation = + ValidationGeneration(page_generation.0, site.nav_hash); + let autorefs = state + .resolutions + .get(&(id.clone(), generation)) + .cloned() + .expect("invariant"); + (key, (references, anchors, autorefs)) + }, + ); + let issues = Issues::new(iter); + state.reported = true; + Some(issues) +} + +/// Compute a generation from page content relevant to validation. +fn page_generation(page: &Page) -> PageGeneration { + let mut hasher = DefaultHasher::new(); + page.content.hash(&mut hasher); + page.meta.hash(&mut hasher); + PageGeneration(hasher.finish()) } /// Create a stream to process static assets. @@ -348,7 +532,11 @@ pub fn generate_nav( ) -> Stream { let config = config.clone(); pages.map(move |pages: Vec<(Key, Page)>| { - Navigation::new(config.get_cache_dir(), config.project.nav.clone(), pages) + Navigation::new( + config.get_cache_dir(), + config.project.nav.clone(), + pages, + ) }) } @@ -469,35 +657,34 @@ pub fn render_templates( /// Render pages. pub fn render_pages( config: &Config, page: &Stream, nav: &Stream, -) -> Stream { +) -> Stream { let config = config.clone(); - page.product(nav) - .map(move |mut page: Page, nav: Navigation| { + let pages = page.map(|id: &Id, page: Page| (id.clone(), page)); + pages.product(nav).map( + move |(source, mut page): (Id, Page), nav: Navigation| { let id = page.url.clone(); // Compute hash of page content - let hash = { - let mut hasher = DefaultHasher::new(); - page.content.hash(&mut hasher); - page.meta.hash(&mut hasher); - hasher.finish() - }; + let page_generation = page_generation(&page); + let generation = ValidationGeneration(page_generation.0, nav.hash); // Render page if we don't have a recent cached version at our own // disposal. Otherwise, just return if the content did not change. - let args = (config.hash, nav.hash, hash); + let args = (config.hash, nav.hash, page_generation.0); cached( &config, id, args, |(_, _, _)| Ok(page.render(&config, nav)?), ) - .and_then(|data| { + .and_then(|(data, autorefs)| { let path = Path::new(&page.path); fs::create_dir_all(path.parent().expect("invariant"))?; - fs::write(path, &*data).map_err(Into::into) + fs::write(path, &*data)?; + Ok((source, generation, autorefs)) }) - }) + }, + ) } /// Creates a workflow for the given config. diff --git a/python/tests/integration/test_validation.py b/python/tests/integration/test_validation.py new file mode 100644 index 0000000..0b49612 --- /dev/null +++ b/python/tests/integration/test_validation.py @@ -0,0 +1,76 @@ +# Copyright (c) 2025-2026 Zensical and contributors + +# SPDX-License-Identifier: MIT +# All contributions are certified under the DCO + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import zensical + +if TYPE_CHECKING: + from pathlib import Path + + import pytest + + +def test_validation_reports_issues_after_rendering( + tmp_path: Path, capfd: pytest.CaptureFixture[str] +) -> None: + """Validation reports source and autoref issues after page rendering.""" + docs = tmp_path / "docs" + docs.mkdir() + (docs / "index.md").write_text( + "# Hello\n\n[normal](missing.md)\n\n[autoref][missing-id]\n", + encoding="utf-8", + ) + (docs / "other.md").write_text("# Other\n", encoding="utf-8") + (tmp_path / "watched.md").write_text( + "# Watched support file\n", encoding="utf-8" + ) + config = tmp_path / "zensical.toml" + config.write_text( + """ +[project] +site_name = "Test" +watch = ["watched.md"] + +[project.validation] +unresolved_references = true +""".lstrip(), + encoding="utf-8", + ) + + zensical.build(str(config), {"clean": True, "strict": False}) + + captured = capfd.readouterr() + assert "page does not exist" in captured.err + assert "unresolved link reference" in captured.err + assert "2 issues found" in captured.err + assert captured.err.count("2 issues found") == 1 + + (docs / "index.md").write_text("# Hello\n", encoding="utf-8") + zensical.build(str(config), {"clean": True, "strict": False}) + + captured = capfd.readouterr() + assert "No issues found" in captured.err + assert "2 issues found" not in captured.err