adotenv 0.2.0

A tooling-grade .env parser: literal values (no variable expansion), exact byte spans, and edits that leave the rest of the file untouched.
Documentation
use core::{error::Error, fmt, ops::Range};

/// The importance of a parsing diagnostic.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Severity {
    /// The statement cannot be interpreted as a Portable assignment.
    Error,
    /// Valid syntax whose interpretation is non-portable or ambiguous.
    Warning,
    /// Source metadata that callers may want to display.
    Info,
}

/// Stable diagnostic identities; messages are descriptive, not API identifiers.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum DiagnosticCode {
    /// A key is empty or contains a character outside the Portable key alphabet.
    InvalidKey,
    /// A quoted value has no matching delimiter before EOF.
    UnterminatedQuote,
    /// Non-comment text follows a closing quote.
    TrailingText,
    /// An unknown double-quoted escape is retained literally.
    UnknownEscape,
    /// An earlier assignment has the same key.
    DuplicateKey,
    /// A declaration has no equals sign or value.
    BareKey,
    /// A key is not a POSIX shell variable name.
    NonPosixKey,
    /// A hash attached to an unquoted value is literal, not a comment.
    TightHash,
    /// Backticks are literal, not a Portable quoting style.
    Backtick,
    /// A UTF-8 byte-order mark precedes the source body.
    ByteOrderMark,
    /// A lone carriage return is retained as a source line ending.
    LoneCarriageReturn,
}

impl DiagnosticCode {
    /// Stable machine-readable identity, independent of enum order and wording.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::InvalidKey => "dotenv.invalid-key",
            Self::UnterminatedQuote => "dotenv.unterminated-quote",
            Self::TrailingText => "dotenv.trailing-text",
            Self::UnknownEscape => "dotenv.unknown-escape",
            Self::DuplicateKey => "dotenv.duplicate-key",
            Self::BareKey => "dotenv.bare-key",
            Self::NonPosixKey => "dotenv.non-posix-key",
            Self::TightHash => "dotenv.tight-hash",
            Self::Backtick => "dotenv.backtick",
            Self::ByteOrderMark => "dotenv.bom",
            Self::LoneCarriageReturn => "dotenv.lone-cr",
        }
    }

    /// The severity assigned by Portable syntax.
    #[must_use]
    pub const fn severity(self) -> Severity {
        match self {
            Self::InvalidKey | Self::UnterminatedQuote | Self::TrailingText => Severity::Error,
            Self::ByteOrderMark => Severity::Info,
            _ => Severity::Warning,
        }
    }

    /// A human-readable explanation; callers should match codes, not this text.
    #[must_use]
    pub const fn message(self) -> &'static str {
        match self {
            Self::InvalidKey => "expected a non-empty Portable key",
            Self::UnterminatedQuote => "quoted value has no closing delimiter",
            Self::TrailingText => "unexpected text after the closing quote",
            Self::UnknownEscape => "unknown escape retained literally",
            Self::DuplicateKey => "duplicate key; lookup uses the last declaration",
            Self::BareKey => "bare declaration retained without assigning a value",
            Self::NonPosixKey => "key is not a POSIX shell variable name",
            Self::TightHash => "hash without preceding whitespace is literal",
            Self::Backtick => "backticks are literal in Portable syntax",
            Self::ByteOrderMark => "leading UTF-8 byte-order mark retained",
            Self::LoneCarriageReturn => "lone carriage return retained as a line ending",
        }
    }
}

/// A source-relative diagnostic. Its primary span always addresses original bytes.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Diagnostic {
    pub(crate) code: DiagnosticCode,
    pub(crate) span: Range<usize>,
    pub(crate) secondary: Option<Range<usize>>,
    pub(crate) line: usize,
}

impl Diagnostic {
    pub(crate) fn new(code: DiagnosticCode, span: Range<usize>, line: usize) -> Self {
        Self {
            code,
            span,
            secondary: None,
            line,
        }
    }

    /// Stable identity of this diagnostic.
    #[must_use]
    pub const fn code(&self) -> DiagnosticCode {
        self.code
    }
    /// Portable severity of this diagnostic.
    #[must_use]
    pub const fn severity(&self) -> Severity {
        self.code.severity()
    }
    /// The primary byte range, including BOM offsets.
    #[must_use]
    pub fn span(&self) -> Range<usize> {
        self.span.clone()
    }
    /// A related source location, such as the first occurrence of a duplicate.
    #[must_use]
    pub fn secondary_span(&self) -> Option<Range<usize>> {
        self.secondary.clone()
    }
    /// The explicitly one-based source line number of the primary span.
    #[must_use]
    pub const fn line(&self) -> usize {
        self.line
    }
    /// Descriptive text; use [`DiagnosticCode::as_str`] for machine matching.
    #[must_use]
    pub const fn message(&self) -> &'static str {
        self.code.message()
    }
}

impl fmt::Display for Diagnostic {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "line {}: {} ({})",
            self.line,
            self.message(),
            self.code.as_str()
        )
    }
}
impl Error for Diagnostic {}