flowlog-build 0.2.3

Build-time FlowLog compiler for library mode.
Documentation
//! Parse errors and grammar-contract internal errors.
//!
//! `ParseError` covers failures reachable from a user-authored `.dl` program:
//! syntax errors, duplicate declarations, references to undeclared relations,
//! broken include directives, and so on. Each variant carries a [`Span`] so
//! the renderer can point at the offending source.
//!
//! [`grammar_bug`] produces an [`InternalError`] for Pest grammar contracts
//! that should hold by construction (e.g. an `atom` rule always has an inner
//! `relation_name`). Those aren't user errors, but they still need to surface
//! as a structured diagnostic rather than a SIGABRT.

use std::path::PathBuf;

use codespan_reporting::diagnostic::{Diagnostic as CsDiagnostic, Label};
use thiserror::Error;

use crate::common::{
    BUG_URL, Diagnostic, FileId, InternalError, Span, primary_label, secondary_label,
};

/// Which `.decl`-style directive is being reported.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DirectiveKind {
    Input,
    Output,
    PrintSize,
}

impl std::fmt::Display for DirectiveKind {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(match self {
            DirectiveKind::Input => ".input",
            DirectiveKind::Output => ".output",
            DirectiveKind::PrintSize => ".printsize",
        })
    }
}

/// Build the `[primary, secondary]` label pair for a "duplicate X, first
/// declared at Y" style diagnostic. Dummy spans (no source position) drop
/// out instead of pointing at a bogus file.
fn dup_labels(span: Span, prior: Span, here: &str, first: &str) -> Vec<Label<FileId>> {
    [
        primary_label(span).map(|l| l.with_message(here)),
        secondary_label(prior).map(|l| l.with_message(first)),
    ]
    .into_iter()
    .flatten()
    .collect()
}

/// Single-element label vec for diagnostics that only point at one span.
/// Returns an empty vec for dummy spans rather than fabricating a location.
fn primary_only(span: Span) -> Vec<Label<FileId>> {
    primary_label(span).into_iter().collect()
}

/// Errors raised while parsing a FlowLog program.
#[non_exhaustive]
#[derive(Debug, Error)]
pub enum ParseError {
    /// Pest rejected the input with a grammar error.
    #[error("syntax error: {message}")]
    Syntax { span: Span, message: String },

    /// Two `.decl` declarations share a name (or case-colliding raw names).
    #[error("duplicate declaration of relation `{name}`")]
    DuplicateDecl {
        span: Span,
        prior: Span,
        name: String,
    },

    /// Two attributes in one `.decl` share a name (or case-colliding raw names).
    #[error("duplicate attribute `{name}` in relation `{relation}`")]
    DuplicateAttribute {
        span: Span,
        prior: Span,
        relation: String,
        name: String,
    },

    /// Two directives of the same kind target the same relation.
    #[error("duplicate {kind} directive for relation `{name}`")]
    DuplicateDirective {
        span: Span,
        prior: Span,
        kind: DirectiveKind,
        name: String,
    },

    /// A directive names a relation that was never `.decl`-d.
    #[error("{kind} directive references undeclared relation `{name}`")]
    UndeclaredInDirective {
        span: Span,
        kind: DirectiveKind,
        name: String,
    },

    /// A loop's `iterative [...]` list names a relation that was never `.decl`-d.
    #[error("iterative list references undeclared relation `{name}`")]
    UndeclaredInIterativeList { span: Span, name: String },

    /// A loop's `until`/`while` condition names a relation that was never `.decl`-d.
    #[error("loop condition references undeclared relation `{name}`")]
    UndeclaredLoopCondition { span: Span, name: String },

    /// A rule head or body atom names a relation that was never `.decl`-d.
    #[error("rule references undeclared relation `{name}`")]
    UndeclaredInRule { span: Span, name: String },

    /// A ground fact names a relation that was never `.decl`-d.
    #[error("fact references undeclared relation `{name}`")]
    UndeclaredInFact { span: Span, name: String },

    /// A `loop` / `fixpoint` block appeared outside `extend-*` mode.
    #[error("`loop`/`fixpoint` blocks require `--mode extend-batch` or `extend-inc`")]
    LoopBlockInStandardMode { span: Span },

    /// A loop's until-condition names a relation with nonzero arity.
    #[error("loop condition relation `{name}` must be nullary, but is declared with arity {arity}")]
    NonNullaryLoopCondition {
        span: Span,
        name: String,
        arity: usize,
    },

    /// A UDF call uses `_` as an argument; wildcards aren't allowed in UDF args.
    #[error("`_` placeholder is not allowed in arguments to UDF `{udf_name}`")]
    PlaceholderInUdf { span: Span, udf_name: String },

    /// An `.include` directive's target could not be opened.
    #[error("failed to read included file `{}`: {source}", path.display())]
    IncludeIo {
        span: Span,
        path: PathBuf,
        #[source]
        source: std::io::Error,
    },

    /// An `.include` chain cycles back to a file already being loaded.
    #[error("circular include of `{}`", path.display())]
    CircularInclude {
        span: Span,
        path: PathBuf,
        /// Files currently being loaded, outer-most first.
        chain: Vec<PathBuf>,
    },

