File size: 9,391 Bytes
46df5f0 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 |
"""
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
|