Coverage for hdl_registers/generator/register_code_generator.py: 98%

164 statements  

« prev     ^ index     » next       coverage.py v7.6.10, created at 2025-01-28 20:51 +0000

1# -------------------------------------------------------------------------------------------------- 

2# Copyright (c) Lukas Vik. All rights reserved. 

3# 

4# This file is part of the hdl-registers project, an HDL register generator fast enough to run 

5# in real time. 

6# https://hdl-registers.com 

7# https://github.com/hdl-registers/hdl-registers 

8# -------------------------------------------------------------------------------------------------- 

9 

10# Standard libraries 

11import datetime 

12import re 

13from abc import ABC, abstractmethod 

14from pathlib import Path 

15from typing import TYPE_CHECKING, Any, Optional 

16 

17# Third party libraries 

18from tsfpga.git_utils import get_git_commit, git_commands_are_available 

19from tsfpga.svn_utils import get_svn_revision_information, svn_commands_are_available 

20from tsfpga.system_utils import create_file, path_relative_to, read_file 

21 

22# First party libraries 

23from hdl_registers import __version__ as hdl_registers_version 

24 

25# Local folder libraries 

26from .register_code_generator_helpers import RegisterCodeGeneratorHelpers 

27from .reserved_keywords import RESERVED_KEYWORDS 

28 

29if TYPE_CHECKING: 

30 # First party libraries 

31 from hdl_registers.register_list import RegisterList 

32 

33 

34class RegisterCodeGenerator(ABC, RegisterCodeGeneratorHelpers): 

35 """ 

36 Abstract interface and common functions for generating register code. 

37 Should be inherited by all custom code generators. 

38 

39 Note that this base class also inherits from :class:`.RegisterCodeGeneratorHelpers`, 

40 meaning some useful methods are available in subclasses. 

41 """ 

42 

43 @property 

44 @abstractmethod 

45 def SHORT_DESCRIPTION(self) -> str: # pylint: disable=invalid-name 

46 """ 

47 A short description of what this generator produces. 

48 Will be used when printing status messages. 

49 

50 Overload in subclass by setting e.g. 

51 

52 .. code-block:: python 

53 

54 SHORT_DESCRIPTION = "C++ header" 

55 

56 as a static class member at the top of the class. 

57 """ 

58 

59 @property 

60 @abstractmethod # type: ignore[override] 

61 def COMMENT_START(self) -> str: # pylint: disable=invalid-name 

62 """ 

63 The character(s) that start a comment line in the programming language that we are 

64 generating code for. 

65 

66 Overload in subclass by setting e.g. 

67 

68 .. code-block:: python 

69 

70 COMMENT_START = "#" 

71 

72 as a static class member at the top of the class. 

73 Note that for some languages you might have to set :attr:`.COMMENT_END` as well. 

74 """ 

75 

76 @property 

77 @abstractmethod 

78 def output_file(self) -> Path: 

79 """ 

80 Result will be placed in this file. 

81 

82 Overload in a subclass to give the proper name to the code artifact. 

83 Probably using a combination of ``self.output_folder`` and ``self.name``. 

84 For example: 

85 

86 .. code-block:: python 

87 

88 @property 

89 def output_file(self) -> Path: 

90 return self.output_folder / f"{self.name}_regs.html" 

91 """ 

92 

93 @abstractmethod 

94 def get_code(self, **kwargs: Any) -> str: # pylint: disable=unused-argument 

95 """ 

96 Get the generated code as a string. 

97 

98 Overload in a subclass where the code generation is implemented. 

99 

100 Arguments: 

101 kwargs: Further optional parameters that can be used. 

102 Can send any number of named arguments, per the requirements of :meth:`.get_code` 

103 of any custom generators that inherit this class. 

104 """ 

105 

106 # Optionally set a non-zero default indentation level, expressed in number of spaces, in a 

107 # subclass for your code generator. 

108 # Will be used by e.g. the 'self.comment' method. 

109 DEFAULT_INDENTATION_LEVEL = 0 

110 

111 # For some languages, comment lines have to be ended with special characters. 

112 # Optionally set this to a non-null string in a subclass. 

113 # For best formatting, leave a space character at the start of this string. 

114 COMMENT_END = "" 

115 

116 # For a custom code generator, overload this value in a subclass. 

117 # This version number of the custom code generator class is added to the file header of 

118 # generated artifacts. 

119 # Changing the number will trigger a re-generate of all artifacts when 'create_if_needed' 

