1from collections import deque
2from dataclasses import MISSING, dataclass, field, fields, replace
3from enum import Enum
4from pathlib import Path
5from typing import Any, Literal, TypedDict, cast
6
7from jsonschema import ValidationError, validate
8from sphinx.application import Sphinx
9from sphinx.config import Config as _SphinxConfig
10
11from sphinx_codelinks.source_discover.config import (
12 CommentType,
13 SourceDiscoverConfig,
14 SourceDiscoverSectionConfigType,
15)
16from sphinx_codelinks.source_discover.source_discover import SourceDiscover
17
18UNIX_NEWLINE = "\n"
19
20
21COMMENT_MARKERS = {
[docs] 22 # @Support C and C++ style comments, IMPL_C_1, impl, [FE_C_SUPPORT, FE_CPP]
23 CommentType.cpp: ["//", "/*"],
[docs] 24 # @Support Python style comments, IMPL_PY_1, impl, [FE_PY]
25 CommentType.python: ["#"],
26 CommentType.cs: ["//", "/*", "///"],
[docs] 27 # @Support Go style comments, IMPL_GO_2, impl, [FE_GO]
28 CommentType.go: ["//", "/*"],
29}
30ESCAPE = "\\"
31
32# Default C/C++ standard for the standalone/defines parse path when no
33# compile_commands.json entry supplies one.
34DEFAULT_CPP_STD = "c++17"
35
36
37class CommentCategory(str, Enum): # noqa: UP042 # StrEnum changes str(member), which reaches CLI warnings and error messages
38 comment = "comment"
39 docstring = "expression_statement"
40
41
42class NeedIdRefsConfigType(TypedDict):
43 markers: list[str]
44
45
46@dataclass
47class NeedIdRefsConfig:
48 @classmethod
49 def field_names(cls) -> set[str]:
50 return {item.name for item in fields(cls)}
51
52 markers: list[str] = field(
53 default_factory=lambda: ["@need-ids:"],
54 metadata={"schema": {"type": "array", "items": {"type": "string"}}},
55 )
56 """The markers to extract need ids from"""
57
58 @classmethod
59 def get_schema(cls, name: str) -> dict[str, Any] | None:
60 _field = next(_field for _field in fields(cls) if _field.name is name)
61 if _field.metadata and "schema" in _field.metadata:
62 return cast(dict[str, Any], _field.metadata["schema"])
63 return None
64
65 def check_schema(self) -> list[str]:
66 errors = []
67 for _field_name in self.field_names():
68 schema = self.get_schema(_field_name)
69 value = getattr(self, _field_name)
70 try:
71 # jsonschema's stubs type `schema` as `bool | Mapping`, but
72 # `get_schema` returns an optional dict
73 validate(
74 instance=value,
75 schema=schema, # ty: ignore[invalid-argument-type]
76 )
77 except ValidationError as e:
78 errors.append(
79 f"Schema validation error in field '{_field_name}': {e.message}"
80 )
81 return errors
82
83
84class MarkedRstConfigType(TypedDict):
85 start_sequence: str
86 end_sequence: str
87
88
89@dataclass
90class MarkedRstConfig:
91 @classmethod
92 def field_names(cls) -> set[str]:
93 return {item.name for item in fields(cls)}
94
95 start_sequence: str = field(default="@rst", metadata={"schema": {"type": "string"}})
96 """Chars sequence to indicate the start of the rst text."""
97 end_sequence: str = field(
98 default="@endrst", metadata={"schema": {"type": "string"}}
99 )
100 """Chars sequence to indicate the end of the rst text."""
101
102 @classmethod
103 def get_schema(cls, name: str) -> dict[str, Any] | None:
104 _field = next(_field for _field in fields(cls) if _field.name is name)
105 if _field.metadata and "schema" in _field.metadata:
106 return cast(dict[str, Any], _field.metadata["schema"])
107 return None
108
109 def check_schema(self) -> list[str]:
110 errors = []
111 for _field_name in self.field_names():
112 schema = self.get_schema(_field_name)
113 value = getattr(self, _field_name)
114 try:
115 # jsonschema's stubs type `schema` as `bool | Mapping`, but
116 # `get_schema` returns an optional dict
117 validate(
118 instance=value,
119 schema=schema, # ty: ignore[invalid-argument-type]
120 )
121 except ValidationError as e:
122 errors.append(
123 f"Schema validation error in field '{_field_name}': {e.message}"
124 )
125 return errors
126
127 def check_sequence_mutually_exclusive(self) -> list[str]:
128 errors = []
129 if self.start_sequence == self.end_sequence:
130 errors.append("start_sequence and end_sequence cannot be the same.")
131 return errors
132
133 def check_fields_configuration(self) -> list[str]:
134 return self.check_schema() + self.check_sequence_mutually_exclusive()
135
136
137@dataclass
138class PreprocessorConfig:
139 """Opt-in libclang engine config. Presence => libclang engine for C/C++."""
140
141 compile_commands: Path | None = field(
142 default=None, metadata={"schema": {"type": ["string", "null"]}}
143 )
144 """Explicit path to compile_commands.json. If None, walk-up auto-discovery."""
145
146 defines: list[str] = field(
147 default_factory=list,
148 metadata={"schema": {"type": "array", "items": {"type": "string"}}},
149 )
150 """Fallback -D defines applied globally when no compile_commands.json applies."""
151
152 includes: list[Path] = field(
153 default_factory=list,
154 metadata={"schema": {"type": "array", "items": {"type": "string"}}},
155 )
156 """Fallback -I include dirs for the defines path."""
157
158 std: str = field(
159 default=DEFAULT_CPP_STD,
160 metadata={"schema": {"type": "string"}},
161 )
162 """C/C++ standard for the standalone/defines parse path (e.g. ``c++17``,
163 ``c++20``, ``c11``); libclang pins ``-x`` to match it. Files resolved from a
164 ``compile_commands.json`` entry use that entry's own ``-std`` instead."""
165
166
167def anchor_preproc_paths(preproc: PreprocessorConfig, base: Path) -> PreprocessorConfig:
168 """Resolve a preprocessor config's ``compile_commands`` and ``includes``
169 against ``base`` (the config file's directory), so a relative path resolves
170 against the TOML file rather than the process CWD — matching ``src_dir`` /
171 ``git_root``. Absolute paths are left unchanged.
172 """
173 return replace(
174 preproc,
175 compile_commands=(
176 (base / preproc.compile_commands).resolve()
177 if preproc.compile_commands is not None
178 else None
179 ),
180 includes=[(base / inc).resolve() for inc in preproc.includes],
181 )
182
183
184class FieldConfig(TypedDict, total=False):
185 name: str
186 type: Literal["str", "list[str]"]
187 default: str | list[str] | None
188
189
190class OneLineCommentStyleType(TypedDict):
191 start_sequence: str
192 end_sequence: str
193 field_split_char: str
194 needs_fields: list[FieldConfig]
195
196
197@dataclass
198class OneLineCommentStyle:
199 def __setattr__(self, name: str, value: Any) -> None:
200 if name == "needs_fields":
201 # apply default to fields
202 self.apply_needs_field_default(value)
203 return super().__setattr__(name, value)
204
205 @classmethod
206 def field_names(cls) -> set[str]:
207 return {item.name for item in fields(cls)}
208
209 start_sequence: str = field(default="@", metadata={"schema": {"type": "string"}})
210 """Chars sequence to indicate the start of the one-line comment."""
211
212 end_sequence: str = field(
213 default=UNIX_NEWLINE, metadata={"schema": {"type": "string"}}
214 )
215 """Chars sequence to indicate the end of the one-line comment."""
216
217 field_split_char: str = field(default=",", metadata={"schema": {"type": "string"}})
218 """Char sequence to split the fields."""
219
220 needs_fields: list[FieldConfig] = field(
221 default_factory=lambda: [
222 {"name": "title"},
223 {"name": "id"},
224 {"name": "type", "default": "impl"},
225 {"name": "links", "type": "list[str]", "default": []},
226 ],
227 metadata={
228 "required_fields": ["title", "type"],
229 "field_default": {
230 "type": "str",
231 },
232 "schema": {
233 "type": "array",
234 "items": {
235 "type": "object",
236 "properties": {
237 "name": {"type": "string"},
238 "type": {
239 "type": "string",
240 "enum": ["str", "list[str]"],
241 "default": "str",
242 },
243 "default": {
244 "anyOf": [
245 {"type": "string"},
246 {"type": "array", "items": {"type": "string"}},
247 ]
248 },
249 },
250 "required": ["name"],
251 "additionalProperties": False,
252 "allOf": [
253 {
254 "if": {"properties": {"type": {"const": "list[str]"}}},
255 "then": {
256 "properties": {
257 "default": {
258 "type": "array",
259 "items": {"type": "string"},
260 }
261 }
262 },
263 },
264 {
265 "if": {"properties": {"type": {"const": "str"}}},
266 "then": {"properties": {"default": {"type": "string"}}},
267 },
268 ],
269 },
270 },
271 },
272 )
273
274 @classmethod
275 def apply_needs_field_default(cls, given_fields: list[FieldConfig]) -> None:
276 field_default = next(
277 _field.metadata["field_default"]
278 for _field in fields(cls)
279 if _field.name == "needs_fields"
280 )
281
282 for _field in given_fields:
283 for _default in field_default:
284 if _default not in _field:
285 _field[_default] = field_default[
286 _default
287 ] # dynamically assign keys
288
289 @classmethod
290 def get_required_fields(cls, name: str) -> list[str] | None:
291 _field = next(_field for _field in fields(cls) if _field.name is name)
292 if _field.metadata:
293 return cast(list[str], _field.metadata["required_fields"])
294 return None
295
296 @classmethod
297 def get_schema(cls, name: str) -> dict[str, Any] | None:
298 _field = next(_field for _field in fields(cls) if _field.name is name)
299 if _field.metadata and "schema" in _field.metadata:
300 return cast(dict[str, Any], _field.metadata["schema"])
301 return None
302
303 def check_schema(self) -> list[str]:
304 errors = []
305 for _field_name in self.field_names():
306 schema = self.get_schema(_field_name)
307 value = getattr(self, _field_name)
308 try:
309 validate(
310 instance=value,
311 schema=schema, # ty: ignore[invalid-argument-type]
312 ) # validate has no type specified
313 except ValidationError as e:
314 if _field_name == "needs_fields":
315 need_field_name = value[e.path[0]]["name"]
316 errors.append(
317 f"Schema validation error in need_fields '{need_field_name}': {e.message}"
318 )
319 else:
320 errors.append(
321 f"Schema validation error in field '{_field_name}': {e.message}"
322 )
323 return errors
324
325 def check_required_fields(self) -> list[str]:
326 errors = []
327 required_fields = self.get_required_fields("needs_fields")
328 if required_fields is None:
329 errors.append("No required fields specified.")
330 return errors
331 given_field_names = [_field["name"] for _field in self.needs_fields]
332 missing_fields = set(required_fields) - set(given_field_names)
333 if len(missing_fields) != 0:
334 errors.append(f"Missing required fields: {sorted(missing_fields)}")
335
336 return errors
337
338 def check_fields_mutually_exclusive(self) -> list[str]:
339 errors = []
340 needs_field_names = set()
341 for _field in self.needs_fields:
342 if _field["name"] in needs_field_names:
343 errors.append(f"Field '{_field['name']}' is defined multiple times.")
344 needs_field_names.add(_field["name"])
345 return errors
346
347 def check_fields_default_order(self) -> list[str]:
348 errors = []
349 seen_default = False
350 first_default_field = ""
351 for _field in self.needs_fields:
352 has_default = _field.get("default") is not None
353 if has_default and not seen_default:
354 seen_default = True
355 first_default_field = _field["name"]
356 elif not has_default and seen_default:
357 errors.append(
358 f"Field '{_field['name']}' without a default follows "
359 f"field '{first_default_field}' which has a default. "
360 f"Fields without defaults must be defined before fields with defaults."
361 )
362 return errors
363
364 def check_fields_configuration(self) -> list[str]:
365 return (
366 self.check_schema()
367 + self.check_required_fields()
368 + self.check_fields_mutually_exclusive()
369 + self.check_fields_default_order()
370 )
371
372 def get_cnt_required_fields(self) -> int:
373 cnt_required_fields = 0
374 for _field in self.needs_fields:
375 if _field.get("default") is None:
376 cnt_required_fields += 1
377 return cnt_required_fields
378
379 def get_pos_list_str(self) -> list[int]:
380 pos_list_str = []
381 for idx, _field in enumerate(self.needs_fields):
382 if _field["type"] == "list[str]":
383 pos_list_str.append(idx + 1)
384 return pos_list_str
385
386
387class AnalyseSectionConfigType(TypedDict, total=False):
388 """Define typing for loading `analyse` section from the file."""
389
390 get_need_id_refs: bool
391 get_oneline_needs: bool
392 get_rst: bool
393 outdir: str
394 git_root: str
395 need_id_refs: NeedIdRefsConfigType
396 marked_rst: MarkedRstConfigType
397 oneline_comment_style: OneLineCommentStyleType
398 preprocessor: dict[str, object]
399
400
401class SourceAnalyseConfigType(TypedDict, total=False):
402 """Define typing for its API configuration."""
403
404 src_files: list[Path]
405 src_dir: Path
406 comment_type: CommentType
407 get_need_id_refs: bool
408 get_oneline_needs: bool
409 get_rst: bool
410 git_root: Path | None
411 need_id_refs_config: NeedIdRefsConfig
412 marked_rst_config: MarkedRstConfig
413 oneline_comment_style: OneLineCommentStyle
414 preprocessor: PreprocessorConfig | None
415
416
417class ProjectsAnalyseConfigType(TypedDict, total=False):
418 projects_config: dict[str, SourceAnalyseConfigType]
419
420
421@dataclass
422class SourceAnalyseConfig:
423 @classmethod
424 def field_names(cls) -> set[str]:
425 return {item.name for item in fields(cls)}
426
427 src_files: list[Path] = field(
428 default_factory=list,
429 metadata={"schema": {"type": "array", "items": {"type": "string"}}},
430 )
431 """A list of source files to be processed."""
432 src_dir: Path = field(
433 default_factory=lambda: Path("./"), metadata={"schema": {"type": "string"}}
434 )
435
436 comment_type: CommentType = field(
437 default=CommentType.cpp, metadata={"schema": {"type": "string"}}
438 )
439 """The type of comment to be processed."""
440
441 get_need_id_refs: bool = field(
442 default=True, metadata={"schema": {"type": "boolean"}}
443 )
444 """Whether to extract need id references from comments"""
445
446 get_oneline_needs: bool = field(
447 default=False, metadata={"schema": {"type": "boolean"}}
448 )
449 """Whether to extract oneline needs from comments"""
450
451 get_rst: bool = field(default=False, metadata={"schema": {"type": "boolean"}})
452 """Whether to extract rst texts from comments"""
453
454 git_root: Path | None = field(
455 default=None, metadata={"schema": {"type": ["string", "null"]}}
456 )
457 """Explicit path to the Git repository root. If not set, it will be auto-detected
458 by traversing parent directories. Useful for Bazel builds or deeply nested configs."""
459
460 need_id_refs_config: NeedIdRefsConfig = field(default_factory=NeedIdRefsConfig)
461 """Configuration for extracting need id references from comments."""
462
463 marked_rst_config: MarkedRstConfig = field(default_factory=MarkedRstConfig)
464 """Configuration for extracting rst texts from comments."""
465
466 oneline_comment_style: OneLineCommentStyle = field(
467 default_factory=OneLineCommentStyle
468 )
469 """Configuration for extracting oneline needs from comments."""
470
471 preprocessor: PreprocessorConfig | None = field(default=None)
472 """Opt-in libclang preprocessor engine. None => tree-sitter (default).
473
474 No flat ``metadata["schema"]`` here: this is a nested dataclass, like the
475 sibling ``need_id_refs_config`` / ``marked_rst_config`` /
476 ``oneline_comment_style`` fields. ``check_schema`` only validates fields that
477 declare a flat schema; giving this field one made it validate the constructed
478 ``PreprocessorConfig`` instance against JSON type ``object`` and fail at
479 ``config-inited``. Its structure is enforced by ``convert_analyse_config``.
480 """
481
482 @classmethod
483 def get_schema(cls, name: str) -> dict[str, Any] | None:
484 _field = next(_field for _field in fields(cls) if _field.name is name)
485 if _field.metadata and "schema" in _field.metadata:
486 return cast(dict[str, Any], _field.metadata["schema"])
487 return None
488
489 def check_schema(self) -> list[str]:
490 errors = []
491 for _field_name in self.field_names():
492 schema = self.get_schema(_field_name)
493 if not schema:
494 continue
495 value = getattr(self, _field_name)
496 if isinstance(value, Path): # adapt to json schema restriction
497 value = str(value)
498 if _field_name == "src_files" and isinstance(
499 value, list
500 ): # adapt to json schema restriction
501 value: list[str] = [
502 str(src_file) for src_file in value
503 ] # only for value adaptation
504 try:
505 validate(instance=value, schema=schema)
506 except ValidationError as e:
507 errors.append(
508 f"Schema validation error in field '{_field_name}': {e.message}"
509 )
510 return errors
511
512 def check_markers_mutually_exclusive(self) -> list[str]:
513 errors = set()
514 markers = set()
515 markers.add(self.oneline_comment_style.start_sequence)
516 markers.add(self.oneline_comment_style.end_sequence)
517 if self.marked_rst_config.start_sequence in markers:
518 errors.add(
519 f"Marker {self.marked_rst_config.start_sequence} is defined multiple times"
520 )
521 else:
522 markers.add(self.marked_rst_config.start_sequence)
523 if self.marked_rst_config.end_sequence in markers:
524 errors.add(
525 f"Marker {self.marked_rst_config.end_sequence} is defined multiple times"
526 )
527 else:
528 markers.add(self.marked_rst_config.end_sequence)
529
530 for marker in self.need_id_refs_config.markers:
531 if marker in markers:
532 errors.add(f"Marker {marker} is defined multiple times")
533 else:
534 markers.add(marker)
535 return list(errors)
536
537 def check_fields_configuration(self) -> list[str]:
538 errors: deque[str] = deque()
539 if self.get_need_id_refs:
540 need_id_refs_errors = self.need_id_refs_config.check_schema()
541 if need_id_refs_errors:
542 errors.appendleft("NeedIdRefs configuration errors:")
543 errors.extend(need_id_refs_errors)
544 if self.get_oneline_needs:
545 oneline_needs_errors = (
546 self.oneline_comment_style.check_fields_configuration()
547 )
548 if oneline_needs_errors:
549 errors.appendleft("OneLineCommentStyle configuration errors:")
550 errors.extend(oneline_needs_errors)
551 if self.get_rst:
552 marked_rst_errors = self.marked_rst_config.check_fields_configuration()
553 if marked_rst_errors:
554 errors.appendleft("MarkedRst configuration errors:")
555 errors.extend(self.marked_rst_config.check_fields_configuration())
556 analyse_errors = self.check_markers_mutually_exclusive() + self.check_schema()
557 if analyse_errors:
558 errors.appendleft("analyse configuration errors:")
559 errors.extend(analyse_errors)
560 return list(errors)
561
562
563# Shared ubCode project file, which other useblocks tools (sphinx-needs,
564# ubCode checker, ...) read as well, so all tools see the same projects.
565DEFAULT_CONFIG_TOML: str = "ubproject.toml"
566
567SRC_TRACE_CACHE: str = "src_trace_cache"
568
569
570class SourceTracingLineHref:
571 """Global class for the mapping between source file line numbers and Sphinx documentation links."""
572
573 def __init__(self) -> None:
574 self.mappings: dict[str, dict[int, str]] = {}
575
576
577file_lineno_href = SourceTracingLineHref()
578
579
580class CodeLinksProjectConfigType(TypedDict, total=False):
581 """TypedDict defining the configuration structure for individual SrcTrace projects.
582
583 Contains both user-provided configuration:
584 - source_discover
585 - remote_url_pattern
586 - analyse
587 and runtime-generated configuration objects
588 - source_discover_config
589 - analyse_config
590 """
591
592 source_discover: SourceDiscoverSectionConfigType
593 remote_url_pattern: str
594 analyse: AnalyseSectionConfigType
595 source_discover_config: SourceDiscoverConfig
596 analyse_config: SourceAnalyseConfig
597
598
599class CodeLinksConfigType(TypedDict):
600 config_from_toml: str | None
601 set_local_url: bool
602 local_url_field: str
603 set_remote_url: bool
604 remote_url_field: str
605 outdir: Path
606 projects: dict[str, CodeLinksProjectConfigType]
607 debug_measurement: bool
608 debug_filters: bool
609
610
611@dataclass
612class CodeLinksConfig:
613 @classmethod
614 def from_sphinx(cls, sphinx_config: _SphinxConfig) -> "CodeLinksConfig":
615 obj = cls()
616 super().__setattr__(obj, "_sphinx_config", sphinx_config)
617 return obj
618
619 def __getattribute__(self, name: str) -> Any:
620 if name.startswith("__") or name == "_sphinx_config":
621 return super().__getattribute__(name)
622 sphinx_config = (
623 object.__getattribute__(self, "_sphinx_config")
624 if "_sphinx_config" in self.__dict__
625 else None
626 )
627 if sphinx_config:
628 return getattr(
629 super().__getattribute__("_sphinx_config"), f"src_trace_{name}"
630 )
631
632 return object.__getattribute__(self, name)
633
634 def __setattr__(self, name: str, value: Any) -> None:
635 if name == "_sphinx_config" and "src_trace_projects" in value:
636 src_trace_projects: dict[str, CodeLinksProjectConfigType] = value[
637 "src_trace_projects"
638 ]
639 generate_project_configs(src_trace_projects)
640
641 if name.startswith("__") or name == "_sphinx_config":
642 return super().__setattr__(name, value)
643
644 sphinx_config = (
645 object.__getattribute__(self, "_sphinx_config")
646 if "_sphinx_config" in self.__dict__
647 else None
648 )
649
650 if sphinx_config:
651 setattr(
652 super().__getattribute__("_sphinx_config"), f"src_trace_{name}", value
653 )
654
655 if name == "outdir" and isinstance(value, str):
656 # Ensure outdir is a Path object
657 value = Path(value)
658 return object.__setattr__(self, name, value)
659
660 @classmethod
661 def add_config_values(cls, app: Sphinx) -> None:
662 """Add all config values to Sphinx application"""
663 for item in fields(cls):
664 if item.default_factory is not MISSING:
665 default = item.default_factory()
666 elif item.default is not MISSING:
667 default = item.default
668 else:
669 raise Exception(f"Field {item.name} has no default value or factory")
670
671 name = item.name
672 app.add_config_value(
673 f"src_trace_{name}",
674 default,
675 item.metadata["rebuild"],
676 types=item.metadata["types"],
677 )
678
679 @classmethod
680 def field_names(cls) -> set[str]:
681 return {item.name for item in fields(cls)}
682
683 @classmethod
684 def get_schema(cls, name: str) -> dict[str, Any] | None:
685 """Get the schema for a config item."""
686 _field = next(field for field in fields(cls) if field.name is name)
687 if _field.metadata and "schema" in _field.metadata:
688 return _field.metadata["schema"]
689 return None
690
691 config_from_toml: str | None = field(
692 default=DEFAULT_CONFIG_TOML,
693 metadata={
694 "rebuild": "env",
695 "types": (str, type(None)),
696 "schema": {
697 "type": ["string", "null"],
698 "examples": [DEFAULT_CONFIG_TOML, None],
699 },
700 },
701 )
702 """Path to a TOML file to load configuration from.
703
704 Defaults to ``ubproject.toml`` next to :file:`conf.py`. A default file that
705 is missing or has no ``[codelinks]`` table is silently ignored; a missing
706 explicitly configured file triggers a warning.
707 """
708
709 set_local_url: bool = field(
710 default=False,
711 metadata={
712 "rebuild": "env",
713 "types": (bool,),
714 "schema": {
715 "type": "boolean",
716 },
717 },
718 )
719 """Set the file URL in the extracted need."""
720
721 local_url_field: str = field(
722 default="local-url",
723 metadata={
724 "rebuild": "env",
725 "types": (str,),
726 "schema": {
727 "type": "string",
728 },
729 },
730 )
731 """The field name for the file URL in the extracted need."""
732
733 set_remote_url: bool = field(
734 default=False,
735 metadata={
736 "rebuild": "env",
737 "types": (bool,),
738 "schema": {
739 "type": "boolean",
740 },
741 },
742 )
743 remote_url_field: str = field(
744 default="remote-url",
745 metadata={
746 "rebuild": "env",
747 "types": (str,),
748 "schema": {
749 "type": "string",
750 },
751 },
752 )
753 """The field name for the remote URL in the extracted need."""
754
755 outdir: Path = field(
756 default=Path("output"),
757 metadata={"rebuild": "env", "types": (str), "schema": {"type": "string"}},
758 )
759 """The directory where the generated artifacts and their caches will be stored."""
760
761 projects: dict[str, CodeLinksProjectConfigType] = field(
762 default_factory=dict,
763 metadata={
764 "rebuild": "env",
765 "types": (),
766 "schema": {
767 "type": "object",
768 "additionalProperties": {
769 "type": "object",
770 "properties": {
771 "source_discover": {},
772 "analyse": {},
773 "remote_url_pattern": {},
774 "source_discover_config": {},
775 "analyse_config": {},
776 },
777 "additionalProperties": False,
778 },
779 },
780 },
781 )
782 """The configuration for the source tracing projects."""
783
784 debug_measurement: bool = field(
785 default=False, metadata={"rebuild": "html", "types": (bool,)}
786 )
787 """If True, log runtime information for various functions."""
788 debug_filters: bool = field(
789 default=False, metadata={"rebuild": "html", "types": (bool,)}
790 )
791 """If True, log filter processing runtime information."""
792
793
794def check_schema(config: CodeLinksConfig) -> list[str]:
795 """Check only first layer's of schema, so that the nested dict is not validated here."""
796 errors = []
797 for _field_name in CodeLinksConfig.field_names():
798 schema = CodeLinksConfig.get_schema(_field_name)
799 if not schema:
800 continue
801 value = getattr(config, _field_name)
802 if isinstance(value, Path): # adapt to json schema restriction
803 value = str(value)
804 try:
805 validate(instance=value, schema=schema)
806 except ValidationError as e:
807 errors.append(
808 f"Schema validation error in filed '{_field_name}': {e.message}"
809 )
810 return errors
811
812
813def check_project_configuration(config: CodeLinksConfig) -> list[str]:
814 """Check nested project configurations"""
815 errors = []
816
817 for project_name, project_config in config.projects.items():
818 project_errors: list[str] = []
819
820 # validate source_discover config
821 src_discover_config: SourceDiscoverConfig | None = project_config.get(
822 "source_discover_config"
823 )
824 src_discover_errors = []
825 if src_discover_config:
826 src_discover_errors.extend(src_discover_config.check_schema())
827
828 # validate analyse config
829 analyse_config: SourceAnalyseConfig | None = project_config.get(
830 "analyse_config"
831 )
832 analyse_errors = []
833 if analyse_config:
834 analyse_errors = analyse_config.check_fields_configuration()
835
836 # validate src-trace config
837 if config.set_remote_url and "remote_url_pattern" not in project_config:
838 project_errors.append(
839 "remote_url_pattern must be given, as set_remote_url is enabled"
840 )
841
842 if "remote_url_pattern" in project_config and not isinstance(
843 project_config["remote_url_pattern"], str
844 ):
845 project_errors.append("remote_url_pattern must be a string")
846
847 if analyse_errors or src_discover_errors or project_errors:
848 errors.append(f"Project '{project_name}' has the following errors:")
849 errors.extend(analyse_errors)
850 errors.extend(src_discover_errors)
851 errors.extend(project_errors)
852
853 return errors
854
855
856def check_configuration(config: CodeLinksConfig) -> list[str]:
857 errors = []
858 errors.extend(check_schema(config))
859 errors.extend(check_project_configuration(config))
860 return errors
861
862
863def convert_src_discovery_config(
864 config_dict: SourceDiscoverSectionConfigType | None,
865) -> SourceDiscoverConfig:
866 if config_dict:
867 src_discover_dict = {
868 key: (Path(value) if key == "src_dir" and isinstance(value, str) else value)
869 for key, value in config_dict.items()
870 }
871 src_discover_config = SourceDiscoverConfig(**src_discover_dict) # ty: ignore[invalid-argument-type]
872 else:
873 src_discover_config = SourceDiscoverConfig()
874
875 return src_discover_config
876
877
878def _validate_preprocessor_dict(preproc: dict[str, object]) -> None:
879 """Validate the schema-less ``[preprocessor]`` TOML section.
880
881 The section has no ``TypedDict``, so a mistyped scalar would otherwise be
882 coerced into garbage instead of reported: e.g. ``defines = "X"`` (a bare
883 string) becomes ``list("X") == ["X"]`` — or worse, ``defines = "cpp17"``
884 becomes ``["c", "p", "p", "1", "7"]`` → five bogus ``-D`` flags. Fail loud.
885
886 :param preproc: the raw ``[preprocessor]`` mapping from TOML.
887 :raises TypeError: if a key has the wrong type.
888 """
889 for key in ("defines", "includes"):
890 value = preproc.get(key)
891 if value is not None and (
892 not isinstance(value, list) or not all(isinstance(x, str) for x in value)
893 ):
894 raise TypeError(
895 f"[preprocessor] {key} must be a list of strings, "
896 f"got {type(value).__name__}: {value!r}"
897 )
898 for key in ("compile_commands", "std"):
899 value = preproc.get(key)
900 if value is not None and not isinstance(value, str):
901 raise TypeError(
902 f"[preprocessor] {key} must be a string, "
903 f"got {type(value).__name__}: {value!r}"
904 )
905
906
907def convert_analyse_config(
908 config_dict: AnalyseSectionConfigType | None,
909 src_discover: SourceDiscover | None = None,
910) -> SourceAnalyseConfig:
911 analyse_config_dict: SourceAnalyseConfigType = {}
912 if config_dict:
913 for k, v in config_dict.items():
914 if k not in {
915 "online_comment_style",
916 "need_id_refs",
917 "marked_rst",
918 "preprocessor",
919 }:
920 # Convert string paths to Path objects
921 if k in {"src_dir", "git_root"} and isinstance(v, str):
922 analyse_config_dict[k] = Path(v)
923 else:
924 # dynamical assignment
925 analyse_config_dict[k] = v # ty: ignore[invalid-key]
926
927 # Get oneline_comment_style configuration
928 oneline_comment_style_dict: OneLineCommentStyleType | None = config_dict.get(
929 "oneline_comment_style"
930 )
931 oneline_comment_style: OneLineCommentStyle = (
932 convert_oneline_comment_style_config(oneline_comment_style_dict)
933 )
934
935 # Get need_id_refs configuration
936 need_id_refs_config_dict: NeedIdRefsConfigType | None = config_dict.get(
937 "need_id_refs"
938 )
939 need_id_refs_config = convert_need_id_refs_config(need_id_refs_config_dict)
940
941 # Get marked_rst configuration
942 marked_rst_config_dict: MarkedRstConfigType | None = config_dict.get(
943 "marked_rst"
944 )
945 marked_rst_config = convert_marked_rst_config(marked_rst_config_dict)
946
947 analyse_config_dict["need_id_refs_config"] = need_id_refs_config
948 analyse_config_dict["marked_rst_config"] = marked_rst_config
949 analyse_config_dict["oneline_comment_style"] = oneline_comment_style
950
951 preprocessor_dict = config_dict.get("preprocessor")
952 if preprocessor_dict is not None:
953 # The preprocessor section has no TypedDict; its values are dynamic
954 # TOML (typed ``object``), so validate the shapes up front -- a mistyped
955 # scalar would otherwise coerce into garbage flags.
956 _validate_preprocessor_dict(preprocessor_dict)
957 analyse_config_dict["preprocessor"] = PreprocessorConfig(
958 compile_commands=(
959 Path(str(preprocessor_dict["compile_commands"]))
960 if preprocessor_dict.get("compile_commands")
961 else None
962 ),
963 defines=list(preprocessor_dict.get("defines", [])), # ty: ignore[invalid-argument-type]
964 includes=[Path(str(p)) for p in preprocessor_dict.get("includes", [])], # ty: ignore[not-iterable]
965 std=str(preprocessor_dict.get("std", DEFAULT_CPP_STD)),
966 )
967
968 if src_discover:
969 analyse_config_dict["src_files"] = src_discover.source_paths
970 analyse_config_dict["src_dir"] = src_discover.src_discover_config.src_dir
971 try:
972 analyse_config_dict["comment_type"] = CommentType(
973 src_discover.src_discover_config.comment_type
974 )
975 except ValueError:
976 # If invalid comment_type, keep the string value
977 # Validation will catch this error later
978 comment_type_str: str = src_discover.src_discover_config.comment_type
979 analyse_config_dict["comment_type"] = comment_type_str # ty: ignore[invalid-assignment]
980
981 return SourceAnalyseConfig(**analyse_config_dict)
982
983
984def convert_oneline_comment_style_config(
985 config_dict: OneLineCommentStyleType | None,
986) -> OneLineCommentStyle:
987 if config_dict is None:
988 oneline_comment_style = OneLineCommentStyle()
989 else:
990 try:
991 oneline_comment_style = OneLineCommentStyle(**config_dict)
992 except TypeError as e:
993 raise TypeError(f"Invalid oneline comment style configuration: {e}") from e
994 return oneline_comment_style
995
996
997def convert_need_id_refs_config(
998 config_dict: NeedIdRefsConfigType | None,
999) -> NeedIdRefsConfig:
1000 if not config_dict:
1001 need_id_refs_config = NeedIdRefsConfig()
1002 else:
1003 try:
1004 need_id_refs_config = NeedIdRefsConfig(**config_dict)
1005 except TypeError as e:
1006 raise TypeError(f"Invalid oneline comment style configuration: {e}") from e
1007 return need_id_refs_config
1008
1009
1010def convert_marked_rst_config(
1011 config_dict: MarkedRstConfigType | None,
1012) -> MarkedRstConfig:
1013 if not config_dict:
1014 marked_rst_config = MarkedRstConfig()
1015 else:
1016 try:
1017 marked_rst_config = MarkedRstConfig(**config_dict)
1018 except TypeError as e:
1019 raise TypeError(f"Invalid oneline comment style configuration: {e}") from e
1020 return marked_rst_config
1021
1022
1023def generate_project_configs(
1024 project_configs: dict[str, CodeLinksProjectConfigType],
1025) -> None:
1026 """Generate configs of source discover and analyse from their classes dynamically."""
1027 for project_config in project_configs.values():
1028 # overwrite the config into different types on purpose
1029 # covert dicts to their own classes
1030 src_discover_section: SourceDiscoverSectionConfigType | None = cast(
1031 SourceDiscoverSectionConfigType,
1032 project_config.get("source_discover"),
1033 )
1034 source_discover_config = convert_src_discovery_config(src_discover_section)
1035 project_config["source_discover_config"] = source_discover_config
1036
1037 analyse_section_config: AnalyseSectionConfigType | None = cast(
1038 AnalyseSectionConfigType, project_config.get("analyse")
1039 )
1040 analyse_config = convert_analyse_config(analyse_section_config)
1041 analyse_config.get_oneline_needs = True # force to get oneline_need
1042 # Copy comment_type from source_discover_config to analyse_config
1043 try:
1044 analyse_config.comment_type = CommentType(
1045 source_discover_config.comment_type
1046 )
1047 except ValueError:
1048 # If invalid comment_type, keep the string value
1049 # Validation will catch this error later
1050 analyse_config.comment_type = source_discover_config.comment_type # ty: ignore[invalid-assignment]
1051 project_config["analyse_config"] = analyse_config