Coverage for src / jsonclark / parser.py: 81%

140 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2025-12-16 12:11 +0300

1"""JSON parser with support for comments using Lark.""" 

2 

3from dataclasses import dataclass, field 

4from typing import Any, Optional, IO 

5 

6from lark import Lark, Transformer, v_args 

7 

8 

9# Lark grammar for JSON with comments 

10JSON_WITH_COMMENTS_GRAMMAR = r""" 

11 ?value: object 

12 | array 

13 | string 

14 | number 

15 | "true" -> true 

16 | "false" -> false 

17 | "null" -> null 

18 

19 array : "[" [value ("," value)*] "]" 

20 object : "{" [pair ("," pair)*] "}" 

21 pair : string ":" value 

22 

23 string : ESCAPED_STRING 

24 

25 number : SIGNED_NUMBER 

26 

27 %import common.ESCAPED_STRING 

28 %import common.SIGNED_NUMBER 

29 %import common.WS 

30 %import common.C_COMMENT 

31 %import common.CPP_COMMENT 

32 

33 %ignore WS 

34 %ignore C_COMMENT 

35 %ignore CPP_COMMENT 

36""" 

37 

38 

39class JSONList(list): 

40 """A list subclass that preserves comment metadata without showing it in repr.""" 

41 

42 def __init__(self, *args, **kwargs): 

43 super().__init__(*args, **kwargs) 

44 self._comments_before: list[str] = [] 

45 self._comments_after: list[str] = [] 

46 self._element_comments: dict[int, tuple[list[str], list[str]]] = {} 

47 

48 def __repr__(self) -> str: 

49 """Return clean repr that mimics standard list.""" 

50 return list.__repr__(self) 

51 

52 def __eq__(self, other: Any) -> bool: 

53 """Compare as standard list.""" 

54 if isinstance(other, list): 

55 return list.__eq__(self, other) 

56 return False 

57 

58 def set_comments_before(self, comments: list[str]) -> None: 

59 """Set comments that appear before this list.""" 

60 self._comments_before = comments 

61 

62 def set_comments_after(self, comments: list[str]) -> None: 

63 """Set comments that appear after this list.""" 

64 self._comments_after = comments 

65 

66 def set_element_comments( 

67 self, 

68 index: int, 

69 before: Optional[list[str]] = None, 

70 after: Optional[list[str]] = None, 

71 ) -> None: 

72 """Set comments for a specific element.""" 

73 if before is None: 

74 before = [] 

75 if after is None: 

76 after = [] 

77 self._element_comments[index] = (before, after) 

78 

79 def get_comments(self) -> dict[str, Any]: 

80 """Get all comment metadata for serialization.""" 

81 return { 

82 "before": self._comments_before, 

83 "after": self._comments_after, 

84 "elements": self._element_comments, 

85 } 

86 

87 

88class JSONDict(dict): 

89 """A dict subclass that preserves comment metadata without showing it in repr.""" 

90 

91 def __init__(self, *args, **kwargs): 

92 super().__init__(*args, **kwargs) 

93 self._comments_before: list[str] = [] 

94 self._comments_after: list[str] = [] 

95 self._key_comments: dict[str, tuple[list[str], list[str]]] = {} 

96 

97 def __repr__(self) -> str: 

98 """Return clean repr that mimics standard dict.""" 

99 return dict.__repr__(self) 

100 

101 def __eq__(self, other: Any) -> bool: 

102 """Compare as standard dict.""" 

103 if isinstance(other, dict): 

104 return dict.__eq__(self, other) 

105 return False 

106 

107 def set_comments_before(self, comments: list[str]) -> None: 

108 """Set comments that appear before this dict.""" 

109 self._comments_before = comments 

110 

111 def set_comments_after(self, comments: list[str]) -> None: 

112 """Set comments that appear after this dict.""" 

113 self._comments_after = comments 

114 

115 def set_key_comments( 

116 self, 

117 key: str, 

118 before: Optional[list[str]] = None, 

119 after: Optional[list[str]] = None, 

120 ) -> None: 

121 """Set comments for a specific key.""" 