120 # is called. 

121 __version__ = "0.0.1" 

122 

123 def __init__(self, register_list: "RegisterList", output_folder: Path): 

124 """ 

125 Arguments: 

126 register_list: Registers and constants from this register list will be included 

127 in the generated artifacts. 

128 output_folder: Result file will be placed in this folder. 

129 """ 

130 self.register_list = register_list 

131 self.output_folder = output_folder 

132 

133 self.name = register_list.name 

134 

135 def create(self, **kwargs: Any) -> Path: 

136 """ 

137 Generate the result artifact. 

138 I.e. create the :meth:`.output_file` containing the result from :meth:`.get_code` method. 

139 

140 Arguments: 

141 kwargs: Further optional parameters that will be sent on to the 

142 :meth:`.get_code` method. 

143 See :meth:`.get_code` for details. 

144 

145 Return: 

146 The path to the created file, i.e. :meth:`.output_file`. 

147 """ 

148 output_file = self.output_file 

149 

150 try: 

151 path_to_print = path_relative_to(path=output_file, other=Path(".")) 

152 except ValueError: 

153 # Fails on Windows if CWD and the file are on different drives. 

154 path_to_print = output_file 

155 print(f"Creating {self.SHORT_DESCRIPTION} file: {path_to_print}") 

156 

157 self._sanity_check() 

158 

159 code = self.get_code(**kwargs) 

160 result = f"{self.header}\n{code}" 

161 

162 # Will create the containing folder unless it already exists. 

163 create_file(file=output_file, contents=result) 

164 

165 return output_file 

166 

167 def create_if_needed(self, **kwargs: Any) -> tuple[bool, Path]: 

168 """ 

169 Generate the result file if needed. 

170 I.e. call :meth:`.create` if :meth:`.should_create` is ``True``. 

171 

172 This method is recommended rather than :meth:`.create` in time-critical scenarios, 

173 such as before a user simulation run. 

174 The :meth:`.should_create` check is very fast (0.5 ms on a decent computer with a typical 

175 register list), while a typical register generator is quite slow by comparison. 

176 Hence it makes sense to run this method in order to save execution time. 

177 This increased speed gives a much nicer user experience. 

178 

179 Arguments: 

180 kwargs: Further optional parameters that will be sent on to the 

181 :meth:`.get_code` method. 

182 See :meth:`.get_code` for details. 

183 

184 Return: 

185 Tuple, where first element is a boolean status, and second element is the path to the 

186 artifact that may or may not have been created. 

187 

188 The boolean is the return value of :meth:`.should_create`. 

189 

190 The path is the :meth:`.output_file` and is set always, even if the file was 

191 not created. 

192 """ 

193 if self.should_create: 

194 create_path = self.create(**kwargs) 

195 return True, create_path 

196 

197 return False, self.output_file 

198 

199 @property 

200 def should_create(self) -> bool: 

201 """ 

202 Indicates if a (re-)create of artifacts is needed. 

203 Will be True if any of these conditions are true: 

204 

205 * Output file does not exist. 

206 * Generator version of artifact does not match current code version. 

207 * Artifact hash does not match :meth:`.RegisterList.object_hash` of the current 

208 register list. 

209 I.e. something in the register list has changed since the previous file was generated 

210 (e.g. a new register added). 

211 

212 The version and hash checks above are dependent on the artifact file having a header 

213 as given by :meth:`.header`. 

214 """ 

215 output_file = self.output_file 

216 

217 if not output_file.exists(): 

218 return True 

219 

220 if ( 

221 hdl_registers_version, 

222 self.__version__, 

223 self.register_list.object_hash, 

224 ) != self._find_versions_and_hash_of_existing_file(file_path=output_file): 

225 return True 

226 

227 return False 

228 

229 def _find_versions_and_hash_of_existing_file( 

230 self, file_path: Path 

231 ) -> tuple[Optional[str], Optional[str], Optional[str]]: 

232 """ 

233 Returns the matching strings in a tuple. Either field can be ``None`` if nothing found. 

234 """ 

235 existing_file_content = read_file(file=file_path) 

236 

237 result_package_version = None 

238 result_generator_version = None 

239 result_hash = None 

240 

241 # This is either the very first line of the file, or starting on a new line. 

242 package_version_re = re.compile( 

243 ( 

244 rf"(^|\n){self.COMMENT_START} This file is automatically generated by " 

245 rf"hdl-registers version (\S+)\.{self.COMMENT_END}\n" 

246 ) 

247 ) 

