vim2bat 0.1.0__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.
vim2bat/__init__.py ADDED
File without changes
vim2bat/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from vim2bat.cli import main
4
+
5
+ sys.exit(main())
vim2bat/cli.py ADDED
@@ -0,0 +1,761 @@
1
+ """Builds bat themes from Vim colorschemes.
2
+
3
+ vim2bat runs Vim with your vimrc, records the colors and attributes a
4
+ colorscheme gives each highlight group (Table 1), and writes bat themes that
5
+ map TextMate scopes to them: share/strict.tsv (Table 2) for Vim's standard
6
+ groups, and share/loose.tsv (Table 3) for language-specific groups. vim2bat
7
+ compare measures a theme against Vim with share/vim-slots.vim.
8
+ """
9
+
10
+ import argparse
11
+ import fcntl
12
+ import os
13
+ import plistlib
14
+ import re
15
+ import select
16
+ import shlex
17
+ import shutil
18
+ import signal
19
+ import subprocess
20
+ import sys
21
+ import tempfile
22
+ import threading
23
+ from collections import Counter
24
+ from collections.abc import Iterator, Sequence
25
+ from concurrent.futures import ThreadPoolExecutor, as_completed
26
+ from contextlib import contextmanager, suppress
27
+ from importlib.metadata import version
28
+ from pathlib import Path
29
+ from types import FrameType
30
+ from typing import IO, NamedTuple, NoReturn, NotRequired, TypedDict
31
+ from xml.parsers.expat import ExpatError
32
+
33
+ SHARE = Path(__file__).with_name("share")
34
+ BACKGROUNDS = ("dark", "light")
35
+ # A scheme that resets 'background' to Vim's guess, such as default, gets the
36
+ # guess from the background color in COLORFGBG.
37
+ COLORFGBG = {"dark": "15;0", "light": "0;15"}
38
+ # bat themes support only these attributes.
39
+ ATTRIBUTES = ("bold", "italic", "underline")
40
+ SGR = re.compile(r"\x1b\[([0-9;]*)m")
41
+
42
+
43
+ class Directories(NamedTuple):
44
+ themes: Path
45
+ cache: Path
46
+
47
+
48
+ class Rule(TypedDict):
49
+ # A theme's first rule, which holds its global colors, has no name or scope.
50
+ name: NotRequired[str]
51
+ scope: NotRequired[str]
52
+ settings: dict[str, str]
53
+
54
+
55
+ class Theme(TypedDict):
56
+ name: str
57
+ comment: str
58
+ settings: list[Rule]
59
+
60
+
61
+ class Failure(Exception):
62
+ """Stops vim2bat with exit status 1, after printing the message if any."""
63
+
64
+
65
+ class Stop(BaseException):
66
+ """Raised by a signal handler, so that vim2bat can stop Vim before exiting."""
67
+
68
+ def __init__(self, signum: int) -> None:
69
+ super().__init__(signum)
70
+ self.signum = signum
71
+
72
+
73
+ # Vim processes still running, which vim2bat kills when it is stopped.
74
+ _running: set[subprocess.Popen[bytes]] = set()
75
+ _running_lock = threading.Lock()
76
+ _stopping = threading.Event()
77
+
78
+
79
+ @contextmanager
80
+ def vim_error_file() -> Iterator[IO[bytes]]:
81
+ """Yields a temporary file to give Vim as its stderr."""
82
+ with tempfile.TemporaryFile() as errors:
83
+ # Vim writes some messages, such as a vimrc's errors, to its stderr
84
+ # directly. On Linux, the Vim scripts' writes through /dev/stderr keep
85
+ # their own offset, so without O_APPEND each would overwrite the other.
86
+ flags = fcntl.fcntl(errors, fcntl.F_GETFL)
87
+ fcntl.fcntl(errors, fcntl.F_SETFL, flags | os.O_APPEND)
88
+ yield errors
89
+
90
+
91
+ def run_vim(
92
+ arguments: list[str], environment: dict[str, str], stderr: IO[bytes] | None = None
93
+ ) -> int:
94
+ """Runs Vim with your vimrc on a pseudo-terminal and returns its status.
95
+
96
+ Vim without a terminal numbers named cterm colors (such as DarkBlue)
97
+ differently from a terminal, so it gets one, as xterm-256color. What Vim
98
+ draws there is discarded, including most error messages, so the Vim
99
+ scripts write their errors to stderr.
100
+ """
101
+ if stderr is None:
102
+ # On Linux, /dev/stderr opens a redirected stderr's file anew, so
103
+ # vim2bat's own writes to a file opened with 2> would overwrite Vim's.
104
+ with vim_error_file() as errors:
105
+ try:
106
+ return run_vim(arguments, environment, errors)
107
+ finally:
108
+ errors.seek(0)
109
+ sys.stderr.write(errors.read().decode("utf-8", "replace"))
110
+ if _stopping.is_set():
111
+ raise Failure("")
112
+ controller, terminal = os.openpty()
113
+ # -n skips the swap file, whose warning for a file open in another Vim
114
+ # would count as an error. nomore keeps Vim from pausing at -- More --
115
+ # after a screenful of messages, which the Enter below would page through
116
+ # one line at a time.
117
+ command = [
118
+ "vim",
119
+ "-N",
120
+ "-i",
121
+ "NONE",
122
+ "-n",
123
+ "--cmd",
124
+ "set nomore",
125
+ *arguments,
126
+ "-c",
127
+ "qa!",
128
+ ]
129
+ # Its own session keeps Ctrl-C from interrupting Vim's script, which would
130
+ # leave Vim waiting for input instead of reaching qa!. vim2bat stops it
131
+ # instead.
132
+ try:
133
+ process = subprocess.Popen(
134
+ command,
135
+ stdin=terminal,
136
+ stdout=terminal,
137
+ stderr=stderr,
138
+ env={**os.environ, "TERM": "xterm-256color", **environment},
139
+ start_new_session=True,
140
+ )
141
+ except BaseException:
142
+ os.close(controller)
143
+ raise
144
+ finally:
145
+ os.close(terminal)
146
+ with _running_lock:
147
+ _running.add(process)
148
+ if _stopping.is_set():
149
+ process.kill()
150
+ try:
151
+ # A command the vimrc started in the background with :! can hold the
152
+ # terminal open after Vim exits.
153
+ while process.poll() is None:
154
+ if not select.select([controller], [], [], 0.5)[0]:
155
+ # Vim waits for a key at a prompt, such as the one after an
156
+ # error message, so an idle half second gets an Enter. If Vim
157
+ # was busy instead, it never reads the Enter, because it quits
158
+ # at the -c 'qa!' that ends its arguments.
159
+ try:
160
+ os.write(controller, b"\r")
161
+ except OSError:
162
+ break
163
+ continue
164
+ try:
165
+ if not os.read(controller, 65536):
166
+ break
167
+ # Some systems report the closed terminal as an error rather than
168
+ # end of file.
169
+ except OSError:
170
+ break
171
+ return process.wait()
172
+ except BaseException:
173
+ process.kill()
174
+ raise
175
+ finally:
176
+ os.close(controller)
177
+ with _running_lock:
178
+ _running.discard(process)
179
+
180
+
181
+ def stop_vims() -> None:
182
+ with _running_lock:
183
+ _stopping.set()
184
+ for process in _running:
185
+ process.kill()
186
+
187
+
188
+ def bat_config_dir() -> Path:
189
+ result = subprocess.run(
190
+ ["bat", "--config-dir"], capture_output=True, text=True, check=True
191
+ )
192
+ folder = result.stdout.strip()
193
+ if not os.path.isabs(folder):
194
+ raise Failure(
195
+ f"bat's config folder {folder!r} isn't an absolute path; check "
196
+ "BAT_CONFIG_DIR"
197
+ )
198
+ return Path(folder)
199
+
200
+
201
+ def cache_dir() -> Path:
202
+ # The XDG Base Directory spec says to ignore a relative path.
203
+ base = os.environ.get("XDG_CACHE_HOME", "")
204
+ return (Path(base) if os.path.isabs(base) else Path.home() / ".cache") / "vim2bat"
205
+
206
+
207
+ def ask_vim(expression: str) -> list[str]:
208
+ """Returns the lines of a list or string that Vim evaluates with your vimrc."""
209
+ with tempfile.TemporaryDirectory() as directory:
210
+ out = Path(directory) / "out"
211
+ status = run_vim(
212
+ ["-c", f"call writefile(flatten([{expression}]), $OUTFILE)"],
213
+ {"OUTFILE": str(out)},
214
+ )
215
+ if status != 0 or not out.exists():
216
+ raise Failure(f"Vim couldn't evaluate {expression}")
217
+ return out.read_text(encoding="utf-8").splitlines()
218
+
219
+
220
+ def color(value: str) -> str:
221
+ """Returns a Table 1 color as a bat theme color.
222
+
223
+ bat reads a color whose alpha is 00 as a palette slot number, and one whose
224
+ alpha is 01 as the terminal's default. Table 1 holds #rrggbb colors under
225
+ 'termguicolors'.
226
+ """
227
+ if not value:
228
+ return "#00000001"
229
+ if value.startswith("#"):
230
+ return value
231
+ return f"#{int(value):02X}000000"
232
+
233
+
234
+ def read_table(path: Path) -> dict[str, tuple[str, str, str]]:
235
+ """Returns Table 1 as {group: (foreground, background, font style)}."""
236
+ groups = {}
237
+ for line in path.read_text(encoding="utf-8").splitlines():
238
+ group, foreground, background, attributes = line.split("\t")
239
+ style = " ".join(a for a in attributes.split(",") if a in ATTRIBUTES)
240
+ groups[group] = (foreground, background, style)
241
+ return groups
242
+
243
+
244
+ def read_rules(strict: bool) -> list[tuple[str, str, bool]]:
245
+ """Returns (group, scopes, loose) for Table 2 and, unless strict, Table 3."""
246
+ rows = [(group, scopes, False) for group, scopes in tsv(SHARE / "strict.tsv", 2)]
247
+ if not strict:
248
+ rows += [
249
+ (group, scopes, True)
250
+ for _filetype, group, scopes in tsv(SHARE / "loose.tsv", 3)
251
+ ]
252
+ return rows
253
+
254
+
255
+ def tsv(path: Path, fields: int) -> list[list[str]]:
256
+ return [
257
+ line.split("\t", fields - 1)
258
+ for line in path.read_text(encoding="utf-8").splitlines()
259
+ if line
260
+ ]
261
+
262
+
263
+ def rule(
264
+ groups: dict[str, tuple[str, str, str]], group: str, scopes: str, loose: bool
265
+ ) -> Rule:
266
+ foreground, _background, style = groups[group]
267
+ # bat keeps the color of a less specific rule that also matches when this
268
+ # one has no foreground, so a loose rule always writes one, taking Normal's
269
+ # when its group has none.
270
+ if loose and not foreground:
271
+ foreground = groups["Normal"][0]
272
+ settings = {}
273
+ if foreground or loose:
274
+ settings["foreground"] = color(foreground)
275
+ # Written even when empty, so that a rule nested inside another doesn't
276
+ # inherit the outer rule's bold or italic.
277
+ settings["fontStyle"] = style
278
+ return {"name": group, "scope": scopes, "settings": settings}
279
+
280
+
281
+ def theme(
282
+ name: str,
283
+ comment: str,
284
+ groups: dict[str, tuple[str, str, str]],
285
+ rules: list[tuple[str, str, bool]],
286
+ ) -> Theme:
287
+ globals_ = {
288
+ "foreground": color(groups["Normal"][0]),
289
+ # bat's --highlight-line, which fzf.vim's preview uses to mark the
290
+ # matched line, draws with lineHighlight.
291
+ "lineHighlight": color(groups["CursorLine"][1]),
292
+ }
293
+ # A group from a newer Vim can't color anything in this one, so it gets no
294
+ # rule.
295
+ scoped = [rule(groups, *row) for row in rules if row[0] in groups]
296
+ return {
297
+ "name": name,
298
+ # vim2bat compare reads the options a theme was built with from here.
299
+ "comment": comment,
300
+ "settings": [{"settings": globals_}, *scoped],
301
+ }
302
+
303
+
304
+ def theme_names(scheme: str, backgrounds: Sequence[str]) -> dict[str, str]:
305
+ """Names the themes: the background is part of the name only when the
306
+ scheme has both."""
307
+ if len(backgrounds) > 1:
308
+ return {background: f"vim-{scheme}-{background}" for background in backgrounds}
309
+ return {background: f"vim-{scheme}" for background in backgrounds}
310
+
311
+
312
+ def build_comment(scheme: str, options: argparse.Namespace) -> str:
313
+ words = ["vim2bat"]
314
+ if options.strict:
315
+ words.append("-s")
316
+ if options.truecolor:
317
+ words.append("-t")
318
+ if options.palette:
319
+ words += ["-p", options.palette]
320
+ if scheme.startswith("-"):
321
+ words.append("--")
322
+ return shlex.join([*words, scheme])
323
+
324
+
325
+ def vim_environment(
326
+ scheme: str, background: str, options: argparse.Namespace
327
+ ) -> dict[str, str]:
328
+ return {
329
+ "SCHEME": scheme,
330
+ "BACKGROUND": background,
331
+ "COLORFGBG": COLORFGBG[background],
332
+ "T_CO": options.palette or "",
333
+ "TERMGUICOLORS": "1" if options.truecolor else "",
334
+ }
335
+
336
+
337
+ def build(
338
+ scheme: str,
339
+ options: argparse.Namespace,
340
+ directories: Directories,
341
+ stderr: IO[bytes] | None = None,
342
+ ) -> None:
343
+ """Writes the themes for one scheme."""
344
+ tables = directories.cache / "tables" / scheme
345
+ tables.mkdir(parents=True, exist_ok=True)
346
+ found = []
347
+ for background in BACKGROUNDS:
348
+ table = tables / f"vim-groups-{background}.tsv"
349
+ environment = {
350
+ **vim_environment(scheme, background, options),
351
+ "OUTFILE": str(table),
352
+ }
353
+ table.unlink(missing_ok=True)
354
+ # vim-groups.vim prints why it failed.
355
+ status = run_vim(["-S", str(SHARE / "vim-groups.vim")], environment, stderr)
356
+ if status != 0:
357
+ raise Failure(f"Vim exited with status {status}")
358
+ if not table.exists():
359
+ raise Failure(f"Vim wrote no table for {scheme}")
360
+ # vim-groups.vim writes an empty table for a background the scheme lacks.
361
+ if table.stat().st_size:
362
+ found.append(background)
363
+ rules = read_rules(options.strict)
364
+ comment = build_comment(scheme, options)
365
+ directories.themes.mkdir(parents=True, exist_ok=True)
366
+ for background, name in theme_names(scheme, found).items():
367
+ groups = read_table(tables / f"vim-groups-{background}.tsv")
368
+ # Two schemes can build the same name in parallel, such as one with both
369
+ # backgrounds and another with its name plus -dark, so each write gets
370
+ # its own temporary file.
371
+ descriptor, temporary = tempfile.mkstemp(dir=directories.themes, suffix=".tmp")
372
+ try:
373
+ with os.fdopen(descriptor, "wb") as f:
374
+ plistlib.dump(theme(name, comment, groups, rules), f, sort_keys=False)
375
+ os.chmod(temporary, 0o644)
376
+ os.replace(temporary, directories.themes / f"{name}.tmTheme")
377
+ except BaseException:
378
+ Path(temporary).unlink(missing_ok=True)
379
+ raise
380
+
381
+
382
+ def build_quietly(
383
+ scheme: str, options: argparse.Namespace, directories: Directories
384
+ ) -> tuple[bool, str]:
385
+ """Builds one scheme for --all and returns (succeeded, its error output)."""
386
+ with vim_error_file() as errors:
387
+ message = ""
388
+ try:
389
+ build(scheme, options, directories, errors)
390
+ succeeded = True
391
+ except Failure as failure:
392
+ succeeded = False
393
+ if str(failure):
394
+ message = f"vim2bat: {failure}\n"
395
+ errors.seek(0)
396
+ return succeeded, errors.read().decode("utf-8", "replace") + message
397
+
398
+
399
+ def build_all(options: argparse.Namespace, directories: Directories) -> list[str]:
400
+ """Builds every scheme Vim lists, one per CPU at a time, and returns the
401
+ ones that failed."""
402
+ schemes = ask_vim('getcompletion("", "color")')
403
+ failed = []
404
+ with ThreadPoolExecutor(os.cpu_count() or 1) as pool:
405
+ try:
406
+ futures = {
407
+ pool.submit(build_quietly, scheme, options, directories): scheme
408
+ for scheme in schemes
409
+ }
410
+ for count, future in enumerate(as_completed(futures), 1):
411
+ scheme = futures[future]
412
+ succeeded, errors = future.result()
413
+ # Each scheme's errors follow its progress line, so that errors
414
+ # from schemes building at the same time don't interleave.
415
+ print(f"vim2bat: [{count}/{len(schemes)}] {scheme}", file=sys.stderr)
416
+ sys.stderr.write(errors)
417
+ if not succeeded:
418
+ failed.append(scheme)
419
+ except BaseException:
420
+ pool.shutdown(wait=False, cancel_futures=True)
421
+ stop_vims()
422
+ raise
423
+ return failed
424
+
425
+
426
+ def build_cache() -> None:
427
+ # bat reads cache as a file name instead of its subcommand when the current
428
+ # folder has an entry named cache.
429
+ with tempfile.TemporaryDirectory() as empty:
430
+ result = subprocess.run(
431
+ ["bat", "cache", "--build"],
432
+ cwd=empty,
433
+ capture_output=True,
434
+ text=True,
435
+ check=True,
436
+ )
437
+ # bat reports a theme it can't parse on stdout and still exits 0, so only
438
+ # the lines a clean build prints are dropped.
439
+ for line in result.stdout.splitlines():
440
+ if not line.endswith(" ... okay") and not line.startswith(
441
+ "No syntaxes were found"
442
+ ):
443
+ print(line, file=sys.stderr)
444
+ sys.stderr.write(result.stderr)
445
+
446
+
447
+ def generate_parser(
448
+ parser_class: type[argparse.ArgumentParser] = argparse.ArgumentParser,
449
+ ) -> argparse.ArgumentParser:
450
+ parser = parser_class(
451
+ prog="vim2bat",
452
+ description=(
453
+ "Builds bat themes from a Vim colorscheme, as Vim shows it with your "
454
+ "vimrc in a 256-color terminal. A scheme with both backgrounds gets "
455
+ "vim-SCHEME-dark and vim-SCHEME-light; one with a single background "
456
+ "gets vim-SCHEME. Then it rebuilds bat's cache."
457
+ ),
458
+ epilog=(
459
+ "To measure a theme against Vim, run vim2bat compare THEME FILE... "
460
+ "(see vim2bat compare --help). To build a scheme named compare, run "
461
+ "vim2bat -- compare."
462
+ ),
463
+ )
464
+ target = parser.add_mutually_exclusive_group(required=True)
465
+ target.add_argument("scheme", nargs="?", help="the colorscheme to build")
466
+ target.add_argument(
467
+ "-a", "--all", action="store_true", help="build every colorscheme Vim lists"
468
+ )
469
+ parser.add_argument(
470
+ "-c",
471
+ "--clean",
472
+ action="store_true",
473
+ help="first delete vim2bat's cache and the whole bat themes folder",
474
+ )
475
+ parser.add_argument(
476
+ "-s",
477
+ "--strict",
478
+ action="store_true",
479
+ help="map only Vim's standard groups (strict.tsv)",
480
+ )
481
+ parser.add_argument(
482
+ "-t",
483
+ "--truecolor",
484
+ action="store_true",
485
+ help="turn on 'termguicolors' to use the scheme's 24-bit colors",
486
+ )
487
+ parser.add_argument(
488
+ "-p",
489
+ "--palette",
490
+ choices=("16", "256"),
491
+ help="override your vimrc's 't_Co' with 16 or 256 colors",
492
+ )
493
+ parser.add_argument(
494
+ "--version", action="version", version=f"%(prog)s {version('vim2bat')}"
495
+ )
496
+ return parser
497
+
498
+
499
+ def generate(argv: list[str]) -> None:
500
+ parser = generate_parser()
501
+ options = parser.parse_args(argv)
502
+ # vim-groups.vim passes the name to :colorscheme, which reads a blank name,
503
+ # or one that starts with | or ", as no name and keeps the vimrc's scheme.
504
+ if options.scheme is not None and re.match(r'\s*($|["|])', options.scheme):
505
+ parser.error("the scheme name is empty")
506
+ # The scheme name becomes part of a theme's file name.
507
+ if "/" in (options.scheme or ""):
508
+ parser.error("a scheme name can't contain /")
509
+ directories = Directories(bat_config_dir() / "themes", cache_dir())
510
+ if options.clean:
511
+ folders = (directories.cache, directories.themes)
512
+ for folder in folders:
513
+ if folder.is_symlink():
514
+ raise Failure(
515
+ f"{folder} is a symbolic link; -c deletes only a real folder"
516
+ )
517
+ for folder in folders:
518
+ with suppress(FileNotFoundError):
519
+ shutil.rmtree(folder)
520
+ if options.all:
521
+ failed = build_all(options, directories)
522
+ # This runs even when some schemes failed, so that the rest are usable.
523
+ build_cache()
524
+ if failed:
525
+ raise Failure(f"failed: {' '.join(failed)}")
526
+ return
527
+ build(options.scheme, options, directories)
528
+ build_cache()
529
+
530
+
531
+ def compare_parser() -> argparse.ArgumentParser:
532
+ parser = argparse.ArgumentParser(
533
+ prog="vim2bat compare",
534
+ description=(
535
+ "Reports how often a bat theme that vim2bat built shows each character "
536
+ "of FILE in the same color as Vim does, with the options the theme was "
537
+ "built with. To measure strict.tsv alone, build the theme with -s first, "
538
+ "since both builds use the same name."
539
+ ),
540
+ )
541
+ parser.add_argument("theme", metavar="THEME")
542
+ parser.add_argument("files", metavar="FILE", nargs="+")
543
+ return parser
544
+
545
+
546
+ def bat_colors(text: str) -> dict[tuple[int, int], str | None]:
547
+ """Returns {(line, index): color} for each character of bat's output."""
548
+ colors = {}
549
+ for lnum, line in enumerate(text.split("\n"), 1):
550
+ current, idx = None, 0
551
+ for k, part in enumerate(SGR.split(line)):
552
+ if k % 2:
553
+ current = apply(current, part)
554
+ continue
555
+ for _ in part:
556
+ colors[(lnum, idx)] = current
557
+ idx += 1
558
+ return colors
559
+
560
+
561
+ def apply(current: str | None, params: str) -> str | None:
562
+ """Returns the foreground color after one SGR sequence."""
563
+ codes = params.split(";") if params else ["0"]
564
+ k = 0
565
+ while k < len(codes):
566
+ code = codes[k]
567
+ if code == "38" and codes[k + 1] == "2":
568
+ current = "#" + "".join(f"{int(c):02x}" for c in codes[k + 2 : k + 5])
569
+ k += 5
570
+ continue
571
+ if code == "38":
572
+ current = codes[k + 2]
573
+ k += 3
574
+ continue
575
+ if code in ("0", "39"):
576
+ current = None
577
+ elif code.isdigit() and 30 <= int(code) <= 37:
578
+ current = str(int(code) - 30)
579
+ k += 1
580
+ return current
581
+
582
+
583
+ class CommentParser(argparse.ArgumentParser):
584
+ def error(self, message: str) -> NoReturn:
585
+ raise ValueError(message)
586
+
587
+
588
+ def built_with(theme_name: str, comment: str) -> argparse.Namespace:
589
+ """Returns the options in a theme's comment, if vim2bat built the theme
590
+ under its own name."""
591
+ options: argparse.Namespace | None
592
+ try:
593
+ words = shlex.split(comment)
594
+ if words[:1] != ["vim2bat"]:
595
+ raise ValueError
596
+ options = generate_parser(CommentParser).parse_args(words[1:])
597
+ except ValueError:
598
+ options = None
599
+ if (
600
+ options is None
601
+ or options.scheme is None
602
+ or theme_name
603
+ not in (
604
+ f"vim-{options.scheme}",
605
+ *theme_names(options.scheme, BACKGROUNDS).values(),
606
+ )
607
+ ):
608
+ raise Failure(f"{theme_name} wasn't built by vim2bat under that name")
609
+ return options
610
+
611
+
612
+ def compare(argv: list[str]) -> None:
613
+ args = compare_parser().parse_args(argv)
614
+ # bat falls back to its default theme for an unknown name instead of failing.
615
+ listed = subprocess.run(
616
+ ["bat", "--list-themes", "--color=never"],
617
+ capture_output=True,
618
+ text=True,
619
+ check=True,
620
+ ).stdout.splitlines()
621
+ if args.theme not in listed:
622
+ raise Failure(f"no bat theme named {args.theme}")
623
+ path = bat_config_dir() / "themes" / f"{args.theme}.tmTheme"
624
+ if not path.exists():
625
+ raise Failure(
626
+ f"{path} doesn't exist; vim2bat compare measures only themes that "
627
+ "vim2bat built"
628
+ )
629
+ try:
630
+ data = plistlib.loads(path.read_bytes())
631
+ except (ValueError, ExpatError) as error:
632
+ raise Failure(f"can't read {path}: {error}") from None
633
+ comment = data.get("comment") if isinstance(data, dict) else None
634
+ options = built_with(args.theme, comment if isinstance(comment, str) else "")
635
+ scheme = options.scheme
636
+ suffix = args.theme[len(f"vim-{scheme}") :]
637
+ # A scheme with one background sets 'background' itself.
638
+ background = suffix[1:] if suffix else "dark"
639
+ totals: Counter[str] = Counter()
640
+ agrees: Counter[str] = Counter()
641
+ mismatches: Counter[tuple[str, str, str, str | None]] = Counter()
642
+ with tempfile.TemporaryDirectory() as directory:
643
+ for index, name in enumerate(args.files):
644
+ out = Path(directory) / f"{index}.tsv"
645
+ environment = {
646
+ **vim_environment(scheme, background, options),
647
+ "TARGET": os.path.join(os.getcwd(), name),
648
+ "OUTFILE": str(out),
649
+ }
650
+ # The backtracking regex engine, which macOS's Vim uses by default,
651
+ # stalls on the TypeScript syntax. Other syntaxes keep the default
652
+ # because the NFA engine highlights some of them, such as ERB,
653
+ # differently. The autocmd gets its own group, which a bare
654
+ # autocmd! in your vimrc doesn't clear.
655
+ status = run_vim(
656
+ [
657
+ "--cmd",
658
+ "augroup vim2bat | augroup END",
659
+ "--cmd",
660
+ (
661
+ "autocmd vim2bat FileType typescript,typescriptreact "
662
+ "set regexpengine=2"
663
+ ),
664
+ "-S",
665
+ str(SHARE / "vim-slots.vim"),
666
+ ],
667
+ environment,
668
+ )
669
+ if status != 0:
670
+ print(f"vim2bat: Vim reported an error on {name}", file=sys.stderr)
671
+ # vim-slots.vim writes nothing when the scheme fails to load.
672
+ if not out.exists():
673
+ raise Failure("")
674
+ # Without COLORTERM, bat rounds #rrggbb colors to the nearest of 256.
675
+ bat = subprocess.run(
676
+ [
677
+ "bat",
678
+ f"--theme={args.theme}",
679
+ "--color=always",
680
+ "--style=plain",
681
+ "--tabs=0",
682
+ "--wrap=never",
683
+ "--",
684
+ name,
685
+ ],
686
+ capture_output=True,
687
+ check=True,
688
+ env={**os.environ, "COLORTERM": "truecolor"},
689
+ )
690
+ got = bat_colors(bat.stdout.decode("utf-8"))
691
+ lines = out.read_text(encoding="utf-8").splitlines()
692
+ lang = lines[0].removeprefix("#ft=") if lines else ""
693
+ total = agree = 0
694
+ for row in lines[1:]:
695
+ lnum, idx, vim, chain = row.split("\t")
696
+ bat_color = got.get((int(lnum), int(idx)))
697
+ total += 1
698
+ # Vim has no color for text in a Normal without a foreground,
699
+ # which bat draws in the terminal's default.
700
+ if bat_color == (vim or None):
701
+ agree += 1
702
+ else:
703
+ mismatches[(lang, chain or "(no syntax item)", vim, bat_color)] += 1
704
+ print(f"{100 * agree / max(total, 1):5.1f}% {total:6} {lang:16} {name}")
705
+ for key in (lang, "all"):
706
+ totals[key] += total
707
+ agrees[key] += agree
708
+ print()
709
+ for key in sorted(totals, key=lambda key: key == "all"):
710
+ print(
711
+ f"{100 * agrees[key] / max(totals[key], 1):5.1f}% {totals[key]:6} {key}"
712
+ )
713
+ print("\nchars language Vim syntax item>links Vim color bat color")
714
+ for (lang, chain, vim, bat_color), n in mismatches.most_common(30):
715
+ print(
716
+ f"{n:5} {lang} {chain} {vim or 'default'} "
717
+ f"{'default' if bat_color is None else bat_color}"
718
+ )
719
+
720
+
721
+ def stop(signum: int, _frame: FrameType | None) -> NoReturn:
722
+ raise Stop(signum)
723
+
724
+
725
+ def main(argv: list[str] | None = None) -> int:
726
+ argv = sys.argv[1:] if argv is None else argv
727
+ for signum in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
728
+ signal.signal(signum, stop)
729
+ try:
730
+ if argv[:1] == ["compare"]:
731
+ compare(argv[1:])
732
+ else:
733
+ generate(argv)
734
+ except Stop as stopped:
735
+ stop_vims()
736
+ # A shell running vim2bat in a loop stops the loop on Ctrl-C only when
737
+ # vim2bat dies by the signal, not when it exits with 130. Dying skips the
738
+ # flush of stdout and stderr that exiting does.
739
+ sys.stdout.flush()
740
+ sys.stderr.flush()
741
+ signal.signal(stopped.signum, signal.SIG_DFL)
742
+ os.kill(os.getpid(), stopped.signum)
743
+ return 128 + stopped.signum
744
+ except Failure as failure:
745
+ if str(failure):
746
+ print(f"vim2bat: {failure}", file=sys.stderr)
747
+ return 1
748
+ except FileNotFoundError as error:
749
+ print(f"vim2bat: {error.filename} not found", file=sys.stderr)
750
+ return 1
751
+ except OSError as error:
752
+ print(f"vim2bat: {error}", file=sys.stderr)
753
+ return 1
754
+ except subprocess.CalledProcessError as error:
755
+ print(f"vim2bat: {shlex.join(error.cmd)} failed", file=sys.stderr)
756
+ reason = (
757
+ error.stderr.decode() if isinstance(error.stderr, bytes) else error.stderr
758
+ )
759
+ sys.stderr.write(SGR.sub("", reason or ""))
760
+ return 1
761
+ return 0
@@ -0,0 +1,84 @@
1
+ ruby Normal punctuation.accessor.dot.ruby, punctuation.accessor.double-colon.ruby, punctuation.definition.group.begin.ruby, punctuation.definition.group.end.ruby, punctuation.section.array.ruby, punctuation.section.scope.ruby, punctuation.separator.sequence.ruby, punctuation.separator.ruby, punctuation.separator.key-value.ruby, punctuation.separator.inheritance.ruby, punctuation.definition.parameters.begin.ruby, punctuation.definition.parameters.end.ruby, punctuation.terminator.statement.ruby
2
+ ruby Normal punctuation.definition.constant.ruby
3
+ ruby Normal keyword.operator.arithmetic.ruby, keyword.operator.assignment.ruby, keyword.operator.assignment.augmented.ruby, keyword.operator.bitwise.ruby, keyword.operator.comparison.ruby, keyword.operator.conditional.ruby, keyword.operator.logical.ruby, keyword.operator.other.ruby, keyword.operator.range.ruby, keyword.operator.ruby, keyword.operator.splat.ruby
4
+ ruby Normal support.function.builtin.ruby
5
+ ruby Normal variable.parameter.ruby
6
+ ruby rubySymbol constant.other.symbol.ruby, constant.other.symbol.single-quoted.ruby, constant.other.symbol.double-quoted.ruby
7
+ ruby rubyConstant support.class.ruby, variable.other.constant.ruby, entity.name.constant.ruby, entity.other.inherited-class.ruby, support.other.namespace.ruby
8
+ ruby rubyClassName entity.name.class.ruby
9
+ ruby rubyModuleName entity.name.namespace.ruby
10
+ ruby rubyDefine keyword.declaration.function.ruby
11
+ ruby rubyClass keyword.declaration.class.ruby
12
+ ruby rubyModule keyword.declaration.namespace.ruby
13
+ ruby rubyMacro keyword.other.special-method.ruby
14
+ ruby rubyInclude keyword.control.import.ruby
15
+ ruby rubyPseudoVariable variable.language.ruby
16
+ ruby rubyInstanceVariable punctuation.definition.variable.ruby
17
+ ruby rubyComment punctuation.definition.comment.ruby
18
+ ruby rubyFloat punctuation.separator.decimal.ruby
19
+ ruby rubyDocumentation punctuation.definition.comment.begin.ruby, punctuation.definition.comment.end.ruby
20
+ ruby rubyRegexpDelimiter keyword.other.ruby
21
+ ruby rubyInteger punctuation.definition.numeric.base.ruby
22
+ html htmlTagName entity.name.tag.structure.any.html, entity.name.tag.block.any.html, entity.name.tag.block.form.html, entity.name.tag.inline.any.html, entity.name.tag.inline.form.html, entity.name.tag.inline.a.html, entity.name.tag.inline.table.html
23
+ html htmlArg entity.other.attribute-name.html, entity.other.attribute-name.class.html, entity.other.attribute-name.id.html, entity.other.attribute-name.style.html
24
+ html htmlTag punctuation.definition.tag.begin.html, punctuation.definition.tag.end.html
25
+ html htmlString punctuation.definition.string.begin.html, punctuation.definition.string.end.html
26
+ css cssClassName source.css entity.other.attribute-name.class.css
27
+ css cssBraces source.css punctuation.section.property-list.css
28
+ css cssUnitDecorators source.css keyword.other.unit.css
29
+ css cssNoise source.css punctuation.separator.key-value.css, source.css punctuation.terminator.rule.css
30
+ css cssTagName source.css entity.name.tag.css
31
+ css cssClassNameDot source.css punctuation.definition.entity.css
32
+ css cssImportant source.css keyword.other.important.css
33
+ typescript Normal source.ts variable.other, source.tsx variable.other, variable.other.constant.ts, variable.other.constant.tsx, variable.other.constant.object.ts, variable.other.constant.object.tsx, variable.other.constant.property.ts, variable.other.constant.property.tsx, variable.other.constant.object.property.ts, variable.other.constant.object.property.tsx
34
+ typescript Normal source.ts punctuation.separator.comma, source.tsx punctuation.separator.comma, source.ts punctuation.accessor, source.tsx punctuation.accessor, source.ts punctuation.separator.key-value, source.tsx punctuation.separator.key-value, source.ts punctuation.terminator.statement, source.tsx punctuation.terminator.statement
35
+ typescript typescriptBraces source.ts punctuation.definition.block, source.tsx punctuation.definition.block
36
+ typescript Normal entity.name.function.ts, entity.name.function.tsx
37
+ typescript typescriptObjectLabel meta.object-literal.key.ts, meta.object-literal.key.tsx
38
+ typescript typescriptTypeReference entity.name.type.ts, entity.name.type.tsx
39
+ typescript Keyword support.variable.property.dom.ts, support.variable.property.dom.tsx
40
+ typescript typescriptCall variable.parameter.ts, variable.parameter.tsx
41
+ typescript typescriptVariable storage.type.ts, storage.type.tsx
42
+ typescript typescriptString punctuation.definition.string.begin.ts, punctuation.definition.string.begin.tsx, punctuation.definition.string.end.ts, punctuation.definition.string.end.tsx
43
+ typescriptreact tsxTagName support.class.component.tsx
44
+ typescriptreact tsxAttrib entity.other.attribute-name.tsx
45
+ typescriptreact tsxTag punctuation.definition.tag.begin.tsx, punctuation.definition.tag.end.tsx
46
+ javascript Normal variable.other.readwrite.js, variable.other.object.js, variable.other.property.js, variable.other.property.static.js, variable.other.class.js, variable.other.constant.js, variable.parameter.function.js, variable.function.js, variable.function.constructor.js, variable.function.tagged-template.js, entity.name.function.js
47
+ javascript Normal string.unquoted.label.js
48
+ javascript Normal keyword.operator.accessor.js, keyword.operator.assignment.js, keyword.operator.assignment.augmented.js, keyword.operator.arithmetic.js, keyword.operator.comparison.js, keyword.operator.relational.js, keyword.operator.logical.js, keyword.operator.bitwise.js, keyword.operator.ternary.js, keyword.operator.spread.js, storage.type.function.arrow.js
49
+ javascript Normal punctuation.terminator.statement.js, punctuation.separator.key-value.js, punctuation.separator.parameter.function.js
50
+ javascriptreact Normal punctuation.definition.tag.begin.jsx, punctuation.definition.tag.end.jsx, keyword.operator.assignment.jsx
51
+ javascript javaScriptReserved storage.type.js, storage.type.class.js, storage.type.extends.js
52
+ javascript javaScriptFunction storage.type.function.js
53
+ javascript javaScriptBraces meta.brace.curly.begin.js, meta.brace.curly.end.js, meta.brace.square.begin.js, meta.brace.square.end.js, punctuation.section.embedded.begin.jsx, punctuation.section.embedded.end.jsx
54
+ javascript String punctuation.definition.string.begin.js, punctuation.definition.string.end.js, punctuation.definition.string.begin.jsx, punctuation.definition.string.end.jsx
55
+ scss Normal source.scss variable.function, source.scss entity.name.function
56
+ scss PreProc source.scss keyword.control.at-rule
57
+ sass sassClass source.scss entity.other.attribute-name.class
58
+ css cssTagName source.scss entity.name.tag
59
+ css cssPseudoClassId source.scss entity.other.pseudo-class, source.scss entity.other.pseudo-element
60
+ scss Normal source.scss punctuation.terminator.rule
61
+ scss Normal source.scss punctuation.separator.key-value
62
+ scss Normal source.scss punctuation.separator.sequence
63
+ scss Normal source.scss punctuation.definition.group
64
+ css cssBraces source.scss punctuation.section.property-list
65
+ sass sassVariable source.scss punctuation.definition.variable
66
+ yaml yamlPlainScalar string.unquoted.plain.out.yaml
67
+ yaml yamlBlockString string.unquoted.block.yaml
68
+ yaml yamlBlockMappingKey entity.name.tag.yaml
69
+ yaml yamlAlias variable.other.alias.yaml
70
+ vim vimFunctionBang meta.function.viml
71
+ vim vimCommand storage.function.viml
72
+ vim vimFunctionName entity.name.function.viml
73
+ vim vimCommand keyword.control.viml
74
+ vim vimCommand support.function.viml
75
+ vim Special support.type.viml
76
+ gitcommit gitcommitSummary markup.heading.subject.git.commit
77
+ gitcommit gitcommitComment punctuation.definition.comment.git.commit
78
+ gitcommit gitcommitSelectedFile markup.changed.file.content.git.commit
79
+ diff diffLine meta.range.unified.diff, punctuation.definition.range.diff
80
+ diff diffSubname entity.name.section.diff
81
+ diff diffOldFile meta.header.from-file.diff, punctuation.definition.from-file.diff
82
+ diff diffNewFile meta.header.to-file.diff, punctuation.definition.to-file.diff
83
+ diff diffAdded punctuation.definition.inserted.diff
84
+ diff diffRemoved punctuation.definition.deleted.diff
@@ -0,0 +1,37 @@
1
+ Comment comment, punctuation.definition.comment
2
+ Constant constant, entity.name.constant, variable.other.constant, support.constant
3
+ String string
4
+ Character constant.character
5
+ Number constant.numeric
6
+ Boolean constant.language.boolean
7
+ Float constant.numeric.float
8
+ Identifier variable, support.variable
9
+ Function entity.name.function, variable.function, support.function
10
+ Statement keyword
11
+ Conditional keyword.control.conditional, keyword.control.switch
12
+ Repeat keyword.control.loop
13
+ Label keyword.control.conditional.case, keyword.control.conditional.default, entity.name.label, variable.label
14
+ Operator keyword.operator
15
+ Keyword keyword.other
16
+ Exception keyword.control.exception, keyword.control.trycatch, keyword.control.flow.throw, keyword.control.flow.raise
17
+ PreProc keyword.control.preprocessor, keyword.other.preprocessor
18
+ Include keyword.control.import.include
19
+ Define keyword.control.import.define
20
+ Macro entity.name.macro, support.macro, variable.macro, entity.name.constant.preprocessor, entity.name.function.preprocessor, variable.function.assumed-macro
21
+ PreCondit keyword.control.import.else, meta.preprocessor keyword.control.import
22
+ Type storage.type, entity.name.type, support.type
23
+ StorageClass storage.modifier
24
+ Structure storage.type.struct, storage.type.union, storage.type.enum, storage.type.class, keyword.declaration.struct, keyword.declaration.union, keyword.declaration.enum, keyword.declaration.class, storage.type.keyword.struct
25
+ Typedef entity.name.type.typedef
26
+ SpecialChar constant.character.escape
27
+ Delimiter punctuation
28
+ SpecialComment keyword.other.documentation
29
+ Underlined markup.underline
30
+ Bold markup.bold
31
+ Italic markup.italic
32
+ BoldItalic markup.bold markup.italic, markup.italic markup.bold
33
+ Error invalid
34
+ Todo comment.line.todo, keyword.other.todo
35
+ Added markup.inserted
36
+ Changed markup.changed
37
+ Removed markup.deleted
@@ -0,0 +1,58 @@
1
+ " Writes $OUTFILE: every highlight group with the foreground color, background
2
+ " color (palette slots, or #rrggbb under 'termguicolors') and attributes that
3
+ " the colorscheme $SCHEME gives it when 'background' is $BACKGROUND (dark or
4
+ " light), or no groups if $SCHEME lacks that background.
5
+
6
+ " vim2bat's -p and -t options, which override your vimrc.
7
+ if $T_CO !=# ''
8
+ let &t_Co = $T_CO
9
+ endif
10
+ if $TERMGUICOLORS !=# ''
11
+ set termguicolors
12
+ endif
13
+ let &background = $BACKGROUND
14
+ " The standard groups, and the links of the language groups, exist only with
15
+ " syntax highlighting on, which a vimrc can leave off.
16
+ if !exists('g:syntax_on')
17
+ syntax enable
18
+ endif
19
+
20
+ " Vim on a terminal exits 0 after an error, so any error here makes it exit 1
21
+ " instead, but not one that silent! hides. That includes a scheme that fails to
22
+ " load, which would leave the colors of whatever scheme your vimrc loaded.
23
+ try
24
+ execute 'colorscheme' $SCHEME
25
+
26
+ let s:dir = expand('<sfile>:p:h')
27
+ " Language groups exist only once their syntax file has run. They load after
28
+ " the colorscheme because its :hi clear hides them from getcompletion().
29
+ for s:ft in uniq(sort(map(readfile(s:dir . '/loose.tsv'), 'split(v:val, "\t")[0]')))
30
+ let &syntax = s:ft
31
+ endfor
32
+
33
+ let s:attrs = ['bold', 'italic', 'underline', 'undercurl', 'strikethrough', 'reverse', 'standout']
34
+ let s:rows = []
35
+ for s:group in filter(getcompletion('', 'highlight'), 'hlexists(v:val)')
36
+ let s:id = synIDtrans(hlID(s:group))
37
+ let s:on = []
38
+ for s:attr in s:attrs
39
+ if synIDattr(s:id, s:attr, 'cterm') == '1'
40
+ call add(s:on, s:attr)
41
+ endif
42
+ endfor
43
+ " With 'termguicolors', Vim draws a group's GUI colors, or its cterm colors
44
+ " if it has none, and always its cterm attributes.
45
+ let s:colors = &termguicolors ? [synIDattr(s:id, 'fg#', 'gui'), synIDattr(s:id, 'bg#', 'gui')] : ['', '']
46
+ if s:colors == ['', '']
47
+ let s:colors = [synIDattr(s:id, 'fg', 'cterm'), synIDattr(s:id, 'bg', 'cterm')]
48
+ endif
49
+ call add(s:rows, join([s:group] + s:colors + [join(s:on, ',')], "\t"))
50
+ endfor
51
+ " A scheme with one background switches 'background' to it.
52
+ call writefile(&background ==# $BACKGROUND ? s:rows : [], $OUTFILE)
53
+ catch
54
+ " On Linux, /dev/stderr opens a redirected stderr's file anew, which Vim
55
+ " would truncate without 'a'.
56
+ call writefile([v:exception], '/dev/stderr', 'a')
57
+ cquit
58
+ endtry
@@ -0,0 +1,80 @@
1
+ " Writes $OUTFILE: for each non-blank character of $TARGET, its line, its
2
+ " character index, the color (palette slot, or #rrggbb under 'termguicolors')
3
+ " the colorscheme $SCHEME shows it in after 'background' is set to $BACKGROUND,
4
+ " and the chain of links from its syntax item to the group that colors it. The
5
+ " first line is the filetype.
6
+
7
+ " See vim-groups.vim.
8
+ if $T_CO !=# ''
9
+ let &t_Co = $T_CO
10
+ endif
11
+ if $TERMGUICOLORS !=# ''
12
+ set termguicolors
13
+ endif
14
+ let &background = $BACKGROUND
15
+ if !exists('g:syntax_on')
16
+ syntax enable
17
+ endif
18
+ try | execute 'colorscheme' $SCHEME | catch | call writefile([v:exception], '/dev/stderr', 'a') | cquit | endtry
19
+
20
+ let s:chains = {}
21
+ function s:Chain(name) abort
22
+ if !has_key(s:chains, a:name)
23
+ let l:chain = [a:name]
24
+ while 1
25
+ let l:next = matchstr(execute('highlight ' . l:chain[-1]), 'links to \zs\S\+')
26
+ if l:next ==# '' || index(l:chain, l:next, 0, v:true) >= 0
27
+ break
28
+ endif
29
+ call add(l:chain, l:next)
30
+ endwhile
31
+ let s:chains[a:name] = join(l:chain, '>')
32
+ endif
33
+ return s:chains[a:name]
34
+ endfunction
35
+
36
+ " As in vim-groups.vim.
37
+ let s:fgs = {}
38
+ function s:Fg(id) abort
39
+ if !has_key(s:fgs, a:id)
40
+ let l:colors = &termguicolors ? [synIDattr(a:id, 'fg#', 'gui'), synIDattr(a:id, 'bg#', 'gui')] : ['', '']
41
+ let s:fgs[a:id] = l:colors == ['', ''] ? synIDattr(a:id, 'fg', 'cterm') : l:colors[0]
42
+ endif
43
+ return s:fgs[a:id]
44
+ endfunction
45
+
46
+ let s:rows = []
47
+ " As in vim-groups.vim, but $OUTFILE is still written after an error, so that
48
+ " vim2bat compare compares what there is and reports the error.
49
+ try
50
+ execute 'edit' fnameescape($TARGET)
51
+ " Highlight each line as Vim would after scrolling down from the top.
52
+ syntax sync fromstart
53
+ call add(s:rows, '#ft=' . &filetype)
54
+ let s:normal = s:Fg(synIDtrans(hlID('Normal')))
55
+ for s:lnum in range(1, line('$'))
56
+ let s:col = 1
57
+ let s:idx = 0
58
+ for s:char in split(getline(s:lnum), '\zs')
59
+ " synID() misses a match that starts on a blank unless it visits that
60
+ " column too.
61
+ let s:id = synID(s:lnum, s:col, 1)
62
+ if s:char !~# '\s'
63
+ let s:fg = s:Fg(synIDtrans(s:id))
64
+ let s:name = synIDattr(s:id, 'name')
65
+ call add(s:rows, join([s:lnum, s:idx, s:fg ==# '' ? s:normal : s:fg, s:name ==# '' ? '' : s:Chain(s:name)], "\t"))
66
+ endif
67
+ let s:col += len(s:char)
68
+ " split() keeps composing characters with their base character. Count
69
+ " each one, as vim2bat compare does.
70
+ let s:idx += strchars(s:char)
71
+ endfor
72
+ endfor
73
+ catch
74
+ call writefile([v:exception], '/dev/stderr', 'a')
75
+ let s:failed = 1
76
+ endtry
77
+ call writefile(s:rows, $OUTFILE)
78
+ if exists('s:failed')
79
+ cquit
80
+ endif
@@ -0,0 +1,231 @@
1
+ Metadata-Version: 2.5
2
+ Name: vim2bat
3
+ Version: 0.1.0
4
+ Summary: Generate bat themes from Vim colorschemes
5
+ Project-URL: Homepage, https://github.com/redodson01/vim2bat
6
+ Project-URL: Repository, https://github.com/redodson01/vim2bat
7
+ Project-URL: Issues, https://github.com/redodson01/vim2bat/issues
8
+ Project-URL: Changelog, https://github.com/redodson01/vim2bat/blob/main/CHANGELOG.md
9
+ Author: Richard E. Dodson
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: MacOS
16
+ Classifier: Operating System :: POSIX :: Linux
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Programming Language :: Python :: 3.15
23
+ Classifier: Topic :: Text Editors
24
+ Classifier: Topic :: Utilities
25
+ Requires-Python: >=3.11
26
+ Description-Content-Type: text/markdown
27
+
28
+ # vim2bat
29
+
30
+ [![CI](https://github.com/redodson01/vim2bat/actions/workflows/ci.yml/badge.svg)](https://github.com/redodson01/vim2bat/actions/workflows/ci.yml)
31
+ [![Version](https://img.shields.io/github/v/release/redodson01/vim2bat)](https://github.com/redodson01/vim2bat/releases/latest)
32
+ [![Release Date](https://img.shields.io/github/release-date/redodson01/vim2bat)](https://github.com/redodson01/vim2bat/releases/latest)
33
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/redodson01/vim2bat/blob/main/LICENSE)
34
+
35
+ Builds [bat](https://github.com/sharkdp/bat) themes from Vim colorschemes, so
36
+ that bat, and tools that use it such as fzf.vim's previews and delta, color code
37
+ the way Vim does.
38
+
39
+ ## The problem
40
+
41
+ bat ships its own themes, and most of them use 24-bit colors. If you use a Vim
42
+ colorscheme, especially one built on your terminal's palette like solarized8
43
+ with `g:solarized_use16`, the previews and diffs that bat colors look different
44
+ from the code you edit in Vim. A matching bat theme means mapping Vim's
45
+ highlight groups to TextMate scopes by hand, for each scheme and background.
46
+
47
+ vim2bat asks Vim which colors a colorscheme gives each highlight group, with
48
+ your vimrc, and writes bat themes that use the same palette slots, or the same
49
+ 24-bit colors.
50
+
51
+ ## Install
52
+
53
+ ### Homebrew (macOS and Linux)
54
+
55
+ ```bash
56
+ brew install redodson01/tap/vim2bat
57
+ ```
58
+
59
+ ### PyPI
60
+
61
+ ```bash
62
+ uv tool install vim2bat # or: pipx install vim2bat
63
+ ```
64
+
65
+ ### From source
66
+
67
+ ```bash
68
+ git clone https://github.com/redodson01/vim2bat.git
69
+ cd vim2bat
70
+ uv tool install . # or: uv tool install --editable . to follow edits to the checkout
71
+ ```
72
+
73
+ The PyPI and source installs put the `vim2bat` command in `~/.local/bin` by
74
+ default. Make sure that folder is on your `$PATH` (`uv tool update-shell` or
75
+ `pipx ensurepath` adds it).
76
+
77
+ ## Runtime requirements
78
+
79
+ - Vim 9.1 or newer, with the colorschemes you want to convert
80
+ - bat (0.25 or newer for the `--theme-dark` and `--theme-light` settings below)
81
+ - Python 3.11 or newer
82
+
83
+ Debian and Ubuntu install bat as `batcat`. vim2bat runs `bat`, so link it:
84
+ `ln -s /usr/bin/batcat ~/.local/bin/bat`.
85
+
86
+ ## Usage
87
+
88
+ ```bash
89
+ vim2bat retrobox # builds vim-retrobox-dark and vim-retrobox-light
90
+ vim2bat --all # builds every colorscheme Vim lists
91
+ bat --theme=vim-retrobox-dark file.rb
92
+ ```
93
+
94
+ | Option | Effect |
95
+ |---|---|
96
+ | `-a`, `--all` | Builds every colorscheme Vim lists, several at a time |
97
+ | `-c`, `--clean` | First deletes vim2bat's cache and the whole bat themes folder |
98
+ | `-s`, `--strict` | Maps only Vim's standard highlight groups, such as `Comment` and `String` |
99
+ | `-t`, `--truecolor` | Turns on `termguicolors`, so the themes use the scheme's 24-bit colors |
100
+ | `-p`, `--palette 16\|256` | Overrides your vimrc's `t_Co` |
101
+
102
+ vim2bat rebuilds bat's cache when it finishes, so the themes are available right
103
+ away.
104
+
105
+ ### Theme names
106
+
107
+ A scheme that supports both backgrounds gets `vim-SCHEME-dark` and
108
+ `vim-SCHEME-light`. A scheme that supports a single background, by setting
109
+ `background` itself, gets `vim-SCHEME`.
110
+
111
+ ### What decides the colors
112
+
113
+ Vim runs with your vimrc in a 256-color terminal, so your vimrc decides what the
114
+ themes look like: a scheme's own options (such as `g:solarized_use16`), `t_Co`
115
+ and `termguicolors` all apply. `-p` and `-t` override `t_Co` and
116
+ `termguicolors` for one run. Each theme records the options it was built with in
117
+ its `comment` entry. `-p` has no effect while `termguicolors` is on, whether
118
+ from `-t` or your vimrc.
119
+
120
+ With `-t`, bat prints the exact colors only when `COLORTERM` is `truecolor` or
121
+ `24bit`. Otherwise it rounds them to the nearest of 256.
122
+
123
+ ## Using the themes
124
+
125
+ ### bat
126
+
127
+ Add these lines to bat's config file, whose path `bat --config-file` prints:
128
+
129
+ ```
130
+ --theme-dark="vim-retrobox-dark"
131
+ --theme-light="vim-retrobox-light"
132
+ --italic-text=always
133
+ ```
134
+
135
+ bat's default, `--theme=auto`, picks between them by asking the terminal for its
136
+ background. It doesn't ask when its output goes to a pipe, as in fzf.vim's
137
+ previews, and then uses its own default theme. On macOS, `--theme="auto:system"`
138
+ follows the system appearance instead, and works in pipes too.
139
+
140
+ bat leaves out italics unless `--italic-text` is `always`.
141
+
142
+ ### delta
143
+
144
+ delta takes a single syntax theme:
145
+
146
+ ```ini
147
+ [delta]
148
+ syntax-theme = vim-retrobox-dark
149
+ ```
150
+
151
+ On macOS, git's pager command can pick the theme from the system appearance:
152
+
153
+ ```ini
154
+ [core]
155
+ pager = delta --syntax-theme=vim-retrobox-$(defaults read -g AppleInterfaceStyle 2>/dev/null | grep -qx Dark && echo dark || echo light)
156
+ ```
157
+
158
+ ## Measuring a theme
159
+
160
+ `vim2bat compare THEME FILE...` reports how often the theme gives each
161
+ character the color Vim does, per file and per language, then the most common
162
+ mismatches:
163
+
164
+ ```bash
165
+ vim2bat compare vim-retrobox-dark app/models/*.rb
166
+ ```
167
+
168
+ Expect 85 to 90 percent on real code in the languages Table 3 covers (see
169
+ below): bat's syntax definitions don't split code exactly the way Vim's do.
170
+ Other languages get only Table 2 and score lower.
171
+
172
+ ## How it works
173
+
174
+ 1. For each background, vim2bat runs Vim with your vimrc on a pseudo-terminal,
175
+ loads the scheme, and records each highlight group's colors and attributes
176
+ (Table 1, cached in `$XDG_CACHE_HOME/vim2bat/tables`, or
177
+ `~/.cache/vim2bat/tables`). Vim gets a terminal because
178
+ without one it numbers named colors such as `DarkBlue` differently.
179
+ 2. `src/vim2bat/share/strict.tsv` (Table 2) maps Vim's standard groups, such
180
+ as `Comment` and `String`, to the TextMate scopes with the same meaning.
181
+ 3. `src/vim2bat/share/loose.tsv` (Table 3) maps language-specific scopes to the
182
+ Vim syntax groups that Vim gives the same tokens, for Ruby, ERB, HTML, CSS
183
+ and SCSS, TypeScript and TSX, JavaScript and JSX, YAML, Vim script, git
184
+ commit messages and diffs. The languages and tokens follow what
185
+ vim-solarized8 colors.
186
+ 4. Palette colors use bat's palette notation, where `#0E000000` means slot 14,
187
+ and the terminal's default color is `#00000001`.
188
+
189
+ ## Known limits
190
+
191
+ - bat doesn't paint a scheme's background color, so a scheme with its own
192
+ background, such as habamax, sits on your terminal's.
193
+ - bat themes support only bold, italic and underline, so reverse and undercurl
194
+ are dropped.
195
+ - `-c` deletes the whole bat themes folder, including themes vim2bat didn't
196
+ build.
197
+ - A single-background scheme whose own name ends in `-dark` or `-light` gets a
198
+ name that looks like another scheme's background variant.
199
+ - Colorschemes in a subfolder of `colors/`, whose names contain `/`, aren't
200
+ supported. vim2bat rejects those names.
201
+
202
+ ## Uninstall
203
+
204
+ ```bash
205
+ brew uninstall vim2bat # or: uv tool uninstall vim2bat, or pipx uninstall vim2bat
206
+ rm "$(bat --config-dir)"/themes/vim-*.tmTheme && (cd / && bat cache --build)
207
+ rm -rf "${XDG_CACHE_HOME:-$HOME/.cache}/vim2bat"
208
+ ```
209
+
210
+ ## Development
211
+
212
+ Development uses [uv](https://docs.astral.sh/uv/) (`brew install uv`).
213
+
214
+ ```bash
215
+ git clone https://github.com/redodson01/vim2bat.git
216
+ cd vim2bat
217
+ uv sync # creates .venv with vim2bat and the dev tools
218
+ uv run pre-commit install # runs ruff and file checks before each commit
219
+ uv run pytest # unit tests, plus integration tests when vim and bat are installed
220
+ uv run mypy # type checks src and tests
221
+ uv run ruff check # lints
222
+ uv run ruff format # formats
223
+ ```
224
+
225
+ [CONTRIBUTING.md](https://github.com/redodson01/vim2bat/blob/main/CONTRIBUTING.md)
226
+ covers the golden files, measuring a change, the rules for the tables, and
227
+ releasing.
228
+
229
+ ## License
230
+
231
+ [MIT](https://github.com/redodson01/vim2bat/blob/main/LICENSE)
@@ -0,0 +1,12 @@
1
+ vim2bat/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ vim2bat/__main__.py,sha256=1UwKOIuKO4tg-OJmllVm1m-JuWMPSkFBdMEm1f1HJkU,59
3
+ vim2bat/cli.py,sha256=n_j9PKHO7tUtbIdg-ln23JAFjgwjpRXDPBnQ1DJjBWA,27725
4
+ vim2bat/share/loose.tsv,sha256=F62TtLnNvMB4yiXMz1xpmFaLuXn6NHSm4pvaO_aHb00,7945
5
+ vim2bat/share/strict.tsv,sha256=ik4d5ZmLy68EHirQvB8I96WkBNOWzD1YZ6AChBpsH6U,1829
6
+ vim2bat/share/vim-groups.vim,sha256=BtplNbxbAvDYri4P4KCRMSGkqN7m4WGvsnSycWm2F7c,2381
7
+ vim2bat/share/vim-slots.vim,sha256=SXuOtiHud2vaEX-BDA8XAHR7C0sYCrqG4KVmLmZebp8,2661
8
+ vim2bat-0.1.0.dist-info/METADATA,sha256=gxE4NgwgTfFf4TBauOC0lKXy1AjWrymBXCh-iTepckk,8667
9
+ vim2bat-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
10
+ vim2bat-0.1.0.dist-info/entry_points.txt,sha256=L0E9B0oCMWjAb1bZqt-pzqknvwM_N3BpHwC_eYdabVc,45
11
+ vim2bat-0.1.0.dist-info/licenses/LICENSE,sha256=sCNhPkNLzVVbaoxdKKVGk_QAbxxx4YDrNfX_7vQGFis,1074
12
+ vim2bat-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ vim2bat = vim2bat.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Richard E. Dodson
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.