Skip to main content

workshop_rs/core/
source.rs

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