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

174 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-09 00:40 +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 

10from __future__ import annotations 

11 

12import datetime 

13import re 

14from abc import ABC, abstractmethod 

15from pathlib import Path 

16from typing import TYPE_CHECKING, Any 

17 

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, read_file 

21 

22from hdl_registers import __version__ as hdl_registers_version 

23from hdl_registers.field.enumeration import Enumeration 

24 

25from .register_code_generator_helpers import RegisterCodeGeneratorHelpers 

26from .reserved_keywords import RESERVED_KEYWORDS 

27 

28if TYPE_CHECKING: 

29 from hdl_registers.register_list import RegisterList 

30 

31 

32class RegisterCodeGenerator(ABC, RegisterCodeGeneratorHelpers): 

33 """ 

34 Abstract interface and common functions for generating register code. 

35 Should be inherited by all custom code generators. 

36 

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

38 meaning some useful methods are available in subclasses. 

39 """ 

40 

41 @property 

42 @abstractmethod 

43 def SHORT_DESCRIPTION(self) -> str: # noqa: N802 

44 """ 

45 A short description of what this generator produces. 

46 Will be used when printing status messages. 

47 

48 Overload in subclass by setting e.g. 

49 

50 .. code-block:: python 

51 

52 class MyCoolGenerator(RegisterCodeGenerator): 

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 

61 def COMMENT_START(self) -> str: # noqa: N802 

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 class MyCoolGenerator(RegisterCodeGenerator): 

71 

72 COMMENT_START = "#" 

73 

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

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

76 """ 

77 

78 @property 

79 @abstractmethod 

80 def output_file(self) -> Path: 

81 """ 

82 Result will be placed in this file. 

83 

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

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

86 For example: 

87 

88 .. code-block:: python 

89 

90 @property 

91 def output_file(self) -> Path: 

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

93 """ 

94 

95 @abstractmethod 

96 def get_code( 

97 self, 

98 **kwargs: Any, # noqa: ANN401 

99 ) -> str: 

100 """ 

101 Get the generated code as a string. 

102 

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

104 

105 Arguments: 

106 kwargs: Further optional parameters that can be used. 

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

108 of any custom generators that inherit this class. 

109 """ 

110 

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

112 # subclass for your code generator. 

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

114 DEFAULT_INDENTATION_LEVEL = 0 

115 

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

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

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

119 COMMENT_END = "" 

120 

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

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

123 # generated artifacts. 

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

125 # is called. 

126 __version__ = "0.0.1" 

127 

128 def __init__(self, register_list: RegisterList, output_folder: Path) -> None: 

129 """ 

130 Arguments: 

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

132 in the generated artifacts. 

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

134 """ 

135 self.register_list = register_list 

136 self.output_folder = output_folder 

137 

138 self.name = register_list.name 

139 

140 def create( 

141 self, 

142 **kwargs: Any, # noqa: ANN401 

143 ) -> Path: 

144 """ 

145 Generate the result artifact. 

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

147 

148 Arguments: 

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

150 :meth:`.get_code` method. 

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

152 

153 Return: 

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

155 """ 

156 output_file = self.output_file 

157 

158 current_working_directory = Path.cwd() 

159 if current_working_directory in output_file.parents: 

160 # Print a shorter path if the output is inside the CWD. 

161 path_to_print = output_file.relative_to(current_working_directory) 

162 else: 

163 # But don't print a long string of ../../ if it is outside, but instead the full path. 

164 path_to_print = output_file 

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

166 

167 self._sanity_check() 

168 

169 # Constructing the code and creating the file is in a separate method that can 

170 # be overridden. 

171 # Everything above should be common though. 

172 return self._create_artifact(output_file=output_file, **kwargs) 

173 

174 def _create_artifact( 

175 self, 

176 output_file: Path, 

177 **kwargs: Any, # noqa: ANN401 

178 ) -> Path: 

179 """ 

180 The last stage of creating the file: Constructs the code and creates the file. 

181 This method is called by :meth:`.create` and should not be called directly. 

182 It is separated out to make it easier to override some specific behaviors in subclasses. 

183 """ 

184 code = self.get_code(**kwargs) 

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

186 

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

188 return create_file(file=output_file, contents=result) 

189 

