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