Skip to main content

submilli_engine/
source.rs

1//! The source registry: maps a [`FileId`] to its source text and display path.
2//!
3//! Every [`Span`](crate::Span) carries a `FileId` indexing into a [`Sources`].
4//! Single-file scripts build a registry with one entry; multi-file packages add
5//! one per module. Diagnostics resolve a span back to `(path, text, line index)`
6//! through this registry so they can render `--> util.subm:5:12` for the right file.
7
8use crate::compiler_error::{CompilerFailure, CompilerStage};
9use crate::span::{FileId, LineIndex, Span};
10
11#[derive(Debug)]
12pub enum SourceError {
13    InvalidSpan { span: Span, reason: &'static str },
14    InvalidOffset { offset: u32 },
15    InvalidPosition { line: u32, col: u32 },
16    UnknownFile { file: FileId },
17    SourceLimit { len: usize },
18    FileLimit { len: usize },
19    Allocation(std::collections::TryReserveError),
20}
21
22impl SourceError {
23    pub const MAX_SOURCE_BYTES: usize = (u32::MAX - 4) as usize;
24
25    pub fn check_source_len(len: usize) -> Result<(), Self> {
26        if len > Self::MAX_SOURCE_BYTES {
27            return Err(Self::SourceLimit { len });
28        }
29        Ok(())
30    }
31
32    pub fn into_compiler_failure(self, stage: CompilerStage) -> CompilerFailure {
33        let message = self.to_string();
34        match self {
35            Self::SourceLimit { .. } | Self::FileLimit { .. } | Self::Allocation(_) => {
36                CompilerFailure::Limit {
37                    stage,
38                    span: None,
39                    message,
40                    help: vec!["split the source into smaller modules".into()],
41                }
42            }
43            _ => CompilerFailure::Internal {
44                stage,
45                span: None,
46                message,
47            },
48        }
49    }
50}
51
52impl std::fmt::Display for SourceError {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        match self {
55            Self::InvalidSpan { span, reason } => {
56                write!(f, "invalid source span {span:?}: {reason}")
57            }
58            Self::InvalidOffset { offset } => write!(f, "invalid source byte offset {offset}"),
59            Self::InvalidPosition { line, col } => {
60                write!(f, "invalid source position {line}:{col}")
61            }
62            Self::UnknownFile { file } => write!(f, "unknown source file {}", file.0),
63            Self::SourceLimit { len } => {
64                write!(f, "source length {len} exceeds the supported byte limit")
65            }
66            Self::FileLimit { len } => {
67                write!(f, "source file count {len} reaches the reserved file IDs")
68            }
69            Self::Allocation(error) => write!(f, "cannot reserve source storage: {error}"),
70        }
71    }
72}
73
74impl std::error::Error for SourceError {}
75
76/// A module's package-relative display path (e.g. `util`, `internal/math`). For
77/// single-file scripts this is just the script filename. A thin newtype today;
78/// later multi-file steps extend it with resolution helpers.
79#[derive(
80    Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
81)]
82pub struct ModulePath(String);
83
84impl ModulePath {
85    pub fn as_str(&self) -> &str {
86        &self.0
87    }
88
89    /// Resolve a relative-path import specifier (`./util`, `../math`) against
90    /// `self`, the *importing* module's package-relative path. Resolution is
91    /// directory-relative like ES/Node: the base is the importer's directory —
92    /// `self` minus its last path segment.
93    ///
94    /// A segment stack seeded with that directory walks the specifier's
95    /// `/`-split segments: `.` and empty segments are skipped, `..` pops (an
96    /// empty stack means it climbed above the package root → [`EscapesRoot`]),
97    /// anything else is pushed. A trailing `.ts` or `.subm` extension is stripped. The
98    /// result has no `./`, no `..`, no source extension, and `/` separators.
99    ///
100    /// Callers must classify with [`is_relative_specifier`] first — a package
101    /// specifier passed here would be misread as a path.
102    ///
103    /// [`EscapesRoot`]: RelativeImportError::EscapesRoot
104    pub fn resolve_relative(&self, specifier: &str) -> Result<ModulePath, RelativeImportError> {
105        let mut stack: Vec<&str> = self
106            .0
107            .rsplit_once('/')
108            .map_or(Vec::new(), |(dir, _last)| dir.split('/').collect());
109        for segment in specifier.split('/') {
110            match segment {
111                "" | "." => {}
112                ".." => {
113                    stack.pop().ok_or(RelativeImportError::EscapesRoot)?;
114                }
115                other => stack.push(other),
116            }
117        }
118        let joined = stack.join("/");
119        let canonical = joined
120            .strip_suffix(".ts")
121            .or_else(|| joined.strip_suffix(".subm"))
122            .unwrap_or(&joined);
123        Ok(ModulePath(canonical.to_string()))
124    }
125}
126
127/// Error from resolving a relative import specifier against an importer's path.
128#[derive(Clone, Copy, Debug, PartialEq, Eq)]
129pub enum RelativeImportError {
130    /// A `..` segment climbed above the package's root module.
131    EscapesRoot,
132}
133
134/// True for ES-style relative-path specifiers (`./…`, `../…`). Package
135/// specifiers (`submilli:http`, `@org/pkg`, `@mcp/x`) never match — none start
136/// with `./` or `../`. Bare `.`/`..` (no trailing `/`) are intentionally not
137/// treated as relative imports.
138pub fn is_relative_specifier(specifier: &str) -> bool {
139    specifier.starts_with("./") || specifier.starts_with("../")
140}
141
142impl From<&str> for ModulePath {
143    fn from(s: &str) -> Self {
144        ModulePath(s.to_string())
145    }
146}
147
148impl From<String> for ModulePath {
149    fn from(s: String) -> Self {
150        ModulePath(s)
151    }
152}
153
154impl std::fmt::Display for ModulePath {
155    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156        f.write_str(&self.0)
157    }
158}
159
160/// One source file: its display path, full text, and a pre-built line index for
161/// span → line/col resolution.
162pub struct SourceFile {
163    pub path: ModulePath,
164    id: FileId,
165    line_index: LineIndex,
166}
167
168impl SourceFile {
169    pub fn text(&self) -> &str {
170        self.line_index.source()
171    }
172
173    pub fn span_text(&self, span: Span) -> Result<&str, SourceError> {
174        span.text(self.text(), self.id)
175    }
176
177    pub fn line_index(&self) -> &LineIndex {
178        &self.line_index
179    }
180}
181
182/// The path a backtrace frame in a package module carries, e.g. `@acme/util/lib`.
183/// Module paths such as `lib` repeat across packages and a frame finds its
184/// source by path, so codegen qualifies a package's debug paths with its name
185/// and embedders register the package's sources under the same paths
186/// ([`Sources::add_package_module`]). Compile diagnostics keep the module path.
187pub(crate) fn package_frame_path(package: &str, module: &str) -> String {
188    format!("{package}/{module}")
189}
190
191/// Registry of every source file in a compilation, indexed by [`FileId`].
192#[derive(Default)]
193pub struct Sources {
194    files: Vec<SourceFile>,
195}
196
197impl Sources {
198    pub fn new() -> Self {
199        Self::default()
200    }
201
202    /// Register a file and return its `FileId`.
203    pub fn add(
204        &mut self,
205        path: impl Into<ModulePath>,
206        text: impl AsRef<str>,
207    ) -> Result<FileId, SourceError> {
208        let id = next_file_id(self.files.len())?;
209        let line_index = LineIndex::new(text.as_ref())?;
210        self.files.try_reserve(1).map_err(SourceError::Allocation)?;
211        self.files.push(SourceFile {
212            path: path.into(),
213            id,
214            line_index,
215        });
216        Ok(id)
217    }
218
219    /// Register a package module under the path its backtrace frames carry
220    /// ([`package_frame_path`]), so they render with its source.
221    pub fn add_package_module(
222        &mut self,
223        package: &str,
224        module: &str,
225        text: impl AsRef<str>,
226    ) -> Result<FileId, SourceError> {
227        self.add(package_frame_path(package, module), text)
228    }
229
230    pub fn get(&self, id: FileId) -> Option<&SourceFile> {
231        self.files.get(id.0 as usize)
232    }
233
234    pub fn find_path(&self, path: &str) -> Option<(FileId, &SourceFile)> {
235        self.files
236            .iter()
237            .find(|file| file.path.as_str() == path)
238            .map(|file| (file.id, file))
239    }
240
241    /// Convenience for the single-file path: a registry with one entry plus its `FileId`.
242    pub fn single(
243        path: impl Into<ModulePath>,
244        text: impl AsRef<str>,
245    ) -> Result<(Self, FileId), SourceError> {
246        let mut sources = Self::new();
247        let id = sources.add(path, text)?;
248        Ok((sources, id))
249    }
250}
251
252fn next_file_id(len: usize) -> Result<FileId, SourceError> {
253    u32::try_from(len)
254        .ok()
255        .filter(|id| *id < FileId::FIRST_RESERVED)
256        .map(FileId)
257        .ok_or(SourceError::FileLimit { len })
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    #[test]
265    fn checked_source_ids_stop_before_reserved_range() {
266        let limit = FileId::FIRST_RESERVED as usize;
267        assert_eq!(
268            next_file_id(limit - 1).unwrap().0,
269            FileId::FIRST_RESERVED - 1
270        );
271        assert!(matches!(
272            next_file_id(limit),
273            Err(SourceError::FileLimit { .. })
274        ));
275        assert!(next_file_id(usize::MAX).is_err());
276    }
277
278    #[test]
279    fn add_assigns_sequential_ids() {
280        let mut sources = Sources::new();
281        let a = sources.add("a.subm", "let x = 1;").unwrap();
282        let b = sources.add("b.subm", "let y = 2;").unwrap();
283        assert_eq!(a, FileId(0));
284        assert_eq!(b, FileId(1));
285    }
286
287    #[test]
288    fn get_resolves_path_and_text() {
289        let (sources, id) = Sources::single("script.subm", "let x = 1;").unwrap();
290        let file = sources.get(id).unwrap();
291        assert_eq!(file.path.as_str(), "script.subm");
292        assert_eq!(file.text(), "let x = 1;");
293    }
294
295    #[test]
296    fn get_unknown_file_is_none() {
297        let sources = Sources::new();
298        assert!(sources.get(FileId(0)).is_none());
299        // Reserved ids are never stored in the registry.
300        assert!(sources.get(FileId::PRELUDE).is_none());
301    }
302
303    #[test]
304    fn find_path_returns_file_id_and_source() {
305        let mut sources = Sources::new();
306        sources.add("lib", "export const a = 1;").unwrap();
307        let util = sources.add("util", "export const b = 2;").unwrap();
308
309        let (id, file) = sources.find_path("util").expect("find util");
310
311        assert_eq!(id, util);
312        assert_eq!(file.text(), "export const b = 2;");
313        assert!(sources.find_path("missing").is_none());
314    }
315
316    fn resolve(importer: &str, specifier: &str) -> Result<String, RelativeImportError> {
317        ModulePath::from(importer)
318            .resolve_relative(specifier)
319            .map(|m| m.as_str().to_string())
320    }
321
322    #[test]
323    fn resolve_relative_canonicalizes_against_importer_dir() {
324        assert_eq!(resolve("lib", "./util").unwrap(), "util");
325        assert_eq!(
326            resolve("internal/handlers", "./util").unwrap(),
327            "internal/util"
328        );
329        assert_eq!(resolve("internal/handlers", "../math").unwrap(), "math");
330        assert_eq!(
331            resolve("internal/handlers", "../shared/x").unwrap(),
332            "shared/x"
333        );
334        assert_eq!(resolve("a/b/c", "./d/../e").unwrap(), "a/b/e");
335    }
336
337    #[test]
338    fn resolve_relative_strips_subm_suffix() {
339        assert_eq!(resolve("util", "./mod.subm").unwrap(), "mod");
340    }
341
342    #[test]
343    fn resolve_relative_strips_ts_suffix() {
344        assert_eq!(resolve("util", "./mod.ts").unwrap(), "mod");
345    }
346
347    #[test]
348    fn resolve_relative_rejects_escaping_root() {
349        assert_eq!(
350            resolve("lib", "../x"),
351            Err(RelativeImportError::EscapesRoot)
352        );
353        assert_eq!(
354            resolve("util", "../../x"),
355            Err(RelativeImportError::EscapesRoot)
356        );
357    }
358
359    #[test]
360    fn is_relative_specifier_classifies_dot_prefixes_only() {
361        assert!(is_relative_specifier("./util"));
362        assert!(is_relative_specifier("../math"));
363        assert!(!is_relative_specifier("submilli:http"));
364        assert!(!is_relative_specifier("@org/pkg"));
365        assert!(!is_relative_specifier("@mcp/linear"));
366        assert!(!is_relative_specifier("util"));
367        // Bare `.` / `..` are deliberately not relative imports.
368        assert!(!is_relative_specifier("."));
369        assert!(!is_relative_specifier(".."));
370    }
371}