248 package_version_match = package_version_re.search(existing_file_content) 

249 if package_version_match: 

250 result_package_version = package_version_match.group(2) 

251 

252 generator_version_re = re.compile( 

253 rf"\n{self.COMMENT_START} Code generator {self.__class__.__name__} " 

254 rf"version (\S+).{self.COMMENT_END}\n", 

255 ) 

256 generator_version_match = generator_version_re.search(existing_file_content) 

257 if generator_version_match: 

258 result_generator_version = generator_version_match.group(1) 

259 

260 hash_re = re.compile( 

261 rf"\n{self.COMMENT_START} Register hash ([0-9a-f]+)\.{self.COMMENT_END}\n" 

262 ) 

263 hash_match = hash_re.search(existing_file_content) 

264 if hash_match: 

265 result_hash = hash_match.group(1) 

266 

267 return result_package_version, result_generator_version, result_hash 

268 

269 @property 

270 def header(self) -> str: 

271 """ 

272 Get file header informing the user that the file is automatically generated. 

273 Basically the information from :meth:`.generated_source_info` formatted as a comment block. 

274 """ 

275 generated_source_info = self.comment_block(text=self.generated_source_info, indent=0) 

276 separator_line = self.get_separator_line(indent=0) 

277 return f"{separator_line}{generated_source_info}{separator_line}" 

278 

279 @property 

280 def generated_source_info(self) -> list[str]: 

281 """ 

282 Return lines informing the user that the file is automatically generated. 

283 Containing info about the source of the generated register information. 

284 """ 

285 # Default: Get git SHA from the user's current working directory. 

286 directory = Path(".") 

287 

288 time_info = datetime.datetime.now().strftime("%Y-%m-%d %H:%M") 

289 

290 file_info = "" 

291 if self.register_list.source_definition_file is not None: 

292 # If the source definition file does exist, get git SHA from that directory instead. 

293 directory = self.register_list.source_definition_file.parent 

294 file_info = f" from file {self.register_list.source_definition_file.name}" 

295 

296 commit_info = "" 

297 if git_commands_are_available(directory=directory): 

298 commit_info = f" at commit {get_git_commit(directory=directory)}" 

299 elif svn_commands_are_available(cwd=directory): 

300 commit_info = f" at revision {get_svn_revision_information(cwd=directory)}" 

301 

302 info = f"Generated {time_info}{file_info}{commit_info}." 

303 

304 return [ 

305 ( 

306 "This file is automatically generated by hdl-registers " 

307 f"version {hdl_registers_version}." 

308 ), 

309 f"Code generator {self.__class__.__name__} version {self.__version__}.", 

310 info, 

311 f"Register hash {self.register_list.object_hash}.", 

312 ] 

313 

314 def _sanity_check(self) -> None: 

315 """ 

316 Do some basic checks that no naming errors are present. 

317 Will raise exception if there is any error. 

318 

319 In general, the user will know if these errors are present when the generated code is 

320 compiled/used since it will probably crash. 

321 But it is better to warn early, rather than the user finding out when compiling headers 

322 after a 1-hour FPGA build. 

323 

324 We run this check at creation time, always and for every single generator. 

325 Hence the user will hopefully get warned when they generate e.g. a VHDL package at the start 

326 of the FPGA build that a register uses a reserved C++ name. 

327 This check takes roughly 140 us on a decent computer with a typical register list. 

328 Hence it is not a big deal that it might be run more than once for each register list. 

329 

330 It is better to have it here in the generator rather than in the parser: 

331 1. Here it runs only when necessary, not adding time to parsing which is often done in 

332 real time. 

333 2. We also catch things that were added with the Python API. 

334 """ 

335 self._check_reserved_keywords() 

336 self._check_for_name_clashes() 

337 

338 def _check_reserved_keywords(self) -> None: 

339 """ 

340 Check that no item in the register list matches a reserved keyword in any of the targeted 

341 generator languages. 

342 To minimize the risk that a generated artifact does not compile. 

343 """ 

344 

345 def check(name: str, description: str) -> None: 

346 if name.lower() in RESERVED_KEYWORDS: 

347 message = ( 

348 f'Error in register list "{self.name}": ' 

349 f'{description} name "{name}" is a reserved keyword.' 

350 ) 

351 raise ValueError(message) 

352 

353 for constant in self.register_list.constants: 

354 check(name=constant.name, description="Constant") 

355 

356 for register, _ in self.iterate_registers(): 

