nit-cli 0.3.0__tar.gz → 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.
Files changed (25) hide show
  1. {nit_cli-0.3.0/src/nit_cli.egg-info → nit_cli-0.4.1}/PKG-INFO +32 -2
  2. {nit_cli-0.3.0 → nit_cli-0.4.1}/README.md +31 -1
  3. {nit_cli-0.3.0 → nit_cli-0.4.1}/pyproject.toml +1 -1
  4. {nit_cli-0.3.0 → nit_cli-0.4.1}/src/nit/app.py +181 -24
  5. {nit_cli-0.3.0 → nit_cli-0.4.1}/src/nit/cli.py +32 -3
  6. {nit_cli-0.3.0 → nit_cli-0.4.1}/src/nit/comments.py +25 -0
  7. {nit_cli-0.3.0 → nit_cli-0.4.1}/src/nit/diff_parser.py +25 -0
  8. {nit_cli-0.3.0 → nit_cli-0.4.1}/src/nit/git.py +47 -15
  9. nit_cli-0.4.1/src/nit/syntax.py +33 -0
  10. {nit_cli-0.3.0 → nit_cli-0.4.1/src/nit_cli.egg-info}/PKG-INFO +32 -2
  11. {nit_cli-0.3.0 → nit_cli-0.4.1}/src/nit_cli.egg-info/SOURCES.txt +3 -1
  12. {nit_cli-0.3.0 → nit_cli-0.4.1}/tests/test_app.py +16 -1
  13. {nit_cli-0.3.0 → nit_cli-0.4.1}/tests/test_comments.py +52 -0
  14. {nit_cli-0.3.0 → nit_cli-0.4.1}/tests/test_git.py +4 -35
  15. nit_cli-0.4.1/tests/test_syntax.py +26 -0
  16. {nit_cli-0.3.0 → nit_cli-0.4.1}/LICENSE +0 -0
  17. {nit_cli-0.3.0 → nit_cli-0.4.1}/setup.cfg +0 -0
  18. {nit_cli-0.3.0 → nit_cli-0.4.1}/src/nit/__init__.py +0 -0
  19. {nit_cli-0.3.0 → nit_cli-0.4.1}/src/nit/models.py +0 -0
  20. {nit_cli-0.3.0 → nit_cli-0.4.1}/src/nit_cli.egg-info/dependency_links.txt +0 -0
  21. {nit_cli-0.3.0 → nit_cli-0.4.1}/src/nit_cli.egg-info/entry_points.txt +0 -0
  22. {nit_cli-0.3.0 → nit_cli-0.4.1}/src/nit_cli.egg-info/requires.txt +0 -0
  23. {nit_cli-0.3.0 → nit_cli-0.4.1}/src/nit_cli.egg-info/top_level.txt +0 -0
  24. {nit_cli-0.3.0 → nit_cli-0.4.1}/tests/test_cli.py +0 -0
  25. {nit_cli-0.3.0 → nit_cli-0.4.1}/tests/test_diff_parser.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nit-cli
3
- Version: 0.3.0
3
+ Version: 0.4.1
4
4
  Summary: Terminal diff viewer with inline review comments
5
5
  Author: Joshua Zink-Duda
6
6
  License-Expression: GPL-3.0-only
@@ -33,6 +33,12 @@ Navigate diffs with vim-style keybindings, leave comments on specific lines, and
33
33
 
34
34
  ## Install
35
35
 
36
+ ```bash
37
+ brew install joshuazd/tap/nit
38
+ ```
39
+
40
+ Or via pip/pipx:
41
+
36
42
  ```bash
37
43
  pipx install nit-cli
38
44
  ```
@@ -60,11 +66,20 @@ nit --mode all
60
66
  nit HEAD~3..HEAD
61
67
  nit main..feature
62
68
 
69
+ # Review any file (read-only, no git required)
70
+ nit path/to/file.py
71
+
63
72
  # Filter to specific files or directories
64
73
  nit --path src/
65
74
  nit --path src/nit/app.py main..feature
