Skip to main content

daml_syntax/
lib.rs

1//! Shared parsed-source surface for Daml tools.
2//!
3//! `daml-parser` stays the low-level lexer/layout/parser implementation.
4//! This crate owns the source-facing facts tools need around that parser:
5//! diagnostics, line/UTF-16 mapping, tokens, trivia, laid-out tokens, and
6//! conversion from parser byte spans to `text-size` ranges.
7//!
8//! # Public dependencies
9//!
10//! This crate intentionally exposes types from `daml-parser` and the
11//! `text-size` crate in its public API. Downstream `SemVer` expectations include
12//! compatible major versions of those dependencies when their types appear in
13//! function signatures or re-exports:
14//!
15//! - [`daml_parser::ast`], [`daml_parser::lexer`], and related parser types
16//!   used by [`SourceFile`] and span conversion helpers.
17//! - [`TextRange`] and [`TextSize`], re-exported from `text-size` for source
18//!   ranges and offsets.
19//!
20//! Coordinate newtypes such as [`LineNumber`], [`ByteColumn`], and
21//! [`CharColumn`] are 1-based and reject zero via [`LineNumber::try_new`] and
22//! siblings; 0-based offsets use [`ByteOffset`] and [`Utf16Offset`].
23//!
24//! ```rust
25//! use daml_syntax::{parser_span_to_text_range, SourceFile};
26//!
27//! let source = "module M where\nfoo : Int\nfoo = 1\n";
28//! let file = SourceFile::parse(source);
29//!
30//! assert_eq!(file.module().name, "M");
31//! assert!(file.diagnostics().is_empty());
32//! assert!(!file.tokens().is_empty());
33//! assert!(!file.laid_out_tokens().is_empty());
34//!
35//! let header_range = parser_span_to_text_range(source, file.module().header);
36//! assert_eq!(usize::from(header_range.start()), 0);
37//! assert_eq!(header_range, file.parser_span_to_text_range(file.module().header));
38//! ```
39
40use daml_parser::ast::{DiagnosticCategory, Module, Span as ParserSpan};
41use daml_parser::layout::resolve_layout;
42use daml_parser::lexer::{lex_with_trivia, LexError, Token, Trivia};
43use daml_parser::parse::parse_module;
44use std::sync::OnceLock;
45
46pub mod coordinate;
47
48pub use coordinate::{
49    ByteColumn, ByteLineCol, ByteOffset, CharColumn, CharLineCol, Coordinate, LineNumber,
50    Utf16Offset,
51};
52pub use text_size::{TextRange, TextSize};
53
54/// A parser or lexer diagnostic anchored in source text.
55///
56/// Constructed by [`SourceFile::parse`]; read fields through accessors so future
57/// metadata (severity, codes, notes) can be added without breaking callers.
58#[derive(Debug, Clone, PartialEq, Eq)]
59#[non_exhaustive]
60pub struct Diagnostic {
61    range: TextRange,
62    line: LineNumber,
63    column: CharColumn,
64    end_column: Option<CharColumn>,
65    message: String,
66    category: DiagnosticCategory,
67}
68
69impl Diagnostic {
70    /// Byte range of the diagnostic in source text.
71    #[must_use]
72    pub const fn range(&self) -> TextRange {
73        self.range
74    }
75
76    /// 1-based line number of the diagnostic start.
77    #[must_use]
78    pub const fn line(&self) -> LineNumber {
79        self.line
80    }
81
82    /// 1-based character column of the diagnostic start.
83    #[must_use]
84    pub const fn column(&self) -> CharColumn {
85        self.column
86    }
87
88    /// 1-based character column of the diagnostic end when the span is single-line.
89    #[must_use]
90    pub const fn end_column(&self) -> Option<CharColumn> {
91        self.end_column
92    }
93
94    /// Human-readable diagnostic message.
95    #[must_use]
96    pub fn message(&self) -> &str {
97        &self.message
98    }
99
100    /// Parser diagnostic category from `daml-parser`.
101    #[must_use]
102    pub const fn category(&self) -> DiagnosticCategory {
103        self.category
104    }
105}
106
107/// Precomputed line, character, and UTF-16 offset tables for a source string.
108///
109/// Offsets passed to lookup methods are clamped to the source length. Returned
110/// line and column coordinates are always valid 1-based values.
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct LineIndex {
113    source_len: usize,
114    line_start_bytes: Vec<usize>,
115    char_offset_by_byte: Vec<usize>,
116    utf16_offset_by_byte: Vec<usize>,
117}
118
119impl LineIndex {
120    /// Builds line and column lookup tables for `source`.
121    #[must_use]
122    pub fn new(source: &str) -> Self {
123        let mut line_start_bytes = vec![0];
124        for (idx, byte) in source.bytes().enumerate() {
125            if byte == b'\n' {
126                line_start_bytes.push(idx + 1);
127            }
128        }
129
130        let mut char_offset_by_byte = vec![0; source.len() + 1];
131        let mut char_count = 0usize;
132        let mut prev = 0usize;
133        for (idx, ch) in source.char_indices() {
134            for slot in char_offset_by_byte.iter_mut().take(idx).skip(prev) {
135                *slot = char_count;
136            }
137            let char_end = idx + ch.len_utf8();
138            for slot in char_offset_by_byte.iter_mut().take(char_end).skip(idx) {
139                *slot = char_count;
140            }
141            char_count += 1;
142            prev = char_end;
143        }
144        for slot in char_offset_by_byte
145            .iter_mut()
146            .take(source.len() + 1)
147            .skip(prev)
148        {
149            *slot = char_count;
150        }
151
152        let mut utf16_offset_by_byte = vec![0; source.len() + 1];
153        let mut utf16 = 0usize;
154        let mut prev = 0usize;
155        for (idx, ch) in source.char_indices() {
156            for slot in utf16_offset_by_byte.iter_mut().take(idx).skip(prev) {
157                *slot = utf16;
158            }
159            let char_end = idx + ch.len_utf8();
160            for slot in utf16_offset_by_byte.iter_mut().take(char_end).skip(idx) {
161                *slot = utf16;
162            }
163            utf16 += ch.len_utf16();
164            prev = char_end;
165        }
166        for slot in utf16_offset_by_byte
167            .iter_mut()
168            .take(source.len() + 1)
169            .skip(prev)
170        {
171            *slot = utf16;
172        }
173
174        Self {
175            source_len: source.len(),
176            line_start_bytes,
177            char_offset_by_byte,
178            utf16_offset_by_byte,
179        }
180    }
181
182    /// Maps a byte offset to a 1-based line and byte column, clamping past EOF.
183    #[must_use]
184    pub fn line_col(&self, offset: TextSize) -> ByteLineCol {
185        let byte = usize::from(offset).min(self.source_len);
186        let line_idx = match self.line_start_bytes.binary_search(&byte) {
187            Ok(idx) => idx,
188            Err(idx) => idx.saturating_sub(1),
189        };
190        ByteLineCol {
191            line: LineNumber::new(line_idx + 1),
192            column: ByteColumn::new(byte - self.line_start_bytes[line_idx] + 1),
193        }
194    }
195
196    /// Maps a byte offset to a 1-based line and Unicode scalar column.
197    ///
198    /// Non-UTF-8-boundary offsets snap to the previous character boundary.
199    #[must_use]
200    pub fn char_line_col(&self, offset: TextSize) -> CharLineCol {
201        let byte = usize::from(offset).min(self.source_len);
202        let line_idx = match self.line_start_bytes.binary_search(&byte) {
203            Ok(idx) => idx,
204            Err(idx) => idx.saturating_sub(1),
205        };
206        let line_start = self.line_start_bytes[line_idx];
207        CharLineCol {
208            line: LineNumber::new(line_idx + 1),
209            column: CharColumn::new(
210                self.char_offset_by_byte[byte] - self.char_offset_by_byte[line_start] + 1,
211            ),
212        }
213    }
214
215    /// Returns the UTF-16 code-unit offset from the start of `line` to `byte_col`.
216    ///
217    /// Both coordinates must be valid 1-based values; zero cannot be represented
218    /// by [`LineNumber`] or [`ByteColumn`].
219    #[must_use]
220    pub fn utf16_col(&self, line: LineNumber, byte_col: ByteColumn) -> Utf16Offset {
221        let line_start = self
222            .line_start_bytes
223            .get(line.get().saturating_sub(1))
224            .copied()
225            .unwrap_or(self.source_len);
226        let byte = line_start
227            .saturating_add(byte_col.get().saturating_sub(1))
228            .min(self.source_len);
229        Utf16Offset::new(self.utf16_offset_by_byte[byte] - self.utf16_offset_by_byte[line_start])
230    }
231
232    /// Maps a byte range to UTF-16 start/end offsets, clamping to source bounds.
233    #[must_use]
234    pub fn utf16_range(&self, range: TextRange) -> (Utf16Offset, Utf16Offset) {
235        let start = usize::from(range.start()).min(self.source_len);
236        let end = usize::from(range.end()).min(self.source_len).max(start);
237        (
238            Utf16Offset::new(self.utf16_offset_by_byte[start]),
239            Utf16Offset::new(self.utf16_offset_by_byte[end]),
240        )
241    }
242}
243
244/// Lexer output for a source string without running the full module parser.
245///
246/// Use [`SourceFile`] when you also need the AST and parse diagnostics.
247#[derive(Debug)]
248pub struct SourceTokens {
249    tokens: Vec<Token>,
250    trivia: Vec<Trivia>,
251    lex_errors: Vec<LexError>,
252    laid_out_tokens: OnceLock<Vec<Token>>,
253}
254
255impl SourceTokens {
256    /// Lexes `source` and records trivia and lexer errors.
257    #[must_use]
258    pub fn lex(source: &str) -> Self {
259        let lexed = lex_with_trivia(source);
260        Self {
261            tokens: lexed.tokens,
262            trivia: lexed.trivia,
263            lex_errors: lexed.errors,
264            laid_out_tokens: OnceLock::new(),
265        }
266    }
267
268    /// Raw lexer tokens in source order.
269    #[must_use]
270    pub fn tokens(&self) -> &[Token] {
271        &self.tokens
272    }
273
274    /// Whitespace and comment trivia between tokens.
275    #[must_use]
276    pub fn trivia(&self) -> &[Trivia] {
277        &self.trivia
278    }
279
280    /// Lexer diagnostics for malformed input.
281    #[must_use]
282    pub fn lex_errors(&self) -> &[LexError] {
283        &self.lex_errors
284    }
285
286    /// Tokens after layout resolution (virtual braces and semicolons inserted).
287    #[must_use]
288    pub fn laid_out_tokens(&self) -> &[Token] {
289        self.laid_out_tokens
290            .get_or_init(|| resolve_layout(self.tokens.clone()))
291    }
292}
293
294/// Parsed Daml module with diagnostics, line index, and lazy token access.
295#[derive(Debug)]
296pub struct SourceFile {
297    source: String,
298    module: Module,
299    diagnostics: Vec<Diagnostic>,
300    line_index: LineIndex,
301    tokens: OnceLock<SourceTokens>,
302}
303
304impl SourceFile {
305    /// Parses `source` into a module AST and source-facing presentation data.
306    ///
307    /// Malformed input still returns a partial module and surfaces diagnostics;
308    /// this function does not fail with `Result`.
309    #[must_use]
310    pub fn parse(source: &str) -> Self {
311        let parsed = parse_module(source);
312        let line_index = LineIndex::new(source);
313        let diagnostics = parsed
314            .diagnostics
315            .into_iter()
316            .map(|diagnostic| {
317                let range = try_parser_span_to_text_range(source, diagnostic.span)
318                    .expect("parser span in diagnostic must map to source bytes");
319                let start = range.start();
320                let end_column = source
321                    .get(usize::from(range.start())..usize::from(range.end()))
322                    .filter(|s| !s.is_empty() && !s.contains('\n'))
323                    .map(|s| CharColumn::new(diagnostic.pos.column + s.chars().count()));
324                let start_pos = line_index.char_line_col(start);
325                Diagnostic {
326                    range,
327                    line: start_pos.line,
328                    column: CharColumn::new(diagnostic.pos.column),
329                    end_column,
330                    message: diagnostic.message,
331                    category: diagnostic.category,
332                }
333            })
334            .collect();
335
336        Self {
337            source: source.to_string(),
338            module: parsed.module,
339            diagnostics,
340            line_index,
341            tokens: OnceLock::new(),
342        }
343    }
344
345    /// Original source text this file was parsed from.
346    #[must_use]
347    pub fn source(&self) -> &str {
348        &self.source
349    }
350
351    /// Parsed module AST from `daml-parser`.
352    #[must_use]
353    pub const fn module(&self) -> &Module {
354        &self.module
355    }
356
357    /// Parse and lexer diagnostics anchored in source text.
358    #[must_use]
359    pub fn diagnostics(&self) -> &[Diagnostic] {
360        &self.diagnostics
361    }
362
363    /// Line, column, and UTF-16 lookup tables for this source.
364    #[must_use]
365    pub const fn line_index(&self) -> &LineIndex {
366        &self.line_index
367    }
368
369    /// Raw lexer tokens for this source (lazy lex on first access).
370    #[must_use]
371    pub fn tokens(&self) -> &[Token] {
372        self.source_tokens().tokens()
373    }
374
375    /// Whitespace and comment trivia for this source.
376    #[must_use]
377    pub fn trivia(&self) -> &[Trivia] {
378        self.source_tokens().trivia()
379    }
380
381    /// Layout-resolved tokens for this source.
382    #[must_use]
383    pub fn laid_out_tokens(&self) -> &[Token] {
384        self.source_tokens().laid_out_tokens()
385    }
386
387    /// Convert a parser span from this source into a `text-size` byte range.
388    ///
389    /// This is the convenience API for spans that originate from this source file
390    /// and are expected to be valid.
391    ///
392    /// # Panics
393    ///
394    /// Panics when `span` does not map to valid UTF-8 source bytes in this
395    /// source.
396    #[must_use]
397    pub fn parser_span_to_text_range(&self, span: ParserSpan) -> TextRange {
398        self.try_parser_span_to_text_range(span)
399            .expect("parser span must map to a valid UTF-8 range in source")
400    }
401
402    /// Try to convert a parser span from this source into a `text-size` byte
403    /// range.
404    ///
405    /// This fallible API is the preferred choice for spans from external or
406    /// untrusted sources where offsets may be invalid. Use
407    /// [`SourceFile::parser_span_to_text_range`] for spans that originate from
408    /// this source and are expected to map to valid UTF-8 bytes.
409    #[must_use = "handle invalid span offsets before using the range"]
410    pub fn try_parser_span_to_text_range(
411        &self,
412        span: ParserSpan,
413    ) -> Result<TextRange, ParserSpanToTextRangeError> {
414        try_parser_span_to_text_range(&self.source, span)
415    }
416
417    fn source_tokens(&self) -> &SourceTokens {
418        self.tokens.get_or_init(|| SourceTokens::lex(&self.source))
419    }
420}
421
422/// Convert a parser span into a `text-size` byte range for an arbitrary source
423/// string.
424///
425/// This is a convenience wrapper around [`try_parser_span_to_text_range`] for
426/// spans that are expected to be valid.
427///
428/// # Panics
429///
430/// Panics when `span` does not map to valid UTF-8 source bytes in `source`.
431#[must_use]
432pub fn parser_span_to_text_range(source: &str, span: ParserSpan) -> TextRange {
433    try_parser_span_to_text_range(source, span)
434        .expect("parser span must map to a valid UTF-8 range")
435}
436
437/// Failure kind when converting a parser span to a [`TextRange`].
438#[derive(Debug, Clone, Copy, PartialEq, Eq)]
439#[non_exhaustive]
440pub enum ParserSpanToTextRangeErrorKind {
441    /// Span endpoint exceeds the source length.
442    OutOfBounds,
443    /// Span start is greater than end.
444    InvertedSpan,
445    /// Span endpoint is not on a UTF-8 character boundary.
446    NonUtf8Boundary,
447    /// Span endpoint cannot fit in [`TextSize`].
448    TextSizeOverflow,
449}
450
451/// Error returned when a parser span cannot be converted to a [`TextRange`].
452#[derive(Debug, Clone, PartialEq, Eq)]
453pub struct ParserSpanToTextRangeError {
454    source_len: usize,
455    span_start: ByteOffset,
456    span_end: ByteOffset,
457    kind: ParserSpanToTextRangeErrorKind,
458}
459
460impl ParserSpanToTextRangeError {
461    /// Length of the source string the span was checked against.
462    #[must_use]
463    pub const fn source_len(&self) -> usize {
464        self.source_len
465    }
466
467    /// Inclusive start byte offset from the parser span.
468    #[must_use]
469    pub const fn span_start(&self) -> ByteOffset {
470        self.span_start
471    }
472
473    /// Exclusive end byte offset from the parser span.
474    #[must_use]
475    pub const fn span_end(&self) -> ByteOffset {
476        self.span_end
477    }
478
479    /// Inclusive start byte offset as a raw `usize`.
480    #[must_use]
481    pub fn span_start_usize(&self) -> usize {
482        self.span_start.get()
483    }
484
485    /// Exclusive end byte offset as a raw `usize`.
486    #[must_use]
487    pub fn span_end_usize(&self) -> usize {
488        self.span_end.get()
489    }
490
491    /// Specific reason the conversion failed.
492    #[must_use]
493    pub const fn kind(&self) -> ParserSpanToTextRangeErrorKind {
494        self.kind
495    }
496}
497
498impl std::fmt::Display for ParserSpanToTextRangeError {
499    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
500        match self.kind {
501            ParserSpanToTextRangeErrorKind::TextSizeOverflow => write!(
502                f,
503                "parser span [{}, {}) cannot be represented as a text-size value",
504                self.span_start.get(),
505                self.span_end.get()
506            ),
507            _ => write!(
508                f,
509                "parser span [{}, {}) is invalid for source length {}",
510                self.span_start.get(),
511                self.span_end.get(),
512                self.source_len
513            ),
514        }
515    }
516}
517
518impl std::error::Error for ParserSpanToTextRangeError {}
519
520/// Try to convert a parser span into a `text-size` byte range.
521///
522/// This is the fallible API and should be used for spans sourced outside
523/// `SourceFile` where invalid offsets are possible; offsets must be valid
524/// UTF-8 character boundaries.
525#[must_use = "handle invalid span offsets before converting"]
526pub fn try_parser_span_to_text_range(
527    source: &str,
528    span: ParserSpan,
529) -> Result<TextRange, ParserSpanToTextRangeError> {
530    let source_len = source.len();
531    if span.start > source_len || span.end > source_len {
532        return Err(ParserSpanToTextRangeError {
533            source_len,
534            span_start: ByteOffset::new(span.start),
535            span_end: ByteOffset::new(span.end),
536            kind: ParserSpanToTextRangeErrorKind::OutOfBounds,
537        });
538    }
539    if span.start > span.end {
540        return Err(ParserSpanToTextRangeError {
541            source_len,
542            span_start: ByteOffset::new(span.start),
543            span_end: ByteOffset::new(span.end),
544            kind: ParserSpanToTextRangeErrorKind::InvertedSpan,
545        });
546    }
547    if !source.is_char_boundary(span.start) || !source.is_char_boundary(span.end) {
548        return Err(ParserSpanToTextRangeError {
549            source_len,
550            span_start: ByteOffset::new(span.start),
551            span_end: ByteOffset::new(span.end),
552            kind: ParserSpanToTextRangeErrorKind::NonUtf8Boundary,
553        });
554    }
555    Ok(TextRange::new(
556        TextSize::try_from(span.start).map_err(|_| ParserSpanToTextRangeError {
557            source_len,
558            span_start: ByteOffset::new(span.start),
559            span_end: ByteOffset::new(span.end),
560            kind: ParserSpanToTextRangeErrorKind::TextSizeOverflow,
561        })?,
562        TextSize::try_from(span.end).map_err(|_| ParserSpanToTextRangeError {
563            source_len,
564            span_start: ByteOffset::new(span.start),
565            span_end: ByteOffset::new(span.end),
566            kind: ParserSpanToTextRangeErrorKind::TextSizeOverflow,
567        })?,
568    ))
569}
570
571// README examples are compile-tested by `cargo test -p daml-syntax --doc`.
572#[doc(hidden)]
573mod readme_examples {
574    //! ```rust
575    //! use daml_syntax::{LineNumber, SourceFile, SourceTokens};
576    //!
577    //! let source = "module M where\nfoo : Int\nfoo = 1\n";
578    //! let file = SourceFile::parse(source);
579    //!
580    //! assert!(file.diagnostics().is_empty());
581    //! assert_eq!(file.module().name, "M");
582    //! assert_eq!(file.line_index().line_col(0.into()).line, LineNumber::new(1));
583    //!
584    //! let tokens = SourceTokens::lex(source);
585    //!
586    //! assert!(tokens.lex_errors().is_empty());
587    //! assert!(!tokens.laid_out_tokens().is_empty());
588    //! ```
589}
590
591#[cfg(test)]
592#[allow(clippy::unwrap_used)]
593mod tests {
594    use super::*;
595    use daml_parser::ast_span::render_from_ast;
596    use daml_parser::lexer::render_lossless;
597
598    #[test]
599    fn maps_empty_source_to_first_line() {
600        let index = LineIndex::new("");
601
602        assert_eq!(
603            index.line_col(0.into()),
604            ByteLineCol {
605                line: LineNumber::new(1),
606                column: ByteColumn::new(1),
607            }
608        );
609        assert_eq!(
610            index.utf16_range(TextRange::empty(0.into())),
611            (Utf16Offset::new(0), Utf16Offset::new(0))
612        );
613    }
614
615    #[test]
616    fn maps_ascii_byte_lines() {
617        let source = "module M where\nfoo = 1\n";
618        let index = LineIndex::new(source);
619
620        assert_eq!(
621            index.line_col(15.into()),
622            ByteLineCol {
623                line: LineNumber::new(2),
624                column: ByteColumn::new(1),
625            }
626        );
627        assert_eq!(
628            index.utf16_col(LineNumber::new(2), ByteColumn::new(4)),
629            Utf16Offset::new(3)
630        );
631    }
632
633    #[test]
634    fn maps_utf8_and_utf16_offsets() {
635        let source = "a😀b\nz";
636        let index = LineIndex::new(source);
637
638        assert_eq!(
639            index.utf16_range(TextRange::new(0.into(), 6.into())),
640            (Utf16Offset::new(0), Utf16Offset::new(4))
641        );
642        assert_eq!(
643            index.utf16_col(LineNumber::new(1), ByteColumn::new(6)),
644            Utf16Offset::new(3)
645        );
646        assert_eq!(
647            index.char_line_col(5.into()),
648            CharLineCol {
649                line: LineNumber::new(1),
650                column: CharColumn::new(3),
651            }
652        );
653    }
654
655    #[test]
656    fn char_line_col_snaps_to_previous_utf8_boundary() {
657        let source = "a😀b";
658        let index = LineIndex::new(source);
659
660        // Offset 3 is inside the 4-byte 😀 sequence (1..5), so we expect snapping to 1.
661        assert_eq!(
662            index.char_line_col(3.into()),
663            CharLineCol {
664                line: LineNumber::new(1),
665                column: CharColumn::new(2),
666            }
667        );
668    }
669
670    #[test]
671    fn preserves_trailing_newline_line_start() {
672        let index = LineIndex::new("a\n");
673
674        assert_eq!(
675            index.line_col(2.into()),
676            ByteLineCol {
677                line: LineNumber::new(2),
678                column: ByteColumn::new(1),
679            }
680        );
681    }
682
683    #[test]
684    fn treats_crlf_as_bytes_without_normalization() {
685        let index = LineIndex::new("a\r\nb");
686
687        assert_eq!(
688            index.line_col(3.into()),
689            ByteLineCol {
690                line: LineNumber::new(2),
691                column: ByteColumn::new(1),
692            }
693        );
694    }
695
696    #[test]
697    fn clamps_ranges_to_source_end() {
698        let index = LineIndex::new("abc");
699        let range = TextRange::new(1.into(), 99.into());
700
701        assert_eq!(
702            index.utf16_range(range),
703            (Utf16Offset::new(1), Utf16Offset::new(3))
704        );
705    }
706
707    #[test]
708    fn byte_and_char_line_col_columns_differ_for_multibyte_utf8() {
709        let source = "😀\n";
710        let index = LineIndex::new(source);
711        let offset = TextSize::from(4);
712
713        let byte_pos = index.line_col(offset);
714        let char_pos = index.char_line_col(offset);
715
716        assert_eq!(byte_pos.line, char_pos.line);
717        assert_ne!(byte_pos.column.get(), char_pos.column.get());
718        assert_eq!(byte_pos.column, ByteColumn::new(5));
719        assert_eq!(char_pos.column, CharColumn::new(2));
720    }
721
722    #[test]
723    fn source_file_exposes_parser_pipeline_facts() {
724        let source = "module M where\nfoo : Int\nfoo = 1\n";
725        let file = SourceFile::parse(source);
726
727        assert_eq!(file.source(), source);
728        assert_eq!(file.module().name, "M");
729        assert!(file.diagnostics().is_empty());
730        assert!(!file.tokens().is_empty());
731        assert!(!file.laid_out_tokens().is_empty());
732        assert_eq!(
733            render_lossless(source, file.tokens(), file.trivia()).as_deref(),
734            Ok(source)
735        );
736        assert_eq!(
737            render_from_ast(source, file.module(), file.trivia()).as_deref(),
738            Ok(source)
739        );
740    }
741
742    #[test]
743    fn source_tokens_exposes_lex_only_pipeline_facts() {
744        let source = "module M where\nfoo : Int\nfoo = 1\n";
745        let tokens = SourceTokens::lex(source);
746
747        assert!(tokens.lex_errors().is_empty());
748        assert!(!tokens.tokens().is_empty());
749        assert!(!tokens.laid_out_tokens().is_empty());
750        assert_eq!(
751            render_lossless(source, tokens.tokens(), tokens.trivia()).as_deref(),
752            Ok(source)
753        );
754    }
755
756    #[test]
757    fn malformed_source_keeps_source_file_and_diagnostics() {
758        let file = SourceFile::parse("module M where\nfoo = \"unterminated\nbar = 1\n");
759
760        assert_eq!(file.module().name, "M");
761        assert!(file
762            .diagnostics()
763            .iter()
764            .any(|diagnostic| diagnostic.category() == DiagnosticCategory::Lex));
765    }
766
767    #[test]
768    fn converts_parser_spans_to_text_ranges() {
769        let file = SourceFile::parse("module M where\nfoo = 1\n");
770        let source_len = file.source().len();
771        let range = file.parser_span_to_text_range(ParserSpan::new(0, source_len));
772
773        assert_eq!(
774            range,
775            TextRange::new(0.into(), source_len.try_into().unwrap())
776        );
777    }
778
779    #[test]
780    fn try_parser_span_to_text_range_rejects_out_of_bounds_spans() {
781        let source = "module M where\nfoo = 1\n";
782        let err = try_parser_span_to_text_range(source, ParserSpan::new(0, source.len() + 1))
783            .unwrap_err();
784        assert_eq!(err.kind(), ParserSpanToTextRangeErrorKind::OutOfBounds);
785        assert_eq!(
786            err.to_string(),
787            format!(
788                "parser span [0, {}) is invalid for source length {}",
789                source.len() + 1,
790                source.len()
791            )
792        );
793        assert_eq!(err.source_len(), source.len());
794        assert_eq!(err.span_start(), ByteOffset::new(0));
795        assert_eq!(err.span_end(), ByteOffset::new(source.len() + 1));
796        assert_eq!(err.span_start_usize(), 0);
797        assert_eq!(err.span_end_usize(), source.len() + 1);
798    }
799
800    #[test]
801    fn try_parser_span_to_text_range_reports_inverted_spans() {
802        let source = "abc";
803        let err = try_parser_span_to_text_range(source, ParserSpan::new(2, 1)).unwrap_err();
804        assert_eq!(err.kind(), ParserSpanToTextRangeErrorKind::InvertedSpan);
805        assert_eq!(
806            err.to_string(),
807            "parser span [2, 1) is invalid for source length 3"
808        );
809        assert_eq!(err.source_len(), source.len());
810        assert_eq!(err.span_start(), ByteOffset::new(2));
811        assert_eq!(err.span_end(), ByteOffset::new(1));
812    }
813
814    #[test]
815    fn try_parser_span_to_text_range_rejects_non_utf8_boundary_spans() {
816        let source = "a😀b";
817        let err = try_parser_span_to_text_range(source, ParserSpan::new(1, 2)).unwrap_err();
818        assert_eq!(err.kind(), ParserSpanToTextRangeErrorKind::NonUtf8Boundary);
819
820        assert_eq!(
821            err.to_string(),
822            "parser span [1, 2) is invalid for source length 6"
823        );
824        assert_eq!(err.source_len(), source.len());
825        assert_eq!(err.span_start(), ByteOffset::new(1));
826        assert_eq!(err.span_end(), ByteOffset::new(2));
827    }
828
829    #[test]
830    fn diagnostics_are_read_through_accessors_not_field_literals() {
831        let file = SourceFile::parse("module M where\nfoo = \"unterminated\n");
832
833        let diagnostic = file
834            .diagnostics()
835            .first()
836            .expect("malformed source should surface diagnostics");
837        assert_eq!(diagnostic.category(), DiagnosticCategory::Lex);
838        assert!(!diagnostic.message().is_empty());
839        assert!(diagnostic.range().start() <= diagnostic.range().end());
840        assert!(diagnostic.line().get() >= 1);
841        assert!(diagnostic.column().get() >= 1);
842    }
843
844    #[test]
845    fn utf16_col_requires_non_zero_line_and_column() {
846        assert!(LineNumber::try_new(0).is_none());
847        assert!(ByteColumn::try_new(0).is_none());
848    }
849
850    #[test]
851    fn try_parser_span_to_text_range_succeeds_for_valid_span() {
852        let source = "module M where\nfoo = 1\n";
853        let range = try_parser_span_to_text_range(source, ParserSpan::new(0, 5))
854            .expect("span should be valid");
855        assert_eq!(range, TextRange::new(0.into(), 5.into()));
856    }
857}