use crate::config::Config;
use crate::search::SearchResult;
use super::OutputFormatter;
use crate::config::OutputConfig;
use anyhow::Result;
use std::io::Write;
#[derive(Default)]
pub struct HtmlFormatter;
impl HtmlFormatter {
pub fn new() -> Self {
Self
}
}
impl OutputFormatter for HtmlFormatter {
fn format<W: Write>(
&self,
results: &[SearchResult],
writer: &mut W,
config: &OutputConfig,
) -> Result<()> {
let html = generate_html(results, config);
write!(writer, "{}", html)?;
Ok(())
}
}
pub fn display_results(results: &[SearchResult], config: &Config) {
let html = generate_html(results, &config.output);
println!("{}", html);
}
pub fn generate_html(results: &[SearchResult], config: &OutputConfig) -> String {
let results_with_matches: Vec<_> = results
.iter()
.filter(|r| !r.matches.is_empty())
.collect();
let total_matches: usize = results_with_matches.iter().map(|r| r.matches.len()).sum();
let mut html = String::new();
html.push_str(&format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OpenGrep Search Results</title>
<style>{}</style>
</head>
<body>
<div class="container">
<header>
<h1>OpenGrep Search Results</h1>
<div class="stats">
<span class="stat">{} files</span>
<span class="stat">{} matches</span>
</div>
</header>
"#,
get_css(),
results_with_matches.len(),
total_matches
));
for result in &results_with_matches {
html.push_str(&generate_file_section(result, config));
}
html.push_str(r#"
<footer>
<p>Generated by OpenGrep - Advanced AST-aware code search</p>
</footer>
</div>
<script>
// Toggle AST context visibility
document.querySelectorAll('.ast-toggle').forEach(button => {
button.addEventListener('click', () => {
const context = button.nextElementSibling;
if (context.style.display === 'none') {
context.style.display = 'block';
button.textContent = '▼ Hide AST';
} else {
context.style.display = 'none';
button.textContent = '▶ Show AST';
}
});
});
// Highlight matches on hover
document.querySelectorAll('.match-line').forEach(line => {
line.addEventListener('mouseenter', () => {
line.classList.add('highlight');
});
line.addEventListener('mouseleave', () => {
line.classList.remove('highlight');
});
});
</script>
</body>
</html>"#);
html
}
fn generate_file_section(result: &SearchResult, _config: &OutputConfig) -> String {
let mut html = String::new();
html.push_str(&format!(
r#"<div class="file-section">
<div class="file-header">
<h2>{}</h2>
<div class="file-info">
<span class="language">{}</span>
<span class="size">{} bytes</span>
<span class="matches">{} matches</span>
</div>
</div>
"#,
html_escape(&result.path.display().to_string()),
result.metadata.language.as_deref().unwrap_or("unknown"),
result.metadata.size,
result.matches.len()
));
#[cfg(feature = "ai")]
if let Some(insights) = &result.ai_insights {
html.push_str(&format!(
r#"<div class="ai-insights">
<h3>AI Insights</h3>
<p class="summary">{}</p>
"#,
html_escape(&insights.summary)
));
if let Some(explanation) = &insights.explanation {
html.push_str(&format!(
r#"<div class="explanation">
<h4>Explanation</h4>
<p>{}</p>
</div>
"#,
html_escape(explanation)
));
}
if !insights.suggestions.is_empty() {
html.push_str(r#"<div class="suggestions">
<h4>Suggestions</h4>
<ul>
"#);
for suggestion in &insights.suggestions {
html.push_str(&format!(
r#"<li>{}</li>"#,
html_escape(suggestion)
));
}
html.push_str("</ul></div>");
}
html.push_str("</div>");
}
html.push_str(r#"<div class="matches">"#);
for (i, match_item) in result.matches.iter().enumerate() {
html.push_str(&format!(
r#"<div class="match" id="match-{}">
<div class="match-header">
<span class="line-number">Line {}</span>
<span class="column">Column {}</span>
</div>
"#,
i,
match_item.line_number,
match_item.column_range.start
));
if let Some(ast_context) = &match_item.ast_context {
html.push_str(r#"<button class="ast-toggle">▼ Hide AST</button>
<div class="ast-context">
"#);
for node in &ast_context.nodes {
let indent = " ".repeat(node.indent);
html.push_str(&format!(
r#"<div class="ast-node" style="margin-left: {}em;">
<span class="node-kind">{}</span>
</div>
"#,
node.indent,
html_escape(&format!("{}{}", indent, node.display_text))
));
}
html.push_str("</div>");
}
html.push_str(r#"<div class="code-block">"#);
let start_line = match_item.line_number.saturating_sub(match_item.before_context.len());
for (j, line) in match_item.before_context.iter().enumerate() {
html.push_str(&format!(
r#"<div class="context-line">
<span class="line-num">{}</span>
<code>{}</code>
</div>
"#,
start_line + j,
html_escape(line)
));
}
html.push_str(&format!(
r#"<div class="match-line">
<span class="line-num">{}</span>
<code>{}</code>
</div>
"#,
match_item.line_number,
highlight_match(&match_item.line_text, &match_item.column_range)
));
for (j, line) in match_item.after_context.iter().enumerate() {
html.push_str(&format!(
r#"<div class="context-line">
<span class="line-num">{}</span>
<code>{}</code>
</div>
"#,
match_item.line_number + j + 1,
html_escape(line)
));
}
html.push_str("</div></div>");
}
html.push_str("</div></div>");
html
}
fn highlight_match(line: &str, range: &std::ops::Range<usize>) -> String {
let before = html_escape(&line[..range.start]);
let matched = html_escape(&line[range.clone()]);
let after = html_escape(&line[range.end..]);
format!("{}<mark>{}</mark>{}", before, matched, after)
}
fn html_escape(text: &str) -> String {
text.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
fn get_css() -> &'static str {
r#"
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
line-height: 1.6;
color: #333;
background-color: #f8f9fa;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
border-radius: 12px;
margin-bottom: 30px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
header h1 {
font-size: 2.5rem;
margin-bottom: 10px;
}
.stats {
display: flex;
gap: 20px;
}
.stat {
background: rgba(255, 255, 255, 0.2);
padding: 8px 16px;
border-radius: 20px;
font-weight: 500;
}
.file-section {
background: white;
border-radius: 12px;
margin-bottom: 30px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.file-header {
background: #f8f9fa;
padding: 20px;
border-bottom: 1px solid #e9ecef;
}
.file-header h2 {
color: #495057;
margin-bottom: 10px;
}
.file-info {
display: flex;
gap: 15px;
}
.file-info span {
background: #e9ecef;
padding: 4px 12px;
border-radius: 12px;
font-size: 0.9rem;
color: #6c757d;
}
.language {
background: #007bff !important;
color: white !important;
}
.ai-insights {
background: #f0f8ff;
border-left: 4px solid #007bff;
padding: 20px;
margin: 20px;
border-radius: 8px;
}
.ai-insights h3 {
color: #007bff;
margin-bottom: 15px;
}
.summary {
font-style: italic;
margin-bottom: 15px;
}
.explanation, .suggestions {
margin-top: 15px;
}
.suggestions ul {
margin-left: 20px;
}
.suggestions li {
margin-bottom: 5px;
}
.match {
border-bottom: 1px solid #e9ecef;
padding: 20px;
}
.match:last-child {
border-bottom: none;
}
.match-header {
display: flex;
gap: 15px;
margin-bottom: 15px;
color: #6c757d;
font-size: 0.9rem;
}
.ast-toggle {
background: #28a745;
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
margin-bottom: 10px;
font-size: 0.9rem;
}
.ast-toggle:hover {
background: #218838;
}
.ast-context {
background: #f8f9fa;
border-left: 3px solid #28a745;
padding: 15px;
margin-bottom: 15px;
border-radius: 4px;
}
.ast-node {
font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace;
font-size: 0.9rem;
color: #495057;
}
.node-kind {
color: #28a745;
font-weight: 500;
}
.code-block {
background: #f8f9fa;
border-radius: 8px;
padding: 15px;
font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace;
font-size: 0.9rem;
overflow-x: auto;
}
.context-line, .match-line {
display: flex;
padding: 2px 0;
border-radius: 4px;
}
.match-line {
background: #fff3cd;
margin: 2px -10px;
padding: 2px 10px;
}
.match-line.highlight {
background: #ffeaa7;
}
.line-num {
color: #6c757d;
width: 60px;
text-align: right;
margin-right: 15px;
user-select: none;
}
code {
flex: 1;
white-space: pre;
}
mark {
background: #ff6b6b;
color: white;
padding: 1px 3px;
border-radius: 3px;
}
footer {
text-align: center;
color: #6c757d;
margin-top: 40px;
padding: 20px;
}
"#
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_html_escape() {
assert_eq!(html_escape("Hello & <world>"), "Hello & <world>");
assert_eq!(html_escape("\"quoted\""), ""quoted"");
assert_eq!(html_escape("'single'"), "'single'");
}
#[test]
fn test_highlight_match() {
let result = highlight_match("hello world", &(0..5));
assert_eq!(result, "<mark>hello</mark> world");
let result = highlight_match("say hello there", &(4..9));
assert_eq!(result, "say <mark>hello</mark> there");
}
}