nanocat-cli 0.4.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,559 @@
|
|
|
1
|
+
#!python
|
|
2
|
+
"""
|
|
3
|
+
nanocat - Syntax-highlight files or stdin using GNU nano (.nanorc) rules.
|
|
4
|
+
Zero external dependencies (Python 3 standard library only).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import glob
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import textwrap
|
|
15
|
+
import warnings
|
|
16
|
+
|
|
17
|
+
warnings.filterwarnings("ignore", category=FutureWarning)
|
|
18
|
+
|
|
19
|
+
ANSI_FG = {
|
|
20
|
+
"black": "30", "red": "31", "green": "32", "yellow": "33",
|
|
21
|
+
"blue": "34", "magenta": "35", "cyan": "36", "white": "37",
|
|
22
|
+
"normal": "39", "grey": "90", "gray": "90",
|
|
23
|
+
"brightblack": "90", "brightred": "91", "brightgreen": "92",
|
|
24
|
+
"brightyellow": "93", "brightblue": "94", "brightmagenta": "95",
|
|
25
|
+
"brightcyan": "96", "brightwhite": "97",
|
|
26
|
+
"lightblack": "90", "lightred": "91", "lightgreen": "92",
|
|
27
|
+
"lightyellow": "93", "lightblue": "94", "lightmagenta": "95",
|
|
28
|
+
"lightcyan": "96", "lightwhite": "97",
|
|
29
|
+
"orange": "38;5;208", "purple": "38;5;135", "pink": "38;5;213",
|
|
30
|
+
"brown": "38;5;130", "peach": "38;5;216", "mint": "38;5;121",
|
|
31
|
+
"sky": "38;5;117", "salmon": "38;5;209", "teal": "38;5;30",
|
|
32
|
+
"crimson": "38;5;161", "plum": "38;5;96", "sage": "38;5;108",
|
|
33
|
+
"sea": "38;5;37", "lagoon": "38;5;38", "rose": "38;5;211",
|
|
34
|
+
"mauve": "38;5;176", "ochre": "38;5;172", "sand": "38;5;180",
|
|
35
|
+
"slate": "38;5;66", "brick": "38;5;124", "lime": "38;5;154",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
ANSI_BG = {
|
|
39
|
+
"black": "40", "red": "41", "green": "42", "yellow": "43",
|
|
40
|
+
"blue": "44", "magenta": "45", "cyan": "46", "white": "47",
|
|
41
|
+
"normal": "49", "grey": "100", "gray": "100",
|
|
42
|
+
"brightblack": "100", "brightred": "101", "brightgreen": "102",
|
|
43
|
+
"brightyellow": "103", "brightblue": "104", "brightmagenta": "105",
|
|
44
|
+
"brightcyan": "106", "brightwhite": "107",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
POSIX_CLASSES = {
|
|
48
|
+
"[:space:]": r"\s",
|
|
49
|
+
"[:blank:]": r"[ \t]",
|
|
50
|
+
"[:alpha:]": r"[A-Za-z]",
|
|
51
|
+
"[:alnum:]": r"[A-Za-z0-9]",
|
|
52
|
+
"[:digit:]": r"[0-9]",
|
|
53
|
+
"[:xdigit:]": r"[0-9A-Fa-f]",
|
|
54
|
+
"[:upper:]": r"[A-Z]",
|
|
55
|
+
"[:lower:]": r"[a-z]",
|
|
56
|
+
"[:punct:]": r"[!\"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~]",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def posix_to_python_regex(pattern: str) -> str:
|
|
61
|
+
pattern = pattern.replace(r"\<", r"\b").replace(r"\>", r"\b")
|
|
62
|
+
for posix, py_equiv in POSIX_CLASSES.items():
|
|
63
|
+
pattern = pattern.replace(f"[{posix}]", py_equiv).replace(posix, py_equiv)
|
|
64
|
+
return pattern
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def color_to_ansi(color_spec: str) -> str:
|
|
68
|
+
codes = []
|
|
69
|
+
parts = [p.strip().lower() for p in color_spec.split(",")]
|
|
70
|
+
idx = 0
|
|
71
|
+
while idx < len(parts) and parts[idx] in ("bold", "italic", "underline"):
|
|
72
|
+
if parts[idx] == "bold":
|
|
73
|
+
codes.append("1")
|
|
74
|
+
elif parts[idx] == "italic":
|
|
75
|
+
codes.append("3")
|
|
76
|
+
elif parts[idx] == "underline":
|
|
77
|
+
codes.append("4")
|
|
78
|
+
idx += 1
|
|
79
|
+
|
|
80
|
+
if idx < len(parts) and parts[idx]:
|
|
81
|
+
fg = ANSI_FG.get(parts[idx])
|
|
82
|
+
if fg:
|
|
83
|
+
codes.append(fg)
|
|
84
|
+
if idx + 1 < len(parts) and parts[idx + 1]:
|
|
85
|
+
bg = ANSI_BG.get(parts[idx + 1])
|
|
86
|
+
if bg:
|
|
87
|
+
codes.append(bg)
|
|
88
|
+
|
|
89
|
+
return f"\033[{';'.join(codes)}m" if codes else ""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def extract_quoted_strings(text: str) -> list[str]:
|
|
93
|
+
results = []
|
|
94
|
+
i = 0
|
|
95
|
+
n = len(text)
|
|
96
|
+
while i < n:
|
|
97
|
+
if text[i] == '"':
|
|
98
|
+
i += 1
|
|
99
|
+
buf = []
|
|
100
|
+
while i < n:
|
|
101
|
+
if text[i] == "\\" and i + 1 < n and text[i + 1] == '"':
|
|
102
|
+
buf.append('"')
|
|
103
|
+
i += 2
|
|
104
|
+
elif text[i] == '"':
|
|
105
|
+
i += 1
|
|
106
|
+
break
|
|
107
|
+
else:
|
|
108
|
+
buf.append(text[i])
|
|
109
|
+
i += 1
|
|
110
|
+
results.append("".join(buf))
|
|
111
|
+
else:
|
|
112
|
+
i += 1
|
|
113
|
+
return results
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def parse_user_nanorc(rcpaths: list[str]) -> tuple[list[str], dict[str, str]]:
|
|
117
|
+
included_files = []
|
|
118
|
+
theme = {
|
|
119
|
+
"numbercolor": "cyan",
|
|
120
|
+
"titlecolor": "bold,brightwhite,blue",
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
for rcpath in rcpaths:
|
|
124
|
+
if not rcpath or not os.path.isfile(rcpath):
|
|
125
|
+
continue
|
|
126
|
+
try:
|
|
127
|
+
with open(rcpath, "r", encoding="utf-8", errors="replace") as f:
|
|
128
|
+
for raw_line in f:
|
|
129
|
+
line = raw_line.strip()
|
|
130
|
+
if line.startswith("include "):
|
|
131
|
+
for pat in extract_quoted_strings(line[8:]):
|
|
132
|
+
expanded = os.path.expanduser(pat)
|
|
133
|
+
included_files.extend(sorted(glob.glob(expanded)))
|
|
134
|
+
elif line.startswith("set numbercolor "):
|
|
135
|
+
parts = line.split()
|
|
136
|
+
if len(parts) >= 3:
|
|
137
|
+
theme["numbercolor"] = parts[2]
|
|
138
|
+
elif line.startswith("set titlecolor "):
|
|
139
|
+
parts = line.split()
|
|
140
|
+
if len(parts) >= 3:
|
|
141
|
+
theme["titlecolor"] = parts[2]
|
|
142
|
+
except OSError:
|
|
143
|
+
pass
|
|
144
|
+
|
|
145
|
+
return included_files, theme
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def parse_nanorc_file(filepath: str, compile_rules: bool = True) -> list[dict]:
|
|
149
|
+
syntaxes = []
|
|
150
|
+
current = None
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
with open(filepath, "r", encoding="utf-8", errors="replace") as f:
|
|
154
|
+
lines = f.readlines()
|
|
155
|
+
except OSError:
|
|
156
|
+
return syntaxes
|
|
157
|
+
|
|
158
|
+
for raw_line in lines:
|
|
159
|
+
line = raw_line.strip()
|
|
160
|
+
if not line or line.startswith("#"):
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
if line.startswith("syntax "):
|
|
164
|
+
quoted = extract_quoted_strings(line[7:])
|
|
165
|
+
if not quoted:
|
|
166
|
+
parts = line.split()
|
|
167
|
+
name = parts[1].strip('"') if len(parts) > 1 else "unknown"
|
|
168
|
+
ext_patterns = [p.strip('"') for p in parts[2:]]
|
|
169
|
+
else:
|
|
170
|
+
name = quoted[0]
|
|
171
|
+
ext_patterns = quoted[1:]
|
|
172
|
+
|
|
173
|
+
current = {
|
|
174
|
+
"name": name,
|
|
175
|
+
"file": filepath,
|
|
176
|
+
"ext_patterns": ext_patterns,
|
|
177
|
+
"header_patterns": [],
|
|
178
|
+
"raw_rules": [],
|
|
179
|
+
"rules": [],
|
|
180
|
+
}
|
|
181
|
+
syntaxes.append(current)
|
|
182
|
+
|
|
183
|
+
elif current and line.startswith("header "):
|
|
184
|
+
current["header_patterns"].extend(extract_quoted_strings(line[7:]))
|
|
185
|
+
|
|
186
|
+
elif current and (line.startswith("color ") or line.startswith("icolor ")):
|
|
187
|
+
current["raw_rules"].append(line)
|
|
188
|
+
|
|
189
|
+
if compile_rules:
|
|
190
|
+
for s in syntaxes:
|
|
191
|
+
compile_syntax_rules(s)
|
|
192
|
+
|
|
193
|
+
return syntaxes
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def compile_syntax_rules(syntax: dict) -> None:
|
|
197
|
+
if syntax["rules"] or not syntax["raw_rules"]:
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
for line in syntax["raw_rules"]:
|
|
201
|
+
icase = line.startswith("icolor ")
|
|
202
|
+
rest = line[7:] if icase else line[6:]
|
|
203
|
+
tokens = rest.split(None, 1)
|
|
204
|
+
if len(tokens) < 2:
|
|
205
|
+
continue
|
|
206
|
+
color_spec, rule_body = tokens[0], tokens[1].strip()
|
|
207
|
+
ansi = color_to_ansi(color_spec)
|
|
208
|
+
flags = re.IGNORECASE if icase else 0
|
|
209
|
+
|
|
210
|
+
if rule_body.startswith("start="):
|
|
211
|
+
quoted = extract_quoted_strings(rule_body)
|
|
212
|
+
if len(quoted) >= 2:
|
|
213
|
+
try:
|
|
214
|
+
start_re = re.compile(posix_to_python_regex(quoted[0]), flags | re.MULTILINE)
|
|
215
|
+
end_re = re.compile(posix_to_python_regex(quoted[1]), flags | re.MULTILINE)
|
|
216
|
+
syntax["rules"].append(("block", ansi, start_re, end_re))
|
|
217
|
+
except re.error:
|
|
218
|
+
pass
|
|
219
|
+
else:
|
|
220
|
+
for pat in extract_quoted_strings(rule_body):
|
|
221
|
+
try:
|
|
222
|
+
compiled = re.compile(posix_to_python_regex(pat), flags | re.MULTILINE)
|
|
223
|
+
syntax["rules"].append(("single", ansi, compiled, None))
|
|
224
|
+
except re.error:
|
|
225
|
+
pass
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def load_all_syntaxes(search_dirs: list[str], rcpaths: list[str] | None = None) -> list[dict]:
|
|
229
|
+
files = []
|
|
230
|
+
seen_paths = set()
|
|
231
|
+
|
|
232
|
+
if rcpaths:
|
|
233
|
+
inc_files, _ = parse_user_nanorc(rcpaths)
|
|
234
|
+
for p in inc_files:
|
|
235
|
+
if p not in seen_paths:
|
|
236
|
+
seen_paths.add(p)
|
|
237
|
+
files.append(p)
|
|
238
|
+
|
|
239
|
+
for d in search_dirs:
|
|
240
|
+
if os.path.isfile(d) and d not in seen_paths:
|
|
241
|
+
seen_paths.add(d)
|
|
242
|
+
files.append(d)
|
|
243
|
+
elif os.path.isdir(d):
|
|
244
|
+
for path in sorted(glob.glob(os.path.join(d, "*.nanorc"))):
|
|
245
|
+
if path not in seen_paths:
|
|
246
|
+
seen_paths.add(path)
|
|
247
|
+
files.append(path)
|
|
248
|
+
|
|
249
|
+
all_syntaxes = []
|
|
250
|
+
for path in files:
|
|
251
|
+
all_syntaxes.extend(parse_nanorc_file(path, compile_rules=False))
|
|
252
|
+
return all_syntaxes
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def get_git_line_markers(filepath: str) -> dict[int, str]:
|
|
256
|
+
"""Return a mapping of 1-indexed line numbers to git status ('+', '~', '_')."""
|
|
257
|
+
markers: dict[int, str] = {}
|
|
258
|
+
if not filepath or not os.path.isfile(filepath) or not shutil.which("git"):
|
|
259
|
+
return markers
|
|
260
|
+
|
|
261
|
+
abs_path = os.path.abspath(filepath)
|
|
262
|
+
repo_dir = os.path.dirname(abs_path)
|
|
263
|
+
|
|
264
|
+
try:
|
|
265
|
+
proc = subprocess.run(
|
|
266
|
+
["git", "-C", repo_dir, "diff", "--no-color", "-U0", "HEAD", "--", abs_path],
|
|
267
|
+
capture_output=True,
|
|
268
|
+
text=True,
|
|
269
|
+
timeout=2,
|
|
270
|
+
)
|
|
271
|
+
if proc.returncode != 0 or not proc.stdout:
|
|
272
|
+
return markers
|
|
273
|
+
|
|
274
|
+
hunk_re = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
|
|
275
|
+
for line in proc.stdout.splitlines():
|
|
276
|
+
m = hunk_re.match(line)
|
|
277
|
+
if not m:
|
|
278
|
+
continue
|
|
279
|
+
old_count = int(m.group(2)) if m.group(2) is not None else 1
|
|
280
|
+
new_start = int(m.group(3))
|
|
281
|
+
new_count = int(m.group(4)) if m.group(4) is not None else 1
|
|
282
|
+
|
|
283
|
+
if new_count == 0 and old_count > 0:
|
|
284
|
+
markers[max(1, new_start)] = "_"
|
|
285
|
+
elif old_count == 0 and new_count > 0:
|
|
286
|
+
for ln in range(new_start, new_start + new_count):
|
|
287
|
+
markers[ln] = "+"
|
|
288
|
+
else:
|
|
289
|
+
for ln in range(new_start, new_start + new_count):
|
|
290
|
+
markers[ln] = "~"
|
|
291
|
+
except (OSError, subprocess.SubprocessError):
|
|
292
|
+
pass
|
|
293
|
+
|
|
294
|
+
return markers
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
LANG_ALIASES = {
|
|
298
|
+
"py": "python",
|
|
299
|
+
"md": "markdown",
|
|
300
|
+
"yml": "yaml",
|
|
301
|
+
"rs": "rust",
|
|
302
|
+
"rb": "ruby",
|
|
303
|
+
"cs": "csharp",
|
|
304
|
+
"cpp": "c++",
|
|
305
|
+
"cc": "c++",
|
|
306
|
+
"hpp": "c++",
|
|
307
|
+
"sh": "sh",
|
|
308
|
+
"bash": "sh",
|
|
309
|
+
"zsh": "sh",
|
|
310
|
+
"dockerfile": "dockerfile",
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def select_syntax(syntaxes: list[dict], lang: str | None, filename: str | None, content: str) -> dict | None:
|
|
315
|
+
matched = None
|
|
316
|
+
|
|
317
|
+
if lang:
|
|
318
|
+
raw_target = lang.lower()
|
|
319
|
+
target = LANG_ALIASES.get(raw_target, raw_target)
|
|
320
|
+
for s in syntaxes:
|
|
321
|
+
if s["name"].lower() == target or os.path.basename(s["file"]).lower() == f"{target}.nanorc":
|
|
322
|
+
matched = s
|
|
323
|
+
break
|
|
324
|
+
|
|
325
|
+
if not matched and filename:
|
|
326
|
+
for s in syntaxes:
|
|
327
|
+
for pat in s["ext_patterns"]:
|
|
328
|
+
try:
|
|
329
|
+
if re.search(posix_to_python_regex(pat), filename):
|
|
330
|
+
matched = s
|
|
331
|
+
break
|
|
332
|
+
except re.error:
|
|
333
|
+
pass
|
|
334
|
+
if matched:
|
|
335
|
+
break
|
|
336
|
+
|
|
337
|
+
if not matched and content:
|
|
338
|
+
first_line = content.splitlines()[0]
|
|
339
|
+
for s in syntaxes:
|
|
340
|
+
for pat in s["header_patterns"]:
|
|
341
|
+
try:
|
|
342
|
+
if re.search(posix_to_python_regex(pat), first_line):
|
|
343
|
+
matched = s
|
|
344
|
+
break
|
|
345
|
+
except re.error:
|
|
346
|
+
pass
|
|
347
|
+
if matched:
|
|
348
|
+
break
|
|
349
|
+
|
|
350
|
+
if matched:
|
|
351
|
+
compile_syntax_rules(matched)
|
|
352
|
+
return matched
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def parse_line_range(range_spec: str) -> tuple[int | None, int | None]:
|
|
356
|
+
if ":" not in range_spec:
|
|
357
|
+
line = int(range_spec)
|
|
358
|
+
return line, line
|
|
359
|
+
start_str, end_str = range_spec.split(":", 1)
|
|
360
|
+
start = int(start_str) if start_str.strip() else None
|
|
361
|
+
end = int(end_str) if end_str.strip() else None
|
|
362
|
+
return start, end
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def wrap_styled_chars(chars_with_styles: list[tuple[str, str]], width: int) -> list[list[tuple[str, str]]]:
|
|
366
|
+
if width <= 0 or len(chars_with_styles) <= width:
|
|
367
|
+
return [chars_with_styles]
|
|
368
|
+
return [chars_with_styles[i : i + width] for i in range(0, len(chars_with_styles), width)]
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def highlight_text(
|
|
372
|
+
text: str,
|
|
373
|
+
syntax: dict | None,
|
|
374
|
+
use_color: bool = True,
|
|
375
|
+
show_numbers: bool = False,
|
|
376
|
+
line_range: tuple[int | None, int | None] = (None, None),
|
|
377
|
+
number_color: str = "cyan",
|
|
378
|
+
git_markers: dict[int, str] | None = None,
|
|
379
|
+
wrap_width: int = 0,
|
|
380
|
+
) -> str:
|
|
381
|
+
if not text:
|
|
382
|
+
return ""
|
|
383
|
+
|
|
384
|
+
n = len(text)
|
|
385
|
+
styles = [""] * n
|
|
386
|
+
|
|
387
|
+
if use_color and syntax:
|
|
388
|
+
for rule_type, ansi, start_re, end_re in syntax["rules"]:
|
|
389
|
+
if rule_type == "single":
|
|
390
|
+
for m in start_re.finditer(text):
|
|
391
|
+
s, e = m.span()
|
|
392
|
+
for i in range(s, e):
|
|
393
|
+
styles[i] = ansi
|
|
394
|
+
elif rule_type == "block":
|
|
395
|
+
pos = 0
|
|
396
|
+
while pos < n:
|
|
397
|
+
m_start = start_re.search(text, pos)
|
|
398
|
+
if not m_start:
|
|
399
|
+
break
|
|
400
|
+
m_end = end_re.search(text, m_start.end())
|
|
401
|
+
end_pos = m_end.end() if m_end else n
|
|
402
|
+
for i in range(m_start.start(), end_pos):
|
|
403
|
+
styles[i] = ansi
|
|
404
|
+
pos = max(end_pos, m_start.end() + 1)
|
|
405
|
+
|
|
406
|
+
raw_lines = text.splitlines(keepends=True)
|
|
407
|
+
total_lines = len(raw_lines)
|
|
408
|
+
start_line, end_line = line_range
|
|
409
|
+
start_idx = max(0, (start_line - 1) if start_line is not None else 0)
|
|
410
|
+
end_idx = min(total_lines, end_line if end_line is not None else total_lines)
|
|
411
|
+
|
|
412
|
+
gutter_width = len(str(total_lines))
|
|
413
|
+
num_ansi = color_to_ansi(number_color) if use_color else ""
|
|
414
|
+
reset = "\033[0m"
|
|
415
|
+
git_colors = {"+": "\033[32m", "~": "\033[33m", "_": "\033[31m"}
|
|
416
|
+
|
|
417
|
+
has_git = git_markers is not None
|
|
418
|
+
gutter_cols = (gutter_width + 1 if show_numbers else 0) + (2 if has_git else 0)
|
|
419
|
+
avail_width = max(10, wrap_width - gutter_cols) if wrap_width > 0 else 0
|
|
420
|
+
|
|
421
|
+
out_lines = []
|
|
422
|
+
offset = 0
|
|
423
|
+
for line_no, raw_line in enumerate(raw_lines, start=1):
|
|
424
|
+
line_len = len(raw_line)
|
|
425
|
+
if start_idx <= line_no - 1 < end_idx:
|
|
426
|
+
has_newline = raw_line.endswith("\n")
|
|
427
|
+
body = raw_line[:-1] if has_newline else raw_line
|
|
428
|
+
styled_chars = [(ch, styles[offset + i] if use_color else "") for i, ch in enumerate(body)]
|
|
429
|
+
chunks = wrap_styled_chars(styled_chars, avail_width) if avail_width > 0 else [styled_chars]
|
|
430
|
+
|
|
431
|
+
for chunk_idx, chunk in enumerate(chunks):
|
|
432
|
+
prefix = ""
|
|
433
|
+
if show_numbers:
|
|
434
|
+
num_str = f"{line_no:>{gutter_width}} " if chunk_idx == 0 else " " * (gutter_width + 1)
|
|
435
|
+
prefix += f"{num_ansi}{num_str}{reset}" if num_ansi else num_str
|
|
436
|
+
if has_git:
|
|
437
|
+
mark = git_markers.get(line_no, " ") if chunk_idx == 0 else " "
|
|
438
|
+
prefix += f"{git_colors[mark]}{mark}{reset} " if (use_color and mark in git_colors) else f"{mark} "
|
|
439
|
+
|
|
440
|
+
line_buf = [prefix]
|
|
441
|
+
cur_style = ""
|
|
442
|
+
for ch, st in chunk:
|
|
443
|
+
if st != cur_style:
|
|
444
|
+
if cur_style:
|
|
445
|
+
line_buf.append(reset)
|
|
446
|
+
if st:
|
|
447
|
+
line_buf.append(st)
|
|
448
|
+
cur_style = st
|
|
449
|
+
line_buf.append(ch)
|
|
450
|
+
if cur_style:
|
|
451
|
+
line_buf.append(reset)
|
|
452
|
+
if has_newline or chunk_idx < len(chunks) - 1:
|
|
453
|
+
line_buf.append("\n")
|
|
454
|
+
out_lines.append("".join(line_buf))
|
|
455
|
+
offset += line_len
|
|
456
|
+
|
|
457
|
+
return "".join(out_lines)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def format_file_header(filepath: str, title_color: str, use_color: bool, width: int) -> str:
|
|
461
|
+
label = f" {filepath} "
|
|
462
|
+
padded = label.ljust(width)
|
|
463
|
+
if use_color:
|
|
464
|
+
ansi = color_to_ansi(title_color)
|
|
465
|
+
return f"{ansi}{padded}\033[0m\n" if ansi else f"=== {filepath} ===\n"
|
|
466
|
+
return f"=== {filepath} ===\n"
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def write_with_paging(output: str, paging_mode: str, use_color: bool) -> None:
|
|
470
|
+
is_tty = sys.stdout.isatty()
|
|
471
|
+
should_page = paging_mode == "always" or (
|
|
472
|
+
paging_mode == "auto" and is_tty and len(output.splitlines()) >= shutil.get_terminal_size((80, 24)).lines
|
|
473
|
+
)
|
|
474
|
+
if should_page and shutil.which("less"):
|
|
475
|
+
less_cmd = ["less", "-R"] if use_color else ["less"]
|
|
476
|
+
try:
|
|
477
|
+
proc = subprocess.Popen(less_cmd, stdin=subprocess.PIPE, text=True)
|
|
478
|
+
proc.communicate(input=output)
|
|
479
|
+
return
|
|
480
|
+
except OSError:
|
|
481
|
+
pass
|
|
482
|
+
sys.stdout.write(output)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def main() -> None:
|
|
486
|
+
parser = argparse.ArgumentParser(
|
|
487
|
+
description="Highlight files or stdin using GNU nano (.nanorc) syntax definitions."
|
|
488
|
+
)
|
|
489
|
+
parser.add_argument("files", nargs="*", help="Files to highlight (reads stdin if omitted)")
|
|
490
|
+
parser.add_argument("-l", "--lang", help="Language/syntax name (e.g., go, tsx, sh, python)")
|
|
491
|
+
parser.add_argument("-n", "--number", action="store_true", help="Show line numbers")
|
|
492
|
+
parser.add_argument("-g", "--git", action="store_true", help="Show git diff line markers (+, ~, _)")
|
|
493
|
+
parser.add_argument("-w", "--wrap", action="store_true", help="Soft-wrap long lines to terminal width")
|
|
494
|
+
parser.add_argument("-q", "--quiet", action="store_true", help="Suppress file headers when viewing multiple files")
|
|
495
|
+
parser.add_argument("-r", "--range", help="Line range to display (e.g., 10:40, 10:, :40)")
|
|
496
|
+
parser.add_argument("--theme", help="Path to a .nanorc theme file (sets numbercolor/titlecolor)")
|
|
497
|
+
parser.add_argument("--color", choices=["auto", "always", "never"], default="auto", help="When to use ANSI colors")
|
|
498
|
+
parser.add_argument("--paging", choices=["auto", "always", "never"], default="auto", help="When to page output")
|
|
499
|
+
parser.add_argument("--list", action="store_true", help="List all available syntax names")
|
|
500
|
+
parser.add_argument("-d", "--dir", action="append", help="Additional directory or .nanorc file to load")
|
|
501
|
+
args = parser.parse_args()
|
|
502
|
+
|
|
503
|
+
rcpaths = [os.path.expanduser("~/.nanorc")]
|
|
504
|
+
if args.theme:
|
|
505
|
+
rcpaths.append(os.path.expanduser(args.theme))
|
|
506
|
+
|
|
507
|
+
_, theme = parse_user_nanorc(rcpaths)
|
|
508
|
+
dirs = args.dir or [os.path.expanduser("~/.nano"), "/usr/share/nano"]
|
|
509
|
+
syntaxes = load_all_syntaxes(dirs, rcpaths=rcpaths if not args.dir else None)
|
|
510
|
+
|
|
511
|
+
if args.list:
|
|
512
|
+
seen = set()
|
|
513
|
+
for s in syntaxes:
|
|
514
|
+
key = (s["name"], os.path.basename(s["file"]))
|
|
515
|
+
if key not in seen:
|
|
516
|
+
seen.add(key)
|
|
517
|
+
print(f"{s['name']:<16} ({s['file']})")
|
|
518
|
+
return
|
|
519
|
+
|
|
520
|
+
line_range = (None, None)
|
|
521
|
+
if args.range:
|
|
522
|
+
try:
|
|
523
|
+
line_range = parse_line_range(args.range)
|
|
524
|
+
except ValueError:
|
|
525
|
+
parser.error(f"Invalid line range: {args.range}")
|
|
526
|
+
|
|
527
|
+
use_color = True if args.color == "always" else (False if args.color == "never" else sys.stdout.isatty())
|
|
528
|
+
term_cols = shutil.get_terminal_size((80, 24)).columns
|
|
529
|
+
wrap_width = term_cols if args.wrap else 0
|
|
530
|
+
|
|
531
|
+
chunks = []
|
|
532
|
+
targets = args.files if args.files else [None]
|
|
533
|
+
show_headers = len(targets) > 1 and not args.quiet
|
|
534
|
+
|
|
535
|
+
for filepath in targets:
|
|
536
|
+
if filepath:
|
|
537
|
+
with open(filepath, "r", encoding="utf-8", errors="replace") as f:
|
|
538
|
+
content = f.read()
|
|
539
|
+
else:
|
|
540
|
+
content = sys.stdin.read()
|
|
541
|
+
|
|
542
|
+
if show_headers and filepath:
|
|
543
|
+
chunks.append(format_file_header(filepath, theme["titlecolor"], use_color, term_cols))
|
|
544
|
+
|
|
545
|
+
git_markers = get_git_line_markers(filepath) if (args.git and filepath) else None
|
|
546
|
+
syntax = select_syntax(syntaxes, args.lang, filepath, content)
|
|
547
|
+
chunks.append(
|
|
548
|
+
highlight_text(
|
|
549
|
+
content, syntax, use_color=use_color, show_numbers=args.number,
|
|
550
|
+
line_range=line_range, number_color=theme["numbercolor"],
|
|
551
|
+
git_markers=git_markers, wrap_width=wrap_width,
|
|
552
|
+
)
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
write_with_paging("".join(chunks), args.paging, use_color)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
if __name__ == "__main__":
|
|
559
|
+
main()
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nanocat-cli
|
|
3
|
+
Version: 0.4.1
|
|
4
|
+
Summary: Syntax-highlight files or stdin using GNU nano (.nanorc) rules.
|
|
5
|
+
Author: Ali Mazaheri
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Ali Mazaheri
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://github.com
|
|
29
|
+
Project-URL: Issues, https://github.com/issues
|
|
30
|
+
Keywords: nano,nanorc,syntax-highlighting,cli,cat,bat
|
|
31
|
+
Classifier: Programming Language :: Python :: 3
|
|
32
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
33
|
+
Classifier: Operating System :: POSIX
|
|
34
|
+
Classifier: Environment :: Console
|
|
35
|
+
Requires-Python: >=3.10
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
License-File: LICENSE
|
|
38
|
+
Dynamic: license-file
|
|
39
|
+
|
|
40
|
+
# nanocat
|
|
41
|
+
|
|
42
|
+
Syntax-highlight files or piped `stdin` directly in your terminal using GNU nano's `.nanorc` syntax definitions—so your CLI output matches the exact colours you see inside `nano`.
|
|
43
|
+
|
|
44
|
+
Zero external dependencies (Python 3 standard library only).
|
|
45
|
+
|
|
46
|
+
## Features
|
|
47
|
+
|
|
48
|
+
- Evaluates `.nanorc` `color` and `icolor` rules (including multi-line `start=... end=...` blocks) in nano's exact top-to-bottom precedence order
|
|
49
|
+
- Reads `~/.nanorc` `include` directives and `numbercolor` / `titlecolor` theme settings (plus `--theme` overrides)
|
|
50
|
+
- Line numbers (`-n` / `--number`), Git diff gutter markers (`-g` / `--git`), and line-range slicing (`-r 10:40`)
|
|
51
|
+
- Soft-wrapping (`-w` / `--wrap`), multi-file headers, and auto-paging through `less -R` (`--paging=auto|always|never`)
|
|
52
|
+
|
|
53
|
+
## Installation
|
|
54
|
+
|
|
55
|
+
### Quick Install (One-Liner)
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
curl -fsSL https://githubusercontent.com | bash
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Via pipx or uv
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pipx install git+https://github.com
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Manual Install
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
git clone https://github.com
|
|
71
|
+
mkdir -p ~/.local/bin
|
|
72
|
+
ln -sf "\$(pwd)/nanocat/nanocat" ~/.local/bin/nanocat
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Usage
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
# Highlight one or more files directly (auto-detects extension or shebang)
|
|
79
|
+
nanocat path/to/file.tsx
|
|
80
|
+
nanocat -n -g src/App.tsx src/index.tsx
|
|
81
|
+
|
|
82
|
+
# Show line numbers, git diff markers, and slice lines 10 to 40
|
|
83
|
+
nanocat -n -g -r 10:40 path/to/file.go
|
|
84
|
+
|
|
85
|
+
# Highlight piped stdin using a specific language
|
|
86
|
+
grep -n "func " main.go | nanocat -l go
|
|
87
|
+
|
|
88
|
+
# List all detected .nanorc syntax definitions
|
|
89
|
+
nanocat --list
|
|
90
|
+
```
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
nanocat_cli-0.4.1.data/scripts/nanocat,sha256=qM2279IkosElfPO3kcUYz-7k2dSp5DYdCf2Ye2qumCI,20154
|
|
2
|
+
nanocat_cli-0.4.1.dist-info/licenses/LICENSE,sha256=7jgX0bVy9Mm7Ny_SxCmnKn8e-NrwIAb3CiD4JJuW3ZQ,1069
|
|
3
|
+
nanocat_cli-0.4.1.dist-info/METADATA,sha256=xUv_p0vW9Doae6OosUqHL179Xj_smy_LogCnvlFdFVQ,3322
|
|
4
|
+
nanocat_cli-0.4.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
5
|
+
nanocat_cli-0.4.1.dist-info/top_level.txt,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
6
|
+
nanocat_cli-0.4.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ali Mazaheri
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|