| //! Support for recording and rendering coverage of the grammar. |
| |
| use grammar::{Character, Expression, ExpressionKind, Grammar}; |
| |
| #[derive(Default)] |
| pub struct Coverage { |
| /// Count of the repetitions for each expression. |
| /// |
| /// The index is the expression ID. The value is the number of times a |
| /// particular number of repetitions was found for that expression (index |
| /// N means it matched N repetitions of the given number of times). |
| pub match_count: Vec<Vec<u32>>, |
| |
| /// Count of how often an expression failed to match its input. |
| /// |
| /// The index is the expression ID, the count is the number of times it failed. |
| pub no_match_count: Vec<u32>, |
| |
| /// Count of how often the expression caused a `ParseError`. |
| /// |
| /// The index is the expression ID, the count is the number of times it caused an error. |
| pub parse_error: Vec<u32>, |
| } |
| |
| impl Coverage { |
| /// Marks a node as being matched. |
| pub fn cov_match(&mut self, id: u32, count: u32) { |
| if self.match_count.len() < (id + 1) as usize { |
| self.match_count.resize((id + 1) as usize, Vec::new()); |
| } |
| let ns = self.match_count.get_mut(id as usize).unwrap(); |
| if ns.len() < (count + 1) as usize { |
| ns.resize((count + 1) as usize, 0); |
| } |
| *ns.get_mut(count as usize).unwrap() += 1; |
| } |
| |
| /// Marks a node that failed to match its input. |
| pub fn cov_no_match(&mut self, id: u32) { |
| if self.no_match_count.len() < (id + 1) as usize { |
| self.no_match_count.resize((id + 1) as usize, 0); |
| } |
| *self.no_match_count.get_mut(id as usize).unwrap() += 1; |
| } |
| |
| /// Marks a node that caused a `ParseError`. |
| pub fn cov_parse_error(&mut self, id: u32) { |
| if self.parse_error.len() < (id + 1) as usize { |
| self.parse_error.resize((id + 1) as usize, 0); |
| } |
| *self.parse_error.get_mut(id as usize).unwrap() += 1; |
| } |
| |
| /// Merge one `Coverage` into this one. |
| pub fn merge(&mut self, other: Coverage) { |
| if self.match_count.len() < other.match_count.len() { |
| self.match_count.resize(other.match_count.len(), Vec::new()); |
| } |
| for (id, counts) in other.match_count.into_iter().enumerate() { |
| let this = self.match_count.get_mut(id).unwrap(); |
| if this.len() < counts.len() { |
| this.resize(counts.len(), 0); |
| } |
| for (count, value) in counts.into_iter().enumerate() { |
| this[count] += value; |
| } |
| } |
| |
| if self.no_match_count.len() < other.no_match_count.len() { |
| self.no_match_count.resize(other.no_match_count.len(), 0); |
| } |
| for (id, value) in other.no_match_count.into_iter().enumerate() { |
| self.no_match_count[id] += value; |
| } |
| |
| if self.parse_error.len() < other.parse_error.len() { |
| self.parse_error.resize(other.parse_error.len(), 0); |
| } |
| for (id, value) in other.parse_error.into_iter().enumerate() { |
| self.parse_error[id] += value; |
| } |
| } |
| |
| /// Saves the coverage data to a file called `coverage.html`. |
| pub fn save(&self, grammar: &Grammar) { |
| let mut html = String::new(); |
| let mut span_stack = Vec::new(); |
| self.render_html(&mut html, &mut span_stack, grammar); |
| std::fs::write("coverage.html", html).expect("failed to write coverage.html"); |
| } |
| |
| fn get_coverage_status(&self, id: u32, kind: &ExpressionKind) -> CoverageStatus { |
| let match_count = self.match_count.get(id as usize); |
| let no_match = self.no_match_count.get(id as usize).copied().unwrap_or(0); |
| let parse_error = self.parse_error.get(id as usize).copied().unwrap_or(0); |
| |
| let has_matches = match_count |
| .map(|counts| counts.iter().any(|&c| c > 0)) |
| .unwrap_or(false); |
| let has_no_match = no_match > 0; |
| |
| // Special case logic for specific expression kinds |
| match kind { |
| ExpressionKind::Optional(_) => { |
| // Green means match_count contains both 0 and 1 |
| if let Some(counts) = match_count { |
| let has_zero = counts.get(0).copied().unwrap_or(0) > 0; |
| let has_one = counts.get(1).copied().unwrap_or(0) > 0; |
| if has_zero && has_one { |
| return CoverageStatus::Green; |
| } else if has_zero || has_one { |
| return CoverageStatus::Yellow; |
| } |
| } |
| CoverageStatus::Red |
| } |
| ExpressionKind::Repeat(_) => { |
| // Green means match_count contains 0, 1, and more than 1 |
| if let Some(counts) = match_count { |
| let has_zero = counts.get(0).copied().unwrap_or(0) > 0; |
| let has_one = counts.get(1).copied().unwrap_or(0) > 0; |
| let has_more = counts.iter().skip(2).any(|&c| c > 0); |
| if has_zero && has_one && has_more { |
| return CoverageStatus::Green; |
| } else if has_zero || has_one || has_more { |
| return CoverageStatus::Yellow; |
| } |
| } |
| CoverageStatus::Red |
| } |
| ExpressionKind::RepeatPlus(_) => { |
| // Green means match_count contains 1 and more than 1 and no_match_count is not zero |
| if let Some(counts) = match_count { |
| let has_one = counts.get(1).copied().unwrap_or(0) > 0; |
| let has_more = counts.iter().skip(2).any(|&c| c > 0); |
| if has_one && has_more && has_no_match { |
| return CoverageStatus::Green; |
| } else if (has_one || has_more) || has_no_match { |
| return CoverageStatus::Yellow; |
| } |
| } |
| CoverageStatus::Red |
| } |
| ExpressionKind::RepeatRange { min, max, .. } => { |
| // Green means match_count contains the minimum and maximum values |
| if let Some(counts) = match_count { |
| let min_val = min.unwrap_or(0) as usize; |
| let has_min = counts.get(min_val).copied().unwrap_or(0) > 0; |
| |
| let has_max = if let Some(max_val) = max { |
| counts.get(*max_val as usize).copied().unwrap_or(0) > 0 |
| } else { |
| // If max is None, consider it green if there are matches for any count over min |
| counts.iter().skip(min_val + 1).any(|&c| c > 0) |
| }; |
| |
| if has_min && has_max { |
| return CoverageStatus::Green; |
| } else if has_min || has_max { |
| return CoverageStatus::Yellow; |
| } |
| } |
| CoverageStatus::Red |
| } |
| ExpressionKind::Cut(_) => { |
| // Green means match_count contains a nonzero value and parse_error contains a nonzero value |
| if has_matches && parse_error > 0 { |
| return CoverageStatus::Green; |
| } else if has_matches || parse_error > 0 { |
| return CoverageStatus::Yellow; |
| } |
| CoverageStatus::Red |
| } |
| _ => { |
| // Default logic for other expression kinds |
| if !has_matches && no_match == 0 && parse_error == 0 { |
| CoverageStatus::Red |
| } else if has_matches && !has_no_match { |
| CoverageStatus::Yellow |
| } else if has_matches && has_no_match { |
| CoverageStatus::Green |
| } else { |
| CoverageStatus::Red |
| } |
| } |
| } |
| } |
| |
| fn render_html(&self, output: &mut String, span_stack: &mut Vec<String>, grammar: &Grammar) { |
| output.push_str(HTML_HEADER); |
| |
| // Group productions by category, preserving first-appearance order. |
| let mut category_order: Vec<String> = Vec::new(); |
| let mut categories: std::collections::HashMap<String, Vec<&str>> = |
| std::collections::HashMap::new(); |
| for name in &grammar.name_order { |
| if let Some(prod) = grammar.productions.get(name) { |
| let cat = &prod.category; |
| if !categories.contains_key(cat) { |
| category_order.push(cat.clone()); |
| categories.insert(cat.clone(), Vec::new()); |
| } |
| categories.get_mut(cat).unwrap().push(name.as_str()); |
| } |
| } |
| |
| for category in &category_order { |
| output.push_str(&format!( |
| "<div class=\"category\"><h2 class=\"category-name\">{}</h2>\n", |
| html_escape(category) |
| )); |
| if let Some(names) = categories.get(category) { |
| for name in names { |
| if let Some(prod) = grammar.productions.get(*name) { |
| self.render_production(prod, output, span_stack); |
| } |
| } |
| } |
| output.push_str("</div>\n"); |
| } |
| |
| output.push_str(HTML_FOOTER); |
| } |
| |
| fn render_production( |
| &self, |
| prod: &grammar::Production, |
| output: &mut String, |
| span_stack: &mut Vec<String>, |
| ) { |
| output.push_str("<div class=\"production\">"); |
| output.push_str(&format!( |
| "<span class=\"production-name\">{}</span>", |
| html_escape(&prod.name) |
| )); |
| output.push_str(" → "); |
| self.render_expression(&prod.expression, output, span_stack); |
| output.push_str("</div>\n"); |
| } |
| |
| fn render_expression( |
| &self, |
| expr: &Expression, |
| output: &mut String, |
| span_stack: &mut Vec<String>, |
| ) { |
| if let ExpressionKind::Break(indent) = &expr.kind { |
| for _ in 0..span_stack.len() { |
| output.push_str("</span>"); |
| } |
| output.push_str("<br>\n"); |
| for span in span_stack { |
| output.push_str(span); |
| } |
| for _ in 0..*indent { |
| output.push_str(" "); |
| } |
| return; |
| } |
| |
| if let ExpressionKind::Comment(s) = &expr.kind { |
| output.push_str(&format!( |
| "<span class=\"comment\">// {}</span>", |
| html_escape(s) |
| )); |
| return; |
| } |
| |
| let status = self.get_coverage_status(expr.id, &expr.kind); |
| let bg_color = status.color(); |
| let has_error = self.parse_error.get(expr.id as usize).copied().unwrap_or(0) > 0; |
| |
| let tooltip = self.generate_tooltip(expr.id); |
| |
| let span = format!( |
| "<span class=\"expr\" style=\"background-color: {};\" \ |
| onmouseover=\"showTooltip(event, '{}')\" \ |
| onmouseout=\"hideTooltip()\">", |
| bg_color, |
| html_escape(&tooltip).replace("'", "'") |
| ); |
| output.push_str(&span); |
| span_stack.push(span); |
| |
| if has_error { |
| output.push_str("<span class=\"error-marker\">●</span>"); |
| } |
| |
| self.render_expression_kind(&expr.kind, output, span_stack); |
| |
| if let Some(suffix) = &expr.suffix { |
| output.push_str(&format!("<sub>{}</sub>", html_escape(suffix))); |
| } |
| |
| output.push_str("</span>"); |
| span_stack.pop(); |
| } |
| |
| fn render_expression_kind( |
| &self, |
| kind: &ExpressionKind, |
| output: &mut String, |
| span_stack: &mut Vec<String>, |
| ) { |
| match kind { |
| ExpressionKind::Grouped(e) => { |
| output.push_str("( "); |
| self.render_expression(e, output, span_stack); |
| output.push_str(" )"); |
| } |
| ExpressionKind::Alt(es) => { |
| let mut iter = es.iter().peekable(); |
| while let Some(e) = iter.next() { |
| self.render_expression(e, output, span_stack); |
| if iter.peek().is_some() { |
| if !e.last_expr().is_break() { |
| output.push(' '); |
| } |
| output.push_str("| "); |
| } |
| } |
| } |
| ExpressionKind::Sequence(es) => { |
| let mut iter = es.iter().peekable(); |
| while let Some(e) = iter.next() { |
| self.render_expression(e, output, span_stack); |
| if iter.peek().is_some() && !e.last_expr().is_break() { |
| output.push(' '); |
| } |
| } |
| } |
| ExpressionKind::Optional(e) => { |
| self.render_expression(e, output, span_stack); |
| output.push_str("<sup>?</sup>"); |
| } |
| ExpressionKind::NegativeLookahead(e) => { |
| output.push('!'); |
| self.render_expression(e, output, span_stack); |
| } |
| ExpressionKind::Repeat(e) => { |
| self.render_expression(e, output, span_stack); |
| output.push_str("<sup>*</sup>"); |
| } |
| ExpressionKind::RepeatPlus(e) => { |
| self.render_expression(e, output, span_stack); |
| output.push_str("<sup>+</sup>"); |
| } |
| ExpressionKind::RepeatRange { |
| expr, |
| name, |
| min, |
| max, |
| limit, |
| } => { |
| self.render_expression(expr, output, span_stack); |
| output.push_str("<sup>"); |
| if let Some(n) = name { |
| output.push_str(&html_escape(n)); |
| output.push(':'); |
| } |
| if let Some(m) = min { |
| output.push_str(&m.to_string()); |
| } |
| output.push_str(&format!("{}", limit)); |
| if let Some(m) = max { |
| output.push_str(&m.to_string()); |
| } |
| output.push_str("</sup>"); |
| } |
| ExpressionKind::RepeatRangeNamed(e, name) => { |
| self.render_expression(e, output, span_stack); |
| output.push_str(&format!("<sup>{}</sup>", html_escape(name))); |
| } |
| ExpressionKind::Nt(nt) => { |
| output.push_str(&format!( |
| "<span class=\"nonterminal\">{}</span>", |
| html_escape(nt) |
| )); |
| } |
| ExpressionKind::Terminal(t) => { |
| output.push_str(&format!( |
| "<span class=\"terminal\">`{}`</span>", |
| html_escape(t) |
| )); |
| } |
| ExpressionKind::Prose(s) => { |
| output.push_str(&format!( |
| "<span class=\"prose\"><{}></span>", |
| html_escape(s) |
| )); |
| } |
| ExpressionKind::Break(_) | ExpressionKind::Comment(_) => { |
| // These are handled in render_expression to avoid coverage spans |
| unreachable!("Break and Comment should be handled in render_expression") |
| } |
| ExpressionKind::Charset(set) => { |
| output.push('['); |
| for (i, chars) in set.iter().enumerate() { |
| if i > 0 { |
| output.push(' '); |
| } |
| self.render_expression(chars, output, span_stack); |
| } |
| output.push(']'); |
| } |
| ExpressionKind::CharacterRange(a, b) => { |
| // TODO: It would be nice if this showed more info about the |
| // actual range that was covered. |
| let render_ch = |ch: &Character| -> String { |
| match ch { |
| Character::Char(c) => format!("`{}`", html_escape(&c.to_string())), |
| Character::Unicode((_, s)) => format!("U+{}", html_escape(s)), |
| } |
| }; |
| output.push_str(&render_ch(a)); |
| output.push('-'); |
| output.push_str(&render_ch(b)); |
| } |
| ExpressionKind::NegExpression(e) => { |
| output.push('~'); |
| self.render_expression(e, output, span_stack); |
| } |
| ExpressionKind::Cut(e) => { |
| output.push_str("^ "); |
| self.render_expression(e, output, span_stack); |
| } |
| ExpressionKind::Unicode((_, s)) => { |
| output.push_str(&format!("U+{}", html_escape(s))); |
| } |
| } |
| } |
| |
| fn generate_tooltip(&self, id: u32) -> String { |
| let mut tooltip = String::new(); |
| |
| tooltip.push_str(&format!("ID: {}\\n", id)); |
| |
| if let Some(counts) = self.match_count.get(id as usize) { |
| if counts.iter().any(|&c| c > 0) { |
| tooltip.push_str("Match counts:\\n"); |
| for (n, &count) in counts.iter().enumerate() { |
| if count > 0 { |
| let bar = "█".repeat((count.min(50) / 5).max(1) as usize); |
| tooltip.push_str(&format!(" {}: {} {}\\n", n, count, bar)); |
| } |
| } |
| } |
| } |
| |
| let no_match = self.no_match_count.get(id as usize).copied().unwrap_or(0); |
| if no_match > 0 { |
| tooltip.push_str(&format!("No match: {}\\n", no_match)); |
| } |
| |
| let parse_error = self.parse_error.get(id as usize).copied().unwrap_or(0); |
| if parse_error > 0 { |
| tooltip.push_str(&format!("Parse errors: {}\\n", parse_error)); |
| } |
| |
| if tooltip.ends_with("\\n") { |
| tooltip.truncate(tooltip.len() - 2); |
| } |
| |
| tooltip |
| } |
| } |
| |
| /// An indication of how well a node was covered. |
| #[derive(Debug, Clone, Copy)] |
| enum CoverageStatus { |
| /// Indicates the node was not covered at all. |
| Red, |
| /// Indicates the node was only partially covered. |
| Yellow, |
| /// Indicates the node was completely covered. |
| Green, |
| } |
| |
| impl CoverageStatus { |
| fn color(&self) -> &'static str { |
| match self { |
| CoverageStatus::Red => "#ffcccc", |
| CoverageStatus::Yellow => "#ffffcc", |
| CoverageStatus::Green => "#ccffcc", |
| } |
| } |
| } |
| |
| fn html_escape(s: &str) -> String { |
| s.replace('&', "&") |
| .replace('<', "<") |
| .replace('>', ">") |
| .replace('"', """) |
| } |
| |
| const HTML_HEADER: &str = r#"<!DOCTYPE html> |
| <html> |
| <head> |
| <meta charset="UTF-8"> |
| <title>Grammar Coverage Report</title> |
| <style> |
| body { |
| font-family: monospace; |
| padding: 20px; |
| background-color: #f5f5f5; |
| } |
| .production { |
| margin: 20px 0; |
| padding: 10px; |
| background-color: white; |
| border: 1px solid #ddd; |
| border-radius: 4px; |
| } |
| .production-name { |
| font-weight: bold; |
| color: #0066cc; |
| } |
| .expr { |
| display: inline-block; |
| border-radius: 6px; |
| position: relative; |
| } |
| .nonterminal { |
| color: #0066cc; |
| } |
| .terminal { |
| color: #009900; |
| } |
| .prose { |
| color: #666; |
| font-style: italic; |
| } |
| .comment { |
| color: #999; |
| } |
| .error-marker { |
| color: #9370db; |
| font-size: 18px; |
| vertical-align: super; |
| margin-left: 2px; |
| } |
| #tooltip { |
| position: fixed; |
| background-color: #333; |
| color: white; |
| padding: 10px; |
| border-radius: 4px; |
| font-size: 12px; |
| white-space: pre; |
| z-index: 1000; |
| display: none; |
| max-width: 400px; |
| pointer-events: none; |
| } |
| h1 { |
| color: #333; |
| } |
| .category { |
| margin: 30px 0; |
| border: 2px solid #aaa; |
| border-radius: 6px; |
| padding: 10px 16px; |
| background-color: #fafafa; |
| } |
| .category-name { |
| margin: 0 0 12px 0; |
| font-size: 1.1em; |
| color: #444; |
| border-bottom: 1px solid #ccc; |
| padding-bottom: 6px; |
| } |
| </style> |
| </head> |
| <body> |
| <h1>Grammar Coverage Report</h1> |
| <div id="tooltip"></div> |
| <div class="grammar"> |
| "#; |
| |
| const HTML_FOOTER: &str = r#" </div> |
| <script> |
| function showTooltip(event, text) { |
| event.stopPropagation(); |
| const tooltip = document.getElementById('tooltip'); |
| tooltip.textContent = text; |
| tooltip.style.display = 'block'; |
| tooltip.style.left = (event.clientX + 10) + 'px'; |
| tooltip.style.top = (event.clientY + 10) + 'px'; |
| } |
| |
| function hideTooltip(event) { |
| if (event) { |
| event.stopPropagation(); |
| } |
| const tooltip = document.getElementById('tooltip'); |
| tooltip.style.display = 'none'; |
| } |
| </script> |
| </body> |
| </html> |
| "#; |