122 if before is None: 

123 before = [] 

124 if after is None: 

125 after = [] 

126 self._key_comments[key] = (before, after) 

127 

128 def get_comments(self) -> dict[str, Any]: 

129 """Get all comment metadata for serialization.""" 

130 return { 

131 "before": self._comments_before, 

132 "after": self._comments_after, 

133 "keys": self._key_comments, 

134 } 

135 

136 

137@dataclass 

138class JSONValue: 

139 """Represents a JSON value with optional associated comments.""" 

140 

141 value: Any 

142 comments_before: list[str] = field(default_factory=list) 

143 comments_after: list[str] = field(default_factory=list) 

144 

145 def __repr__(self) -> str: 

146 return f"JSONValue(value={self.value!r}, comments_before={self.comments_before}, comments_after={self.comments_after})" 

147 

148 

149class JSONTransformer(Transformer): 

150 """Transformer that converts Lark tree to Python objects.""" 

151 

152 @v_args(inline=True) 

153 def string(self, s: Any) -> str: 

154 return s.value[1:-1].encode().decode("unicode_escape") 

155 

156 @v_args(inline=True) 

157 def number(self, n: Any) -> float | int: 

158 val = n.value 

159 return int(val) if "." not in val and "e" not in val.lower() else float(val) 

160 

161 def array(self, items: list) -> JSONList: 

162 filtered = [item for item in items if item is not None] 

163 result = JSONList(filtered) 

164 return result 

165 

166 def pair(self, items: list) -> tuple: 

167 return tuple(items) 

168 

169 def object(self, items: list) -> JSONDict: 

170 filtered = [item for item in items if item is not None] 

171 result = JSONDict(dict(filtered)) 

172 return result 

173 

174 def true(self, _=None) -> bool: 

175 return True 

176 

177 def false(self, _=None) -> bool: 

178 return False 

179 

180 def null(self, _=None) -> None: 

181 return None 

182 

183 

184class JSONDecoder: 

185 """Decoder for JSON with comments, similar to json.JSONDecoder.""" 

186 

187 def __init__(self) -> None: 

188 self.parser = Lark( 

189 JSON_WITH_COMMENTS_GRAMMAR, 

190 start="value", 

191 parser="lalr", 

192 transformer=JSONTransformer(), 

193 ) 

194 self._extracted_comments: list[str] = [] 

195 

196 def decode(self, s: str) -> Any: 

197 """Decode a JSON string, ignoring comments. 

198 

199 Args: 

200 s: JSON string possibly containing C-style comments 

201 

202 Returns: 

203 Parsed Python object (JSONDict/JSONList/str/number/bool/None) 

204 

205 Raises: 

206 lark.exceptions.LarkError: If JSON is invalid 

207 """ 

208 self._extracted_comments = self._extract_comments(s) 

209 return self.parser.parse(s) 

210 

211 def decode_with_comments(self, s: str) -> tuple[Any, list[str]]: 

212 """Decode JSON and return both value and extracted comments. 

213 

214 Args: 

215 s: JSON string possibly containing C-style comments 

216 

217 Returns: 

218 Tuple of (parsed_value, comments_list) 

219 """ 

220 comments = self._extract_comments(s) 

221 value = self.decode(s) 

222 return value, comments 

223 

224 # Backward compatibility methods 

225 def parse(self, text: str) -> Any: 

226 """Backward compatibility method. Use decode() instead.""" 

227 return self.decode(text) 

228 

229 def parse_with_comments(self, text: str) -> dict[str, Any]: 

230 """Backward compatibility method. Use decode_with_comments() instead.""" 

231 value, comments = self.decode_with_comments(text) 

232 return {"value": value, "comments": comments} 

233 

234 @staticmethod 

235 def _extract_comments(text: str) -> list[str]: 

236 """Extract all comments from JSON text.""" 

237 comments = [] 

238 i = 0 

239 while i < len(text): 

240 if i < len(text) - 1 and text[i : i + 2] == "//": 

241 end = text.find("\n", i) 

242 if end == -1: 

243 end = len(text) 

