| //! 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> | |
| "#; |