Skip to main content

workshop_rs/core/
source.rs

1//! Source model: files, positions, and spans.
2//!
3//! Conventions: positions are 1-based, and a span is a half-open interval
4//! (`end` is exclusive). Spans carry a typed [`FileId`] instead of a raw file
5//! index.
6
7use std::{ops::Range, slice::Iter};
8
9use super::ids::Id;
10
11/// A typed ID referencing a [`SourceFile`] in the program's file arena.
12pub type FileId = Id<SourceFile>;
13
14/// One source file in the program's file registry.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct SourceFile {
17    /// The file name as the frontend reported it (for diagnostics).
18    pub path: String,
19    file: Option<FileId>,
20    source: Option<SourceDocument>,
21}
22
23impl SourceFile {
24    /// Create a file entry with the given path.
25    pub fn new(path: impl Into<String>) -> Self {
26        SourceFile {
27            path: path.into(),
28            file: None,
29            source: None,
30        }
31    }
32
33    /// Create a file entry that retains its authored source text and comments.
34    pub fn with_source(path: impl Into<String>, source: impl Into<String>) -> Self {
35        SourceFile {
36            path: path.into(),
37            file: None,
38            source: Some(SourceDocument::new(source)),
39        }
40    }
41
42    /// Attach authored source to an existing file entry.
43    pub fn set_source(&mut self, source: impl Into<String>) {
44        let mut document = SourceDocument::new(source);
45        document.file = self.file;
46        self.source = Some(document);
47    }
48
49    pub(crate) fn bind_file(&mut self, file: FileId) {
50        self.file = Some(file);
51        if let Some(source) = &mut self.source {
52            source.file = Some(file);
53        }
54    }
55
56    /// The retained authored source, when this file was created source-aware.
57    pub fn source(&self) -> Option<&SourceDocument> {
58        self.source.as_ref()
59    }
60}
61
62/// A source document retained independently from canonical semantic nodes.
63///
64/// Whitespace and other non-comment trivia remain in [`Self::text`]. Comments
65/// are indexed as a convenience for stable span-based attachment; callers
66/// should use [`SourceEdit`] for local changes and reparse the edited text to
67/// obtain updated semantic spans.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct SourceDocument {
70    file: Option<FileId>,
71    text: String,
72    comments: Vec<SourceComment>,
73    line_starts: Vec<usize>,
74}
75
76impl SourceDocument {
77    /// Retain source text and index supported line comments (`// ...`).
78    pub fn new(text: impl Into<String>) -> Self {
79        let text = text.into();
80        let comments = find_line_comments(&text);
81        Self {
82            file: None,
83            line_starts: line_starts(&text),
84            text,
85            comments,
86        }
87    }
88
89    /// The exact authored source text.
90    pub fn text(&self) -> &str {
91        &self.text
92    }
93
94    /// All indexed comments in authored source order.
95    pub fn comments(&self) -> Iter<'_, SourceComment> {
96        self.comments.iter()
97    }
98
99    /// Comments whose complete source range is inside a semantic span.
100    ///
101    /// This is the attachment rule for the supported slice. Comments outside
102    /// a node span remain document-level preserved source and are never
103    /// guessed into a neighboring node.
104    pub fn comments_for(&self, span: Span) -> impl Iterator<Item = &SourceComment> {
105        let range = self.byte_range(span);
106        self.comments.iter().filter(move |comment| {
107            range.as_ref().is_some_and(|range| {
108                comment.range.start >= range.start && comment.range.end <= range.end
109            })
110        })
111    }
112
113    /// Convert a line/column span into a UTF-8 byte range in this document.
114    pub fn byte_range(&self, span: Span) -> Option<Range<usize>> {
115        if self.file != Some(span.file) {
116            return None;
117        }
118        let start = self.byte_offset(span.start)?;
119        let end = self.byte_offset(span.end)?;
120        (start <= end).then_some(start..end)
121    }
122
123    /// Create a checked replacement for a UTF-8 byte range.
124    pub fn edit(
125        &self,
126        range: Range<usize>,
127        replacement: impl Into<String>,
128    ) -> Result<SourceEdit, SourceEditError> {
129        if range.start > range.end
130            || !self.text.is_char_boundary(range.start)
131            || !self.text.is_char_boundary(range.end)
132            || range.end > self.text.len()
133        {
134            return Err(SourceEditError::InvalidRange);
135        }
136        Ok(SourceEdit {
137            expected: self.text[range.clone()].to_string(),
138            range,
139            replacement: replacement.into(),
140        })
141    }
142
143    /// Create a checked replacement for a semantic span.
144    pub fn edit_span(
145        &self,
146        span: Span,
147        replacement: impl Into<String>,
148    ) -> Result<SourceEdit, SourceEditError> {
149        let range = self.byte_range(span).ok_or(SourceEditError::InvalidRange)?;
150        self.edit(range, replacement)
151    }
152
153    /// Apply non-overlapping edits against this exact document.
154    pub fn apply(&self, edits: &[SourceEdit]) -> Result<Self, SourceEditError> {
155        let mut ordered = edits.iter().collect::<Vec<_>>();
156        ordered.sort_by_key(|edit| edit.range.start);
157        for pair in ordered.windows(2) {
158            if pair[0].range.end > pair[1].range.start || pair[0].range.start == pair[1].range.start
159            {
160                return Err(SourceEditError::OverlappingEdits);
161            }
162        }
163        let mut text = self.text.clone();
164        for edit in ordered.into_iter().rev() {
165            edit.apply_to(&mut text)?;
166        }
167        Ok(Self::new(text))
168    }
169}
170
171/// A source comment retained from the authored document.
172#[derive(Debug, Clone, PartialEq, Eq)]
173pub struct SourceComment {
174    kind: CommentKind,
175    range: Range<usize>,
176}
177
178impl SourceComment {
179    pub fn kind(&self) -> CommentKind {
180        self.kind
181    }
182
183    /// The UTF-8 byte range including the `//` marker and excluding its line
184    /// ending.
185    pub fn range(&self) -> Range<usize> {
186        self.range.clone()
187    }
188
189    /// The exact comment text from the containing document.
190    pub fn text<'a>(&self, document: &'a SourceDocument) -> &'a str {
191        &document.text[self.range.clone()]
192    }
193}
194
195/// Comment kinds currently supported by the raw Workshop source contract.
196#[derive(Debug, Clone, Copy, PartialEq, Eq)]
197pub enum CommentKind {
198    Line,
199}
200
201/// A checked, byte-oriented source replacement.
202#[derive(Debug, Clone, PartialEq, Eq)]
203pub struct SourceEdit {
204    range: Range<usize>,
205    expected: String,
206    replacement: String,
207}
208
209impl SourceEdit {
210    pub fn range(&self) -> Range<usize> {
211        self.range.clone()
212    }
213
214    pub fn replacement(&self) -> &str {
215        &self.replacement
216    }
217
218    /// Apply this edit only when the original bytes still match.
219    pub fn apply(&self, source: &str) -> Result<String, SourceEditError> {
220        let mut result = source.to_string();
221        self.apply_to(&mut result)?;
222        Ok(result)
223    }
224
225    fn apply_to(&self, source: &mut String) -> Result<(), SourceEditError> {
226        if source.get(self.range.clone()) != Some(self.expected.as_str()) {
227            return Err(SourceEditError::SourceMismatch);
228        }
229        source.replace_range(self.range.clone(), &self.replacement);
230        Ok(())
231    }
232}
233
234/// Failure while creating or applying source edits.
235#[derive(Debug, Clone, Copy, PartialEq, Eq)]
236#[non_exhaustive]
237pub enum SourceEditError {
238    InvalidRange,
239    SourceMismatch,
240    OverlappingEdits,
241}
242
243impl std::fmt::Display for SourceEditError {
244    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
245        formatter.write_str(match self {
246            Self::InvalidRange => "source edit range is not a valid UTF-8 range",
247            Self::SourceMismatch => "source no longer matches the edit",
248            Self::OverlappingEdits => "source edits overlap",
249        })
250    }
251}
252
253impl std::error::Error for SourceEditError {}
254
255fn find_line_comments(source: &str) -> Vec<SourceComment> {
256    let mut comments = Vec::new();
257    let mut index = 0;
258    let mut in_string = false;
259    let mut escaped = false;
260    while index < source.len() {
261        let character = source[index..].chars().next().unwrap();
262        if in_string {
263            if escaped {
264                escaped = false;
265            } else if character == '\\' {
266                escaped = true;
267            } else if character == '"' {
268                in_string = false;
269            }
270            index += character.len_utf8();
271            continue;
272        }
273        if character == '"' {
274            in_string = true;
275            index += character.len_utf8();
276        } else if character == '/' && source[index..].starts_with("//") {
277            let start = index;
278            index += 2;
279            while index < source.len()
280                && !source[index..].starts_with('\n')
281                && !source[index..].starts_with('\r')
282            {
283                index += source[index..].chars().next().unwrap().len_utf8();
284            }
285            comments.push(SourceComment {
286                kind: CommentKind::Line,
287                range: start..index,
288            });
289        } else {
290            index += character.len_utf8();
291        }
292    }
293    comments
294}
295
296fn line_starts(source: &str) -> Vec<usize> {
297    let mut starts = Vec::with_capacity(source.len() / 40 + 1);
298    starts.push(0);
299    for (index, _) in source.match_indices('\n') {
300        starts.push(index + 1);
301    }
302    starts
303}
304
305impl SourceDocument {
306    fn byte_offset(&self, position: Position) -> Option<usize> {
307        self.byte_offset_scan(position).0
308    }
309
310    fn byte_offset_scan(&self, position: Position) -> (Option<usize>, usize) {
311        if !position.is_valid() {
312            return (None, 0);
313        }
314        let line_index = position.line as usize - 1;
315        let Some(&start) = self.line_starts.get(line_index) else {
316            return (None, 0);
317        };
318        let end = self
319            .line_starts
320            .get(line_index + 1)
321            .copied()
322            .unwrap_or(self.text.len());
323        let mut scanned = 0;
324        let mut col = 1;
325        for (offset, character) in self.text[start..end].char_indices() {
326            scanned += character.len_utf8();
327            if col == position.col {
328                return (Some(start + offset), scanned);
329            }
330            if character == '\n' {
331                return (None, scanned);
332            }
333            col += 1;
334        }
335        ((col == position.col).then_some(end), scanned)
336    }
337}
338
339/// A 1-based line/column position in a source file.
340#[derive(Debug, Clone, Copy, PartialEq, Eq)]
341pub struct Position {
342    pub line: u32,
343    pub col: u32,
344}
345
346impl Position {
347    /// A position at line `line`, column `col` (both 1-based).
348    pub const fn new(line: u32, col: u32) -> Self {
349        Position { line, col }
350    }
351
352    /// Whether this position is valid (1-based).
353    pub const fn is_valid(self) -> bool {
354        self.line >= 1 && self.col >= 1
355    }
356}
357
358/// A half-open, 1-based source interval in one file.
359#[derive(Debug, Clone, Copy, PartialEq, Eq)]
360pub struct Span {
361    pub file: FileId,
362    pub start: Position,
363    pub end: Position,
364}
365
366impl Span {
367    /// Create a span in `file` from `start` (inclusive) to `end` (exclusive).
368    pub const fn new(file: FileId, start: Position, end: Position) -> Self {
369        Span { file, start, end }
370    }
371
372    /// Whether the span is structurally valid: both positions are 1-based and
373    /// `end` is not before `start`.
374    pub const fn is_valid(self) -> bool {
375        self.start.is_valid()
376            && self.end.is_valid()
377            && (self.end.line > self.start.line
378                || (self.end.line == self.start.line && self.end.col >= self.start.col))
379    }
380}
381
382#[cfg(test)]
383mod tests {
384    use super::super::ids::Id;
385    use super::{CommentKind, Position, SourceDocument, SourceFile, Span};
386
387    #[test]
388    fn positions_are_one_based_and_validated() {
389        assert!(Position::new(1, 1).is_valid());
390        assert!(Position::new(10, 24).is_valid());
391        assert!(!Position::new(0, 1).is_valid());
392        assert!(!Position::new(1, 0).is_valid());
393    }
394
395    #[test]
396    fn spans_require_end_not_before_start() {
397        let file = Id::from_index(0);
398        assert!(Span::new(file, Position::new(1, 1), Position::new(1, 5)).is_valid());
399        assert!(Span::new(file, Position::new(1, 1), Position::new(2, 1)).is_valid());
400        assert!(Span::new(file, Position::new(1, 1), Position::new(1, 1)).is_valid());
401        assert!(!Span::new(file, Position::new(1, 5), Position::new(1, 1)).is_valid());
402        assert!(!Span::new(file, Position::new(2, 1), Position::new(1, 1)).is_valid());
403    }
404
405    #[test]
406    fn source_files_carry_paths() {
407        let file = SourceFile::new("source.opy");
408        assert_eq!(file.path, "source.opy");
409        assert!(file.source().is_none());
410    }
411
412    #[test]
413    fn source_documents_index_line_comments_but_not_string_contents() {
414        let document = SourceDocument::new("// before\nWait(\"// not a comment\"); // after\n");
415        let comments: Vec<_> = document.comments().collect();
416        assert_eq!(comments.len(), 2);
417        assert_eq!(comments[0].kind(), CommentKind::Line);
418        assert_eq!(comments[0].text(&document), "// before");
419        assert_eq!(comments[1].text(&document), "// after");
420    }
421
422    #[test]
423    fn source_edits_are_checked_and_reindex_comments() {
424        let document = SourceDocument::new("// keep\nvalue: 1\n");
425        let edit = document.edit(15..16, "2").expect("valid edit");
426        let updated = document.apply(&[edit]).expect("edit applies");
427        assert_eq!(updated.text(), "// keep\nvalue: 2\n");
428        assert_eq!(updated.comments().count(), 1);
429    }
430
431    #[test]
432    fn source_edits_reject_stale_and_overlapping_inputs() {
433        let document = SourceDocument::new("abcdef");
434        let edit = document.edit(1..3, "x").unwrap();
435        assert!(matches!(
436            edit.apply("aXcdef"),
437            Err(super::SourceEditError::SourceMismatch)
438        ));
439        let left = document.edit(1..3, "x").unwrap();
440        let right = document.edit(2..4, "y").unwrap();
441        assert!(matches!(
442            document.apply(&[left, right]),
443            Err(super::SourceEditError::OverlappingEdits)
444        ));
445    }
446
447    #[test]
448    fn source_comment_ranges_exclude_crlf_line_endings() {
449        let document = SourceDocument::new("// comment\r\nnext\r\n");
450        let comment = document.comments().next().unwrap();
451        assert_eq!(comment.text(&document), "// comment");
452    }
453
454    fn naive_byte_offset(source: &str, position: Position) -> Option<usize> {
455        if !position.is_valid() {
456            return None;
457        }
458        let mut line = 1;
459        let mut col = 1;
460        for (index, character) in source.char_indices() {
461            if line == position.line && col == position.col {
462                return Some(index);
463            }
464            if character == '\n' {
465                line += 1;
466                col = 1;
467            } else {
468                col += 1;
469            }
470        }
471        (line == position.line && col == position.col).then_some(source.len())
472    }
473
474    #[test]
475    fn byte_offsets_match_full_document_scan() {
476        let text = "one\r\ntwo\nthree\nlast é\u{301}\n";
477        let document = SourceDocument::new(text);
478        for line in 0..=8 {
479            for col in 0..=12 {
480                let position = Position::new(line, col);
481                assert_eq!(
482                    document.byte_offset_scan(position).0,
483                    naive_byte_offset(text, position),
484                    "position {position:?}"
485                );
486            }
487        }
488    }
489
490    #[test]
491    fn byte_offsets_scan_only_the_addressed_line() {
492        let line = "xxxxxxxxxxxxxxxx\n";
493        let document = SourceDocument::new(line.repeat(1000));
494        let (_, scanned) = document.byte_offset_scan(Position::new(1000, 9));
495        assert_eq!(document.line_starts.len(), 1001);
496        assert!(
497            scanned <= line.len(),
498            "resolving a late position scanned {scanned} bytes, expected at most one line ({})",
499            line.len()
500        );
501        let (_, scanned) = document.byte_offset_scan(Position::new(999, 20));
502        assert!(
503            scanned <= line.len(),
504            "an overshot column scanned {scanned} bytes, expected at most one line ({})",
505            line.len()
506        );
507        assert!(document.byte_offset(Position::new(1002, 1)).is_none());
508    }
509}