244 comment = text[i + 2 : end].strip() 

245 if comment: 

246 comments.append(comment) 

247 i = end 

248 elif i < len(text) - 1 and text[i : i + 2] == "/*": 

249 end = text.find("*/", i + 2) 

250 if end != -1: 

251 comment = text[i + 2 : end].strip() 

252 if comment: 

253 comments.append(comment) 

254 i = end + 2 

255 else: 

256 i += 1 

257 else: 

258 i += 1 

259 return comments 

260 

261 

262# Module-level decoder instance (like json module) 

263_default_decoder = JSONDecoder() 

264 

265 

266def loads(s: str) -> Any: 

267 """Deserialize a JSON string with comments to a Python object. 

268 

269 Similar to json.loads(), but supports C-style comments (// and /* */). 

270 

271 Args: 

272 s: JSON string possibly containing comments 

273 

274 Returns: 

275 Parsed Python object (JSONDict/JSONList/str/number/bool/None) 

276 

277 Raises: 

278 lark.exceptions.LarkError: If JSON is invalid 

279 

280 Examples: 

281 >>> data = loads('{\"name\": \"Alice\"} // comment') 

282 >>> data['name'] 

283 'Alice' 

284 """ 

285 return _default_decoder.decode(s) 

286 

287 

288def load(fp: IO[str]) -> Any: 

289 """Deserialize a JSON file with comments to a Python object. 

290 

291 Similar to json.load(), but supports C-style comments (// and /* */). 

292 

293 Args: 

294 fp: File-like object containing JSON with possible comments 

295 

296 Returns: 

297 Parsed Python object (JSONDict/JSONList/str/number/bool/None) 

298 

299 Raises: 

300 lark.exceptions.LarkError: If JSON is invalid 

301 

302 Examples: 

303 >>> with open('config.json') as f: 

304 ... config = load(f) 

305 """ 

306 return loads(fp.read()) 

307 

308 

309def loads_comments(s: str) -> tuple[Any, list[str]]: 

310 """Deserialize JSON with comments, returning value and extracted comments. 

311 

312 Args: 

313 s: JSON string possibly containing comments 

314 

315 Returns: 

316 Tuple of (parsed_value, comments_list) 

317 

318 Examples: 

319 >>> data, comments = loads_comments('// Config\\n{\"x\": 1}') 

320 >>> data 

321 {'x': 1} 

322 >>> comments 

323 ['Config'] 

324 """ 

325 return _default_decoder.decode_with_comments(s) 

326 

327 

328def load_comments(fp: IO[str]) -> tuple[Any, list[str]]: 

329 """Deserialize JSON file with comments, returning value and extracted comments. 

330 

331 Args: 

332 fp: File-like object containing JSON with possible comments 

333 

334 Returns: 

335 Tuple of (parsed_value, comments_list) 

336 

337 Examples: 

338 >>> with open('config.json') as f: 

339 ... config, comments = load_comments(f) 

340 """ 

341 return loads_comments(fp.read()) 

342 

343 

344def extract_comments(text: str) -> list[str]: 

345 """Extract all comments from JSON text. 

346 

347 Args: 

348 text: JSON string possibly containing comments 

349 

350 Returns: 

351 List of comment strings (without the comment delimiters) 

352 

353 Examples: 

354 >>> extract_comments('// comment\\n{}') 

355 ['comment'] 

356 """ 

357 return JSONDecoder._extract_comments(text) 

358 

359 

360# Convenience aliases for backward compatibility 

361load_json_with_comments = loads 

362load_json_preserving_comments = loads_comments 

363JSONWithCommentsParser = JSONDecoder 

364 

365 

366# Backward compatibility wrapper for old API 

367def _old_style_load_json_preserving_comments(s: str) -> dict[str, Any]: 

368 """Old API wrapper that returns dict with 'value' and 'comments' keys.""" 

369 value, comments = loads_comments(s) 

370 return {"value": value, "comments": comments} 

371 

372 

373# Override the alias to use the wrapper 

374load_json_preserving_comments = _old_style_load_json_preserving_comments