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`] or
22//! `TryFrom<usize>`; 0-based offsets use [`ByteOffset`] and [`Utf16Offset`].
23//! Use `usize::from(coordinate)` for explicit raw extraction.
24//! UTF-16 column lookup returns [`CoordinateRangeError`] for line or column
25//! coordinates outside a source, and UTF-16 ranges use [`Utf16Range`] so
26//! JavaScript string slices cannot be mistaken for byte ranges.
27//!
28//! ```rust
29//! use daml_syntax::{parser_span_to_text_range, SourceFile};
30//!
31//! let source = "module M where\nfoo : Int\nfoo = 1\n";
32//! let file = SourceFile::parse(source);
33//!
34//! assert_eq!(file.module().name, "M");
35//! assert!(file.diagnostics().is_empty());
36//! assert!(!file.tokens().is_empty());
37//! assert!(!file.laid_out_tokens().is_empty());
38//!
39//! let header_range = parser_span_to_text_range(source, file.module().header);
40//! assert_eq!(usize::from(header_range.start()), 0);
41//! assert_eq!(header_range, file.parser_span_to_text_range(file.module().header));
42//! ```
43//!
44//! Fallible coordinate and span conversion reject invalid inputs instead of
45//! clamping or panicking:
46//!
47//! ```rust
48//! use daml_parser::ast::Span;
49//! use daml_syntax::{ByteColumn, LineIndex, LineNumber, SourceFile};
50//!
51//! let source = "module M where\n";
52//! let file = SourceFile::parse(source);
53//! assert!(
54//!     file.try_parser_span_to_text_range(Span::from_usize(999, 1000))
55//!         .is_err()
56//! );
57//!
58//! let index = LineIndex::new(source);
59//! assert!(index.utf16_col(LineNumber::new(99), ByteColumn::new(1)).is_err());
60//! assert!(LineNumber::try_new(0).is_none());
61//! ```
62
63use daml_parser::ast::{DiagnosticCategory, Module, Span as ParserSpan};
64use daml_parser::layout::resolve_layout;
65use daml_parser::lexer::{lex_with_trivia, LexError, Token, Trivia};
66use daml_parser::parse::parse_module;
67use std::sync::OnceLock;
68
69pub mod coordinate;
70
71pub use coordinate::{
72    ByteColumn, ByteLineCol, ByteOffset, CharColumn, CharLineCol, InvalidOneBasedCoordinate,
73    LineNumber, Utf16Offset, Utf16Range,
74};
75pub use text_size::{TextRange, TextSize};
76
77/// A parser or lexer diagnostic anchored in source text.
78///
79/// Constructed by [`SourceFile::parse`]; read fields through accessors so future
80/// metadata (severity, codes, notes) can be added without breaking callers.
81/// Diagnostic positions are 1-based Unicode scalar columns; byte ranges remain
82/// available through [`Diagnostic::range`].
83#[derive(Debug, Clone, PartialEq, Eq)]
84#[non_exhaustive]
85pub struct Diagnostic {
86    range: TextRange,
87    line: LineNumber,
88    column: CharColumn,
89    end_column: DiagnosticEndColumn,
90    message: String,
91    category: DiagnosticCategory,
92}
93
94/// End-column shape for a diagnostic span.
95///
96/// The end column is an exclusive 1-based Unicode-scalar column only when a
97/// diagnostic covers non-empty text on one line. Multi-line and empty spans are
98/// separate variants so callers cannot silently treat both cases as `None`.
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100#[non_exhaustive]
101pub enum DiagnosticEndColumn {
102    /// Non-empty single-line diagnostic ending at this exclusive column.
103    SameLineEnd(CharColumn),
104    /// Diagnostic span crosses at least one newline.
105    Multiline,
106    /// Diagnostic span is zero-width.
107    EmptySpan,
108}
109
110impl Diagnostic {
111    /// Byte range of the diagnostic in source text.
112    #[must_use]
113    pub const fn range(&self) -> TextRange {
114        self.range
115    }
116
117    /// 1-based line number of the diagnostic start.
118    #[must_use]
119    pub const fn line(&self) -> LineNumber {
120        self.line
121    }
122
123    /// 1-based character column of the diagnostic start.
124    #[must_use]
125    pub const fn column(&self) -> CharColumn {
126        self.column
127    }
128
129    /// End-column shape for the diagnostic span.
130    ///
131    /// [`DiagnosticEndColumn::SameLineEnd`] contains the exclusive 1-based
132    /// Unicode-scalar column for non-empty same-line spans. Empty and multi-line
133    /// spans are reported as distinct variants instead of a nullable column.
134    #[must_use]
135    pub const fn end_column(&self) -> DiagnosticEndColumn {
136        self.end_column
137    }
138
139    /// Human-readable diagnostic message.
140    #[must_use]
141    pub fn message(&self) -> &str {
142        &self.message
143    }
144
145    /// Parser diagnostic category from `daml-parser`.
146    #[must_use]
147    pub const fn category(&self) -> DiagnosticCategory {
148        self.category
149    }
150}
151
152/// Precomputed line, character, and UTF-16 offset tables for a source string.
153///
154/// Byte offsets passed to [`LineIndex::line_col`] and
155/// [`LineIndex::char_line_col`] are clamped to the source length, and
156/// [`LineIndex::utf16_range`] clamps ranges to source bounds.
157/// [`LineIndex::utf16_col`] is deliberately fallible for line/column pairs and
158/// returns [`CoordinateRangeError`] when the pair is outside this source.
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub struct LineIndex {
161    source_len: usize,
162    line_start_bytes: Vec<usize>,
163    char_offset_by_byte: Vec<usize>,
164    utf16_offset_by_byte: Vec<usize>,
165}
166
167impl LineIndex {
168    /// Builds line and column lookup tables for `source`.
169    #[must_use]
170    pub fn new(source: &str) -> Self {
171        let mut line_start_bytes = vec![0];
172        for (idx, byte) in source.bytes().enumerate() {
173            if byte == b'\n' {
174                line_start_bytes.push(idx + 1);
175            }
176        }
177
178        let mut char_offset_by_byte = vec![0; source.len() + 1];
179        let mut char_count = 0usize;
180        let mut prev = 0usize;
181        for (idx, ch) in source.char_indices() {
182            for slot in char_offset_by_byte.iter_mut().take(idx).skip(prev) {
183                *slot = char_count;
184            }
185            let char_end = idx + ch.len_utf8();
186            for slot in char_offset_by_byte.iter_mut().take(char_end).skip(idx) {
187                *slot = char_count;
188            }
189            char_count += 1;
190            prev = char_end;
191        }
192        for slot in char_offset_by_byte
193            .iter_mut()
194            .take(source.len() + 1)
195            .skip(prev)
196        {
197            *slot = char_count;
198        }
199
200        let mut utf16_offset_by_byte = vec![0; source.len() + 1];
201        let mut utf16 = 0usize;
202        let mut prev = 0usize;
203        for (idx, ch) in source.char_indices() {
204            for slot in utf16_offset_by_byte.iter_mut().take(idx).skip(prev) {
205                *slot = utf16;
206            }
207            let char_end = idx + ch.len_utf8();
208            for slot in utf16_offset_by_byte.iter_mut().take(char_end).skip(idx) {
209                *slot = utf16;
210            }
211            utf16 += ch.len_utf16();
212            prev = char_end;
213        }
214        for slot in utf16_offset_by_byte
215            .iter_mut()
216            .take(source.len() + 1)
217            .skip(prev)
218        {
219            *slot = utf16;
220        }
221
222        Self {
223            source_len: source.len(),
224            line_start_bytes,
225            char_offset_by_byte,
226            utf16_offset_by_byte,
227        }
228    }
229
230    /// Source length in bytes, represented as the EOF byte offset.
231    #[must_use]
232    pub const fn source_len_bytes(&self) -> ByteOffset {
233        ByteOffset::new(self.source_len)
234    }
235
236    /// Maps a byte offset to a 1-based line and byte column, clamping past EOF.
237    #[must_use]
238    pub fn line_col(&self, offset: TextSize) -> ByteLineCol {
239        let byte = usize::from(offset).min(self.source_len);
240        let line_idx = match self.line_start_bytes.binary_search(&byte) {
241            Ok(idx) => idx,
242            Err(idx) => idx.saturating_sub(1),
243        };
244        ByteLineCol {
245            line: LineNumber::new(line_idx + 1),
246            column: ByteColumn::new(byte - self.line_start_bytes[line_idx] + 1),
247        }
248    }
249
250    /// Maps a byte offset to a 1-based line and Unicode scalar column.
251    ///
252    /// Non-UTF-8-boundary offsets snap to the previous character boundary.
253    #[must_use]
254    pub fn char_line_col(&self, offset: TextSize) -> CharLineCol {
255        let byte = usize::from(offset).min(self.source_len);
256        let line_idx = match self.line_start_bytes.binary_search(&byte) {
257            Ok(idx) => idx,
258            Err(idx) => idx.saturating_sub(1),
259        };
260        let line_start = self.line_start_bytes[line_idx];
261        CharLineCol {
262            line: LineNumber::new(line_idx + 1),
263            column: CharColumn::new(
264                self.char_offset_by_byte[byte] - self.char_offset_by_byte[line_start] + 1,
265            ),
266        }
267    }
268
269    /// Returns the UTF-16 code-unit offset from the start of `line` to `byte_col`.
270    ///
271    /// Both coordinates must be within this source. Zero cannot be represented
272    /// by [`LineNumber`] or [`ByteColumn`], and this method returns a typed
273    /// error instead of clamping lines or columns past the source.
274    ///
275    /// # Errors
276    ///
277    /// Returns [`CoordinateRangeError`] when `line` is past the source line
278    /// count or `byte_col` is past the end column for that line.
279    #[must_use = "handle invalid source coordinates before using the UTF-16 offset"]
280    pub fn utf16_col(
281        &self,
282        line: LineNumber,
283        byte_col: ByteColumn,
284    ) -> Result<Utf16Offset, CoordinateRangeError> {
285        let line_idx = line.get() - 1;
286        let Some(line_start) = self.line_start_bytes.get(line_idx).copied() else {
287            return Err(CoordinateRangeError {
288                line,
289                byte_column: byte_col,
290                source_line_count: LineNumber::new(self.line_start_bytes.len()),
291                max_byte_column: None,
292                kind: CoordinateRangeErrorKind::LineOutOfRange,
293            });
294        };
295
296        let line_end = self.line_end_byte(line_idx);
297        let max_byte_column = ByteColumn::new(line_end - line_start + 1);
298        let byte = byte_col
299            .get()
300            .checked_sub(1)
301            .and_then(|column_offset| line_start.checked_add(column_offset));
302        let Some(byte) = byte.filter(|byte| *byte <= line_end) else {
303            return Err(CoordinateRangeError {
304                line,
305                byte_column: byte_col,
306                source_line_count: LineNumber::new(self.line_start_bytes.len()),
307                max_byte_column: Some(max_byte_column),
308                kind: CoordinateRangeErrorKind::ColumnOutOfRange,
309            });
310        };
311
312        Ok(Utf16Offset::new(
313            self.utf16_offset_by_byte[byte] - self.utf16_offset_by_byte[line_start],
314        ))
315    }
316
317    /// Maps a byte range to UTF-16 start/end offsets, clamping to source bounds.
318    #[must_use]
319    pub fn utf16_range(&self, range: TextRange) -> Utf16Range {
320        let start = usize::from(range.start()).min(self.source_len);
321        let end = usize::from(range.end()).min(self.source_len).max(start);
322        Utf16Range::new(
323            Utf16Offset::new(self.utf16_offset_by_byte[start]),
324            Utf16Offset::new(self.utf16_offset_by_byte[end]),
325        )
326    }
327
328    fn line_end_byte(&self, line_idx: usize) -> usize {
329        self.line_start_bytes
330            .get(line_idx + 1)
331            .map_or(self.source_len, |next_line_start| next_line_start - 1)
332    }
333}
334
335/// Failure kind when resolving a line and byte column in a [`LineIndex`].
336#[derive(Debug, Clone, Copy, PartialEq, Eq)]
337#[non_exhaustive]
338pub enum CoordinateRangeErrorKind {
339    /// Requested line is past the source line count.
340    LineOutOfRange,
341    /// Requested byte column is past the end column for its line.
342    ColumnOutOfRange,
343}
344
345/// Error returned when a source coordinate is outside a [`LineIndex`].
346#[derive(Debug, Clone, PartialEq, Eq)]
347pub struct CoordinateRangeError {
348    line: LineNumber,
349    byte_column: ByteColumn,
350    source_line_count: LineNumber,
351    max_byte_column: Option<ByteColumn>,
352    kind: CoordinateRangeErrorKind,
353}
354
355impl CoordinateRangeError {
356    /// Requested 1-based line number.
357    #[must_use]
358    pub const fn line(&self) -> LineNumber {
359        self.line
360    }
361
362    /// Requested 1-based byte column.
363    #[must_use]
364    pub const fn byte_column(&self) -> ByteColumn {
365        self.byte_column
366    }
367
368    /// Number of lines known to the [`LineIndex`].
369    #[must_use]
370    pub const fn source_line_count(&self) -> LineNumber {
371        self.source_line_count
372    }
373
374    /// Last valid 1-based byte column on the requested line, when the line exists.
375    #[must_use]
376    pub const fn max_byte_column(&self) -> Option<ByteColumn> {
377        self.max_byte_column
378    }
379
380    /// Specific reason coordinate resolution failed.
381    #[must_use]
382    pub const fn kind(&self) -> CoordinateRangeErrorKind {
383        self.kind
384    }
385}
386
387impl std::fmt::Display for CoordinateRangeError {
388    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
389        match self.kind {
390            CoordinateRangeErrorKind::LineOutOfRange => write!(
391                f,
392                "line {} is outside source line range 1..={}",
393                self.line, self.source_line_count
394            ),
395            CoordinateRangeErrorKind::ColumnOutOfRange => {
396                if let Some(max_byte_column) = self.max_byte_column {
397                    write!(
398                        f,
399                        "byte column {} is outside valid range 1..={} on line {}",
400                        self.byte_column, max_byte_column, self.line
401                    )
402                } else {
403                    write!(
404                        f,
405                        "byte column {} is outside the valid range on line {}",
406                        self.byte_column, self.line
407                    )
408                }
409            }
410        }
411    }
412}
413
414impl std::error::Error for CoordinateRangeError {}
415
416/// Lexer output for a source string without running the full module parser.
417///
418/// Use [`SourceFile`] when you also need the AST and parse diagnostics.
419#[derive(Debug)]
420pub struct SourceTokens {
421    tokens: Vec<Token>,
422    trivia: Vec<Trivia>,
423    lex_errors: Vec<LexError>,
424    laid_out_tokens: OnceLock<Vec<Token>>,
425}
426
427impl Clone for SourceTokens {
428    fn clone(&self) -> Self {
429        let cloned = Self {
430            tokens: self.tokens.clone(),
431            trivia: self.trivia.clone(),
432            lex_errors: self.lex_errors.clone(),
433            laid_out_tokens: OnceLock::new(),
434        };
435        if let Some(laid_out_tokens) = self.laid_out_tokens.get() {
436            let _ = cloned.laid_out_tokens.set(laid_out_tokens.clone());
437        }
438        cloned
439    }
440}
441
442impl PartialEq for SourceTokens {
443    fn eq(&self, other: &Self) -> bool {
444        self.tokens == other.tokens
445            && self.trivia == other.trivia
446            && self.lex_errors == other.lex_errors
447    }
448}
449
450impl Eq for SourceTokens {}
451
452impl SourceTokens {
453    /// Lexes `source` and records trivia and lexer errors.
454    #[must_use]
455    pub fn lex(source: &str) -> Self {
456        let lexed = lex_with_trivia(source);
457        Self {
458            tokens: lexed.tokens,
459            trivia: lexed.trivia,
460            lex_errors: lexed.errors,
461            laid_out_tokens: OnceLock::new(),
462        }
463    }
464
465    /// Raw lexer tokens in source order.
466    #[must_use]
467    pub fn tokens(&self) -> &[Token] {
468        &self.tokens
469    }
470
471    /// Whitespace and comment trivia between tokens.
472    #[must_use]
473    pub fn trivia(&self) -> &[Trivia] {
474        &self.trivia
475    }
476
477    /// Lexer diagnostics for malformed input.
478    #[must_use]
479    pub fn lex_errors(&self) -> &[LexError] {
480        &self.lex_errors
481    }
482
483    /// Tokens after layout resolution (virtual braces and semicolons inserted).
484    #[must_use]
485    pub fn laid_out_tokens(&self) -> &[Token] {
486        self.laid_out_tokens
487            .get_or_init(|| resolve_layout(self.tokens.clone()))
488    }
489}
490
491/// Parsed Daml module with diagnostics, line index, and lazy token access.
492#[derive(Debug)]
493pub struct SourceFile {
494    source: String,
495    module: Module,
496    diagnostics: Vec<Diagnostic>,
497    line_index: LineIndex,
498    tokens: OnceLock<SourceTokens>,
499}
500
501impl Clone for SourceFile {
502    fn clone(&self) -> Self {
503        let cloned = Self {
504            source: self.source.clone(),
505            module: self.module.clone(),
506            diagnostics: self.diagnostics.clone(),
507            line_index: self.line_index.clone(),
508            tokens: OnceLock::new(),
509        };
510        if let Some(tokens) = self.tokens.get() {
511            let _ = cloned.tokens.set(tokens.clone());
512        }
513        cloned
514    }
515}
516
517impl PartialEq for SourceFile {
518    fn eq(&self, other: &Self) -> bool {
519        self.source == other.source
520            && self.module == other.module
521            && self.diagnostics == other.diagnostics
522            && self.line_index == other.line_index
523    }
524}
525
526impl Eq for SourceFile {}
527
528impl SourceFile {
529    /// Parses `source` into a module AST and source-facing presentation data.
530    ///
531    /// Malformed input still returns a partial module and surfaces diagnostics;
532    /// this function does not fail with `Result`.
533    ///
534    /// # Panics
535    ///
536    /// Panics when a parser diagnostic span does not map to valid UTF-8 source
537    /// bytes in `source`.
538    #[must_use]
539    pub fn parse(source: &str) -> Self {
540        let parsed = parse_module(source);
541        let line_index = LineIndex::new(source);
542        let diagnostics = parsed
543            .diagnostics
544            .into_iter()
545            .map(|diagnostic| {
546                let range = try_parser_span_to_text_range(source, diagnostic.span)
547                    .expect("parser span in diagnostic must map to source bytes");
548                let start = range.start();
549                let start_pos = line_index.char_line_col(start);
550                let end_column = diagnostic_end_column(source, range, start_pos.column);
551                Diagnostic {
552                    range,
553                    line: start_pos.line,
554                    column: start_pos.column,
555                    end_column,
556                    message: diagnostic.message,
557                    category: diagnostic.category,
558                }
559            })
560            .collect();
561
562        Self {
563            source: source.to_string(),
564            module: parsed.module,
565            diagnostics,
566            line_index,
567            tokens: OnceLock::new(),
568        }
569    }
570
571    /// Original source text this file was parsed from.
572    #[must_use]
573    pub fn source(&self) -> &str {
574        &self.source
575    }
576
577    /// Parsed module AST from `daml-parser`.
578    #[must_use]
579    pub const fn module(&self) -> &Module {
580        &self.module
581    }
582
583    /// Parse and lexer diagnostics anchored in source text.
584    #[must_use]
585    pub fn diagnostics(&self) -> &[Diagnostic] {
586        &self.diagnostics
587    }
588
589    /// Line, column, and UTF-16 lookup tables for this source.
590    #[must_use]
591    pub const fn line_index(&self) -> &LineIndex {
592        &self.line_index
593    }
594
595    /// Raw lexer tokens for this source (lazy lex on first access).
596    #[must_use]
597    pub fn tokens(&self) -> &[Token] {
598        self.source_tokens().tokens()
599    }
600
601    /// Whitespace and comment trivia for this source.
602    #[must_use]
603    pub fn trivia(&self) -> &[Trivia] {
604        self.source_tokens().trivia()
605    }
606
607    /// Layout-resolved tokens for this source.
608    #[must_use]
609    pub fn laid_out_tokens(&self) -> &[Token] {
610        self.source_tokens().laid_out_tokens()
611    }
612
613    /// Convert a parser span from this source into a `text-size` byte range.
614    ///
615    /// This is the convenience API for spans that originate from this source file
616    /// and are expected to be valid.
617    ///
618    /// # Panics
619    ///
620    /// Panics when `span` does not map to valid UTF-8 source bytes in this
621    /// source.
622    #[must_use]
623    pub fn parser_span_to_text_range(&self, span: ParserSpan) -> TextRange {
624        self.try_parser_span_to_text_range(span)
625            .expect("parser span must map to a valid UTF-8 range in source")
626    }
627
628    /// Try to convert a parser span from this source into a `text-size` byte
629    /// range.
630    ///
631    /// This fallible API is the preferred choice for spans from external or
632    /// untrusted sources where offsets may be invalid. Use
633    /// [`SourceFile::parser_span_to_text_range`] for spans that originate from
634    /// this source and are expected to map to valid UTF-8 bytes.
635    ///
636    /// # Errors
637    ///
638    /// Returns [`ParserSpanToTextRangeError`] when `span` is out of bounds,
639    /// inverted, not on a UTF-8 boundary, or cannot fit in [`TextSize`].
640    #[must_use = "handle invalid span offsets before using the range"]
641    pub fn try_parser_span_to_text_range(
642        &self,
643        span: ParserSpan,
644    ) -> Result<TextRange, ParserSpanToTextRangeError> {
645        try_parser_span_to_text_range(&self.source, span)
646    }
647
648    fn source_tokens(&self) -> &SourceTokens {
649        self.tokens.get_or_init(|| SourceTokens::lex(&self.source))
650    }
651}
652
653fn diagnostic_end_column(
654    source: &str,
655    range: TextRange,
656    start_column: CharColumn,
657) -> DiagnosticEndColumn {
658    let span_text = source
659        .get(usize::from(range.start())..usize::from(range.end()))
660        .expect("validated parser span should slice source");
661    if span_text.is_empty() {
662        DiagnosticEndColumn::EmptySpan
663    } else if span_text.contains('\n') {
664        DiagnosticEndColumn::Multiline
665    } else {
666        DiagnosticEndColumn::SameLineEnd(CharColumn::new(
667            start_column.get() + span_text.chars().count(),
668        ))
669    }
670}
671
672/// Convert a parser span into a `text-size` byte range for an arbitrary source
673/// string.
674///
675/// This is a convenience wrapper around [`try_parser_span_to_text_range`] for
676/// spans that are expected to be valid.
677///
678/// # Panics
679///
680/// Panics when `span` does not map to valid UTF-8 source bytes in `source`.
681#[must_use]
682pub fn parser_span_to_text_range(source: &str, span: ParserSpan) -> TextRange {
683    try_parser_span_to_text_range(source, span)
684        .expect("parser span must map to a valid UTF-8 range")
685}
686
687/// Failure kind when converting a parser span to a [`TextRange`].
688#[derive(Debug, Clone, Copy, PartialEq, Eq)]
689#[non_exhaustive]
690pub enum ParserSpanToTextRangeErrorKind {
691    /// Span endpoint exceeds the source length.
692    OutOfBounds,
693    /// Span start is greater than end.
694    InvertedSpan,
695    /// Span endpoint is not on a UTF-8 character boundary.
696    NonUtf8Boundary,
697    /// Span endpoint cannot fit in [`TextSize`].
698    TextSizeOverflow,
699}
700
701impl std::fmt::Display for ParserSpanToTextRangeErrorKind {
702    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
703        match self {
704            Self::OutOfBounds => f.write_str("out of bounds"),
705            Self::InvertedSpan => f.write_str("start after end"),
706            Self::NonUtf8Boundary => f.write_str("not on a UTF-8 boundary"),
707            Self::TextSizeOverflow => f.write_str("text-size overflow"),
708        }
709    }
710}
711
712/// Error returned when a parser span cannot be converted to a [`TextRange`].
713#[derive(Debug, Clone, PartialEq, Eq)]
714pub struct ParserSpanToTextRangeError {
715    source_len: usize,
716    span_start: ByteOffset,
717    span_end: ByteOffset,
718    kind: ParserSpanToTextRangeErrorKind,
719}
720
721impl ParserSpanToTextRangeError {
722    /// Length of the source string the span was checked against.
723    #[must_use]
724    pub const fn source_len_bytes(&self) -> ByteOffset {
725        ByteOffset::new(self.source_len)
726    }
727
728    /// Inclusive start byte offset from the parser span.
729    #[must_use]
730    pub const fn span_start(&self) -> ByteOffset {
731        self.span_start
732    }
733
734    /// Exclusive end byte offset from the parser span.
735    #[must_use]
736    pub const fn span_end(&self) -> ByteOffset {
737        self.span_end
738    }
739
740    /// Specific reason the conversion failed.
741    #[must_use]
742    pub const fn kind(&self) -> ParserSpanToTextRangeErrorKind {
743        self.kind
744    }
745}
746
747impl std::fmt::Display for ParserSpanToTextRangeError {
748    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
749        match self.kind {
750            ParserSpanToTextRangeErrorKind::OutOfBounds
751            | ParserSpanToTextRangeErrorKind::InvertedSpan
752            | ParserSpanToTextRangeErrorKind::NonUtf8Boundary => write!(
753                f,
754                "parser span [{}, {}) is invalid: {} for source length {}",
755                self.span_start.get(),
756                self.span_end.get(),
757                self.kind,
758                self.source_len
759            ),
760            ParserSpanToTextRangeErrorKind::TextSizeOverflow => write!(
761                f,
762                "parser span [{}, {}) is invalid: {}; endpoints cannot be represented as text-size values",
763                self.span_start.get(),
764                self.span_end.get(),
765                self.kind
766            ),
767        }
768    }
769}
770
771impl std::error::Error for ParserSpanToTextRangeError {}
772
773/// Try to convert a parser span into a `text-size` byte range.
774///
775/// This is the fallible API and should be used for spans sourced outside
776/// `SourceFile` where invalid offsets are possible; offsets must be valid
777/// UTF-8 character boundaries.
778///
779/// # Errors
780///
781/// Returns [`ParserSpanToTextRangeError`] when `span` is out of bounds,
782/// inverted, not on a UTF-8 boundary, or cannot fit in [`TextSize`].
783#[must_use = "handle invalid span offsets before converting"]
784pub fn try_parser_span_to_text_range(
785    source: &str,
786    span: ParserSpan,
787) -> Result<TextRange, ParserSpanToTextRangeError> {
788    let source_len = source.len();
789    if span.start_usize() > source_len || span.end_usize() > source_len {
790        return Err(ParserSpanToTextRangeError {
791            source_len,
792            span_start: ByteOffset::new(span.start_usize()),
793            span_end: ByteOffset::new(span.end_usize()),
794            kind: ParserSpanToTextRangeErrorKind::OutOfBounds,
795        });
796    }
797    if span.start > span.end {
798        return Err(ParserSpanToTextRangeError {
799            source_len,
800            span_start: ByteOffset::new(span.start_usize()),
801            span_end: ByteOffset::new(span.end_usize()),
802            kind: ParserSpanToTextRangeErrorKind::InvertedSpan,
803        });
804    }
805    if !source.is_char_boundary(span.start_usize()) || !source.is_char_boundary(span.end_usize()) {
806        return Err(ParserSpanToTextRangeError {
807            source_len,
808            span_start: ByteOffset::new(span.start_usize()),
809            span_end: ByteOffset::new(span.end_usize()),
810            kind: ParserSpanToTextRangeErrorKind::NonUtf8Boundary,
811        });
812    }
813    Ok(TextRange::new(
814        TextSize::try_from(span.start_usize()).map_err(|_| ParserSpanToTextRangeError {
815            source_len,
816            span_start: ByteOffset::new(span.start_usize()),
817            span_end: ByteOffset::new(span.end_usize()),
818            kind: ParserSpanToTextRangeErrorKind::TextSizeOverflow,
819        })?,
820        TextSize::try_from(span.end_usize()).map_err(|_| ParserSpanToTextRangeError {
821            source_len,
822            span_start: ByteOffset::new(span.start_usize()),
823            span_end: ByteOffset::new(span.end_usize()),
824            kind: ParserSpanToTextRangeErrorKind::TextSizeOverflow,
825        })?,
826    ))
827}
828
829#[doc = include_str!("../README.md")]
830#[cfg(doctest)]
831#[doc(hidden)]
832pub struct ReadmeDoctests;
833
834// Unit tests for [`LineIndex`] mapping internals stay here; [`SourceFile`],
835// [`SourceTokens`], diagnostics, and span-conversion behavior live in integration tests.
836#[cfg(test)]
837#[allow(clippy::unwrap_used)]
838mod tests {
839    use super::*;
840
841    #[test]
842    fn maps_empty_source_to_first_line() {
843        let index = LineIndex::new("");
844        assert_eq!(index.source_len_bytes(), ByteOffset::new(0));
845
846        assert_eq!(
847            index.line_col(0.into()),
848            ByteLineCol {
849                line: LineNumber::new(1),
850                column: ByteColumn::new(1),
851            }
852        );
853        assert_eq!(
854            index.utf16_range(TextRange::empty(0.into())),
855            Utf16Range::new(Utf16Offset::new(0), Utf16Offset::new(0))
856        );
857    }
858
859    #[test]
860    fn maps_ascii_byte_lines() {
861        let source = "module M where\nfoo = 1\n";
862        let index = LineIndex::new(source);
863
864        assert_eq!(
865            index.line_col(15.into()),
866            ByteLineCol {
867                line: LineNumber::new(2),
868                column: ByteColumn::new(1),
869            }
870        );
871        assert_eq!(
872            index.utf16_col(LineNumber::new(2), ByteColumn::new(4)),
873            Ok(Utf16Offset::new(3))
874        );
875    }
876
877    #[test]
878    fn maps_utf8_and_utf16_offsets() {
879        let source = "a😀b\nz";
880        let index = LineIndex::new(source);
881
882        assert_eq!(
883            index.utf16_range(TextRange::new(0.into(), 6.into())),
884            Utf16Range::new(Utf16Offset::new(0), Utf16Offset::new(4))
885        );
886        assert_eq!(
887            index.utf16_col(LineNumber::new(1), ByteColumn::new(6)),
888            Ok(Utf16Offset::new(3))
889        );
890        assert_eq!(
891            index.char_line_col(5.into()),
892            CharLineCol {
893                line: LineNumber::new(1),
894                column: CharColumn::new(3),
895            }
896        );
897    }
898
899    #[test]
900    fn char_line_col_snaps_to_previous_utf8_boundary() {
901        let source = "a😀b";
902        let index = LineIndex::new(source);
903
904        // Offset 3 is inside the 4-byte 😀 sequence (1..5), so we expect snapping to 1.
905        assert_eq!(
906            index.char_line_col(3.into()),
907            CharLineCol {
908                line: LineNumber::new(1),
909                column: CharColumn::new(2),
910            }
911        );
912    }
913
914    #[test]
915    fn preserves_trailing_newline_line_start() {
916        let index = LineIndex::new("a\n");
917
918        assert_eq!(
919            index.line_col(2.into()),
920            ByteLineCol {
921                line: LineNumber::new(2),
922                column: ByteColumn::new(1),
923            }
924        );
925    }
926
927    #[test]
928    fn treats_crlf_as_bytes_without_normalization() {
929        let index = LineIndex::new("a\r\nb");
930
931        assert_eq!(
932            index.line_col(3.into()),
933            ByteLineCol {
934                line: LineNumber::new(2),
935                column: ByteColumn::new(1),
936            }
937        );
938    }
939
940    #[test]
941    fn clamps_ranges_to_source_end() {
942        let index = LineIndex::new("abc");
943        let range = TextRange::new(1.into(), 99.into());
944
945        assert_eq!(
946            index.utf16_range(range),
947            Utf16Range::new(Utf16Offset::new(1), Utf16Offset::new(3))
948        );
949    }
950
951    #[test]
952    fn utf16_col_reports_line_past_source() {
953        let index = LineIndex::new("abc\n");
954
955        let err = index
956            .utf16_col(LineNumber::new(3), ByteColumn::new(1))
957            .unwrap_err();
958
959        assert_eq!(err.kind(), CoordinateRangeErrorKind::LineOutOfRange);
960        assert_eq!(err.line(), LineNumber::new(3));
961        assert_eq!(err.byte_column(), ByteColumn::new(1));
962        assert_eq!(err.source_line_count(), LineNumber::new(2));
963        assert_eq!(err.max_byte_column(), None);
964        assert_eq!(err.to_string(), "line 3 is outside source line range 1..=2");
965    }
966
967    #[test]
968    fn utf16_col_reports_column_past_line_without_clamping_to_eof() {
969        let index = LineIndex::new("abc\nz");
970
971        let err = index
972            .utf16_col(LineNumber::new(1), ByteColumn::new(5))
973            .unwrap_err();
974
975        assert_eq!(err.kind(), CoordinateRangeErrorKind::ColumnOutOfRange);
976        assert_eq!(err.line(), LineNumber::new(1));
977        assert_eq!(err.byte_column(), ByteColumn::new(5));
978        assert_eq!(err.source_line_count(), LineNumber::new(2));
979        assert_eq!(err.max_byte_column(), Some(ByteColumn::new(4)));
980        assert_eq!(
981            err.to_string(),
982            "byte column 5 is outside valid range 1..=4 on line 1"
983        );
984    }
985
986    #[test]
987    fn diagnostic_end_column_names_same_line_multiline_and_empty_spans() {
988        let source = "abc\ndef";
989
990        assert_eq!(
991            diagnostic_end_column(
992                source,
993                TextRange::new(1.into(), 3.into()),
994                CharColumn::new(2)
995            ),
996            DiagnosticEndColumn::SameLineEnd(CharColumn::new(4))
997        );
998        assert_eq!(
999            diagnostic_end_column(
1000                source,
1001                TextRange::new(1.into(), 5.into()),
1002                CharColumn::new(2)
1003            ),
1004            DiagnosticEndColumn::Multiline
1005        );
1006        assert_eq!(
1007            diagnostic_end_column(source, TextRange::empty(1.into()), CharColumn::new(2)),
1008            DiagnosticEndColumn::EmptySpan
1009        );
1010    }
1011
1012    #[test]
1013    fn byte_and_char_line_col_columns_differ_for_multibyte_utf8() {
1014        let source = "😀\n";
1015        let index = LineIndex::new(source);
1016        let offset = TextSize::from(4);
1017
1018        let byte_pos = index.line_col(offset);
1019        let char_pos = index.char_line_col(offset);
1020
1021        assert_eq!(byte_pos.line, char_pos.line);
1022        assert_ne!(byte_pos.column.get(), char_pos.column.get());
1023        assert_eq!(byte_pos.column, ByteColumn::new(5));
1024        assert_eq!(char_pos.column, CharColumn::new(2));
1025    }
1026
1027    #[test]
1028    fn parser_span_text_size_overflow_display_includes_kind() {
1029        let text_size_overflow_start = usize::try_from(u32::MAX).unwrap() + 1;
1030        let text_size_overflow_end = usize::try_from(u32::MAX).unwrap() + 2;
1031        let err = ParserSpanToTextRangeError {
1032            source_len: usize::MAX,
1033            span_start: ByteOffset::new(text_size_overflow_start),
1034            span_end: ByteOffset::new(text_size_overflow_end),
1035            kind: ParserSpanToTextRangeErrorKind::TextSizeOverflow,
1036        };
1037
1038        assert_eq!(err.kind().to_string(), "text-size overflow");
1039        assert_eq!(
1040            err.to_string(),
1041            format!(
1042                "parser span [{text_size_overflow_start}, {text_size_overflow_end}) is invalid: text-size overflow; endpoints cannot be represented as text-size values"
1043            )
1044        );
1045    }
1046}