|
|
""" |
|
|
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 |
|
|
|
|
|
|
|
|
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. |
|
|
""" |
|
|
|
|
|
|
|
|
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]] = [] |
|
|
|
|
|
|
|
|
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)) |
|
|
|
|
|
|
|
|
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: |
|
|
|
|
|
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 = [] |
|
|
|
|
|
|
|
|
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("") |
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
block = self._get_block_for_line(line_num) |
|
|
|
|
|
if block: |
|
|
start, end, env_type = block |
|
|
|
|
|
|
|
|
for ln in range(start, end + 1): |
|
|
processed_lines.add(ln) |
|
|
|
|
|
|
|
|
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("") |
|
|
|
|
|
|
|
|
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: |
|
|
|
|
|
processed_lines.add(line_num) |
|
|
|
|
|
|
|
|
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("") |
|
|
|
|
|
|
|
|
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 |
|
|
|