BibGuard / src /report /line_report.py
thinkwee
init
46df5f0
"""
Line-by-line report generator.
Generates a report that follows the TeX file structure,
showing issues in order of appearance in the document.
"""
import re
from typing import List, Dict, Tuple, Optional
from dataclasses import dataclass, field
from collections import defaultdict
from datetime import datetime
from pathlib import Path
from ..checkers.base import CheckResult, CheckSeverity
@dataclass
class LineIssue:
"""An issue associated with a specific line or range."""
start_line: int
end_line: int
line_content: str
issues: List[CheckResult] = field(default_factory=list)
block_type: Optional[str] = None # 'figure', 'table', 'equation', etc.
class LineByLineReportGenerator:
"""
Generates a report organized by TeX file line order.
Groups consecutive lines and special environments into blocks,
then outputs issues in the order they appear in the document.
"""
# LaTeX environments that should be grouped as blocks
BLOCK_ENVIRONMENTS = [
'figure', 'figure*', 'table', 'table*', 'tabular', 'tabular*',
'equation', 'equation*', 'align', 'align*', 'gather', 'gather*',
'algorithm', 'algorithm2e', 'algorithmic', 'lstlisting',
'verbatim', 'minted', 'tikzpicture', 'minipage', 'subfigure',
]
def __init__(self, tex_content: str, tex_path: str):
self.tex_content = tex_content
self.tex_path = tex_path
self.lines = tex_content.split('\n')
self.line_issues: Dict[int, List[CheckResult]] = defaultdict(list)
self.blocks: List[Tuple[int, int, str]] = [] # (start, end, env_type)
# Parse block environments
self._parse_blocks()
def _parse_blocks(self):
"""Find all block environments in the TeX content."""
for env in self.BLOCK_ENVIRONMENTS:
env_escaped = env.replace('*', r'\*')
pattern = re.compile(
rf'\\begin\{{{env_escaped}\}}.*?\\end\{{{env_escaped}\}}',
re.DOTALL
)
for match in pattern.finditer(self.tex_content):
start_line = self._pos_to_line(match.start())
end_line = self._pos_to_line(match.end())
self.blocks.append((start_line, end_line, env))
# Sort blocks by start line
self.blocks.sort(key=lambda x: x[0])
def _pos_to_line(self, pos: int) -> int:
"""Convert character position to line number (1-indexed)."""
return self.tex_content[:pos].count('\n') + 1
def add_results(self, results: List[CheckResult]):
"""Add check results to the line-by-line mapping."""
for result in results:
if result.passed:
continue
line_num = result.line_number or 0
if line_num > 0:
self.line_issues[line_num].append(result)
def _get_block_for_line(self, line_num: int) -> Optional[Tuple[int, int, str]]:
"""Check if a line is part of a block environment."""
for start, end, env_type in self.blocks:
if start <= line_num <= end:
return (start, end, env_type)
return None
def _get_block_content(self, start: int, end: int) -> str:
"""Get content for a block of lines."""
block_lines = self.lines[start-1:end]
if len(block_lines) > 10:
# Truncate long blocks
return '\n'.join(block_lines[:5]) + '\n [...]\n' + '\n'.join(block_lines[-3:])
return '\n'.join(block_lines)
def _severity_icon(self, severity: CheckSeverity) -> str:
"""Get icon for severity level."""
icons = {
CheckSeverity.ERROR: 'πŸ”΄',
CheckSeverity.WARNING: '🟑',
CheckSeverity.INFO: 'πŸ”΅',
}
return icons.get(severity, 'βšͺ')
def generate(self) -> str:
"""Generate the line-by-line report."""
lines = []
# Header
lines.append("# BibGuard Line-by-Line Report")
lines.append("")
lines.append(f"**File:** `{Path(self.tex_path).name}`")
lines.append(f"**Generated at:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
lines.append("")
lines.append("---")
lines.append("")
# Summary counts
error_count = sum(1 for issues in self.line_issues.values()
for r in issues if r.severity == CheckSeverity.ERROR)
warning_count = sum(1 for issues in self.line_issues.values()
for r in issues if r.severity == CheckSeverity.WARNING)
info_count = sum(1 for issues in self.line_issues.values()
for r in issues if r.severity == CheckSeverity.INFO)
lines.append("## πŸ“Š Overview")
lines.append("")
lines.append(f"| πŸ”΄ Errors | 🟑 Warnings | πŸ”΅ Suggestions |")
lines.append(f"|:---------:|:-----------:|:--------------:|")
lines.append(f"| {error_count} | {warning_count} | {info_count} |")
lines.append("")
lines.append("---")
lines.append("")
if not self.line_issues:
lines.append("πŸŽ‰ **No issues found!**")
return '\n'.join(lines)
# Process lines in order
lines.append("## πŸ“ Line-by-Line Details")
lines.append("")
processed_lines = set()
sorted_line_nums = sorted(self.line_issues.keys())
for line_num in sorted_line_nums:
if line_num in processed_lines:
continue
issues = self.line_issues[line_num]
if not issues:
continue
# Check if this line is part of a block
block = self._get_block_for_line(line_num)
if block:
start, end, env_type = block
# Mark all lines in block as processed
for ln in range(start, end + 1):
processed_lines.add(ln)
# Collect all issues in this block
block_issues = []
for ln in range(start, end + 1):
if ln in self.line_issues:
block_issues.extend(self.line_issues[ln])
if block_issues:
lines.append(f"### πŸ“¦ `{env_type}` Environment (Lines {start}-{end})")
lines.append("")
lines.append("```latex")
lines.append(self._get_block_content(start, end))
lines.append("```")
lines.append("")
# Group issues by type
for issue in block_issues:
icon = self._severity_icon(issue.severity)
lines.append(f"- {icon} **{issue.message}**")
if issue.suggestion:
lines.append(f" - πŸ’‘ {issue.suggestion}")
lines.append("")
else:
# Single line
processed_lines.add(line_num)
# Use custom line_content from CheckResult if available, otherwise get from file
custom_content = None
for issue in issues:
if issue.line_content:
custom_content = issue.line_content
break
line_content = custom_content if custom_content else (
self.lines[line_num - 1] if line_num <= len(self.lines) else ""
)
lines.append(f"### Line {line_num}")
lines.append("")
lines.append("```latex")
lines.append(line_content)
lines.append("```")
lines.append("")
for issue in issues:
icon = self._severity_icon(issue.severity)
lines.append(f"- {icon} **{issue.message}**")
if issue.suggestion:
lines.append(f" - πŸ’‘ {issue.suggestion}")
lines.append("")
# Footer
lines.append("---")
lines.append("")
lines.append("*Report generated by BibGuard*")
return '\n'.join(lines)
def save(self, filepath: str):
"""Save report to file."""
content = self.generate()
with open(filepath, 'w', encoding='utf-8') as f:
f.write(content)
def generate_line_report(
tex_content: str,
tex_path: str,
results: List[CheckResult],
output_path: str
) -> str:
"""
Generate a line-by-line report from check results.
Args:
tex_content: The TeX file content
tex_path: Path to the TeX file
results: List of check results from all checkers
output_path: Where to save the report
Returns:
Path to the generated report
"""
generator = LineByLineReportGenerator(tex_content, tex_path)
generator.add_results(results)
generator.save(output_path)
return output_path