abstractcode 0.2.0__py3-none-any.whl → 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,557 @@
1
+ """Minimal, terminal-friendly Markdown renderer.
2
+
3
+ Goal: improve readability in the TUI without attempting full CommonMark compliance.
4
+ We deliberately keep this conservative:
5
+ - Only style headings, code fences, and a few inline constructs.
6
+ - Never mutate the underlying content used for copy-to-clipboard.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from dataclasses import dataclass
13
+ from typing import List
14
+
15
+ from .theme import Theme, theme_from_env
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class AnsiPalette:
20
+ reset: str = "\033[0m"
21
+ dim: str = "\033[2m"
22
+ bold: str = "\033[1m"
23
+ italic: str = "\033[3m"
24
+ underline: str = "\033[4m"
25
+ cyan: str = "\033[36m"
26
+ green: str = "\033[32m"
27
+ blue: str = "\033[38;5;39m"
28
+
29
+
30
+ def _ansi_fg(hex_color: str) -> str:
31
+ s = str(hex_color or "").strip()
32
+ if not s:
33
+ return ""
34
+ if not s.startswith("#"):
35
+ s = "#" + s
36
+ if len(s) != 7:
37
+ return ""
38
+ try:
39
+ r = int(s[1:3], 16)
40
+ g = int(s[3:5], 16)
41
+ b = int(s[5:7], 16)
42
+ except Exception:
43
+ return ""
44
+ return f"\033[38;2;{r};{g};{b}m"
45
+
46
+
47
+ def _palette_from_theme(theme: Theme) -> AnsiPalette:
48
+ t = theme.normalized()
49
+ primary = _ansi_fg(t.primary) or AnsiPalette.cyan
50
+ secondary = _ansi_fg(t.secondary) or AnsiPalette.blue
51
+ return AnsiPalette(cyan=primary, green=primary, blue=secondary)
52
+
53
+
54
+ class TerminalMarkdownRenderer:
55
+ """Render a subset of Markdown to ANSI-styled plain text."""
56
+
57
+ _re_heading = re.compile(r"^(?P<hashes>#{1,6})\s+(?P<title>.+?)\s*$")
58
+ _re_hr = re.compile(r"^\s*(-{3,}|_{3,}|\*{3,})\s*$")
59
+ _re_inline_code = re.compile(r"`(?P<code>[^`]+)`")
60
+ _re_md_link = re.compile(r"\[(?P<label>[^\]]+)\]\((?P<url>[^)\s]+)(?:\s+\"[^\"]*\")?\)")
61
+ _re_autolink = re.compile(r"<(?P<url>https?://[^>]+)>")
62
+ _re_bare_url = re.compile(r"(?P<url>https?://[^\s<>()\]]+)")
63
+ _re_bold = re.compile(r"(\*\*|__)(?P<txt>.+?)\1")
64
+ _re_strike = re.compile(r"~~(?P<txt>[^~]+)~~")
65
+ _re_italic_star = re.compile(r"(?<!\*)\*(?P<txt>[^*]+?)\*(?!\*)")
66
+ _re_italic_us = re.compile(r"(?<!_)_(?P<txt>[^_]+?)_(?!_)")
67
+ _re_blockquote = re.compile(r"^\s*>\s?(?P<body>.*)$")
68
+ _re_task = re.compile(r"^(?P<indent>\s*)[-*+]\s+\[(?P<state>[ xX])\]\s+(?P<body>.*)$")
69
+ _re_bullet = re.compile(r"^(?P<indent>\s*)[-*+]\s+(?P<body>.*)$")
70
+ _re_ordered = re.compile(r"^(?P<indent>\s*)(?P<num>\d+)[.)]\s+(?P<body>.*)$")
71
+ _re_table_sep_cell = re.compile(r"^:?-{3,}:?$")
72
+ _re_ansi = re.compile(r"\x1b\[[0-9;]*m")
73
+
74
+ def __init__(
75
+ self,
76
+ *,
77
+ color: bool = True,
78
+ theme: Theme | None = None,
79
+ palette: AnsiPalette | None = None,
80
+ width: int | None = None,
81
+ ) -> None:
82
+ self._color = bool(color)
83
+ if palette is not None:
84
+ self._p = palette
85
+ else:
86
+ self._p = _palette_from_theme(theme or theme_from_env()) if self._color else AnsiPalette()
87
+ try:
88
+ w = int(width) if width is not None else None
89
+ except Exception:
90
+ w = None
91
+ self._width = max(40, w) if isinstance(w, int) else 120
92
+
93
+ def _style(self, text: str, *codes: str) -> str:
94
+ if not self._color or not codes:
95
+ return text
96
+ return "".join(codes) + text + self._p.reset
97
+
98
+ def _strip_ansi(self, s: str) -> str:
99
+ return self._re_ansi.sub("", str(s or ""))
100
+
101
+ def _split_inline_code(self, s: str) -> List[tuple[str, str]]:
102
+ """Split into [('text'|'code', chunk), ...] preserving order."""
103
+ out: List[tuple[str, str]] = []
104
+ pos = 0
105
+ for m in self._re_inline_code.finditer(s):
106
+ if m.start() > pos:
107
+ out.append(("text", s[pos : m.start()]))
108
+ out.append(("code", m.group("code")))
109
+ pos = m.end()
110
+ if pos < len(s):
111
+ out.append(("text", s[pos:]))
112
+ if not out:
113
+ out.append(("text", s))
114
+ return out
115
+
116
+ def _split_url_trailing_punct(self, url: str) -> tuple[str, str]:
117
+ u = str(url or "")
118
+ trailing = ""
119
+ while u and u[-1] in ".,;:)]}":
120
+ trailing = u[-1] + trailing
121
+ u = u[:-1]
122
+ return u, trailing
123
+
124
+ def _style_url(self, url: str) -> str:
125
+ clean, trailing = self._split_url_trailing_punct(url)
126
+ if not clean:
127
+ return url
128
+ return self._style(clean, self._p.blue, self._p.underline) + trailing
129
+
130
+ def _render_inline_text(self, s: str) -> str:
131
+ """Render inline Markdown for non-code text segments."""
132
+
133
+ def _md_link(m: re.Match) -> str:
134
+ label = str(m.group("label") or "")
135
+ url = str(m.group("url") or "")
136
+ if not url:
137
+ return label
138
+ label_s = self._style(label, self._p.blue, self._p.underline) if label else self._style_url(url)
139
+ return f"{label_s} {self._style('↗', self._p.dim)} {self._style_url(url)}"
140
+
141
+ def _auto(m: re.Match) -> str:
142
+ return self._style_url(str(m.group("url") or ""))
143
+
144
+ # Links first (to avoid emphasis rules eating brackets/parentheses).
145
+ out = self._re_md_link.sub(_md_link, s)
146
+ out = self._re_autolink.sub(_auto, out)
147
+
148
+ # Bare URLs.
149
+ def _bare(m: re.Match) -> str:
150
+ return self._style_url(str(m.group("url") or ""))
151
+
152
+ out = self._re_bare_url.sub(_bare, out)
153
+
154
+ # Bold / strike / italic.
155
+ out = self._re_bold.sub(lambda m: self._style(m.group("txt"), self._p.bold), out)
156
+ out = self._re_strike.sub(lambda m: self._style(m.group("txt"), self._p.dim), out)
157
+ out = self._re_italic_star.sub(lambda m: self._style(m.group("txt"), self._p.italic), out)
158
+ out = self._re_italic_us.sub(lambda m: self._style(m.group("txt"), self._p.italic), out)
159
+ return out
160
+
161
+ def _render_inline(self, line: str) -> str:
162
+ """Render a line with inline Markdown (code + emphasis + links)."""
163
+ out: List[str] = []
164
+ for kind, chunk in self._split_inline_code(str(line or "")):
165
+ if not chunk:
166
+ continue
167
+ if kind == "code":
168
+ out.append(self._style(chunk, self._p.blue))
169
+ else:
170
+ out.append(self._render_inline_text(chunk))
171
+ return "".join(out)
172
+
173
+ def _is_table_separator(self, line: str) -> bool:
174
+ s = str(line or "").strip()
175
+ if not s or "|" not in s:
176
+ return False
177
+ s = s.strip("|").replace(" ", "")
178
+ if not s:
179
+ return False
180
+ parts = s.split("|")
181
+ if not parts or any(not p for p in parts):
182
+ return False
183
+ return all(self._re_table_sep_cell.match(p or "") for p in parts)
184
+
185
+ def _split_table_row(self, line: str) -> List[str]:
186
+ s = str(line or "").strip()
187
+ if s.startswith("|"):
188
+ s = s[1:]
189
+ if s.endswith("|"):
190
+ s = s[:-1]
191
+ parts = [p.strip() for p in s.split("|")]
192
+ return parts
193
+
194
+ def _table_alignments(self, sep_line: str, ncols: int) -> List[str]:
195
+ parts = self._split_table_row(sep_line)
196
+ aligns: List[str] = []
197
+ for p in parts[:ncols]:
198
+ cell = str(p or "").strip()
199
+ left = cell.startswith(":")
200
+ right = cell.endswith(":")
201
+ if left and right:
202
+ aligns.append("center")
203
+ elif right:
204
+ aligns.append("right")
205
+ else:
206
+ aligns.append("left")
207
+ while len(aligns) < ncols:
208
+ aligns.append("left")
209
+ return aligns
210
+
211
+ def _truncate_plain(self, s: str, max_len: int) -> str:
212
+ txt = str(s or "").replace("\t", " ").replace("\r", "")
213
+ txt = txt.replace("\n", " ")
214
+ if max_len <= 0:
215
+ return ""
216
+ if len(txt) <= max_len:
217
+ return txt
218
+ if max_len == 1:
219
+ return "…"
220
+ return txt[: max_len - 1] + "…"
221
+
222
+ def _render_table(self, header: List[str], sep_line: str, rows: List[List[str]]) -> List[str]:
223
+ ncols = max(1, len(header))
224
+ aligns = self._table_alignments(sep_line, ncols=ncols)
225
+
226
+ norm_rows: List[List[str]] = []
227
+ for r in rows:
228
+ rr = list(r or [])
229
+ if len(rr) < ncols:
230
+ rr.extend([""] * (ncols - len(rr)))
231
+ norm_rows.append(rr[:ncols])
232
+
233
+ # Compute widths from plain (un-styled) content.
234
+ widths = [0] * ncols
235
+ for c in range(ncols):
236
+ widths[c] = max(widths[c], len(self._strip_ansi(str(header[c] if c < len(header) else ""))))
237
+ for r in norm_rows:
238
+ for c in range(ncols):
239
+ widths[c] = max(widths[c], len(self._strip_ansi(str(r[c] or ""))))
240
+
241
+ # Fit into available width (best-effort; avoid wrapping table borders).
242
+ max_total = max(40, int(self._width or 120))
243
+ # Borders: 1 + (ncols-1) + 1 = ncols+1, plus padding " " around cells (2*ncols).
244
+ border_cost = (ncols + 1) + (2 * ncols)
245
+ avail_cells = max(1, max_total - border_cost)
246
+ total_cells = sum(widths)
247
+ if total_cells > avail_cells:
248
+ # Shrink proportionally, but keep a minimum for readability.
249
+ min_w = 6
250
+ widths = [max(min_w, w) for w in widths]
251
+ total_cells = sum(widths)
252
+ if total_cells > avail_cells:
253
+ # Still too wide: hard-cap each column.
254
+ cap = max(min_w, avail_cells // ncols)
255
+ widths = [min(w, cap) for w in widths]
256
+
257
+ def hline(left: str, mid: str, right: str) -> str:
258
+ segs = ["─" * (w + 2) for w in widths]
259
+ return self._style(left + mid.join(segs) + right, self._p.dim)
260
+
261
+ def render_row(cells: List[str], *, header_row: bool) -> str:
262
+ parts: List[str] = []
263
+ for i, w in enumerate(widths):
264
+ raw = str(cells[i] if i < len(cells) else "")
265
+ plain = self._truncate_plain(raw, w)
266
+ if aligns[i] == "right":
267
+ plain = plain.rjust(w)
268
+ elif aligns[i] == "center":
269
+ plain = plain.center(w)
270
+ else:
271
+ plain = plain.ljust(w)
272
+ styled = self._render_inline(plain)
273
+ if header_row:
274
+ styled = self._style(styled, self._p.bold, self._p.cyan)
275
+ parts.append(f" {styled} ")
276
+ inner = "│".join(parts)
277
+ return "│" + inner + "│"
278
+
279
+ out: List[str] = []
280
+ out.append(hline("┌", "┬", "┐"))
281
+ out.append(render_row(header, header_row=True))
282
+ out.append(hline("├", "┼", "┤"))
283
+ for r in norm_rows:
284
+ out.append(render_row(r, header_row=False))
285
+ out.append(hline("└", "┴", "┘"))
286
+ return out
287
+
288
+ def _render_mermaid(self, code_lines: List[str]) -> List[str]:
289
+ """Best-effort text rendering for common Mermaid diagrams."""
290
+ lines = [str(l or "").rstrip() for l in (code_lines or [])]
291
+ non_empty = [ln for ln in lines if ln.strip()]
292
+ if not non_empty:
293
+ return []
294
+
295
+ head = non_empty[0].strip()
296
+ items: List[str] = []
297
+
298
+ def add(line: str) -> None:
299
+ if line:
300
+ items.append(line)
301
+
302
+ if head.startswith(("graph", "flowchart")):
303
+ arrows = ["-->", "==>", "-.->", "---", "--", "->>"]
304
+ for ln in non_empty[1:]:
305
+ raw = ln.strip()
306
+ if not raw or raw.startswith("%%"):
307
+ continue
308
+ # Strip inline labels like -->|label|
309
+ raw = re.sub(r"\|[^|]*\|", "", raw)
310
+ found = None
311
+ for a in arrows:
312
+ if a in raw:
313
+ found = a
314
+ break
315
+ if not found:
316
+ continue
317
+ left, right = raw.split(found, 1)
318
+ left = left.strip()
319
+ right = right.strip()
320
+ for sep in ("[", "(", "{", "<"):
321
+ if sep in left:
322
+ left = left.split(sep, 1)[0].strip()
323
+ if sep in right:
324
+ right = right.split(sep, 1)[0].strip()
325
+ if not left or not right:
326
+ continue
327
+ add(f"• {left} → {right}")
328
+ elif head.startswith("sequenceDiagram"):
329
+ msg_re = re.compile(r"^(?P<a>[^-]+?)-+>>?(?P<b>[^:]+?):\\s*(?P<msg>.+)$")
330
+ for ln in non_empty[1:]:
331
+ raw = ln.strip()
332
+ if not raw or raw.startswith("%%"):
333
+ continue
334
+ m = msg_re.match(raw)
335
+ if not m:
336
+ continue
337
+ a = m.group("a").strip()
338
+ b = m.group("b").strip()
339
+ msg = m.group("msg").strip()
340
+ add(f"• {a} ⇢ {b}: {msg}")
341
+ else:
342
+ # Unknown kind: provide a lightweight summary.
343
+ add(f"• (unrecognized mermaid; showing source)")
344
+
345
+ if not items:
346
+ add("• (no edges/messages parsed; showing source)")
347
+
348
+ # Always show the source too (still inside the fenced block).
349
+ out: List[str] = []
350
+ for it in items[:40]:
351
+ out.append(self._style("│ ", self._p.dim) + self._render_inline(it))
352
+ if len(items) > 40:
353
+ out.append(self._style("│ … (more)", self._p.dim))
354
+ out.append(self._style("│", self._p.dim))
355
+ out.append(self._style("│ source:", self._p.dim))
356
+ for ln in lines[:60]:
357
+ out.append(self._style("│ ", self._p.dim) + ln)
358
+ if len(lines) > 60:
359
+ out.append(self._style("│ … (source truncated)", self._p.dim))
360
+ return out
361
+
362
+ def _unescape_newlines_if_needed(self, s: str) -> str:
363
+ """Convert literal "\\n" / "\\r" / "\\r\\n" sequences into real newlines.
364
+
365
+ Some upstream layers accidentally pass serialized strings (repr/json) where newlines are
366
+ encoded as the two characters backslash+n. We only unescape when the input has *no* real
367
+ newlines to avoid corrupting valid code like `print("a\\nb")`.
368
+ """
369
+ if "\n" in s or "\r" in s:
370
+ return s
371
+ if "\\n" not in s and "\\r" not in s:
372
+ return s
373
+
374
+ out: List[str] = []
375
+ i = 0
376
+ n = len(s)
377
+ while i < n:
378
+ ch = s[i]
379
+ if ch != "\\":
380
+ out.append(ch)
381
+ i += 1
382
+ continue
383
+
384
+ # Count consecutive backslashes.
385
+ j = i
386
+ while j < n and s[j] == "\\":
387
+ j += 1
388
+ run_len = j - i
389
+
390
+ if j >= n:
391
+ out.append("\\" * run_len)
392
+ break
393
+
394
+ nxt = s[j]
395
+
396
+ # Only treat "\n"/"\r" as escapes when the escape backslash is not itself escaped.
397
+ if nxt in ("n", "r") and (run_len % 2 == 1):
398
+ # Preserve all but the escape backslash.
399
+ if run_len > 1:
400
+ out.append("\\" * (run_len - 1))
401
+ out.append("\n")
402
+ i = j + 1
403
+
404
+ # Collapse \r\n into a single newline (Windows-style payloads).
405
+ if nxt == "r" and i < n and s[i] == "\\":
406
+ k = i
407
+ while k < n and s[k] == "\\":
408
+ k += 1
409
+ run2_len = k - i
410
+ if k < n and s[k] == "n" and (run2_len % 2 == 1):
411
+ if run2_len > 1:
412
+ out.append("\\" * (run2_len - 1))
413
+ i = k + 1
414
+ continue
415
+
416
+ # Not an escape we handle; emit literally.
417
+ out.append("\\" * run_len)
418
+ out.append(nxt)
419
+ i = j + 1
420
+
421
+ return "".join(out)
422
+
423
+ def render(self, text: str) -> str:
424
+ s = "" if text is None else str(text)
425
+ s = self._unescape_newlines_if_needed(s)
426
+ lines = s.splitlines()
427
+ out: List[str] = []
428
+
429
+ i = 0
430
+ while i < len(lines):
431
+ raw = lines[i]
432
+ line = str(raw or "").rstrip("\n")
433
+
434
+ # Code fences (block-level).
435
+ stripped = line.strip()
436
+ if stripped.startswith("```"):
437
+ fence_lang = stripped[3:].strip().lower()
438
+ code_lines: List[str] = []
439
+ i += 1
440
+ while i < len(lines):
441
+ candidate = str(lines[i] or "").rstrip("\n")
442
+ if candidate.strip().startswith("```"):
443
+ break
444
+ code_lines.append(candidate)
445
+ i += 1
446
+
447
+ label = "code" + (f" ({fence_lang})" if fence_lang else "")
448
+ if fence_lang == "mermaid":
449
+ label = "diagram (mermaid)"
450
+ out.append(self._style(f"┌─ {label}", self._p.dim))
451
+ if fence_lang == "mermaid":
452
+ rendered = self._render_mermaid(code_lines)
453
+ if rendered:
454
+ out.extend(rendered)
455
+ else:
456
+ for ln in code_lines:
457
+ out.append(self._style("│ ", self._p.dim) + ln)
458
+ else:
459
+ for ln in code_lines:
460
+ out.append(self._style("│ ", self._p.dim) + ln)
461
+ out.append(self._style("└─", self._p.dim))
462
+ # Skip the closing fence if present.
463
+ if i < len(lines) and str(lines[i] or "").strip().startswith("```"):
464
+ i += 1
465
+ continue
466
+
467
+ # Tables (header + separator line).
468
+ if i + 1 < len(lines) and "|" in line and self._is_table_separator(lines[i + 1]):
469
+ header = self._split_table_row(line)
470
+ sep = str(lines[i + 1] or "")
471
+ body: List[List[str]] = []
472
+ i += 2
473
+ while i < len(lines):
474
+ row_line = str(lines[i] or "").rstrip("\n")
475
+ if not row_line.strip() or "|" not in row_line:
476
+ break
477
+ body.append(self._split_table_row(row_line))
478
+ i += 1
479
+ out.extend(self._render_table(header, sep, body))
480
+ continue
481
+
482
+ # Horizontal rules
483
+ if self._re_hr.match(line):
484
+ out.append(self._style("─" * 60, self._p.dim))
485
+ i += 1
486
+ continue
487
+
488
+ # Headings
489
+ m = self._re_heading.match(line)
490
+ if m:
491
+ title = m.group("title").strip()
492
+ level = len(m.group("hashes"))
493
+ rendered_title = self._render_inline(title)
494
+ plain_title = self._strip_ansi(rendered_title)
495
+ if level <= 2:
496
+ if self._color:
497
+ base = self._p.bold + self._p.cyan
498
+ # Keep heading styling applied after inline segments reset.
499
+ rendered_title = rendered_title.replace(self._p.reset, self._p.reset + base)
500
+ out.append(base + rendered_title + self._p.reset)
501
+ else:
502
+ out.append(rendered_title)
503
+ underline = "─" * max(8, min(60, len(plain_title)))
504
+ out.append(self._style(underline, self._p.dim))
505
+ else:
506
+ if self._color:
507
+ base = self._p.bold
508
+ rendered_title = rendered_title.replace(self._p.reset, self._p.reset + base)
509
+ out.append(base + rendered_title + self._p.reset)
510
+ else:
511
+ out.append(rendered_title)
512
+ i += 1
513
+ continue
514
+
515
+ # Blockquotes
516
+ m = self._re_blockquote.match(line)
517
+ if m:
518
+ body = str(m.group("body") or "")
519
+ out.append(self._style("│ ", self._p.dim) + self._render_inline(body))
520
+ i += 1
521
+ continue
522
+
523
+ # Task lists
524
+ m = self._re_task.match(line)
525
+ if m:
526
+ indent = str(m.group("indent") or "")
527
+ state = str(m.group("state") or " ").lower()
528
+ body = str(m.group("body") or "")
529
+ box = "☑" if state == "x" else "☐"
530
+ prefix = self._style(box, self._p.cyan, self._p.bold)
531
+ out.append(f"{indent}{prefix} {self._render_inline(body)}")
532
+ i += 1
533
+ continue
534
+
535
+ # Bullets / ordered lists
536
+ m = self._re_bullet.match(line)
537
+ if m:
538
+ indent = str(m.group("indent") or "")
539
+ body = str(m.group("body") or "")
540
+ bullet = self._style("•", self._p.cyan, self._p.bold)
541
+ out.append(f"{indent}{bullet} {self._render_inline(body)}")
542
+ i += 1
543
+ continue
544
+ m = self._re_ordered.match(line)
545
+ if m:
546
+ indent = str(m.group("indent") or "")
547
+ num = str(m.group("num") or "").strip()
548
+ body = str(m.group("body") or "")
549
+ num_s = self._style(f"{num}.", self._p.cyan, self._p.bold)
550
+ out.append(f"{indent}{num_s} {self._render_inline(body)}")
551
+ i += 1
552
+ continue
553
+
554
+ out.append(self._render_inline(line))
555
+ i += 1
556
+
557
+ return "\n".join(out)