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}
74
75impl SourceDocument {
76    /// Retain source text and index supported line comments (`// ...`).
77    pub fn new(text: impl Into<String>) -> Self {
78        let text = text.into();
79        let comments = find_line_comments(&text);
80        Self {
81            file: None,
82            text,
83            comments,
84        }
85    }
86
87    /// The exact authored source text.
88    pub fn text(&self) -> &str {
89        &self.text
90    }
91
92    /// All indexed comments in authored source order.
93    pub fn comments(&self) -> Iter<'_, SourceComment> {
94        self.comments.iter()
95    }
96
97    /// Comments whose complete source range is inside a semantic span.
98    ///
99    /// This is the attachment rule for the supported slice. Comments outside
100    /// a node span remain document-level preserved source and are never
101    /// guessed into a neighboring node.
102    pub fn comments_for(&self, span: Span) -> impl Iterator<Item = &SourceComment> {
103        let range = self.byte_range(span);
104        self.comments.iter().filter(move |comment| {
105            range.as_ref().is_some_and(|range| {
106                comment.range.start >= range.start && comment.range.end <= range.end
107            })
108        })
109    }
110
111    /// Convert a line/column span into a UTF-8 byte range in this document.
112    pub fn byte_range(&self, span: Span) -> Option<Range<usize>> {
113        if self.file != Some(span.file) {
114            return None;
115        }
116        let start = byte_offset(&self.text, span.start)?;
117        let end = byte_offset(&self.text, span.end)?;
118        (start <= end).then_some(start..end)
119    }
120
121    /// Create a checked replacement for a UTF-8 byte range.
122    pub fn edit(
123        &self,
124        range: Range<usize>,
125        replacement: impl Into<String>,
126    ) -> Result<SourceEdit, SourceEditError> {
127        if range.start > range.end
128            || !self.text.is_char_boundary(range.start)
129            || !self.text.is_char_boundary(range.end)
130            || range.end > self.text.len()
131        {
132            return Err(SourceEditError::InvalidRange);
133        }
134        Ok(SourceEdit {
135            expected: self.text[range.clone()].to_string(),
136            range,
137            replacement: replacement.into(),
138        })
139    }
140
141    /// Create a checked replacement for a semantic span.
142    pub fn edit_span(
143        &self,
144        span: Span,
145        replacement: impl Into<String>,
146    ) -> Result<SourceEdit, SourceEditError> {
147        let range = self.byte_range(span).ok_or(SourceEditError::InvalidRange)?;
148        self.edit(range, replacement)
149    }
150
151    /// Apply non-overlapping edits against this exact document.
152    pub fn apply(&self, edits: &[SourceEdit]) -> Result<Self, SourceEditError> {
153        let mut ordered = edits.iter().collect::<Vec<_>>();
154        ordered.sort_by_key(|edit| edit.range.start);
155        for pair in ordered.windows(2) {
156            if pair[0].range.end > pair[1].range.start || pair[0].range.start == pair[1].range.start
157            {
158                return Err(SourceEditError::OverlappingEdits);
159            }
160        }
161        let mut text = self.text.clone();
162        for edit in ordered.into_iter().rev() {
163            edit.apply_to(&mut text)?;
164        }
165        Ok(Self::new(text))
166    }
167}
168
169/// A source comment retained from the authored document.
170#[derive(Debug, Clone, PartialEq, Eq)]
171pub struct SourceComment {
172    kind: CommentKind,
173    range: Range<usize>,
174}
175
176impl SourceComment {
177    pub fn kind(&self) -> CommentKind {
178        self.kind
179    }
180
181    /// The UTF-8 byte range including the `//` marker and excluding its line
182    /// ending.
183    pub fn range(&self) -> Range<usize> {
184        self.range.clone()
185    }
186
187    /// The exact comment text from the containing document.
188    pub fn text<'a>(&self, document: &'a SourceDocument) -> &'a str {
189        &document.text[self.range.clone()]
190    }
191}
192
193/// Comment kinds currently supported by the raw Workshop source contract.
194#[derive(Debug, Clone, Copy, PartialEq, Eq)]
195pub enum CommentKind {
196    Line,
197}
198
199/// A checked, byte-oriented source replacement.
200#[derive(Debug, Clone, PartialEq, Eq)]
201pub struct SourceEdit {
202    range: Range<usize>,
203    expected: String,
204    replacement: String,
205}
206
207impl SourceEdit {
208    pub fn range(&self) -> Range<usize> {
209        self.range.clone()
210    }
211
212    pub fn replacement(&self) -> &str {
213        &self.replacement
214    }
215
216    /// Apply this edit only when the original bytes still match.
217    pub fn apply(&self, source: &str) -> Result<String, SourceEditError> {
218        let mut result = source.to_string();
219        self.apply_to(&mut result)?;
220        Ok(result)
221    }
222
223    fn apply_to(&self, source: &mut String) -> Result<(), SourceEditError> {
224        if source.get(self.range.clone()) != Some(self.expected.as_str()) {
225            return Err(SourceEditError::SourceMismatch);
226        }
227        source.replace_range(self.range.clone(), &self.replacement);
228        Ok(())
229    }
230}
231
232/// Failure while creating or applying source edits.
233#[derive(Debug, Clone, Copy, PartialEq, Eq)]
234pub enum SourceEditError {
235    InvalidRange,
236    SourceMismatch,
237    OverlappingEdits,
238}
239
240impl std::fmt::Display for SourceEditError {
241    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
242        formatter.write_str(match self {
243            Self::InvalidRange => "source edit range is not a valid UTF-8 range",
244            Self::SourceMismatch => "source no longer matches the edit",
245            Self::OverlappingEdits => "source edits overlap",
246        })
247    }
248}
249
250impl std::error::Error for SourceEditError {}
251
252fn find_line_comments(source: &str) -> Vec<SourceComment> {
253    let mut comments = Vec::new();
254    let mut index = 0;
255    let mut in_string = false;
256    let mut escaped = false;
257    while index < source.len() {
258        let character = source[index..].chars().next().unwrap();
259        if in_string {
260            if escaped {
261                escaped = false;
262            } else if character == '\\' {
263                escaped = true;
264            } else if character == '"' {
265                in_string = false;
266            }
267            index += character.len_utf8();
268            continue;
269        }
270        if character == '"' {
271            in_string = true;
272            index += character.len_utf8();
273        } else if character == '/' && source[index..].starts_with("//") {
274            let start = index;
275            index += 2;
276            while index < source.len()
277                && !source[index..].starts_with('\n')
278                && !source[index..].starts_with('\r')
279            {
280                index += source[index..].chars().next().unwrap().len_utf8();
281            }
282            comments.push(SourceComment {
283                kind: CommentKind::Line,
284                range: start..index,
285            });
286        } else {
287            index += character.len_utf8();
288        }
289    }
290    comments
291}
292
293fn byte_offset(source: &str, position: Position) -> Option<usize> {
294    if !position.is_valid() {
295        return None;
296    }
297    let mut line = 1;
298    let mut col = 1;
299    for (index, character) in source.char_indices() {
300        if line == position.line && col == position.col {
301            return Some(index);
302        }
303        if character == '\n' {
304            line += 1;
305            col = 1;
306        } else {
307            col += 1;
308        }
309    }
310    (line == position.line && col == position.col).then_some(source.len())
311}
312
313/// A 1-based line/column position in a source file.
314#[derive(Debug, Clone, Copy, PartialEq, Eq)]
315pub struct Position {
316    pub line: u32,
317    pub col: u32,
318}
319
320impl Position {
321    /// A position at line `line`, column `col` (both 1-based).
322    pub const fn new(line: u32, col: u32) -> Self {
323        Position { line, col }
324    }
325
326    /// Whether this position is valid (1-based).
327    pub const fn is_valid(self) -> bool {
328        self.line >= 1 && self.col >= 1
329    }
330}
331
332/// A half-open, 1-based source interval in one file.
333#[derive(Debug, Clone, Copy, PartialEq, Eq)]
334pub struct Span {
335    pub file: FileId,
336    pub start: Position,
337    pub end: Position,
338}
339
340impl Span {
341    /// Create a span in `file` from `start` (inclusive) to `end` (exclusive).
342    pub const fn new(file: FileId, start: Position, end: Position) -> Self {
343        Span { file, start, end }
344    }
345
346    /// Whether the span is structurally valid: both positions are 1-based and
347    /// `end` is not before `start`.
348    pub const fn is_valid(self) -> bool {
349        self.start.is_valid()
350            && self.end.is_valid()
351            && (self.end.line > self.start.line
352                || (self.end.line == self.start.line && self.end.col >= self.start.col))
353    }
354}
355
356#[cfg(test)]
357mod tests {
358    use super::super::ids::Id;
359    use super::{CommentKind, Position, SourceDocument, SourceFile, Span};
360
361    #[test]
362    fn positions_are_one_based_and_validated() {
363        assert!(Position::new(1, 1).is_valid());
364        assert!(Position::new(10, 24).is_valid());
365        assert!(!Position::new(0, 1).is_valid());
366        assert!(!Position::new(1, 0).is_valid());
367    }
368
369    #[test]
370    fn spans_require_end_not_before_start() {
371        let file = Id::from_index(0);
372        assert!(Span::new(file, Position::new(1, 1), Position::new(1, 5)).is_valid());
373        assert!(Span::new(file, Position::new(1, 1), Position::new(2, 1)).is_valid());
374        assert!(Span::new(file, Position::new(1, 1), Position::new(1, 1)).is_valid());
375        assert!(!Span::new(file, Position::new(1, 5), Position::new(1, 1)).is_valid());
376        assert!(!Span::new(file, Position::new(2, 1), Position::new(1, 1)).is_valid());
377    }
378
379    #[test]
380    fn source_files_carry_paths() {
381        let file = SourceFile::new("source.opy");
382        assert_eq!(file.path, "source.opy");
383        assert!(file.source().is_none());
384    }
385
386    #[test]
387    fn source_documents_index_line_comments_but_not_string_contents() {
388        let document = SourceDocument::new("// before\nWait(\"// not a comment\"); // after\n");
389        let comments: Vec<_> = document.comments().collect();
390        assert_eq!(comments.len(), 2);
391        assert_eq!(comments[0].kind(), CommentKind::Line);
392        assert_eq!(comments[0].text(&document), "// before");
393        assert_eq!(comments[1].text(&document), "// after");
394    }
395
396    #[test]
397    fn source_edits_are_checked_and_reindex_comments() {
398        let document = SourceDocument::new("// keep\nvalue: 1\n");
399        let edit = document.edit(15..16, "2").expect("valid edit");
400        let updated = document.apply(&[edit]).expect("edit applies");
401        assert_eq!(updated.text(), "// keep\nvalue: 2\n");
402        assert_eq!(updated.comments().count(), 1);
403    }
404
405    #[test]
406    fn source_edits_reject_stale_and_overlapping_inputs() {
407        let document = SourceDocument::new("abcdef");
408        let edit = document.edit(1..3, "x").unwrap();
409        assert!(matches!(
410            edit.apply("aXcdef"),
411            Err(super::SourceEditError::SourceMismatch)
412        ));
413        let left = document.edit(1..3, "x").unwrap();
414        let right = document.edit(2..4, "y").unwrap();
415        assert!(matches!(
416            document.apply(&[left, right]),
417            Err(super::SourceEditError::OverlappingEdits)
418        ));
419    }
420
421    #[test]
422    fn source_comment_ranges_exclude_crlf_line_endings() {
423        let document = SourceDocument::new("// comment\r\nnext\r\n");
424        let comment = document.comments().next().unwrap();
425        assert_eq!(comment.text(&document), "// comment");
426    }
427}