Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ exclude = ["src/main.rs", "docs", "PythonScripts"] # should have "Rules/", bu
[features]
"include-zip" = []
"enable-logs" = ["android_logger"]
"rule-coverage" = []

[dependencies]
sxd-document = "0.3"
Expand Down
2 changes: 2 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ pub use shim_filesystem::ZIPPED_RULE_FILES;
mod canonicalize;
mod infer_intent;
pub mod speech;
#[cfg(feature = "rule-coverage")]
pub mod rule_coverage;
mod braille;
mod navigate;
mod prefs;
Expand Down
153 changes: 153 additions & 0 deletions src/rule_coverage.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
//! Rule-level coverage tracking for YAML speech/braille/intent rules.
//! Enabled only when the `rule-coverage` Cargo feature is active.
//!
//! - When rules are loaded, each rule registers and gets a small integer id.
//! - When a rule matches, we record a hit for that id.
//! - A thread-local guard triggers a one-time LCOV export on program/test shutdown,
//! so callers don’t need to remember to “flush” coverage.
//! All state is behind a Mutex for safety; exports land in `target/rule-coverage/*.info`.
//!
//! To view everything on one page, regenerate HTML with:
//! genhtml --flat target/rule-coverage/lcov.info -o target/rule-coverage/html-flat
//! then open `target/rule-coverage/html-flat/index.html`.
#![cfg(feature = "rule-coverage")]

use std::io::{self, Write};
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{LazyLock, Mutex};

const MANIFEST_DIR: &str = env!("CARGO_MANIFEST_DIR");
thread_local! {
// One guard per thread. When the thread ends, its Drop runs; only the first guard
// across all threads performs the export (see DID_EXPORT below). This avoids needing
// an explicit "finish" call and still works when tests spawn threads.
static EXPORT_GUARD: ExportGuard = ExportGuard;
}

#[derive(Default, Debug)]
struct RuleEntry {
name: String, // pattern name (optionally with tag)
hits: u64,
}

#[derive(Default, Debug)]
struct FileEntry {
path: String,
rules: Vec<RuleEntry>,
}

#[derive(Default, Debug)]
struct Coverage {
files: Vec<FileEntry>,
index: Vec<(usize, usize)>, // id -> (file, rule)
}
impl Coverage {
fn clear(&mut self) { self.files.clear(); self.index.clear(); }
}

static COVERAGE: LazyLock<Mutex<Coverage>> = LazyLock::new(|| Mutex::new(Coverage::default()));

fn normalize_path(path: &str) -> String {
let path = Path::new(path);
if let Ok(cwd) = std::env::current_dir() {
if let Ok(stripped) = path.strip_prefix(&cwd) {
return stripped.to_string_lossy().into_owned();
}
}
path.to_string_lossy().into_owned()
}

pub fn register_rule(file_path: &str, rule_name: &str, tag_name: &str) -> usize {
ensure_guard();
let mut cov = COVERAGE.lock().unwrap();
let path = normalize_path(file_path);
let file_index = match cov.files.iter().position(|f| f.path == path) {
Some(i) => i,
None => { cov.files.push(FileEntry { path: path.clone(), rules: Vec::new() }); cov.files.len() - 1 }
};
let composite = format!("{rule_name} ({tag_name})");
if let Some(rule_index) = cov.files[file_index].rules.iter().position(|r| r.name == composite) {
if let Some(id) = cov.index.iter().position(|&(f, r)| f == file_index && r == rule_index) { return id; }
}
let rule_index = cov.files[file_index].rules.len();
cov.files[file_index].rules.push(RuleEntry { name: composite, hits: 0 });
let id = cov.index.len();
cov.index.push((file_index, rule_index));
id
}

pub fn record_rule_hit(id: usize) {
ensure_guard();
let mut cov = COVERAGE.lock().unwrap();
if let Some(&(f, r)) = cov.index.get(id) {
if let Some(rule) = cov.files.get_mut(f).and_then(|ff| ff.rules.get_mut(r)) {
rule.hits += 1;
}
}
}

pub fn reset_rule_coverage() { COVERAGE.lock().unwrap().clear(); }

/// Emit LCOV records:
/// FN/FNDA for rule declarations and hit counts
/// DA for per-rule line hits (one line per rule here)
/// LF/LH for lines found/hit; FNF/FNH for functions (rules) found/hit
///
/// See: https://manpages.debian.org/trixie/lcov/geninfo.1.en.html
pub fn export_rule_coverage_lcov<W: Write>(mut w: W) -> io::Result<()> {
let cov = COVERAGE.lock().unwrap();
for file in &cov.files {
let total = file.rules.len();
let covered = file.rules.iter().filter(|r| r.hits > 0).count();
writeln!(w, "SF:{}", file.path)?;
for (i, rule) in file.rules.iter().enumerate() {
let line = i + 1;
writeln!(w, "FN:{line},{}", rule.name)?;
}
for rule in &file.rules {
writeln!(w, "FNDA:{},{}", rule.hits, rule.name)?;
}
for (i, rule) in file.rules.iter().enumerate() {
let line = i + 1;
writeln!(w, "DA:{line},{}", rule.hits)?;
}
writeln!(w, "LF:{total}")?;
writeln!(w, "LH:{}", covered)?;
writeln!(w, "FNF:{total}")?;
writeln!(w, "FNH:{}", covered)?;
writeln!(w, "end_of_record")?;
}
Ok(())
}

