diff --git a/Cargo.toml b/Cargo.toml index ca9ca482..deb26f49 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,8 +16,11 @@ thiserror = { version = "1.0", default-features = false } derive_more = { version = "2.1", default-features = false, features = ["from", "as_ref"] } rayon = "1.10" smallvec = "1.15.1" +metrics = { version = "0.24.6", default-features = false, optional = true } +tracing = { version = "0.1.44", default-features = false, optional = true } [features] +observability = ["dep:metrics", "dep:tracing"] test-support = [] [dev-dependencies] diff --git a/docs/contents.md b/docs/contents.md index a664bb40..6f37bfb8 100644 --- a/docs/contents.md +++ b/docs/contents.md @@ -30,6 +30,8 @@ - [Parser conformance register](./parser-conformance-register.md): Tracker for specification and implementation deltas, resolved contradictions, and open parser design decisions. +- [Parser observability](./parser-observability.md): How to export parser + attempt and diagnostic events to downstream logging and metrics backends. - [Haskell parser analysis](./haskell-parser-analysis.md): Reference analysis of the upstream DDlog parser behaviour used when validating parser compatibility. diff --git a/docs/parser-conformance-register.md b/docs/parser-conformance-register.md index f7a8624b..98be13eb 100644 --- a/docs/parser-conformance-register.md +++ b/docs/parser-conformance-register.md @@ -193,6 +193,21 @@ This register tracks parser behaviour against the syntax specification. - Decision status: `scheduled`. - Roadmap item: `docs/roadmap.md` item `2.6.8`. +## Stable diagnostic compatibility surface + +Issue `#304` establishes a backend-neutral observability precursor to ADR-001 +Phase 2. `DiagnosticCode` freezes `D-REL-001` through `D-REL-008` as stable +identifiers, while `DiagnosticCategory` classifies every current scanner and +parser orchestration merge point. Existing code meanings do not change; adding +a code is additive. Consumers must not treat human-facing message text as an +identifier. + +`parse_with_observer()` reports attempt and diagnostic records without changing +the `Parsed` result. `parse()` uses the no-op observer and remains independent +of any logging or metrics runtime. This does not complete roadmap item `2.8.1`, +which still requires the post-split public diagnostic contract and stage +modelling. + ## Maintenance rules When parser behaviour changes, update this register in the same change: diff --git a/docs/parser-implementation-notes.md b/docs/parser-implementation-notes.md index 569ea6db..3af2ef48 100644 --- a/docs/parser-implementation-notes.md +++ b/docs/parser-implementation-notes.md @@ -50,6 +50,9 @@ Current pipeline guarantees are intentionally narrow: - `parse()` builds the CST-backed `Parsed` result, collects top-level `for` semantic rules, and runs top-level name-uniqueness validation. +- `parse_with_observer()` reports the same pipeline through a backend-neutral + observer. Observer callbacks do not return parser-control decisions, and + `parse()` supplies the no-op observer. - `parse()` does **not** classify rule-body aggregations or report duplicate or wrong-arity aggregation diagnostics in `Parsed::errors()`. - Aggregation classification and validation happen when callers request @@ -379,6 +382,9 @@ token names or their human-readable equivalents. - Tokenization and keyword policy: `src/tokenizer.rs` - Entry parse orchestration: `src/parser/mod.rs` +- Stable diagnostic taxonomy: `src/parser/diagnostics/*` +- Backend-neutral observer and optional adapter: + `src/parser/observability/*` - Pratt parser: `src/parser/expression/pratt.rs` - Pratt postfix helpers: `src/parser/expression/pratt/{postfix,diff,delay}.rs` diff --git a/docs/parser-observability.md b/docs/parser-observability.md new file mode 100644 index 00000000..c6bc58ba --- /dev/null +++ b/docs/parser-observability.md @@ -0,0 +1,144 @@ +# How to export parser observability + +This guide shows host-application maintainers how to collect parser attempts +and diagnostics without coupling parser behaviour to a telemetry backend. Use +it when a service, command-line application, or batch worker needs parser logs, +metrics, or alerts. + +## Prerequisites + +- A host application that calls `ddlint::parse`. +- An existing logging or metrics backend if events must leave the process. +- The `observability` feature only when using the supplied `tracing` and + `metrics` adapter. Custom observers do not need this feature. + +The library never installs a tracing subscriber, metrics recorder, or exporter. +Only the host application initializes global telemetry. + +## Choose an observer + +For a custom backend, implement `ParseObserver` and copy any borrowed +diagnostic data needed after the callback: + +```rust +use ddlint::{ + DiagnosticContext, ParseAttemptContext, ParseObserver, parse_with_observer, +}; + +struct HostObserver; + +impl ParseObserver for HostObserver { + fn parse_attempt_completed(&self, context: ParseAttemptContext) { + host_metrics::record_attempt( + context.category().as_str(), + context.diagnostic_count(), + ); + } + + fn diagnostic_emitted(&self, context: &DiagnosticContext<'_>) { + host_log::record_parser_failure( + context.code().map(|code| code.as_str()), + context.category().as_str(), + context.span(), + context.severity().as_str(), + context.message(), + ); + } +} + +let parsed = parse_with_observer(source, &HostObserver); +``` + +Do not use message text, source spans, source text, paths, or request +identifiers as metrics labels. They are unbounded and belong in structured logs +or traces. + +For the supplied facade adapter, enable the feature: + +```toml +[dependencies] +ddlint = { version = "0.1", features = ["observability"] } +``` + +Initialize the application's existing tracing subscriber and metrics recorder, +then pass `TelemetryObserver`: + +```rust +use ddlint::{TelemetryObserver, parse_with_observer}; + +// The application initializes its subscriber, recorder, and exporters first. +let observer = TelemetryObserver::new(); +let parsed = parse_with_observer(source, &observer); +``` + +Without a configured subscriber or recorder, the facades discard events and +parsing remains deterministic. Calling `parse(source)` uses `NoopParseObserver` +and does not require a telemetry runtime. + +## Export logs and metrics + +`TelemetryObserver` emits the following counters: + +| Metric | Labels | Meaning | +| --------------------------------- | ------------------------------ | ---------------------------------- | +| `ddlint_parser_attempts_total` | `category` | Parser or scanner attempts started | +| `ddlint_parser_diagnostics_total` | `code`, `category`, `severity` | Diagnostics emitted | + +The `code` label is the stable diagnostic code or `uncoded`. Categories and +severity are bounded enums. The adapter emits attempt start/completion events at +`DEBUG` and diagnostics at `ERROR` under the `ddlint::parser` tracing target. +Diagnostic events include `code`, `category`, `severity`, `span_start`, +`span_end`, and `message` fields. + +Configure the host's normal OpenTelemetry, Prometheus, or vendor exporter to +collect these facade events. Keep exporter lifecycle, batching, retries, and +shutdown in the application boundary. + +## Configure alerts + +Alert on rates and ratios over a sustained window rather than raw counter +values: + +1. Use `ddlint_parser_attempts_total{category="parser"}` as the parse-volume + denominator. +2. Alert when the diagnostic-to-parser-attempt ratio exceeds the + application's error budget after a minimum traffic threshold. +3. Group failures by `category` to distinguish scanner regressions from lexer, + span-builder, top-level-`for`, or name-validation failures. +4. Add focused alerts for unexpected increases in `D-REL-001` through + `D-REL-008` when relation input quality is operationally significant. +5. Track `code="uncoded"` separately. A sustained increase can identify a + scanner family that needs its own stable code. + +Do not page solely because a diagnostic exists: invalid user input can be an +expected outcome. Choose thresholds from the host application's baseline and +route alerts to the team that owns its input pipeline. + +## Stable diagnostic contract + +The diagnostic-code list is a compatibility surface under ADR-001 Phase 2. +Existing code meanings do not change; new codes may be added. Human-facing +message wording may become clearer, so automation must match `DiagnosticCode` or +`DiagnosticCategory`, not message text. + +| Code | Relation failure | +| ----------- | ---------------------------------------------- | +| `D-REL-001` | Kind keyword appears before the role keyword | +| `D-REL-002` | More than one role keyword | +| `D-REL-003` | More than one kind keyword | +| `D-REL-004` | Bracket-form relation declares a primary key | +| `D-REL-005` | Bracket form lacks one element type | +| `D-REL-006` | Non-input relation declares a primary key | +| `D-REL-007` | Malformed primary-key clause | +| `D-REL-008` | Unsupported bracket-wrapped primary-key clause | + +Uncoded diagnostics retain a stable category. Current categories are `parser`, +`attribute`, `import`, `typedef`, `relation`, `index`, `function`, +`transformer`, `apply`, `rule`, `lexer`, `span_builder`, `top_level_for`, and +`name_uniqueness`. + +## See also + +- [Parser implementation notes](./parser-implementation-notes.md) +- [Parser conformance register](./parser-conformance-register.md) +- [ADR-001: parser crate split](./adr-001-parser-crate-split.md) diff --git a/src/lib.rs b/src/lib.rs index 2d032d50..168a12a9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,7 +17,17 @@ pub mod tokenizer; pub mod test_util; pub use language::{DdlogLanguage, SyntaxKind}; -pub use parser::{Parsed, ast, parse}; +#[cfg(feature = "observability")] +pub use parser::observability::TelemetryObserver; +pub use parser::{ + Parsed, ast, + diagnostics::{DiagnosticCategory, DiagnosticCode}, + observability::{ + DiagnosticContext, DiagnosticSeverity, NoopParseObserver, ParseAttemptContext, + ParseObserver, + }, + parse, parse_with_observer, +}; /// Re-exported for macro-generated rule handlers and downstream CST consumers /// so callers can use `ddlint`'s public syntax types without a direct `rowan` /// dependency. diff --git a/src/parser/cst_builder/tree.rs b/src/parser/cst_builder/tree.rs index 01ebda44..21e0b1ab 100644 --- a/src/parser/cst_builder/tree.rs +++ b/src/parser/cst_builder/tree.rs @@ -111,7 +111,7 @@ fn validate_token_span(span: &Span, src_len: usize) -> bool { /// /// let src = "import foo::bar;"; /// let tokens = tokenize(src); -/// let (spans, errors) = parse_tokens(&tokens, src); +/// let (spans, errors) = parse_tokens(&tokens, src, &NoopParseObserver); /// assert!(errors.is_empty()); /// let green = build_green_tree(&tokens, src, &spans); /// let root = Root::from_green(green); @@ -183,7 +183,11 @@ mod tests { fn build_green_tree_round_trip() { let src = "import foo::bar;"; let tokens = tokenize(src); - let (spans, errors) = parse_tokens(&tokens, src); + let (spans, errors) = parse_tokens( + &tokens, + src, + &crate::parser::observability::NoopParseObserver, + ); assert!(errors.is_empty()); let green = build_green_tree(&tokens, src, &spans); let root = crate::parser::ast::Root::from_green(green); @@ -209,7 +213,11 @@ mod tests { fn build_green_tree_skips_oob_token_span_in_release() { let src = "import foo::bar;"; let mut tokens = tokenize(src); - let (spans, errors) = parse_tokens(&tokens, src); + let (spans, errors) = parse_tokens( + &tokens, + src, + &crate::parser::observability::NoopParseObserver, + ); assert!(errors.is_empty()); tokens.push((SyntaxKind::K_IMPORT, src.len() + 1..src.len() + 2)); @@ -244,7 +252,11 @@ mod tests { "User(id, name) :- name == \"a\", id > 0.\n" ); let tokens = tokenize(src); - let (spans, errors) = parse_tokens(&tokens, src); + let (spans, errors) = parse_tokens( + &tokens, + src, + &crate::parser::observability::NoopParseObserver, + ); assert!(errors.is_empty()); let green = build_green_tree(&tokens, src, &spans); let root = Root::from_green(green); diff --git a/src/parser/diagnostics/code.rs b/src/parser/diagnostics/code.rs new file mode 100644 index 00000000..41f802da --- /dev/null +++ b/src/parser/diagnostics/code.rs @@ -0,0 +1,195 @@ +//! Diagnostic codes and their owning parser categories. + +use std::fmt; + +/// Stable parser diagnostic families. +/// +/// Categories identify the parser phase that produced a diagnostic. They are +/// suitable for bounded metrics labels and remain available while individual +/// scanner families acquire more specific diagnostic codes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum DiagnosticCategory { + /// The complete parser entry point. + Parser, + /// Attribute span scanning. + Attribute, + /// Import span scanning. + Import, + /// Type definition span scanning. + Typedef, + /// Relation span scanning. + Relation, + /// Index span scanning. + Index, + /// Function span scanning. + Function, + /// Transformer span scanning. + Transformer, + /// Apply-item span scanning. + Apply, + /// Rule and expression span scanning. + Rule, + /// Lexical error collection. + Lexer, + /// Parsed-span validation and construction. + SpanBuilder, + /// Top-level `for` desugaring. + TopLevelFor, + /// Parser-level name-uniqueness validation. + NameUniqueness, +} + +impl DiagnosticCategory { + /// Return the stable, low-cardinality category label. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Parser => "parser", + Self::Attribute => "attribute", + Self::Import => "import", + Self::Typedef => "typedef", + Self::Relation => "relation", + Self::Index => "index", + Self::Function => "function", + Self::Transformer => "transformer", + Self::Apply => "apply", + Self::Rule => "rule", + Self::Lexer => "lexer", + Self::SpanBuilder => "span_builder", + Self::TopLevelFor => "top_level_for", + Self::NameUniqueness => "name_uniqueness", + } + } +} + +impl fmt::Display for DiagnosticCategory { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +/// Stable parser diagnostic codes. +/// +/// Code strings are compatibility identifiers. Consumers should use +/// [`DiagnosticCode::as_str`] rather than deriving labels from variant names or +/// diagnostic message text. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum DiagnosticCode { + /// A relation kind appears before its role keyword. + RelationKindBeforeRole, + /// A relation declaration contains more than one role keyword. + RelationDuplicateRole, + /// A relation declaration contains more than one kind keyword. + RelationDuplicateKind, + /// A bracket-form relation declares a primary-key clause. + RelationBracketPrimaryKey, + /// A bracket-form relation does not contain exactly one element type. + RelationInvalidBracketElementType, + /// A non-input relation declares a primary-key clause. + RelationPrimaryKeyOnNonInput, + /// A relation contains an unexpected or malformed primary-key clause. + RelationMalformedPrimaryKey, + /// A relation uses an unsupported bracket-wrapped primary-key clause. + RelationBracketWrappedPrimaryKey, +} + +impl DiagnosticCode { + /// Return the stable external diagnostic identifier. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::RelationKindBeforeRole => "D-REL-001", + Self::RelationDuplicateRole => "D-REL-002", + Self::RelationDuplicateKind => "D-REL-003", + Self::RelationBracketPrimaryKey => "D-REL-004", + Self::RelationInvalidBracketElementType => "D-REL-005", + Self::RelationPrimaryKeyOnNonInput => "D-REL-006", + Self::RelationMalformedPrimaryKey => "D-REL-007", + Self::RelationBracketWrappedPrimaryKey => "D-REL-008", + } + } + + /// Return the parser category that owns this diagnostic. + #[must_use] + pub const fn category(self) -> DiagnosticCategory { + match self { + Self::RelationKindBeforeRole + | Self::RelationDuplicateRole + | Self::RelationDuplicateKind + | Self::RelationBracketPrimaryKey + | Self::RelationInvalidBracketElementType + | Self::RelationPrimaryKeyOnNonInput + | Self::RelationMalformedPrimaryKey + | Self::RelationBracketWrappedPrimaryKey => DiagnosticCategory::Relation, + } + } + + pub(crate) fn from_message(message: &str) -> Option { + [ + Self::RelationKindBeforeRole, + Self::RelationDuplicateRole, + Self::RelationDuplicateKind, + Self::RelationBracketPrimaryKey, + Self::RelationInvalidBracketElementType, + Self::RelationPrimaryKeyOnNonInput, + Self::RelationMalformedPrimaryKey, + Self::RelationBracketWrappedPrimaryKey, + ] + .into_iter() + .find(|code| message.starts_with(code.as_str())) + } +} + +impl fmt::Display for DiagnosticCode { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +#[cfg(test)] +mod tests { + //! Contract tests for stable diagnostic identifiers. + + use rstest::rstest; + + use super::{DiagnosticCategory, DiagnosticCode}; + + #[rstest] + #[case(DiagnosticCode::RelationKindBeforeRole, "D-REL-001")] + #[case(DiagnosticCode::RelationDuplicateRole, "D-REL-002")] + #[case(DiagnosticCode::RelationDuplicateKind, "D-REL-003")] + #[case(DiagnosticCode::RelationBracketPrimaryKey, "D-REL-004")] + #[case(DiagnosticCode::RelationInvalidBracketElementType, "D-REL-005")] + #[case(DiagnosticCode::RelationPrimaryKeyOnNonInput, "D-REL-006")] + #[case(DiagnosticCode::RelationMalformedPrimaryKey, "D-REL-007")] + #[case(DiagnosticCode::RelationBracketWrappedPrimaryKey, "D-REL-008")] + fn relation_codes_have_stable_labels(#[case] code: DiagnosticCode, #[case] expected: &str) { + assert_eq!(code.as_str(), expected); + assert_eq!(code.to_string(), expected); + assert_eq!(code.category(), DiagnosticCategory::Relation); + assert_eq!( + DiagnosticCode::from_message(&format!("{expected}: message")), + Some(code) + ); + } + + #[rstest] + #[case(DiagnosticCategory::Parser, "parser")] + #[case(DiagnosticCategory::Attribute, "attribute")] + #[case(DiagnosticCategory::Import, "import")] + #[case(DiagnosticCategory::Typedef, "typedef")] + #[case(DiagnosticCategory::Relation, "relation")] + #[case(DiagnosticCategory::Index, "index")] + #[case(DiagnosticCategory::Function, "function")] + #[case(DiagnosticCategory::Transformer, "transformer")] + #[case(DiagnosticCategory::Apply, "apply")] + #[case(DiagnosticCategory::Rule, "rule")] + #[case(DiagnosticCategory::Lexer, "lexer")] + #[case(DiagnosticCategory::SpanBuilder, "span_builder")] + #[case(DiagnosticCategory::TopLevelFor, "top_level_for")] + #[case(DiagnosticCategory::NameUniqueness, "name_uniqueness")] + fn categories_have_stable_labels(#[case] category: DiagnosticCategory, #[case] expected: &str) { + assert_eq!(category.as_str(), expected); + assert_eq!(category.to_string(), expected); + } +} diff --git a/src/parser/diagnostics/mod.rs b/src/parser/diagnostics/mod.rs new file mode 100644 index 00000000..fe2f6893 --- /dev/null +++ b/src/parser/diagnostics/mod.rs @@ -0,0 +1,10 @@ +//! Stable parser diagnostic identifiers. +//! +//! Diagnostic codes are a compatibility surface under ADR-001 Phase 2. Once +//! published, a code keeps its meaning even when its human-facing message is +//! clarified. Categories provide a stable fallback for parser failures that do +//! not yet have individual codes. + +mod code; + +pub use code::{DiagnosticCategory, DiagnosticCode}; diff --git a/src/parser/error_messages.rs b/src/parser/error_messages.rs index 53bbffd3..962d0b37 100644 --- a/src/parser/error_messages.rs +++ b/src/parser/error_messages.rs @@ -12,3 +12,53 @@ pub const MISSING_OUTPUT_SIGNATURE_ERROR: &str = /// instead of a lowercase letter or underscore. pub const CAPITALIZED_TRANSFORMER_NAME_ERROR: &str = "transformer names must start with a lowercase letter or underscore"; + +/// Message for [`DiagnosticCode::RelationKindBeforeRole`]. +/// +/// [`DiagnosticCode::RelationKindBeforeRole`]: super::diagnostics::DiagnosticCode::RelationKindBeforeRole +pub const RELATION_KIND_BEFORE_ROLE_ERROR: &str = + "D-REL-001: relation role keyword (input/output) must precede the kind keyword"; + +/// Message for [`DiagnosticCode::RelationDuplicateRole`]. +/// +/// [`DiagnosticCode::RelationDuplicateRole`]: super::diagnostics::DiagnosticCode::RelationDuplicateRole +pub const RELATION_DUPLICATE_ROLE_ERROR: &str = + "D-REL-002: at most one role keyword (input, output) is permitted"; + +/// Message for [`DiagnosticCode::RelationDuplicateKind`]. +/// +/// [`DiagnosticCode::RelationDuplicateKind`]: super::diagnostics::DiagnosticCode::RelationDuplicateKind +pub const RELATION_DUPLICATE_KIND_ERROR: &str = + "D-REL-003: at most one kind keyword (relation, stream, multiset) is permitted"; + +/// Message for [`DiagnosticCode::RelationBracketPrimaryKey`]. +/// +/// [`DiagnosticCode::RelationBracketPrimaryKey`]: super::diagnostics::DiagnosticCode::RelationBracketPrimaryKey +pub const RELATION_BRACKET_PRIMARY_KEY_ERROR: &str = + "D-REL-004: bracket-form relations cannot declare a primary key clause"; + +/// Message for [`DiagnosticCode::RelationInvalidBracketElementType`]. +/// +/// [`DiagnosticCode::RelationInvalidBracketElementType`]: super::diagnostics::DiagnosticCode::RelationInvalidBracketElementType +pub const RELATION_INVALID_BRACKET_ELEMENT_TYPE_ERROR: &str = + "D-REL-005: bracket-form relations require a single element type between '[' and ']'"; + +/// Message for [`DiagnosticCode::RelationPrimaryKeyOnNonInput`]. +/// +/// [`DiagnosticCode::RelationPrimaryKeyOnNonInput`]: super::diagnostics::DiagnosticCode::RelationPrimaryKeyOnNonInput +pub const RELATION_PRIMARY_KEY_ON_NON_INPUT_ERROR: &str = + "D-REL-006: primary key clauses are only valid on input relations"; + +/// Message for [`DiagnosticCode::RelationMalformedPrimaryKey`]. +/// +/// [`DiagnosticCode::RelationMalformedPrimaryKey`]: super::diagnostics::DiagnosticCode::RelationMalformedPrimaryKey +pub const RELATION_MALFORMED_PRIMARY_KEY_ERROR: &str = + "D-REL-007: unexpected or malformed primary key clause"; + +/// Message for [`DiagnosticCode::RelationBracketWrappedPrimaryKey`]. +/// +/// [`DiagnosticCode::RelationBracketWrappedPrimaryKey`]: super::diagnostics::DiagnosticCode::RelationBracketWrappedPrimaryKey +pub const RELATION_BRACKET_WRAPPED_PRIMARY_KEY_ERROR: &str = concat!( + "D-REL-008: bracket-wrapped primary key clauses are not supported; ", + "remove the surrounding '['/']'" +); diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 2d3df3e3..895f378a 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -26,14 +26,18 @@ use span_scanner::parse_tokens; mod cst_builder; use cst_builder::build_green_tree; mod delimiter; +pub mod diagnostics; pub mod expression; mod expression_span; +pub mod observability; pub mod pattern; mod top_level_for; pub use cst_builder::{Parsed, ParsedSpans}; use top_level_for::collect_desugared_top_level_for_rules; use crate::Span; +use diagnostics::DiagnosticCategory; +use observability::{NoopParseObserver, ParseAttemptContext, ParseObserver, complete_attempt}; /// Parse the provided source string. /// @@ -64,18 +68,53 @@ use crate::Span; /// ``` #[must_use] pub fn parse(src: &str) -> Parsed { + parse_with_observer(src, &NoopParseObserver) +} + +/// Parse source while reporting attempts and diagnostics to `observer`. +/// +/// The observer receives a deterministic event sequence and cannot affect +/// parser recovery or the returned [`Parsed`] value. +/// +/// # Examples +/// +/// ```rust,no_run +/// # use ddlint::{NoopParseObserver, parse_with_observer}; +/// let parsed = parse_with_observer( +/// "input relation R(x: u32);", +/// &NoopParseObserver, +/// ); +/// assert!(parsed.errors().is_empty()); +/// ``` +#[must_use] +pub fn parse_with_observer(src: &str, observer: &dyn ParseObserver) -> Parsed { + observer.parse_attempt_started(DiagnosticCategory::Parser); let tokens = tokenize_with_trivia(src); - let (spans, mut errors) = parse_tokens(&tokens, src); + let (spans, mut errors) = parse_tokens(&tokens, src, observer); let exclusions = top_level_for_exclusions(&spans); + + observer.parse_attempt_started(DiagnosticCategory::TopLevelFor); let (semantic_rules, top_level_for_errors) = collect_desugared_top_level_for_rules(&tokens, src, &exclusions); + complete_attempt( + observer, + DiagnosticCategory::TopLevelFor, + &top_level_for_errors, + ); errors.extend(top_level_for_errors); let green = build_green_tree(&tokens, src, &spans); let root = ast::Root::from_green(green.clone()); - errors.extend(validators::validate_name_uniqueness(&root)); + observer.parse_attempt_started(DiagnosticCategory::NameUniqueness); + let name_errors = validators::validate_name_uniqueness(&root); + complete_attempt(observer, DiagnosticCategory::NameUniqueness, &name_errors); + errors.extend(name_errors); + observer.parse_attempt_completed(ParseAttemptContext::new( + DiagnosticCategory::Parser, + errors.len(), + )); Parsed::new(green, root, semantic_rules, errors) } diff --git a/src/parser/observability/mod.rs b/src/parser/observability/mod.rs new file mode 100644 index 00000000..50066d7e --- /dev/null +++ b/src/parser/observability/mod.rs @@ -0,0 +1,173 @@ +//! Backend-neutral parser observability. +//! +//! The observer contract contains no logging or metrics runtime types. +//! Applications opt in by passing an observer to +//! [`parse_with_observer`](crate::parser::parse_with_observer). Observer return +//! values cannot alter parser control flow. + +use std::fmt; + +use chumsky::error::{Simple, SimpleReason}; + +use crate::{Span, SyntaxKind}; + +use super::diagnostics::{DiagnosticCategory, DiagnosticCode}; + +#[cfg(feature = "observability")] +mod telemetry; +#[cfg(feature = "observability")] +pub use telemetry::TelemetryObserver; + +/// Severity of a parser diagnostic. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum DiagnosticSeverity { + /// The parser could not accept or validate part of the source. + Error, +} + +impl DiagnosticSeverity { + /// Return the stable, low-cardinality severity label. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Error => "error", + } + } +} + +impl fmt::Display for DiagnosticSeverity { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +/// Structured context for one parser diagnostic. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DiagnosticContext<'a> { + code: Option, + category: DiagnosticCategory, + span: Span, + severity: DiagnosticSeverity, + message: &'a str, +} + +impl<'a> DiagnosticContext<'a> { + /// Return the stable diagnostic code, when one is assigned. + #[must_use] + pub const fn code(&self) -> Option { + self.code + } + + /// Return the diagnostic's parser category. + #[must_use] + pub const fn category(&self) -> DiagnosticCategory { + self.category + } + + /// Return the source span associated with the diagnostic. + #[must_use] + pub fn span(&self) -> &Span { + &self.span + } + + /// Return the diagnostic severity. + #[must_use] + pub const fn severity(&self) -> DiagnosticSeverity { + self.severity + } + + /// Return the human-facing diagnostic message. + /// + /// Message text is structured logging context, not a metrics label. + #[must_use] + pub const fn message(&self) -> &'a str { + self.message + } +} + +/// Completion context for one parser or scanner attempt. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ParseAttemptContext { + category: DiagnosticCategory, + diagnostic_count: usize, +} + +impl ParseAttemptContext { + /// Construct completion context for a parser attempt. + #[must_use] + pub const fn new(category: DiagnosticCategory, diagnostic_count: usize) -> Self { + Self { + category, + diagnostic_count, + } + } + + /// Return the attempted parser category. + #[must_use] + pub const fn category(self) -> DiagnosticCategory { + self.category + } + + /// Return the number of diagnostics emitted by the attempt. + #[must_use] + pub const fn diagnostic_count(self) -> usize { + self.diagnostic_count + } +} + +/// Observer for deterministic parser attempts and diagnostics. +/// +/// Implementations may forward callbacks to any logging or metrics backend. +/// Callback results are deliberately absent, so an observer cannot direct +/// parser recovery or change the returned syntax tree. +pub trait ParseObserver { + /// Observe the start of a parser or scanner attempt. + fn parse_attempt_started(&self, _category: DiagnosticCategory) {} + + /// Observe completion of a parser or scanner attempt. + fn parse_attempt_completed(&self, _context: ParseAttemptContext) {} + + /// Observe one parser diagnostic. + fn diagnostic_emitted(&self, _context: &DiagnosticContext<'_>) {} +} + +/// Observer that discards every callback. +#[derive(Debug, Default, Clone, Copy)] +pub struct NoopParseObserver; + +impl ParseObserver for NoopParseObserver {} + +pub(crate) fn report_diagnostics( + observer: &dyn ParseObserver, + category: DiagnosticCategory, + errors: &[Simple], +) { + for error in errors { + let message = diagnostic_message(error); + let context = DiagnosticContext { + code: DiagnosticCode::from_message(message), + category, + span: error.span(), + severity: DiagnosticSeverity::Error, + message, + }; + observer.diagnostic_emitted(&context); + } +} + +pub(crate) fn complete_attempt( + observer: &dyn ParseObserver, + category: DiagnosticCategory, + errors: &[Simple], +) { + report_diagnostics(observer, category, errors); + observer.parse_attempt_completed(ParseAttemptContext::new(category, errors.len())); +} + +fn diagnostic_message(error: &Simple) -> &str { + match error.reason() { + SimpleReason::Custom(message) => message, + SimpleReason::Unexpected => "unexpected input", + SimpleReason::Unclosed { .. } => "unclosed delimiter", + } +} diff --git a/src/parser/observability/telemetry.rs b/src/parser/observability/telemetry.rs new file mode 100644 index 00000000..47e3a175 --- /dev/null +++ b/src/parser/observability/telemetry.rs @@ -0,0 +1,83 @@ +//! Optional `tracing` and `metrics` observer adapter. + +use metrics::{counter, describe_counter}; + +use super::{DiagnosticContext, ParseAttemptContext, ParseObserver}; +use crate::parser::diagnostics::DiagnosticCategory; + +const ATTEMPT_METRIC: &str = "ddlint_parser_attempts_total"; +const DIAGNOSTIC_METRIC: &str = "ddlint_parser_diagnostics_total"; + +/// Observer that forwards parser events to the `tracing` and `metrics` facades. +/// +/// Constructing this adapter describes its counters but does not install a +/// global tracing subscriber, metrics recorder, or exporter. +#[derive(Debug, Clone, Copy)] +#[non_exhaustive] +pub struct TelemetryObserver; + +impl TelemetryObserver { + /// Construct the optional telemetry adapter. + #[must_use] + pub fn new() -> Self { + describe_counter!(ATTEMPT_METRIC, "Parser and scanner attempts"); + describe_counter!(DIAGNOSTIC_METRIC, "Parser diagnostics emitted"); + Self + } +} + +impl Default for TelemetryObserver { + fn default() -> Self { + Self::new() + } +} + +impl ParseObserver for TelemetryObserver { + fn parse_attempt_started(&self, category: DiagnosticCategory) { + let category = category.as_str(); + counter!(ATTEMPT_METRIC, "category" => category).increment(1); + tracing::debug!( + target: "ddlint::parser", + category, + "parser attempt started" + ); + } + + fn parse_attempt_completed(&self, context: ParseAttemptContext) { + let category = context.category().as_str(); + let diagnostic_count = context.diagnostic_count(); + tracing::debug!( + target: "ddlint::parser", + category, + diagnostic_count, + "parser attempt completed" + ); + } + + fn diagnostic_emitted(&self, context: &DiagnosticContext<'_>) { + let code = context.code().map_or("uncoded", |code| code.as_str()); + let category = context.category().as_str(); + let severity = context.severity().as_str(); + let span_start = context.span().start; + let span_end = context.span().end; + let message = context.message(); + + counter!( + DIAGNOSTIC_METRIC, + "code" => code, + "category" => category, + "severity" => severity + ) + .increment(1); + tracing::error!( + target: "ddlint::parser", + code, + category, + severity, + span_start, + span_end, + message, + "parser diagnostic" + ); + } +} diff --git a/src/parser/span_scanner.rs b/src/parser/span_scanner.rs index 42533016..1145b061 100644 --- a/src/parser/span_scanner.rs +++ b/src/parser/span_scanner.rs @@ -8,6 +8,8 @@ use crate::{Span, SyntaxKind}; use super::ParsedSpans; +use super::diagnostics::DiagnosticCategory; +use super::observability::{ParseObserver, complete_attempt}; use super::span_scanners::{ collect_apply_spans, collect_attribute_spans, collect_function_spans, collect_import_spans, collect_index_spans, collect_relation_spans, collect_rule_spans, collect_transformer_spans, @@ -18,15 +20,40 @@ use super::span_scanners::{ pub(super) fn parse_tokens( tokens: &[(SyntaxKind, Span)], src: &str, + observer: &dyn ParseObserver, ) -> (ParsedSpans, Vec>) { - let (attribute_spans, attribute_errors) = collect_attribute_spans(tokens, src); - let (import_spans, errors) = collect_import_spans(tokens, src); - let (typedef_spans, typedef_errors) = collect_typedef_spans(tokens, src); - let (relation_spans, relation_errors) = collect_relation_spans(tokens, src); - let (index_spans, index_errors) = collect_index_spans(tokens, src); - let (function_spans, function_errors) = collect_function_spans(tokens, src); - let (transformer_spans, transformer_errors) = collect_transformer_spans(tokens, src); - let (apply_spans, apply_errors) = collect_apply_spans(tokens, src); + let (attribute_spans, attribute_errors) = + observe_span_scan(observer, DiagnosticCategory::Attribute, || { + collect_attribute_spans(tokens, src) + }); + let (import_spans, import_errors) = + observe_span_scan(observer, DiagnosticCategory::Import, || { + collect_import_spans(tokens, src) + }); + let (typedef_spans, typedef_errors) = + observe_span_scan(observer, DiagnosticCategory::Typedef, || { + collect_typedef_spans(tokens, src) + }); + let (relation_spans, relation_errors) = + observe_span_scan(observer, DiagnosticCategory::Relation, || { + collect_relation_spans(tokens, src) + }); + let (index_spans, index_errors) = + observe_span_scan(observer, DiagnosticCategory::Index, || { + collect_index_spans(tokens, src) + }); + let (function_spans, function_errors) = + observe_span_scan(observer, DiagnosticCategory::Function, || { + collect_function_spans(tokens, src) + }); + let (transformer_spans, transformer_errors) = + observe_span_scan(observer, DiagnosticCategory::Transformer, || { + collect_transformer_spans(tokens, src) + }); + let (apply_spans, apply_errors) = + observe_span_scan(observer, DiagnosticCategory::Apply, || { + collect_apply_spans(tokens, src) + }); let non_rule_span_capacity = attribute_spans.len() + import_spans.len() @@ -48,10 +75,12 @@ pub(super) fn parse_tokens( non_rule_spans.extend(apply_spans.iter().cloned()); let non_rule_spans = merge_spans(non_rule_spans); - let (rule_spans, expr_spans, rule_errors) = collect_rule_spans(tokens, src, &non_rule_spans); + let (rule_spans, expr_spans, rule_errors) = observe_rule_scan(observer, || { + collect_rule_spans(tokens, src, &non_rule_spans) + }); let mut all_errors = attribute_errors; - all_errors.extend(errors); + all_errors.extend(import_errors); all_errors.extend(typedef_errors); all_errors.extend(relation_errors); all_errors.extend(index_errors); @@ -59,8 +88,10 @@ pub(super) fn parse_tokens( all_errors.extend(transformer_errors); all_errors.extend(apply_errors); all_errors.extend(rule_errors); - all_errors.extend(lexer_errors(tokens)); + let lexer_errors = observe_error_scan(observer, || lexer_errors(tokens)); + all_errors.extend(lexer_errors); + observer.parse_attempt_started(DiagnosticCategory::SpanBuilder); let span_result = ParsedSpans::builder() .attributes(attribute_spans) .imports(import_spans) @@ -75,9 +106,17 @@ pub(super) fn parse_tokens( .build(); let spans = match span_result { - Ok(spans) => spans, + Ok(spans) => { + observer.parse_attempt_completed(super::observability::ParseAttemptContext::new( + DiagnosticCategory::SpanBuilder, + 0, + )); + spans + } Err(err) => { - all_errors.push(chumsky::error::Simple::custom(0..0, err.to_string())); + let span_errors = [chumsky::error::Simple::custom(0..0, err.to_string())]; + complete_attempt(observer, DiagnosticCategory::SpanBuilder, &span_errors); + all_errors.extend(span_errors); ParsedSpans::default() } }; @@ -85,6 +124,39 @@ pub(super) fn parse_tokens( (spans, all_errors) } +type ScanErrors = Vec>; + +fn observe_span_scan( + observer: &dyn ParseObserver, + category: DiagnosticCategory, + scan: impl FnOnce() -> (Vec, ScanErrors), +) -> (Vec, ScanErrors) { + observer.parse_attempt_started(category); + let result = scan(); + complete_attempt(observer, category, &result.1); + result +} + +fn observe_rule_scan( + observer: &dyn ParseObserver, + scan: impl FnOnce() -> (Vec, Vec, ScanErrors), +) -> (Vec, Vec, ScanErrors) { + observer.parse_attempt_started(DiagnosticCategory::Rule); + let result = scan(); + complete_attempt(observer, DiagnosticCategory::Rule, &result.2); + result +} + +fn observe_error_scan( + observer: &dyn ParseObserver, + scan: impl FnOnce() -> ScanErrors, +) -> ScanErrors { + observer.parse_attempt_started(DiagnosticCategory::Lexer); + let errors = scan(); + complete_attempt(observer, DiagnosticCategory::Lexer, &errors); + errors +} + fn lexer_errors(tokens: &[(SyntaxKind, Span)]) -> Vec> { tokens .iter() @@ -128,7 +200,11 @@ mod tests { let src = "@"; let tokens = vec![(SyntaxKind::N_ERROR, 0..src.len())]; - let (_spans, errors) = parse_tokens(&tokens, src); + let (_spans, errors) = parse_tokens( + &tokens, + src, + &super::super::observability::NoopParseObserver, + ); assert_parse_error(&errors, "unrecognized token", 0, src.len()); } diff --git a/src/parser/span_scanners/relations.rs b/src/parser/span_scanners/relations.rs index 970bc169..82a64186 100644 --- a/src/parser/span_scanners/relations.rs +++ b/src/parser/span_scanners/relations.rs @@ -9,6 +9,13 @@ mod preamble; use chumsky::error::Simple; +use crate::parser::error_messages::{ + RELATION_BRACKET_PRIMARY_KEY_ERROR as D_REL_004, + RELATION_BRACKET_WRAPPED_PRIMARY_KEY_ERROR as D_REL_008, + RELATION_INVALID_BRACKET_ELEMENT_TYPE_ERROR as D_REL_005, + RELATION_MALFORMED_PRIMARY_KEY_ERROR as D_REL_007, + RELATION_PRIMARY_KEY_ON_NON_INPUT_ERROR as D_REL_006, +}; use crate::{Span, SyntaxKind}; use cursor::{ @@ -21,13 +28,6 @@ use super::utils::State; pub(super) type ScanResult = Result>>; -const D_REL_004: &str = "D-REL-004: bracket-form relations cannot declare a primary key clause"; -const D_REL_005: &str = - "D-REL-005: bracket-form relations require a single element type between '[' and ']'"; -const D_REL_006: &str = "D-REL-006: primary key clauses are only valid on input relations"; -pub(super) const D_REL_007: &str = "D-REL-007: unexpected or malformed primary key clause"; -const D_REL_008: &str = "D-REL-008: bracket-wrapped primary key clauses are not supported; remove the surrounding '['/']'"; - #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum BodyForm { Record, diff --git a/src/parser/span_scanners/relations/cursor.rs b/src/parser/span_scanners/relations/cursor.rs index 3b5d2bd7..348b973f 100644 --- a/src/parser/span_scanners/relations/cursor.rs +++ b/src/parser/span_scanners/relations/cursor.rs @@ -5,10 +5,11 @@ use chumsky::{Error, error::Simple}; +use crate::parser::error_messages::RELATION_MALFORMED_PRIMARY_KEY_ERROR as D_REL_007; use crate::parser::lexer_helpers::token_display; use crate::{Span, SyntaxKind}; -use super::{D_REL_007, ScanResult, custom_error}; +use super::{ScanResult, custom_error}; /// Parse a balanced block opened by the delimiter matching `close`. /// diff --git a/src/parser/span_scanners/relations/preamble.rs b/src/parser/span_scanners/relations/preamble.rs index 8a986075..3a0ea79b 100644 --- a/src/parser/span_scanners/relations/preamble.rs +++ b/src/parser/span_scanners/relations/preamble.rs @@ -43,17 +43,15 @@ use chumsky::{Error, error::Simple}; +use crate::parser::error_messages::{ + RELATION_DUPLICATE_KIND_ERROR as D_REL_003, RELATION_DUPLICATE_ROLE_ERROR as D_REL_002, + RELATION_KIND_BEFORE_ROLE_ERROR as D_REL_001, +}; use crate::{Span, SyntaxKind}; use super::cursor::skip_trivia; use super::{ScanResult, custom_error}; -const D_REL_001: &str = - "D-REL-001: relation role keyword (input/output) must precede the kind keyword"; -const D_REL_002: &str = "D-REL-002: at most one role keyword (input, output) is permitted"; -const D_REL_003: &str = - "D-REL-003: at most one kind keyword (relation, stream, multiset) is permitted"; - /// Relation role annotation parsed from a relation preamble keyword. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum RelationRole { diff --git a/src/parser/span_scanners/tests/mod.rs b/src/parser/span_scanners/tests/mod.rs index a789b92b..0c246534 100644 --- a/src/parser/span_scanners/tests/mod.rs +++ b/src/parser/span_scanners/tests/mod.rs @@ -352,7 +352,11 @@ fn parse_tokens_skips_non_rule_constructs_when_scanning_rules() { ); let tokens = tokenize(src); - let (spans, errors) = parse_tokens(&tokens, src); + let (spans, errors) = parse_tokens( + &tokens, + src, + &crate::parser::observability::NoopParseObserver, + ); assert!(errors.is_empty()); diff --git a/src/parser/tests/helpers.rs b/src/parser/tests/helpers.rs index ebd04a28..08c43963 100644 --- a/src/parser/tests/helpers.rs +++ b/src/parser/tests/helpers.rs @@ -126,7 +126,6 @@ pub(super) fn round_trip(src: impl Into) { /// /// The helper asserts that parsing succeeds without errors and that the /// extractor yields at least one item. -#[expect(clippy::expect_used, reason = "helpers used only in tests")] fn parse_single_item Vec>( src: impl Into, extractor: F, @@ -135,8 +134,9 @@ fn parse_single_item Vec>( let parsed = parse(src.as_ref()); crate::test_util::assert_no_parse_errors(parsed.errors()); assert_eq!(parsed.root().kind(), SyntaxKind::N_DATALOG_PROGRAM); - let items = extractor(parsed.root()); - items.first().cloned().expect("item missing") + let mut items = extractor(parsed.root()); + assert!(!items.is_empty(), "item missing"); + items.remove(0) } /// Parse a program containing a single relation and return it. diff --git a/src/parser/tests/rules/aggregations.rs b/src/parser/tests/rules/aggregations.rs index ec04c7b2..c4ca7825 100644 --- a/src/parser/tests/rules/aggregations.rs +++ b/src/parser/tests/rules/aggregations.rs @@ -12,13 +12,9 @@ use crate::test_util::{ /// Assert that `body_terms()` reports an expected error for a literal found in `src`. fn assert_body_terms_error(src: &str, literal: &str, expected_error: &str) { let parsed = parse_ok(src); - #[expect(clippy::expect_used, reason = "tests require a single rule")] - let rule = parsed - .root() - .rules() - .first() - .cloned() - .expect("rule missing"); + let mut rules = parsed.root().rules(); + assert_eq!(rules.len(), 1, "expected a single rule"); + let rule = rules.remove(0); let errors = match rule.body_terms() { Ok(terms) => panic!("expected body_terms error, got {terms:?}"), Err(errs) => errs, diff --git a/src/parser/tests/rules/body_terms.rs b/src/parser/tests/rules/body_terms.rs index 2d607006..d43d320d 100644 --- a/src/parser/tests/rules/body_terms.rs +++ b/src/parser/tests/rules/body_terms.rs @@ -4,22 +4,15 @@ use super::super::helpers::{parse_err, parse_ok}; use crate::parser::ast::{Expr, Pattern, RuleBodyTerm}; use crate::test_util::{call, var}; -#[expect( - clippy::expect_used, - reason = "tests require a single parsed rule for assignment assertions" -)] fn assert_body_assignment( src: &str, expected_terms_count: usize, assignment_index: usize, ) -> (Pattern, Expr) { let parsed = parse_ok(src); - let rule = parsed - .root() - .rules() - .first() - .cloned() - .expect("rule missing"); + let mut rules = parsed.root().rules(); + assert_eq!(rules.len(), 1, "expected a single rule"); + let rule = rules.remove(0); let terms = match rule.body_terms() { Ok(terms) => terms, Err(errs) => panic!("body terms should parse: {errs:?}"), diff --git a/tests/parser_observability.rs b/tests/parser_observability.rs new file mode 100644 index 00000000..7fbc1705 --- /dev/null +++ b/tests/parser_observability.rs @@ -0,0 +1,179 @@ +//! Integration tests for backend-neutral parser observability. + +use ddlint::{DiagnosticCategory, DiagnosticCode, DiagnosticSeverity, parse, parse_with_observer}; +use rstest::rstest; + +#[path = "support/observability.rs"] +mod support; +use support::{RecordedParseEvent, RecordingParseObserver}; + +#[rstest] +#[case::kind_before_role( + "relation input R(id: u32)\noutput R(id: u32)", + DiagnosticCode::RelationKindBeforeRole, + 9..14 +)] +#[case::duplicate_role( + "input output R(id: u32)\noutput R(id: u32)", + DiagnosticCode::RelationDuplicateRole, + 6..12 +)] +#[case::duplicate_kind( + "stream multiset R(id: u32)\noutput R(id: u32)", + DiagnosticCode::RelationDuplicateKind, + 7..15 +)] +#[case::bracket_primary_key( + "input R[u32] primary key (id)\noutput R(id: u32)", + DiagnosticCode::RelationBracketPrimaryKey, + 12..29 +)] +#[case::invalid_bracket_element( + "input R[]\noutput R(id: u32)", + DiagnosticCode::RelationInvalidBracketElementType, + 9..10 +)] +#[case::primary_key_on_non_input( + "output R(id: u32) primary key (id)\noutput S(id: u32)", + DiagnosticCode::RelationPrimaryKeyOnNonInput, + 17..34 +)] +#[case::malformed_primary_key( + "input R(id: u32) primary value\noutput R(id: u32)", + DiagnosticCode::RelationMalformedPrimaryKey, + 25..30 +)] +#[case::bracket_wrapped_primary_key( + "input R(id: u32) [ primary key (id) ]\noutput S(id: u32)", + DiagnosticCode::RelationBracketWrappedPrimaryKey, + 16..37 +)] +fn relation_diagnostics_emit_stable_code_and_span( + #[case] source: &str, + #[case] expected_code: DiagnosticCode, + #[case] expected_span: std::ops::Range, +) { + let observer = RecordingParseObserver::default(); + + let parsed = parse_with_observer(source, &observer); + + let diagnostics: Vec<_> = observer + .events() + .into_iter() + .filter_map(|event| match event { + RecordedParseEvent::Diagnostic { + code: Some(code), + category, + span, + severity, + message, + } => Some((code, category, span, severity, message)), + _ => None, + }) + .collect(); + let [diagnostic] = diagnostics.as_slice() else { + panic!("expected exactly one coded diagnostic, got {diagnostics:?}"); + }; + let (code, category, span, severity, message) = diagnostic; + assert_eq!(*code, expected_code); + assert_eq!(*category, DiagnosticCategory::Relation); + assert_eq!(*span, expected_span); + assert_eq!(*severity, DiagnosticSeverity::Error); + assert!(message.starts_with(expected_code.as_str())); + assert!(!parsed.errors().is_empty()); +} + +#[test] +fn valid_parse_attempts_cover_every_parser_category_in_order() { + let observer = RecordingParseObserver::default(); + let source = "input relation Source(id: u32)\nOutput(id) :- Source(id)."; + + let parsed = parse_with_observer(source, &observer); + + assert!(parsed.errors().is_empty()); + let started: Vec<_> = observer + .events() + .into_iter() + .filter_map(|event| match event { + RecordedParseEvent::AttemptStarted(category) => Some(category), + _ => None, + }) + .collect(); + assert_eq!( + started, + vec![ + DiagnosticCategory::Parser, + DiagnosticCategory::Attribute, + DiagnosticCategory::Import, + DiagnosticCategory::Typedef, + DiagnosticCategory::Relation, + DiagnosticCategory::Index, + DiagnosticCategory::Function, + DiagnosticCategory::Transformer, + DiagnosticCategory::Apply, + DiagnosticCategory::Rule, + DiagnosticCategory::Lexer, + DiagnosticCategory::SpanBuilder, + DiagnosticCategory::TopLevelFor, + DiagnosticCategory::NameUniqueness, + ] + ); + let completed: Vec<_> = observer + .events() + .into_iter() + .filter_map(|event| match event { + RecordedParseEvent::AttemptCompleted { + category, + diagnostic_count, + } => Some((category, diagnostic_count)), + _ => None, + }) + .collect(); + assert_eq!( + completed, + [ + DiagnosticCategory::Attribute, + DiagnosticCategory::Import, + DiagnosticCategory::Typedef, + DiagnosticCategory::Relation, + DiagnosticCategory::Index, + DiagnosticCategory::Function, + DiagnosticCategory::Transformer, + DiagnosticCategory::Apply, + DiagnosticCategory::Rule, + DiagnosticCategory::Lexer, + DiagnosticCategory::SpanBuilder, + DiagnosticCategory::TopLevelFor, + DiagnosticCategory::NameUniqueness, + DiagnosticCategory::Parser, + ] + .into_iter() + .map(|category| (category, 0)) + .collect::>() + ); +} + +#[test] +fn observer_events_and_parser_output_are_deterministic() { + let source = concat!( + "relation input Broken(id: u32)\n", + "input relation Source(id: u32)\n", + "Output(id) :- Source(id).\n", + ); + let baseline = parse(source); + let mut expected_events = None; + + for _ in 0..5 { + let observer = RecordingParseObserver::default(); + let instrumented_parse = parse_with_observer(source, &observer); + let events = observer.events(); + + assert_eq!(instrumented_parse.green(), baseline.green()); + assert_eq!(instrumented_parse.errors(), baseline.errors()); + if let Some(expected) = &expected_events { + assert_eq!(&events, expected); + } else { + expected_events = Some(events); + } + } +} diff --git a/tests/support/observability.rs b/tests/support/observability.rs new file mode 100644 index 00000000..cfd5f1cc --- /dev/null +++ b/tests/support/observability.rs @@ -0,0 +1,77 @@ +//! Recording parser observer for integration tests. + +use std::cell::RefCell; + +use ddlint::{ + DiagnosticCategory, DiagnosticCode, DiagnosticContext, DiagnosticSeverity, ParseAttemptContext, + ParseObserver, Span, +}; + +/// Owned representation of an observer callback. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RecordedParseEvent { + /// A parser category started an attempt. + AttemptStarted(DiagnosticCategory), + /// A parser category completed an attempt. + AttemptCompleted { + /// Category that completed. + category: DiagnosticCategory, + /// Diagnostics emitted during the attempt. + diagnostic_count: usize, + }, + /// The parser emitted a diagnostic. + Diagnostic { + /// Stable diagnostic code, when assigned. + code: Option, + /// Parser category that emitted the diagnostic. + category: DiagnosticCategory, + /// Diagnostic source span. + span: Span, + /// Diagnostic severity. + severity: DiagnosticSeverity, + /// Human-facing diagnostic message. + message: String, + }, +} + +/// Observer that records callbacks in emission order. +#[derive(Debug, Default)] +pub struct RecordingParseObserver { + events: RefCell>, +} + +impl RecordingParseObserver { + /// Return an owned snapshot of all recorded events. + pub fn events(&self) -> Vec { + self.events.borrow().clone() + } +} + +impl ParseObserver for RecordingParseObserver { + fn parse_attempt_started(&self, category: DiagnosticCategory) { + self.events + .borrow_mut() + .push(RecordedParseEvent::AttemptStarted(category)); + } + + fn parse_attempt_completed(&self, context: ParseAttemptContext) { + self.events + .borrow_mut() + .push(RecordedParseEvent::AttemptCompleted { + category: context.category(), + diagnostic_count: context.diagnostic_count(), + }); + } + + fn diagnostic_emitted(&self, context: &DiagnosticContext<'_>) { + self.events + .borrow_mut() + .push(RecordedParseEvent::Diagnostic { + code: context.code(), + category: context.category(), + span: context.span().clone(), + severity: context.severity(), + message: context.message().to_owned(), + }); + } +}