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)]
234#[non_exhaustive]
235pub enum SourceEditError {
236    InvalidRange,
237    SourceMismatch,
238    OverlappingEdits,
239}
240
241impl std::fmt::Display for SourceEditError {
242    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
243        formatter.write_str(match self {
244            Self::InvalidRange => "source edit range is not a valid UTF-8 range",
245            Self::SourceMismatch => "source no longer matches the edit",
246            Self::OverlappingEdits => "source edits overlap",
247        })
248    }
249}
250
251impl std::error::Error for SourceEditError {}
252
253fn find_line_comments(source: &str) -> Vec<SourceComment> {
254    let mut comments = Vec::new();
255    let mut index = 0;
256    let mut in_string = false;
257    let mut escaped = false;
258    while index < source.len() {
259        let character = source[index..].chars().next().unwrap();
260        if in_string {
261            if escaped {
262                escaped = false;
263            } else if character == '\\' {
264                escaped = true;
265            } else if character == '"' {
266                in_string = false;
267            }
268            index += character.len_utf8();
269            continue;
270        }
271        if character == '"' {
272            in_string = true;
273            index += character.len_utf8();
274        } else if character == '/' && source[index..].starts_with("//") {
275            let start = index;
276            index += 2;
277            while index < source.len()
278                && !source[index..].starts_with('\n')
279                && !source[index..].starts_with('\r')
280            {
281                index += source[index..].chars().next().unwrap().len_utf8();
282            }
283            comments.push(SourceComment {
284                kind: CommentKind::Line,
285                range: start..index,
286            });
287        } else {
288            index += character.len_utf8();
289        }
290    }
291    comments
292}
293
294fn byte_offset(source: &str, position: Position) -> Option<usize> {
295    if !position.is_valid() {
296        return None;
297    }
298    let mut line = 1;
299    let mut col = 1;
300    for (index, character) in source.char_indices() {
301        if line == position.line && col == position.col {
302            return Some(index);
303        }
304        if character == '\n' {
305            line += 1;
306            col = 1;
307        } else {
308            col += 1;
309        }
310    }
311    (line == position.line && col == position.col).then_some(source.len())
312}
313
314/// A 1-based line/column position in a source file.
315#[derive(Debug, Clone, Copy, PartialEq, Eq)]
316pub struct Position {
317    pub line: u32,
318    pub col: u32,
319}
320
321impl Position {
322    /// A position at line `line`, column `col` (both 1-based).
323    pub const fn new(line: u32, col: u32) -> Self {
324        Position { line, col }
325    }
326
327    /// Whether this position is valid (1-based).
328    pub const fn is_valid(self) -> bool {
329        self.line >= 1 && self.col >= 1
330    }
331}
332
333/// A half-open, 1-based source interval in one file.
334#[derive(Debug, Clone, Copy, PartialEq, Eq)]
335pub struct Span {
336    pub file: FileId,
337    pub start: Position,
338    pub end: Position,
339}
340
341impl Span {
342    /// Create a span in `file` from `start` (inclusive) to `end` (exclusive).
343    pub const fn new(file: FileId, start: Position, end: Position) -> Self {
344        Span { file, start, end }
345    }
346
347    /// Whether the span is structurally valid: both positions are 1-based and
348    /// `end` is not before `start`.
349    pub const fn is_valid(self) -> bool {
350        self.start.is_valid()
351            && self.end.is_valid()
352            && (self.end.line > self.start.line
353                || (self.end.line == self.start.line && self.end.col >= self.start.col))
354    }
355}
356
357#[cfg(test)]
358mod tests {
359    use super::super::ids::Id;
360    use super::{CommentKind, Position, SourceDocument, SourceFile, Span};
361
362    #[test]
363    fn positions_are_one_based_and_validated() {
364        assert!(Position::new(1, 1).is_valid());
365        assert!(Position::new(10, 24).is_valid());
366        assert!(!Position::new(0, 1).is_valid());
367        assert!(!Position::new(1, 0).is_valid());
368    }
369
370    #[test]
371    fn spans_require_end_not_before_start() {
372        let file = Id::from_index(0);
373        assert!(Span::new(file, Position::new(1, 1), Position::new(1, 5)).is_valid());
374        assert!(Span::new(file, Position::new(1, 1), Position::new(2, 1)).is_valid());
375        assert!(Span::new(file, Position::new(1, 1), Position::new(1, 1)).is_valid());
376        assert!(!Span::new(file, Position::new(1, 5), Position::new(1, 1)).is_valid());
377        assert!(!Span::new(file, Position::new(2, 1), Position::new(1, 1)).is_valid());
378    }
379
380    #[test]
381    fn source_files_carry_paths() {
382        let file = SourceFile::new("source.opy");
383        assert_eq!(file.path, "source.opy");
384        assert!(file.source().is_none());
385    }
386
387    #[test]
388    fn source_documents_index_line_comments_but_not_string_contents() {
389        let document = SourceDocument::new("// before\nWait(\"// not a comment\"); // after\n");
390        let comments: Vec<_> = document.comments().collect();
391        assert_eq!(comments.len(), 2);
392        assert_eq!(comments[0].kind(), CommentKind::Line);
393        assert_eq!(comments[0].text(&document), "// before");
394        assert_eq!(comments[1].text(&document), "// after");
395    }
396
397    #[test]
398    fn source_edits_are_checked_and_reindex_comments() {
399        let document = SourceDocument::new("// keep\nvalue: 1\n");
400        let edit = document.edit(15..16, "2").expect("valid edit");
401        let updated = document.apply(&[edit]).expect("edit applies");
402        assert_eq!(updated.text(), "// keep\nvalue: 2\n");
403        assert_eq!(updated.comments().count(), 1);
404    }
405
406    #[test]
407    fn source_edits_reject_stale_and_overlapping_inputs() {
408        let document = SourceDocument::new("abcdef");
409        let edit = document.edit(1..3, "x").unwrap();
410        assert!(matches!(
411            edit.apply("aXcdef"),
412            Err(super::SourceEditError::SourceMismatch)
413        ));
414        let left = document.edit(1..3, "x").unwrap();
415        let right = document.edit(2..4, "y").unwrap();
416        assert!(matches!(
417            document.apply(&[left, right]),
418            Err(super::SourceEditError::OverlappingEdits)
419        ));
420    }
421
422    #[test]
423    fn source_comment_ranges_exclude_crlf_line_endings() {
424        let document = SourceDocument::new("// comment\r\nnext\r\n");
425        let comment = document.comments().next().unwrap();
426        assert_eq!(comment.text(&document), "// comment");
427    }
428}