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)]
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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
316pub struct Position {
317 pub line: u32,
318 pub col: u32,
319}
320
321impl Position {
322 pub const fn new(line: u32, col: u32) -> Self {
324 Position { line, col }
325 }
326
327 pub const fn is_valid(self) -> bool {
329 self.line >= 1 && self.col >= 1
330 }
331}
332
333#[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 pub const fn new(file: FileId, start: Position, end: Position) -> Self {
344 Span { file, start, end }
345 }
346
347 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}