1use std::{ops::Range, slice::Iter};
2
3use super::ids::Id;
4
5pub type FileId = Id<SourceFile>;
7
8#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct SourceFile {
11 pub path: String,
13 file: Option<FileId>,
14 source: Option<SourceDocument>,
15}
16
17impl SourceFile {
18 pub fn new(path: impl Into<String>) -> Self {
20 SourceFile {
21 path: path.into(),
22 file: None,
23 source: None,
24 }
25 }
26
27 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 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 pub fn source(&self) -> Option<&SourceDocument> {
52 self.source.as_ref()
53 }
54}
55
56#[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 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 pub fn text(&self) -> &str {
85 &self.text
86 }
87
88 pub fn comments(&self) -> Iter<'_, SourceComment> {
90 self.comments.iter()
91 }
92
93 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 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 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 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 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#[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 pub fn range(&self) -> Range<usize> {
169 self.range.clone()
170 }
171
172 pub fn text<'a>(&self, document: &'a SourceDocument) -> &'a str {
174 &document.text[self.range.clone()]
175 }
176}
177
178#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180pub enum CommentKind {
181 Line,
182}
183
184#[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 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
363pub struct Position {
364 pub line: u32,
365 pub col: u32,
366}
367
368impl Position {
369 pub const fn new(line: u32, col: u32) -> Self {
371 Position { line, col }
372 }
373
374 pub const fn is_valid(self) -> bool {
376 self.line >= 1 && self.col >= 1
377 }
378}
379
380#[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 pub const fn new(file: FileId, start: Position, end: Position) -> Self {
391 Span { file, start, end }
392 }
393
394 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}