Coverage for hdl_registers/generator/register_code_generator.py: 99%
176 statements
« prev ^ index » next coverage.py v7.8.2, created at 2025-06-02 20:52 +0000
« prev ^ index » next coverage.py v7.8.2, created at 2025-06-02 20:52 +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# --------------------------------------------------------------------------------------------------
10from __future__ import annotations
12import datetime
13import re
14from abc import ABC, abstractmethod
15from pathlib import Path
16from typing import TYPE_CHECKING, Any
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
22from hdl_registers import __version__ as hdl_registers_version
23from hdl_registers.field.enumeration import Enumeration
25from .register_code_generator_helpers import RegisterCodeGeneratorHelpers
26from .reserved_keywords import RESERVED_KEYWORDS
28if TYPE_CHECKING:
29 from hdl_registers.register_list import RegisterList
32class RegisterCodeGenerator(ABC, RegisterCodeGeneratorHelpers):
33 """
34 Abstract interface and common functions for generating register code.
35 Should be inherited by all custom code generators.
37 Note that this base class also inherits from :class:`.RegisterCodeGeneratorHelpers`,
38 meaning some useful methods are available in subclasses.
39 """
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.
48 Overload in subclass by setting e.g.
50 .. code-block:: python
52 class MyCoolGenerator(RegisterCodeGenerator):
54 SHORT_DESCRIPTION = "C++ header"
56 as a static class member at the top of the class.
57 """
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.
66 Overload in subclass by setting e.g.
68 .. code-block:: python
70 class MyCoolGenerator(RegisterCodeGenerator):
72 COMMENT_START = "#"
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 """
78 @property
79 @abstractmethod
80 def output_file(self) -> Path:
81 """
82 Result will be placed in this file.
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:
88 .. code-block:: python
90 @property
91 def output_file(self) -> Path:
92 return self.output_folder / f"{self.name}_regs.html"
93 """
95 @abstractmethod
96 def get_code(
97 self,
98 **kwargs: Any, # noqa: ANN401
99 ) -> str:
100 """
101 Get the generated code as a string.
103 Overload in a subclass where the code generation is implemented.
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 """
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
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 = ""
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"
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
138 self.name = register_list.name
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.
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.
153 Return:
154 The path to the created file, i.e. :meth:`.output_file`.
155 """
156 output_file = self.output_file
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}")
167 self._sanity_check()
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)
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}"
187 # Will create the containing folder unless it already exists.
188 return create_file(file=output_file, contents=result)
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``.
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.
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.
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.
214 The boolean is the return value of :meth:`.should_create`.
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
223 return False, self.output_file
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:
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).
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)
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
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 )
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)
264 result_package_version = None
265 result_generator_version = None
266 result_hash = None
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)
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)
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)
292 return result_package_version, result_generator_version, result_hash
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}"
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)
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()
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
326 annotation = "``" if use_rst_annotation else ""
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
333 file_name = f"{annotation}{self.register_list.source_definition_file.name}{annotation}"
334 file_info = f" from file {file_name}"
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}"
346 info = f"Generated {time_info}{file_info}{commit_info}."
348 name_link = (
349 "`hdl-registers <https://hdl-registers.com>`_"
350 if use_rst_annotation
351 else "hdl-registers"
352 )
354 return [
355 f"This file is automatically generated by {name_link} version {hdl_registers_version}.",
356 f"Code generator {annotation}{self.__class__.__name__}{annotation} "
357 f"version {self.__version__}.",
358 info,
359 f"Register hash {self.register_list.object_hash}.",
360 ]
362 def _sanity_check(self) -> None:
363 """
364 Do some basic checks that no naming errors are present.
365 Will raise exception if there is any error.
367 In general, the user will know if these errors are present when the generated code is
368 compiled/used since it will probably crash.
369 But it is better to warn early, rather than the user finding out when compiling headers
370 after a 1-hour FPGA build.
372 We run this check at creation time, always and for every single generator.
373 Hence the user will hopefully get warned when they generate e.g. a VHDL package at the start
374 of the FPGA build that a register uses a reserved C++ name.
375 This check takes roughly 140 us on a decent computer with a typical register list.
376 Hence it is not a big deal that it might be run more than once for each register list.
378 It is better to have it here in the generator rather than in the parser:
379 1. Here it runs only when necessary, not adding time to parsing which is often done in
380 real time.
381 2. We also catch things that were added with the Python API.
382 """
383 self._check_reserved_keywords()
384 self._check_for_name_clashes()
386 def _check_reserved_keywords(self) -> None:
387 """
388 Check that no item in the register list matches a reserved keyword in any of the targeted
389 generator languages.
390 To minimize the risk that a generated artifact does not compile.
391 """
393 def check(name: str, description: str) -> None:
394 if name.lower() in RESERVED_KEYWORDS:
395 message = (
396 f'Error in register list "{self.name}": '
397 f'{description} name "{name}" is a reserved keyword.'
398 )
399 raise ValueError(message)
401 for constant in self.register_list.constants:
402 check(name=constant.name, description="Constant")
404 for register, _ in self.iterate_registers():
405 check(name=register.name, description="Register")
407 for field in register.fields:
408 check(name=field.name, description="Field")
410 if isinstance(field, Enumeration):
411 for element in field.elements:
412 check(name=element.name, description="Enumeration element")
414 for register_array in self.iterate_register_arrays():
415 check(name=register_array.name, description="Register array")
417 def _check_for_name_clashes(self) -> None:
418 """
419 Check that there are no name clashes between items in the register list.
420 To minimize the risk that a generated artifact does not compile.
421 """
422 self._check_for_constant_name_clashes()
423 self._check_for_top_level_name_clashes()
424 self._check_for_field_name_clashes()
425 self._check_for_qualified_name_clashes()
427 def _check_for_constant_name_clashes(self) -> None:
428 """
429 Check that there are no constants with the same name.
430 """
431 constant_names = set()
432 for constant in self.register_list.constants:
433 if constant.name in constant_names:
434 message = (
435 f'Error in register list "{self.name}": '
436 f'Duplicate constant name "{constant.name}".'
437 )
438 raise ValueError(message)
440 constant_names.add(constant.name)
442 def _check_for_top_level_name_clashes(self) -> None:
443 """
444 Check that there are no
445 * duplicate register names,
446 * duplicate register array names,
447 * register array names that clash with register names.
448 """
449 plain_register_names = set()
450 for register in self.iterate_plain_registers():
451 if register.name in plain_register_names:
452 message = (
453 f'Error in register list "{self.name}": '
454 f'Duplicate plain register name "{register.name}".'
455 )
456 raise ValueError(message)
458 plain_register_names.add(register.name)
460 register_array_names = set()
461 for register_array in self.iterate_register_arrays():
462 if register_array.name in register_array_names:
463 message = (
464 f'Error in register list "{self.name}": '
465 f'Duplicate register array name "{register_array.name}".'
466 )
467 raise ValueError(message)
469 if register_array.name in plain_register_names:
470 message = (
471 f'Error in register list "{self.name}": '
472 f'Register array "{register_array.name}" may not have same name as register.'
473 )
474 raise ValueError(message)
476 register_array_names.add(register_array.name)
478 def _check_for_field_name_clashes(self) -> None:
479 """
480 Check that no register contains fields with the same name.
481 """
482 for register, register_array in self.iterate_registers():
483 field_names = set()
485 for field in register.fields:
486 if field.name in field_names:
487 register_description = (
488 f"{register_array.name}.{register.name}"
489 if register_array
490 else register.name
491 )
492 message = (
493 f'Error in register list "{self.name}": '
494 f'Duplicate field name "{field.name}" in register "{register_description}".'
495 )
496 raise ValueError(message)
498 field_names.add(field.name)
500 def _check_for_qualified_name_clashes(self) -> None:
501 """
502 Check that there are no name clashes when names of registers and fields are qualified.
504 The register 'apa_hest' will give a conflict with the field 'apa.hest' since both will get
505 e.g. a VHDL simulation method 'read_apa_hest'.
506 Hence we need to check for these conflicts.
507 """
508 qualified_names = set()
510 for register, register_array in self.iterate_registers():
511 register_name = self.qualified_register_name(
512 register=register, register_array=register_array
513 )
514 register_description = (
515 f"{register_array.name}.{register.name}" if register_array else register.name
516 )
518 if register_name in qualified_names:
519 message = (
520 f'Error in register list "{self.name}": '
521 f'Qualified name of register "{register_description}" '
522 f'("{register_name}") clashes with another item.'
523 )
524 raise ValueError(message)
526 qualified_names.add(register_name)
528 for field in register.fields:
529 field_name = self.qualified_field_name(
530 register=register, register_array=register_array, field=field
531 )
533 if field_name in qualified_names:
534 field_description = f"{register_description}.{field.name}"
535 message = (
536 f'Error in register list "{self.name}": '
537 f'Qualified name of field "{field_description}" '
538 f'("{field_name}") clashes with another item.'
539 )
540 raise ValueError(message)
542 qualified_names.add(field_name)