tilewright-source 0.2.2

Loads and validates Tilewright world source trees into a typed, game-neutral model
Documentation
// Copyright 2026 Seraphina Studios
// SPDX-License-Identifier: MIT

use std::{cell::Cell, fmt, rc::Rc};
use tilewright_jsonc::ErrorCode;

/// Anything with a stable diagnostic code string.
pub(crate) trait CodeName: Copy {
    fn name(self) -> &'static str;
}

impl CodeName for Code {
    fn name(self) -> &'static str {
        self.as_str()
    }
}

impl CodeName for ErrorCode {
    fn name(self) -> &'static str {
        self.as_str()
    }
}

/// Stable diagnostic codes. JSONC syntax errors use the parser's own codes.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum Code {
    /// A file could not be read.
    Io,
    /// A file is not UTF-8.
    Encoding,
    /// A load exceeded its file or byte budget.
    Budget,
    /// A symbolic link where the format expects a file or directory.
    Symlink,
    /// A file or directory the format does not define.
    UnexpectedFile,
    /// A required file is missing.
    MissingFile,
    /// A path in a document is not a safe relative path.
    Path,
    /// A file name disagrees with the document's content.
    NameMismatch,
    /// A `format` value other than the one the document kind requires.
    Format,
    /// A key the format or schema does not define.
    UnknownKey,
    /// A required key is missing.
    MissingKey,
    /// A value of the wrong JSON type.
    Type,
    /// A value outside its allowed range or set.
    Range,
    /// An identifier that does not match the identifier pattern.
    Identifier,
    /// Two entries with the same identity.
    Duplicate,
    /// A reference that does not resolve.
    Reference,
    /// Zone parents that form a cycle.
    Cycle,
    /// A malformed terrain grid.
    Grid,
    /// A stairwell step that is not on the layer next to the step before
    /// it, in the stairwell's direction, or that moves more than one tile
    /// on an axis.
    StairwellStep,
    /// A tile that holds steps of two stairwells.
    StairwellOverlap,
}

impl Code {
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Io => "source.io",
            Self::Encoding => "source.encoding",
            Self::Budget => "source.budget",
            Self::Symlink => "source.symlink",
            Self::UnexpectedFile => "source.unexpected_file",
            Self::MissingFile => "source.missing_file",
            Self::Path => "source.path",
            Self::NameMismatch => "source.name_mismatch",
            Self::Format => "shape.format",
            Self::UnknownKey => "shape.unknown_key",
            Self::MissingKey => "shape.missing_key",
            Self::Type => "shape.type",
            Self::Range => "shape.range",
            Self::Identifier => "shape.identifier",
            Self::Duplicate => "ref.duplicate",
            Self::Reference => "ref.unresolved",
            Self::Cycle => "ref.cycle",
            Self::Grid => "grid.invalid",
            Self::StairwellStep => "stairwell.step",
            Self::StairwellOverlap => "stairwell.overlap",
        }
    }
}

/// One problem in a source tree.
///
/// `start..end` is the byte span in the file; `line` and `column` are
/// 1-based and both 0 when the problem has no position, such as a missing
/// file.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct Diagnostic {
    pub path: String,
    pub start: usize,
    pub end: usize,
    pub line: usize,
    pub column: usize,
    pub code: &'static str,
    pub message: String,
}

impl Diagnostic {
    /// A diagnostic without a position.
    pub(crate) fn file(path: &str, code: impl CodeName, message: impl Into<String>) -> Self {
        Self {
            path: path.to_owned(),
            start: 0,
            end: 0,
            line: 0,
            column: 0,
            code: code.name(),
            message: message.into(),
        }
    }

    /// A diagnostic at a byte span of the text `positions` indexes.
    pub(crate) fn at(
        path: &str,
        positions: &Positions<'_>,
        start: usize,
        end: usize,
        code: impl CodeName,
        message: impl Into<String>,
    ) -> Self {
        let (line, column) = positions.locate(start);
        Self {
            path: path.to_owned(),
            start,
            end,
            line,
            column,
            code: code.name(),
            message: message.into(),
        }
    }
}

/// Bytes between character-count checkpoints in [`Positions`].
const STRIDE: usize = 256;

/// Whether `byte` continues a UTF-8 character rather than starting one.
const fn is_continuation(byte: u8) -> bool {
    byte & 0xC0 == 0x80
}

/// Line and column lookup for one text. Building it reads the text once;
/// each lookup is then a binary search and a scan of at most [`STRIDE`]
/// bytes, so a document with many diagnostics is not rescanned for each.
pub(crate) struct Positions<'s> {
    source: &'s str,
    /// The byte offset where each line starts.
    lines: Vec<usize>,
    /// Characters before byte `i * STRIDE`, for every such offset up to and
    /// including the end of the text.
    characters: Vec<usize>,
}