75
+
76
+ # Export comments on quit
77
+ nit --export-comments comments.md
78
+ nit --export-comments - --export-format json # stdout as JSON
66
79
  ```
67
80
 
81
+ On main/master, nit defaults to unstaged mode. On feature branches, it defaults to branch diff. The diff auto-refreshes every 5 seconds.
82
+
68
83
  ## Keybindings
69
84
 
70
85
  | Key | Action |
@@ -73,12 +88,27 @@ nit --path src/nit/app.py main..feature
73
88
  | `J` / `K` | Jump to next / previous hunk |
74
89
  | `n` / `p` | Next / previous file |
75
90
  | `]` / `[` | Jump to next / previous comment |
91
+ | `gg` / `G` | Jump to top / bottom |
76
92
  | `c` | Add comment on current line |
77
93
  | `d` | Delete comment on current line |
78
- | `m` | Cycle diff mode (branch / unstaged / all) |
94
+ | `e` | Export comments to clipboard |
95
+ | `m` | Cycle diff mode (branch / unstaged / staged / all) |
96
+ | `s` | Toggle side-by-side diff view |
97
+ | `w` | Toggle word-level diff highlighting |
98
+ | `W` | Toggle hide whitespace-only changes |
99
+ | `h` | Toggle syntax highlighting |
79
100
  | `r` | Refresh diff |
80
101
  | `q` | Quit |
81
102
 
103
+ ### Git operations (g-prefixed)
104
+
105
+ | Key | Action |
106
+ |-----|--------|
107
+ | `ga` | Stage hunk under cursor |
108
+ | `gu` | Unstage hunk (staged mode) |
109
+ | `gx` | Discard hunk |
110
+ | `gc` | Commit with message prompt |
111
+
82
112
  ## Comment Storage
83
113
 
84
114
  Comments are saved to `.nit.json` at the root of your git repository. The format is structured JSON:
@@ -6,6 +6,12 @@ Navigate diffs with vim-style keybindings, leave comments on specific lines, and
6
6
 
7
7
  ## Install
8
8
 
9
+ ```bash
10
+ brew install joshuazd/tap/nit
11
+ ```
12
+
13
+ Or via pip/pipx:
14
+
9
15
  ```bash
10
16
  pipx install nit-cli
11
17
  ```
@@ -33,11 +39,20 @@ nit --mode all
33
39
  nit HEAD~3..HEAD
34
40
  nit main..feature
35
41
 
42
+ # Review any file (read-only, no git required)
43
+ nit path/to/file.py
44
+
36
45
  # Filter to specific files or directories
37
46
  nit --path src/
38
47
  nit --path src/nit/app.py main..feature
48
+
49
+ # Export comments on quit
50
+ nit --export-comments comments.md
51
+ nit --export-comments - --export-format json # stdout as JSON
39
52
  ```
40
53
 
54
+ On main/master, nit defaults to unstaged mode. On feature branches, it defaults to branch diff. The diff auto-refreshes every 5 seconds.
55
+
41
56
  ## Keybindings
42
57
 
43
58
  | Key | Action |
@@ -46,12 +61,27 @@ nit --path src/nit/app.py main..feature
46
61
  | `J` / `K` | Jump to next / previous hunk |
47
62
  | `n` / `p` | Next / previous file |
48
63
  | `]` / `[` | Jump to next / previous comment |
64
+ | `gg` / `G` | Jump to top / bottom |
49
65
  | `c` | Add comment on current line |
50
66
  | `d` | Delete comment on current line |
51
- | `m` | Cycle diff mode (branch / unstaged / all) |
67
+ | `e` | Export comments to clipboard |
68
+ | `m` | Cycle diff mode (branch / unstaged / staged / all) |
69
+ | `s` | Toggle side-by-side diff view |
70
+ | `w` | Toggle word-level diff highlighting |
71
+ | `W` | Toggle hide whitespace-only changes |
72
+ | `h` | Toggle syntax highlighting |
52
73
  | `r` | Refresh diff |
53
74
  | `q` | Quit |
54
75
 
76
+ ### Git operations (g-prefixed)
77
+
78
+ | Key | Action |
79
+ |-----|--------|
80
+ | `ga` | Stage hunk under cursor |
81
+ | `gu` | Unstage hunk (staged mode) |
82
+ | `gx` | Discard hunk |
83
+ | `gc` | Commit with message prompt |
84
+
55
85
  ## Comment Storage
56
86
 
57
87
  Comments are saved to `.nit.json` at the root of your git repository. The format is structured JSON:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "nit-cli"
7
- version = "0.3.0"
7
+ version = "0.4.1"
8
8
  description = "Terminal diff viewer with inline review comments"
9
9
  readme = "README.md"
10
10
  license = "GPL-3.0-only"
@@ -2,8 +2,10 @@ from __future__ import annotations
2
2
 
3
3
  import logging
4
4
  import os
5
+ import shutil
5
6
  import subprocess
6
7
  from collections import Counter
8
+ from pathlib import Path
7
9
 
8
10
  from rich.style import Style
9
11
  from rich.text import Text
@@ -18,9 +20,10 @@ from textual.widgets._tree import TreeNode
18
20
  from . import comments as comments_mod
19
21
  from . import git
20
22
  from .cli import CLIArgs
21
- from .diff_parser import align_hunk_lines, build_patch, parse_diff, word_diff_segments
23
+ from .diff_parser import align_hunk_lines, build_patch, file_to_diff, parse_diff, word_diff_segments
22
24
  from .models import DiffHunk, FileDiff, ReviewComment, SideBySideRow
23
25
  from .models import DiffLine as DiffLineModel
26
+ from .syntax import detect_language, highlight_line
24
27
 
25
28
  logger = logging.getLogger(__name__)
26
29
 
@@ -214,6 +217,7 @@ class DiffView(VerticalScroll):
214
217
  cursor_index: reactive[int] = reactive(0)
215
218
  side_by_side: reactive[bool] = reactive(False)
216
219
  word_diff: reactive[bool] = reactive(False)
220
+ syntax_highlight: reactive[bool] = reactive(False)
217
221
 
218
222
  def __init__(self, *args, **kwargs) -> None:
219
223
  super().__init__(*args, **kwargs)
@@ -295,6 +299,8 @@ class DiffView(VerticalScroll):
295
299
  else:
296
300
  i += 1
297
301
 
302
+ lang = detect_language(file_diff.path) if self.syntax_highlight else None
303
+
298
304
  flat_idx = 0
299
305
  for hunk in file_diff.hunks:
300
306
  for dl in hunk.lines:
@@ -307,11 +313,22 @@ class DiffView(VerticalScroll):
307
313
  text = self._format_word_diff_line(
308
314
  dl, word_pairs[flat_idx], line_no_str, prefix
309
315
  )
316
+ elif lang and dl.line_type == "context":
317
+ text = self._format_syntax_line(dl, line_no_str, prefix, lang)
318
+ elif lang and dl.line_type in ("add", "remove"):
319
+ style = "reverse green" if dl.line_type == "add" else "reverse red"
320
+ text = Text(f"{line_no_str}{prefix}{dl.content}", style=style)
310
321
  else:
311
- text = f"{line_no_str}{prefix}{dl.content}"
322
+ text = Text()
323
+ if dl.line_type == "context":
324
+ text.append(line_no_str, style="dim")
325
+ else:
326
+ text.append(line_no_str)
327
+ text.append(f"{prefix}{dl.content}")
312
328
 
313
329
  w = DiffLineWidget(text)
314
- w.add_class(dl.line_type.replace("_", "-"))
330
+ if not (lang and dl.line_type in ("add", "remove", "context")):
331
+ w.add_class(dl.line_type.replace("_", "-"))
315
332
  self._line_widgets.append(w)
316
333
  self.mount(w)
317
334
 
@@ -330,6 +347,7 @@ class DiffView(VerticalScroll):
330
347
  comment_map: dict[int | None, list[ReviewComment]],
331
348
  ) -> None:
