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

1from __future__ import annotations 

2 

3""" 

4JSON-Pointer path high-lighter 

5============================== 

6 

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: 

10 

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* 

17 

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 

22 

23from rich.style import Style 

24from rich.text import Text 

25 

26from ..abstraction import LineHighlighter 

27 

28 

29class PathHighlighter(LineHighlighter): 

30 """Colourise JSON-pointer-like paths. 

31 

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 """ 

45 

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) 

60 

61 # ------------------------------------------------------------------ 

62 # Public API 

63 # ------------------------------------------------------------------ 

64 def colorize_line(self, line: Text) -> Text: 

65 """Apply path styling **in place** and return the same ``Text``. 

66 

67 Parameters 

68 ---------- 

69 line : 

70 The :class:`rich.text.Text` object to be stylised. 

71 

72 Returns 

73 ------- 

74 rich.text.Text 

75 The *modified* object (for fluent method chaining). 

76 """ 

77 s = line.plain 

78 

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 

90 

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) 

94 

95 def is_ident_start(ch: str) -> bool: 

96 return ch.isalpha() or ch in "_$" 

97 

98 def is_ident_part(ch: str) -> bool: 

99 return ch.isalnum() or ch in "_$" 

100 

101 while i < path_end: 

102 ch = s[i] 

103 

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 

114 

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 

122 

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 

160 

161 i += 1 

162 

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 

174 

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) 

179 

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) 

186 

187 # highlight ':' with base style 

188 if colon != -1: 

189 line.stylize(self.base_style, colon, colon + 1) 

190 

191 return line