// SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: 2023 The Gleam contributors use gleam_core::{Error, Warning, diagnostic::Diagnostic}; use std::collections::{HashMap, HashSet}; use camino::Utf8PathBuf; use super::engine::Compilation; #[derive(Debug, Default, PartialEq, Eq)] pub struct Feedback { pub diagnostics: HashMap>, pub messages: Vec, } impl Feedback { /// Set the diagnostics for a file to an empty vector. This will overwrite /// any existing diagnostics on the client. pub fn unset_existing_diagnostics(&mut self, path: Utf8PathBuf) { _ = self.diagnostics.insert(path, vec![]); } pub fn append_diagnostic(&mut self, path: Utf8PathBuf, diagnostic: Diagnostic) { self.diagnostics.entry(path).or_default().push(diagnostic); } /// No feedback at all. /// pub fn none() -> Feedback { Feedback::default() } /// Add all the content of another feedback to this feedback. /// pub fn append_feedback(&mut self, feedback: Feedback) { for (path, diagnostics) in feedback.diagnostics { // Any new diagnostics for a file will overwrite any existing ones. _ = self.diagnostics.insert(path, diagnostics); } for diagnostic in feedback.messages { self.append_message(diagnostic); } } fn append_message(&mut self, diagnostic: Diagnostic) { self.messages.push(diagnostic); } } /// When an operation succeeds or fails we want to send diagnostics and /// messages to the client for displaying to the user. This object converts /// Gleam warnings, errors, etc to these feedback items. /// /// Gleam has incremental compilation so we cannot erase all previous /// diagnostics and replace each time new diagnostics are available; if a file /// has not been recompiled then any diagnostics it had previously are still /// valid and must not be erased. /// To do this we keep track of which files have diagnostics and only overwrite /// them if the file has been recompiled. /// #[derive(Debug, Default)] pub struct FeedbackBookKeeper { files_with_warnings: HashSet, files_with_errors: HashSet, /// Skipped files need special handling: a compile will skip a file if it /// depends on some other file that couldn't be compiled. /// This means that skipped files will not have regular type errors, but a /// single error telling the developer to go fix the wrong file they depend /// on. /// /// However, we don't want those errors to always be displayed, it would be /// quite annoying having to browse through them in the IDE while the only /// error that really matters is the one in the wrong module they depend on. /// But it's really important that this is displayed on files that are open /// in the IDE so that a developer is not confused by the total lack of /// errors and can go fix them. /// /// For each file skipped during compilation we keep track of that error /// message and make sure to present it to the developer if the file is /// open. skipped_files_diagnostics: HashMap, open_files: HashSet, } impl FeedbackBookKeeper { /// Send diagnostics for any warnings and remove any diagnostics for files /// that have compiled without warnings. /// pub fn response(&mut self, compilation: Compilation, warnings: Vec) -> Feedback { let mut feedback = Feedback::default(); if let Compilation::Yes(compiled_modules) = compilation { // Any existing diagnostics for files that have been compiled are no // longer valid so we set an empty vector of diagnostics for the files // to erase their diagnostics. for path in compiled_modules { let has_existing_diagnostics = self.files_with_warnings.remove(&path); if has_existing_diagnostics { feedback.unset_existing_diagnostics(path); } } // Compilation was attempted and there is no error (which there is not // in this function) then it means that compilation has succeeded, so // there should be no error diagnostics. // We don't limit this to files that have been compiled as a previous // cached version could be used instead of a recompile. self.unset_errors(&mut feedback); self.unset_skipped_files(&mut feedback); } for warning in warnings { self.insert_warning(&mut feedback, warning); } feedback } fn unset_errors(&mut self, feedback: &mut Feedback) { // TODO: avoid clobbering warnings. They should be preserved rather than // removed with the errors here. We will need to store the warnings and // re-send them. for path in self.files_with_errors.drain() { feedback.unset_existing_diagnostics(path); } } fn unset_skipped_files(&mut self, feedback: &mut Feedback) { for (path, _) in self.skipped_files_diagnostics.drain() { feedback.unset_existing_diagnostics(path); } } /// Compilation failed, boo! /// /// Send diagnostics for any warnings and remove any diagnostics for files /// that have compiled without warnings, AND ALSO send diagnostics for the /// error that caused compilation to fail. /// pub fn build_with_error( &mut self, error: Error, compilation: Compilation, warnings: Vec, ) -> Feedback { let diagnostics = error.to_diagnostics(); let mut feedback = self.response(compilation, warnings); // A new error means that any existing errors are no longer valid. // Unset them. self.unset_skipped_files(&mut feedback); self.unset_errors(&mut feedback); for diagnostic in diagnostics { match diagnostic.location.as_ref().map(|l| l.path.clone()) { Some(path) => { _ = self.files_with_errors.insert(path.clone()); feedback.append_diagnostic(path, diagnostic); } None => { feedback.append_message(diagnostic); } } } self.skipped_files_diagnostics = error.skipped_files_diagnostics(); for open_file in self.open_files.iter() { if let Some(diagnostic) = self.skipped_files_diagnostics.get(open_file) { feedback.append_diagnostic(open_file.clone(), diagnostic.clone()); } } feedback } pub fn error(&mut self, error: Error) -> Feedback { self.build_with_error(error, Compilation::No, vec![]) } fn insert_warning(&mut self, feedback: &mut Feedback, warning: Warning) { let diagnostic = warning.to_diagnostic(); if let Some(path) = diagnostic.location.as_ref().map(|l| l.path.clone()) { _ = self.files_with_warnings.insert(path.clone()); feedback.append_diagnostic(path, diagnostic); } } pub fn open_file(&mut self, file: Utf8PathBuf) -> Feedback { let _ = self.open_files.insert(file.clone()); let mut feedback = Feedback::none(); if let Some(diagnostic) = self.skipped_files_diagnostics.get(&file) { feedback.append_diagnostic(file, diagnostic.clone()); } feedback } pub fn close_file(&mut self, file: &Utf8PathBuf) -> Feedback { let _ = self.open_files.remove(file); let mut feedback = Feedback::none(); if self.skipped_files_diagnostics.contains_key(file) { feedback.unset_existing_diagnostics(file.clone()); } feedback } } #[cfg(test)] mod tests { use std::assert_eq; use super::*; use gleam_core::{ ast::SrcSpan, diagnostic::Level, parse::error::{ParseError, ParseErrorType}, type_, }; #[test] fn feedback() { let mut book_keeper = FeedbackBookKeeper::default(); let file1 = Utf8PathBuf::from("src/file1.gleam"); let file2 = Utf8PathBuf::from("src/file2.gleam"); let file3 = Utf8PathBuf::from("src/file3.gleam"); let warning1 = Warning::Type { path: file1.clone(), src: "src".into(), warning: Box::new(type_::Warning::NoFieldsRecordUpdate { location: SrcSpan::new(1, 2), }), }; let warning2 = Warning::Type { path: file2.clone(), src: "src".into(), warning: Box::new(type_::Warning::NoFieldsRecordUpdate { location: SrcSpan::new(1, 2), }), }; let feedback = book_keeper.response( Compilation::Yes(vec![file1.clone()]), vec![warning1.clone(), warning1.clone(), warning2.clone()], ); assert_eq!( Feedback { diagnostics: HashMap::from([ ( file1.clone(), vec![warning1.to_diagnostic(), warning1.to_diagnostic(),] ), (file2.clone(), vec![warning2.to_diagnostic(),]) ]), messages: vec![], }, feedback ); let feedback = book_keeper.response( Compilation::Yes(vec![file1.clone(), file2.clone(), file3]), vec![], ); assert_eq!( Feedback { diagnostics: HashMap::from([ // File 1 and 2 had diagnostics before so they have been unset (file1, vec![]), (file2, vec![]), // File 3 had no diagnostics so does not need to to be unset ]), messages: vec![], }, feedback ); } #[test] fn locationless_error() { // The failed method sets an additional messages for errors without a // location. let mut book_keeper = FeedbackBookKeeper::default(); let file1 = Utf8PathBuf::from("src/file1.gleam"); let warning1 = Warning::Type { path: file1.clone(), src: "src".into(), warning: Box::new(type_::Warning::NoFieldsRecordUpdate { location: SrcSpan::new(1, 2), }), }; let locationless_error = Error::Gzip("Hello!".into()); let feedback = book_keeper.build_with_error( locationless_error.clone(), Compilation::Yes(vec![]), vec![warning1.clone()], ); assert_eq!( Feedback { diagnostics: HashMap::from([(file1, vec![warning1.to_diagnostic()])]), messages: locationless_error.to_diagnostics(), }, feedback ); } #[test] fn error() { // The failed method sets an additional diagnostic if the error has a // location. let mut book_keeper = FeedbackBookKeeper::default(); let file1 = Utf8PathBuf::from("src/file1.gleam"); let file3 = Utf8PathBuf::from("src/file2.gleam"); let warning1 = Warning::Type { path: file1.clone(), src: "src".into(), warning: Box::new(type_::Warning::NoFieldsRecordUpdate { location: SrcSpan::new(1, 2), }), }; let error = Error::Parse { path: file3.clone(), src: "blah".into(), error: Box::new(ParseError { error: ParseErrorType::ConcatPatternVariableLeftHandSide, location: SrcSpan::new(1, 4), }), }; let feedback = book_keeper.build_with_error( error.clone(), Compilation::Yes(vec![]), vec![warning1.clone()], ); assert_eq!( Feedback { diagnostics: HashMap::from([ (file1, vec![warning1.to_diagnostic()]), (file3.clone(), error.to_diagnostics()), ]), messages: vec![], }, feedback ); // The error diagnostic should be removed if the file compiles later. let feedback = book_keeper.response(Compilation::Yes(vec![file3.clone()]), vec![]); assert_eq!( Feedback { diagnostics: HashMap::from([(file3, vec![])]), messages: vec![], }, feedback ); } // https://github.com/gleam-lang/gleam/issues/2093 #[test] fn successful_compilation_removes_error_diagnostic() { // It is possible for a compile error to be fixed but the module that // had the error to not actually be recompiled. // // 1. File is OK // 2. File is edited to an invalid state // 3. A compile error is emitted // 4. File is edited back to the earlier valid state // 5. File is not recompiled as the cache from step 1 is still valid // // Because of this the compiled files iterator does not contain the // file, so we need to make sure that the error is removed through other // means, such as tracking which files have errors and removing them all // when a successful compilation occurs. let mut book_keeper = FeedbackBookKeeper::default(); let file1 = Utf8PathBuf::from("src/file1.gleam"); let file2 = Utf8PathBuf::from("src/file2.gleam"); let error = Error::Parse { path: file1.clone(), src: "blah".into(), error: Box::new(ParseError { error: ParseErrorType::ConcatPatternVariableLeftHandSide, location: SrcSpan::new(1, 4), }), }; let feedback = book_keeper.build_with_error(error.clone(), Compilation::Yes(vec![]), vec![]); assert_eq!( Feedback { diagnostics: HashMap::from([(file1.clone(), error.to_diagnostics())]), messages: vec![], }, feedback ); // The error diagnostic should be removed on a successful compilation, // even though the file is not in the compiled files iterator. let feedback = book_keeper.response(Compilation::Yes(vec![file2]), vec![]); assert_eq!( Feedback { diagnostics: HashMap::from([(file1, vec![])]), messages: vec![], }, feedback ); } // https://github.com/gleam-lang/gleam/issues/2122 #[test] fn second_failure_unsets_previous_error() { let mut book_keeper = FeedbackBookKeeper::default(); let file1 = Utf8PathBuf::from("src/file1.gleam"); let file2 = Utf8PathBuf::from("src/file2.gleam"); let error = |file: &camino::Utf8Path| Error::Parse { path: file.to_path_buf(), src: "blah".into(), error: Box::new(ParseError { error: ParseErrorType::ConcatPatternVariableLeftHandSide, location: SrcSpan::new(1, 4), }), }; let feedback = book_keeper.build_with_error(error(&file1), Compilation::Yes(vec![]), vec![]); assert_eq!( Feedback { diagnostics: HashMap::from([(file1.clone(), error(&file1).to_diagnostics())]), messages: vec![], }, feedback ); let feedback = book_keeper.build_with_error(error(&file2), Compilation::Yes(vec![]), vec![]); assert_eq!( Feedback { diagnostics: HashMap::from([ // Unset the previous error (file1, vec![]), // Set the new one (file2.clone(), error(&file2).to_diagnostics()), ]), messages: vec![], }, feedback ); } // https://github.com/gleam-lang/gleam/issues/2105 #[test] fn successful_non_compilation_does_not_remove_error_diagnostic() { let mut book_keeper = FeedbackBookKeeper::default(); let file1 = Utf8PathBuf::from("src/file1.gleam"); let error = Error::Parse { path: file1.clone(), src: "blah".into(), error: Box::new(ParseError { error: ParseErrorType::ConcatPatternVariableLeftHandSide, location: SrcSpan::new(1, 4), }), }; let feedback = book_keeper.build_with_error(error.clone(), Compilation::Yes(vec![]), vec![]); assert_eq!( Feedback { diagnostics: HashMap::from([(file1, error.to_diagnostics())]), messages: vec![], }, feedback ); // The error diagnostic should not be removed, nothing has been // successfully compiled. let feedback = book_keeper.response(Compilation::No, vec![]); assert_eq!( Feedback { diagnostics: HashMap::new(), messages: vec![], }, feedback ); } #[test] fn append_feedback_new_file() { let mut feedback = Feedback { diagnostics: HashMap::from([( Utf8PathBuf::from("src/file1.gleam"), vec![Diagnostic { location: None, hint: None, text: "Error 1".to_string(), title: "Error 1".to_string(), level: Level::Error, }], )]), messages: vec![Diagnostic { location: None, hint: None, text: "Error 2".to_string(), title: "Error 2".to_string(), level: Level::Error, }], }; feedback.append_feedback(Feedback { diagnostics: HashMap::from([( Utf8PathBuf::from("src/file2.gleam"), vec![Diagnostic { location: None, hint: None, text: "Error 3".to_string(), title: "Error 3".to_string(), level: Level::Error, }], )]), messages: vec![], }); assert_eq!( feedback, Feedback { diagnostics: HashMap::from([ ( Utf8PathBuf::from("src/file1.gleam"), vec![Diagnostic { location: None, hint: None, text: "Error 1".to_string(), title: "Error 1".to_string(), level: Level::Error, }], ), ( Utf8PathBuf::from("src/file2.gleam"), vec![Diagnostic { location: None, hint: None, text: "Error 3".to_string(), title: "Error 3".to_string(), level: Level::Error, }], ), ]), messages: vec![Diagnostic { location: None, hint: None, text: "Error 2".to_string(), title: "Error 2".to_string(), level: Level::Error, },], } ); } #[test] fn append_feedback_same_file() { let mut feedback = Feedback { diagnostics: HashMap::from([( Utf8PathBuf::from("src/file1.gleam"), vec![Diagnostic { location: None, hint: None, text: "Error 1".to_string(), title: "Error 1".to_string(), level: Level::Error, }], )]), messages: vec![Diagnostic { location: None, hint: None, text: "Error 2".to_string(), title: "Error 2".to_string(), level: Level::Error, }], }; feedback.append_feedback(Feedback { diagnostics: HashMap::from([( Utf8PathBuf::from("src/file1.gleam"), vec![Diagnostic { location: None, hint: None, text: "Error 3".to_string(), title: "Error 3".to_string(), level: Level::Error, }], )]), messages: vec![], }); assert_eq!( feedback, Feedback { diagnostics: HashMap::from([( Utf8PathBuf::from("src/file1.gleam"), vec![Diagnostic { location: None, hint: None, text: "Error 3".to_string(), title: "Error 3".to_string(), level: Level::Error, }], ),]), messages: vec![Diagnostic { location: None, hint: None, text: "Error 2".to_string(), title: "Error 2".to_string(), level: Level::Error, },], } ); } #[test] fn append_feedback_new_message() { let mut feedback = Feedback { diagnostics: HashMap::from([( Utf8PathBuf::from("src/file1.gleam"), vec![Diagnostic { location: None, hint: None, text: "Error 1".to_string(), title: "Error 1".to_string(), level: Level::Error, }], )]), messages: vec![Diagnostic { location: None, hint: None, text: "Error 2".to_string(), title: "Error 2".to_string(), level: Level::Error, }], }; feedback.append_feedback(Feedback { diagnostics: HashMap::from([]), messages: vec![Diagnostic { location: None, hint: None, text: "Error 3".to_string(), title: "Error 3".to_string(), level: Level::Error, }], }); assert_eq!( feedback, Feedback { diagnostics: HashMap::from([( Utf8PathBuf::from("src/file1.gleam"), vec![Diagnostic { location: None, hint: None, text: "Error 1".to_string(), title: "Error 1".to_string(), level: Level::Error, },], ),]), messages: vec![ Diagnostic { location: None, hint: None, text: "Error 2".to_string(), title: "Error 2".to_string(), level: Level::Error, }, Diagnostic { location: None, hint: None, text: "Error 3".to_string(), title: "Error 3".to_string(), level: Level::Error, } ], } ); } #[test] fn append_feedback_new_file_blank() { let mut feedback = Feedback { diagnostics: HashMap::from([( Utf8PathBuf::from("src/file1.gleam"), vec![Diagnostic { location: None, hint: None, text: "Error 1".to_string(), title: "Error 1".to_string(), level: Level::Error, }], )]), messages: vec![Diagnostic { location: None, hint: None, text: "Error 2".to_string(), title: "Error 2".to_string(), level: Level::Error, }], }; feedback.append_feedback(Feedback { diagnostics: HashMap::from([(Utf8PathBuf::from("src/file2.gleam"), vec![])]), messages: vec![], }); assert_eq!( feedback, Feedback { diagnostics: HashMap::from([ ( Utf8PathBuf::from("src/file1.gleam"), vec![Diagnostic { location: None, hint: None, text: "Error 1".to_string(), title: "Error 1".to_string(), level: Level::Error, },], ), (Utf8PathBuf::from("src/file2.gleam"), vec![],), ]), messages: vec![Diagnostic { location: None, hint: None, text: "Error 2".to_string(), title: "Error 2".to_string(), level: Level::Error, },], } ); } #[test] fn append_feedback_existing_file_blank() { let mut feedback = Feedback { diagnostics: HashMap::from([( Utf8PathBuf::from("src/file1.gleam"), vec![Diagnostic { location: None, hint: None, text: "Error 1".to_string(), title: "Error 1".to_string(), level: Level::Error, }], )]), messages: vec![Diagnostic { location: None, hint: None, text: "Error 2".to_string(), title: "Error 2".to_string(), level: Level::Error, }], }; feedback.append_feedback(Feedback { diagnostics: HashMap::from([(Utf8PathBuf::from("src/file1.gleam"), vec![])]), messages: vec![], }); assert_eq!( feedback, Feedback { diagnostics: HashMap::from([(Utf8PathBuf::from("src/file1.gleam"), vec![],),]), messages: vec![Diagnostic { location: None, hint: None, text: "Error 2".to_string(), title: "Error 2".to_string(), level: Level::Error, },], } ); } }