Skip to content

Cobertura coverage gutter #10758

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 15 commits into
base: master
Choose a base branch
from
12 changes: 12 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions helix-view/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
277 changes: 277 additions & 0 deletions helix-view/src/coverage.rs
Original file line number Diff line number Diff line change
@@ -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<std::path::PathBuf, FileCoverage>,
}

#[derive(Debug)]
pub struct FileCoverage {
pub lines: HashMap<u32, bool>,
pub modified_time: Option<SystemTime>,
}

#[derive(Deserialize, Debug)]
struct RawCoverage {
#[serde(rename = "@version")]
version: String,
sources: Sources,
packages: Packages,
modified_time: Option<SystemTime>,
}

#[derive(Deserialize, Debug)]
struct Sources {
source: Vec<Source>,
}

#[derive(Deserialize, Debug)]
struct Source {
#[serde(rename = "$value")]
name: String,
}

#[derive(Deserialize, Debug)]
struct Packages {
package: Vec<Package>,
}

#[derive(Deserialize, Debug)]
struct Package {
#[serde(rename = "@name")]
name: String,
classes: Classes,
}

#[derive(Deserialize, Debug)]
struct Classes {
class: Vec<Class>,
}

#[derive(Deserialize, Debug)]
struct Class {
#[serde(rename = "@name")]
name: String,
#[serde(rename = "@filename")]
filename: String,
lines: Lines,
}

#[derive(Deserialize, Debug)]
struct Lines {
line: Option<Vec<Line>>,
}

#[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<FileCoverage> {
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<std::path::PathBuf> {
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<Coverage> {
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<RawCoverage> 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#"<?xml version="1.0" ?>
<coverage version="7.3.0" timestamp="4333222111000">
<sources>
<source>{}</source>
</sources>
<packages>
<package name="a package">
<classes>
<class name="a class" filename="coverage.rs">
<lines>
<line number="3" hits="1"/>
<line number="5" hits="0"/>
</lines>
</class>
<class name="another class" filename="other.ext">
<lines>
<line number="1" hits="0"/>
<line number="7" hits="1"/>
</lines>
</class>
</classes>
</package>
</packages>
</coverage>"#,
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));
}
}
2 changes: 1 addition & 1 deletion helix-view/src/document.rs
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ pub struct Document {
/// update from the LSP
pub inlay_hints_oudated: bool,

path: Option<PathBuf>,
pub path: Option<PathBuf>,
relative_path: OnceCell<Option<PathBuf>>,
encoding: &'static encoding::Encoding,
has_bom: bool,
Expand Down
7 changes: 5 additions & 2 deletions helix-view/src/editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -420,7 +420,7 @@ pub fn get_terminal_provider() -> Option<TerminalConfig> {
})
}

#[cfg(not(any(windows, target_arch = "wasm32")))]
#[cfg(not(any(windows, target_os = "wasm32")))]
pub fn get_terminal_provider() -> Option<TerminalConfig> {
use helix_stdx::env::{binary_exists, env_var_is_set};

Expand Down Expand Up @@ -711,6 +711,8 @@ pub enum GutterType {
Spacer,
/// Highlight local changes
Diff,
/// Highlight local changes
Coverage,
}

impl std::str::FromStr for GutterType {
Expand All @@ -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`."
),
}
}
Expand Down
Loading