332
349
  half = max(40, (self.size.width - 15) // 2) # each side's content width
350
+ lang = detect_language(file_diff.path) if self.syntax_highlight else None
333
351
 
334
352
  for hunk in file_diff.hunks:
335
353
  rows = align_hunk_lines(hunk.lines)
@@ -343,7 +361,7 @@ class DiffView(VerticalScroll):
343
361
  self.diff_lines.append(primary)
344
362
 
345
363
  # Build Rich Text for the row
346
- text = self._format_sbs_row(row, half)
364
+ text = self._format_sbs_row(row, half, lang if row.row_type == "context" else None)
347
365
  w = DiffLineWidget(text)
348
366
 
349
367
  w.add_class("sbs")
@@ -351,7 +369,6 @@ class DiffView(VerticalScroll):
351
369
  w.add_class("hunk-header")
352
370
  elif row.row_type == "context":
353
371
  w.add_class("context")
354
- # For change rows, coloring is in the Rich Text itself
355
372
 
356
373
  self._line_widgets.append(w)
357
374
  self.mount(w)
@@ -365,7 +382,7 @@ class DiffView(VerticalScroll):
365
382
  block = CommentBlock(f"-- {c.comment}")
366
383
  self.mount(block)
367
384
 
368
- def _format_sbs_row(self, row: SideBySideRow, half: int) -> Text:
385
+ def _format_sbs_row(self, row: SideBySideRow, half: int, language: str | None = None) -> Text:
369
386
  """Format a side-by-side row as Rich Text with per-side coloring."""
370
387
  if row.row_type == "hunk_header":
371
388
  left = row.left
@@ -400,6 +417,10 @@ class DiffView(VerticalScroll):
400
417
  if left_len >= half:
401
418
  break
402
419
  text.append(" " * max(0, half - left_len))
420
+ elif language:
421
+ highlighted = highlight_line(row.left.content[:half], language)
422
+ text.append_text(highlighted)
423
+ text.append(" " * max(0, half - highlighted.cell_len))
403
424
  else:
404
425
  left_content = row.left.content[:half]
405
426
  style = "red" if row.left.line_type == "remove" else ""
@@ -418,6 +439,9 @@ class DiffView(VerticalScroll):
418
439
  if do_word_diff:
419
440
  for seg_text, changed in new_segs:
420
441
  text.append(seg_text[:half], style="bold reverse green" if changed else "green")
442
+ elif language:
443
+ highlighted = highlight_line(row.right.content[:half], language)
444
+ text.append_text(highlighted)
421
445
  else:
422
446
  right_content = row.right.content[:half]
423
447
  style = "green" if row.right.line_type == "add" else ""
@@ -443,6 +467,20 @@ class DiffView(VerticalScroll):
443
467
  return ""
444
468
  return " "
445
469
 
470
+ def _format_syntax_line(
471
+ self,
472
+ dl: DiffLineModel,
473
+ line_no_str: str,
474
+ prefix: str,
475
+ language: str,
476
+ ) -> Text:
477
+ """Render a context line with syntax highlighting."""
478
+ text = Text()
479
+ text.append(f"{line_no_str}{prefix}", style="dim")
480
+ highlighted = highlight_line(dl.content, language)
481
+ text.append_text(highlighted)
482
+ return text
483
+
446
484
  def _format_word_diff_line(
447
485
  self,
448
486
  dl: DiffLineModel,
@@ -621,7 +659,7 @@ class NitApp(App):
621
659
  """
622
660
 
623
661
  BINDINGS = [
624
- Binding("q", "quit", "Quit"),
662
+ Binding("q", "quit", "Quit", show=False),
625
663
  Binding("j", "cursor_down", "Down", show=False),
626
664
  Binding("k", "cursor_up", "Up", show=False),
627
665
  Binding("J", "next_hunk", "Next hunk", show=False),
@@ -629,17 +667,21 @@ class NitApp(App):
629
667
  Binding("n", "next_file", "Next file"),
630
668
  Binding("p", "prev_file", "Prev file"),
631
669
  Binding("c", "comment", "Comment"),
632
- Binding("d", "delete_comment", "Delete comment"),
670
+ Binding("d", "delete_comment", "Delete"),
633
671
  Binding("m", "cycle_mode", "Mode"),
634
- Binding("r", "refresh", "Refresh"),
672
+ Binding("r", "refresh", "Refresh", show=False),
635
673
  Binding("right_square_bracket", "next_comment", "]", show=False),
636
674
  Binding("left_square_bracket", "prev_comment", "[", show=False),
637
675
  Binding("G", "cursor_end", "End", show=False),
638
676
  Binding("s", "toggle_side_by_side", "Split"),
639
- Binding("w", "toggle_word_diff", "Word diff"),
677
+ Binding("w", "toggle_word_diff", "Word"),
678
+ Binding("W", "toggle_whitespace", "Whitespace"),
679
+ Binding("e", "export_comments", "Export", show=False),
680
+ Binding("h", "toggle_syntax", "Highlight", show=False),
640
681
  ]
641
682
 
642
683
  diff_mode: reactive[str] = reactive("branch")
684
+ ignore_whitespace: reactive[bool] = reactive(False)
643
685
 
644
686
  def __init__(self, cli_args: CLIArgs | None = None, *args, **kwargs) -> None:
645
687
  kwargs.setdefault("ansi_color", True)
@@ -654,6 +696,8 @@ class NitApp(App):
654
696
  self.branch = ""
655
697
  self.base = ""
656
698
  self._file_index: int = 0
699
+ self._last_raw_diff: str = ""
700
+ self._file_review_mode: bool = False
657
701
 
658
702
  def compose(self) -> ComposeResult:
659
703
  with Vertical(id="frame"):
@@ -670,6 +714,9 @@ class NitApp(App):
670
714
  yield Footer()
671
715
 
672
716
  def on_mount(self) -> None:
717
+ if self._cli_args.file_path:
718
+ self._mount_file_review()
719
+ return
673
720
  try:
674
721
  self.repo_root = git.get_repo_root()
675
722
  except Exception:
@@ -679,10 +726,12 @@ class NitApp(App):
679
726
  try:
680
727
  self.branch = git.get_current_branch(self.repo_root)
681
728
  except Exception:
682
- self.branch = ""
729
+ self.branch = "(detached HEAD)"
683
730
  self.base = git.get_main_branch(self.repo_root)
684
731
  if self._cli_args.mode:
685
732
  self.diff_mode = self._cli_args.mode
733
+ elif self.branch and self.branch == self.base:
734
+ self.diff_mode = "unstaged"
686
735
  try:
687
736
  self.comments = comments_mod.load_comments(self.repo_root)
688
737
  except Exception:
@@ -690,30 +739,79 @@ class NitApp(App):
690
739
  self.notify("Could not load .nit.json — starting with no comments", severity="warning")
691
740
  self.comments = []
692
741
  self._load_diff()
742
+ self.set_interval(5.0, self._auto_refresh_poll)
693
743
 
694
- def _load_diff(self) -> None:
695
- if self.repo_root is None:
744
+ def _mount_file_review(self) -> None:
745
+ from pathlib import Path
746
+
747
+ file_path = self._cli_args.file_path
748
+ p = Path(file_path)
749
+ if not p.exists():
750
+ self.notify(f"File not found: {file_path}", severity="error")
751
+ self.exit()
752
+ return
753
+ self._file_review_mode = True
754
+ self.branch = str(p.name)
755
+ self.diff_mode = "file"
756
+ try:
757
+ self.repo_root = git.get_repo_root()
758
+ except Exception:
759
+ self.repo_root = p.parent
760
+ try:
761
+ self.comments = comments_mod.load_comments(self.repo_root)
762
+ except Exception:
763
+ self.comments = []
764
+ content = p.read_text()
765
+ self.file_diffs = file_to_diff(str(p), content)
766
+ self._update_file_list()
767
+ self._update_status()
768
+
769
+ def _auto_refresh_poll(self) -> None:
770
+ if self._file_review_mode:
771
+ return
772
+ if self._commit_input is not None:
773
+ return
774
+ dv = self.query_one("#diff-view", DiffView)
775
+ if dv._active_input is not None:
776
+ return
777
+ try:
778
+ raw = self._get_raw_diff()
779
+ except Exception:
696
780
  return
781
+ if raw != self._last_raw_diff:
782
+ self._last_raw_diff = raw
783
+ self.file_diffs = parse_diff(raw)
784
+ self._update_file_list()
785
+ self._update_status()
786
+
787
+ def _get_raw_diff(self) -> str:
788
+ if self.repo_root is None:
789
+ return ""
697
790
  cwd = self.repo_root
698
791
  path_filter = self._cli_args.path_filter
699
-
700
792
  try:
793
+ ws = self.ignore_whitespace
701
794
  if self._cli_args.commit_range:
702
- raw = git.get_commit_range_diff(self._cli_args.commit_range, cwd, path_filter)
795
+ return git.get_commit_range_diff(
796
+ self._cli_args.commit_range, cwd, path_filter, ignore_whitespace=ws
797
+ )
703
798
  elif self.diff_mode == "branch":
704
- raw = git.get_branch_diff(cwd, path_filter)
799
+ return git.get_branch_diff(cwd, path_filter, ignore_whitespace=ws)
705
800
  elif self.diff_mode == "unstaged":
706
- raw = git.get_unstaged_diff(cwd, path_filter)
801
+ return git.get_unstaged_diff(cwd, path_filter, ignore_whitespace=ws)
707
802
  elif self.diff_mode == "staged":
708
- raw = git.get_staged_diff(cwd, path_filter)
803
+ return git.get_staged_diff(cwd, path_filter, ignore_whitespace=ws)
709
804
  else:
710
- raw = git.get_all_uncommitted_diff(cwd, path_filter)
805
+ return git.get_all_uncommitted_diff(cwd, path_filter, ignore_whitespace=ws)
711
806
  except subprocess.CalledProcessError as e:
712
807
  msg = (e.stderr or "").strip() or "Failed to load diff"
713
808
  logger.warning("Git diff failed: %s", msg)
714
809
  self.notify(msg, severity="error")
715
- raw = ""
810
+ return ""
716
811
 
812
+ def _load_diff(self) -> None:
813
+ raw = self._get_raw_diff()
814
+ self._last_raw_diff = raw
717
815
  self.file_diffs = parse_diff(raw)
718
816
  self._update_file_list()
719
817
  self._update_status()
@@ -777,7 +875,8 @@ class NitApp(App):
777
875
  n_comments = len(self.comments)
778
876
  n_files = len(self.file_diffs)
779
877
  self.query_one("#seg-branch", Label).update(f"⎇ {self.branch}")
780
- self.query_one("#seg-mode", Label).update(f"⇄ {mode_label}")
878
+ ws_indicator = " [no-ws]" if self.ignore_whitespace else ""
879
+ self.query_one("#seg-mode", Label).update(f"⇄ {mode_label}{ws_indicator}")
781
880
  self.query_one("#seg-files", Label).update(f"▤ {n_files} files")
782
881
  self.query_one("#seg-comments", Label).update(f"✎ {n_comments} comments")
783
882
 
@@ -832,7 +931,7 @@ class NitApp(App):
832
931
  action()
833
932
  event.prevent_default()
834
933
  event.stop()
835
- return
934
+ return
836
935
  if event.key == "g":
837
936
  self._pending_g = True
838
937
  event.prevent_default()
@@ -866,7 +965,7 @@ class NitApp(App):
866
965
  self.query_one("#diff-view", DiffView).jump_to_next_comment(forward=False)
867
966
 
868
967
  def action_cycle_mode(self) -> None:
869
- if self._cli_args.commit_range:
968
+ if self._file_review_mode or self._cli_args.commit_range:
870
969
  return
871
970
  idx = DIFF_MODES.index(self.diff_mode)
872
971
  self.diff_mode = DIFF_MODES[(idx + 1) % len(DIFF_MODES)]
@@ -914,7 +1013,12 @@ class NitApp(App):
914
1013
  diff_mode=self.diff_mode,
915
1014
  )
916
1015
  self.comments.append(comment)
917
- comments_mod.save_comments(self.repo_root, self.comments, self.branch, self.base)
1016
+ try:
1017
+ comments_mod.save_comments(
1018
+ self.repo_root, self.comments, self.branch, self.base
1019
+ )
1020
+ except Exception as e:
1021
+ self.notify(f"Failed to save comment: {e}", severity="error")
918
1022
  saved_cursor = diff_view.cursor_index
919
1023
  diff_view.hide_comment_input()
920
1024
  if self.current_file:
@@ -923,6 +1027,10 @@ class NitApp(App):
923
1027
  self._update_status()
924
1028
  self._refresh_file_labels()
925
1029
 
1030
+ def action_toggle_whitespace(self) -> None:
1031
+ self.ignore_whitespace = not self.ignore_whitespace
1032
+ self._load_diff()
1033
+
926
1034
  def action_toggle_word_diff(self) -> None:
927
1035
  dv = self.query_one("#diff-view", DiffView)
928
1036
  dv.word_diff = not dv.word_diff
@@ -931,6 +1039,14 @@ class NitApp(App):
931
1039
  file_comments = [c for c in self.comments if c.file_path == self.current_file.path]
932
1040
  dv.load_file_diff(self.current_file, file_comments, restore_cursor=saved_cursor)
933
1041
 
1042
+ def action_toggle_syntax(self) -> None:
1043
+ dv = self.query_one("#diff-view", DiffView)
1044
+ dv.syntax_highlight = not dv.syntax_highlight
1045
+ if self.current_file:
1046
+ saved_cursor = dv.cursor_index
1047
+ file_comments = [c for c in self.comments if c.file_path == self.current_file.path]
1048
+ dv.load_file_diff(self.current_file, file_comments, restore_cursor=saved_cursor)
1049
+
934
1050
  def action_toggle_side_by_side(self) -> None:
935
1051
  dv = self.query_one("#diff-view", DiffView)
936
1052
  dv.side_by_side = not dv.side_by_side
@@ -942,6 +1058,8 @@ class NitApp(App):
942
1058
  # --- Git operations (g-prefixed chords) ---
943
1059
 
944
1060
  def _action_stage_hunk(self) -> None:
1061
+ if self._file_review_mode:
1062
+ return
945
1063
  if self.diff_mode not in ("unstaged", "all"):
946
1064
  self.notify("Stage: switch to unstaged/all mode", severity="warning")
947
1065
  return
@@ -960,6 +1078,8 @@ class NitApp(App):
960
1078
  self._load_diff()
961
1079
 
962
1080
  def _action_unstage_hunk(self) -> None:
1081
+ if self._file_review_mode:
1082
+ return
963
1083
  if self.diff_mode != "staged":
964
1084
  self.notify("Unstage: switch to staged mode", severity="warning")
965
1085
  return
@@ -978,6 +1098,8 @@ class NitApp(App):
978
1098
  self._load_diff()
979
1099
 
980
1100
  def _action_discard_hunk(self) -> None:
1101
+ if self._file_review_mode:
1102
+ return
981
1103
  if self.diff_mode not in ("unstaged", "all"):
982
1104
  self.notify("Discard: switch to unstaged/all mode", severity="warning")
983
1105
  return
@@ -996,6 +1118,8 @@ class NitApp(App):
996
1118
  self._load_diff()
997
1119
 
998
1120
  def _action_commit_prompt(self) -> None:
1121
+ if self._file_review_mode:
1122
+ return
999
1123
  if self._commit_input is not None:
1000
1124
  return
1001
1125
  self._commit_input = CommitInput(
@@ -1032,6 +1156,38 @@ class NitApp(App):
1032
1156
  self._update_status()
1033
1157
  self._refresh_file_labels()
1034
1158
 
1159
+ def action_export_comments(self) -> None:
1160
+ if not self.comments:
1161
+ self.notify("No comments to export", severity="warning")
1162
+ return
1163
+ text = comments_mod.export_comments_markdown(self.comments)
1164
+ clip_cmd = None
1165
+ for cmd in ("pbcopy", "xclip", "xsel"):
1166
+ if shutil.which(cmd):
1167
+ clip_cmd = [cmd] if cmd == "pbcopy" else [cmd, "-selection", "clipboard"]
1168
+ break
1169
+ if clip_cmd is None:
1170
+ self.notify("No clipboard command found (pbcopy/xclip/xsel)", severity="error")
1171
+ return
1172
+ try:
1173
+ subprocess.run(clip_cmd, input=text, text=True, check=True, timeout=5)
1174
+ self.notify(f"Copied {len(self.comments)} comments to clipboard")
1175
+ except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
1176
+ self.notify("Clipboard copy failed", severity="error")
1177
+
1178
+
1179
+ def _export_on_quit(args: CLIArgs, comments: list[ReviewComment]) -> None:
1180
+ if not args.export_comments or not comments:
1181
+ return
1182
+ if args.export_format == "json":
1183
+ text = comments_mod.export_comments_json(comments)
1184
+ else:
1185
+ text = comments_mod.export_comments_markdown(comments)
1186
+ if args.export_comments == "-":
1187
+ print(text)
1188
+ else:
1189
+ Path(args.export_comments).write_text(text)
1190
+
1035
1191
 
1036
1192
  def main() -> None:
1037
1193
  from .cli import parse_args
@@ -1044,6 +1200,7 @@ def main() -> None:
1044
1200
  )
1045
1201
  app = NitApp(cli_args=args)
1046
1202
  app.run()
1203
+ _export_on_quit(args, app.comments)
1047
1204
 
1048
1205
 
1049
1206
  if __name__ == "__main__":
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import argparse
4
4
  import importlib.metadata
5
5
  from dataclasses import dataclass
6
+ from pathlib import Path
6
7
 
7
8
 
8
9
  @dataclass
@@ -11,6 +12,9 @@ class CLIArgs:
11
12
  commit_range: str | None = None
12
13
  path_filter: str | None = None
13
14
  verbose: bool = False
15
+ file_path: str | None = None
16
+ export_comments: str | None = None
17
+ export_format: str = "markdown"
14
18
 
15
19
 
16
20
  def build_parser() -> argparse.ArgumentParser:
@@ -47,10 +51,24 @@ def build_parser() -> argparse.ArgumentParser:
47
51
  help="Enable verbose logging to stderr",
48
52
  )
49
53
  parser.add_argument(
50
- "commit_range",
54
+ "--export-comments",
51
55
  nargs="?",
56
+ const="-",
52
57
  default=None,
53
- help="Git commit range, e.g. HEAD~3..HEAD or main..feature",
58
+ metavar="FILE",
59
+ help="Export comments on quit (- for stdout, or file path)",
60
+ )
61
+ parser.add_argument(
62
+ "--export-format",
63
+ choices=["markdown", "json"],
64
+ default="markdown",
65
+ help="Comment export format (default: markdown)",
66
+ )
67
+ parser.add_argument(
68
+ "target",
69
+ nargs="?",
70
+ default=None,
71
+ help="File path (review mode) or git commit range (e.g. HEAD~3..HEAD)",
54
72
  )
55
73
  return parser
56
74
 
@@ -58,9 +76,20 @@ def build_parser() -> argparse.ArgumentParser:
58
76
  def parse_args(argv: list[str] | None = None) -> CLIArgs:
59
77
  parser = build_parser()
60
78
  ns = parser.parse_args(argv)
79
+ target = ns.target
80
+ file_path = None
81
+ commit_range = None
82
+ if target:
83
+ if Path(target).exists():
84
+ file_path = target
85
+ else:
86
+ commit_range = target
61
87
  return CLIArgs(
62
88
  mode=ns.mode,
63
- commit_range=ns.commit_range,
89
+ commit_range=commit_range,
64
90
  path_filter=ns.path,
65
91
  verbose=ns.verbose,
92
+ file_path=file_path,
93
+ export_comments=ns.export_comments,
94
+ export_format=ns.export_format,
66
95
  )
@@ -75,6 +75,31 @@ def save_comments(
75
75
  raise
76
76
 
77
77
 
78
+ def export_comments_markdown(comments: list[ReviewComment]) -> str:
79
+ if not comments:
80
+ return ""
81
+ by_file: dict[str, list[ReviewComment]] = {}
82
+ for c in comments:
83
+ by_file.setdefault(c.file_path, []).append(c)
84
+ lines = ["# Code Review Comments", ""]
85
+ for path in sorted(by_file):
86
+ lines.append(f"## {path}")
87
+ lines.append("")
88
+ for c in sorted(by_file[path], key=lambda x: x.new_line_no or x.old_line_no or 0):
89
+ line_ref = f"L{c.new_line_no}" if c.new_line_no else f"old L{c.old_line_no}"
90
+ lines.append(f"- **{line_ref}**: {c.comment}")
91
+ if c.line_content:
92
+ lines.append(" ```")
93
+ lines.append(f" {c.line_content}")
94
+ lines.append(" ```")
95
+ lines.append("")
96
+ return "\n".join(lines)
97
+
98
+
99
+ def export_comments_json(comments: list[ReviewComment]) -> str:
100
+ return json.dumps([_comment_to_dict(c) for c in comments], indent=2)
101
+
102
+
78
103
  def comment_matches_line(c: ReviewComment, dl: DiffLine) -> bool:
79
104
  if c.new_line_no is not None and dl.new_line_no == c.new_line_no:
80
105
  return True
@@ -127,6 +127,31 @@ def parse_diff(text: str) -> list[FileDiff]:
127
127
  return files
128
128
 
129
129
 
130
+ def file_to_diff(path: str, content: str) -> list[FileDiff]:
131
+ """Create a synthetic FileDiff showing all lines as context for file review."""
132
+ lines_text = content.splitlines()
133
+ n = len(lines_text)
134
+ diff_lines = [
135
+ DiffLine(
136
+ content=f"@@ -1,{n} +1,{n} @@",
137
+ line_type="hunk_header",
138
+ raw=f"@@ -1,{n} +1,{n} @@",
139
+ )
140
+ ]
141
+ for i, line in enumerate(lines_text, 1):
142
+ diff_lines.append(
143
+ DiffLine(
144
+ content=line,
145
+ line_type="context",
146
+ old_line_no=i,
147
+ new_line_no=i,
148
+ raw=f" {line}",
149
+ )
150
+ )
151
+ hunk = DiffHunk(header=diff_lines[0].content, lines=diff_lines, old_start=1, new_start=1)
152
+ return [FileDiff(path=path, status="modified", hunks=[hunk])]
153
+
154
+
130
155
  def align_hunk_lines(lines: list[DiffLine]) -> list[SideBySideRow]:
131
156
  """Pair remove/add lines for side-by-side display."""
132
157
  rows: list[SideBySideRow] = []
@@ -65,25 +65,54 @@ def _append_path_filter(cmd: list[str], path_filter: str | None) -> list[str]:
65
65
  return cmd
66
66
 
67
67
 
68
- def get_branch_diff(cwd: Path | None = None, path_filter: str | None = None) -> str:
68
+ def _append_whitespace_flag(cmd: list[str], ignore_whitespace: bool) -> list[str]:
69
+ if ignore_whitespace:
70
+ cmd = cmd + ["-w"]
71
+ return cmd
72
+
73
+
74
+ def _build_diff_cmd(
75
+ base_cmd: list[str], ignore_whitespace: bool, path_filter: str | None
76
+ ) -> list[str]:
77
+ cmd = _append_whitespace_flag(base_cmd, ignore_whitespace)
78
+ return _append_path_filter(cmd, path_filter)
79
+
80
+
81
+ def get_branch_diff(
82
+ cwd: Path | None = None,
83
+ path_filter: str | None = None,
84
+ ignore_whitespace: bool = False,
85
+ ) -> str:
69
86
  base = get_main_branch(cwd)
70
- cmd = ["git", "diff", f"{base}...HEAD"]
71
- return _run(_append_path_filter(cmd, path_filter), cwd=cwd)
87
+ cmd = _build_diff_cmd(["git", "diff", f"{base}...HEAD"], ignore_whitespace, path_filter)
88
+ return _run(cmd, cwd=cwd)
72
89
 
73
90
 
74
- def get_unstaged_diff(cwd: Path | None = None, path_filter: str | None = None) -> str:
75
- cmd = ["git", "diff"]
76
- return _run(_append_path_filter(cmd, path_filter), cwd=cwd)
91
+ def get_unstaged_diff(
92
+ cwd: Path | None = None,
93
+ path_filter: str | None = None,
94
+ ignore_whitespace: bool = False,
95
+ ) -> str:
96
+ cmd = _build_diff_cmd(["git", "diff"], ignore_whitespace, path_filter)
97
+ return _run(cmd, cwd=cwd)
77
98
 
78
99
 
79
- def get_all_uncommitted_diff(cwd: Path | None = None, path_filter: str | None = None) -> str:
80
- cmd = ["git", "diff", "HEAD"]
81
- return _run(_append_path_filter(cmd, path_filter), cwd=cwd)
100
+ def get_all_uncommitted_diff(
101
+ cwd: Path | None = None,
102
+ path_filter: str | None = None,
103
+ ignore_whitespace: bool = False,
104
+ ) -> str:
105
+ cmd = _build_diff_cmd(["git", "diff", "HEAD"], ignore_whitespace, path_filter)
106
+ return _run(cmd, cwd=cwd)
82
107
 
83
108
 
84
- def get_staged_diff(cwd: Path | None = None, path_filter: str | None = None) -> str:
85
- cmd = ["git", "diff", "--cached"]
86
- return _run(_append_path_filter(cmd, path_filter), cwd=cwd)
109
+ def get_staged_diff(
110
+ cwd: Path | None = None,
111
+ path_filter: str | None = None,
112
+ ignore_whitespace: bool = False,
113
+ ) -> str:
114
+ cmd = _build_diff_cmd(["git", "diff", "--cached"], ignore_whitespace, path_filter)
115
+ return _run(cmd, cwd=cwd)
87
116
 
88
117
 
89
118
  def apply_patch(
@@ -116,7 +145,10 @@ def commit(message: str, cwd: Path | None = None) -> str:
116
145
 
117
146
 
118
147
  def get_commit_range_diff(
119
- commit_range: str, cwd: Path | None = None, path_filter: str | None = None
148
+ commit_range: str,
149
+ cwd: Path | None = None,
150
+ path_filter: str | None = None,
151
+ ignore_whitespace: bool = False,
120
152
  ) -> str:
121
- cmd = ["git", "diff", commit_range]
122
- return _run(_append_path_filter(cmd, path_filter), cwd=cwd)
153
+ cmd = _build_diff_cmd(["git", "diff", commit_range], ignore_whitespace, path_filter)
154
+ return _run(cmd, cwd=cwd)
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from functools import lru_cache
5
+
6
+ from rich.syntax import Syntax
7
+ from rich.text import Text
8
+
9
+
10
+ @lru_cache(maxsize=32)
11
+ def _get_syntax(language: str) -> Syntax:
12
+ """Get a cached Syntax object for a language."""
13
+ theme = "ansi_dark"
14
+ if os.environ.get("NO_COLOR"):
15
+ theme = "default"
16
+ return Syntax("", language, theme=theme, line_numbers=False)
17
+
18
+
19
+ def detect_language(file_path: str) -> str | None:
20
+ """Detect language from file extension. Returns None if unknown."""
21
+ try:
22
+ lexer = Syntax.guess_lexer(file_path)
23
+ return lexer if lexer not in ("text", "default") else None
24
+ except Exception:
25
+ return None
26
+
27
+
28
+ def highlight_line(code: str, language: str) -> Text:
29
+ """Return a Rich Text with syntax highlighting for a single line."""
30
+ syntax = _get_syntax(language)
31
+ text = syntax.highlight(code)
32
+ text.rstrip()
33
+ return text
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nit-cli
3
- Version: 0.3.0
3
+ Version: 0.4.1
4
4
  Summary: Terminal diff viewer with inline review comments
5
5
  Author: Joshua Zink-Duda
6
6
  License-Expression: GPL-3.0-only
@@ -33,6 +33,12 @@ Navigate diffs with vim-style keybindings, leave comments on specific lines, and
33
33
 
34
34
  ## Install
35
35
 
36
+ ```bash
37
+ brew install joshuazd/tap/nit
38
+ ```
39
+
40
+ Or via pip/pipx:
41
+
36
42
  ```bash
37
43
  pipx install nit-cli
38
44
  ```
@@ -60,11 +66,20 @@ nit --mode all
60
66
  nit HEAD~3..HEAD
61
67
  nit main..feature
62
68
 
69
+ # Review any file (read-only, no git required)
70
+ nit path/to/file.py
71
+
63
72
  # Filter to specific files or directories
64
73
  nit --path src/
65
74
  nit --path src/nit/app.py main..feature
75
+
76
+ # Export comments on quit
77
+ nit --export-comments comments.md
78
+ nit --export-comments - --export-format json # stdout as JSON
66
79
  ```
67
80
 
81
+ On main/master, nit defaults to unstaged mode. On feature branches, it defaults to branch diff. The diff auto-refreshes every 5 seconds.
82
+
68
83
  ## Keybindings
69
84
 
70
85
  | Key | Action |
@@ -73,12 +88,27 @@ nit --path src/nit/app.py main..feature
73
88
  | `J` / `K` | Jump to next / previous hunk |
74
89
  | `n` / `p` | Next / previous file |
75
90
  | `]` / `[` | Jump to next / previous comment |
91
+ | `gg` / `G` | Jump to top / bottom |
76
92
  | `c` | Add comment on current line |
77
93
  | `d` | Delete comment on current line |
78
- | `m` | Cycle diff mode (branch / unstaged / all) |
94
+ | `e` | Export comments to clipboard |
95
+ | `m` | Cycle diff mode (branch / unstaged / staged / all) |
96
+ | `s` | Toggle side-by-side diff view |
97
+ | `w` | Toggle word-level diff highlighting |
98
+ | `W` | Toggle hide whitespace-only changes |
99
+ | `h` | Toggle syntax highlighting |
79
100
  | `r` | Refresh diff |
80
101
  | `q` | Quit |
81
102
 
103
+ ### Git operations (g-prefixed)
104
+
105
+ | Key | Action |
106
+ |-----|--------|
107
+ | `ga` | Stage hunk under cursor |
108
+ | `gu` | Unstage hunk (staged mode) |
109
+ | `gx` | Discard hunk |
110
+ | `gc` | Commit with message prompt |
111
+
82
112
  ## Comment Storage
83
113
 
84
114
  Comments are saved to `.nit.json` at the root of your git repository. The format is structured JSON:
@@ -8,6 +8,7 @@ src/nit/comments.py
8
8
  src/nit/diff_parser.py
9
9
  src/nit/git.py
10
10
  src/nit/models.py
11
+ src/nit/syntax.py
11
12
  src/nit_cli.egg-info/PKG-INFO
12
13
  src/nit_cli.egg-info/SOURCES.txt
13
14
  src/nit_cli.egg-info/dependency_links.txt
@@ -18,4 +19,5 @@ tests/test_app.py
18
19
  tests/test_cli.py
19
20
  tests/test_comments.py
20
21
  tests/test_diff_parser.py
21
- tests/test_git.py
22
+ tests/test_git.py
23
+ tests/test_syntax.py
@@ -92,4 +92,19 @@ async def test_app_detached_head(mock_git, mock_comments):
92
92
  mock_git.get_current_branch.side_effect = Exception("not on branch")
93
93
  app = NitApp()
94
94
  async with app.run_test():
95
- assert app.branch == ""
95
+ assert app.branch == "(detached HEAD)"
96
+
97
+
98
+ async def test_g_chord_unrecognized_key_not_swallowed(mock_git, mock_comments):
99
+ app = NitApp()
100
+ async with app.run_test() as pilot:
101
+ # Press g — sets pending flag
102
+ await pilot.press("g")
103
+ assert app._pending_g is True
104
+ # Press unrecognized key — flag resets, key not swallowed
105
+ await pilot.press("z")
106
+ assert app._pending_g is False
107
+ # Valid chord still works after: gg = cursor to start
108
+ await pilot.press("g", "g")
109
+ dv = app.query_one("#diff-view")
110
+ assert dv.cursor_index == 0
@@ -2,6 +2,8 @@ import json
2
2
 
3
3
  from nit.comments import (
4
4
  comment_matches_line,
5
+ export_comments_json,
6
+ export_comments_markdown,
5
7
  load_comments,
6
8
  make_comment,
7
9
  save_comments,
@@ -114,3 +116,53 @@ def test_load_missing_comment_fields(tmp_path):
114
116
  loaded = load_comments(tmp_path)
115
117
  assert len(loaded) == 1
116
118
  assert loaded[0].file_path == "x.py"
119
+
120
+
121
+ def _make_review_comment(**overrides):
122
+ defaults = dict(
123
+ file_path="src/app.py",
124
+ new_line_no=10,
125
+ old_line_no=None,
126
+ line_content="return result",
127
+ comment="Fix this",
128
+ hunk_context=["line1", "return result"],
129
+ timestamp="2025-01-15T10:00:00+00:00",
130
+ diff_mode="branch",
131
+ )
132
+ defaults.update(overrides)
133
+ return ReviewComment(**defaults)
134
+
135
+
136
+ def test_export_comments_markdown_empty():
137
+ assert export_comments_markdown([]) == ""
138
+
139
+
140
+ def test_export_comments_markdown():
141
+ comments = [
142
+ _make_review_comment(file_path="b.py", new_line_no=5, comment="second file"),
143
+ _make_review_comment(file_path="a.py", new_line_no=10, comment="note here"),
144
+ _make_review_comment(
145
+ file_path="a.py", new_line_no=None, old_line_no=3, comment="old line comment"
146
+ ),
147
+ ]
148
+ md = export_comments_markdown(comments)
149
+ assert md.startswith("# Code Review Comments")
150
+ # Files sorted alphabetically
151
+ assert md.index("## a.py") < md.index("## b.py")
152
+ assert "**L10**: note here" in md
153
+ assert "**old L3**: old line comment" in md
154
+ assert "**L5**: second file" in md
155
+ assert "```" in md # code fence for line_content
156
+
157
+
158
+ def test_export_comments_json():
159
+ comments = [
160
+ _make_review_comment(comment="test comment"),
161
+ ]
162
+ result = export_comments_json(comments)
163
+ parsed = json.loads(result)
164
+ assert isinstance(parsed, list)
165
+ assert len(parsed) == 1
166
+ assert parsed[0]["comment"] == "test comment"
167
+ assert parsed[0]["file"] == "src/app.py"
168
+ assert parsed[0]["line"] == 10
@@ -10,21 +10,11 @@ def git_repo(tmp_path):
10
10
  """Create a temporary git repo with one commit."""
11
11
  subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True)
12
12
  subprocess.run(["git", "checkout", "-b", "main"], cwd=tmp_path, capture_output=True)
13
+ subprocess.run(["git", "config", "user.name", "test"], cwd=tmp_path, capture_output=True)
14
+ subprocess.run(["git", "config", "user.email", "t@t"], cwd=tmp_path, capture_output=True)
13
15
  (tmp_path / "file.txt").write_text("hello\n")
14
16
  subprocess.run(["git", "add", "."], cwd=tmp_path, capture_output=True)
15
- subprocess.run(
16
- ["git", "commit", "-m", "init"],
17
- cwd=tmp_path,
18
- capture_output=True,
19
- env={
20
- "GIT_AUTHOR_NAME": "test",
21
- "GIT_AUTHOR_EMAIL": "t@t",
22
- "GIT_COMMITTER_NAME": "test",
23
- "GIT_COMMITTER_EMAIL": "t@t",
24
- "HOME": str(tmp_path),
25
- "PATH": subprocess.check_output(["bash", "-c", "echo $PATH"], text=True).strip(),
26
- },
27
- )
17
+ subprocess.run(["git", "commit", "-m", "init"], cwd=tmp_path, capture_output=True)
28
18
  return tmp_path
29
19
 
30
20
 
@@ -70,22 +60,6 @@ def test_detached_head(git_repo):
70
60
  assert branch == ""
71
61
 
72
62
 
73
- GIT_ENV_KEYS = {
74
- "GIT_AUTHOR_NAME": "test",
75
- "GIT_AUTHOR_EMAIL": "t@t",
76
- "GIT_COMMITTER_NAME": "test",
77
- "GIT_COMMITTER_EMAIL": "t@t",
78
- }
79
-
80
-
81
- def _git_env(tmp_path):
82
- return {
83
- **GIT_ENV_KEYS,
84
- "HOME": str(tmp_path),
85
- "PATH": subprocess.check_output(["bash", "-c", "echo $PATH"], text=True).strip(),
86
- }
87
-
88
-
89
63
  def test_get_staged_diff_empty(git_repo):
90
64
  diff = git.get_staged_diff(cwd=git_repo)
91
65
  assert diff == ""
@@ -132,12 +106,7 @@ def test_commit(git_repo):
132
106
  (git_repo / "file.txt").write_text("committed\n")
133
107
  subprocess.run(["git", "add", "file.txt"], cwd=git_repo, capture_output=True)
134
108
  git.commit("test commit", cwd=git_repo)
135
- log = subprocess.check_output(
136
- ["git", "log", "--oneline", "-1"],
137
- cwd=git_repo,
138
- text=True,
139
- env=_git_env(git_repo),
140
- )
109
+ log = subprocess.check_output(["git", "log", "--oneline", "-1"], cwd=git_repo, text=True)
141
110
  assert "test commit" in log
142
111
 
143
112
 
@@ -0,0 +1,26 @@
1
+ from rich.text import Text
2
+
3
+ from nit.syntax import detect_language, highlight_line
4
+
5
+
6
+ def test_detect_language_python():
7
+ assert detect_language("app.py") == "python"
8
+
9
+
10
+ def test_detect_language_javascript():
11
+ assert detect_language("index.js") == "javascript"
12
+
13
+
14
+ def test_detect_language_unknown():
15
+ assert detect_language("file.xyzunknown") is None
16
+
17
+
18
+ def test_highlight_line_returns_text():
19
+ result = highlight_line("x = 1", "python")
20
+ assert isinstance(result, Text)
21
+ assert str(result).strip() == "x = 1"
22
+
23
+
24
+ def test_highlight_line_empty():
25
+ result = highlight_line("", "python")
26
+ assert isinstance(result, Text)
File without changes
File without changes
File without changes
File without changes
File without changes