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