Fork of daniellemaywood.uk/gleam — Wasm codegen work
27 kB
792 lines
1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: 2023 The Gleam contributors
3
4use gleam_core::{Error, Warning, diagnostic::Diagnostic};
5use std::collections::{HashMap, HashSet};
6
7use camino::Utf8PathBuf;
8
9use super::engine::Compilation;
10
11#[derive(Debug, Default, PartialEq, Eq)]
12pub struct Feedback {
13 pub diagnostics: HashMap<Utf8PathBuf, Vec<Diagnostic>>,
14 pub messages: Vec<Diagnostic>,
15}
16
17impl Feedback {
18 /// Set the diagnostics for a file to an empty vector. This will overwrite
19 /// any existing diagnostics on the client.
20 pub fn unset_existing_diagnostics(&mut self, path: Utf8PathBuf) {
21 _ = self.diagnostics.insert(path, vec![]);
22 }
23
24 pub fn append_diagnostic(&mut self, path: Utf8PathBuf, diagnostic: Diagnostic) {
25 self.diagnostics.entry(path).or_default().push(diagnostic);
26 }
27
28 /// No feedback at all.
29 ///
30 pub fn none() -> Feedback {
31 Feedback::default()
32 }
33
34 /// Add all the content of another feedback to this feedback.
35 ///
36 pub fn append_feedback(&mut self, feedback: Feedback) {
37 for (path, diagnostics) in feedback.diagnostics {
38 // Any new diagnostics for a file will overwrite any existing ones.
39 _ = self.diagnostics.insert(path, diagnostics);
40 }
41 for diagnostic in feedback.messages {
42 self.append_message(diagnostic);
43 }
44 }
45
46 fn append_message(&mut self, diagnostic: Diagnostic) {
47 self.messages.push(diagnostic);
48 }
49}
50
51/// When an operation succeeds or fails we want to send diagnostics and
52/// messages to the client for displaying to the user. This object converts
53/// Gleam warnings, errors, etc to these feedback items.
54///
55/// Gleam has incremental compilation so we cannot erase all previous
56/// diagnostics and replace each time new diagnostics are available; if a file
57/// has not been recompiled then any diagnostics it had previously are still
58/// valid and must not be erased.
59/// To do this we keep track of which files have diagnostics and only overwrite
60/// them if the file has been recompiled.
61///
62#[derive(Debug, Default)]
63pub struct FeedbackBookKeeper {
64 files_with_warnings: HashSet<Utf8PathBuf>,
65 files_with_errors: HashSet<Utf8PathBuf>,
66 /// Skipped files need special handling: a compile will skip a file if it
67 /// depends on some other file that couldn't be compiled.
68 /// This means that skipped files will not have regular type errors, but a
69 /// single error telling the developer to go fix the wrong file they depend
70 /// on.
71 ///
72 /// However, we don't want those errors to always be displayed, it would be
73 /// quite annoying having to browse through them in the IDE while the only
74 /// error that really matters is the one in the wrong module they depend on.
75 /// But it's really important that this is displayed on files that are open
76 /// in the IDE so that a developer is not confused by the total lack of
77 /// errors and can go fix them.
78 ///
79 /// For each file skipped during compilation we keep track of that error
80 /// message and make sure to present it to the developer if the file is
81 /// open.
82 skipped_files_diagnostics: HashMap<Utf8PathBuf, Diagnostic>,
83 open_files: HashSet<Utf8PathBuf>,
84}
85
86impl FeedbackBookKeeper {
87 /// Send diagnostics for any warnings and remove any diagnostics for files
88 /// that have compiled without warnings.
89 ///
90 pub fn response(&mut self, compilation: Compilation, warnings: Vec<Warning>) -> Feedback {
91 let mut feedback = Feedback::default();
92
93 if let Compilation::Yes(compiled_modules) = compilation {
94 // Any existing diagnostics for files that have been compiled are no
95 // longer valid so we set an empty vector of diagnostics for the files
96 // to erase their diagnostics.
97 for path in compiled_modules {
98 let has_existing_diagnostics = self.files_with_warnings.remove(&path);
99 if has_existing_diagnostics {
100 feedback.unset_existing_diagnostics(path);
101 }
102 }
103
104 // Compilation was attempted and there is no error (which there is not
105 // in this function) then it means that compilation has succeeded, so
106 // there should be no error diagnostics.
107 // We don't limit this to files that have been compiled as a previous
108 // cached version could be used instead of a recompile.
109 self.unset_errors(&mut feedback);
110 self.unset_skipped_files(&mut feedback);
111 }
112
113 for warning in warnings {
114 self.insert_warning(&mut feedback, warning);
115 }
116
117 feedback
118 }
119
120 fn unset_errors(&mut self, feedback: &mut Feedback) {
121 // TODO: avoid clobbering warnings. They should be preserved rather than
122 // removed with the errors here. We will need to store the warnings and
123 // re-send them.
124 for path in self.files_with_errors.drain() {
125 feedback.unset_existing_diagnostics(path);
126 }
127 }
128
129 fn unset_skipped_files(&mut self, feedback: &mut Feedback) {
130 for (path, _) in self.skipped_files_diagnostics.drain() {
131 feedback.unset_existing_diagnostics(path);
132 }
133 }
134
135 /// Compilation failed, boo!
136 ///
137 /// Send diagnostics for any warnings and remove any diagnostics for files
138 /// that have compiled without warnings, AND ALSO send diagnostics for the
139 /// error that caused compilation to fail.
140 ///
141 pub fn build_with_error(
142 &mut self,
143 error: Error,
144 compilation: Compilation,
145 warnings: Vec<Warning>,
146 ) -> Feedback {
147 let diagnostics = error.to_diagnostics();
148 let mut feedback = self.response(compilation, warnings);
149
150 // A new error means that any existing errors are no longer valid.
151 // Unset them.
152 self.unset_skipped_files(&mut feedback);
153 self.unset_errors(&mut feedback);
154
155 for diagnostic in diagnostics {
156 match diagnostic.location.as_ref().map(|l| l.path.clone()) {
157 Some(path) => {
158 _ = self.files_with_errors.insert(path.clone());
159 feedback.append_diagnostic(path, diagnostic);
160 }
161
162 None => {
163 feedback.append_message(diagnostic);
164 }
165 }
166 }
167
168 self.skipped_files_diagnostics = error.skipped_files_diagnostics();
169 for open_file in self.open_files.iter() {
170 if let Some(diagnostic) = self.skipped_files_diagnostics.get(open_file) {
171 feedback.append_diagnostic(open_file.clone(), diagnostic.clone());
172 }
173 }
174
175 feedback
176 }
177
178 pub fn error(&mut self, error: Error) -> Feedback {
179 self.build_with_error(error, Compilation::No, vec![])
180 }
181
182 fn insert_warning(&mut self, feedback: &mut Feedback, warning: Warning) {
183 let diagnostic = warning.to_diagnostic();
184 if let Some(path) = diagnostic.location.as_ref().map(|l| l.path.clone()) {
185 _ = self.files_with_warnings.insert(path.clone());
186 feedback.append_diagnostic(path, diagnostic);
187 }
188 }
189
190 pub fn open_file(&mut self, file: Utf8PathBuf) -> Feedback {
191 let _ = self.open_files.insert(file.clone());
192 let mut feedback = Feedback::none();
193 if let Some(diagnostic) = self.skipped_files_diagnostics.get(&file) {
194 feedback.append_diagnostic(file, diagnostic.clone());
195 }
196 feedback
197 }
198
199 pub fn close_file(&mut self, file: &Utf8PathBuf) -> Feedback {
200 let _ = self.open_files.remove(file);
201 let mut feedback = Feedback::none();
202 if self.skipped_files_diagnostics.contains_key(file) {
203 feedback.unset_existing_diagnostics(file.clone());
204 }
205 feedback
206 }
207}
208
209#[cfg(test)]
210mod tests {
211
212 use std::assert_eq;
213
214 use super::*;
215 use gleam_core::{
216 ast::SrcSpan,
217 diagnostic::Level,
218 parse::error::{ParseError, ParseErrorType},
219 type_,
220 };
221
222 #[test]
223 fn feedback() {
224 let mut book_keeper = FeedbackBookKeeper::default();
225 let file1 = Utf8PathBuf::from("src/file1.gleam");
226 let file2 = Utf8PathBuf::from("src/file2.gleam");
227 let file3 = Utf8PathBuf::from("src/file3.gleam");
228
229 let warning1 = Warning::Type {
230 path: file1.clone(),
231 src: "src".into(),
232 warning: Box::new(type_::Warning::NoFieldsRecordUpdate {
233 location: SrcSpan::new(1, 2),
234 }),
235 };
236 let warning2 = Warning::Type {
237 path: file2.clone(),
238 src: "src".into(),
239 warning: Box::new(type_::Warning::NoFieldsRecordUpdate {
240 location: SrcSpan::new(1, 2),
241 }),
242 };
243
244 let feedback = book_keeper.response(
245 Compilation::Yes(vec![file1.clone()]),
246 vec![warning1.clone(), warning1.clone(), warning2.clone()],
247 );
248
249 assert_eq!(
250 Feedback {
251 diagnostics: HashMap::from([
252 (
253 file1.clone(),
254 vec![warning1.to_diagnostic(), warning1.to_diagnostic(),]
255 ),
256 (file2.clone(), vec![warning2.to_diagnostic(),])
257 ]),
258 messages: vec![],
259 },
260 feedback
261 );
262
263 let feedback = book_keeper.response(
264 Compilation::Yes(vec![file1.clone(), file2.clone(), file3]),
265 vec![],
266 );
267
268 assert_eq!(
269 Feedback {
270 diagnostics: HashMap::from([
271 // File 1 and 2 had diagnostics before so they have been unset
272 (file1, vec![]),
273 (file2, vec![]),
274 // File 3 had no diagnostics so does not need to to be unset
275 ]),
276 messages: vec![],
277 },
278 feedback
279 );
280 }
281
282 #[test]
283 fn locationless_error() {
284 // The failed method sets an additional messages for errors without a
285 // location.
286
287 let mut book_keeper = FeedbackBookKeeper::default();
288 let file1 = Utf8PathBuf::from("src/file1.gleam");
289
290 let warning1 = Warning::Type {
291 path: file1.clone(),
292 src: "src".into(),
293 warning: Box::new(type_::Warning::NoFieldsRecordUpdate {
294 location: SrcSpan::new(1, 2),
295 }),
296 };
297
298 let locationless_error = Error::Gzip("Hello!".into());
299
300 let feedback = book_keeper.build_with_error(
301 locationless_error.clone(),
302 Compilation::Yes(vec![]),
303 vec![warning1.clone()],
304 );
305
306 assert_eq!(
307 Feedback {
308 diagnostics: HashMap::from([(file1, vec![warning1.to_diagnostic()])]),
309 messages: locationless_error.to_diagnostics(),
310 },
311 feedback
312 );
313 }
314
315 #[test]
316 fn error() {
317 // The failed method sets an additional diagnostic if the error has a
318 // location.
319
320 let mut book_keeper = FeedbackBookKeeper::default();
321 let file1 = Utf8PathBuf::from("src/file1.gleam");
322 let file3 = Utf8PathBuf::from("src/file2.gleam");
323
324 let warning1 = Warning::Type {
325 path: file1.clone(),
326 src: "src".into(),
327 warning: Box::new(type_::Warning::NoFieldsRecordUpdate {
328 location: SrcSpan::new(1, 2),
329 }),
330 };
331 let error = Error::Parse {
332 path: file3.clone(),
333 src: "blah".into(),
334 error: Box::new(ParseError {
335 error: ParseErrorType::ConcatPatternVariableLeftHandSide,
336 location: SrcSpan::new(1, 4),
337 }),
338 };
339
340 let feedback = book_keeper.build_with_error(
341 error.clone(),
342 Compilation::Yes(vec![]),
343 vec![warning1.clone()],
344 );
345
346 assert_eq!(
347 Feedback {
348 diagnostics: HashMap::from([
349 (file1, vec![warning1.to_diagnostic()]),
350 (file3.clone(), error.to_diagnostics()),
351 ]),
352 messages: vec![],
353 },
354 feedback
355 );
356
357 // The error diagnostic should be removed if the file compiles later.
358
359 let feedback = book_keeper.response(Compilation::Yes(vec![file3.clone()]), vec![]);
360
361 assert_eq!(
362 Feedback {
363 diagnostics: HashMap::from([(file3, vec![])]),
364 messages: vec![],
365 },
366 feedback
367 );
368 }
369
370 // https://github.com/gleam-lang/gleam/issues/2093
371 #[test]
372 fn successful_compilation_removes_error_diagnostic() {
373 // It is possible for a compile error to be fixed but the module that
374 // had the error to not actually be recompiled.
375 //
376 // 1. File is OK
377 // 2. File is edited to an invalid state
378 // 3. A compile error is emitted
379 // 4. File is edited back to the earlier valid state
380 // 5. File is not recompiled as the cache from step 1 is still valid
381 //
382 // Because of this the compiled files iterator does not contain the
383 // file, so we need to make sure that the error is removed through other
384 // means, such as tracking which files have errors and removing them all
385 // when a successful compilation occurs.
386
387 let mut book_keeper = FeedbackBookKeeper::default();
388 let file1 = Utf8PathBuf::from("src/file1.gleam");
389 let file2 = Utf8PathBuf::from("src/file2.gleam");
390
391 let error = Error::Parse {
392 path: file1.clone(),
393 src: "blah".into(),
394 error: Box::new(ParseError {
395 error: ParseErrorType::ConcatPatternVariableLeftHandSide,
396 location: SrcSpan::new(1, 4),
397 }),
398 };
399
400 let feedback =
401 book_keeper.build_with_error(error.clone(), Compilation::Yes(vec![]), vec![]);
402
403 assert_eq!(
404 Feedback {
405 diagnostics: HashMap::from([(file1.clone(), error.to_diagnostics())]),
406 messages: vec![],
407 },
408 feedback
409 );
410
411 // The error diagnostic should be removed on a successful compilation,
412 // even though the file is not in the compiled files iterator.
413
414 let feedback = book_keeper.response(Compilation::Yes(vec![file2]), vec![]);
415
416 assert_eq!(
417 Feedback {
418 diagnostics: HashMap::from([(file1, vec![])]),
419 messages: vec![],
420 },
421 feedback
422 );
423 }
424
425 // https://github.com/gleam-lang/gleam/issues/2122
426 #[test]
427 fn second_failure_unsets_previous_error() {
428 let mut book_keeper = FeedbackBookKeeper::default();
429 let file1 = Utf8PathBuf::from("src/file1.gleam");
430 let file2 = Utf8PathBuf::from("src/file2.gleam");
431
432 let error = |file: &camino::Utf8Path| Error::Parse {
433 path: file.to_path_buf(),
434 src: "blah".into(),
435 error: Box::new(ParseError {
436 error: ParseErrorType::ConcatPatternVariableLeftHandSide,
437 location: SrcSpan::new(1, 4),
438 }),
439 };
440
441 let feedback =
442 book_keeper.build_with_error(error(&file1), Compilation::Yes(vec![]), vec![]);
443
444 assert_eq!(
445 Feedback {
446 diagnostics: HashMap::from([(file1.clone(), error(&file1).to_diagnostics())]),
447 messages: vec![],
448 },
449 feedback
450 );
451
452 let feedback =
453 book_keeper.build_with_error(error(&file2), Compilation::Yes(vec![]), vec![]);
454
455 assert_eq!(
456 Feedback {
457 diagnostics: HashMap::from([
458 // Unset the previous error
459 (file1, vec![]),
460 // Set the new one
461 (file2.clone(), error(&file2).to_diagnostics()),
462 ]),
463 messages: vec![],
464 },
465 feedback
466 );
467 }
468
469 // https://github.com/gleam-lang/gleam/issues/2105
470 #[test]
471 fn successful_non_compilation_does_not_remove_error_diagnostic() {
472 let mut book_keeper = FeedbackBookKeeper::default();
473 let file1 = Utf8PathBuf::from("src/file1.gleam");
474
475 let error = Error::Parse {
476 path: file1.clone(),
477 src: "blah".into(),
478 error: Box::new(ParseError {
479 error: ParseErrorType::ConcatPatternVariableLeftHandSide,
480 location: SrcSpan::new(1, 4),
481 }),
482 };
483
484 let feedback =
485 book_keeper.build_with_error(error.clone(), Compilation::Yes(vec![]), vec![]);
486
487 assert_eq!(
488 Feedback {
489 diagnostics: HashMap::from([(file1, error.to_diagnostics())]),
490 messages: vec![],
491 },
492 feedback
493 );
494
495 // The error diagnostic should not be removed, nothing has been
496 // successfully compiled.
497
498 let feedback = book_keeper.response(Compilation::No, vec![]);
499
500 assert_eq!(
501 Feedback {
502 diagnostics: HashMap::new(),
503 messages: vec![],
504 },
505 feedback
506 );
507 }
508
509 #[test]
510 fn append_feedback_new_file() {
511 let mut feedback = Feedback {
512 diagnostics: HashMap::from([(
513 Utf8PathBuf::from("src/file1.gleam"),
514 vec![Diagnostic {
515 location: None,
516 hint: None,
517 text: "Error 1".to_string(),
518 title: "Error 1".to_string(),
519 level: Level::Error,
520 }],
521 )]),
522 messages: vec![Diagnostic {
523 location: None,
524 hint: None,
525 text: "Error 2".to_string(),
526 title: "Error 2".to_string(),
527 level: Level::Error,
528 }],
529 };
530 feedback.append_feedback(Feedback {
531 diagnostics: HashMap::from([(
532 Utf8PathBuf::from("src/file2.gleam"),
533 vec![Diagnostic {
534 location: None,
535 hint: None,
536 text: "Error 3".to_string(),
537 title: "Error 3".to_string(),
538 level: Level::Error,
539 }],
540 )]),
541 messages: vec![],
542 });
543 assert_eq!(
544 feedback,
545 Feedback {
546 diagnostics: HashMap::from([
547 (
548 Utf8PathBuf::from("src/file1.gleam"),
549 vec![Diagnostic {
550 location: None,
551 hint: None,
552 text: "Error 1".to_string(),
553 title: "Error 1".to_string(),
554 level: Level::Error,
555 }],
556 ),
557 (
558 Utf8PathBuf::from("src/file2.gleam"),
559 vec![Diagnostic {
560 location: None,
561 hint: None,
562 text: "Error 3".to_string(),
563 title: "Error 3".to_string(),
564 level: Level::Error,
565 }],
566 ),
567 ]),
568 messages: vec![Diagnostic {
569 location: None,
570 hint: None,
571 text: "Error 2".to_string(),
572 title: "Error 2".to_string(),
573 level: Level::Error,
574 },],
575 }
576 );
577 }
578
579 #[test]
580 fn append_feedback_same_file() {
581 let mut feedback = Feedback {
582 diagnostics: HashMap::from([(
583 Utf8PathBuf::from("src/file1.gleam"),
584 vec![Diagnostic {
585 location: None,
586 hint: None,
587 text: "Error 1".to_string(),
588 title: "Error 1".to_string(),
589 level: Level::Error,
590 }],
591 )]),
592 messages: vec![Diagnostic {
593 location: None,
594 hint: None,
595 text: "Error 2".to_string(),
596 title: "Error 2".to_string(),
597 level: Level::Error,
598 }],
599 };
600 feedback.append_feedback(Feedback {
601 diagnostics: HashMap::from([(
602 Utf8PathBuf::from("src/file1.gleam"),
603 vec![Diagnostic {
604 location: None,
605 hint: None,
606 text: "Error 3".to_string(),
607 title: "Error 3".to_string(),
608 level: Level::Error,
609 }],
610 )]),
611 messages: vec![],
612 });
613 assert_eq!(
614 feedback,
615 Feedback {
616 diagnostics: HashMap::from([(
617 Utf8PathBuf::from("src/file1.gleam"),
618 vec![Diagnostic {
619 location: None,
620 hint: None,
621 text: "Error 3".to_string(),
622 title: "Error 3".to_string(),
623 level: Level::Error,
624 }],
625 ),]),
626 messages: vec![Diagnostic {
627 location: None,
628 hint: None,
629 text: "Error 2".to_string(),
630 title: "Error 2".to_string(),
631 level: Level::Error,
632 },],
633 }
634 );
635 }
636
637 #[test]
638 fn append_feedback_new_message() {
639 let mut feedback = Feedback {
640 diagnostics: HashMap::from([(
641 Utf8PathBuf::from("src/file1.gleam"),
642 vec![Diagnostic {
643 location: None,
644 hint: None,
645 text: "Error 1".to_string(),
646 title: "Error 1".to_string(),
647 level: Level::Error,
648 }],
649 )]),
650 messages: vec![Diagnostic {
651 location: None,
652 hint: None,
653 text: "Error 2".to_string(),
654 title: "Error 2".to_string(),
655 level: Level::Error,
656 }],
657 };
658 feedback.append_feedback(Feedback {
659 diagnostics: HashMap::from([]),
660 messages: vec![Diagnostic {
661 location: None,
662 hint: None,
663 text: "Error 3".to_string(),
664 title: "Error 3".to_string(),
665 level: Level::Error,
666 }],
667 });
668 assert_eq!(
669 feedback,
670 Feedback {
671 diagnostics: HashMap::from([(
672 Utf8PathBuf::from("src/file1.gleam"),
673 vec![Diagnostic {
674 location: None,
675 hint: None,
676 text: "Error 1".to_string(),
677 title: "Error 1".to_string(),
678 level: Level::Error,
679 },],
680 ),]),
681 messages: vec![
682 Diagnostic {
683 location: None,
684 hint: None,
685 text: "Error 2".to_string(),
686 title: "Error 2".to_string(),
687 level: Level::Error,
688 },
689 Diagnostic {
690 location: None,
691 hint: None,
692 text: "Error 3".to_string(),
693 title: "Error 3".to_string(),
694 level: Level::Error,
695 }
696 ],
697 }
698 );
699 }
700
701 #[test]
702 fn append_feedback_new_file_blank() {
703 let mut feedback = Feedback {
704 diagnostics: HashMap::from([(
705 Utf8PathBuf::from("src/file1.gleam"),
706 vec![Diagnostic {
707 location: None,
708 hint: None,
709 text: "Error 1".to_string(),
710 title: "Error 1".to_string(),
711 level: Level::Error,
712 }],
713 )]),
714 messages: vec![Diagnostic {
715 location: None,
716 hint: None,
717 text: "Error 2".to_string(),
718 title: "Error 2".to_string(),
719 level: Level::Error,
720 }],
721 };
722 feedback.append_feedback(Feedback {
723 diagnostics: HashMap::from([(Utf8PathBuf::from("src/file2.gleam"), vec![])]),
724 messages: vec![],
725 });
726 assert_eq!(
727 feedback,
728 Feedback {
729 diagnostics: HashMap::from([
730 (
731 Utf8PathBuf::from("src/file1.gleam"),
732 vec![Diagnostic {
733 location: None,
734 hint: None,
735 text: "Error 1".to_string(),
736 title: "Error 1".to_string(),
737 level: Level::Error,
738 },],
739 ),
740 (Utf8PathBuf::from("src/file2.gleam"), vec![],),
741 ]),
742 messages: vec![Diagnostic {
743 location: None,
744 hint: None,
745 text: "Error 2".to_string(),
746 title: "Error 2".to_string(),
747 level: Level::Error,
748 },],
749 }
750 );
751 }
752
753 #[test]
754 fn append_feedback_existing_file_blank() {
755 let mut feedback = Feedback {
756 diagnostics: HashMap::from([(
757 Utf8PathBuf::from("src/file1.gleam"),
758 vec![Diagnostic {
759 location: None,
760 hint: None,
761 text: "Error 1".to_string(),
762 title: "Error 1".to_string(),
763 level: Level::Error,
764 }],
765 )]),
766 messages: vec![Diagnostic {
767 location: None,
768 hint: None,
769 text: "Error 2".to_string(),
770 title: "Error 2".to_string(),
771 level: Level::Error,
772 }],
773 };
774 feedback.append_feedback(Feedback {
775 diagnostics: HashMap::from([(Utf8PathBuf::from("src/file1.gleam"), vec![])]),
776 messages: vec![],
777 });
778 assert_eq!(
779 feedback,
780 Feedback {
781 diagnostics: HashMap::from([(Utf8PathBuf::from("src/file1.gleam"), vec![],),]),
782 messages: vec![Diagnostic {
783 location: None,
784 hint: None,
785 text: "Error 2".to_string(),
786 title: "Error 2".to_string(),
787 level: Level::Error,
788 },],
789 }
790 );
791 }
792}