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 line_starts: Vec<usize>,
74}
75
76impl SourceDocument {
77 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 pub fn text(&self) -> &str {
91 &self.text
92 }
93
94 pub fn comments(&self) -> Iter<'_, SourceComment> {
96 self.comments.iter()
97 }
98
99 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 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 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 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 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#[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 pub fn range(&self) -> Range<usize> {
186 self.range.clone()
187 }
188
189 pub fn text<'a>(&self, document: &'a SourceDocument) -> &'a str {
191 &document.text[self.range.clone()]
192 }
193}
194
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
197pub enum CommentKind {
198 Line,
199}
200
201#[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 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
341pub struct Position {
342 pub line: u32,
343 pub col: u32,
344}
345
346impl Position {
347 pub const fn new(line: u32, col: u32) -> Self {
349 Position { line, col }
350 }
351
352 pub const fn is_valid(self) -> bool {
354 self.line >= 1 && self.col >= 1
355 }
356}
357
358#[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 pub const fn new(file: FileId, start: Position, end: Position) -> Self {
369 Span { file, start, end }
370 }
371
372 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}