diff --git a/.gitignore b/.gitignore index 23e34f6b..bcc109cc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /target /result* lcov.info +.direnv/ diff --git a/docs/cli.md b/docs/cli.md index d39a0a85..885a2467 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -37,6 +37,10 @@ Don't reload for `README.md` files: ghciwatch --reload-glob '!src/**/README.md' +Track warnings across recompilations to prevent them from disappearing: + + ghciwatch --track-warnings + ## Arguments
@@ -88,6 +92,19 @@ Don't interrupt reloads when files change. Depending on your workflow, `ghciwatch` may feel more responsive with this set. + +
--track-warnings
+ +Track warnings across recompilations. + +When enabled, warnings will be preserved in memory even when files are recompiled due to dependency changes, helping prevent "ephemeral warnings" from being missed. + +This feature addresses the common issue where GHC warnings disappear when files are recompiled due to dependency changes (without the file itself changing). With `--track-warnings`, warnings persist until the file is directly modified or removed. + +Tracked warnings are displayed with the same formatting and colors as fresh warnings, and are included in the error file output when using `--error-file`. + +Can also be enabled by setting the `GHCIWATCH_TRACK_WARNINGS` environment variable to any value. +
--completions <COMPLETIONS>
diff --git a/src/cli.rs b/src/cli.rs index c840944e..5c47edf7 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -96,6 +96,13 @@ pub struct Opts { #[arg(long, hide = true)] pub tui: bool, + /// Track warnings across recompilations. + /// + /// When enabled, warnings will be preserved in memory even when files are recompiled + /// due to dependency changes, helping prevent "ephemeral warnings" from being missed. + #[arg(long, env = "GHCIWATCH_TRACK_WARNINGS")] + pub track_warnings: bool, + /// Generate Markdown CLI documentation. #[cfg(feature = "clap-markdown")] #[arg(long, hide = true)] diff --git a/src/ghci/compilation_log.rs b/src/ghci/compilation_log.rs index 0b130038..8426eb1a 100644 --- a/src/ghci/compilation_log.rs +++ b/src/ghci/compilation_log.rs @@ -1,5 +1,6 @@ use crate::ghci::parse::CompilationResult; use crate::ghci::parse::CompilationSummary; +use crate::ghci::parse::CompilingModule; use crate::ghci::parse::GhcDiagnostic; use crate::ghci::parse::GhcMessage; use crate::ghci::parse::Severity; @@ -9,6 +10,7 @@ use crate::ghci::parse::Severity; pub struct CompilationLog { pub summary: Option, pub diagnostics: Vec, + pub compiled_modules: Vec, } impl CompilationLog { @@ -24,6 +26,7 @@ impl Extend for CompilationLog { match message { GhcMessage::Compiling(module) => { tracing::debug!(module = %module.name, path = %module.path, "Compiling"); + self.compiled_modules.push(module); } GhcMessage::Diagnostic(diagnostic) => { if let GhcDiagnostic { diff --git a/src/ghci/error_log.rs b/src/ghci/error_log.rs index ae954383..7a8b8ee3 100644 --- a/src/ghci/error_log.rs +++ b/src/ghci/error_log.rs @@ -1,3 +1,4 @@ +use camino::Utf8Path; use camino::Utf8PathBuf; use miette::IntoDiagnostic; use tokio::fs::File; @@ -23,6 +24,11 @@ impl ErrorLog { Self { path } } + /// Get the path for this error log writer, if any. + pub fn path(&self) -> Option<&Utf8Path> { + self.path.as_deref() + } + /// Write the error log, if any, with the given compilation summary and diagnostic messages. #[instrument(skip(self, log), name = "error_log_write", level = "debug")] pub async fn write(&mut self, log: &CompilationLog) -> miette::Result<()> { diff --git a/src/ghci/mod.rs b/src/ghci/mod.rs index 6eb2b4f8..3ea432f2 100644 --- a/src/ghci/mod.rs +++ b/src/ghci/mod.rs @@ -9,6 +9,7 @@ use std::borrow::Borrow; use std::borrow::Cow; use std::collections::BTreeMap; use std::collections::BTreeSet; +use std::collections::HashSet; use std::fmt::Debug; use std::path::Path; use std::process::ExitStatus; @@ -26,6 +27,7 @@ use miette::IntoDiagnostic; use miette::WrapErr; use nix::unistd::Pid; use tokio::io::AsyncBufReadExt; +use tokio::io::AsyncWriteExt; use tokio::io::BufReader; use tokio::sync::mpsc; use tracing::instrument; @@ -51,6 +53,7 @@ pub mod parse; use parse::parse_eval_commands; use parse::CompilationResult; use parse::EvalCommand; +use parse::ModulesLoaded; use parse::ShowPaths; mod ghci_command; @@ -69,6 +72,11 @@ pub use module_set::ModuleSet; mod loaded_module; use loaded_module::LoadedModule; +mod warning_formatter; + +mod warning_tracker; +use warning_tracker::WarningTracker; + use crate::aho_corasick::AhoCorasickExt; use crate::buffers::LINE_BUFFER_CAPACITY; use crate::cli::Opts; @@ -118,6 +126,8 @@ pub struct GhciOpts { pub stderr_writer: GhciWriter, /// Whether to clear the screen before reloads and restarts. pub clear: bool, + /// Whether to track warnings across recompilations. + pub track_warnings: bool, } impl GhciOpts { @@ -166,6 +176,7 @@ impl GhciOpts { stdout_writer, stderr_writer, clear: opts.clear, + track_warnings: opts.track_warnings, }, tui_reader, )) @@ -218,6 +229,8 @@ pub struct Ghci { search_paths: ShowPaths, /// Tasks running `async:` shell commands in the background. command_handles: Vec>>, + /// Warning tracker for managing warnings across recompilations. + warning_tracker: WarningTracker, } impl Debug for Ghci { @@ -339,6 +352,7 @@ impl Ghci { search_paths: Default::default(), }, command_handles, + warning_tracker: WarningTracker::new(), }) } @@ -364,6 +378,13 @@ impl Ghci { // Get the initial list of eval commands. self.refresh_eval_commands().await?; + // For initialization, consider all targets as "changed" + self.warning_tracker.reset_changed_files(); + for target in self.targets.iter() { + self.warning_tracker + .mark_file_changed(target.path().clone()); + } + self.finish_compilation(start_instant, log, events).await?; Ok(()) @@ -460,9 +481,16 @@ impl Ghci { kind_sender: oneshot::Sender, ) -> miette::Result<()> { let start_instant = Instant::now(); - let actions = self.get_reload_actions(events).await?; + let actions = self.get_reload_actions(events.clone()).await?; let _ = kind_sender.send(actions.kind()); + // Track which files were directly changed in this reload + self.warning_tracker.reset_changed_files(); + for event in &events { + let path = self.relative_path(event.as_path())?; + self.warning_tracker.mark_file_changed(path); + } + if actions.needs_restart() { self.opts.clear(); tracing::info!( @@ -801,6 +829,9 @@ impl Ghci { } self.clear_eval_commands_for_paths(paths).await; + if self.opts.track_warnings { + self.warning_tracker.clear_warnings_for_paths(paths); + } Ok(()) } @@ -875,6 +906,11 @@ impl Ghci { log: &mut CompilationLog, events: [LifecycleEvent; N], ) -> miette::Result<()> { + // Update warnings from the compilation log only if tracking is enabled + if self.opts.track_warnings { + self.warning_tracker.update_warnings_from_log(log); + } + // Allow hooks to consume the error log by updating it before running the hooks. self.write_error_log(log).await?; @@ -891,12 +927,36 @@ impl Ghci { compilation_start.elapsed() ); } else { - tracing::info!( - "{} Finished {} in {:.2?}", - "All good!".if_supports_color(Stdout, |text| text.green()), - event.event_noun(), - compilation_start.elapsed() - ); + // Display any tracked warnings even if compilation succeeded + // but exclude files that were compiled in this cycle (to avoid duplicates) + if self.opts.track_warnings { + self.display_tracked_warnings_excluding_compiled(log).await; + } + + let warning_count = if self.opts.track_warnings { + self.warning_tracker.warning_count() + } else { + 0 + }; + + if warning_count > 0 { + tracing::info!( + "{} Finished {} in {:.2?} ({} warning{} tracked)", + "Compilation succeeded".if_supports_color(Stdout, |text| text.yellow()), + event.event_noun(), + compilation_start.elapsed(), + warning_count, + if warning_count == 1 { "" } else { "s" } + ); + } else { + tracing::info!( + "{} Finished {} in {:.2?}", + "All good!".if_supports_color(Stdout, |text| text.green()), + event.event_noun(), + compilation_start.elapsed() + ); + } + // Run the eval commands, if any. self.eval(log).await?; // Run the user-provided test command, if any. @@ -933,9 +993,136 @@ impl Ghci { Ok(()) } + /// Display tracked warnings excluding files that were compiled in the current cycle. + #[instrument(skip_all, level = "trace")] + async fn display_tracked_warnings_excluding_compiled(&self, log: &CompilationLog) { + if !self.opts.track_warnings { + return; + } + + // Create a set of file paths that were compiled in this cycle + // Use relative paths for comparison since GHC reports relative paths + let compiled_files: HashSet<_> = log + .compiled_modules + .iter() + .map(|module| module.path.as_path()) + .collect(); + + for (file_path, file_warnings) in self.warning_tracker.get_all_warnings() { + // Skip warnings for files that were compiled in this cycle + // Compare using relative paths since compilation logs use relative paths + if compiled_files.contains(file_path.relative()) { + continue; + } + + for warning in file_warnings { + warning.display_colored(); + } + } + } + + /// Display all tracked warnings to the user with GHC-matching colors. + #[instrument(skip_all, level = "trace")] + async fn display_tracked_warnings(&self) { + // Single iteration - no need to check has_warnings() first + for file_warnings in self.warning_tracker.get_all_warnings().values() { + for warning in file_warnings { + warning.display_colored(); + } + } + } + #[instrument(skip(self), level = "trace")] async fn write_error_log(&mut self, log: &CompilationLog) -> miette::Result<()> { - self.error_log.write(log).await + if self.opts.track_warnings { + self.write_error_log_with_tracked_warnings(log).await + } else { + self.error_log.write(log).await + } + } + + /// Write error log including tracked warnings from previous compilations. + /// + /// This method combines current compilation diagnostics with tracked warnings, + /// avoiding duplicates and only including warnings (not errors) from tracked diagnostics. + #[instrument(skip(self), level = "trace")] + async fn write_error_log_with_tracked_warnings( + &mut self, + log: &CompilationLog, + ) -> miette::Result<()> { + use crate::ghci::parse::Severity; + use std::collections::HashSet; + + let path = match self.error_log.path() { + Some(path) => path, + None => { + tracing::debug!("No error log path, not writing"); + return Ok(()); + } + }; + + let file = tokio::fs::File::create(path).await.into_diagnostic()?; + let mut writer = tokio::io::BufWriter::new(file); + + // Write compilation summary header if compilation succeeded + if let Some(summary) = log.summary { + if let CompilationResult::Ok = summary.result { + tracing::debug!(%path, "Writing 'All good'"); + let modules_loaded = if summary.modules_loaded != ModulesLoaded::Count(1) { + format!("{} modules", summary.modules_loaded) + } else { + format!("{} module", summary.modules_loaded) + }; + writer + .write_all(format!("All good ({modules_loaded})\n").as_bytes()) + .await + .into_diagnostic()?; + } + } + + // Write current compilation diagnostics + for diagnostic in &log.diagnostics { + tracing::debug!(%diagnostic, "Writing current compilation diagnostic"); + writer + .write_all(diagnostic.to_string().as_bytes()) + .await + .into_diagnostic()?; + } + + // Create a set of diagnostics from current compilation to avoid duplicates + // We'll use a simple string-based deduplication approach + let mut current_diagnostics: HashSet = HashSet::new(); + for diagnostic in &log.diagnostics { + current_diagnostics.insert(diagnostic.to_string()); + } + + // Write tracked warnings (only warnings, not errors) that are not already in current compilation + for file_warnings in self.warning_tracker.get_all_warnings().values() { + for warning in file_warnings { + // Only include warnings, not errors + if warning.severity != Severity::Warning { + continue; + } + + let warning_str = warning.to_string(); + + // Skip if this warning is already in the current compilation log + if current_diagnostics.contains(&warning_str) { + continue; + } + + tracing::debug!(%warning, "Writing tracked warning"); + writer + .write_all(warning_str.as_bytes()) + .await + .into_diagnostic()?; + } + } + + // Flush and shutdown the writer + writer.shutdown().await.into_diagnostic()?; + + Ok(()) } } @@ -987,3 +1174,656 @@ pub enum GhciReloadKind { /// Restart the whole session. Cannot be interrupted. Restart, } + +#[cfg(test)] +mod tests { + use super::*; + use crate::ghci::parse::{ + CompilationSummary, CompilingModule, GhcDiagnostic, ModulesLoaded, PositionRange, Severity, + }; + use camino::Utf8PathBuf; + use std::collections::BTreeMap; + + /// Helper to create a test `GhcDiagnostic` with the given severity and path. + fn make_diagnostic(severity: Severity, path: &str, message: &str) -> GhcDiagnostic { + GhcDiagnostic { + severity, + path: Some(Utf8PathBuf::from(path)), + span: PositionRange::new(1, 1, 1, 1), + message: message.to_string(), + } + } + + /// Helper to create a test `CompilingModule`. + fn make_compiling_module(name: &str, path: &str) -> CompilingModule { + CompilingModule { + name: name.to_string(), + path: Utf8PathBuf::from(path), + } + } + + /// Helper to create a `CompilationLog` with the given modules and diagnostics. + fn make_compilation_log( + modules: Vec, + diagnostics: Vec, + result: CompilationResult, + ) -> CompilationLog { + CompilationLog { + summary: Some(CompilationSummary { + result, + modules_loaded: ModulesLoaded::Count(modules.len()), + }), + diagnostics, + compiled_modules: modules, + } + } + + #[tokio::test] + async fn test_warning_tracking_basic() { + // Test the core warning tracking logic using simplified path handling + let base_dir = std::env::current_dir().unwrap(); + let mut warnings: BTreeMap> = BTreeMap::new(); + + // Simulate the first compilation: file A has warnings, file B is clean + let log1 = make_compilation_log( + vec![ + make_compiling_module("MyLib", "src/MyLib.hs"), + make_compiling_module("MyModule", "src/MyModule.hs"), + ], + vec![ + make_diagnostic(Severity::Warning, "src/MyLib.hs", "Unused import"), + make_diagnostic(Severity::Warning, "src/MyLib.hs", "Unused variable"), + make_diagnostic(Severity::Error, "src/MyModule.hs", "Type error"), + ], + CompilationResult::Err, + ); + + // Extract warnings (simulating update_warnings_from_log logic) + let mut warnings_by_file: BTreeMap> = BTreeMap::new(); + for diagnostic in &log1.diagnostics { + if diagnostic.severity == Severity::Warning { + if let Some(path) = &diagnostic.path { + warnings_by_file + .entry(path.clone()) + .or_default() + .push(diagnostic.clone()); + } + } + } + + // Update warnings for compiled files + for module in &log1.compiled_modules { + let path = NormalPath::new(&module.path, &base_dir).unwrap(); + if let Some(file_warnings) = warnings_by_file.remove(&module.path) { + warnings.insert(path, file_warnings); + } else { + warnings.remove(&path); + } + } + + // After first compilation: MyLib has 2 warnings, MyModule has 0 warnings + assert_eq!(warnings.len(), 1); + let mylib_path = NormalPath::new("src/MyLib.hs", &base_dir).unwrap(); + let mymodule_path = NormalPath::new("src/MyModule.hs", &base_dir).unwrap(); + assert_eq!(warnings.get(&mylib_path).unwrap().len(), 2); + assert_eq!(warnings.get(&mymodule_path), None); + + // Simulate second compilation: MyLib fixed warnings, MyModule still clean, but only MyLib was recompiled + let log2 = make_compilation_log( + vec![make_compiling_module("MyLib", "src/MyLib.hs")], + vec![], // No diagnostics - warnings fixed + CompilationResult::Ok, + ); + + // Update warnings again + let mut warnings_by_file: BTreeMap> = BTreeMap::new(); + for diagnostic in &log2.diagnostics { + if diagnostic.severity == Severity::Warning { + if let Some(path) = &diagnostic.path { + warnings_by_file + .entry(path.clone()) + .or_default() + .push(diagnostic.clone()); + } + } + } + + for module in &log2.compiled_modules { + let path = NormalPath::new(&module.path, &base_dir).unwrap(); + if let Some(file_warnings) = warnings_by_file.remove(&module.path) { + warnings.insert(path, file_warnings); + } else { + warnings.remove(&path); // Clear warnings for MyLib + } + } + + // After second compilation: MyLib warnings cleared (it was recompiled), MyModule warnings unchanged (not recompiled) + assert_eq!(warnings.len(), 0); + assert_eq!(warnings.get(&mylib_path), None); + assert_eq!(warnings.get(&mymodule_path), None); + } + + #[tokio::test] + async fn test_warning_persistence_across_dependency_recompilation() { + // This test simulates the core use case: warnings should persist when a file + // is recompiled due to dependencies but the file itself didn't change + + let base_dir = std::env::current_dir().unwrap(); + let mut warnings: BTreeMap> = BTreeMap::new(); + + // Initial compilation: A has warnings, B is clean + let log1 = make_compilation_log( + vec![ + make_compiling_module("A", "src/A.hs"), + make_compiling_module("B", "src/B.hs"), + ], + vec![make_diagnostic( + Severity::Warning, + "src/A.hs", + "Unused import", + )], + CompilationResult::Ok, + ); + + // Process initial warnings + let mut warnings_by_file: BTreeMap> = BTreeMap::new(); + for diagnostic in &log1.diagnostics { + if diagnostic.severity == Severity::Warning { + if let Some(path) = &diagnostic.path { + warnings_by_file + .entry(path.clone()) + .or_default() + .push(diagnostic.clone()); + } + } + } + + for module in &log1.compiled_modules { + let path = NormalPath::new(&module.path, &base_dir).unwrap(); + if let Some(file_warnings) = warnings_by_file.remove(&module.path) { + warnings.insert(path, file_warnings); + } else { + warnings.remove(&path); + } + } + + // A has warnings, B is clean + assert_eq!(warnings.len(), 1); + let a_path = NormalPath::new("src/A.hs", &base_dir).unwrap(); + let b_path = NormalPath::new("src/B.hs", &base_dir).unwrap(); + assert_eq!(warnings.get(&a_path).unwrap().len(), 1); + + // Second compilation: only B is recompiled (due to dependency change), A is not touched + // This simulates the scenario where A's warnings would disappear in normal GHC output + let log2 = make_compilation_log( + vec![make_compiling_module("B", "src/B.hs")], + vec![], // No new warnings + CompilationResult::Ok, + ); + + // Process second compilation + let mut warnings_by_file: BTreeMap> = BTreeMap::new(); + for diagnostic in &log2.diagnostics { + if diagnostic.severity == Severity::Warning { + if let Some(path) = &diagnostic.path { + warnings_by_file + .entry(path.clone()) + .or_default() + .push(diagnostic.clone()); + } + } + } + + for module in &log2.compiled_modules { + let path = NormalPath::new(&module.path, &base_dir).unwrap(); + if let Some(file_warnings) = warnings_by_file.remove(&module.path) { + warnings.insert(path, file_warnings); + } else { + warnings.remove(&path); + } + } + + // CRITICAL: A's warnings should still be there (not recompiled), B should have no warnings + assert_eq!(warnings.len(), 1); + assert_eq!(warnings.get(&a_path).unwrap().len(), 1); + assert_eq!(warnings.get(&b_path), None); + } + + #[test] + fn test_tracked_warnings_exclude_currently_compiled_files() { + // Test that tracked warnings don't show duplicates for files that were just compiled + + let base_dir = Utf8PathBuf::from("/tmp/test"); + + // Set up initial warnings in memory + let mut warnings: BTreeMap> = BTreeMap::new(); + let file_a_path = NormalPath::new("src/A.hs", &base_dir).unwrap(); + let file_b_path = NormalPath::new("src/B.hs", &base_dir).unwrap(); + + // Both files have warnings tracked + warnings.insert( + file_a_path.clone(), + vec![GhcDiagnostic { + severity: Severity::Warning, + path: Some("src/A.hs".into()), + span: PositionRange::new(1, 1, 1, 1), + message: "Warning in A".to_string(), + }], + ); + warnings.insert( + file_b_path.clone(), + vec![GhcDiagnostic { + severity: Severity::Warning, + path: Some("src/B.hs".into()), + span: PositionRange::new(2, 1, 2, 1), + message: "Warning in B".to_string(), + }], + ); + + // Create a compilation log where only file A was compiled + let compilation_log = CompilationLog { + compiled_modules: vec![CompilingModule { + name: "A".into(), + path: "src/A.hs".into(), + }], + diagnostics: vec![ + // File A shows its warning in fresh compilation output + GhcDiagnostic { + severity: Severity::Warning, + path: Some("src/A.hs".into()), + span: PositionRange::new(1, 1, 1, 1), + message: "Warning in A".to_string(), + }, + ], + summary: Some(CompilationSummary { + result: CompilationResult::Ok, + modules_loaded: ModulesLoaded::Count(1), + }), + }; + + // Simulate filtering: when we display tracked warnings, we should exclude file A + // because it was just compiled and already showed its warnings + let compiled_files: HashSet<_> = compilation_log + .compiled_modules + .iter() + .map(|module| module.path.as_path()) + .collect(); + + // The logic should match what we do in the actual implementation + // Compare using relative paths since compilation logs use relative paths + let should_display_a = !compiled_files.contains(file_a_path.relative()); + let should_display_b = !compiled_files.contains(file_b_path.relative()); + + // File A should NOT be displayed (it was just compiled) + assert!( + !should_display_a, + "File A was just compiled, its warnings should not be displayed again" + ); + + // File B SHOULD be displayed (it was not compiled, so its warnings are still relevant) + assert!( + should_display_b, + "File B was not compiled, its warnings should still be displayed" + ); + } + + #[test] + fn test_error_log_content_generation() { + // Test the logic for combining current diagnostics with tracked warnings + use crate::ghci::parse::Severity; + use std::collections::HashSet; + + // Mock tracked warnings + let mut warnings: BTreeMap> = BTreeMap::new(); + let base_dir = std::env::current_dir().unwrap(); + let file_a_path = NormalPath::new("src/A.hs", &base_dir).unwrap(); + let file_b_path = NormalPath::new("src/B.hs", &base_dir).unwrap(); + + // File A has a tracked warning + warnings.insert( + file_a_path.clone(), + vec![GhcDiagnostic { + severity: Severity::Warning, + path: Some("src/A.hs".into()), + span: PositionRange::new(1, 1, 1, 1), + message: "Unused import".to_string(), + }], + ); + + // File B has a tracked error (should not be included in error log) + warnings.insert( + file_b_path.clone(), + vec![GhcDiagnostic { + severity: Severity::Error, + path: Some("src/B.hs".into()), + span: PositionRange::new(2, 1, 2, 1), + message: "Type error".to_string(), + }], + ); + + // Create a compilation log with current compilation diagnostics + let compilation_log = CompilationLog { + compiled_modules: vec![CompilingModule { + name: "C".into(), + path: "src/C.hs".into(), + }], + diagnostics: vec![GhcDiagnostic { + severity: Severity::Warning, + path: Some("src/C.hs".into()), + span: PositionRange::new(3, 1, 3, 1), + message: "Current warning".to_string(), + }], + summary: Some(CompilationSummary { + result: CompilationResult::Ok, + modules_loaded: ModulesLoaded::Count(1), + }), + }; + + // Test the logic for combining diagnostics + let mut content = String::new(); + + // Add compilation summary + if let Some(summary) = compilation_log.summary { + if let CompilationResult::Ok = summary.result { + let modules_loaded = if summary.modules_loaded != ModulesLoaded::Count(1) { + format!("{} modules", summary.modules_loaded) + } else { + format!("{} module", summary.modules_loaded) + }; + content.push_str(&format!("All good ({modules_loaded})\n")); + } + } + + // Add current compilation diagnostics + for diagnostic in &compilation_log.diagnostics { + content.push_str(&diagnostic.to_string()); + } + + // Create deduplication set + let mut current_diagnostics: HashSet = HashSet::new(); + for diagnostic in &compilation_log.diagnostics { + current_diagnostics.insert(diagnostic.to_string()); + } + + // Add tracked warnings (only warnings, not errors) + for file_warnings in warnings.values() { + for warning in file_warnings { + // Only include warnings, not errors + if warning.severity != Severity::Warning { + continue; + } + + let warning_str = warning.to_string(); + + // Skip if already in current compilation log + if current_diagnostics.contains(&warning_str) { + continue; + } + + content.push_str(&warning_str); + } + } + + // Verify the content + assert!(content.contains("All good (1 module)")); + assert!(content.contains("src/C.hs:3:1: warning: Current warning")); + assert!(content.contains("src/A.hs:1:1: warning: Unused import")); + assert!(!content.contains("src/B.hs")); // Error should not be included + assert!(!content.contains("Type error")); // Error should not be included + + // Verify that errors are filtered out + let warning_count = warnings + .values() + .flatten() + .filter(|diag| diag.severity == Severity::Warning) + .count(); + assert_eq!(warning_count, 1); // Only the warning from A.hs should be counted + } + + #[tokio::test] + async fn test_write_error_log_with_tracked_warnings() { + // Test the write_error_log_with_tracked_warnings method by simulating its core logic + use crate::ghci::parse::Severity; + use std::collections::HashSet; + use std::fs; + use tokio::io::AsyncWriteExt; + + // Create a temporary file for the error log + let temp_dir = std::env::temp_dir(); + let temp_file = temp_dir.join("test_error_log.txt"); + let error_log_path = camino::Utf8PathBuf::from_path_buf(temp_file).unwrap(); + + // Create mock tracked warnings + let mut warnings: BTreeMap> = BTreeMap::new(); + let base_dir = std::env::current_dir().unwrap(); + let file_a_path = NormalPath::new("src/A.hs", &base_dir).unwrap(); + let file_b_path = NormalPath::new("src/B.hs", &base_dir).unwrap(); + + // Add tracked warnings + warnings.insert( + file_a_path.clone(), + vec![GhcDiagnostic { + severity: Severity::Warning, + path: Some("src/A.hs".into()), + span: PositionRange::new(10, 5, 10, 15), + message: "Unused import warning".to_string(), + }], + ); + + warnings.insert( + file_b_path.clone(), + vec![GhcDiagnostic { + severity: Severity::Warning, + path: Some("src/B.hs".into()), + span: PositionRange::new(20, 1, 20, 10), + message: "Unused variable warning".to_string(), + }], + ); + + // Create a CompilationLog with some current diagnostics + let compilation_log = CompilationLog { + summary: Some(CompilationSummary { + result: CompilationResult::Ok, + modules_loaded: ModulesLoaded::Count(2), + }), + diagnostics: vec![ + GhcDiagnostic { + severity: Severity::Warning, + path: Some("src/C.hs".into()), + span: PositionRange::new(30, 1, 30, 5), + message: "Current compilation warning".to_string(), + }, + GhcDiagnostic { + severity: Severity::Error, + path: Some("src/C.hs".into()), + span: PositionRange::new(31, 1, 31, 5), + message: "Current compilation error".to_string(), + }, + ], + compiled_modules: vec![CompilingModule { + name: "C".to_string(), + path: "src/C.hs".into(), + }], + }; + + // Simulate the write_error_log_with_tracked_warnings method logic + let file = tokio::fs::File::create(&error_log_path).await.unwrap(); + let mut writer = tokio::io::BufWriter::new(file); + + // Write compilation summary header if compilation succeeded + if let Some(summary) = compilation_log.summary { + if let CompilationResult::Ok = summary.result { + let modules_loaded = if summary.modules_loaded != ModulesLoaded::Count(1) { + format!("{} modules", summary.modules_loaded) + } else { + format!("{} module", summary.modules_loaded) + }; + writer + .write_all(format!("All good ({modules_loaded})\n").as_bytes()) + .await + .unwrap(); + } + } + + // Write current compilation diagnostics + for diagnostic in &compilation_log.diagnostics { + writer + .write_all(diagnostic.to_string().as_bytes()) + .await + .unwrap(); + } + + // Create a set of diagnostics from current compilation to avoid duplicates + let mut current_diagnostics: HashSet = HashSet::new(); + for diagnostic in &compilation_log.diagnostics { + current_diagnostics.insert(diagnostic.to_string()); + } + + // Write tracked warnings (only warnings, not errors) that are not already in current compilation + for file_warnings in warnings.values() { + for warning in file_warnings { + // Only include warnings, not errors + if warning.severity != Severity::Warning { + continue; + } + + let warning_str = warning.to_string(); + + // Skip if this warning is already in the current compilation log + if current_diagnostics.contains(&warning_str) { + continue; + } + + writer.write_all(warning_str.as_bytes()).await.unwrap(); + } + } + + // Flush and shutdown the writer + writer.shutdown().await.unwrap(); + + // Read the content from the error log file + let content = fs::read_to_string(&error_log_path).unwrap(); + + // Verify the output contains expected elements + // 1. Compilation summary header + assert!( + content.contains("All good (2 modules)"), + "Should contain compilation summary" + ); + + // 2. Current compilation diagnostics (both warning and error) + assert!( + content.contains("src/C.hs:30:1-5: warning: Current compilation warning"), + "Should contain current warning" + ); + assert!( + content.contains("src/C.hs:31:1-5: error: Current compilation error"), + "Should contain current error" + ); + + // 3. Tracked warnings (only warnings, not errors) + assert!( + content.contains("src/A.hs:10:5-15: warning: Unused import warning"), + "Should contain tracked warning from A.hs" + ); + assert!( + content.contains("src/B.hs:20:1-10: warning: Unused variable warning"), + "Should contain tracked warning from B.hs" + ); + + // Test deduplication: create a new compilation log with a duplicate warning + let compilation_log_with_duplicate = CompilationLog { + summary: Some(CompilationSummary { + result: CompilationResult::Ok, + modules_loaded: ModulesLoaded::Count(1), + }), + diagnostics: vec![ + // Same warning as in tracked warnings + GhcDiagnostic { + severity: Severity::Warning, + path: Some("src/A.hs".into()), + span: PositionRange::new(10, 5, 10, 15), + message: "Unused import warning".to_string(), + }, + ], + compiled_modules: vec![CompilingModule { + name: "A".to_string(), + path: "src/A.hs".into(), + }], + }; + + // Clear the file and test again + let file = tokio::fs::File::create(&error_log_path).await.unwrap(); + let mut writer = tokio::io::BufWriter::new(file); + + // Write compilation summary header + if let Some(summary) = compilation_log_with_duplicate.summary { + if let CompilationResult::Ok = summary.result { + let modules_loaded = if summary.modules_loaded != ModulesLoaded::Count(1) { + format!("{} modules", summary.modules_loaded) + } else { + format!("{} module", summary.modules_loaded) + }; + writer + .write_all(format!("All good ({modules_loaded})\n").as_bytes()) + .await + .unwrap(); + } + } + + // Write current compilation diagnostics + for diagnostic in &compilation_log_with_duplicate.diagnostics { + writer + .write_all(diagnostic.to_string().as_bytes()) + .await + .unwrap(); + } + + // Create deduplication set + let mut current_diagnostics: HashSet = HashSet::new(); + for diagnostic in &compilation_log_with_duplicate.diagnostics { + current_diagnostics.insert(diagnostic.to_string()); + } + + // Write tracked warnings (only warnings, not errors) that are not already in current compilation + for file_warnings in warnings.values() { + for warning in file_warnings { + // Only include warnings, not errors + if warning.severity != Severity::Warning { + continue; + } + + let warning_str = warning.to_string(); + + // Skip if this warning is already in the current compilation log + if current_diagnostics.contains(&warning_str) { + continue; + } + + writer.write_all(warning_str.as_bytes()).await.unwrap(); + } + } + + writer.shutdown().await.unwrap(); + + let content_with_duplicate = fs::read_to_string(&error_log_path).unwrap(); + + // Count occurrences of the warning - should appear only once + let warning_count = content_with_duplicate + .matches("src/A.hs:10:5-15: warning: Unused import warning") + .count(); + assert_eq!( + warning_count, 1, + "Duplicate warning should only appear once" + ); + + // But the warning from B.hs should still be there since it's not a duplicate + assert!( + content_with_duplicate.contains("src/B.hs:20:1-10: warning: Unused variable warning"), + "Non-duplicate tracked warning should still appear" + ); + + // Clean up + let _ = fs::remove_file(&error_log_path); + } +} diff --git a/src/ghci/parse/ghc_message/mod.rs b/src/ghci/parse/ghc_message/mod.rs index 59733df9..5b5dac56 100644 --- a/src/ghci/parse/ghc_message/mod.rs +++ b/src/ghci/parse/ghc_message/mod.rs @@ -4,6 +4,8 @@ use std::fmt::Display; use camino::Utf8PathBuf; use miette::miette; +use owo_colors::OwoColorize; +use owo_colors::Stream::Stdout; use winnow::combinator::alt; use winnow::combinator::repeat; use winnow::prelude::*; @@ -148,6 +150,66 @@ impl Display for GhcDiagnostic { } } +impl GhcDiagnostic { + /// Display this diagnostic with GHC-style coloring. + pub fn display_colored(&self) { + // Format each part of the warning to match GHC's selective coloring + let mut parts = Vec::new(); + + // File path (bold, like GHC) + match &self.path { + Some(path) => parts.push(format!( + "{}", + path.if_supports_color(Stdout, |text| text.bold()) + )), + None => parts.push(format!( + "{}", + "".if_supports_color(Stdout, |text| text.bold()) + )), + } + + // Position range (bold, like GHC) + if !self.span.is_zero() { + parts.push(format!( + ":{}", + self.span.if_supports_color(Stdout, |text| text.bold()) + )); + } + + // Severity with color (this is what GHC colors) + let severity_colored = match self.severity { + Severity::Warning => { + format!( + ": {}:", + "warning".if_supports_color(Stdout, |text| text.magenta()) + ) + } + Severity::Error => { + format!( + ": {}:", + "error".if_supports_color(Stdout, |text| text.red()) + ) + } + }; + parts.push(severity_colored); + + // Message content with GHC-style pattern coloring + let colored_message = if self.message.starts_with('\n') { + super::super::warning_formatter::colorize_message(&self.message, self.severity) + } else { + format!( + " {}", + super::super::warning_formatter::colorize_message(&self.message, self.severity) + ) + }; + parts.push(colored_message); + + // Join all parts and display + let formatted = parts.join(""); + tracing::info!("{}", formatted); + } +} + /// Parse [`GhcMessage`]s from lines of compiler output. pub fn parse_ghc_messages(lines: &str) -> miette::Result> { // TODO: Preserve ANSI colors somehow. diff --git a/src/ghci/parse/mod.rs b/src/ghci/parse/mod.rs index 69529510..d2017782 100644 --- a/src/ghci/parse/mod.rs +++ b/src/ghci/parse/mod.rs @@ -20,6 +20,8 @@ pub use ghc_message::CompilationSummary; pub use ghc_message::GhcDiagnostic; pub use ghc_message::GhcMessage; pub use ghc_message::ModulesLoaded; +#[cfg(test)] +pub use ghc_message::PositionRange; pub use ghc_message::Severity; pub use module_and_files::CompilingModule; pub use show_paths::parse_show_paths; diff --git a/src/ghci/warning_formatter.rs b/src/ghci/warning_formatter.rs new file mode 100644 index 00000000..f229acfd --- /dev/null +++ b/src/ghci/warning_formatter.rs @@ -0,0 +1,208 @@ +//! Warning message formatting with GHC-style coloring. +//! +//! This module provides functionality to colorize GHC diagnostic messages +//! to match the output format and colors used by GHC itself. + +use owo_colors::OwoColorize; +use owo_colors::Stream::Stdout; + +use crate::ghci::parse::Severity; + +/// Apply GHC-style coloring to a complete diagnostic message. +/// +/// This processes multi-line messages and applies appropriate coloring +/// to each line based on GHC's output patterns. +pub fn colorize_message(message: &str, severity: Severity) -> String { + message + .lines() + .map(|line| colorize_line(line, severity)) + .collect::>() + .join("\n") +} + +/// Apply colors to a single line of a diagnostic message based on GHC patterns. +/// +/// This function recognizes different types of lines in GHC output and applies +/// appropriate coloring: +/// - Source code lines with line numbers (e.g., " 28 | import Data.Coerce (coerce)") +/// - Caret indicator lines (e.g., " | ^^^^^^^^^^^^^^^^^^^^^^^^^^^") +/// - Warning/error flags in brackets (e.g., "[-Wunused-imports]") +/// +/// TODO: Connect it to the ANSI colors +/// that we need to preserve in +fn colorize_line(line: &str, severity: Severity) -> String { + // Detect different types of lines and apply appropriate coloring + + // Source code lines with line numbers (e.g., " 28 | import Data.Coerce (coerce)") + // TODO: This is a pretty hacky way to find the lines to color. In the future, if we have all of this structured from the parser, we can store the original colors alongside the warning text and re-emit it directly. + if let Some(pipe_pos) = line.find(" | ") { + let before_pipe = &line[..pipe_pos]; + if before_pipe.trim().chars().all(|c| c.is_ascii_digit()) { + // This looks like a line number followed by pipe + let line_num_part = &line[..pipe_pos]; + let pipe_and_after = &line[pipe_pos..]; + + return format!( + "{}{}", + line_num_part.if_supports_color(Stdout, |text| text.magenta()), + pipe_and_after.if_supports_color(Stdout, |text| text.magenta()) + ); + } + } + + // Caret lines (e.g., " | ^^^^^^^^^^^^^^^^^^^^^^^^^^^") + if line.trim_start().starts_with('|') && line.contains('^') { + return format!("{}", line.if_supports_color(Stdout, |text| text.magenta())); + } + + // Color warning/error flags in brackets + let mut result = line.to_string(); + + // Look for [-Wxxxx] patterns + if line.contains("[-W") { + if let Some(start) = line.find("[-W") { + if let Some(end) = line[start..].find(']') { + let flag = &line[start..start + end + 1]; + let colored_flag = match severity { + Severity::Warning => { + format!("{}", flag.if_supports_color(Stdout, |text| text.magenta())) + } + Severity::Error => { + format!("{}", flag.if_supports_color(Stdout, |text| text.red())) + } + }; + result = result.replace(flag, &colored_flag); + } + } + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_colorize_line_number_with_pipe() { + let line = " 28 | import Data.Coerce (coerce)"; + let result = colorize_line(line, Severity::Warning); + + // The result should contain the original content + assert!(result.contains("28")); + assert!(result.contains("import Data.Coerce (coerce)")); + + // Test that the logic correctly identifies line number patterns + assert!(line.contains(" | ")); + let before_pipe = &line[..line.find(" | ").unwrap()]; + assert!(before_pipe.trim().chars().all(|c| c.is_ascii_digit())); + } + + #[test] + fn test_colorize_caret_line() { + let line = " | ^^^^^^^^^^^^^^^^^^^^^^^^^^^"; + let result = colorize_line(line, Severity::Warning); + + // Should contain the original caret characters + assert!(result.contains("^")); + assert!(result.contains("|")); + + // Test that the logic correctly identifies caret patterns + assert!(line.trim_start().starts_with('|')); + assert!(line.contains('^')); + } + + #[test] + fn test_colorize_warning_flag() { + let line = " [-Wunused-imports]"; + let result = colorize_line(line, Severity::Warning); + + // Should contain the original flag + assert!(result.contains("[-Wunused-imports]")); + + // Test that the logic correctly identifies warning flags + assert!(line.contains("[-W")); + } + + #[test] + fn test_colorize_error_flag() { + let line = " [-Werror]"; + let result = colorize_line(line, Severity::Error); + + // Should contain the original flag + assert!(result.contains("[-Werror]")); + + // Test that the logic correctly identifies warning flags + assert!(line.contains("[-W")); + } + + #[test] + fn test_colorize_plain_line() { + let line = "This is a plain line with no special formatting"; + let result = colorize_line(line, Severity::Warning); + + // Plain lines should remain unchanged + assert_eq!(result, line); + } + + #[test] + fn test_colorize_multiline_message() { + let message = "Top-level binding with no type signature:\n foo :: [Char] -> [Char]\n 28 | foo x = x\n | ^^^^^^^^^"; + let result = colorize_message(message, Severity::Warning); + + // Should preserve the structure and content + assert!(result.contains("Top-level binding")); + assert!(result.contains("foo :: [Char] -> [Char]")); + assert!(result.contains("28")); + assert!(result.contains("^")); + + // Should preserve line structure + assert_eq!(result.lines().count(), message.lines().count()); + } + + #[test] + fn test_line_number_detection() { + // Valid line number patterns - test the logic directly + let line1 = " 1 | module Main"; + let result1 = colorize_line(line1, Severity::Warning); + assert!(result1.contains("1")); + assert!(result1.contains("module Main")); + + let line2 = "123 | import Data.List"; + let result2 = colorize_line(line2, Severity::Warning); + assert!(result2.contains("123")); + assert!(result2.contains("import Data.List")); + + // Invalid patterns (should not match line number logic) + let line3 = "abc | not a line number"; + let result3 = colorize_line(line3, Severity::Warning); + assert_eq!(result3, line3); + + let line4 = " | no line number"; + let result4 = colorize_line(line4, Severity::Warning); + assert_eq!(result4, line4); + } + + #[test] + fn test_caret_line_detection() { + // Valid caret patterns - test the logic directly + let line1 = " | ^^^"; + let result1 = colorize_line(line1, Severity::Warning); + assert!(result1.contains("^")); + assert!(result1.contains("|")); + + let line2 = " | ^^^^^^^^^^^^^^^"; + let result2 = colorize_line(line2, Severity::Warning); + assert!(result2.contains("^")); + assert!(result2.contains("|")); + + // Invalid patterns should remain unchanged + let line3 = " | no carets here"; + let result3 = colorize_line(line3, Severity::Warning); + assert_eq!(result3, line3); + + let line4 = "^^^ no pipe"; + let result4 = colorize_line(line4, Severity::Warning); + assert_eq!(result4, line4); + } +} diff --git a/src/ghci/warning_tracker.rs b/src/ghci/warning_tracker.rs new file mode 100644 index 00000000..aa139893 --- /dev/null +++ b/src/ghci/warning_tracker.rs @@ -0,0 +1,325 @@ +//! Warning tracking and management for GHC diagnostics. +//! +//! This module provides functionality to track warnings across recompilations, +//! managing the lifecycle of warnings as files are modified, added, or removed. + +use std::borrow::Borrow; +use std::collections::{BTreeMap, BTreeSet}; + +use crate::ghci::parse::GhcDiagnostic; +use crate::ghci::CompilationLog; +use crate::normal_path::NormalPath; + +/// Tracks warnings across recompilations, managing their lifecycle. +/// +/// This tracker maintains an in-memory store of warnings per file and provides +/// smart logic for updating warnings based on compilation results and file changes. +pub struct WarningTracker { + /// Per-file warnings from GHC compilation, persisted across reloads. + warnings: BTreeMap>, + /// Files that were directly changed in the current reload operation. + /// Used to distinguish between direct changes and dependency-driven recompilations. + current_changed_files: BTreeSet, +} + +impl WarningTracker { + /// Create a new warning tracker. + pub fn new() -> Self { + Self { + warnings: BTreeMap::new(), + current_changed_files: BTreeSet::new(), + } + } + + /// Reset the list of files that were directly changed. + /// This should be called at the start of each reload operation. + pub fn reset_changed_files(&mut self) { + self.current_changed_files.clear(); + } + + /// Mark a file as having been directly changed. + /// This affects how warnings are managed for this file. + pub fn mark_file_changed(&mut self, path: NormalPath) { + self.current_changed_files.insert(path); + } + + /// Update warnings from a compilation log. + /// + /// This method implements smart warning persistence logic: + /// - Files that were directly changed: always update warnings (or clear if none) + /// - Files that were recompiled due to dependencies: only update if warnings exist + /// - Files that weren't recompiled: keep existing warnings + pub fn update_warnings_from_log(&mut self, log: &CompilationLog) { + // Extract new warnings by file from the compilation log + let mut new_warnings_by_file: BTreeMap> = BTreeMap::new(); + + for diagnostic in &log.diagnostics { + if let Some(path) = &diagnostic.path { + // Convert to NormalPath - in a real implementation, this would need proper error handling + if let Ok(normal_path) = NormalPath::new(path, std::env::current_dir().unwrap()) { + new_warnings_by_file + .entry(normal_path) + .or_default() + .push(diagnostic.clone()); + } + } + } + + // Process compiled files from the log + for compiled_module in &log.compiled_modules { + // Convert module path to NormalPath + if let Ok(compiled_file) = + NormalPath::new(&compiled_module.path, std::env::current_dir().unwrap()) + { + if let Some(file_warnings) = new_warnings_by_file.get(&compiled_file) { + // File was compiled and has warnings - always update them + self.warnings + .insert(compiled_file.clone(), file_warnings.clone()); + } else if self.current_changed_files.contains(&compiled_file) { + // File was directly changed and compiled but has no warnings - clear existing warnings + self.warnings.remove(&compiled_file); + } + // If file was compiled due to dependencies and has no warnings, keep existing warnings + } + } + + tracing::debug!( + total_files_with_warnings = self.warnings.len(), + total_warnings = self.warnings.values().map(|w| w.len()).sum::(), + "Updated warnings from compilation log" + ); + } + + /// Clear warnings for the specified paths. + /// This is called when files are removed or when we know they should no longer have warnings. + pub fn clear_warnings_for_paths

(&mut self, paths: impl IntoIterator) + where + P: Borrow, + { + for path in paths { + self.warnings.remove(path.borrow()); + } + + tracing::debug!( + files_with_warnings = self.warnings.len(), + total_warnings = self.warnings.values().map(|w| w.len()).sum::(), + "Cleared warnings for paths" + ); + } + + /// Get all warnings as a map from file path to warnings. + pub fn get_all_warnings(&self) -> &BTreeMap> { + &self.warnings + } + + /// Get the total number of warnings across all files. + pub fn warning_count(&self) -> usize { + self.warnings.values().map(|w| w.len()).sum() + } + + /// Check if there are any warnings tracked. + pub fn has_warnings(&self) -> bool { + !self.warnings.is_empty() + } +} + +impl Default for WarningTracker { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ghci::parse::CompilingModule; + use crate::ghci::parse::PositionRange; + use crate::ghci::parse::{GhcDiagnostic, Severity}; + + fn create_test_diagnostic(severity: Severity, path: &str, message: &str) -> GhcDiagnostic { + GhcDiagnostic { + severity, + path: Some(path.into()), + span: PositionRange::new(1, 1, 1, 1), + message: message.to_string(), + } + } + + fn create_test_compilation_log( + diagnostics: Vec, + compiled_modules: Vec, + ) -> CompilationLog { + CompilationLog { + diagnostics, + compiled_modules, + summary: None, + } + } + + #[test] + fn test_new_tracker_is_empty() { + let tracker = WarningTracker::new(); + assert!(!tracker.has_warnings()); + assert_eq!(tracker.warning_count(), 0); + assert_eq!(tracker.warnings.len(), 0); + } + + #[test] + fn test_mark_file_changed() { + let mut tracker = WarningTracker::new(); + let base_dir = std::env::current_dir().unwrap(); + let path = NormalPath::new("src/test.hs", &base_dir).unwrap(); + + tracker.mark_file_changed(path.clone()); + assert!(tracker.current_changed_files.contains(&path)); + + tracker.reset_changed_files(); + assert!(!tracker.current_changed_files.contains(&path)); + } + + #[test] + fn test_clear_warnings_for_paths() { + let mut tracker = WarningTracker::new(); + let base_dir = std::env::current_dir().unwrap(); + let path1 = NormalPath::new("src/test1.hs", &base_dir).unwrap(); + let path2 = NormalPath::new("src/test2.hs", &base_dir).unwrap(); + + let warnings = vec![create_test_diagnostic( + Severity::Warning, + "src/test1.hs", + "unused import", + )]; + + tracker.warnings.insert(path1.clone(), warnings.clone()); + tracker.warnings.insert(path2.clone(), warnings); + assert_eq!(tracker.warnings.len(), 2); + + tracker.clear_warnings_for_paths([&path1]); + assert_eq!(tracker.warnings.len(), 1); + assert!(!tracker.warnings.contains_key(&path1)); + assert!(tracker.warnings.contains_key(&path2)); + } + + #[test] + fn test_update_warnings_from_log_direct_change() { + let mut tracker = WarningTracker::new(); + let base_dir = std::env::current_dir().unwrap(); + let path = NormalPath::new("src/test.hs", &base_dir).unwrap(); + + // Add initial warnings + let old_warning = create_test_diagnostic(Severity::Warning, "src/test.hs", "old warning"); + tracker.warnings.insert(path.clone(), vec![old_warning]); + + // Mark file as changed + tracker.mark_file_changed(path.clone()); + + // Create compilation log with no warnings for the changed file + let log = create_test_compilation_log( + vec![], // No warnings in this compilation + vec![CompilingModule { + name: "Test".to_string(), + path: "src/test.hs".into(), + }], + ); + + tracker.update_warnings_from_log(&log); + + // Warnings should be cleared since file was directly changed and has no new warnings + assert!(!tracker.has_warnings()); + } + + #[test] + fn test_update_warnings_from_log_dependency_change() { + let mut tracker = WarningTracker::new(); + let base_dir = std::env::current_dir().unwrap(); + let path = NormalPath::new("src/test.hs", &base_dir).unwrap(); + + // Add initial warnings + let old_warning = create_test_diagnostic(Severity::Warning, "src/test.hs", "old warning"); + tracker + .warnings + .insert(path.clone(), vec![old_warning.clone()]); + + // Don't mark file as changed (dependency-driven recompilation) + + // Create compilation log with no warnings for the file + let log = create_test_compilation_log( + vec![], // No warnings in this compilation + vec![CompilingModule { + name: "Test".to_string(), + path: "src/test.hs".into(), + }], + ); + + tracker.update_warnings_from_log(&log); + + // Warnings should be kept since file was recompiled due to dependencies + assert!(tracker.has_warnings()); + assert_eq!(tracker.warning_count(), 1); + } + + #[test] + fn test_edge_case_empty_file_path() { + let mut tracker = WarningTracker::new(); + + let diagnostic_no_path = GhcDiagnostic { + severity: Severity::Warning, + path: None, + span: PositionRange::new(1, 1, 1, 1), + message: "warning without path".to_string(), + }; + + let log = create_test_compilation_log(vec![diagnostic_no_path], vec![]); + + tracker.update_warnings_from_log(&log); + + // Should handle diagnostics without paths gracefully + assert!(!tracker.has_warnings()); + } + + #[test] + fn test_edge_case_large_number_of_warnings() { + let mut tracker = WarningTracker::new(); + let base_dir = std::env::current_dir().unwrap(); + + // Create 1000 warnings across 100 files + for i in 0..100 { + let path = NormalPath::new(format!("src/test{}.hs", i), &base_dir).unwrap(); + let mut warnings = Vec::new(); + for j in 0..10 { + warnings.push(create_test_diagnostic( + Severity::Warning, + &format!("src/test{}.hs", i), + &format!("warning {} in file {}", j, i), + )); + } + tracker.warnings.insert(path, warnings); + } + + assert_eq!(tracker.warning_count(), 1000); + assert_eq!(tracker.warnings.len(), 100); + } + + #[test] + fn test_edge_case_special_characters_in_paths() { + let mut tracker = WarningTracker::new(); + let base_dir = std::env::current_dir().unwrap(); + + // Test with various special characters that might appear in file paths + let special_paths = vec![ + "src/test with spaces.hs", + "src/test-with-dashes.hs", + "src/test_with_underscores.hs", + "src/test.with.dots.hs", + ]; + + for path_str in special_paths { + if let Ok(path) = NormalPath::new(path_str, &base_dir) { + let warning = create_test_diagnostic(Severity::Warning, path_str, "test warning"); + tracker.warnings.insert(path, vec![warning]); + } + } + + assert!(!tracker.warnings.is_empty()); + } +}