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