praxis-source 0.1.0

Source files, spans, line maps, and diagnostics for the Praxis compiler.
Documentation
//! Source files and the [`SourceMap`] that interns them.
//!
//! A [`SourceMap`] is the compiler's registry of loaded source files. It owns
//! the file text, mints opaque [`FileId`] handles, and precomputes each file's
//! [`LineMap`]. Files are append-only once interned — source snapshots (§13.1)
//! must remain stable for the lifetime of a compilation session.

use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};

use crate::line_map::LineMap;
use crate::span::FileSpan;

/// An opaque handle to one interned source file.
///
/// `FileId`s are only ever minted by [`SourceMap::intern`]; they are pure
/// identity tokens and must never be constructed by hand. The field is private,
/// which keeps the construction surface closed across crate boundaries.
#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct FileId(u32);

impl FileId {
    /// The synthetic file id used for diagnostics that are not tied to any real
    /// source location (for example a CLI usage error). It sits at the top of
    /// the id space: [`SourceMap::intern`] mints from 0 upward and panics
    /// rather than reach it.
    pub const SYNTHETIC: FileId = FileId(u32::MAX);

    #[inline]
    pub const fn to_u32(self) -> u32 {
        self.0
    }
}

impl std::fmt::Debug for FileId {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        if *self == Self::SYNTHETIC {
            write!(f, "FileId(<synthetic>)")
        } else {
            write!(f, "FileId({})", self.0)
        }
    }
}

/// One loaded source file: its id, the path it was loaded from, the full text,
/// and a precomputed line table.
#[derive(Clone)]
pub struct SourceFile {
    id: FileId,
    path: PathBuf,
    text: String,
    line_map: LineMap,
}

impl SourceFile {
    #[inline]
    pub fn id(&self) -> FileId {
        self.id
    }

    #[inline]
    pub fn path(&self) -> &Path {
        &self.path
    }

    #[inline]
    pub fn text(&self) -> &str {
        &self.text
    }

    #[inline]
    pub fn line_map(&self) -> &LineMap {
        &self.line_map
    }

    /// A [`FileSpan`] covering the whole file.
    pub fn full_span(&self) -> FileSpan {
        FileSpan::new(self.id, crate::span::Span::new(0, self.text.len() as u32))
    }
}

/// The compiler's registry of source files.
///
/// Interning is append-only: once a file has an id its text and path are fixed
/// for the lifetime of the map, which keeps diagnostics and snapshots stable.
/// The map is internally synchronized so it can be shared across threads (the
/// LSP, for example, reads source from a background query thread).
#[derive(Default)]
pub struct SourceMap {
    /// Each file is behind an `Arc`, so its address does not depend on the
    /// `Vec`'s capacity. That is what lets [`SourceMap::get`] hand out a view
    /// that outlives the read guard without extending a borrow into storage a
    /// later `intern` may reallocate.
    files: RwLock<Vec<Arc<SourceFile>>>,
}

impl SourceMap {
    /// Create an empty source map.
    pub fn new() -> SourceMap {
        SourceMap::default()
    }

    /// Intern a source file under the given path, returning its id.
    ///
    /// Each call mints a fresh id even for a repeated path: a later load of the
    /// same path is treated as a distinct snapshot. This matches the §13.1
    /// "revisioned source" model — the LSP holds several revisions of one file
    /// simultaneously.
    pub fn intern(&self, path: impl Into<PathBuf>, text: impl Into<String>) -> FileId {
        let path = path.into();
        let text = text.into();
        let line_map = LineMap::new(&text);

        let mut files = self.files.write().unwrap();
        // SYNTHETIC is reserved; a non-pathological program cannot exhaust u32
        // ids, but if it ever does we'd rather panic loudly than alias SYNTHETIC.
        let id = u32::try_from(files.len()).expect("more than 2^32 source files");
        assert!(id != FileId::SYNTHETIC.to_u32(), "file id space exhausted");

        files.push(Arc::new(SourceFile {
            id: FileId(id),
            path,
            text,
            line_map,
        }));
        FileId(id)
    }

