Skip to main content

bamts_compiler/
project.rs

1use crate::lint::{LintConfig, LintLevel, LintSetting};
2
3use std::{
4    fmt,
5    path::{Component, Path, PathBuf},
6    sync::Arc,
7};
8
9const MAX_JSON_DEPTH: usize = 128;
10
11/// A path operation that would make a project depend on the ambient working directory
12/// or access a path outside its declared root.
13#[derive(Clone, Debug, Eq, PartialEq)]
14pub enum PathError {
15    RootIsNotAbsolute { path: PathBuf },
16    PathEscapesRoot { root: PathBuf, path: PathBuf },
17    PathHasNoParent { path: PathBuf },
18}
19
20impl fmt::Display for PathError {
21    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
22        match self {
23            Self::RootIsNotAbsolute { path } => {
24                write!(
25                    formatter,
26                    "project root must be absolute: {}",
27                    path.display()
28                )
29            }
30            Self::PathEscapesRoot { root, path } => write!(
31                formatter,
32                "path {} escapes project root {}",
33                path.display(),
34                root.display()
35            ),
36            Self::PathHasNoParent { path } => {
37                write!(formatter, "module path has no parent: {}", path.display())
38            }
39        }
40    }
41}
42
43impl std::error::Error for PathError {}
44
45/// A normalized immutable project boundary.
46///
47/// Construction and resolution are lexical: neither operation requires the path to
48/// exist. Every path returned by this type is absolute, normalized, and confined.
49#[derive(Clone, Debug, Eq, Hash, PartialEq)]
50pub struct ProjectRoot {
51    path: PathBuf,
52}
53
54impl ProjectRoot {
55    /// Creates a project boundary from an absolute path without touching the file system.
56    pub fn new(path: impl AsRef<Path>) -> Result<Self, PathError> {
57        let path = path.as_ref();
58        if !path.is_absolute() {
59            return Err(PathError::RootIsNotAbsolute {
60                path: path.to_path_buf(),
61            });
62        }
63        Ok(Self {
64            path: normalize_absolute(path),
65        })
66    }
67
68    /// Returns the normalized absolute root.
69    #[must_use]
70    pub fn path(&self) -> &Path {
71        &self.path
72    }
73
74    /// Resolves a root-relative or already-absolute path and rejects root escapes.
75    pub fn resolve(&self, path: impl AsRef<Path>) -> Result<PathBuf, PathError> {
76        self.resolve_from(&self.path, path)
77    }
78
79    /// Resolves a path relative to a confined absolute directory.
80    pub fn resolve_from(
81        &self,
82        directory: impl AsRef<Path>,
83        path: impl AsRef<Path>,
84    ) -> Result<PathBuf, PathError> {
85        let directory = self.confine(directory)?;
86        let path = path.as_ref();
87        let joined = if path.is_absolute() {
88            path.to_path_buf()
89        } else {
90            directory.join(path)
91        };
92        self.confine(joined)
93    }
94
95    /// Normalizes an absolute path and verifies that it belongs to this project.
96    pub fn confine(&self, path: impl AsRef<Path>) -> Result<PathBuf, PathError> {
97        let original = path.as_ref();
98        let absolute = if original.is_absolute() {
99            normalize_absolute(original)
100        } else {
101            normalize_absolute(&self.path.join(original))
102        };
103        if absolute.starts_with(&self.path) {
104            Ok(absolute)
105        } else {
106            Err(PathError::PathEscapesRoot {
107                root: self.path.clone(),
108                path: absolute,
109            })
110        }
111    }
112}
113
114fn normalize_absolute(path: &Path) -> PathBuf {
115    let mut normalized = PathBuf::new();
116    for component in path.components() {
117        match component {
118            Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
119            Component::RootDir => normalized.push(component.as_os_str()),
120            Component::CurDir => {}
121            Component::ParentDir => {
122                normalized.pop();
123            }
124            Component::Normal(part) => normalized.push(part),
125        }
126    }
127    normalized
128}
129
130/// The reason a JSON-with-comments document was rejected.
131#[derive(Clone, Debug, Eq, PartialEq)]
132pub enum JsoncErrorKind {
133    UnexpectedEnd,
134    UnexpectedToken,
135    UnterminatedBlockComment,
136    UnterminatedString,
137    InvalidEscape,
138    InvalidUnicodeEscape,
139    LoneSurrogate,
140    UnescapedControlCharacter,
141    InvalidNumber,
142    DuplicateObjectKey { key: Arc<str> },
143    TrailingCharacters,
144    NestingTooDeep,
145}
146
147/// An offset-bearing JSONC parse error. `offset` is a UTF-8 byte offset into the
148/// original document, which makes it safe to use directly with file I/O diagnostics.
149#[derive(Clone, Debug, Eq, PartialEq)]
150pub struct JsoncError {
151    offset: usize,
152    kind: JsoncErrorKind,
153}
154
155impl JsoncError {
156    #[must_use]
157    pub const fn offset(&self) -> usize {
158        self.offset
159    }
160
161    #[must_use]
162    pub const fn kind(&self) -> &JsoncErrorKind {
163        &self.kind
164    }
165}
166
167impl fmt::Display for JsoncError {
168    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
169        write!(formatter, "JSONC error at byte {}: ", self.offset)?;
170        match &self.kind {
171            JsoncErrorKind::UnexpectedEnd => formatter.write_str("unexpected end of input"),
172            JsoncErrorKind::UnexpectedToken => formatter.write_str("unexpected token"),
173            JsoncErrorKind::UnterminatedBlockComment => {
174                formatter.write_str("unterminated block comment")
175            }
176            JsoncErrorKind::UnterminatedString => formatter.write_str("unterminated string"),
177            JsoncErrorKind::InvalidEscape => formatter.write_str("invalid string escape"),
178            JsoncErrorKind::InvalidUnicodeEscape => formatter.write_str("invalid Unicode escape"),
179            JsoncErrorKind::LoneSurrogate => formatter.write_str("lone UTF-16 surrogate"),
180            JsoncErrorKind::UnescapedControlCharacter => {
181                formatter.write_str("unescaped control character in string")
182            }
183            JsoncErrorKind::InvalidNumber => formatter.write_str("invalid number"),
184            JsoncErrorKind::DuplicateObjectKey { key } => {
185                write!(formatter, "duplicate object key {key:?}")
186            }
187            JsoncErrorKind::TrailingCharacters => {
188                formatter.write_str("characters follow the root value")
189            }
190            JsoncErrorKind::NestingTooDeep => formatter.write_str("nesting limit exceeded"),
191        }
192    }
193}
194
195impl std::error::Error for JsoncError {}
196
197/// An immutable JSON object that preserves declaration order for package condition maps.
198#[derive(Clone, Debug, Eq, PartialEq)]
199pub struct JsonObject {
200    entries: Arc<[(Arc<str>, JsonValue)]>,
201}
202
203impl JsonObject {
204    #[must_use]
205    pub fn get(&self, key: &str) -> Option<&JsonValue> {
206        self.entries
207            .iter()
208            .find_map(|(candidate, value)| (candidate.as_ref() == key).then_some(value))
209    }
210
211    #[must_use]
212    pub fn entries(&self) -> &[(Arc<str>, JsonValue)] {
213        &self.entries
214    }
215}
216
217/// A dependency-free immutable JSON value used for tsconfig and package metadata.
218#[derive(Clone, Debug, Eq, PartialEq)]
219pub enum JsonValue {
220    Null,
221    Bool(bool),
222    Number(Arc<str>),
223    String(Arc<str>),
224    Array(Arc<[JsonValue]>),
225    Object(JsonObject),
226}
227
228impl JsonValue {
229    #[must_use]
230    pub const fn as_object(&self) -> Option<&JsonObject> {
231        if let Self::Object(value) = self {
232            Some(value)
233        } else {
234            None
235        }
236    }
237
238    #[must_use]
239    pub fn as_str(&self) -> Option<&str> {
240        if let Self::String(value) = self {
241            Some(value)
242        } else {
243            None
244        }
245    }
246
247    #[must_use]
248    pub const fn as_bool(&self) -> Option<bool> {
249        if let Self::Bool(value) = self {
250            Some(*value)
251        } else {
252            None
253        }
254    }
255
256    #[must_use]
257    pub fn as_array(&self) -> Option<&[JsonValue]> {
258        if let Self::Array(value) = self {
259            Some(value)
260        } else {
261            None
262        }
263    }
264}
265
266/// Parses strict JSON plus line comments, block comments, and trailing commas.
267/// Comments inside strings remain ordinary string content.
268pub fn parse_jsonc(source: &str) -> Result<JsonValue, JsoncError> {
269    JsoncParser::new(source).parse()
270}
271
272struct JsoncParser<'a> {
273    source: &'a str,
274    bytes: &'a [u8],
275    position: usize,
276}
277
278impl<'a> JsoncParser<'a> {
279    const fn new(source: &'a str) -> Self {
280        Self {
281            source,
282            bytes: source.as_bytes(),
283            position: 0,
284        }
285    }
286
287    fn parse(mut self) -> Result<JsonValue, JsoncError> {
288        self.skip_trivia()?;
289        let value = self.parse_value(0)?;
290        self.skip_trivia()?;
291        if self.position != self.bytes.len() {
292            return self.error(JsoncErrorKind::TrailingCharacters);
293        }
294        Ok(value)
295    }
296
297    fn parse_value(&mut self, depth: usize) -> Result<JsonValue, JsoncError> {
298        if depth > MAX_JSON_DEPTH {
299            return self.error(JsoncErrorKind::NestingTooDeep);
300        }
301        self.skip_trivia()?;
302        match self.peek() {
303            Some(b'{') => self.parse_object(depth + 1),
304            Some(b'[') => self.parse_array(depth + 1),
305            Some(b'"') => self.parse_string().map(JsonValue::String),
306            Some(b't') => {
307                self.expect_literal(b"true")?;
308                Ok(JsonValue::Bool(true))
309            }
310            Some(b'f') => {
311                self.expect_literal(b"false")?;
312                Ok(JsonValue::Bool(false))
313            }
314            Some(b'n') => {
315                self.expect_literal(b"null")?;
316                Ok(JsonValue::Null)
317            }
318            Some(b'-' | b'0'..=b'9') => self.parse_number().map(JsonValue::Number),
319            Some(_) => self.error(JsoncErrorKind::UnexpectedToken),
320            None => self.error(JsoncErrorKind::UnexpectedEnd),
321        }
322    }
323
324    fn parse_object(&mut self, depth: usize) -> Result<JsonValue, JsoncError> {
325        self.position += 1;
326        self.skip_trivia()?;
327        let mut entries: Vec<(Arc<str>, JsonValue)> = Vec::new();
328        if self.consume(b'}') {
329            return Ok(JsonValue::Object(JsonObject {
330                entries: Arc::from(entries),
331            }));
332        }
333        loop {
334            self.skip_trivia()?;
335            if self.peek() != Some(b'"') {
336                return self.error(JsoncErrorKind::UnexpectedToken);
337            }
338            let key_offset = self.position;
339            let key = self.parse_string()?;
340            if entries
341                .iter()
342                .any(|(existing, _)| existing.as_ref() == key.as_ref())
343            {
344                return Err(JsoncError {
345                    offset: key_offset,
346                    kind: JsoncErrorKind::DuplicateObjectKey { key },
347                });
348            }
349            self.skip_trivia()?;
350            if !self.consume(b':') {
351                return self.error(JsoncErrorKind::UnexpectedToken);
352            }
353            let value = self.parse_value(depth)?;
354            entries.push((key, value));
355            self.skip_trivia()?;
356            if self.consume(b'}') {
357                break;
358            }
359            if !self.consume(b',') {
360                return self.error(JsoncErrorKind::UnexpectedToken);
361            }
362            self.skip_trivia()?;
363            if self.consume(b'}') {
364                break;
365            }
366        }
367        Ok(JsonValue::Object(JsonObject {
368            entries: Arc::from(entries),
369        }))
370    }
371
372    fn parse_array(&mut self, depth: usize) -> Result<JsonValue, JsoncError> {
373        self.position += 1;
374        self.skip_trivia()?;
375        let mut values = Vec::new();
376        if self.consume(b']') {
377            return Ok(JsonValue::Array(Arc::from(values)));
378        }
379        loop {
380            values.push(self.parse_value(depth)?);
381            self.skip_trivia()?;
382            if self.consume(b']') {
383                break;
384            }
385            if !self.consume(b',') {
386                return self.error(JsoncErrorKind::UnexpectedToken);
387            }
388            self.skip_trivia()?;
389            if self.consume(b']') {
390                break;
391            }
392        }
393        Ok(JsonValue::Array(Arc::from(values)))
394    }
395
396    fn parse_string(&mut self) -> Result<Arc<str>, JsoncError> {
397        let opening = self.position;
398        self.position += 1;
399        let content_start = self.position;
400        let mut decoded: Option<String> = None;
401        let mut segment_start = content_start;
402        while let Some(byte) = self.peek() {
403            match byte {
404                b'"' => {
405                    let end = self.position;
406                    self.position += 1;
407                    if let Some(mut text) = decoded {
408                        text.push_str(&self.source[segment_start..end]);
409                        return Ok(Arc::from(text));
410                    }
411                    return Ok(Arc::from(&self.source[content_start..end]));
412                }
413                b'\\' => {
414                    let escape_start = self.position;
415                    let text = decoded.get_or_insert_with(String::new);
416                    text.push_str(&self.source[segment_start..escape_start]);
417                    self.position += 1;
418                    let escape = self.peek().ok_or(JsoncError {
419                        offset: opening,
420                        kind: JsoncErrorKind::UnterminatedString,
421                    })?;
422                    self.position += 1;
423                    match escape {
424                        b'"' => text.push('"'),
425                        b'\\' => text.push('\\'),
426                        b'/' => text.push('/'),
427                        b'b' => text.push('\u{0008}'),
428                        b'f' => text.push('\u{000c}'),
429                        b'n' => text.push('\n'),
430                        b'r' => text.push('\r'),
431                        b't' => text.push('\t'),
432                        b'u' => {
433                            let first_offset = self.position;
434                            let first = self.parse_hex_quad()?;
435                            let scalar = if (0xd800..=0xdbff).contains(&first) {
436                                if self.bytes.get(self.position..self.position + 2) != Some(b"\\u")
437                                {
438                                    return Err(JsoncError {
439                                        offset: first_offset,
440                                        kind: JsoncErrorKind::LoneSurrogate,
441                                    });
442                                }
443                                self.position += 2;
444                                let second_offset = self.position;
445                                let second = self.parse_hex_quad()?;
446                                if !(0xdc00..=0xdfff).contains(&second) {
447                                    return Err(JsoncError {
448                                        offset: second_offset,
449                                        kind: JsoncErrorKind::LoneSurrogate,
450                                    });
451                                }
452                                0x1_0000
453                                    + ((u32::from(first) - 0xd800) << 10)
454                                    + (u32::from(second) - 0xdc00)
455                            } else if (0xdc00..=0xdfff).contains(&first) {
456                                return Err(JsoncError {
457                                    offset: first_offset,
458                                    kind: JsoncErrorKind::LoneSurrogate,
459                                });
460                            } else {
461                                u32::from(first)
462                            };
463                            let character = char::from_u32(scalar).ok_or(JsoncError {
464                                offset: first_offset,
465                                kind: JsoncErrorKind::InvalidUnicodeEscape,
466                            })?;
467                            text.push(character);
468                        }
469                        _ => {
470                            return Err(JsoncError {
471                                offset: escape_start,
472                                kind: JsoncErrorKind::InvalidEscape,
473                            });
474                        }
475                    }
476                    segment_start = self.position;
477                }
478                0x00..=0x1f => {
479                    return self.error(JsoncErrorKind::UnescapedControlCharacter);
480                }
481                _ => {
482                    let character =
483                        self.source[self.position..]
484                            .chars()
485                            .next()
486                            .ok_or(JsoncError {
487                                offset: opening,
488                                kind: JsoncErrorKind::UnterminatedString,
489                            })?;
490                    self.position += character.len_utf8();
491                }
492            }
493        }
494        Err(JsoncError {
495            offset: opening,
496            kind: JsoncErrorKind::UnterminatedString,
497        })
498    }
499
500    fn parse_hex_quad(&mut self) -> Result<u16, JsoncError> {
501        let start = self.position;
502        let end = start.saturating_add(4);
503        let digits = self.bytes.get(start..end).ok_or(JsoncError {
504            offset: start,
505            kind: JsoncErrorKind::InvalidUnicodeEscape,
506        })?;
507        let mut value = 0_u16;
508        for &digit in digits {
509            value = value
510                .checked_mul(16)
511                .and_then(|prefix| hex_value(digit).map(|suffix| prefix + suffix))
512                .ok_or(JsoncError {
513                    offset: start,
514                    kind: JsoncErrorKind::InvalidUnicodeEscape,
515                })?;
516        }
517        self.position = end;
518        Ok(value)
519    }
520
521    fn parse_number(&mut self) -> Result<Arc<str>, JsoncError> {
522        let start = self.position;
523        self.consume(b'-');
524        match self.peek() {
525            Some(b'0') => {
526                self.position += 1;
527                if matches!(self.peek(), Some(b'0'..=b'9')) {
528                    return self.error(JsoncErrorKind::InvalidNumber);
529                }
530            }
531            Some(b'1'..=b'9') => {
532                self.position += 1;
533                while matches!(self.peek(), Some(b'0'..=b'9')) {
534                    self.position += 1;
535                }
536            }
537            _ => return self.error(JsoncErrorKind::InvalidNumber),
538        }
539        if self.consume(b'.') {
540            if !matches!(self.peek(), Some(b'0'..=b'9')) {
541                return self.error(JsoncErrorKind::InvalidNumber);
542            }
543            while matches!(self.peek(), Some(b'0'..=b'9')) {
544                self.position += 1;
545            }
546        }
547        if matches!(self.peek(), Some(b'e' | b'E')) {
548            self.position += 1;
549            if matches!(self.peek(), Some(b'+' | b'-')) {
550                self.position += 1;
551            }
552            if !matches!(self.peek(), Some(b'0'..=b'9')) {
553                return self.error(JsoncErrorKind::InvalidNumber);
554            }
555            while matches!(self.peek(), Some(b'0'..=b'9')) {
556                self.position += 1;
557            }
558        }
559        Ok(Arc::from(&self.source[start..self.position]))
560    }
561
562    fn expect_literal(&mut self, literal: &[u8]) -> Result<(), JsoncError> {
563        if self.bytes.get(self.position..self.position + literal.len()) == Some(literal) {
564            self.position += literal.len();
565            Ok(())
566        } else {
567            self.error(JsoncErrorKind::UnexpectedToken)
568        }
569    }
570
571    fn skip_trivia(&mut self) -> Result<(), JsoncError> {
572        loop {
573            while matches!(self.peek(), Some(b' ' | b'\t' | b'\r' | b'\n')) {
574                self.position += 1;
575            }
576            match self.bytes.get(self.position..self.position + 2) {
577                Some(b"//") => {
578                    self.position += 2;
579                    while !matches!(self.peek(), None | Some(b'\r' | b'\n')) {
580                        self.position += 1;
581                    }
582                }
583                Some(b"/*") => {
584                    let start = self.position;
585                    self.position += 2;
586                    while self.bytes.get(self.position..self.position + 2) != Some(b"*/") {
587                        if self.position == self.bytes.len() {
588                            return Err(JsoncError {
589                                offset: start,
590                                kind: JsoncErrorKind::UnterminatedBlockComment,
591                            });
592                        }
593                        self.position += 1;
594                    }
595                    self.position += 2;
596                }
597                _ => return Ok(()),
598            }
599        }
600    }
601
602    fn peek(&self) -> Option<u8> {
603        self.bytes.get(self.position).copied()
604    }
605
606    fn consume(&mut self, expected: u8) -> bool {
607        if self.peek() == Some(expected) {
608            self.position += 1;
609            true
610        } else {
611            false
612        }
613    }
614
615    fn error<T>(&self, kind: JsoncErrorKind) -> Result<T, JsoncError> {
616        Err(JsoncError {
617            offset: self.position,
618            kind,
619        })
620    }
621}
622
623const fn hex_value(byte: u8) -> Option<u16> {
624    match byte {
625        b'0'..=b'9' => Some((byte - b'0') as u16),
626        b'a'..=b'f' => Some((byte - b'a' + 10) as u16),
627        b'A'..=b'F' => Some((byte - b'A' + 10) as u16),
628        _ => None,
629    }
630}
631
632/// A syntax or value error in the lint-owned portion of `bamts.toml`.
633#[derive(Clone, Debug, Eq, PartialEq)]
634pub struct BamtsTomlError {
635    line: usize,
636    message: Arc<str>,
637}
638
639impl BamtsTomlError {
640    #[must_use]
641    pub const fn line(&self) -> usize {
642        self.line
643    }
644
645    #[must_use]
646    pub fn message(&self) -> &str {
647        &self.message
648    }
649}
650
651impl fmt::Display for BamtsTomlError {
652    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
653        write!(formatter, "bamts.toml:{}: {}", self.line, self.message)
654    }
655}
656
657impl std::error::Error for BamtsTomlError {}
658
659#[derive(Clone, Copy, Debug, Eq, PartialEq)]
660enum LintTomlSection {
661    Other,
662    Groups,
663    Rules,
664}
665
666/// Parses only `[lints.groups]` and `[lints.rules]` from an already-loaded
667/// `bamts.toml`. Other native BamTS sections remain owned by their subsystems.
668pub fn parse_bamts_toml(source: &str) -> Result<LintConfig, BamtsTomlError> {
669    let mut section = LintTomlSection::Other;
670    let mut groups = Vec::new();
671    let mut rules = Vec::new();
672    for (line_index, raw_line) in source.lines().enumerate() {
673        let line_number = line_index + 1;
674        let line = strip_toml_comment(raw_line).trim();
675        if line.is_empty() {
676            continue;
677        }
678        if line.starts_with('[') {
679            let Some(name) = line
680                .strip_prefix('[')
681                .and_then(|line| line.strip_suffix(']'))
682            else {
683                return Err(bamts_toml_error(line_number, "malformed table header"));
684            };
685            section = match name.trim() {
686                "lints.groups" => LintTomlSection::Groups,
687                "lints.rules" => LintTomlSection::Rules,
688                _ => LintTomlSection::Other,
689            };
690            continue;
691        }
692        if section == LintTomlSection::Other {
693            continue;
694        }
695        let Some((raw_name, raw_level)) = line.split_once('=') else {
696            return Err(bamts_toml_error(
697                line_number,
698                "lint setting must be `name = \"level\"`",
699            ));
700        };
701        let name = parse_toml_atom(raw_name.trim()).ok_or_else(|| {
702            bamts_toml_error(
703                line_number,
704                "lint name must be a non-empty bare or quoted key",
705            )
706        })?;
707        let level_name = parse_toml_atom(raw_level.trim())
708            .ok_or_else(|| bamts_toml_error(line_number, "lint level must be a quoted string"))?;
709        if !raw_level.trim().starts_with('"') {
710            return Err(bamts_toml_error(
711                line_number,
712                "lint level must be a quoted string",
713            ));
714        }
715        let level = level_name.parse::<LintLevel>().map_err(|_| {
716            bamts_toml_error(
717                line_number,
718                "lint level must be one of allow, warn, deny, or forbid",
719            )
720        })?;
721        let setting = LintSetting::new(name, level, format!("bamts.toml:{line_number}"));
722        match section {
723            LintTomlSection::Groups => groups.push(setting),
724            LintTomlSection::Rules => rules.push(setting),
725            LintTomlSection::Other => unreachable!("other sections were skipped"),
726        }
727    }
728    Ok(LintConfig::new(groups, rules))
729}
730
731fn strip_toml_comment(line: &str) -> &str {
732    let mut quoted = false;
733    let mut escaped = false;
734    for (index, character) in line.char_indices() {
735        if escaped {
736            escaped = false;
737            continue;
738        }
739        match character {
740            '\\' if quoted => escaped = true,
741            '"' => quoted = !quoted,
742            '#' if !quoted => return &line[..index],
743            _ => {}
744        }
745    }
746    line
747}
748
749fn parse_toml_atom(value: &str) -> Option<&str> {
750    if let Some(quoted) = value
751        .strip_prefix('"')
752        .and_then(|value| value.strip_suffix('"'))
753    {
754        (!quoted.is_empty() && !quoted.contains(['"', '\\'])).then_some(quoted)
755    } else {
756        (!value.is_empty()
757            && value
758                .bytes()
759                .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')))
760        .then_some(value)
761    }
762}
763
764fn bamts_toml_error(line: usize, message: &'static str) -> BamtsTomlError {
765    BamtsTomlError {
766        line,
767        message: Arc::from(message),
768    }
769}
770
771/// A strict tsconfig schema or confinement failure.
772#[derive(Clone, Debug, Eq, PartialEq)]
773pub enum ConfigError {
774    Json(JsoncError),
775    Path(PathError),
776    RootMustBeObject,
777    InvalidField {
778        field: Arc<str>,
779        expected: &'static str,
780    },
781}
782
783impl fmt::Display for ConfigError {
784    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
785        match self {
786            Self::Json(error) => error.fmt(formatter),
787            Self::Path(error) => error.fmt(formatter),
788            Self::RootMustBeObject => formatter.write_str("tsconfig root must be an object"),
789            Self::InvalidField { field, expected } => {
790                write!(formatter, "tsconfig field {field:?} must be {expected}")
791            }
792        }
793    }
794}
795
796impl std::error::Error for ConfigError {
797    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
798        match self {
799            Self::Json(error) => Some(error),
800            Self::Path(error) => Some(error),
801            Self::RootMustBeObject | Self::InvalidField { .. } => None,
802        }
803    }
804}
805
806impl From<JsoncError> for ConfigError {
807    fn from(error: JsoncError) -> Self {
808        Self::Json(error)
809    }
810}
811
812impl From<PathError> for ConfigError {
813    fn from(error: PathError) -> Self {
814        Self::Path(error)
815    }
816}
817
818/// One immutable `compilerOptions.paths` entry. Targets are normalized absolute
819/// patterns confined to the project root; `*` remains a literal substitution marker.
820#[derive(Clone, Debug, Eq, PartialEq)]
821pub struct PathMapping {
822    pattern: Arc<str>,
823    targets: Arc<[PathBuf]>,
824}
825
826impl PathMapping {
827    #[must_use]
828    pub fn pattern(&self) -> &str {
829        &self.pattern
830    }
831
832    #[must_use]
833    pub fn targets(&self) -> &[PathBuf] {
834        &self.targets
835    }
836}
837/// The deliberately narrow tsconfig view consumed by lint configuration.
838///
839/// No TypeScript strictness switch changes BamTS lint levels; those live only in
840/// `bamts.toml` and the BamTS profile/CLI surface.
841#[derive(Clone, Debug, Eq, PartialEq)]
842pub struct LintTsConfig {
843    target: Option<Arc<str>>,
844    module: Option<Arc<str>>,
845    module_resolution: Option<Arc<str>>,
846    paths: Arc<[PathMapping]>,
847}
848
849impl LintTsConfig {
850    /// Parses only `paths`, `target`, `module`, and `moduleResolution`.
851    pub fn parse(
852        root: &ProjectRoot,
853        config_path: impl AsRef<Path>,
854        source: &str,
855    ) -> Result<Self, ConfigError> {
856        let path = root.confine(config_path)?;
857        let directory = path
858            .parent()
859            .ok_or_else(|| PathError::PathHasNoParent { path: path.clone() })?;
860        let raw = parse_jsonc(source)?
861            .as_object()
862            .ok_or(ConfigError::RootMustBeObject)?
863            .clone();
864        let compiler = match raw.get("compilerOptions") {
865            None => None,
866            Some(value) => Some(value.as_object().ok_or_else(|| ConfigError::InvalidField {
867                field: Arc::from("compilerOptions"),
868                expected: "an object",
869            })?),
870        };
871        Ok(Self {
872            target: optional_nested_string(compiler, "target")?,
873            module: optional_nested_string(compiler, "module")?,
874            module_resolution: optional_nested_string(compiler, "moduleResolution")?,
875            paths: parse_path_mappings(root, directory, compiler)?,
876        })
877    }
878
879    #[must_use]
880    pub fn target(&self) -> Option<&str> {
881        self.target.as_deref()
882    }
883
884    #[must_use]
885    pub fn module(&self) -> Option<&str> {
886        self.module.as_deref()
887    }
888
889    #[must_use]
890    pub fn module_resolution(&self) -> Option<&str> {
891        self.module_resolution.as_deref()
892    }
893
894    #[must_use]
895    pub fn paths(&self) -> &[PathMapping] {
896        &self.paths
897    }
898}
899
900/// The compiler options needed by deterministic project and module resolution.
901/// Unknown compiler options remain available through [`ProjectConfig::raw`] so this
902/// foundation does not silently reinterpret options owned by later compiler phases.
903#[derive(Clone, Debug, Eq, PartialEq)]
904pub struct CompilerOptions {
905    target: Option<Arc<str>>,
906    module: Option<Arc<str>>,
907    module_resolution: Option<Arc<str>>,
908    jsx: Option<Arc<str>>,
909    strict: bool,
910    allow_js: bool,
911    check_js: bool,
912    resolve_json_module: bool,
913    base_url: PathBuf,
914    root_dir: Option<PathBuf>,
915    out_dir: Option<PathBuf>,
916    paths: Arc<[PathMapping]>,
917}
918
919impl CompilerOptions {
920    #[must_use]
921    pub fn target(&self) -> Option<&str> {
922        self.target.as_deref()
923    }
924
925    #[must_use]
926    pub fn module(&self) -> Option<&str> {
927        self.module.as_deref()
928    }
929
930    #[must_use]
931    pub fn module_resolution(&self) -> Option<&str> {
932        self.module_resolution.as_deref()
933    }
934
935    #[must_use]
936    pub fn jsx(&self) -> Option<&str> {
937        self.jsx.as_deref()
938    }
939
940    #[must_use]
941    pub const fn strict(&self) -> bool {
942        self.strict
943    }
944
945    #[must_use]
946    pub const fn allow_js(&self) -> bool {
947        self.allow_js
948    }
949
950    #[must_use]
951    pub const fn check_js(&self) -> bool {
952        self.check_js
953    }
954
955    #[must_use]
956    pub const fn resolve_json_module(&self) -> bool {
957        self.resolve_json_module
958    }
959
960    #[must_use]
961    pub fn base_url(&self) -> &Path {
962        &self.base_url
963    }
964
965    #[must_use]
966    pub fn root_dir(&self) -> Option<&Path> {
967        self.root_dir.as_deref()
968    }
969
970    #[must_use]
971    pub fn out_dir(&self) -> Option<&Path> {
972        self.out_dir.as_deref()
973    }
974
975    #[must_use]
976    pub fn paths(&self) -> &[PathMapping] {
977        &self.paths
978    }
979}
980
981/// Immutable, validated tsconfig metadata. Parsing performs no reads and does not
982/// resolve `extends`; callers can load that named document under their own I/O policy.
983#[derive(Clone, Debug, Eq, PartialEq)]
984pub struct ProjectConfig {
985    path: PathBuf,
986    extends: Option<Arc<str>>,
987    files: Arc<[PathBuf]>,
988    include: Arc<[Arc<str>]>,
989    exclude: Arc<[Arc<str>]>,
990    options: CompilerOptions,
991    raw: JsonObject,
992}
993
994impl ProjectConfig {
995    /// Parses one already-loaded tsconfig and confines every concrete path it names.
996    pub fn parse(
997        root: &ProjectRoot,
998        config_path: impl AsRef<Path>,
999        source: &str,
1000    ) -> Result<Self, ConfigError> {
1001        let path = root.confine(config_path)?;
1002        let directory = path
1003            .parent()
1004            .ok_or_else(|| PathError::PathHasNoParent { path: path.clone() })?;
1005        let value = parse_jsonc(source)?;
1006        let raw = value
1007            .as_object()
1008            .ok_or(ConfigError::RootMustBeObject)?
1009            .clone();
1010        let extends = optional_string(&raw, "extends")?;
1011        let files = path_list(root, directory, &raw, "files")?;
1012        let include = string_list(&raw, "include")?;
1013        let exclude = string_list(&raw, "exclude")?;
1014        validate_patterns(root, directory, "include", &include)?;
1015        validate_patterns(root, directory, "exclude", &exclude)?;
1016
1017        let compiler = match raw.get("compilerOptions") {
1018            None => None,
1019            Some(value) => Some(value.as_object().ok_or_else(|| ConfigError::InvalidField {
1020                field: Arc::from("compilerOptions"),
1021                expected: "an object",
1022            })?),
1023        };
1024        let base_url = optional_path(root, directory, compiler, "baseUrl")?
1025            .unwrap_or_else(|| directory.to_path_buf());
1026        let paths = parse_path_mappings(root, &base_url, compiler)?;
1027        let options = CompilerOptions {
1028            target: optional_nested_string(compiler, "target")?,
1029            module: optional_nested_string(compiler, "module")?,
1030            module_resolution: optional_nested_string(compiler, "moduleResolution")?,
1031            jsx: optional_nested_string(compiler, "jsx")?,
1032            strict: optional_bool(compiler, "strict")?.unwrap_or(false),
1033            allow_js: optional_bool(compiler, "allowJs")?.unwrap_or(false),
1034            check_js: optional_bool(compiler, "checkJs")?.unwrap_or(false),
1035            resolve_json_module: optional_bool(compiler, "resolveJsonModule")?.unwrap_or(false),
1036            base_url,
1037            root_dir: optional_path(root, directory, compiler, "rootDir")?,
1038            out_dir: optional_path(root, directory, compiler, "outDir")?,
1039            paths,
1040        };
1041        Ok(Self {
1042            path,
1043            extends,
1044            files,
1045            include,
1046            exclude,
1047            options,
1048            raw,
1049        })
1050    }
1051
1052    #[must_use]
1053    pub fn path(&self) -> &Path {
1054        &self.path
1055    }
1056
1057    #[must_use]
1058    pub fn extends(&self) -> Option<&str> {
1059        self.extends.as_deref()
1060    }
1061
1062    #[must_use]
1063    pub fn files(&self) -> &[PathBuf] {
1064        &self.files
1065    }
1066
1067    #[must_use]
1068    pub fn include(&self) -> &[Arc<str>] {
1069        &self.include
1070    }
1071
1072    #[must_use]
1073    pub fn exclude(&self) -> &[Arc<str>] {
1074        &self.exclude
1075    }
1076
1077    #[must_use]
1078    pub const fn options(&self) -> &CompilerOptions {
1079        &self.options
1080    }
1081
1082    #[must_use]
1083    pub const fn raw(&self) -> &JsonObject {
1084        &self.raw
1085    }
1086}
1087
1088fn invalid_field(field: impl Into<Arc<str>>, expected: &'static str) -> ConfigError {
1089    ConfigError::InvalidField {
1090        field: field.into(),
1091        expected,
1092    }
1093}
1094
1095fn optional_string(
1096    object: &JsonObject,
1097    key: &'static str,
1098) -> Result<Option<Arc<str>>, ConfigError> {
1099    object
1100        .get(key)
1101        .map(|value| {
1102            value
1103                .as_str()
1104                .map(Arc::from)
1105                .ok_or_else(|| invalid_field(key, "a string"))
1106        })
1107        .transpose()
1108}
1109
1110fn optional_nested_string(
1111    object: Option<&JsonObject>,
1112    key: &'static str,
1113) -> Result<Option<Arc<str>>, ConfigError> {
1114    object.map_or(Ok(None), |object| optional_string(object, key))
1115}
1116
1117fn optional_bool(
1118    object: Option<&JsonObject>,
1119    key: &'static str,
1120) -> Result<Option<bool>, ConfigError> {
1121    object
1122        .and_then(|object| object.get(key))
1123        .map(|value| {
1124            value
1125                .as_bool()
1126                .ok_or_else(|| invalid_field(key, "a boolean"))
1127        })
1128        .transpose()
1129}
1130
1131fn optional_path(
1132    root: &ProjectRoot,
1133    directory: &Path,
1134    object: Option<&JsonObject>,
1135    key: &'static str,
1136) -> Result<Option<PathBuf>, ConfigError> {
1137    object
1138        .and_then(|object| object.get(key))
1139        .map(|value| {
1140            let value = value
1141                .as_str()
1142                .ok_or_else(|| invalid_field(key, "a path string"))?;
1143            root.resolve_from(directory, value)
1144                .map_err(ConfigError::from)
1145        })
1146        .transpose()
1147}
1148
1149fn string_list(object: &JsonObject, key: &'static str) -> Result<Arc<[Arc<str>]>, ConfigError> {
1150    let Some(value) = object.get(key) else {
1151        return Ok(Arc::from([]));
1152    };
1153    let values = value
1154        .as_array()
1155        .ok_or_else(|| invalid_field(key, "an array of strings"))?;
1156    values
1157        .iter()
1158        .map(|value| {
1159            value
1160                .as_str()
1161                .map(Arc::from)
1162                .ok_or_else(|| invalid_field(key, "an array of strings"))
1163        })
1164        .collect::<Result<Vec<_>, _>>()
1165        .map(Arc::from)
1166}
1167
1168fn path_list(
1169    root: &ProjectRoot,
1170    directory: &Path,
1171    object: &JsonObject,
1172    key: &'static str,
1173) -> Result<Arc<[PathBuf]>, ConfigError> {
1174    let values = string_list(object, key)?;
1175    values
1176        .iter()
1177        .map(|value| {
1178            root.resolve_from(directory, value.as_ref())
1179                .map_err(ConfigError::from)
1180        })
1181        .collect::<Result<Vec<_>, _>>()
1182        .map(Arc::from)
1183}
1184
1185fn validate_patterns(
1186    root: &ProjectRoot,
1187    directory: &Path,
1188    field: &'static str,
1189    patterns: &[Arc<str>],
1190) -> Result<(), ConfigError> {
1191    for pattern in patterns {
1192        if pattern.is_empty() {
1193            return Err(invalid_field(field, "non-empty confined path patterns"));
1194        }
1195        root.resolve_from(directory, pattern.as_ref())?;
1196    }
1197    Ok(())
1198}
1199
1200fn parse_path_mappings(
1201    root: &ProjectRoot,
1202    base_url: &Path,
1203    compiler: Option<&JsonObject>,
1204) -> Result<Arc<[PathMapping]>, ConfigError> {
1205    let Some(value) = compiler.and_then(|object| object.get("paths")) else {
1206        return Ok(Arc::from([]));
1207    };
1208    let object = value
1209        .as_object()
1210        .ok_or_else(|| invalid_field("paths", "an object of string arrays"))?;
1211    let mut mappings = Vec::with_capacity(object.entries().len());
1212    for (pattern, value) in object.entries() {
1213        if pattern.is_empty() || pattern.matches('*').count() > 1 {
1214            return Err(invalid_field(
1215                format!("paths.{pattern}"),
1216                "a non-empty pattern with at most one '*'",
1217            ));
1218        }
1219        let targets = value
1220            .as_array()
1221            .ok_or_else(|| invalid_field(format!("paths.{pattern}"), "an array of strings"))?;
1222        if targets.is_empty() {
1223            return Err(invalid_field(
1224                format!("paths.{pattern}"),
1225                "a non-empty array of strings",
1226            ));
1227        }
1228        let resolved = targets
1229            .iter()
1230            .map(|target| {
1231                let target = target.as_str().ok_or_else(|| {
1232                    invalid_field(format!("paths.{pattern}"), "an array of strings")
1233                })?;
1234                if target.matches('*').count() > 1 {
1235                    return Err(invalid_field(
1236                        format!("paths.{pattern}"),
1237                        "targets with at most one '*'",
1238                    ));
1239                }
1240                root.resolve_from(base_url, target)
1241                    .map_err(ConfigError::from)
1242            })
1243            .collect::<Result<Vec<_>, _>>()?;
1244        mappings.push(PathMapping {
1245            pattern: Arc::clone(pattern),
1246            targets: Arc::from(resolved),
1247        });
1248    }
1249    Ok(Arc::from(mappings))
1250}
1251
1252/// File families to prioritize in a relative module search plan.
1253#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
1254pub enum ResolutionFlavor {
1255    Runtime,
1256    Types,
1257}
1258
1259/// A relative module planning failure. Planning is pure and never reports a missing
1260/// file; callers decide existence by applying their own probe to the candidates.
1261#[derive(Clone, Debug, Eq, PartialEq)]
1262pub enum ModuleResolutionError {
1263    Path(PathError),
1264    EmptySpecifier,
1265    BareSpecifier { specifier: Arc<str> },
1266    UrlLikeSpecifier { specifier: Arc<str> },
1267    UnsupportedExtension { specifier: Arc<str> },
1268}
1269
1270impl fmt::Display for ModuleResolutionError {
1271    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1272        match self {
1273            Self::Path(error) => error.fmt(formatter),
1274            Self::EmptySpecifier => formatter.write_str("module specifier is empty"),
1275            Self::BareSpecifier { specifier } => {
1276                write!(
1277                    formatter,
1278                    "{specifier:?} is not a relative module specifier"
1279                )
1280            }
1281            Self::UrlLikeSpecifier { specifier } => write!(
1282                formatter,
1283                "URL-like module specifier {specifier:?} cannot be resolved as a file"
1284            ),
1285            Self::UnsupportedExtension { specifier } => write!(
1286                formatter,
1287                "relative module specifier {specifier:?} has an unsupported extension"
1288            ),
1289        }
1290    }
1291}
1292
1293impl std::error::Error for ModuleResolutionError {
1294    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1295        if let Self::Path(error) = self {
1296            Some(error)
1297        } else {
1298            None
1299        }
1300    }
1301}
1302
1303impl From<PathError> for ModuleResolutionError {
1304    fn from(error: PathError) -> Self {
1305        Self::Path(error)
1306    }
1307}
1308
1309/// An ordered, immutable set of candidate files for one relative import.
1310#[derive(Clone, Debug, Eq, PartialEq)]
1311pub struct ResolutionPlan {
1312    specifier: Arc<str>,
1313    candidates: Arc<[PathBuf]>,
1314}
1315
1316impl ResolutionPlan {
1317    #[must_use]
1318    pub fn specifier(&self) -> &str {
1319        &self.specifier
1320    }
1321
1322    #[must_use]
1323    pub fn candidates(&self) -> &[PathBuf] {
1324        &self.candidates
1325    }
1326
1327    /// Returns the first candidate accepted by an injected existence policy.
1328    /// Planning itself deliberately does not perform file-system I/O.
1329    #[must_use]
1330    pub fn select(&self, mut exists: impl FnMut(&Path) -> bool) -> Option<&Path> {
1331        self.candidates
1332            .iter()
1333            .find(|candidate| exists(candidate))
1334            .map(PathBuf::as_path)
1335    }
1336}
1337
1338/// Plans TypeScript/JavaScript extension substitution and directory-index search for
1339/// a relative import. The importer and every candidate must remain under `root`.
1340pub fn plan_relative_module(
1341    root: &ProjectRoot,
1342    importer: impl AsRef<Path>,
1343    specifier: &str,
1344    flavor: ResolutionFlavor,
1345    resolve_json_module: bool,
1346) -> Result<ResolutionPlan, ModuleResolutionError> {
1347    if specifier.is_empty() {
1348        return Err(ModuleResolutionError::EmptySpecifier);
1349    }
1350    if specifier.contains('?') || specifier.contains('#') || specifier.contains("//") {
1351        return Err(ModuleResolutionError::UrlLikeSpecifier {
1352            specifier: Arc::from(specifier),
1353        });
1354    }
1355    if !(specifier.starts_with("./") || specifier.starts_with("../")) {
1356        return Err(ModuleResolutionError::BareSpecifier {
1357            specifier: Arc::from(specifier),
1358        });
1359    }
1360    let importer = root.confine(importer)?;
1361    let directory = importer
1362        .parent()
1363        .ok_or_else(|| PathError::PathHasNoParent {
1364            path: importer.clone(),
1365        })?;
1366    let requested = root.resolve_from(directory, specifier)?;
1367    let mut candidates = Vec::new();
1368    append_file_candidates(
1369        &mut candidates,
1370        &requested,
1371        flavor,
1372        resolve_json_module,
1373        specifier,
1374    )?;
1375    if requested.extension().is_none() {
1376        let index = requested.join("index");
1377        append_extensionless_candidates(&mut candidates, &index, flavor, resolve_json_module);
1378    }
1379    Ok(ResolutionPlan {
1380        specifier: Arc::from(specifier),
1381        candidates: Arc::from(candidates),
1382    })
1383}
1384
1385fn append_file_candidates(
1386    output: &mut Vec<PathBuf>,
1387    requested: &Path,
1388    flavor: ResolutionFlavor,
1389    resolve_json_module: bool,
1390    specifier: &str,
1391) -> Result<(), ModuleResolutionError> {
1392    let Some(extension) = requested.extension().and_then(|value| value.to_str()) else {
1393        append_unique(output, requested.to_path_buf());
1394        append_extensionless_candidates(output, requested, flavor, resolve_json_module);
1395        return Ok(());
1396    };
1397    let substitutions: &[&str] = match extension {
1398        "js" => &["ts", "tsx", "d.ts", "js"],
1399        "jsx" => &["tsx", "d.ts", "jsx"],
1400        "mjs" => &["mts", "d.mts", "mjs"],
1401        "cjs" => &["cts", "d.cts", "cjs"],
1402        "ts" | "tsx" | "mts" | "cts" => &[extension],
1403        "json" if resolve_json_module => &["json"],
1404        _ => {
1405            return Err(ModuleResolutionError::UnsupportedExtension {
1406                specifier: Arc::from(specifier),
1407            });
1408        }
1409    };
1410    if flavor == ResolutionFlavor::Types {
1411        for extension in substitutions.iter().filter(|value| value.contains("d.")) {
1412            append_with_extension(output, requested, extension);
1413        }
1414    }
1415    for extension in substitutions {
1416        append_with_extension(output, requested, extension);
1417    }
1418    Ok(())
1419}
1420
1421fn append_extensionless_candidates(
1422    output: &mut Vec<PathBuf>,
1423    stem: &Path,
1424    flavor: ResolutionFlavor,
1425    resolve_json_module: bool,
1426) {
1427    let runtime = ["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
1428    if flavor == ResolutionFlavor::Types {
1429        append_with_extension(output, stem, "d.ts");
1430        append_with_extension(output, stem, "d.mts");
1431        append_with_extension(output, stem, "d.cts");
1432    }
1433    for extension in runtime {
1434        append_with_extension(output, stem, extension);
1435    }
1436    if flavor == ResolutionFlavor::Runtime {
1437        append_with_extension(output, stem, "d.ts");
1438        append_with_extension(output, stem, "d.mts");
1439        append_with_extension(output, stem, "d.cts");
1440    }
1441    if resolve_json_module {
1442        append_with_extension(output, stem, "json");
1443    }
1444}
1445
1446fn append_with_extension(output: &mut Vec<PathBuf>, path: &Path, extension: &str) {
1447    let mut candidate = path.to_path_buf();
1448    candidate.set_extension(extension);
1449    append_unique(output, candidate);
1450}
1451
1452fn append_unique(output: &mut Vec<PathBuf>, candidate: PathBuf) {
1453    if !output.contains(&candidate) {
1454        output.push(candidate);
1455    }
1456}
1457
1458/// The interpretation used for legacy package entry fields when `exports` is absent.
1459#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
1460pub enum PackageMode {
1461    Import,
1462    Require,
1463    Types,
1464}
1465
1466/// A package `imports` result may point to a confined project file or to another bare
1467/// package specifier. This layer plans only; it never downloads or executes a package.
1468#[derive(Clone, Debug, Eq, PartialEq)]
1469pub enum PackageTarget {
1470    Path(PathBuf),
1471    External(Arc<str>),
1472}
1473
1474/// Package metadata or map resolution failure.
1475#[derive(Clone, Debug, Eq, PartialEq)]
1476pub enum PackageError {
1477    Json(JsoncError),
1478    Path(PathError),
1479    RootMustBeObject,
1480    InvalidField {
1481        field: Arc<str>,
1482        expected: &'static str,
1483    },
1484    InvalidSubpath {
1485        subpath: Arc<str>,
1486    },
1487    InvalidImportSpecifier {
1488        specifier: Arc<str>,
1489    },
1490    InvalidCondition {
1491        condition: Arc<str>,
1492    },
1493    MixedExportsKeys,
1494    InvalidPattern {
1495        pattern: Arc<str>,
1496    },
1497    InvalidTarget {
1498        target: Arc<str>,
1499    },
1500    TargetEscapesPackage {
1501        target: Arc<str>,
1502    },
1503    TargetUsesNodeModules {
1504        target: Arc<str>,
1505    },
1506    SubpathNotExported {
1507        subpath: Arc<str>,
1508    },
1509    ImportNotDefined {
1510        specifier: Arc<str>,
1511    },
1512    TargetBlocked,
1513    NoLegacyEntry,
1514}
1515
1516impl fmt::Display for PackageError {
1517    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1518        match self {
1519            Self::Json(error) => error.fmt(formatter),
1520            Self::Path(error) => error.fmt(formatter),
1521            Self::RootMustBeObject => formatter.write_str("package.json root must be an object"),
1522            Self::InvalidField { field, expected } => {
1523                write!(formatter, "package.json field {field:?} must be {expected}")
1524            }
1525            Self::InvalidSubpath { subpath } => {
1526                write!(formatter, "invalid package export subpath {subpath:?}")
1527            }
1528            Self::InvalidImportSpecifier { specifier } => {
1529                write!(formatter, "invalid package import specifier {specifier:?}")
1530            }
1531            Self::InvalidCondition { condition } => {
1532                write!(formatter, "invalid package condition {condition:?}")
1533            }
1534            Self::MixedExportsKeys => formatter
1535                .write_str("package exports object cannot mix subpath keys and condition keys"),
1536            Self::InvalidPattern { pattern } => {
1537                write!(formatter, "invalid package map pattern {pattern:?}")
1538            }
1539            Self::InvalidTarget { target } => {
1540                write!(formatter, "invalid package target {target:?}")
1541            }
1542            Self::TargetEscapesPackage { target } => {
1543                write!(formatter, "package target {target:?} escapes its package")
1544            }
1545            Self::TargetUsesNodeModules { target } => write!(
1546                formatter,
1547                "package target {target:?} contains a forbidden node_modules segment"
1548            ),
1549            Self::SubpathNotExported { subpath } => {
1550                write!(formatter, "package subpath {subpath:?} is not exported")
1551            }
1552            Self::ImportNotDefined { specifier } => {
1553                write!(formatter, "package import {specifier:?} is not defined")
1554            }
1555            Self::TargetBlocked => formatter.write_str("package target is explicitly blocked"),
1556            Self::NoLegacyEntry => formatter.write_str("package has no matching legacy entry"),
1557        }
1558    }
1559}
1560
1561impl std::error::Error for PackageError {
1562    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1563        match self {
1564            Self::Json(error) => Some(error),
1565            Self::Path(error) => Some(error),
1566            _ => None,
1567        }
1568    }
1569}
1570
1571impl From<JsoncError> for PackageError {
1572    fn from(error: JsoncError) -> Self {
1573        Self::Json(error)
1574    }
1575}
1576
1577impl From<PathError> for PackageError {
1578    fn from(error: PathError) -> Self {
1579        Self::Path(error)
1580    }
1581}
1582
1583/// An immutable set of active package conditions. `default` is always eligible and
1584/// therefore need not be supplied explicitly.
1585#[derive(Clone, Debug, Eq, PartialEq)]
1586pub struct ResolutionConditions {
1587    values: Arc<[Arc<str>]>,
1588}
1589
1590impl ResolutionConditions {
1591    pub fn new<I, S>(conditions: I) -> Result<Self, PackageError>
1592    where
1593        I: IntoIterator<Item = S>,
1594        S: AsRef<str>,
1595    {
1596        let mut values: Vec<Arc<str>> = Vec::new();
1597        for condition in conditions {
1598            let condition = condition.as_ref();
1599            if condition.is_empty() || condition.starts_with('.') || condition.contains('/') {
1600                return Err(PackageError::InvalidCondition {
1601                    condition: Arc::from(condition),
1602                });
1603            }
1604            if !values.iter().any(|existing| existing.as_ref() == condition) {
1605                values.push(Arc::from(condition));
1606            }
1607        }
1608        Ok(Self {
1609            values: Arc::from(values),
1610        })
1611    }
1612
1613    #[must_use]
1614    pub fn for_mode(mode: PackageMode) -> Self {
1615        let values: &[&str] = match mode {
1616            PackageMode::Import => &["import", "node"],
1617            PackageMode::Require => &["require", "node"],
1618            PackageMode::Types => &["types", "import", "node"],
1619        };
1620        Self {
1621            values: Arc::from(
1622                values
1623                    .iter()
1624                    .map(|value| Arc::from(*value))
1625                    .collect::<Vec<_>>(),
1626            ),
1627        }
1628    }
1629
1630    #[must_use]
1631    pub fn contains(&self, condition: &str) -> bool {
1632        condition == "default"
1633            || self
1634                .values
1635                .iter()
1636                .any(|candidate| candidate.as_ref() == condition)
1637    }
1638
1639    #[must_use]
1640    pub fn values(&self) -> &[Arc<str>] {
1641        &self.values
1642    }
1643}
1644
1645/// Immutable package.json metadata and pure export/import resolution.
1646#[derive(Clone, Debug, Eq, PartialEq)]
1647pub struct PackageJson {
1648    path: PathBuf,
1649    directory: PathBuf,
1650    name: Option<Arc<str>>,
1651    package_type: Option<Arc<str>>,
1652    main: Option<Arc<str>>,
1653    module: Option<Arc<str>>,
1654    types: Option<Arc<str>>,
1655    exports: Option<JsonValue>,
1656    imports: Option<JsonObject>,
1657    raw: JsonObject,
1658}
1659
1660impl PackageJson {
1661    /// Parses already-loaded package metadata without scripts, network access, or I/O.
1662    pub fn parse(
1663        root: &ProjectRoot,
1664        package_path: impl AsRef<Path>,
1665        source: &str,
1666    ) -> Result<Self, PackageError> {
1667        let path = root.confine(package_path)?;
1668        let directory = path
1669            .parent()
1670            .ok_or_else(|| PathError::PathHasNoParent { path: path.clone() })?
1671            .to_path_buf();
1672        let value = parse_jsonc(source)?;
1673        let raw = value
1674            .as_object()
1675            .ok_or(PackageError::RootMustBeObject)?
1676            .clone();
1677        let imports = match raw.get("imports") {
1678            None => None,
1679            Some(value) => Some(
1680                value
1681                    .as_object()
1682                    .ok_or_else(|| package_invalid_field("imports", "an object"))?
1683                    .clone(),
1684            ),
1685        };
1686        let package_type = package_optional_string(&raw, "type")?;
1687        if let Some(value) = package_type.as_deref()
1688            && value != "module"
1689            && value != "commonjs"
1690        {
1691            return Err(package_invalid_field("type", "\"module\" or \"commonjs\""));
1692        }
1693        Ok(Self {
1694            path,
1695            directory,
1696            name: package_optional_string(&raw, "name")?,
1697            package_type,
1698            main: package_optional_string(&raw, "main")?,
1699            module: package_optional_string(&raw, "module")?,
1700            types: package_optional_string(&raw, "types")?
1701                .or(package_optional_string(&raw, "typings")?),
1702            exports: raw.get("exports").cloned(),
1703            imports,
1704            raw,
1705        })
1706    }
1707
1708    #[must_use]
1709    pub fn path(&self) -> &Path {
1710        &self.path
1711    }
1712
1713    #[must_use]
1714    pub fn directory(&self) -> &Path {
1715        &self.directory
1716    }
1717
1718    #[must_use]
1719    pub fn name(&self) -> Option<&str> {
1720        self.name.as_deref()
1721    }
1722
1723    #[must_use]
1724    pub fn package_type(&self) -> Option<&str> {
1725        self.package_type.as_deref()
1726    }
1727
1728    #[must_use]
1729    pub const fn raw(&self) -> &JsonObject {
1730        &self.raw
1731    }
1732
1733    /// Resolves an export map target or a legacy root entry. Targets are planned
1734    /// lexically and need not exist.
1735    pub fn resolve_export(
1736        &self,
1737        root: &ProjectRoot,
1738        subpath: &str,
1739        mode: PackageMode,
1740        conditions: &ResolutionConditions,
1741    ) -> Result<PathBuf, PackageError> {
1742        validate_export_subpath(subpath)?;
1743        let Some(exports) = &self.exports else {
1744            if subpath != "." {
1745                return Err(PackageError::SubpathNotExported {
1746                    subpath: Arc::from(subpath),
1747                });
1748            }
1749            return self.resolve_legacy(root, mode);
1750        };
1751        let capture: Option<Arc<str>> = if let JsonValue::Object(object) = exports {
1752            let has_subpaths = object.entries().iter().any(|(key, _)| key.starts_with('.'));
1753            let has_conditions = object
1754                .entries()
1755                .iter()
1756                .any(|(key, _)| !key.starts_with('.'));
1757            if has_subpaths && has_conditions {
1758                return Err(PackageError::MixedExportsKeys);
1759            }
1760            if has_subpaths {
1761                let entry = select_map_entry(object, subpath)?.ok_or_else(|| {
1762                    PackageError::SubpathNotExported {
1763                        subpath: Arc::from(subpath),
1764                    }
1765                })?;
1766                return self.finish_export_target(
1767                    root,
1768                    resolve_package_target(entry.target, conditions, entry.capture.as_deref())?,
1769                );
1770            }
1771            None
1772        } else {
1773            None
1774        };
1775        if subpath != "." {
1776            return Err(PackageError::SubpathNotExported {
1777                subpath: Arc::from(subpath),
1778            });
1779        }
1780        self.finish_export_target(
1781            root,
1782            resolve_package_target(exports, conditions, capture.as_deref())?,
1783        )
1784    }
1785
1786    /// Resolves a package-local `#imports` map. Bare results remain explicit external
1787    /// targets for a higher package locator; relative results are confined paths.
1788    pub fn resolve_import(
1789        &self,
1790        root: &ProjectRoot,
1791        specifier: &str,
1792        conditions: &ResolutionConditions,
1793    ) -> Result<PackageTarget, PackageError> {
1794        if !specifier.starts_with('#') || specifier == "#" || specifier.starts_with("#/") {
1795            return Err(PackageError::InvalidImportSpecifier {
1796                specifier: Arc::from(specifier),
1797            });
1798        }
1799        let imports = self
1800            .imports
1801            .as_ref()
1802            .ok_or_else(|| PackageError::ImportNotDefined {
1803                specifier: Arc::from(specifier),
1804            })?;
1805        let entry = select_map_entry(imports, specifier)?.ok_or_else(|| {
1806            PackageError::ImportNotDefined {
1807                specifier: Arc::from(specifier),
1808            }
1809        })?;
1810        match resolve_package_target(entry.target, conditions, entry.capture.as_deref())? {
1811            TargetOutcome::Path(target) => self
1812                .resolve_package_path(root, &target)
1813                .map(PackageTarget::Path),
1814            TargetOutcome::External(target) => Ok(PackageTarget::External(target)),
1815            TargetOutcome::Blocked => Err(PackageError::TargetBlocked),
1816            TargetOutcome::NoMatch => Err(PackageError::ImportNotDefined {
1817                specifier: Arc::from(specifier),
1818            }),
1819        }
1820    }
1821
1822    fn resolve_legacy(
1823        &self,
1824        root: &ProjectRoot,
1825        mode: PackageMode,
1826    ) -> Result<PathBuf, PackageError> {
1827        let target = match mode {
1828            PackageMode::Types => self.types.as_deref(),
1829            PackageMode::Import => self.module.as_deref().or(self.main.as_deref()),
1830            PackageMode::Require => self.main.as_deref(),
1831        }
1832        .ok_or(PackageError::NoLegacyEntry)?;
1833        self.resolve_package_path(root, target)
1834    }
1835
1836    fn finish_export_target(
1837        &self,
1838        root: &ProjectRoot,
1839        outcome: TargetOutcome,
1840    ) -> Result<PathBuf, PackageError> {
1841        match outcome {
1842            TargetOutcome::Path(target) => self.resolve_package_path(root, &target),
1843            TargetOutcome::External(target) => Err(PackageError::InvalidTarget { target }),
1844            TargetOutcome::Blocked => Err(PackageError::TargetBlocked),
1845            TargetOutcome::NoMatch => Err(PackageError::SubpathNotExported {
1846                subpath: Arc::from("."),
1847            }),
1848        }
1849    }
1850
1851    fn resolve_package_path(
1852        &self,
1853        root: &ProjectRoot,
1854        target: &str,
1855    ) -> Result<PathBuf, PackageError> {
1856        // Export/import targets are pre-validated to start with `./`; legacy
1857        // main/module/types entries may be bare (`index.js`) or `./`-prefixed.
1858        let relative = target.strip_prefix("./").unwrap_or(target);
1859        if Path::new(relative)
1860            .components()
1861            .any(|component| component.as_os_str() == "node_modules")
1862        {
1863            return Err(PackageError::TargetUsesNodeModules {
1864                target: Arc::from(target),
1865            });
1866        }
1867        let resolved = root.resolve_from(&self.directory, relative)?;
1868        if !resolved.starts_with(&self.directory) {
1869            return Err(PackageError::TargetEscapesPackage {
1870                target: Arc::from(target),
1871            });
1872        }
1873        Ok(resolved)
1874    }
1875}
1876
1877fn package_invalid_field(field: impl Into<Arc<str>>, expected: &'static str) -> PackageError {
1878    PackageError::InvalidField {
1879        field: field.into(),
1880        expected,
1881    }
1882}
1883
1884fn package_optional_string(
1885    object: &JsonObject,
1886    key: &'static str,
1887) -> Result<Option<Arc<str>>, PackageError> {
1888    object
1889        .get(key)
1890        .map(|value| {
1891            value
1892                .as_str()
1893                .map(Arc::from)
1894                .ok_or_else(|| package_invalid_field(key, "a string"))
1895        })
1896        .transpose()
1897}
1898
1899fn validate_export_subpath(subpath: &str) -> Result<(), PackageError> {
1900    if subpath == "."
1901        || (subpath.starts_with("./")
1902            && subpath.len() > 2
1903            && !subpath.contains('\\')
1904            && !subpath
1905                .split('/')
1906                .any(|part| part == ".." || part.is_empty()))
1907    {
1908        Ok(())
1909    } else {
1910        Err(PackageError::InvalidSubpath {
1911            subpath: Arc::from(subpath),
1912        })
1913    }
1914}
1915
1916struct MapEntry<'a> {
1917    target: &'a JsonValue,
1918    capture: Option<Arc<str>>,
1919}
1920
1921fn select_map_entry<'a>(
1922    object: &'a JsonObject,
1923    request: &str,
1924) -> Result<Option<MapEntry<'a>>, PackageError> {
1925    if let Some(value) = object.get(request) {
1926        return Ok(Some(MapEntry {
1927            target: value,
1928            capture: None,
1929        }));
1930    }
1931    let mut selected: Option<(&JsonValue, Arc<str>, usize)> = None;
1932    for (pattern, value) in object.entries() {
1933        let stars = pattern.matches('*').count();
1934        if stars == 0 {
1935            continue;
1936        }
1937        if stars != 1 {
1938            return Err(PackageError::InvalidPattern {
1939                pattern: Arc::clone(pattern),
1940            });
1941        }
1942        let (prefix, suffix) =
1943            pattern
1944                .split_once('*')
1945                .ok_or_else(|| PackageError::InvalidPattern {
1946                    pattern: Arc::clone(pattern),
1947                })?;
1948        let Some(remainder) = request.strip_prefix(prefix) else {
1949            continue;
1950        };
1951        let Some(capture) = remainder.strip_suffix(suffix) else {
1952            continue;
1953        };
1954        let specificity = prefix.len() + suffix.len();
1955        if selected
1956            .as_ref()
1957            .is_none_or(|(_, _, current)| specificity > *current)
1958        {
1959            selected = Some((value, Arc::from(capture), specificity));
1960        }
1961    }
1962    Ok(selected.map(|(value, capture, _)| MapEntry {
1963        target: value,
1964        capture: Some(capture),
1965    }))
1966}
1967
1968#[derive(Clone, Debug, Eq, PartialEq)]
1969enum TargetOutcome {
1970    Path(Arc<str>),
1971    External(Arc<str>),
1972    Blocked,
1973    NoMatch,
1974}
1975
1976fn resolve_package_target(
1977    value: &JsonValue,
1978    conditions: &ResolutionConditions,
1979    capture: Option<&str>,
1980) -> Result<TargetOutcome, PackageError> {
1981    match value {
1982        JsonValue::Null => Ok(TargetOutcome::Blocked),
1983        JsonValue::String(target) => {
1984            let target: Arc<str> = if let Some(capture) = capture {
1985                Arc::from(target.replace('*', capture))
1986            } else {
1987                Arc::clone(target)
1988            };
1989            if target.starts_with("./") {
1990                Ok(TargetOutcome::Path(target))
1991            } else if target.starts_with('/') || target.starts_with("../") {
1992                Err(PackageError::InvalidTarget { target })
1993            } else {
1994                Ok(TargetOutcome::External(target))
1995            }
1996        }
1997        JsonValue::Array(values) => {
1998            let mut blocked = false;
1999            for value in values.iter() {
2000                match resolve_package_target(value, conditions, capture)? {
2001                    TargetOutcome::NoMatch => {}
2002                    TargetOutcome::Blocked => blocked = true,
2003                    outcome => return Ok(outcome),
2004                }
2005            }
2006            Ok(if blocked {
2007                TargetOutcome::Blocked
2008            } else {
2009                TargetOutcome::NoMatch
2010            })
2011        }
2012        JsonValue::Object(object) => {
2013            for (condition, target) in object.entries() {
2014                if conditions.contains(condition) {
2015                    let outcome = resolve_package_target(target, conditions, capture)?;
2016                    if outcome != TargetOutcome::NoMatch {
2017                        return Ok(outcome);
2018                    }
2019                }
2020            }
2021            Ok(TargetOutcome::NoMatch)
2022        }
2023        JsonValue::Bool(_) | JsonValue::Number(_) => Err(package_invalid_field(
2024            "exports/imports target",
2025            "a string, object, array, or null",
2026        )),
2027    }
2028}
2029
2030#[cfg(test)]
2031mod tests {
2032    use super::{
2033        ConfigError, JsonValue, JsoncErrorKind, LintTsConfig, ModuleResolutionError, PackageError,
2034        PackageJson, PackageMode, PackageTarget, ProjectConfig, ProjectRoot, ResolutionConditions,
2035        ResolutionFlavor, parse_bamts_toml, parse_jsonc, plan_relative_module,
2036    };
2037    use std::path::{Path, PathBuf};
2038
2039    fn root() -> ProjectRoot {
2040        ProjectRoot::new("/workspace/corpus").expect("absolute test root")
2041    }
2042
2043    #[test]
2044    fn bamts_toml_reads_only_lint_groups_and_rules() {
2045        let config = parse_bamts_toml(
2046            r#"
2047                title = "ignored"
2048                [lints.groups]
2049                escape-hatches = "deny"
2050                [unrelated]
2051                setting = "ignored"
2052                [lints.rules]
2053                BAMTS-W017 = "forbid" # exact rule
2054            "#,
2055        )
2056        .expect("valid bamts.toml lint tables");
2057        assert_eq!(config.groups().len(), 1);
2058        assert_eq!(config.groups()[0].name(), "escape-hatches");
2059        assert_eq!(config.rules().len(), 1);
2060        assert_eq!(config.rules()[0].name(), "BAMTS-W017");
2061        assert_eq!(config.rules()[0].source(), "bamts.toml:8");
2062    }
2063
2064    #[test]
2065    fn lint_tsconfig_ignores_typescript_strictness_options() {
2066        let config = LintTsConfig::parse(
2067            &root(),
2068            "/workspace/corpus/tsconfig.json",
2069            r#"{
2070                "compilerOptions": {
2071                    "target": "ES2022",
2072                    "module": "NodeNext",
2073                    "moduleResolution": "NodeNext",
2074                    "paths": {"@app/*": ["src/*"]},
2075                    "strict": true,
2076                    "useDefineForClassFields": false
2077                }
2078            }"#,
2079        )
2080        .expect("supported tsconfig view");
2081        assert_eq!(config.target(), Some("ES2022"));
2082        assert_eq!(config.module(), Some("NodeNext"));
2083        assert_eq!(config.module_resolution(), Some("NodeNext"));
2084        assert_eq!(config.paths()[0].pattern(), "@app/*");
2085    }
2086
2087    #[test]
2088    fn jsonc_accepts_tsconfig_comments_trailing_commas_and_surrogate_pairs() {
2089        let parsed = parse_jsonc(
2090            r#"{
2091                // TypeScript permits line comments.
2092                "compilerOptions": {
2093                    "module": "NodeNext", /* and block comments */
2094                    "types": ["node",],
2095                    "icon": "\uD83D\uDE80",
2096                },
2097            }"#,
2098        )
2099        .expect("valid JSONC");
2100        let compiler = parsed
2101            .as_object()
2102            .and_then(|object| object.get("compilerOptions"))
2103            .and_then(JsonValue::as_object)
2104            .expect("compiler options object");
2105        assert_eq!(compiler.get("icon").and_then(JsonValue::as_str), Some("🚀"));
2106        assert_eq!(
2107            compiler
2108                .get("types")
2109                .and_then(JsonValue::as_array)
2110                .map(<[JsonValue]>::len),
2111            Some(1)
2112        );
2113    }
2114
2115    #[test]
2116    fn jsonc_rejects_duplicate_keys_and_unterminated_comments_with_offsets() {
2117        let duplicate = parse_jsonc(r#"{"x": 1, "x": 2}"#).expect_err("duplicate must fail");
2118        assert!(matches!(
2119            duplicate.kind(),
2120            JsoncErrorKind::DuplicateObjectKey { key } if key.as_ref() == "x"
2121        ));
2122        assert_eq!(duplicate.offset(), 9);
2123
2124        let comment = parse_jsonc("{/* never closed").expect_err("comment must terminate");
2125        assert_eq!(comment.offset(), 1);
2126        assert_eq!(comment.kind(), &JsoncErrorKind::UnterminatedBlockComment);
2127    }
2128
2129    #[test]
2130    fn jsonc_rejects_non_json_numbers_and_lone_surrogates() {
2131        assert_eq!(
2132            parse_jsonc("01").expect_err("leading zero").kind(),
2133            &JsoncErrorKind::InvalidNumber
2134        );
2135        assert_eq!(
2136            parse_jsonc(r#""\uD800""#)
2137                .expect_err("lone surrogate")
2138                .kind(),
2139            &JsoncErrorKind::LoneSurrogate
2140        );
2141    }
2142
2143    #[test]
2144    fn root_normalization_is_lexical_and_rejects_escape() {
2145        let project = root();
2146        assert_eq!(
2147            project
2148                .resolve("projects/ohash/src/../src/index.ts")
2149                .expect("confined path"),
2150            PathBuf::from("/workspace/corpus/projects/ohash/src/index.ts")
2151        );
2152        assert!(project.resolve("../secrets.ts").is_err());
2153        assert!(ProjectRoot::new("relative/root").is_err());
2154    }
2155
2156    #[test]
2157    fn project_config_parses_corpus_shaped_jsonc_into_immutable_options() {
2158        let config = ProjectConfig::parse(
2159            &root(),
2160            "/workspace/corpus/projects/hookable/tsconfig.json",
2161            r##"{
2162                "compilerOptions": {
2163                    "target": "ESNext",
2164                    "module": "NodeNext",
2165                    "moduleResolution": "NodeNext",
2166                    "strict": true,
2167                    "resolveJsonModule": true,
2168                    "baseUrl": ".",
2169                    "paths": { "#src/*": ["src/*"] },
2170                },
2171                "include": ["src", "test",],
2172            }"##,
2173        )
2174        .expect("corpus-shaped config");
2175        assert_eq!(config.options().module(), Some("NodeNext"));
2176        assert!(config.options().strict());
2177        assert!(config.options().resolve_json_module());
2178        assert_eq!(
2179            config.options().base_url(),
2180            Path::new("/workspace/corpus/projects/hookable")
2181        );
2182        assert_eq!(
2183            config.options().paths()[0].targets()[0],
2184            PathBuf::from("/workspace/corpus/projects/hookable/src/*")
2185        );
2186        assert_eq!(config.include()[1].as_ref(), "test");
2187    }
2188
2189    #[test]
2190    fn project_config_rejects_wrong_types_and_every_root_escape() {
2191        let wrong = ProjectConfig::parse(
2192            &root(),
2193            "/workspace/corpus/tsconfig.json",
2194            r#"{"compilerOptions":{"strict":"yes"}}"#,
2195        )
2196        .expect_err("wrong type");
2197        assert!(matches!(wrong, ConfigError::InvalidField { .. }));
2198
2199        let escape = ProjectConfig::parse(
2200            &root(),
2201            "/workspace/corpus/tsconfig.json",
2202            r#"{"compilerOptions":{"outDir":"../outside"}}"#,
2203        )
2204        .expect_err("outDir escape");
2205        assert!(matches!(escape, ConfigError::Path(_)));
2206
2207        let include_escape = ProjectConfig::parse(
2208            &root(),
2209            "/workspace/corpus/tsconfig.json",
2210            r#"{"include":["../outside/**/*.ts"]}"#,
2211        )
2212        .expect_err("include escape");
2213        assert!(matches!(include_escape, ConfigError::Path(_)));
2214    }
2215
2216    #[test]
2217    fn relative_module_plan_covers_extension_substitution_and_index_without_io() {
2218        let project = root();
2219        let explicit = plan_relative_module(
2220            &project,
2221            "/workspace/corpus/cases/dot-prop.ts",
2222            "../projects/dot-prop/index.js",
2223            ResolutionFlavor::Runtime,
2224            false,
2225        )
2226        .expect("relative JS plan");
2227        assert_eq!(
2228            &explicit.candidates()[..4],
2229            &[
2230                PathBuf::from("/workspace/corpus/projects/dot-prop/index.ts"),
2231                PathBuf::from("/workspace/corpus/projects/dot-prop/index.tsx"),
2232                PathBuf::from("/workspace/corpus/projects/dot-prop/index.d.ts"),
2233                PathBuf::from("/workspace/corpus/projects/dot-prop/index.js"),
2234            ]
2235        );
2236
2237        let directory = plan_relative_module(
2238            &project,
2239            "/workspace/corpus/cases/ohash.ts",
2240            "../projects/ohash/src/crypto/node",
2241            ResolutionFlavor::Runtime,
2242            false,
2243        )
2244        .expect("extension and index plan");
2245        assert!(directory.candidates().contains(&PathBuf::from(
2246            "/workspace/corpus/projects/ohash/src/crypto/node/index.ts"
2247        )));
2248        assert_eq!(
2249            directory.select(|path| path.ends_with("index.ts")),
2250            Some(Path::new(
2251                "/workspace/corpus/projects/ohash/src/crypto/node/index.ts"
2252            ))
2253        );
2254    }
2255
2256    #[test]
2257    fn relative_module_plan_rejects_bare_url_unsupported_and_escaping_specifiers() {
2258        let project = root();
2259        let importer = "/workspace/corpus/cases/main.ts";
2260        assert!(matches!(
2261            plan_relative_module(
2262                &project,
2263                importer,
2264                "node:fs",
2265                ResolutionFlavor::Runtime,
2266                false
2267            ),
2268            Err(ModuleResolutionError::BareSpecifier { .. })
2269        ));
2270        assert!(matches!(
2271            plan_relative_module(
2272                &project,
2273                importer,
2274                "./x.ts?raw",
2275                ResolutionFlavor::Runtime,
2276                false
2277            ),
2278            Err(ModuleResolutionError::UrlLikeSpecifier { .. })
2279        ));
2280        assert!(
2281            plan_relative_module(
2282                &project,
2283                importer,
2284                "../../outside",
2285                ResolutionFlavor::Runtime,
2286                false
2287            )
2288            .is_err()
2289        );
2290    }
2291
2292    #[test]
2293    fn package_exports_cover_corpus_string_conditional_subpath_and_wildcard_shapes() {
2294        let project = root();
2295        let flat = PackageJson::parse(
2296            &project,
2297            "/workspace/corpus/projects/escape-string-regexp/package.json",
2298            r#"{"name":"escape-string-regexp","type":"module","exports":"./index.js"}"#,
2299        )
2300        .expect("flat corpus package");
2301        assert_eq!(
2302            flat.resolve_export(
2303                &project,
2304                ".",
2305                PackageMode::Import,
2306                &ResolutionConditions::for_mode(PackageMode::Import)
2307            )
2308            .expect("root export"),
2309            PathBuf::from("/workspace/corpus/projects/escape-string-regexp/index.js")
2310        );
2311
2312        let nested = PackageJson::parse(
2313            &project,
2314            "/workspace/corpus/projects/ohash/package.json",
2315            r#"{
2316                "name":"ohash",
2317                "exports": {
2318                    ".":"./dist/index.mjs",
2319                    "./crypto":{"node":"./dist/crypto/node/index.mjs","default":"./dist/crypto/js/index.mjs"},
2320                    "./*":"./src/*.ts"
2321                }
2322            }"#,
2323        )
2324        .expect("nested corpus package");
2325        assert_eq!(
2326            nested
2327                .resolve_export(
2328                    &project,
2329                    "./crypto",
2330                    PackageMode::Import,
2331                    &ResolutionConditions::for_mode(PackageMode::Import)
2332                )
2333                .expect("node condition"),
2334            PathBuf::from("/workspace/corpus/projects/ohash/dist/crypto/node/index.mjs")
2335        );
2336        assert_eq!(
2337            nested
2338                .resolve_export(
2339                    &project,
2340                    "./serialize",
2341                    PackageMode::Import,
2342                    &ResolutionConditions::for_mode(PackageMode::Import)
2343                )
2344                .expect("wildcard"),
2345            PathBuf::from("/workspace/corpus/projects/ohash/src/serialize.ts")
2346        );
2347    }
2348
2349    #[test]
2350    fn package_conditions_preserve_declaration_precedence_and_types_branch() {
2351        let project = root();
2352        let package = PackageJson::parse(
2353            &project,
2354            "/workspace/corpus/projects/defu/package.json",
2355            r#"{
2356                "exports": {
2357                    ".": {
2358                        "types": "./dist/defu.d.mts",
2359                        "import": {"default":"./dist/defu.mjs"},
2360                        "require":"./lib/defu.cjs"
2361                    }
2362                }
2363            }"#,
2364        )
2365        .expect("defu-shaped package");
2366        assert_eq!(
2367            package
2368                .resolve_export(
2369                    &project,
2370                    ".",
2371                    PackageMode::Types,
2372                    &ResolutionConditions::for_mode(PackageMode::Types)
2373                )
2374                .expect("types condition"),
2375            PathBuf::from("/workspace/corpus/projects/defu/dist/defu.d.mts")
2376        );
2377    }
2378
2379    #[test]
2380    fn package_imports_return_confined_or_explicit_external_targets() {
2381        let project = root();
2382        let package = PackageJson::parse(
2383            &project,
2384            "/workspace/corpus/projects/demo/package.json",
2385            r##"{
2386                "imports": {
2387                    "#internal/*": "./src/*.ts",
2388                    "#dependency": "dep/subpath"
2389                }
2390            }"##,
2391        )
2392        .expect("imports map");
2393        assert_eq!(
2394            package
2395                .resolve_import(
2396                    &project,
2397                    "#internal/value",
2398                    &ResolutionConditions::for_mode(PackageMode::Import)
2399                )
2400                .expect("local import"),
2401            PackageTarget::Path(PathBuf::from(
2402                "/workspace/corpus/projects/demo/src/value.ts"
2403            ))
2404        );
2405        assert_eq!(
2406            package
2407                .resolve_import(
2408                    &project,
2409                    "#dependency",
2410                    &ResolutionConditions::for_mode(PackageMode::Import)
2411                )
2412                .expect("external import"),
2413            PackageTarget::External("dep/subpath".into())
2414        );
2415    }
2416
2417    #[test]
2418    fn package_targets_cannot_escape_or_reenter_node_modules() {
2419        let project = root();
2420        let escape = PackageJson::parse(
2421            &project,
2422            "/workspace/corpus/projects/demo/package.json",
2423            r#"{"exports":"./../outside.js"}"#,
2424        )
2425        .expect("metadata parses");
2426        assert!(matches!(
2427            escape.resolve_export(
2428                &project,
2429                ".",
2430                PackageMode::Import,
2431                &ResolutionConditions::for_mode(PackageMode::Import)
2432            ),
2433            Err(PackageError::TargetEscapesPackage { .. })
2434        ));
2435
2436        let node_modules = PackageJson::parse(
2437            &project,
2438            "/workspace/corpus/projects/demo/package.json",
2439            r#"{"exports":"./node_modules/dep/index.js"}"#,
2440        )
2441        .expect("metadata parses");
2442        assert!(matches!(
2443            node_modules.resolve_export(
2444                &project,
2445                ".",
2446                PackageMode::Import,
2447                &ResolutionConditions::for_mode(PackageMode::Import)
2448            ),
2449            Err(PackageError::TargetUsesNodeModules { .. })
2450        ));
2451    }
2452
2453    #[test]
2454    fn legacy_package_entries_are_mode_specific_and_need_not_exist() {
2455        let project = root();
2456        let package = PackageJson::parse(
2457            &project,
2458            "/workspace/corpus/projects/tslib/package.json",
2459            r#"{
2460                "main":"./tslib.js",
2461                "module":"./tslib.es6.js",
2462                "typings":"./tslib.d.ts"
2463            }"#,
2464        )
2465        .expect("legacy corpus package");
2466        let conditions = ResolutionConditions::for_mode(PackageMode::Import);
2467        assert_eq!(
2468            package
2469                .resolve_export(&project, ".", PackageMode::Import, &conditions)
2470                .expect("module entry"),
2471            PathBuf::from("/workspace/corpus/projects/tslib/tslib.es6.js")
2472        );
2473        assert_eq!(
2474            package
2475                .resolve_export(&project, ".", PackageMode::Types, &conditions)
2476                .expect("types entry"),
2477            PathBuf::from("/workspace/corpus/projects/tslib/tslib.d.ts")
2478        );
2479
2480        // mitt ships bare (no leading `./`) legacy fields across all three modes.
2481        let bare = PackageJson::parse(
2482            &project,
2483            "/workspace/corpus/projects/mitt/package.json",
2484            r#"{
2485                "main":"dist/mitt.js",
2486                "module":"dist/mitt.mjs",
2487                "typings":"index.d.ts"
2488            }"#,
2489        )
2490        .expect("bare legacy package");
2491        assert_eq!(
2492            bare.resolve_export(&project, ".", PackageMode::Require, &conditions)
2493                .expect("bare main entry"),
2494            PathBuf::from("/workspace/corpus/projects/mitt/dist/mitt.js")
2495        );
2496        assert_eq!(
2497            bare.resolve_export(&project, ".", PackageMode::Import, &conditions)
2498                .expect("bare module entry"),
2499            PathBuf::from("/workspace/corpus/projects/mitt/dist/mitt.mjs")
2500        );
2501        assert_eq!(
2502            bare.resolve_export(&project, ".", PackageMode::Types, &conditions)
2503                .expect("bare typings entry"),
2504            PathBuf::from("/workspace/corpus/projects/mitt/index.d.ts")
2505        );
2506    }
2507}