190 def create_if_needed( 

191 self, 

192 **kwargs: Any, # noqa: ANN401 

193 ) -> tuple[bool, Path]: 

194 """ 

195 Generate the result file if needed. 

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

197 

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

199 such as before a user simulation run. 

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

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

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

203 This increased speed gives a much nicer user experience. 

204 

205 Arguments: 

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

207 :meth:`.get_code` method. 

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

209 

210 Return: 

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

212 artifact that may or may not have been created. 

213 

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

215 

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

217 not created. 

218 """ 

219 if self.should_create: 

220 create_path = self.create(**kwargs) 

221 return True, create_path 

222 

223 return False, self.output_file 

224 

225 @property 

226 def should_create(self) -> bool: 

227 """ 

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

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

230 

231 * Output file does not exist. 

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

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

234 register list. 

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

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

237 

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

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

240 """ 

241 return self._should_create(file_path=self.output_file) 

242 

243 def _should_create(self, file_path: Path) -> bool: 

244 """ 

245 Check if a (re-)create of this specific artifact is needed. 

246 """ 

247 if not file_path.exists(): 

248 return True 

249 

250 return self._find_versions_and_hash_of_existing_file(file_path=file_path) != ( 

251 hdl_registers_version, 

252 self.__version__, 

253 self.register_list.object_hash, 

254 ) 

255 

256 def _find_versions_and_hash_of_existing_file( 

257 self, file_path: Path 

258 ) -> tuple[str | None, str | None, str | None]: 

259 """ 

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

261 """ 

262 existing_file_content = read_file(file=file_path) 

263 

264 result_package_version = None 

265 result_generator_version = None 

266 result_hash = None 

267 

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

269 package_version_re = re.compile( 

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

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

272 ) 

273 package_version_match = package_version_re.search(existing_file_content) 

274 if package_version_match: 

275 result_package_version = package_version_match.group(2) 

276 

277 generator_version_re = re.compile( 

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

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

280 ) 

281 generator_version_match = generator_version_re.search(existing_file_content) 

282 if generator_version_match: 

283 result_generator_version = generator_version_match.group(1) 

284 

285 hash_re = re.compile( 

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

287 ) 

288 hash_match = hash_re.search(existing_file_content) 

289 if hash_match: 

290 result_hash = hash_match.group(1) 

291 

292 return result_package_version, result_generator_version, result_hash 

293 

294 @property 

295 def header(self) -> str: 

296 """ 

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

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

299 """ 

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

301 separator_line = self.get_separator_line(indent=0) 

302 return f"{separator_line}{generated_source_info}{separator_line}" 

303 

304 @property 

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

306 """ 

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

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

309 """ 

310 return self._get_generated_source_info(use_rst_annotation=False) 

311 

312 def _get_generated_source_info(self, use_rst_annotation: bool) -> list[str]: 

313 """ 

314 Lines with info about the automatically generated file. 

315 """ 

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

317 directory = Path.cwd() 

318 

319 # This call will more or less guess the user's timezone. 

320 # In a general use case this is not reliable, hence the rule, but in our case 

321 # the date information is not critical in any way. 

322 # It is just there for extra info. 

323 # https://docs.astral.sh/ruff/rules/call-datetime-now-without-tzinfo/ 

324 time_info = datetime.datetime.now().strftime("%Y-%m-%d %H:%M") # noqa: DTZ005 

325 

326 annotation = "``" if use_rst_annotation else "" 

327 

328 file_info = "" 

329 if self.register_list.source_definition_file is not None: 

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

331 directory = self.register_list.source_definition_file.parent 

332 

333 file_name = f"{annotation}{self.register_list.source_definition_file.name}{annotation}" 

334 file_info = f" from file {file_name}" 

335 

336 commit_info = "" 

337 if git_commands_are_available(directory=directory): 

338 git_commit = get_git_commit(directory=directory, use_rst_annotation=use_rst_annotation) 

339 commit_info = f" at Git commit {git_commit}" 

340 elif svn_commands_are_available(cwd=directory): 

341 svn_revision = get_svn_revision_information( 

342 cwd=directory, use_rst_annotation=use_rst_annotation 

343 ) 

344 commit_info = f" at SVN revision {svn_revision}" 

345 

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

347 

