1use std::{ops::Range, slice::Iter};
8
9use super::ids::Id;
10
11pub type FileId = Id<SourceFile>;
13
14#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct SourceFile {
17 pub path: String,
19 file: Option<FileId>,
20 source: Option<SourceDocument>,
21}
22
23impl SourceFile {
24 pub fn new(path: impl Into<String>) -> Self {
26 SourceFile {
27 path: path.into(),
28 file: None,
29 source: None,
30 }
31 }
32
33 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 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 pub fn source(&self) -> Option<&SourceDocument> {
58 self.source.as_ref()
59 }
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct SourceDocument {
70 file: Option<FileId>,
71 text: String,
72 comments: Vec<SourceComment>,
73}
74
75impl SourceDocument {
76 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 pub fn text(&self) -> &str {
89 &self.text
90 }
91
92 pub fn comments(&self) -> Iter<'_, SourceComment> {
94 self.comments.iter()
95 }
96
97 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 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 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 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 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#[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 pub fn range(&self) -> Range<usize> {
184 self.range.clone()
185 }
186
187 pub fn text<'a>(&self, document: &'a SourceDocument) -> &'a str {
189 &document.text[self.range.clone()]
190 }
191}
192
193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
195pub enum CommentKind {
196 Line,
197}
198
199#[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 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
315pub struct Position {
316 pub line: u32,
317 pub col: u32,
318}
319
320impl Position {
321 pub const fn new(line: u32, col: u32) -> Self {
323 Position { line, col }
324 }
325
326 pub const fn is_valid(self) -> bool {
328 self.line >= 1 && self.col >= 1
329 }
330}
331
332#[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 pub const fn new(file: FileId, start: Position, end: Position) -> Self {
343 Span { file, start, end }
344 }
345
346 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}