abstractcode 0.3.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.
@@ -12,45 +12,351 @@ import re
12
12
  from dataclasses import dataclass
13
13
  from typing import List
14
14
 
15
+ from .theme import Theme, theme_from_env
16
+
15
17
 
16
18
  @dataclass(frozen=True)
17
19
  class AnsiPalette:
18
20
  reset: str = "\033[0m"
19
21
  dim: str = "\033[2m"
20
22
  bold: str = "\033[1m"
23
+ italic: str = "\033[3m"
24
+ underline: str = "\033[4m"
21
25
  cyan: str = "\033[36m"
22
26
  green: str = "\033[32m"
23
27
  blue: str = "\033[38;5;39m"
24
28
 
25
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
+
26
54
  class TerminalMarkdownRenderer:
27
55
  """Render a subset of Markdown to ANSI-styled plain text."""
28
56
 
29
57
  _re_heading = re.compile(r"^(?P<hashes>#{1,6})\s+(?P<title>.+?)\s*$")
30
58
  _re_hr = re.compile(r"^\s*(-{3,}|_{3,}|\*{3,})\s*$")
31
- _re_bold = re.compile(r"\*\*(?P<txt>[^*]+)\*\*")
32
59
  _re_inline_code = re.compile(r"`(?P<code>[^`]+)`")
33
-
34
- def __init__(self, *, color: bool = True, palette: AnsiPalette | None = None) -> None:
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:
35
82
  self._color = bool(color)
36
- self._p = palette or AnsiPalette()
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
37
92
 
38
93
  def _style(self, text: str, *codes: str) -> str:
39
94
  if not self._color or not codes:
40
95
  return text
41
96
  return "".join(codes) + text + self._p.reset
42
97
 
43
- def _style_inline(self, line: str) -> str:
44
- # Bold
45
- def _bold(m: re.Match) -> str:
46
- return self._style(m.group("txt"), self._p.bold)
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
47
115
 
48
- # Inline code
49
- def _code(m: re.Match) -> str:
50
- return self._style(m.group("code"), self._p.blue)
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
51
160
 
52
- out = self._re_bold.sub(_bold, line)
53
- out = self._re_inline_code.sub(_code, out)
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))
54
360
  return out
55
361
 
56
362
  def _unescape_newlines_if_needed(self, s: str) -> str:
@@ -120,49 +426,132 @@ class TerminalMarkdownRenderer:
120
426
  lines = s.splitlines()
121
427
  out: List[str] = []
122
428
 
123
- in_code = False
124
- fence_lang = ""
125
-
126
- for raw in lines:
127
- line = raw.rstrip("\n")
128
-
129
- # Code fences
130
- if line.strip().startswith("```"):
131
- if not in_code:
132
- in_code = True
133
- fence_lang = line.strip()[3:].strip()
134
- label = f"code" + (f" ({fence_lang})" if fence_lang else "")
135
- out.append(self._style(f"┌─ {label}", self._p.dim))
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)
136
458
  else:
137
- in_code = False
138
- out.append(self._style("└─", self._p.dim))
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
139
465
  continue
140
466
 
141
- if in_code:
142
- # Keep code unmodified; add a subtle gutter.
143
- out.append(self._style("│ ", self._p.dim) + line)
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))
144
480
  continue
145
481
 
146
482
  # Horizontal rules
147
483
  if self._re_hr.match(line):
148
484
  out.append(self._style("─" * 60, self._p.dim))
485
+ i += 1
149
486
  continue
150
487
 
151
488
  # Headings
152
489
  m = self._re_heading.match(line)
153
490
  if m:
154
- hashes = m.group("hashes")
155
491
  title = m.group("title").strip()
156
- level = len(hashes)
492
+ level = len(m.group("hashes"))
493
+ rendered_title = self._render_inline(title)
494
+ plain_title = self._strip_ansi(rendered_title)
157
495
  if level <= 2:
158
- out.append(self._style(title, self._p.bold, self._p.cyan))
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))
159
505
  else:
160
- out.append(self._style(title, self._p.bold))
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
161
513
  continue
162
514
 
163
- out.append(self._style_inline(line))
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
164
522
 
165
- return "\n".join(out)
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
166
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
167
553
 
554
+ out.append(self._render_inline(line))
555
+ i += 1
168
556
 
557
+ return "\n".join(out)