357 check(name=register.name, description="Register") 

358 

359 for field in register.fields: 

360 check(name=field.name, description="Field") 

361 

362 for register_array in self.iterate_register_arrays(): 

363 check(name=register_array.name, description="Register array") 

364 

365 def _check_for_name_clashes(self) -> None: 

366 """ 

367 Check that there are no name clashes between items in the register list. 

368 To minimize the risk that a generated artifact does not compile. 

369 """ 

370 self._check_for_constant_name_clashes() 

371 self._check_for_top_level_name_clashes() 

372 self._check_for_field_name_clashes() 

373 self._check_for_qualified_name_clashes() 

374 

375 def _check_for_constant_name_clashes(self) -> None: 

376 """ 

377 Check that there are no constants with the same name. 

378 """ 

379 constant_names = set() 

380 for constant in self.register_list.constants: 

381 if constant.name in constant_names: 

382 message = ( 

383 f'Error in register list "{self.name}": ' 

384 f'Duplicate constant name "{constant.name}".' 

385 ) 

386 raise ValueError(message) 

387 

388 constant_names.add(constant.name) 

389 

390 def _check_for_top_level_name_clashes(self) -> None: 

391 """ 

392 Check that there are no 

393 * duplicate register names, 

394 * duplicate register array names, 

395 * register array names that clash with register names. 

396 """ 

397 plain_register_names = set() 

398 for register in self.iterate_plain_registers(): 

399 if register.name in plain_register_names: 

400 message = ( 

401 f'Error in register list "{self.name}": ' 

402 f'Duplicate plain register name "{register.name}".' 

403 ) 

404 raise ValueError(message) 

405 

406 plain_register_names.add(register.name) 

407 

408 register_array_names = set() 

409 for register_array in self.iterate_register_arrays(): 

410 if register_array.name in register_array_names: 

411 message = ( 

412 f'Error in register list "{self.name}": ' 

413 f'Duplicate register array name "{register_array.name}".' 

414 ) 

415 raise ValueError(message) 

416 

417 if register_array.name in plain_register_names: 

418 message = ( 

419 f'Error in register list "{self.name}": ' 

420 f'Register array "{register_array.name}" may not have same name as register.' 

421 ) 

422 raise ValueError(message) 

423 

424 register_array_names.add(register_array.name) 

425 

426 def _check_for_field_name_clashes(self) -> None: 

427 """ 

428 Check that no register contains fields with the same name. 

429 """ 

430 for register, register_array in self.iterate_registers(): 

431 field_names = set() 

432 

433 for field in register.fields: 

434 if field.name in field_names: 

435 register_description = ( 

436 f"{register_array.name}.{register.name}" 

437 if register_array 

438 else register.name 

439 ) 

440 message = ( 

441 f'Error in register list "{self.name}": ' 

442 f'Duplicate field name "{field.name}" in register "{register_description}".' 

443 ) 

444 raise ValueError(message) 

445 

446 field_names.add(field.name) 

447 

448 def _check_for_qualified_name_clashes(self) -> None: 

449 """ 

450 Check that there are no name clashes when names of registers and fields are qualified. 

451 

452 The register 'apa_hest' will give a conflict with the field 'apa.hest' since both will get 

453 e.g. a VHDL simulation method 'read_apa_hest'. 

454 Hence we need to check for these conflicts. 

455 """ 

456 qualified_names = set() 

457 

458 for register, register_array in self.iterate_registers(): 

459 register_name = self.qualified_register_name( 

460 register=register, register_array=register_array 

461 ) 

462 register_description = ( 

463 f"{register_array.name}.{register.name}" if register_array else register.name 

464 ) 

465 

466 if register_name in qualified_names: 

467 message = ( 

468 f'Error in register list "{self.name}": ' 

469 f'Qualified name of register "{register_description}" ' 

470 f'("{register_name}") clashes with another item.' 

471 ) 

472 raise ValueError(message) 

473 

474 qualified_names.add(register_name) 

475 

476 for field in register.fields: 

477 field_name = self.qualified_field_name( 

478 register=register, register_array=register_array, field=field 

479 ) 

480 

481 if field_name in qualified_names: 

482 field_description = f"{register_description}.{field.name}" 

483 message = ( 

484 f'Error in register list "{self.name}": ' 

485 f'Qualified name of field "{field_description}" ' 

486 f'("{field_name}") clashes with another item.' 

487 ) 

488 raise ValueError(message) 

489 

490 qualified_names.add(field_name)