Coverage for jsonschema_diff/color/stages/path.py: 92%
97 statements
« prev ^ index » next coverage.py v7.10.5, created at 2025-08-25 07:00 +0000
« prev ^ index » next coverage.py v7.10.5, created at 2025-08-25 07:00 +0000
1from __future__ import annotations
3"""
4JSON-Pointer path high-lighter
5==============================
7Rich-native version of the original ``PathHighlighter`` that styles a
8:class:`rich.text.Text` object **in place** instead of emitting raw ANSI. It
9distinguishes:
11* brackets ``[ ... ]`` and dots ``.`` → *base colour*
12* quoted strings inside brackets → *string colour*
13* numbers inside brackets → *number colour*
14* property names before the final ``:``:
15 * intermediate path components → *path_prop colour*
16 * the final property → *prop colour*
18Only the public constructor and :meth:`colorize_line` are part of the public
19API; everything else is an implementation detail.
20"""
21from typing import List, Optional, Tuple
23from rich.style import Style
24from rich.text import Text
26from ..abstraction import LineHighlighter
29class PathHighlighter(LineHighlighter):
30 """Colourise JSON-pointer-like paths.
32 Parameters
33 ----------
34 base_color :
35 Colour for structural characters (``.[]:``).
36 string_color :
37 Colour for quoted strings inside brackets.
38 number_color :
39 Colour for numeric indices inside brackets.
40 path_prop_color :
41 Colour for non-final property names.
42 prop_color :
43 Colour for the final property (right before the ``:``).
44 """
46 def __init__( # noqa: D401 – imperative mood is fine in NumPy style
47 self,
48 *,
49 base_color: str = "grey70",
50 string_color: str = "yellow",
51 number_color: str = "magenta",
52 path_prop_color: str = "color(103)",
53 prop_color: str = "color(146)",
54 ) -> None:
55 self.base_style = Style(color=base_color)
56 self.string_style = Style(color=string_color)
57 self.number_style = Style(color=number_color)
58 self.path_prop_style = Style(color=path_prop_color)
59 self.prop_style = Style(color=prop_color)
61 # ------------------------------------------------------------------
62 # Public API
63 # ------------------------------------------------------------------
64 def colorize_line(self, line: Text) -> Text:
65 """Apply path styling **in place** and return the same ``Text``.
67 Parameters
68 ----------
69 line :
70 The :class:`rich.text.Text` object to be stylised.
72 Returns
73 -------
74 rich.text.Text
75 The *modified* object (for fluent method chaining).
76 """
77 s = line.plain
79 # --- Find path boundaries -------------------------------------
80 first_dot = s.find(".")
81 first_br = s.find("[")
82 starts = [i for i in (first_dot, first_br) if i != -1]
83 if not starts:
84 return line # nothing to colourise
85 path_start = min(starts)
86 colon = s.find(":")
87 path_end = colon if colon != -1 else len(s)
88 if path_start >= path_end:
89 return line
91 # --- Scan char‑by‑char to locate identifiers and brackets ------
92 i = path_start
93 dot_name_spans: List[Tuple[int, int]] = [] # absolute [start,end)
95 def is_ident_start(ch: str) -> bool:
96 return ch.isalpha() or ch in "_$"
98 def is_ident_part(ch: str) -> bool:
99 return ch.isalnum() or ch in "_$"
101 while i < path_end:
102 ch = s[i]
104 # .identifier
105 if ch == "." and i + 1 < path_end and is_ident_start(s[i + 1]):
106 # the dot itself
107 line.stylize(self.base_style, i, i + 1)
108 j = i + 2
109 while j < path_end and is_ident_part(s[j]):
110 j += 1
111 dot_name_spans.append((i + 1, j)) # name without the dot
112 i = j
113 continue
115 # [ ... ]
116 if ch == "[":
117 # '['
118 line.stylize(self.base_style, i, i + 1)
119 j = i + 1
120 while j < path_end and s[j] != "]":
121 j += 1
123 inner_start = i + 1
124 inner_end = j
125 if inner_start < inner_end:
126 inner = s[inner_start:inner_end]
127 inner_stripped = inner.strip()
128 # quoted string "..." / '...'
129 if (inner_stripped.startswith('"') and inner_stripped.endswith('"')) or (
130 inner_stripped.startswith("'") and inner_stripped.endswith("'")
131 ):
132 lead_ws = len(inner) - len(inner.lstrip())
133 trail_ws = len(inner) - len(inner.rstrip())
134 a = inner_start + lead_ws
135 b = inner_end - trail_ws
136 line.stylize(self.string_style, a, b)
137 else:
138 # numbers
139 k = inner_start
140 while k < inner_end:
141 if s[k].isspace():
142 k += 1
143 continue
144 if s[k] == "-" or s[k].isdigit():
145 t0 = k
146 if s[k] == "-":
147 k += 1
148 while k < inner_end and s[k].isdigit():
149 k += 1
150 line.stylize(self.number_style, t0, k)
151 else:
152 k += 1
153 # ']'
154 if j < path_end and s[j] == "]":
155 line.stylize(self.base_style, j, j + 1)
156 i = j + 1
157 else:
158 i = path_end
159 continue
161 i += 1
163 # --- Determine final property (before ':') ---------------------
164 final_idx: Optional[int] = None
165 if dot_name_spans:
166 k = path_end - 1
167 while k >= path_start and s[k].isspace():
168 k -= 1
169 for idx, (a, b) in enumerate(dot_name_spans):
170 if a <= k < b:
171 final_idx = idx
172 if final_idx is None:
173 final_idx = len(dot_name_spans) - 1
175 # colourise property names
176 for idx, (a, b) in enumerate(dot_name_spans):
177 style = self.prop_style if idx == final_idx else self.path_prop_style
178 line.stylize(style, a, b)
180 # --- Ensure dots & brackets use base style ---------------------
181 seg = s[path_start:path_end]
182 for off, ch in enumerate(seg):
183 if ch in ".[]":
184 pos = path_start + off
185 line.stylize(self.base_style, pos, pos + 1)
187 # highlight ':' with base style
188 if colon != -1:
189 line.stylize(self.base_style, colon, colon + 1)
191 return line