348 name_link = ( 

349 "`hdl-registers <https://hdl-registers.com>`_" 

350 if use_rst_annotation 

351 else "hdl-registers" 

352 ) 

353 

354 return [ 

355 f"This file is automatically generated by {name_link} version {hdl_registers_version}.", 

356 ( 

357 f"Code generator {annotation}{self.__class__.__name__}{annotation} " 

358 f"version {self.__version__}." 

359 ), 

360 info, 

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

362 ] 

363 

364 def _sanity_check(self) -> None: 

365 """ 

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

367 Will raise exception if there is any error. 

368 

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

370 compiled/used since it will probably crash. 

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

372 after a 1-hour FPGA build. 

373 

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

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

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

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

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

379 

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

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

382 real time. 

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

384 """ 

385 self._check_reserved_keywords() 

386 self._check_for_name_clashes() 

387 

388 def _check_reserved_keywords(self) -> None: 

389 """ 

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

391 generator languages. 

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

393 """ 

394 

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

396 if name.lower() in RESERVED_KEYWORDS: 

397 message = ( 

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

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

400 ) 

401 raise ValueError(message) 

402 

403 for constant in self.register_list.constants: 

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

405 

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

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

408 

409 for field in register.fields: 

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

411 

412 if isinstance(field, Enumeration): 

413 for element in field.elements: 

414 check(name=element.name, description="Enumeration element") 

415 

416 for register_array in self.iterate_register_arrays(): 

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

418 

419 def _check_for_name_clashes(self) -> None: 

420 """ 

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

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

423 """ 

424 self._check_for_constant_name_clashes() 

425 self._check_for_top_level_name_clashes() 

426 self._check_for_field_name_clashes() 

427 self._check_for_qualified_name_clashes() 

428 

429 def _check_for_constant_name_clashes(self) -> None: 

430 """ 

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

432 """ 

433 constant_names = set() 

434 for constant in self.register_list.constants: 

435 if constant.name in constant_names: 

436 message = ( 

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

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

439 ) 

440 raise ValueError(message) 

441 

442 constant_names.add(constant.name) 

443 

444 def _check_for_top_level_name_clashes(self) -> None: 

445 """ 

446 Check that there are no 

447 * duplicate register names, 

448 * duplicate register array names, 

449 * register array names that clash with register names. 

450 """ 

451 plain_register_names = set() 

452 for register in self.iterate_plain_registers(): 

453 if register.name in plain_register_names: 

454 message = ( 

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

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

457 ) 

458 raise ValueError(message) 

459 

460 plain_register_names.add(register.name) 

461 

462 register_array_names = set() 

463 for register_array in self.iterate_register_arrays(): 

464 if register_array.name in register_array_names: 

465 message = ( 

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

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

468 ) 

469 raise ValueError(message) 

470 

471 if register_array.name in plain_register_names: 

472 message = ( 

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

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

475 ) 

476 raise ValueError(message) 

477 

478 register_array_names.add(register_array.name) 

479 

480 def _check_for_field_name_clashes(self) -> None: 

481 """ 

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

483 """ 

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

485 field_names = set() 

486 

487 for field in register.fields: 

488 if field.name in field_names: 

489 register_description = ( 

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

491 if register_array 

492 else register.name 

493 ) 

494 message = ( 

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

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

497 ) 

498 raise ValueError(message) 

499 

500 field_names.add(field.name) 

501 

502 def _check_for_qualified_name_clashes(self) -> None: 

503 """ 

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

505 

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

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

508 Hence we need to check for these conflicts. 

509 """ 

510 qualified_names = set() 

511 

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

513 register_name = self.qualified_register_name( 

514 register=register, register_array=register_array 

515 ) 

516 register_description = ( 

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

518 ) 

519 

520 if register_name in qualified_names: 

521 message = ( 

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

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

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

525 ) 

526 raise ValueError(message) 

527 

528 qualified_names.add(register_name) 

529 

530 for field in register.fields: 

531 field_name = self.qualified_field_name( 

532 register=register, register_array=register_array, field=field 

533 ) 

534 

535 if field_name in qualified_names: 

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

537 message = ( 

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

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

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

541 ) 

542 raise ValueError(message) 

543 

544 qualified_names.add(field_name)