diff --git a/Cargo.lock b/Cargo.lock index 03c43148e..b9918006d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1608,6 +1608,7 @@ dependencies = [ "once_cell", "parking_lot", "rustix 1.0.3", + "quick-xml", "serde", "serde_json", "slotmap", @@ -1617,6 +1618,7 @@ dependencies = [ "tokio-stream", "toml", "url", + "walkdir", ] [[package]] @@ -2185,6 +2187,16 @@ dependencies = [ "unicase", ] +[[package]] +name = "quick-xml" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quickcheck" version = "1.0.3" diff --git a/helix-view/Cargo.toml b/helix-view/Cargo.toml index bcee1a0a7..72324ccad 100644 --- a/helix-view/Cargo.toml +++ b/helix-view/Cargo.toml @@ -51,6 +51,8 @@ log = "~0.4" parking_lot.workspace = true thiserror.workspace = true +quick-xml = { version = "0.31.0", features = ["serialize"] } +walkdir = "2.5.0" [target.'cfg(windows)'.dependencies] clipboard-win = { version = "5.4", features = ["std"] } diff --git a/helix-view/src/coverage.rs b/helix-view/src/coverage.rs new file mode 100644 index 000000000..1ba8fea2b --- /dev/null +++ b/helix-view/src/coverage.rs @@ -0,0 +1,277 @@ +use quick_xml::de::from_reader; +use serde::Deserialize; +use std::collections::HashMap; +use std::fs::File; +use std::io::BufReader; +use std::time::SystemTime; +use walkdir; + +#[derive(Debug)] +pub struct Coverage { + pub files: HashMap, +} + +#[derive(Debug)] +pub struct FileCoverage { + pub lines: HashMap, + pub modified_time: Option, +} + +#[derive(Deserialize, Debug)] +struct RawCoverage { + #[serde(rename = "@version")] + version: String, + sources: Sources, + packages: Packages, + modified_time: Option, +} + +#[derive(Deserialize, Debug)] +struct Sources { + source: Vec, +} + +#[derive(Deserialize, Debug)] +struct Source { + #[serde(rename = "$value")] + name: String, +} + +#[derive(Deserialize, Debug)] +struct Packages { + package: Vec, +} + +#[derive(Deserialize, Debug)] +struct Package { + #[serde(rename = "@name")] + name: String, + classes: Classes, +} + +#[derive(Deserialize, Debug)] +struct Classes { + class: Vec, +} + +#[derive(Deserialize, Debug)] +struct Class { + #[serde(rename = "@name")] + name: String, + #[serde(rename = "@filename")] + filename: String, + lines: Lines, +} + +#[derive(Deserialize, Debug)] +struct Lines { + line: Option>, +} + +#[derive(Deserialize, Debug)] +struct Line { + #[serde(rename = "@number")] + number: u32, + #[serde(rename = "@hits")] + hits: u32, +} + +/// Get coverage information for a document from the configured coverage file. +/// +/// The coverage file is set by environment variable HELIX_COVERAGE_FILE. This +/// function will return None if the coverage file is not found, invalid, does +/// not contain the document, or if it is out of date compared to the document. +pub fn get_coverage(document_path: &std::path::PathBuf) -> Option { + let coverage_path = find_coverage_file()?; + log::debug!("coverage file is {:?}", coverage_path); + let coverage = read_cobertura_coverage(&coverage_path)?; + log::debug!("coverage is valid"); + + log::debug!("document path: {:?}", document_path); + + let file_coverage = coverage.files.get(document_path).or_else(|| { + log::warn!("file: {:?} not found in coverage", document_path); + None + })?; + + let coverage_time = file_coverage.modified_time?; + let document_metadata = document_path.metadata().ok()?; + let document_time = document_metadata.modified().ok()?; + + if document_time < coverage_time { + log::debug!("file coverage contains {} lines", file_coverage.lines.len()); + return Some(FileCoverage { + lines: file_coverage.lines.clone(), + modified_time: file_coverage.modified_time, + }); + } else { + log::debug!("document is newer than coverage file, will not return coverage"); + return None; + } +} + +fn find_coverage_file() -> Option { + if let Some(coverage_path) = std::env::var("HELIX_COVERAGE_FILE").ok() { + return Some(std::path::PathBuf::from(coverage_path)); + } + for entry in walkdir::WalkDir::new(".") + .max_depth(1) + .into_iter() + .filter_map(|e| e.ok()) + { + if entry.file_name() == "coverage.xml" || entry.file_name() == "cobertura.xml" { + return Some(entry.path().to_path_buf()); + } + } + return None; +} + +fn read_cobertura_coverage(path: &std::path::PathBuf) -> Option { + let file = File::open(path) + .inspect_err(|e| log::info!("error opening {:?}: {:?}", path, e)) + .ok()?; + let metadata = file + .metadata() + .inspect_err(|e| log::info!("error reading metadata for {:?}: {:?}", path, e)) + .ok()?; + let modified = metadata + .modified() + .inspect_err(|e| log::info!("error reading timestamp for {:?}: {:?}", path, e)) + .ok()?; + let reader = BufReader::new(file); + let mut tmp: RawCoverage = from_reader(reader) + .inspect_err(|e| log::info!("error parsing coverage for {:?}: {:?}", path, e)) + .ok()?; + tmp.modified_time = Some(modified); + Some(tmp.into()) +} + +impl From for Coverage { + fn from(coverage: RawCoverage) -> Self { + let mut files = HashMap::new(); + for package in coverage.packages.package { + for class in package.classes.class { + let mut lines = HashMap::new(); + if let Some(class_lines) = class.lines.line { + for line in class_lines { + lines.insert(line.number - 1, line.hits > 0); + } + } + for source in &coverage.sources.source { + // it is ambiguous to which source a coverage class might belong + // so check each in the path + let raw_path: std::path::PathBuf = + [&source.name, &class.filename].iter().collect(); + if let Ok(path) = std::fs::canonicalize(raw_path.clone()) { + log::debug!("add file {:?} to coverage", path); + files.insert( + path, + FileCoverage { + lines, + modified_time: coverage.modified_time, + }, + ); + break; + } + log::warn!("could not add file {:?} to coverage", raw_path); + } + } + } + Coverage { files } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use quick_xml::de::from_str; + use std::path::PathBuf; + + fn test_string(use_relative_paths: bool) -> String { + let source_path = if use_relative_paths { + PathBuf::from("src") + } else { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src") + }; + return format!( + r#" + + + {} + + + + + + + + + + + + + + + + + + + +"#, + source_path.to_string_lossy() + ); + } + + #[test] + fn test_deserialize_raw_coverage_from_string() { + let result: RawCoverage = from_str(&test_string(true)).unwrap(); + println!("result is {:?}", result); + assert_eq!(result.version, "7.3.0"); + assert_eq!(result.sources.source[0].name, "src"); + assert_eq!(result.packages.package[0].name, "a package"); + let first = &result.packages.package[0].classes.class[0]; + assert_eq!(first.name, "a class"); + assert_eq!(first.filename, "coverage.rs"); + assert_eq!(first.lines.line[0].number, 3); + assert_eq!(first.lines.line[0].hits, 1); + assert_eq!(first.lines.line[1].number, 5); + assert_eq!(first.lines.line[1].hits, 0); + let second = &result.packages.package[0].classes.class[1]; + assert_eq!(second.name, "another class"); + assert_eq!(second.filename, "other.ext"); + assert_eq!(second.lines.line[0].number, 1); + assert_eq!(second.lines.line[0].hits, 0); + assert_eq!(second.lines.line[1].number, 7); + assert_eq!(second.lines.line[1].hits, 1); + } + + #[test] + fn test_convert_raw_coverage_to_coverage_with_relative_path() { + let tmp: RawCoverage = from_str(&test_string(true)).unwrap(); + check_coverage(tmp.into()); + } + #[test] + fn test_convert_raw_coverage_to_coverage_with_absolute_path() { + let tmp: RawCoverage = from_str(&test_string(false)).unwrap(); + check_coverage(tmp.into()); + } + + fn check_coverage(result: Coverage) { + println!("result is {:?}", result); + // only one file should be included, since src/other.ext does not exist + assert_eq!(result.files.len(), 1); + // coverage will always canonicalize path + let first = result + .files + .get( + &PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("src") + .join("coverage.rs"), + ) + .unwrap(); + println!("cov {:?}", first); + assert_eq!(first.lines.len(), 2); + assert_eq!(first.lines.get(&2), Some(&true)); + assert_eq!(first.lines.get(&4), Some(&false)); + } +} diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs index 92c1e3ca0..54dc12704 100644 --- a/helix-view/src/document.rs +++ b/helix-view/src/document.rs @@ -152,7 +152,7 @@ pub struct Document { /// update from the LSP pub inlay_hints_oudated: bool, - path: Option, + pub path: Option, relative_path: OnceCell>, encoding: &'static encoding::Encoding, has_bom: bool, diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index be2218997..52e6ede09 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -420,7 +420,7 @@ pub fn get_terminal_provider() -> Option { }) } -#[cfg(not(any(windows, target_arch = "wasm32")))] +#[cfg(not(any(windows, target_os = "wasm32")))] pub fn get_terminal_provider() -> Option { use helix_stdx::env::{binary_exists, env_var_is_set}; @@ -711,6 +711,8 @@ pub enum GutterType { Spacer, /// Highlight local changes Diff, + /// Highlight local changes + Coverage, } impl std::str::FromStr for GutterType { @@ -722,8 +724,9 @@ impl std::str::FromStr for GutterType { "spacer" => Ok(Self::Spacer), "line-numbers" => Ok(Self::LineNumbers), "diff" => Ok(Self::Diff), + "coverage" => Ok(Self::Coverage), _ => anyhow::bail!( - "Gutter type can only be `diagnostics`, `spacer`, `line-numbers` or `diff`." + "Gutter type can only be `diagnostics`, `spacer`, `line-numbers`, `diff` or `coverage`." ), } } diff --git a/helix-view/src/gutter.rs b/helix-view/src/gutter.rs index 665a78bcc..1d8cf9a54 100644 --- a/helix-view/src/gutter.rs +++ b/helix-view/src/gutter.rs @@ -1,8 +1,9 @@ -use std::fmt::Write; +use std::{fmt::Write, path::PathBuf}; use helix_core::syntax::LanguageServerFeature; use crate::{ + coverage, editor::GutterType, graphics::{Style, UnderlineStyle}, Document, Editor, Theme, View, @@ -32,6 +33,7 @@ impl GutterType { GutterType::LineNumbers => line_numbers(editor, doc, view, theme, is_focused), GutterType::Spacer => padding(editor, doc, view, theme, is_focused), GutterType::Diff => diff(editor, doc, view, theme, is_focused), + GutterType::Coverage => coverage(editor, doc, view, theme, is_focused), } } @@ -41,6 +43,7 @@ impl GutterType { GutterType::LineNumbers => line_numbers_width(view, doc), GutterType::Spacer => 1, GutterType::Diff => 1, + GutterType::Coverage => 1, } } } @@ -139,6 +142,40 @@ pub fn diff<'doc>( } } +pub fn coverage<'doc>( + _editor: &'doc Editor, + doc: &'doc Document, + _view: &View, + theme: &Theme, + _is_focused: bool, +) -> GutterFn<'doc> { + let covered = theme.get("diff.plus.gutter"); + let not_covered = theme.get("diff.minus.gutter"); + + if let Some(document_path) = &doc.path { + if let Some(file_coverage) = coverage::get_coverage(document_path) { + log::info!("return valid coverage gutter for {:?}", document_path); + return Box::new( + move |line: usize, _selected: bool, _first_visual_line: bool, out: &mut String| { + if let Some(line_coverage) = file_coverage.lines.get(&(line as u32)) { + let (icon, style) = if *line_coverage { + ("┃", covered) + } else { + ("┃", not_covered) + }; + write!(out, "{}", icon).unwrap(); + Some(style) + } else { + None + } + }, + ); + } + } + log::info!("return empty coverage gutter"); + return Box::new(move |_, _, _, _| None); +} + pub fn line_numbers<'doc>( editor: &'doc Editor, doc: &'doc Document, diff --git a/helix-view/src/lib.rs b/helix-view/src/lib.rs index e30a23381..1424d6e09 100644 --- a/helix-view/src/lib.rs +++ b/helix-view/src/lib.rs @@ -4,6 +4,7 @@ pub mod macros; pub mod annotations; pub mod base64; pub mod clipboard; +pub mod coverage; pub mod document; pub mod editor; pub mod events;