impl<'s> Positions<'s> {
    pub(crate) fn new(source: &'s str) -> Self {
        let mut lines = vec![0];
        let mut characters = Vec::with_capacity(source.len() / STRIDE + 1);
        let mut count = 0;
        for (index, byte) in source.bytes().enumerate() {
            if index.is_multiple_of(STRIDE) {
                characters.push(count);
            }
            if byte == b'\n' {
                lines.push(index + 1);
            }
            if !is_continuation(byte) {
                count += 1;
            }
        }
        if source.len().is_multiple_of(STRIDE) {
            characters.push(count);
        }
        Self {
            source,
            lines,
            characters,
        }
    }

    /// Characters before `offset`, a character boundary within the text.
    fn characters_before(&self, offset: usize) -> usize {
        let block = offset / STRIDE;
        self.characters[block]
            + self.source.as_bytes()[block * STRIDE..offset]
                .iter()
                .filter(|byte| !is_continuation(**byte))
                .count()
    }

    /// The 1-based line and column, in characters, of `offset`, clamped to
    /// the text and moved back to a character boundary.
    pub(crate) fn locate(&self, offset: usize) -> (usize, usize) {
        let mut boundary = offset.min(self.source.len());
        while !self.source.is_char_boundary(boundary) {
            boundary -= 1;
        }
        let line = self.lines.partition_point(|start| *start <= boundary);
        let start = self.lines[line - 1];
        (
            line,
            self.characters_before(boundary) - self.characters_before(start) + 1,
        )
    }
}

/// The diagnostics of one load. At most `maximum` are kept, so hostile input
/// cannot grow the list without bound; past that, one `source.budget`
/// diagnostic notes that the rest were dropped.
pub(crate) struct Diagnostics {
    items: Vec<Diagnostic>,
    maximum: usize,
    dropped: bool,
    /// Whether reading stopped at the maximum, so that problems there may
    /// be were not looked for.
    skipped: bool,
    /// The diagnostics kept by every collection of one load, shared, so
    /// that the load stops reading once their sum reaches the maximum:
    /// nothing more could be reported.
    total: Rc<Cell<usize>>,
}

impl Diagnostics {
    pub(crate) fn new(maximum: usize) -> Self {
        Self::sharing(maximum, Rc::new(Cell::new(0)))
    }

    /// A collection that shares its count with others of the same load.
    pub(crate) const fn sharing(maximum: usize, total: Rc<Cell<usize>>) -> Self {
        Self {
            items: Vec::new(),
            maximum,
            dropped: false,
            skipped: false,
            total,
        }
    }

    /// The count shared by the collections of this load.
    pub(crate) fn total(&self) -> Rc<Cell<usize>> {
        Rc::clone(&self.total)
    }

    /// Whether the collections of this load together hold the maximum.
    pub(crate) fn is_full(&self) -> bool {
        self.total.get() >= self.maximum
    }

    pub(crate) fn push(&mut self, diagnostic: Diagnostic) {
        if self.is_full() {
            self.dropped = true;
        } else {
            self.items.push(diagnostic);
            self.total.set(self.total.get() + 1);
        }
    }

    pub(crate) const fn is_empty(&self) -> bool {
        self.items.is_empty() && !self.dropped && !self.skipped
    }

    /// Notes that reading stopped because the collections are full.
    pub(crate) const fn skip(&mut self) {
        self.skipped = true;
    }

    /// Takes in another collection of the same load, whose diagnostics are
    /// already counted.
    pub(crate) fn append(&mut self, other: Self) {
        self.items.extend(other.items);
        self.dropped |= other.dropped;
        self.skipped |= other.skipped;
    }

    /// The diagnostics sorted by path and position, without duplicates.
    pub(crate) fn finish(self) -> Vec<Diagnostic> {
        let mut items = self.items;
        if self.dropped {
            items.push(Diagnostic::file(
                ".",
                Code::Budget,
                format!(
                    "more than {} problems; the rest are not reported",
                    self.maximum
                ),
            ));
        } else if self.skipped {
            items.push(Diagnostic::file(
                ".",
                Code::Budget,
                format!(
                    "{} problems, as many as are reported; reading stopped there, so there may be more",
                    self.maximum
                ),
            ));
        }
        items.sort();
        items.dedup();
        items
    }
}

impl fmt::Display for Diagnostic {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        if self.line == 0 {
            write!(formatter, "{}: {}: {}", self.path, self.code, self.message)
        } else {
            write!(
                formatter,
                "{}:{}:{}: {}: {}",
                self.path, self.line, self.column, self.code, self.message
            )
        }
    }
}