fn ensure_guard() {
EXPORT_GUARD.with(|_| {});
static DID_RESET: LazyLock<Mutex<bool>> = LazyLock::new(|| Mutex::new(false));
let mut done = DID_RESET.lock().unwrap();
if !*done {
reset_rule_coverage();
*done = true;
}
}

// RAII helper: when an ExportGuard is dropped, it attempts to export coverage.
// A global AtomicBool ensures only the first drop across all threads writes the LCOV,
// so multiple threads shutting down won't duplicate the file.
struct ExportGuard;
static DID_EXPORT: AtomicBool = AtomicBool::new(false);

impl Drop for ExportGuard {
fn drop(&mut self) {
if DID_EXPORT.swap(true, Ordering::SeqCst) { return; }
use std::fs::{create_dir_all, File};
use std::path::PathBuf;
let mut path = PathBuf::from(MANIFEST_DIR).join("target/rule-coverage");
if create_dir_all(&path).is_err() { return; }
let exe = std::env::current_exe().ok()
.and_then(|p| p.file_stem().map(|s| s.to_string_lossy().into_owned()))
.unwrap_or_else(|| "tests".to_string());
path.push(format!("{exe}.info"));
if let Ok(mut f) = File::create(&path) { let _ = export_rule_coverage_lcov(&mut f); }
}
}
13 changes: 11 additions & 2 deletions src/speech.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ use crate::shim_filesystem::{read_to_string_shim, canonicalize_shim};
use crate::canonicalize::{as_element, create_mathml_element, set_mathml_name, name, MATHML_FROM_NAME_ATTR};
use regex::Regex;
use log::{debug, error, info};
#[cfg(feature = "rule-coverage")]
use crate::rule_coverage;


pub const NAV_NODE_SPEECH_NOT_FOUND: &str = "NAV_NODE_NOT_FOUND";
Expand Down Expand Up @@ -1259,6 +1261,8 @@ struct SpeechPattern {
pattern_name: String,
tag_name: String,
file_name: String,
#[cfg(feature = "rule-coverage")]
coverage_id: usize,
pattern: MyXPath, // the xpath expr to attempt to match
match_uses_var_defs: bool, // include var_defs in context for matching
var_defs: VariableDefinitions, // any variable definitions [can be and probably is an empty vector most of the time]
Expand Down Expand Up @@ -1341,11 +1345,14 @@ impl SpeechPattern {
format!("value for 'match' in rule ({}: {}):\n{}",
tag_name, pattern_name, yaml_to_string(dict, 1))
})?;
let file_name_string = file.to_str().unwrap().to_string();
let speech_pattern =
Box::new( SpeechPattern{
pattern_name: pattern_name.clone(),
tag_name: tag_name.clone(),
file_name: file.to_str().unwrap().to_string(),
file_name: file_name_string.clone(),
#[cfg(feature = "rule-coverage")]
coverage_id: rule_coverage::register_rule(&file_name_string, &pattern_name, &tag_name),
match_uses_var_defs: dict["variables"].is_array() && pattern_xpath.rc.string.contains('$'), // FIX: should look at var_defs for actual name
pattern: pattern_xpath,
var_defs: VariableDefinitions::build(&dict["variables"])
Expand Down Expand Up @@ -2429,6 +2436,8 @@ impl<'c, 's:'c, 'r, 'm:'c> SpeechRulesWithContext<'c, 's,'m> {
if !pattern.match_uses_var_defs && pattern.var_defs.len() > 0 { // don't push them on twice
self.context_stack.push(pattern.var_defs.clone(), mathml)?;
}
#[cfg(feature = "rule-coverage")]
rule_coverage::record_rule_hit(pattern.coverage_id);
let result = if self.nav_node_offset > 0 &&
self.nav_node_id == mathml.attribute_value("id").unwrap_or_default() && is_leaf(mathml) {
let ch = crate::canonicalize::as_text(mathml).chars().nth(self.nav_node_offset-1).unwrap_or_default();
Expand Down Expand Up @@ -2933,4 +2942,4 @@ cfg_if::cfg_if! {if #[cfg(not(feature = "include-zip"))] {
// assert_eq!(result.unwrap(), r#"DEBUG(*[2]/*[3][DEBUG(text()='(')], "DEBUG(*[2]/*[3][DEBUG(text()='(')], \"text()='(')]\")"#);
// }

}
}
Loading