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