nanocat-cli 0.4.1__tar.gz

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,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,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,51 @@
1
+ # nanocat
2
+
3
+ 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`.
4
+
5
+ Zero external dependencies (Python 3 standard library only).
6
+
7
+ ## Features
8
+
9
+ - Evaluates `.nanorc` `color` and `icolor` rules (including multi-line `start=... end=...` blocks) in nano's exact top-to-bottom precedence order
10
+ - Reads `~/.nanorc` `include` directives and `numbercolor` / `titlecolor` theme settings (plus `--theme` overrides)
11
+ - Line numbers (`-n` / `--number`), Git diff gutter markers (`-g` / `--git`), and line-range slicing (`-r 10:40`)
12
+ - Soft-wrapping (`-w` / `--wrap`), multi-file headers, and auto-paging through `less -R` (`--paging=auto|always|never`)
13
+
14
+ ## Installation
15
+
16
+ ### Quick Install (One-Liner)
17
+
18
+ ```bash
19
+ curl -fsSL https://githubusercontent.com | bash
20
+ ```
21
+
22
+ ### Via pipx or uv
23
+
24
+ ```bash
25
+ pipx install git+https://github.com
26
+ ```
27
+
28
+ ### Manual Install
29
+
30
+ ```bash
31
+ git clone https://github.com
32
+ mkdir -p ~/.local/bin
33
+ ln -sf "\$(pwd)/nanocat/nanocat" ~/.local/bin/nanocat
34
+ ```
35
+
36
+ ## Usage
37
+
38
+ ```bash
39
+ # Highlight one or more files directly (auto-detects extension or shebang)
40
+ nanocat path/to/file.tsx
41
+ nanocat -n -g src/App.tsx src/index.tsx
42
+
43
+ # Show line numbers, git diff markers, and slice lines 10 to 40
44
+ nanocat -n -g -r 10:40 path/to/file.go
45
+
46
+ # Highlight piped stdin using a specific language
47
+ grep -n "func " main.go | nanocat -l go
48
+
49
+ # List all detected .nanorc syntax definitions
50
+ nanocat --list
51
+ ```
@@ -0,0 +1,559 @@
1
+ #!/usr/bin/env python3
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,9 @@
1
+ LICENSE
2
+ README.md
3
+ nanocat
4
+ pyproject.toml
5
+ nanocat_cli.egg-info/PKG-INFO
6
+ nanocat_cli.egg-info/SOURCES.txt
7
+ nanocat_cli.egg-info/dependency_links.txt
8
+ nanocat_cli.egg-info/top_level.txt
9
+ tests/test_nanocat.py
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["setuptools"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "nanocat-cli"
7
+ version = "0.4.1"
8
+ description = "Syntax-highlight files or stdin using GNU nano (.nanorc) rules."
9
+ readme = "README.md"
10
+ license = {file = "LICENSE"}
11
+ requires-python = ">=3.10"
12
+ authors = [{name = "Ali Mazaheri"}]
13
+ keywords = ["nano", "nanorc", "syntax-highlighting", "cli", "cat", "bat"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Operating System :: POSIX",
18
+ "Environment :: Console",
19
+ ]
20
+
21
+ [project.urls]
22
+ Homepage = "https://github.com"
23
+ Issues = "https://github.com/issues"
24
+
25
+ [tool.setuptools]
26
+ script-files = ["nanocat"]
27
+ py-modules = []
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,79 @@
1
+ import importlib.machinery
2
+ import importlib.util
3
+ import os
4
+ import tempfile
5
+ import unittest
6
+
7
+ SCRIPT_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "nanocat"))
8
+ loader = importlib.machinery.SourceFileLoader("nanocat_mod", SCRIPT_PATH)
9
+ spec = importlib.util.spec_from_loader("nanocat_mod", loader)
10
+ nanocat = importlib.util.module_from_spec(spec)
11
+ loader.exec_module(nanocat)
12
+
13
+
14
+ class TestNanocat(unittest.TestCase):
15
+ def test_posix_to_python_regex(self):
16
+ converted = nanocat.posix_to_python_regex(r"\<func\>[[:space:]]+[[:digit:]]+")
17
+ self.assertEqual(converted, r"\bfunc\b\s+[0-9]+")
18
+
19
+ def test_color_to_ansi(self):
20
+ self.assertEqual(nanocat.color_to_ansi("bold,brightwhite,blue"), "\033[1;97;44m")
21
+ self.assertEqual(nanocat.color_to_ansi("orange"), "\033[38;5;208m")
22
+
23
+ def test_parse_and_highlight(self):
24
+ sample_rc = '''
25
+ syntax "demo" "\\.demo$"
26
+ color green "\\<(func|return)\\>"
27
+ color brightblue start="/\\*" end="\\*/"
28
+ '''
29
+ with tempfile.NamedTemporaryFile("w", suffix=".nanorc", delete=False) as tf:
30
+ tf.write(sample_rc)
31
+ rc_path = tf.name
32
+
33
+ try:
34
+ syntaxes = nanocat.parse_nanorc_file(rc_path)
35
+ self.assertEqual(len(syntaxes), 1)
36
+ code = "func main() { /* func */ return }"
37
+ out = nanocat.highlight_text(code, syntaxes[0], use_color=True)
38
+ self.assertIn("\033[32mfunc\033[0m", out)
39
+ self.assertIn("\033[94m/* func */\033[0m", out)
40
+ finally:
41
+ os.remove(rc_path)
42
+
43
+ def test_line_numbers_range_and_git_markers(self):
44
+ text = "line1\nline2\nline3\nline4\n"
45
+ out = nanocat.highlight_text(
46
+ text,
47
+ syntax=None,
48
+ use_color=False,
49
+ show_numbers=True,
50
+ line_range=(2, 3),
51
+ git_markers={2: "+", 3: "~"},
52
+ )
53
+ self.assertEqual(out, "2 + line2\n3 ~ line3\n")
54
+
55
+ def test_soft_wrap(self):
56
+ text = "abcdefghijklmno\n"
57
+ out = nanocat.highlight_text(
58
+ text,
59
+ syntax=None,
60
+ use_color=False,
61
+ show_numbers=True,
62
+ wrap_width=8,
63
+ )
64
+ lines = out.splitlines()
65
+ self.assertEqual(len(lines), 2)
66
+ self.assertTrue(lines[0].startswith("1 "))
67
+ self.assertTrue(lines[1].startswith(" "))
68
+
69
+
70
+ if __name__ == "__main__":
71
+ unittest.main()
72
+
73
+
74
+ class TestAliases(unittest.TestCase):
75
+ def test_lang_aliases(self):
76
+ syntaxes = [{"name": "Python", "file": "/tmp/python.nanorc", "ext_patterns": [], "header_patterns": [], "raw_rules": [], "rules": []}]
77
+ matched = nanocat.select_syntax(syntaxes, "py", None, "")
78
+ self.assertIsNotNone(matched)
79
+ self.assertEqual(matched["name"], "Python")