    /// A grammar contract the Pest grammar should have made unreachable. Not a
    /// user error; reported as an internal compiler bug.
    #[error(transparent)]
    Internal(#[from] InternalError),
}

impl ParseError {
    /// Construct a [`ParseError::Syntax`] from a Pest error, anchoring the
    /// span to `file`.
    pub(crate) fn syntax_from_pest(
        err: &pest::error::Error<crate::parser::Rule>,
        file: FileId,
    ) -> Self {
        use pest::error::InputLocation;
        let (start, end) = match err.location {
            InputLocation::Pos(p) => (p as u32, p as u32),
            InputLocation::Span((s, e)) => (s as u32, e as u32),
        };
        ParseError::Syntax {
            span: Span::new(file, start, end),
            message: err.variant.message().into_owned(),
        }
    }
}

impl Diagnostic for ParseError {
    fn to_diagnostic(&self) -> CsDiagnostic<FileId> {
        if let ParseError::Internal(e) = self {
            return e.to_diagnostic();
        }
        let base = CsDiagnostic::error().with_message(self.to_string());
        match self {
            ParseError::DuplicateDecl { span, prior, .. } => {
                base.with_labels(dup_labels(*span, *prior, "redeclared here", "first declared here"))
            }

            ParseError::DuplicateDirective { span, prior, .. } => base.with_labels(dup_labels(
                *span,
                *prior,
                "duplicate directive",
                "first directive here",
            )),

            ParseError::DuplicateAttribute { span, prior, .. } => base.with_labels(dup_labels(
                *span,
                *prior,
                "duplicate attribute here",
                "first declared here",
            )),

            ParseError::UndeclaredInDirective { span, name, .. } => base
                .with_labels(primary_only(*span))
                .with_notes(vec![format!(
                    "add a `.decl {name}(...)` before this directive"
                )]),

            ParseError::UndeclaredInIterativeList { span, name } => base
                .with_labels(primary_only(*span))
                .with_notes(vec![format!(
                    "either `.decl {name}(...)` it, or drop `{name}` from the iterative list"
                )]),

            ParseError::UndeclaredLoopCondition { span, name } => base
                .with_labels(primary_only(*span))
                .with_notes(vec![format!(
                    "declare `{name}` as a nullary relation with `.decl {name}()` and derive it inside the loop"
                )]),

            ParseError::UndeclaredInRule { span, name }
            | ParseError::UndeclaredInFact { span, name } => base
                .with_labels(primary_only(*span))
                .with_notes(vec![format!(
                    "add a matching `.decl {name}(...)` declaration, or remove the reference"
                )]),

            ParseError::CircularInclude { span, chain, .. } => {
                let mut diag = base.with_labels(primary_only(*span));
                if !chain.is_empty() {
                    let shown: Vec<String> = chain.iter().map(|p| p.display().to_string()).collect();
                    diag = diag.with_notes(vec![format!("include chain: {}", shown.join(""))]);
                }
                diag
            }

            ParseError::Syntax { span, .. }
            | ParseError::LoopBlockInStandardMode { span }
            | ParseError::NonNullaryLoopCondition { span, .. }
            | ParseError::PlaceholderInUdf { span, .. }
            | ParseError::IncludeIo { span, .. } => base.with_labels(primary_only(*span)),

            ParseError::Internal(_) => unreachable!("handled above"),
        }
    }

    fn is_internal(&self) -> bool {
        matches!(self, ParseError::Internal(_))
    }
}

/// Produce a `ParseError::Internal` for a Pest grammar-contract violation.
///
/// Use this at sites where an `.expect` would otherwise fire on an inner
/// token that the grammar guarantees — e.g. `"atom_rule always contains
/// relation_name"`. If such a site ever trips, it's a FlowLog bug, not a
/// user error.
pub(crate) fn grammar_bug(detail: impl Into<String>) -> ParseError {
    ParseError::Internal(InternalError::new("parser", detail, BUG_URL))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::common::SourceMap;
    use crate::common::{BoxError, emit};

    fn make_sm_with(text: &str) -> (SourceMap, FileId) {
        let mut sm = SourceMap::new();
        let f = sm.add("t.dl".into(), text.into());
        (sm, f)
    }

    fn render(err: ParseError, sm: &SourceMap) -> String {
        let err: BoxError = err.into();
        let mut buf: Vec<u8> = Vec::new();
        emit(&err, sm, &mut buf).unwrap();
        String::from_utf8(buf).unwrap()
    }

    #[test]
    fn duplicate_decl_labels_both_sites() {
        let (sm, f) = make_sm_with(".decl Foo(x: int)\n.decl Foo(y: int)\n");
        let out = render(
            ParseError::DuplicateDecl {
                span: Span::new(f, 24, 27),
                prior: Span::new(f, 6, 9),
                name: "Foo".into(),
            },
            &sm,
        );
        assert!(out.contains("duplicate declaration"), "got: {out}");
        assert!(out.contains("redeclared here"), "got: {out}");
        assert!(out.contains("first declared here"), "got: {out}");
    }

    #[test]
    fn undeclared_in_directive_includes_help_note() {
        let (sm, f) = make_sm_with(".input Bar(filename=\"b.csv\")\n");
        let out = render(
            ParseError::UndeclaredInDirective {
                span: Span::new(f, 7, 10),
                kind: DirectiveKind::Input,
                name: "Bar".into(),
            },
            &sm,
        );
        assert!(out.contains(".input"), "got: {out}");
        assert!(out.contains("undeclared"), "got: {out}");
        assert!(out.contains("add a `.decl Bar"), "got: {out}");
    }

    #[test]
    fn internal_variant_renders_bug_note() {
        let (sm, _) = make_sm_with("");
        let out = render(grammar_bug("ghosts in the AST"), &sm);
        assert!(out.contains("bug"), "got: {out}");
        assert!(out.contains("ghosts in the AST"), "got: {out}");
        assert!(out.contains(BUG_URL), "got: {out}");
    }
}