z",
+ ),
+ ))
+ .expect("document audit");
+ let ObservationState::Captured(document) = document.final_invariants else {
+ panic!("document final report");
+ };
+ assert_eq!(document.fields().count(), 16);
+ assert!(
+ document
+ .fields()
+ .all(|(_, outcome)| matches!(outcome, InvariantOutcome::Satisfied))
+ );
+ }
+
+ #[test]
+ fn final_audit_failed_field_iteration_is_allocation_free_and_canonical() {
+ let failed = InvariantOutcome::Failed;
+ let report = ParserFinalizationReport {
+ input: InputFinalizationChecks {
+ decoder_carry_empty: failed.clone(),
+ preprocessing_flushed: failed.clone(),
+ },
+ tokenizer: TokenizerFinalizationChecks {
+ eof_emitted_once: failed.clone(),
+ pending_constructs_flushed: failed.clone(),
+ output_accounted_for: failed.clone(),
+ },
+ tree_builder: TreeBuilderFinalizationChecks {
+ pending_table_text_empty: failed.clone(),
+ insertion_mode_valid: failed.clone(),
+ open_elements_consistent: failed.clone(),
+ active_formatting_consistent: failed.clone(),
+ template_modes_consistent: failed.clone(),
+ form_pointer_valid: failed.clone(),
+ },
+ dom: DomFinalizationChecks {
+ parent_child_links_valid: failed.clone(),
+ namespaces_valid: failed.clone(),
+ template_associations_valid: failed.clone(),
+ },
+ patches: PatchFinalizationChecks {
+ all_patches_materialized: failed.clone(),
+ live_tree_matches_materialized_dom: failed,
+ },
+ };
+ assert_eq!(
+ report
+ .failed_fields()
+ .map(|(_, code)| code)
+ .collect::>(),
+ [
+ InvariantFailureCode::DecoderCarryNotEmpty,
+ InvariantFailureCode::PreprocessingNotFlushed,
+ InvariantFailureCode::EofEmissionInvalid,
+ InvariantFailureCode::PendingTokenizerConstruct,
+ InvariantFailureCode::TokenizerOutputUnaccounted,
+ InvariantFailureCode::PendingTableText,
+ InvariantFailureCode::InvalidInsertionMode,
+ InvariantFailureCode::OpenElementsInconsistent,
+ InvariantFailureCode::ActiveFormattingInconsistent,
+ InvariantFailureCode::TemplateModesInconsistent,
+ InvariantFailureCode::FormPointerInvalid,
+ InvariantFailureCode::ParentChildRelationshipInvalid,
+ InvariantFailureCode::NamespaceRelationshipInvalid,
+ InvariantFailureCode::TemplateAssociationInvalid,
+ InvariantFailureCode::PatchMaterializationIncomplete,
+ InvariantFailureCode::LiveTreeMismatch,
+ ]
+ );
+ }
+
+ #[test]
+ fn final_audit_patch_witness_requires_every_terminal_lifecycle_fact() {
+ let complete = crate::parser::PatchMaterializationWitness {
+ terminal_empty_drain_observed: true,
+ builder_pending_patch_count_after_finish: 0,
+ builder_pending_patch_count_after_terminal_drain: 0,
+ emitter_pending_patch_count_after_terminal_drain: 0,
+ drained_operation_count: 7,
+ applied_operation_count: 7,
+ materialized_after_terminal_drain: true,
+ };
+ assert!(patch_materialization_complete(complete));
+ for incomplete in [
+ crate::parser::PatchMaterializationWitness {
+ terminal_empty_drain_observed: false,
+ ..complete
+ },
+ crate::parser::PatchMaterializationWitness {
+ builder_pending_patch_count_after_finish: 1,
+ ..complete
+ },
+ crate::parser::PatchMaterializationWitness {
+ builder_pending_patch_count_after_terminal_drain: 1,
+ ..complete
+ },
+ crate::parser::PatchMaterializationWitness {
+ emitter_pending_patch_count_after_terminal_drain: 1,
+ ..complete
+ },
+ crate::parser::PatchMaterializationWitness {
+ applied_operation_count: 6,
+ ..complete
+ },
+ crate::parser::PatchMaterializationWitness {
+ materialized_after_terminal_drain: false,
+ ..complete
+ },
+ ] {
+ assert!(!patch_materialization_complete(incomplete));
+ }
+ }
+
fn captured(surface: &ObservationState>) -> &[T] {
match surface {
ObservationState::Captured(items) => items,
@@ -1265,6 +2124,7 @@ mod tests {
document_mode: ScalarObservationRequest::NotRequested,
tree,
patches: ObservationRequest::NotRequested,
+ final_invariants: FinalInvariantRequest::NotRequested,
})
.expect("AE13b4 production observation")
}
@@ -1292,6 +2152,7 @@ mod tests {
ObservationState::NotRequested,
ParserObservationTarget::DocumentParser,
ObservationRequest::NotRequested,
+ ObservationState::NotRequested,
)
}
@@ -1390,6 +2251,7 @@ mod tests {
document_mode: ScalarObservationRequest::NotRequested,
tree: ObservationRequest::NotRequested,
patches: ObservationRequest::NotRequested,
+ final_invariants: FinalInvariantRequest::NotRequested,
})
.expect("unobserved conformance execution still runs production parsing");
assert!(matches!(result.tokens, ObservationState::NotRequested));
@@ -1416,6 +2278,7 @@ mod tests {
document_mode: ScalarObservationRequest::NotRequested,
tree: ObservationRequest::NotRequested,
patches: ObservationRequest::NotRequested,
+ final_invariants: FinalInvariantRequest::NotRequested,
})
.unwrap();
assert_eq!(
@@ -2024,6 +2887,7 @@ mod tests {
document_mode: ScalarObservationRequest::NotRequested,
tree: ObservationRequest::NotRequested,
patches: ObservationRequest::NotRequested,
+ final_invariants: FinalInvariantRequest::NotRequested,
})
.unwrap();
assert_eq!(whole.transitions, chunked.transitions);
@@ -2041,6 +2905,7 @@ mod tests {
document_mode: ScalarObservationRequest::NotRequested,
tree: ObservationRequest::Capture { capacity: 256 },
patches: ObservationRequest::NotRequested,
+ final_invariants: FinalInvariantRequest::NotRequested,
})
.unwrap();
let unobserved_tree = execute_parser_observation(ParserObservationRequest {
@@ -2054,6 +2919,7 @@ mod tests {
document_mode: ScalarObservationRequest::NotRequested,
tree: ObservationRequest::Capture { capacity: 256 },
patches: ObservationRequest::NotRequested,
+ final_invariants: FinalInvariantRequest::NotRequested,
})
.unwrap();
assert_eq!(observed_tree.tree, unobserved_tree.tree);
@@ -2149,6 +3015,7 @@ mod tests {
true,
ObservationRequest::NotRequested,
ObservationRequest::NotRequested,
+ FinalInvariantRequest::NotRequested,
),
Err(ParserObservationExecutionError::ParserInvariant)
);
@@ -2173,6 +3040,26 @@ mod tests {
);
}
+ #[test]
+ fn final_audit_patch_application_failure_returns_no_partial_report() {
+ let mut parser = HtmlParser::new_with_conformance_observations(
+ HtmlParseOptions::default(),
+ ParserObservationConfig::default(),
+ PatchHistoryObservationConfig::capture(64),
+ )
+ .unwrap();
+ parser.push_str("x
").unwrap();
+ parser.finish().unwrap();
+ parser.force_materialization_failure_for_test();
+ let mut reserve = |_| Ok(());
+ assert!(matches!(
+ parser.into_output_with_final_audit(&mut reserve),
+ Err(ConformanceFinalizationError::Parser(
+ crate::HtmlParseError::PatchValidation(_)
+ ))
+ ));
+ }
+
#[test]
fn requested_patch_observation_without_session_capture_is_an_execution_failure() {
let mut parser = HtmlParser::new_with_observations(
@@ -2188,6 +3075,7 @@ mod tests {
false,
ObservationRequest::NotRequested,
ObservationRequest::Capture { capacity: 64 },
+ FinalInvariantRequest::NotRequested,
),
Err(ParserObservationExecutionError::PatchHistoryCaptureMissing)
);
@@ -2214,6 +3102,7 @@ mod tests {
false,
ObservationRequest::Capture { capacity: 16 },
ObservationRequest::NotRequested,
+ FinalInvariantRequest::NotRequested,
&mut allocations,
),
Err(ParserObservationExecutionError::ResourceExhaustion(
@@ -2224,6 +3113,126 @@ mod tests {
);
}
+ #[test]
+ fn every_final_audit_reservation_site_fails_without_a_partial_report() {
+ let sites = [
+ (
+ ObservationReservationSite::FinalAuditLiveTreeStructuralProjection,
+ ObservationAllocationStep::FinalAuditLiveTreeStructuralProjection,
+ ),
+ (
+ ObservationReservationSite::FinalAuditPatchArenaStructuralProjection,
+ ObservationAllocationStep::FinalAuditPatchArenaStructuralProjection,
+ ),
+ (
+ ObservationReservationSite::FinalAuditDomStructuralTraversal,
+ ObservationAllocationStep::FinalAuditDomStructuralTraversal,
+ ),
+ (
+ ObservationReservationSite::FinalAuditOpenElementsIndex,
+ ObservationAllocationStep::FinalAuditOpenElementsIndex,
+ ),
+ (
+ ObservationReservationSite::FinalAuditActiveFormattingIndex,
+ ObservationAllocationStep::FinalAuditActiveFormattingIndex,
+ ),
+ (
+ ObservationReservationSite::FinalAuditTemplateCoordinationIndex,
+ ObservationAllocationStep::FinalAuditTemplateCoordinationIndex,
+ ),
+ (
+ ObservationReservationSite::FinalAuditSemanticTraversal,
+ ObservationAllocationStep::FinalAuditSemanticTraversal,
+ ),
+ ];
+ for (site, step) in sites {
+ let mut parser = HtmlParser::new_with_conformance_observations(
+ HtmlParseOptions::default(),
+ ParserObservationConfig::default(),
+ PatchHistoryObservationConfig::default(),
+ )
+ .expect("parser");
+ parser.push_str("x
").expect("input");
+ parser.finish().expect("finish");
+ let mut allocations =
+ ObservationAllocationController::with_failure(ObservationFailureInjection {
+ step,
+ occurrence: NonZeroU64::MIN,
+ });
+ let result = finalize_document_parser_with_allocations(
+ parser,
+ false,
+ ObservationRequest::NotRequested,
+ ObservationRequest::NotRequested,
+ FinalInvariantRequest::Capture,
+ &mut allocations,
+ );
+ assert!(matches!(
+ result,
+ Err(ParserObservationExecutionError::ResourceExhaustion(error))
+ if error.site() == site
+ ));
+ }
+ }
+
+ #[test]
+ fn final_audit_later_real_reservations_are_injectable_without_partial_reports() {
+ let later_sites = [
+ (
+ ObservationReservationSite::FinalAuditLiveTreeStructuralProjection,
+ ObservationAllocationStep::FinalAuditLiveTreeStructuralProjection,
+ ),
+ (
+ ObservationReservationSite::FinalAuditPatchArenaStructuralProjection,
+ ObservationAllocationStep::FinalAuditPatchArenaStructuralProjection,
+ ),
+ (
+ ObservationReservationSite::FinalAuditDomStructuralTraversal,
+ ObservationAllocationStep::FinalAuditDomStructuralTraversal,
+ ),
+ (
+ ObservationReservationSite::FinalAuditOpenElementsIndex,
+ ObservationAllocationStep::FinalAuditOpenElementsIndex,
+ ),
+ (
+ ObservationReservationSite::FinalAuditTemplateCoordinationIndex,
+ ObservationAllocationStep::FinalAuditTemplateCoordinationIndex,
+ ),
+ (
+ ObservationReservationSite::FinalAuditSemanticTraversal,
+ ObservationAllocationStep::FinalAuditSemanticTraversal,
+ ),
+ ];
+ for (site, step) in later_sites {
+ let mut parser = HtmlParser::new_with_conformance_observations(
+ HtmlParseOptions::default(),
+ ParserObservationConfig::default(),
+ PatchHistoryObservationConfig::default(),
+ )
+ .expect("parser");
+ parser.push_str("x
").expect("input");
+ parser.finish().expect("finish");
+ let mut allocations =
+ ObservationAllocationController::with_failure(ObservationFailureInjection {
+ step,
+ occurrence: NonZeroU64::new(2).expect("non-zero"),
+ });
+ let result = finalize_document_parser_with_allocations(
+ parser,
+ false,
+ ObservationRequest::NotRequested,
+ ObservationRequest::NotRequested,
+ FinalInvariantRequest::Capture,
+ &mut allocations,
+ );
+ assert!(matches!(
+ result,
+ Err(ParserObservationExecutionError::ResourceExhaustion(error))
+ if error.site() == site
+ ));
+ }
+ }
+
#[test]
fn live_patch_history_invariant_is_stable_fatal_but_exact_for_conformance() {
let mut parser = HtmlParser::new_with_conformance_observations(
@@ -2576,6 +3585,7 @@ mod tests {
document_mode: ScalarObservationRequest::NotRequested,
tree: ObservationRequest::NotRequested,
patches: ObservationRequest::NotRequested,
+ final_invariants: FinalInvariantRequest::NotRequested,
})
.unwrap();
let ObservationState::Incomplete { partial, reason } = result.implementation_diagnostics
@@ -2986,6 +3996,7 @@ mod tests {
document_mode: ScalarObservationRequest::NotRequested,
tree: ObservationRequest::NotRequested,
patches: ObservationRequest::NotRequested,
+ final_invariants: FinalInvariantRequest::NotRequested,
})
.expect("document parser observation");
assert!(captured(&text_mode.parse_errors).iter().any(|event| {
@@ -4408,6 +5419,7 @@ mod tests {
document_mode,
tree: ObservationRequest::NotRequested,
patches: ObservationRequest::NotRequested,
+ final_invariants: FinalInvariantRequest::NotRequested,
})
.expect("document observation should complete")
}
@@ -4705,6 +5717,7 @@ mod tests {
document_mode: ScalarObservationRequest::Capture,
tree: ObservationRequest::NotRequested,
patches: ObservationRequest::NotRequested,
+ final_invariants: FinalInvariantRequest::NotRequested,
})
.expect("scalar-only production execution");
assert_eq!(scalar_only.document_mode, result.document_mode);
@@ -4724,6 +5737,7 @@ mod tests {
document_mode: ScalarObservationRequest::Capture,
tree: ObservationRequest::NotRequested,
patches: ObservationRequest::NotRequested,
+ final_invariants: FinalInvariantRequest::NotRequested,
})
.expect("standalone tokenizer execution");
assert_eq!(
@@ -4752,6 +5766,7 @@ mod tests {
document_mode: ScalarObservationRequest::NotRequested,
tree: ObservationRequest::Capture { capacity: 256 },
patches: ObservationRequest::Capture { capacity: 512 },
+ final_invariants: FinalInvariantRequest::NotRequested,
})
.expect("canonical document observation");
assert!(matches!(result.tokens, ObservationState::NotRequested));
@@ -4963,6 +5978,7 @@ mod tests {
document_mode: ScalarObservationRequest::NotRequested,
tree: ObservationRequest::NotRequested,
patches: ObservationRequest::Capture { capacity },
+ final_invariants: FinalInvariantRequest::NotRequested,
})
.unwrap()
.patches
@@ -5019,6 +6035,7 @@ mod tests {
document_mode: ScalarObservationRequest::NotRequested,
tree: ObservationRequest::Capture { capacity: 32 },
patches: ObservationRequest::Capture { capacity: 32 },
+ final_invariants: FinalInvariantRequest::NotRequested,
})
.unwrap();
assert!(matches!(result.tokens, ObservationState::NotRequested));
@@ -5061,6 +6078,7 @@ mod tests {
capacity: depth + 5,
},
patches: ObservationRequest::NotRequested,
+ final_invariants: FinalInvariantRequest::NotRequested,
})
.unwrap();
let ObservationState::Captured(tree) = result.tree else {
diff --git a/crates/html/src/conformance/mod.rs b/crates/html/src/conformance/mod.rs
index dfad7daa..0adb4bcd 100644
--- a/crates/html/src/conformance/mod.rs
+++ b/crates/html/src/conformance/mod.rs
@@ -23,11 +23,12 @@ pub use crate::html5::shared::{
WhatwgParseErrorCode,
};
pub use execution::{
- ObservationRequest, ObservationReservationSite, ObservationResourceExhaustion,
- ParserFatalIdentity, ParserObservationExecutionError, ParserObservationExecutionIdentity,
- ParserObservationInput, ParserObservationInvariantError, ParserObservationRequest,
- ParserObservationTarget, ParserReservationSiteIdentity, ParserTokenizerInvariantError,
- ScalarObservationRequest, UnsupportedFeatureObservationInvariantError,
- execute_parser_observation,
+ FinalInvariantRequest, ObservationRequest, ObservationReservationSite,
+ ObservationResourceExhaustion, ParserFatalIdentity, ParserObservationDeliveryError,
+ ParserObservationDeliveryErrorIdentity, ParserObservationExecutionError,
+ ParserObservationExecutionIdentity, ParserObservationInput, ParserObservationInvariantError,
+ ParserObservationRequest, ParserObservationTarget, ParserReservationSiteIdentity,
+ ParserTokenizerInvariantError, ScalarObservationRequest,
+ UnsupportedFeatureObservationInvariantError, execute_parser_observation,
};
pub use model::*;
diff --git a/crates/html/src/conformance/model.rs b/crates/html/src/conformance/model.rs
index e305ca60..02a167cf 100644
--- a/crates/html/src/conformance/model.rs
+++ b/crates/html/src/conformance/model.rs
@@ -61,7 +61,10 @@ impl CanonicalParserResult {
}
pub fn has_failed_final_invariant(&self) -> bool {
- !self.failed_final_invariants().is_empty()
+ matches!(
+ &self.final_invariants,
+ ObservationState::Captured(report) if report.has_failure()
+ )
}
pub fn failed_final_invariants(&self) -> Vec {
@@ -210,24 +213,133 @@ pub struct ParserFinalizationReport {
impl ParserFinalizationReport {
pub fn has_failure(&self) -> bool {
- !self.failures().is_empty()
+ self.failed_fields().next().is_some()
+ }
+
+ /// Fixed canonical field order without heap allocation.
+ pub fn fields(&self) -> impl Iterator- {
+ [
+ (
+ FinalInvariantField::DecoderCarryEmpty,
+ &self.input.decoder_carry_empty,
+ ),
+ (
+ FinalInvariantField::PreprocessingFlushed,
+ &self.input.preprocessing_flushed,
+ ),
+ (
+ FinalInvariantField::EofEmittedOnce,
+ &self.tokenizer.eof_emitted_once,
+ ),
+ (
+ FinalInvariantField::PendingConstructsFlushed,
+ &self.tokenizer.pending_constructs_flushed,
+ ),
+ (
+ FinalInvariantField::OutputAccountedFor,
+ &self.tokenizer.output_accounted_for,
+ ),
+ (
+ FinalInvariantField::PendingTableTextEmpty,
+ &self.tree_builder.pending_table_text_empty,
+ ),
+ (
+ FinalInvariantField::InsertionModeValid,
+ &self.tree_builder.insertion_mode_valid,
+ ),
+ (
+ FinalInvariantField::OpenElementsConsistent,
+ &self.tree_builder.open_elements_consistent,
+ ),
+ (
+ FinalInvariantField::ActiveFormattingConsistent,
+ &self.tree_builder.active_formatting_consistent,
+ ),
+ (
+ FinalInvariantField::TemplateModesConsistent,
+ &self.tree_builder.template_modes_consistent,
+ ),
+ (
+ FinalInvariantField::FormPointerValid,
+ &self.tree_builder.form_pointer_valid,
+ ),
+ (
+ FinalInvariantField::ParentChildLinksValid,
+ &self.dom.parent_child_links_valid,
+ ),
+ (
+ FinalInvariantField::NamespacesValid,
+ &self.dom.namespaces_valid,
+ ),
+ (
+ FinalInvariantField::TemplateAssociationsValid,
+ &self.dom.template_associations_valid,
+ ),
+ (
+ FinalInvariantField::AllPatchesMaterialized,
+ &self.patches.all_patches_materialized,
+ ),
+ (
+ FinalInvariantField::LiveTreeMatchesMaterializedDom,
+ &self.patches.live_tree_matches_materialized_dom,
+ ),
+ ]
+ .into_iter()
+ }
+
+ pub fn failed_fields(
+ &self,
+ ) -> impl Iterator
- + '_ {
+ self.fields().filter_map(|(field, outcome)| {
+ matches!(outcome, InvariantOutcome::Failed).then_some((field, field.failure_code()))
+ })
}
pub fn failures(&self) -> Vec {
- let Self {
- input,
- tokenizer,
- tree_builder,
- dom,
- patches,
- } = self;
- let mut failures = Vec::new();
- input.append_failures(&mut failures);
- tokenizer.append_failures(&mut failures);
- tree_builder.append_failures(&mut failures);
- dom.append_failures(&mut failures);
- patches.append_failures(&mut failures);
- failures
+ self.failed_fields().map(|(_, code)| code).collect()
+ }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum FinalInvariantField {
+ DecoderCarryEmpty,
+ PreprocessingFlushed,
+ EofEmittedOnce,
+ PendingConstructsFlushed,
+ OutputAccountedFor,
+ PendingTableTextEmpty,
+ InsertionModeValid,
+ OpenElementsConsistent,
+ ActiveFormattingConsistent,
+ TemplateModesConsistent,
+ FormPointerValid,
+ ParentChildLinksValid,
+ NamespacesValid,
+ TemplateAssociationsValid,
+ AllPatchesMaterialized,
+ LiveTreeMatchesMaterializedDom,
+}
+
+impl FinalInvariantField {
+ pub const fn failure_code(self) -> InvariantFailureCode {
+ match self {
+ Self::DecoderCarryEmpty => InvariantFailureCode::DecoderCarryNotEmpty,
+ Self::PreprocessingFlushed => InvariantFailureCode::PreprocessingNotFlushed,
+ Self::EofEmittedOnce => InvariantFailureCode::EofEmissionInvalid,
+ Self::PendingConstructsFlushed => InvariantFailureCode::PendingTokenizerConstruct,
+ Self::OutputAccountedFor => InvariantFailureCode::TokenizerOutputUnaccounted,
+ Self::PendingTableTextEmpty => InvariantFailureCode::PendingTableText,
+ Self::InsertionModeValid => InvariantFailureCode::InvalidInsertionMode,
+ Self::OpenElementsConsistent => InvariantFailureCode::OpenElementsInconsistent,
+ Self::ActiveFormattingConsistent => InvariantFailureCode::ActiveFormattingInconsistent,
+ Self::TemplateModesConsistent => InvariantFailureCode::TemplateModesInconsistent,
+ Self::FormPointerValid => InvariantFailureCode::FormPointerInvalid,
+ Self::ParentChildLinksValid => InvariantFailureCode::ParentChildRelationshipInvalid,
+ Self::NamespacesValid => InvariantFailureCode::NamespaceRelationshipInvalid,
+ Self::TemplateAssociationsValid => InvariantFailureCode::TemplateAssociationInvalid,
+ Self::AllPatchesMaterialized => InvariantFailureCode::PatchMaterializationIncomplete,
+ Self::LiveTreeMatchesMaterializedDom => InvariantFailureCode::LiveTreeMismatch,
+ }
}
}
@@ -237,25 +349,6 @@ pub struct InputFinalizationChecks {
pub preprocessing_flushed: InvariantOutcome,
}
-impl InputFinalizationChecks {
- fn append_failures(&self, failures: &mut Vec) {
- let Self {
- decoder_carry_empty,
- preprocessing_flushed,
- } = self;
- append_invariant_failure(
- decoder_carry_empty,
- InvariantFailureCode::DecoderCarryNotEmpty,
- failures,
- );
- append_invariant_failure(
- preprocessing_flushed,
- InvariantFailureCode::PreprocessingNotFlushed,
- failures,
- );
- }
-}
-
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TokenizerFinalizationChecks {
pub eof_emitted_once: InvariantOutcome,
@@ -263,31 +356,6 @@ pub struct TokenizerFinalizationChecks {
pub output_accounted_for: InvariantOutcome,
}
-impl TokenizerFinalizationChecks {
- fn append_failures(&self, failures: &mut Vec) {
- let Self {
- eof_emitted_once,
- pending_constructs_flushed,
- output_accounted_for,
- } = self;
- append_invariant_failure(
- eof_emitted_once,
- InvariantFailureCode::EofEmissionInvalid,
- failures,
- );
- append_invariant_failure(
- pending_constructs_flushed,
- InvariantFailureCode::PendingTokenizerConstruct,
- failures,
- );
- append_invariant_failure(
- output_accounted_for,
- InvariantFailureCode::TokenizerOutputUnaccounted,
- failures,
- );
- }
-}
-
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TreeBuilderFinalizationChecks {
pub pending_table_text_empty: InvariantOutcome,
@@ -298,49 +366,6 @@ pub struct TreeBuilderFinalizationChecks {
pub form_pointer_valid: InvariantOutcome,
}
-impl TreeBuilderFinalizationChecks {
- fn append_failures(&self, failures: &mut Vec) {
- let Self {
- pending_table_text_empty,
- insertion_mode_valid,
- open_elements_consistent,
- active_formatting_consistent,
- template_modes_consistent,
- form_pointer_valid,
- } = self;
- append_invariant_failure(
- pending_table_text_empty,
- InvariantFailureCode::PendingTableText,
- failures,
- );
- append_invariant_failure(
- insertion_mode_valid,
- InvariantFailureCode::InvalidInsertionMode,
- failures,
- );
- append_invariant_failure(
- open_elements_consistent,
- InvariantFailureCode::OpenElementsInconsistent,
- failures,
- );
- append_invariant_failure(
- active_formatting_consistent,
- InvariantFailureCode::ActiveFormattingInconsistent,
- failures,
- );
- append_invariant_failure(
- template_modes_consistent,
- InvariantFailureCode::TemplateModesInconsistent,
- failures,
- );
- append_invariant_failure(
- form_pointer_valid,
- InvariantFailureCode::FormPointerInvalid,
- failures,
- );
- }
-}
-
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DomFinalizationChecks {
pub parent_child_links_valid: InvariantOutcome,
@@ -348,66 +373,12 @@ pub struct DomFinalizationChecks {
pub template_associations_valid: InvariantOutcome,
}
-impl DomFinalizationChecks {
- fn append_failures(&self, failures: &mut Vec) {
- let Self {
- parent_child_links_valid,
- namespaces_valid,
- template_associations_valid,
- } = self;
- append_invariant_failure(
- parent_child_links_valid,
- InvariantFailureCode::ParentChildRelationshipInvalid,
- failures,
- );
- append_invariant_failure(
- namespaces_valid,
- InvariantFailureCode::NamespaceRelationshipInvalid,
- failures,
- );
- append_invariant_failure(
- template_associations_valid,
- InvariantFailureCode::TemplateAssociationInvalid,
- failures,
- );
- }
-}
-
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PatchFinalizationChecks {
pub all_patches_materialized: InvariantOutcome,
pub live_tree_matches_materialized_dom: InvariantOutcome,
}
-impl PatchFinalizationChecks {
- fn append_failures(&self, failures: &mut Vec) {
- let Self {
- all_patches_materialized,
- live_tree_matches_materialized_dom,
- } = self;
- append_invariant_failure(
- all_patches_materialized,
- InvariantFailureCode::PatchMaterializationIncomplete,
- failures,
- );
- append_invariant_failure(
- live_tree_matches_materialized_dom,
- InvariantFailureCode::LiveTreeMismatch,
- failures,
- );
- }
-}
-
-fn append_invariant_failure(
- outcome: &InvariantOutcome,
- code: InvariantFailureCode,
- failures: &mut Vec,
-) {
- if matches!(outcome, InvariantOutcome::Failed) {
- failures.push(code);
- }
-}
-
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum InvariantOutcome {
Satisfied,
diff --git a/crates/html/src/conformance/projection.rs b/crates/html/src/conformance/projection.rs
index a6608e2b..40a32476 100644
--- a/crates/html/src/conformance/projection.rs
+++ b/crates/html/src/conformance/projection.rs
@@ -46,6 +46,13 @@ pub(super) enum ObservationAllocationStep {
PatchCreationHistoryStorage,
SnapshotLabelMapStorage,
SnapshotLabelStringStorage,
+ FinalAuditLiveTreeStructuralProjection,
+ FinalAuditPatchArenaStructuralProjection,
+ FinalAuditDomStructuralTraversal,
+ FinalAuditOpenElementsIndex,
+ FinalAuditActiveFormattingIndex,
+ FinalAuditTemplateCoordinationIndex,
+ FinalAuditSemanticTraversal,
}
#[cfg(test)]
@@ -59,6 +66,37 @@ impl ObservationAllocationController {
}
impl ObservationAllocationController {
+ pub(super) fn before_final_audit(
+ &mut self,
+ site: ObservationReservationSite,
+ ) -> Result<(), ObservationResourceExhaustion> {
+ let step = match site {
+ ObservationReservationSite::FinalAuditLiveTreeStructuralProjection => {
+ ObservationAllocationStep::FinalAuditLiveTreeStructuralProjection
+ }
+ ObservationReservationSite::FinalAuditPatchArenaStructuralProjection => {
+ ObservationAllocationStep::FinalAuditPatchArenaStructuralProjection
+ }
+ ObservationReservationSite::FinalAuditDomStructuralTraversal => {
+ ObservationAllocationStep::FinalAuditDomStructuralTraversal
+ }
+ ObservationReservationSite::FinalAuditOpenElementsIndex => {
+ ObservationAllocationStep::FinalAuditOpenElementsIndex
+ }
+ ObservationReservationSite::FinalAuditActiveFormattingIndex => {
+ ObservationAllocationStep::FinalAuditActiveFormattingIndex
+ }
+ ObservationReservationSite::FinalAuditTemplateCoordinationIndex => {
+ ObservationAllocationStep::FinalAuditTemplateCoordinationIndex
+ }
+ ObservationReservationSite::FinalAuditSemanticTraversal => {
+ ObservationAllocationStep::FinalAuditSemanticTraversal
+ }
+ _ => return Ok(()),
+ };
+ self.before_reservation(site, step)
+ }
+
fn before_reservation(
&mut self,
site: ObservationReservationSite,
diff --git a/crates/html/src/html5/bridge/adapters.rs b/crates/html/src/html5/bridge/adapters.rs
index 148c6983..da209bfd 100644
--- a/crates/html/src/html5/bridge/adapters.rs
+++ b/crates/html/src/html5/bridge/adapters.rs
@@ -46,6 +46,10 @@ pub(crate) struct PatchEmitterAdapter {
}
impl PatchEmitterAdapter {
+ #[cfg(feature = "parser-conformance")]
+ pub(crate) fn buffered_patch_count_for_final_audit(&self) -> usize {
+ self.patches.len()
+ }
pub(crate) fn new() -> Self {
Self {
patches: Vec::new(),
diff --git a/crates/html/src/html5/mod.rs b/crates/html/src/html5/mod.rs
index 5413c229..f9aa6fba 100644
--- a/crates/html/src/html5/mod.rs
+++ b/crates/html/src/html5/mod.rs
@@ -28,6 +28,8 @@ pub use fuzz::{
))]
pub use fuzz::{Html5PipelineRegressionError, render_html5_pipeline_regression_snapshot};
pub use session::Html5ParseSession;
+#[cfg(feature = "parser-conformance")]
+pub(crate) use session::Html5SessionFinalAudit;
#[cfg(all(feature = "internal-api", feature = "parser-failure-injection"))]
pub use shared::ParserFailureInjection;
pub use shared::{
diff --git a/crates/html/src/html5/session/api.rs b/crates/html/src/html5/session/api.rs
index 3c5fc6b0..6b990296 100644
--- a/crates/html/src/html5/session/api.rs
+++ b/crates/html/src/html5/session/api.rs
@@ -14,6 +14,18 @@ use crate::html5::tokenizer::{Html5Tokenizer, TokenizerConfig};
#[cfg(test)]
use crate::html5::tree_builder::PatchSink;
use crate::html5::tree_builder::{Html5TreeBuilder, TreeBuilderConfig};
+#[cfg(feature = "parser-conformance")]
+use crate::html5::tree_builder::{TreeBuilderFinalAudit, TreeBuilderFinalAuditAllocation};
+
+#[cfg(feature = "parser-conformance")]
+pub(crate) struct Html5SessionFinalAudit {
+ pub(crate) decoder_carry_empty: bool,
+ pub(crate) preprocessing_flushed: bool,
+ pub(crate) tokenizer_eof_lifecycle_complete: bool,
+ pub(crate) tokenizer_pending_constructs_flushed: bool,
+ pub(crate) tokenizer_output_accounted_for: bool,
+ pub(crate) tree_builder: TreeBuilderFinalAudit,
+}
/// Feature-gated runtime entrypoint for the HTML5 parsing path.
pub struct Html5ParseSession {
@@ -255,6 +267,44 @@ impl Html5ParseSession {
self.patch_history_invariant
}
+ #[cfg(feature = "parser-conformance")]
+ pub(crate) fn final_audit_for_conformance(
+ &self,
+ reserve: &mut impl FnMut(crate::conformance::ObservationReservationSite) -> Result<(), ()>,
+ ) -> Result {
+ let tokenizer = self.tokenizer.final_audit_for_conformance();
+ let mut tree_builder = self
+ .builder
+ .final_audit_for_conformance(&self.ctx.atoms, reserve)?;
+ tree_builder.insertion_mode_valid = tokenizer.active_text_mode.is_none()
+ && tree_builder.active_text_mode.is_none()
+ && tree_builder.original_insertion_mode.is_none()
+ && tree_builder.pending_tokenizer_control.is_none()
+ && !matches!(
+ tree_builder.insertion_mode,
+ crate::html5::tree_builder::modes::InsertionMode::Text
+ | crate::html5::tree_builder::modes::InsertionMode::InTableText
+ );
+ Ok(Html5SessionFinalAudit {
+ decoder_carry_empty: !self.decoder.has_pending_bytes(),
+ preprocessing_flushed: !self.input.has_pending_preprocessing(),
+ tokenizer_eof_lifecycle_complete: tokenizer.eof_lifecycle_complete,
+ tokenizer_pending_constructs_flushed: tokenizer.pending_constructs_flushed,
+ tokenizer_output_accounted_for: tokenizer.output_queue_empty,
+ tree_builder,
+ })
+ }
+
+ #[cfg(feature = "parser-conformance")]
+ pub(crate) fn builder_pending_patch_count_for_final_audit(&self) -> usize {
+ self.builder.pending_patch_count_for_final_audit()
+ }
+
+ #[cfg(feature = "parser-conformance")]
+ pub(crate) fn emitter_pending_patch_count_for_final_audit(&self) -> usize {
+ self.patch_emitter.buffered_patch_count_for_final_audit()
+ }
+
#[cfg(feature = "parser-conformance")]
pub(crate) fn take_patch_history_for_conformance(
&mut self,
@@ -333,6 +383,25 @@ impl Html5ParseSession {
self.tokenizer.active_text_mode_for_test()
}
+ #[cfg(all(test, feature = "parser-conformance"))]
+ pub(crate) fn set_terminal_text_state_for_test(
+ &mut self,
+ tokenizer_active_text_mode: Option,
+ tree_builder_active_text_mode: Option,
+ original_insertion_mode: Option,
+ pending_tokenizer_control: Option,
+ insertion_mode: crate::html5::tree_builder::modes::InsertionMode,
+ ) {
+ self.tokenizer
+ .set_active_text_mode_for_test(tokenizer_active_text_mode);
+ self.builder.set_terminal_text_state_for_test(
+ tree_builder_active_text_mode,
+ original_insertion_mode,
+ pending_tokenizer_control,
+ insertion_mode,
+ );
+ }
+
#[cfg(all(test, feature = "parser-conformance"))]
pub(crate) fn applied_tokenizer_controls_for_test(&self) -> &[TokenizerControl] {
&self.applied_tokenizer_controls_for_test
diff --git a/crates/html/src/html5/session/mod.rs b/crates/html/src/html5/session/mod.rs
index 22eee373..199e5e03 100644
--- a/crates/html/src/html5/session/mod.rs
+++ b/crates/html/src/html5/session/mod.rs
@@ -5,6 +5,8 @@ mod counters;
mod driver;
pub use api::Html5ParseSession;
+#[cfg(feature = "parser-conformance")]
+pub(crate) use api::Html5SessionFinalAudit;
#[cfg(all(test, feature = "html5"))]
mod tests;
diff --git a/crates/html/src/html5/session/tests/text_mode.rs b/crates/html/src/html5/session/tests/text_mode.rs
index 53a24e32..3a8cd28a 100644
--- a/crates/html/src/html5/session/tests/text_mode.rs
+++ b/crates/html/src/html5/session/tests/text_mode.rs
@@ -384,6 +384,137 @@ fn session_head_script_restores_in_head_after_matching_close() {
);
}
+#[cfg(feature = "parser-conformance")]
+#[test]
+fn terminal_final_audit_requires_both_text_modes_and_controls_to_be_clear() {
+ use crate::html5::tokenizer::TextModeSpec;
+ use crate::html5::tree_builder::modes::InsertionMode;
+
+ fn terminal_audit(
+ tokenizer_mode: Option,
+ builder_mode: Option,
+ original_mode: Option,
+ pending_control: Option,
+ insertion_mode: InsertionMode,
+ ) -> bool {
+ let context = DocumentParseContext::new();
+ let mut session = Html5ParseSession::new(
+ TokenizerConfig::default(),
+ TreeBuilderConfig::default(),
+ context,
+ )
+ .expect("session init");
+ session.push_str_for_test("
x
");
+ session.pump().expect("terminal fixture pump");
+ session.finish_for_test().expect("terminal fixture finish");
+ session.set_terminal_text_state_for_test(
+ tokenizer_mode,
+ builder_mode,
+ original_mode,
+ pending_control,
+ insertion_mode,
+ );
+ let mut reserve = |_| Ok(());
+ session
+ .final_audit_for_conformance(&mut reserve)
+ .expect("terminal audit should return a report")
+ .tree_builder
+ .insertion_mode_valid
+ }
+
+ let mode = {
+ let mut names = crate::names::NameInterner::new();
+ TextModeSpec::rcdata_textarea(
+ names
+ .intern_ascii_folded("textarea")
+ .expect("textarea atom"),
+ )
+ };
+ let other_mode = {
+ let mut names = crate::names::NameInterner::new();
+ TextModeSpec::script_data(names.intern_ascii_folded("script").expect("script atom"))
+ };
+
+ for (label, tokenizer_mode, builder_mode, original_mode, pending_control, insertion_mode) in [
+ (
+ "tokenizer-only",
+ Some(mode),
+ None,
+ None,
+ None,
+ InsertionMode::InBody,
+ ),
+ (
+ "tree-builder-only",
+ None,
+ Some(mode),
+ None,
+ None,
+ InsertionMode::InBody,
+ ),
+ (
+ "both-equal",
+ Some(mode),
+ Some(mode),
+ None,
+ None,
+ InsertionMode::InBody,
+ ),
+ (
+ "both-unequal",
+ Some(mode),
+ Some(other_mode),
+ None,
+ None,
+ InsertionMode::InBody,
+ ),
+ ("terminal-text", None, None, None, None, InsertionMode::Text),
+ (
+ "terminal-table-text",
+ None,
+ None,
+ None,
+ None,
+ InsertionMode::InTableText,
+ ),
+ (
+ "original-mode",
+ None,
+ None,
+ Some(InsertionMode::InBody),
+ None,
+ InsertionMode::InBody,
+ ),
+ (
+ "pending-control",
+ None,
+ None,
+ None,
+ Some(crate::html5::tokenizer::TokenizerControl::ExitTextMode),
+ InsertionMode::InBody,
+ ),
+ ] {
+ assert!(
+ !terminal_audit(
+ tokenizer_mode,
+ builder_mode,
+ original_mode,
+ pending_control,
+ insertion_mode,
+ ),
+ "{label} must fail terminal insertion-mode validity"
+ );
+ }
+
+ assert!(terminal_audit(
+ None,
+ None,
+ None,
+ None,
+ InsertionMode::InBody
+ ));
+}
+
#[test]
fn session_exits_text_mode_on_eof_recovery() {
let mut ctx = DocumentParseContext::new();
diff --git a/crates/html/src/html5/tokenizer/api.rs b/crates/html/src/html5/tokenizer/api.rs
index e67a6fc4..a305981b 100644
--- a/crates/html/src/html5/tokenizer/api.rs
+++ b/crates/html/src/html5/tokenizer/api.rs
@@ -707,6 +707,11 @@ impl Html5Tokenizer {
self.active_text_mode
}
+ #[cfg(test)]
+ pub(crate) fn set_active_text_mode_for_test(&mut self, mode: Option) {
+ self.active_text_mode = mode;
+ }
+
#[cfg(test)]
pub(crate) fn force_self_closing_flag_without_solidus_for_test(&mut self) {
self.current_tag_self_closing = true;
diff --git a/crates/html/src/html5/tokenizer/invariants.rs b/crates/html/src/html5/tokenizer/invariants.rs
index e8f82043..de4cb2e7 100644
--- a/crates/html/src/html5/tokenizer/invariants.rs
+++ b/crates/html/src/html5/tokenizer/invariants.rs
@@ -35,6 +35,55 @@ pub(crate) enum TokenizerInvariantKind {
ProcessingInstructionDataStartAfterCursor,
}
+#[cfg(feature = "parser-conformance")]
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) struct TokenizerFinalAudit {
+ pub(crate) eof_lifecycle_complete: bool,
+ pub(crate) pending_constructs_flushed: bool,
+ pub(crate) output_queue_empty: bool,
+ pub(crate) active_text_mode: Option,
+}
+
+#[cfg(feature = "parser-conformance")]
+impl Html5Tokenizer {
+ /// Read-only terminal audit over the tokenizer's authoritative lifecycle
+ /// and pending-construction state. EOF emission has no parallel witness:
+ /// `finish_with_context` owns the guarded `end_of_stream`/`eof_emitted`
+ /// transition inspected here.
+ pub(crate) fn final_audit_for_conformance(&self) -> TokenizerFinalAudit {
+ TokenizerFinalAudit {
+ eof_lifecycle_complete: self.end_of_stream && self.eof_emitted == self.config.emit_eof,
+ pending_constructs_flushed: self.pending_text_mode_end_tag_matcher.is_none()
+ && self.pending_text_mode_end_tag.is_none()
+ && self.pending_text_start.is_none()
+ && self.pending_comment_start.is_none()
+ && !self.pending_comment_limit_reported
+ && self.pending_processing_instruction.is_none()
+ && self.pending_doctype_name.is_none()
+ && self.pending_doctype_name_start.is_none()
+ && self.pending_doctype_public_id.is_none()
+ && self.pending_doctype_system_id.is_none()
+ && !self.pending_doctype_force_quirks
+ && !self.pending_doctype_limit_reported
+ && self.tag_name_start.is_none()
+ && self.tag_name_end.is_none()
+ && !self.tag_name_complete
+ && !self.current_tag_is_end
+ && !self.current_tag_self_closing
+ && self.current_tag_self_closing_solidus_position.is_none()
+ && self.current_tag_attrs.is_empty()
+ && self.current_attr_name_start.is_none()
+ && self.current_attr_name_end.is_none()
+ && !self.current_attr_has_value
+ && self.current_attr_value_start.is_none()
+ && self.current_attr_value_end.is_none()
+ && !self.end_tag_prefix_consumed,
+ output_queue_empty: self.tokens.is_empty(),
+ active_text_mode: self.active_text_mode,
+ }
+ }
+}
+
/// Debug/runtime tokenizer hardening checks.
///
/// These checks are enabled in debug/test builds and in release when the
diff --git a/crates/html/src/html5/tree_builder/api.rs b/crates/html/src/html5/tree_builder/api.rs
index c8d9bc4f..2fd0c457 100644
--- a/crates/html/src/html5/tree_builder/api.rs
+++ b/crates/html/src/html5/tree_builder/api.rs
@@ -319,6 +319,10 @@ pub struct TreeBuilderStateSnapshot {
}
impl Html5TreeBuilder {
+ #[cfg(feature = "parser-conformance")]
+ pub(crate) fn pending_patch_count_for_final_audit(&self) -> usize {
+ self.patches.len()
+ }
pub(in crate::html5::tree_builder) fn adjusted_current_node(
&self,
) -> Option> {
@@ -660,6 +664,20 @@ impl Html5TreeBuilder {
}
}
+ #[cfg(test)]
+ pub(crate) fn set_terminal_text_state_for_test(
+ &mut self,
+ active_text_mode: Option,
+ original_insertion_mode: Option,
+ pending_tokenizer_control: Option,
+ insertion_mode: InsertionMode,
+ ) {
+ self.active_text_mode = active_text_mode;
+ self.original_insertion_mode = original_insertion_mode;
+ self.pending_tokenizer_control = pending_tokenizer_control;
+ self.insertion_mode = insertion_mode;
+ }
+
#[cold]
#[track_caller]
pub(in crate::html5::tree_builder) fn assert_atom_table_binding(&self, atoms: &AtomTable) {
diff --git a/crates/html/src/html5/tree_builder/final_audit.rs b/crates/html/src/html5/tree_builder/final_audit.rs
new file mode 100644
index 00000000..40ffee5a
--- /dev/null
+++ b/crates/html/src/html5/tree_builder/final_audit.rs
@@ -0,0 +1,311 @@
+use std::collections::{HashMap, HashSet};
+
+use crate::ElementNamespace;
+use crate::attributes::parser_created_attribute_lists_equal_ordered;
+use crate::conformance::ObservationReservationSite;
+use crate::dom_patch::PatchKey;
+use crate::html5::shared::AtomTable;
+use crate::html5::tokenizer::{TextModeSpec, TokenizerControl};
+use crate::html5::tree_builder::formatting::{AfeEntry, AfeMarkerKind};
+use crate::html5::tree_builder::modes::InsertionMode;
+use crate::html5::tree_builder::stack::ExpandedNameKey;
+use crate::html5::tree_builder::{DomInvariantState, Html5TreeBuilder};
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) enum TreeBuilderFinalAuditAllocation {
+ OpenElementsIndex,
+ ActiveFormattingIndex,
+ TemplateCoordinationIndex,
+ DomStructuralTraversal,
+ LiveTreeStructuralProjection,
+}
+
+pub(crate) struct TreeBuilderFinalAudit {
+ pub(crate) pending_table_text_empty: bool,
+ pub(crate) insertion_mode_valid: bool,
+ pub(crate) open_elements_consistent: bool,
+ pub(crate) active_formatting_consistent: bool,
+ pub(crate) template_modes_consistent: bool,
+ pub(crate) form_pointer_valid: bool,
+ pub(crate) parent_child_links_valid: bool,
+ pub(crate) namespaces_valid: bool,
+ pub(crate) template_associations_valid: bool,
+ pub(crate) live_structure: DomInvariantState,
+ pub(crate) active_text_mode: Option,
+ pub(crate) original_insertion_mode: Option,
+ pub(crate) pending_tokenizer_control: Option,
+ pub(crate) insertion_mode: InsertionMode,
+}
+
+impl Html5TreeBuilder {
+ pub(crate) fn final_audit_for_conformance(
+ &self,
+ atoms: &AtomTable,
+ reserve: &mut impl FnMut(ObservationReservationSite) -> Result<(), ()>,
+ ) -> Result {
+ let open_elements_consistent = self.audit_open_elements(atoms, reserve)?;
+ let active_formatting_consistent = self.audit_active_formatting(atoms, reserve)?;
+ let template_modes_consistent = self.audit_template_coordination(reserve)?;
+ let live = self
+ .live_tree
+ .try_final_audit(reserve)
+ .map_err(|_| TreeBuilderFinalAuditAllocation::DomStructuralTraversal)?;
+ let live_structure = self
+ .live_tree
+ .try_invariant_state_for_final_audit(reserve)
+ .map_err(|_| TreeBuilderFinalAuditAllocation::LiveTreeStructuralProjection)?;
+ let insertion_mode_valid = self.original_insertion_mode.is_none()
+ && self.active_text_mode.is_none()
+ && self.pending_tokenizer_control.is_none()
+ && !matches!(
+ self.insertion_mode,
+ InsertionMode::Text | InsertionMode::InTableText
+ );
+ let form_pointer_valid = self.form_element_pointer.is_none_or(|pointer| {
+ self.live_tree
+ .element_semantics_for_final_audit(pointer.key())
+ .is_some_and(|(name, _)| name.is(ElementNamespace::Html, "form"))
+ });
+ Ok(TreeBuilderFinalAudit {
+ pending_table_text_empty: self.pending_table_text.is_none(),
+ insertion_mode_valid,
+ open_elements_consistent,
+ active_formatting_consistent,
+ template_modes_consistent,
+ form_pointer_valid,
+ parent_child_links_valid: live.parent_child_links_valid,
+ namespaces_valid: live.namespaces_valid,
+ template_associations_valid: live.template_associations_valid,
+ live_structure,
+ active_text_mode: self.active_text_mode,
+ original_insertion_mode: self.original_insertion_mode,
+ pending_tokenizer_control: self.pending_tokenizer_control,
+ insertion_mode: self.insertion_mode,
+ })
+ }
+
+ fn audit_open_elements(
+ &self,
+ atoms: &AtomTable,
+ reserve: &mut impl FnMut(ObservationReservationSite) -> Result<(), ()>,
+ ) -> Result {
+ let mut keys = HashSet::new();
+ reserve(ObservationReservationSite::FinalAuditOpenElementsIndex)
+ .map_err(|_| TreeBuilderFinalAuditAllocation::OpenElementsIndex)?;
+ keys.try_reserve(self.open_elements.len())
+ .map_err(|_| TreeBuilderFinalAuditAllocation::OpenElementsIndex)?;
+ let mut counts = HashMap::::new();
+ reserve(ObservationReservationSite::FinalAuditOpenElementsIndex)
+ .map_err(|_| TreeBuilderFinalAuditAllocation::OpenElementsIndex)?;
+ counts
+ .try_reserve(self.open_elements.len())
+ .map_err(|_| TreeBuilderFinalAuditAllocation::OpenElementsIndex)?;
+ let mut valid = true;
+ for entry in self.open_elements.iter_entries() {
+ if entry.key() == PatchKey::INVALID || !keys.insert(entry.key()) {
+ valid = false;
+ }
+ let Some(atom_name) = atoms.resolve(entry.name()) else {
+ valid = false;
+ continue;
+ };
+ if u64::from(entry.name().interner_id()) != self.atom_table_id {
+ valid = false;
+ }
+ if !self
+ .live_tree
+ .element_semantics_for_final_audit(entry.key())
+ .is_some_and(|(name, _)| {
+ name.namespace() == entry.namespace() && name.local_name().as_str() == atom_name
+ })
+ {
+ valid = false;
+ }
+ let count = counts.entry(entry.expanded_name_key()).or_insert(0);
+ let Some(next) = count.checked_add(1) else {
+ valid = false;
+ continue;
+ };
+ *count = next;
+ }
+ if counts.len() != self.open_elements.cached_name_counts().len() {
+ valid = false;
+ }
+ for (name, expected) in self.open_elements.cached_name_counts() {
+ if counts.get(name) != Some(expected) {
+ valid = false;
+ }
+ }
+ Ok(valid)
+ }
+
+ fn audit_active_formatting(
+ &self,
+ atoms: &AtomTable,
+ reserve: &mut impl FnMut(ObservationReservationSite) -> Result<(), ()>,
+ ) -> Result {
+ let mut keys = HashSet::new();
+ reserve(ObservationReservationSite::FinalAuditActiveFormattingIndex)
+ .map_err(|_| TreeBuilderFinalAuditAllocation::ActiveFormattingIndex)?;
+ keys.try_reserve(self.active_formatting.len())
+ .map_err(|_| TreeBuilderFinalAuditAllocation::ActiveFormattingIndex)?;
+ let mut valid = true;
+ for entry in self.active_formatting.entries() {
+ match entry {
+ AfeEntry::Element(element) => {
+ if element.key == PatchKey::INVALID || !keys.insert(element.key) {
+ valid = false;
+ }
+ let Some(atom_name) = atoms.resolve(element.name) else {
+ valid = false;
+ continue;
+ };
+ if !self
+ .live_tree
+ .element_semantics_for_final_audit(element.key)
+ .is_some_and(|(name, attrs)| {
+ name.namespace() == ElementNamespace::Html
+ && name.local_name().as_str() == atom_name
+ && parser_created_attribute_lists_equal_ordered(
+ &element.attrs,
+ attrs,
+ )
+ })
+ {
+ valid = false;
+ }
+ }
+ AfeEntry::Marker(marker) => {
+ let owner = marker.owner;
+ let Some(owner) = owner else {
+ valid = false;
+ continue;
+ };
+ let Some((name, _)) = self.live_tree.element_semantics_for_final_audit(owner)
+ else {
+ valid = false;
+ continue;
+ };
+ let owner_valid = match marker.kind {
+ AfeMarkerKind::FormattingBoundary => {
+ name.namespace() == ElementNamespace::Html
+ && matches!(
+ name.local_name().as_str(),
+ "applet" | "marquee" | "object"
+ )
+ }
+ AfeMarkerKind::Caption => name.is(ElementNamespace::Html, "caption"),
+ AfeMarkerKind::TableCell => {
+ name.is(ElementNamespace::Html, "td")
+ || name.is(ElementNamespace::Html, "th")
+ }
+ AfeMarkerKind::Template => {
+ name.is(ElementNamespace::Html, "template")
+ && self
+ .live_tree
+ .template_state_for_final_audit(owner)
+ .is_some_and(|(is_template, contents, child_count)| {
+ is_template && contents.is_some() && child_count == 0
+ })
+ }
+ };
+ if !owner_valid {
+ valid = false;
+ }
+ }
+ }
+ }
+ Ok(valid)
+ }
+
+ fn audit_template_coordination(
+ &self,
+ reserve: &mut impl FnMut(ObservationReservationSite) -> Result<(), ()>,
+ ) -> Result {
+ let mut open_templates = HashMap::::new();
+ reserve(ObservationReservationSite::FinalAuditTemplateCoordinationIndex)
+ .map_err(|_| TreeBuilderFinalAuditAllocation::TemplateCoordinationIndex)?;
+ open_templates
+ .try_reserve(self.open_elements.len())
+ .map_err(|_| TreeBuilderFinalAuditAllocation::TemplateCoordinationIndex)?;
+ let mut open_template_order = Vec::new();
+ reserve(ObservationReservationSite::FinalAuditTemplateCoordinationIndex)
+ .map_err(|_| TreeBuilderFinalAuditAllocation::TemplateCoordinationIndex)?;
+ open_template_order
+ .try_reserve(self.open_elements.len())
+ .map_err(|_| TreeBuilderFinalAuditAllocation::TemplateCoordinationIndex)?;
+ let mut depth = 0usize;
+ let mut valid = true;
+ for entry in self.open_elements.iter_entries() {
+ if entry.namespace() == ElementNamespace::Html
+ && entry.name() == self.known_tags.template
+ {
+ if open_templates.insert(entry.key(), depth).is_some() {
+ valid = false;
+ }
+ open_template_order.push(entry.key());
+ depth = match depth.checked_add(1) {
+ Some(next) => next,
+ None => {
+ valid = false;
+ depth
+ }
+ };
+ }
+ }
+ if depth != self.template_modes.len() {
+ valid = false;
+ }
+ for (index, mode) in self.template_modes.entries().iter().enumerate() {
+ let template_state = self.live_tree.template_state_for_final_audit(mode.owner());
+ if open_templates.get(&mode.owner()) != Some(&index)
+ || !template_state.is_some_and(|(is_template, contents, child_count)| {
+ is_template && contents.is_some() && child_count == 0
+ })
+ {
+ valid = false;
+ }
+ }
+ let mut marker_owners = HashSet::new();
+ reserve(ObservationReservationSite::FinalAuditTemplateCoordinationIndex)
+ .map_err(|_| TreeBuilderFinalAuditAllocation::TemplateCoordinationIndex)?;
+ marker_owners
+ .try_reserve(self.active_formatting.len())
+ .map_err(|_| TreeBuilderFinalAuditAllocation::TemplateCoordinationIndex)?;
+ let mut open_marker_order = Vec::new();
+ reserve(ObservationReservationSite::FinalAuditTemplateCoordinationIndex)
+ .map_err(|_| TreeBuilderFinalAuditAllocation::TemplateCoordinationIndex)?;
+ open_marker_order
+ .try_reserve(self.active_formatting.len())
+ .map_err(|_| TreeBuilderFinalAuditAllocation::TemplateCoordinationIndex)?;
+ for entry in self.active_formatting.entries() {
+ if let AfeEntry::Marker(marker) = entry
+ && marker.kind == AfeMarkerKind::Template
+ {
+ let Some(owner) = marker.owner else {
+ valid = false;
+ continue;
+ };
+ if !marker_owners.insert(owner) {
+ valid = false;
+ }
+ if open_templates.contains_key(&owner) {
+ open_marker_order.push(owner);
+ } else if !self
+ .live_tree
+ .template_state_for_final_audit(owner)
+ .is_some_and(|(is_template, contents, _)| is_template && contents.is_some())
+ {
+ // EOF recovery deliberately permits diagnostic markers for
+ // already-closed template hosts. Their typed owner must
+ // still name a live template association.
+ valid = false;
+ }
+ }
+ }
+ if open_marker_order.as_slice() != open_template_order.as_slice() {
+ valid = false;
+ }
+ Ok(valid)
+ }
+}
diff --git a/crates/html/src/html5/tree_builder/formatting.rs b/crates/html/src/html5/tree_builder/formatting.rs
index 4ac64ae6..1378fee6 100644
--- a/crates/html/src/html5/tree_builder/formatting.rs
+++ b/crates/html/src/html5/tree_builder/formatting.rs
@@ -158,6 +158,99 @@ impl ActiveFormattingList {
marker.owner = owner;
}
+ #[cfg(test)]
+ pub(in crate::html5::tree_builder) fn corrupt_marker_owner_for_test(
+ &mut self,
+ kind: AfeMarkerKind,
+ owner: Option,
+ ) {
+ let marker = self
+ .items
+ .iter_mut()
+ .rev()
+ .find_map(|entry| match entry {
+ AfeEntry::Marker(marker) if marker.kind == kind => Some(marker),
+ _ => None,
+ })
+ .expect("requested production marker");
+ marker.owner = owner;
+ }
+
+ #[cfg(test)]
+ pub(in crate::html5::tree_builder) fn corrupt_element_for_test(
+ &mut self,
+ mutate: impl FnOnce(&mut AfeElementEntry),
+ ) {
+ let element = self
+ .items
+ .iter_mut()
+ .rev()
+ .find_map(|entry| match entry {
+ AfeEntry::Element(element) => Some(element),
+ AfeEntry::Marker(_) => None,
+ })
+ .expect("requested production AFE element");
+ mutate(element);
+ }
+
+ #[cfg(test)]
+ pub(in crate::html5::tree_builder) fn remove_template_marker_at_for_test(
+ &mut self,
+ index: usize,
+ ) {
+ let marker_index = self
+ .items
+ .iter()
+ .enumerate()
+ .filter_map(|(index, entry)| match entry {
+ AfeEntry::Marker(marker) if marker.kind == AfeMarkerKind::Template => Some(index),
+ _ => None,
+ })
+ .nth(index)
+ .expect("template marker index");
+ self.items.remove(marker_index);
+ }
+
+ #[cfg(test)]
+ pub(in crate::html5::tree_builder) fn insert_template_marker_at_for_test(
+ &mut self,
+ index: usize,
+ owner: PatchKey,
+ ) {
+ let marker_index = self
+ .items
+ .iter()
+ .enumerate()
+ .filter_map(|(index, entry)| match entry {
+ AfeEntry::Marker(marker) if marker.kind == AfeMarkerKind::Template => Some(index),
+ _ => None,
+ })
+ .nth(index)
+ .unwrap_or(self.items.len());
+ self.items.insert(
+ marker_index,
+ AfeEntry::Marker(AfeMarker::new(AfeMarkerKind::Template, Some(owner))),
+ );
+ }
+
+ #[cfg(test)]
+ pub(in crate::html5::tree_builder) fn swap_template_markers_for_test(
+ &mut self,
+ left: usize,
+ right: usize,
+ ) {
+ let indices = self
+ .items
+ .iter()
+ .enumerate()
+ .filter_map(|(index, entry)| match entry {
+ AfeEntry::Marker(marker) if marker.kind == AfeMarkerKind::Template => Some(index),
+ _ => None,
+ })
+ .collect::>();
+ self.items.swap(indices[left], indices[right]);
+ }
+
/// Pushes a formatting element entry while enforcing the HTML5 Noah's Ark
/// duplicate bound within the suffix after the last marker.
///
diff --git a/crates/html/src/html5/tree_builder/invariants/model.rs b/crates/html/src/html5/tree_builder/invariants/model.rs
index c7302a96..8fb7419f 100644
--- a/crates/html/src/html5/tree_builder/invariants/model.rs
+++ b/crates/html/src/html5/tree_builder/invariants/model.rs
@@ -45,12 +45,12 @@ impl DomInvariantNodeKind {
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DomInvariantNode {
- pub(in crate::html5::tree_builder) kind: DomInvariantNodeKind,
- pub(in crate::html5::tree_builder) parent: Option,
- pub(in crate::html5::tree_builder) children: Vec,
- pub(in crate::html5::tree_builder) template_contents: Option,
- pub(in crate::html5::tree_builder) fragment_host: Option,
- pub(in crate::html5::tree_builder) is_template_element: bool,
+ pub(crate) kind: DomInvariantNodeKind,
+ pub(crate) parent: Option,
+ pub(crate) children: Vec,
+ pub(crate) template_contents: Option,
+ pub(crate) fragment_host: Option,
+ pub(crate) is_template_element: bool,
}
impl DomInvariantNode {
@@ -82,8 +82,8 @@ impl DomInvariantNode {
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct DomInvariantState {
- pub(in crate::html5::tree_builder) nodes: Vec