    /// The number of interned files.
    pub fn len(&self) -> usize {
        self.files.read().unwrap().len()
    }

    /// True if no files have been interned.
    pub fn is_empty(&self) -> bool {
        self.files.read().unwrap().is_empty()
    }

    /// Fetch a file by id. Returns `None` for unknown or synthetic ids.
    ///
    /// The returned view shares ownership of the file, so it stays valid across
    /// later `intern` calls (which reallocate the `Vec`) and across threads.
    pub fn get(&self, id: FileId) -> Option<FileView> {
        if id == FileId::SYNTHETIC {
            return None;
        }
        let files = self.files.read().unwrap();
        let file = Arc::clone(files.get(id.to_u32() as usize)?);
        Some(FileView { file })
    }
}

/// A shared view of an interned source file.
///
/// It holds a reference count rather than a borrow, so it is independent of the
/// map's internal storage and of the lock: interning more files, or dropping
/// the map itself, cannot invalidate a live view.
#[derive(Clone)]
pub struct FileView {
    file: Arc<SourceFile>,
}

impl std::ops::Deref for FileView {
    type Target = SourceFile;
    fn deref(&self) -> &SourceFile {
        &self.file
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn intern_assigns_sequential_ids() {
        let map = SourceMap::new();
        let a = map.intern("a.px", "first");
        let b = map.intern("b.px", "second");
        assert_eq!(a.to_u32(), 0);
        assert_eq!(b.to_u32(), 1);
        assert_eq!(map.len(), 2);
    }

    #[test]
    fn intern_same_path_yields_distinct_ids() {
        let map = SourceMap::new();
        let first = map.intern("dup.px", "one");
        let second = map.intern("dup.px", "two");
        assert_ne!(first, second, "each intern is a distinct snapshot");
    }

    #[test]
    fn get_returns_interned_file() {
        let map = SourceMap::new();
        let id = map.intern("day.px", "out(1)\n");
        let view = map.get(id).expect("file was just interned");
        assert_eq!(view.path(), Path::new("day.px"));
        assert_eq!(view.text(), "out(1)\n");
        assert_eq!(view.id(), id);
    }

    #[test]
    fn synthetic_and_unknown_ids_return_none() {
        let map = SourceMap::new();
        assert!(map.get(FileId::SYNTHETIC).is_none());
        assert!(map.get(FileId(0)).is_none()); // no files interned
    }

    #[test]
    fn full_span_covers_whole_file() {
        let map = SourceMap::new();
        let id = map.intern("f.px", "abc");
        let view = map.get(id).unwrap();
        let span = view.full_span();
        assert_eq!(span.file, id);
        assert_eq!(span.span.start().to_u32(), 0);
        assert_eq!(span.span.end().to_u32(), 3);
    }

    #[test]
    fn empty_map_reports_empty() {
        let map = SourceMap::new();
        assert!(map.is_empty());
        assert_eq!(map.len(), 0);
    }

    /// A live view must survive a reallocating `intern`. Violating this is UB
    /// rather than a wrong answer, so only Miri would fail on it; it is in the
    /// ordinary suite because shared ownership is observable without Miri.
    #[test]
    fn regression_file_view_remains_valid_when_more_files_are_interned() {
        let map = SourceMap::new();
        let first = map.intern("first.px", "stable");
        let view = map.get(first).expect("first file exists");

        // Force the backing Vec through several reallocations while `view`
        // remains live: the `Arc` is what keeps the file's address stable
        // through them.
        for i in 0..4_096 {
            map.intern(format!("later-{i}.px"), format!("revision {i}"));
        }

        assert_eq!(view.text(), "stable");
        assert_eq!(view.path(), Path::new("first.px"));
    }
}