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