1from collections import deque
   2from collections.abc import Collection
   3from dataclasses import MISSING, dataclass, field, fields, replace
   4from enum import Enum
   5from pathlib import Path
   6from typing import Any, Literal, TypedDict, cast
   7
   8from jsonschema import ValidationError, validate
   9from sphinx.application import Sphinx
  10from sphinx.config import Config as _SphinxConfig
  11
  12from sphinx_codelinks.source_discover.config import (
  13    CommentType,
  14    SourceDiscoverConfig,
  15    SourceDiscoverSectionConfigType,
  16)
  17from sphinx_codelinks.source_discover.source_discover import SourceDiscover
  18from ub_project import anchor, load_toml, select_table
  19
  20UNIX_NEWLINE = "\n"
  21
  22
  23COMMENT_MARKERS = {
[docs]  24    # @Support C and C++ style comments, IMPL_C_1, impl, [FE_C_SUPPORT, FE_CPP]
  25    CommentType.cpp: ["//", "/*"],
[docs]  26    # @Support Python style comments, IMPL_PY_1, impl, [FE_PY]
  27    CommentType.python: ["#"],
  28    CommentType.cs: ["//", "/*", "///"],
[docs]  29    # @Support Go style comments, IMPL_GO_2, impl, [FE_GO]
  30    CommentType.go: ["//", "/*"],
  31}
  32ESCAPE = "\\"
  33
  34# Default C/C++ standard for the standalone/defines parse path when no
  35# compile_commands.json entry supplies one.
  36DEFAULT_CPP_STD = "c++17"
  37
  38
  39class CommentCategory(str, Enum):  # noqa: UP042  # StrEnum changes str(member), which reaches CLI warnings and error messages
  40    comment = "comment"
  41    docstring = "expression_statement"
  42
  43
  44class NeedIdRefsMarkerType(TypedDict):
  45    """A reference marker given as a table: its text and the field its URLs go to."""
  46
  47    marker: str
  48    field: str
  49
  50
  51class NeedIdRefsConfigType(TypedDict):
  52    markers: list[str | NeedIdRefsMarkerType]
  53
  54
  55MARKER_TABLE_KEYS: tuple[str, ...] = ("marker", "field")
  56"""The keys of a reference marker given as a table, every one required."""
  57
  58
  59def _marker_table(index: int, entry: dict[Any, Any]) -> tuple[str, str]:
  60    """The ``(marker, field)`` of the marker table at ``markers[index]``.
  61
  62    :raises TypeError: If the table lacks a key, has another key, its ``marker`` is not
  63        a non-empty string or its ``field`` not a string.
  64    """
  65    where = f"markers[{index}]"
  66    unknown = [key for key in entry if key not in MARKER_TABLE_KEYS]
  67    if unknown:
  68        names = ", ".join(repr(key) for key in unknown)
  69        raise TypeError(
  70            f"{where}: unknown key {names}; a marker table takes 'marker' and 'field'"
  71        )
  72    missing = [key for key in MARKER_TABLE_KEYS if key not in entry]
  73    if missing:
  74        names = " and ".join(repr(key) for key in missing)
  75        raise TypeError(
  76            f"{where}: missing {names}; a marker table takes 'marker' and 'field'"
  77        )
  78    marker, field_name = entry["marker"], entry["field"]
  79    if not isinstance(marker, str) or not marker:
  80        raise TypeError(f"{where}: 'marker' must be a non-empty string")
  81    if not isinstance(field_name, str):
  82        raise TypeError(f"{where}: 'field' must be a string")
  83    return marker, field_name
  84
  85
  86@dataclass
  87class NeedIdRefsConfig:
  88    @classmethod
  89    def field_names(cls) -> set[str]:
  90        return {item.name for item in fields(cls)}
  91
  92    markers: list[str] = field(
  93        default_factory=lambda: ["@need-ids:"],
  94        metadata={
  95            "schema": {
  96                "type": "array",
  97                "items": {
  98                    "anyOf": [
  99                        {"type": "string"},
 100                        {
 101                            "type": "object",
 102                            "properties": {
 103                                "marker": {"type": "string", "minLength": 1},
 104                                "field": {"type": "string"},
 105                            },
 106                            "required": list(MARKER_TABLE_KEYS),
 107                            "additionalProperties": False,
 108                        },
 109                    ]
 110                },
 111            }
 112        },
 113    )
 114    """The markers to extract need ids from.
 115
 116    Each entry is a marker string, whose references go to the project's
 117    ``ref_url_field``, or a table ``{"marker": ..., "field": ...}``, whose references go
 118    to ``field`` instead (``""``: to no field). Once constructed, this is the list of
 119    the marker texts, and :attr:`fields` holds the tables' fields.
 120    """
 121    fields: dict[str, str] = field(default_factory=dict, init=False)
 122    """Each marker given as a table, mapped to its field (resolved from ``markers``)."""
 123
 124    def __post_init__(self) -> None:
 125        """Split the marker tables of ``markers`` into their texts and :attr:`fields`.
 126
 127        :raises TypeError: For a malformed marker table (see :func:`_marker_table`).
 128        """
 129        if not isinstance(self.markers, list):
 130            return  # reported by check_schema
 131        texts: list[str] = []
 132        for index, entry in enumerate(self.markers):
 133            if isinstance(entry, dict):
 134                marker, field_name = _marker_table(index, entry)
 135                texts.append(marker)
 136                self.fields.setdefault(marker, field_name)
 137            else:
 138                texts.append(entry)
 139        self.markers = texts
 140
 141    @classmethod
 142    def get_schema(cls, name: str) -> dict[str, Any] | None:
 143        _field = next(_field for _field in fields(cls) if _field.name is name)
 144        if _field.metadata and "schema" in _field.metadata:
 145            return cast(dict[str, Any], _field.metadata["schema"])
 146        return None
 147
 148    def check_schema(self) -> list[str]:
 149        errors = []
 150        for _field_name in self.field_names():
 151            schema = self.get_schema(_field_name)
 152            if schema is None:
 153                # no schema to check against; `validate` would raise TypeError
 154                continue
 155            value = getattr(self, _field_name)
 156            try:
 157                validate(
 158                    instance=value,
 159                    schema=schema,
 160                )
 161            except ValidationError as e:
 162                errors.append(
 163                    f"Schema validation error in field '{_field_name}': {e.message}"
 164                )
 165        return errors
 166
 167
 168class MultilineNeedsConfigType(TypedDict, total=False):
 169    start_sequence: str
 170    end_sequence: str
 171    default_markup: str
 172    markups: dict[str, str]
 173
 174
 175DEFAULT_MARKUPS: dict[str, str] = {"rst": ".rst", "md": ".md"}
 176"""The default ``markups`` table: markup tag -> the ``doctype`` suffix of the need."""
 177
 178
 179@dataclass
 180class MultilineNeedsConfig:
 181    """The markers and markups of multi-line needs (``[analyse.multiline_needs]``)."""
 182
 183    @classmethod
 184    def field_names(cls) -> set[str]:
 185        return {item.name for item in fields(cls)}
 186
 187    start_sequence: str = field(
 188        default="@need", metadata={"schema": {"type": "string", "minLength": 1}}
 189    )
 190    """The word that opens a multi-line need, at the start of its line."""
 191    end_sequence: str = field(
 192        default="@endneed", metadata={"schema": {"type": "string", "minLength": 1}}
 193    )
 194    """The word that closes a multi-line need, alone on its line."""
 195    default_markup: str = field(
 196        default="rst", metadata={"schema": {"type": "string", "minLength": 1}}
 197    )
 198    """The markup tag of a block whose open line names none; a key of ``markups``."""
 199    markups: dict[str, str] = field(
 200        default_factory=lambda: dict(DEFAULT_MARKUPS),
 201        metadata={
 202            "schema": {
 203                "type": "object",
 204                "additionalProperties": {"type": "string", "minLength": 1},
 205                "minProperties": 1,
 206            }
 207        },
 208    )
 209    """Markup tag -> the ``doctype`` suffix the need's content is parsed with. Not
 210    checked against any parser: the consumer decides what it can parse."""
 211
 212    @classmethod
 213    def get_schema(cls, name: str) -> dict[str, Any] | None:
 214        _field = next(_field for _field in fields(cls) if _field.name is name)
 215        if _field.metadata and "schema" in _field.metadata:
 216            return cast(dict[str, Any], _field.metadata["schema"])
 217        return None
 218
 219    def check_schema(self) -> list[str]:
 220        errors = []
 221        for _field_name in self.field_names():
 222            schema = self.get_schema(_field_name)
 223            if schema is None:
 224                # no schema to check against; `validate` would raise TypeError
 225                continue
 226            value = getattr(self, _field_name)
 227            try:
 228                validate(
 229                    instance=value,
 230                    schema=schema,
 231                )
 232            except ValidationError as e:
 233                errors.append(
 234                    f"Schema validation error in field '{_field_name}': {e.message}"
 235                )
 236        return errors
 237
 238    def check_fields_configuration(self) -> list[str]:
 239        errors = self.check_schema()
 240        if self.start_sequence == self.end_sequence:
 241            errors.append("start_sequence and end_sequence cannot be the same.")
 242        if isinstance(self.markups, dict) and self.default_markup not in self.markups:
 243            errors.append(
 244                f"default_markup {self.default_markup!r} is not a key of markups "
 245                f"({', '.join(repr(tag) for tag in sorted(self.markups))})."
 246            )
 247        return errors
 248
 249
 250@dataclass
 251class PreprocessorConfig:
 252    """Opt-in libclang engine config. Presence => libclang engine for C/C++."""
 253
 254    compile_commands: Path | None = field(
 255        default=None, metadata={"schema": {"type": ["string", "null"]}}
 256    )
 257    """Explicit path to compile_commands.json. If None, walk-up auto-discovery."""
 258
 259    defines: list[str] = field(
 260        default_factory=list,
 261        metadata={"schema": {"type": "array", "items": {"type": "string"}}},
 262    )
 263    """Fallback -D defines applied globally when no compile_commands.json applies."""
 264
 265    includes: list[Path] = field(
 266        default_factory=list,
 267        metadata={"schema": {"type": "array", "items": {"type": "string"}}},
 268    )
 269    """Fallback -I include dirs for the defines path."""
 270
 271    std: str = field(
 272        default=DEFAULT_CPP_STD,
 273        metadata={"schema": {"type": "string"}},
 274    )
 275    """C/C++ standard for the standalone/defines parse path (e.g. ``c++17``,
 276    ``c++20``, ``c11``); libclang pins ``-x`` to match it. Files resolved from a
 277    ``compile_commands.json`` entry use that entry's own ``-std`` instead."""
 278
 279    resource_dir: Path | None = field(
 280        default=None, metadata={"schema": {"type": ["string", "null"]}}
 281    )
 282    """A clang resource directory (``clang -print-resource-dir``), passed to every parse
 283    as ``-resource-dir``. If None, the builtin headers that ship with the package are
 284    passed, unless a compile command chooses its own (``-resource-dir``, ``-nostdinc``,
 285    ``-nobuiltininc``)."""
 286
 287
 288def anchor_preproc_paths(preproc: PreprocessorConfig, base: Path) -> PreprocessorConfig:
 289    """Resolve a preprocessor config's ``compile_commands``, ``includes`` and
 290    ``resource_dir`` against ``base`` (the config file's directory), so a relative path resolves
 291    against the TOML file rather than the process CWD — matching ``src_dir`` /
 292    ``git_root``. An absolute path is not anchored, but it is resolved too
 293    (symlinks followed, ``..`` folded), like every other path here.
 294    """
 295    return replace(
 296        preproc,
 297        compile_commands=(
 298            anchor(preproc.compile_commands, base).resolve()
 299            if preproc.compile_commands is not None
 300            else None
 301        ),
 302        includes=[anchor(inc, base).resolve() for inc in preproc.includes],
 303        resource_dir=(
 304            anchor(preproc.resource_dir, base).resolve()
 305            if preproc.resource_dir is not None
 306            else None
 307        ),
 308    )
 309
 310
 311class FieldConfig(TypedDict, total=False):
 312    name: str
 313    type: Literal["str", "list[str]"]
 314    default: str | list[str] | None
 315
 316
 317class OneLineCommentStyleType(TypedDict):
 318    start_sequence: str
 319    end_sequence: str
 320    field_split_char: str
 321    needs_fields: list[FieldConfig]
 322
 323
 324@dataclass
 325class OneLineCommentStyle:
 326    def __setattr__(self, name: str, value: Any) -> None:
 327        if name == "needs_fields":
 328            # apply default to fields
 329            self.apply_needs_field_default(value)
 330        return super().__setattr__(name, value)
 331
 332    @classmethod
 333    def field_names(cls) -> set[str]:
 334        return {item.name for item in fields(cls)}
 335
 336    start_sequence: str = field(default="@", metadata={"schema": {"type": "string"}})
 337    """Chars sequence to indicate the start of the one-line comment."""
 338
 339    end_sequence: str = field(
 340        default=UNIX_NEWLINE, metadata={"schema": {"type": "string"}}
 341    )
 342    """Chars sequence to indicate the end of the one-line comment."""
 343
 344    field_split_char: str = field(default=",", metadata={"schema": {"type": "string"}})
 345    """Char sequence to split the fields."""
 346
 347    needs_fields: list[FieldConfig] = field(
 348        default_factory=lambda: [
 349            {"name": "title"},
 350            {"name": "id"},
 351            {"name": "type", "default": "impl"},
 352            {"name": "links", "type": "list[str]", "default": []},
 353        ],
 354        metadata={
 355            "required_fields": ["title", "type"],
 356            "field_default": {
 357                "type": "str",
 358            },
 359            "schema": {
 360                "type": "array",
 361                "items": {
 362                    "type": "object",
 363                    "properties": {
 364                        "name": {"type": "string"},
 365                        "type": {
 366                            "type": "string",
 367                            "enum": ["str", "list[str]"],
 368                            "default": "str",
 369                        },
 370                        "default": {
 371                            "anyOf": [
 372                                {"type": "string"},
 373                                {"type": "array", "items": {"type": "string"}},
 374                            ]
 375                        },
 376                    },
 377                    "required": ["name"],
 378                    "additionalProperties": False,
 379                    "allOf": [
 380                        {
 381                            "if": {"properties": {"type": {"const": "list[str]"}}},
 382                            "then": {
 383                                "properties": {
 384                                    "default": {
 385                                        "type": "array",
 386                                        "items": {"type": "string"},
 387                                    }
 388                                }
 389                            },
 390                        },
 391                        {
 392                            "if": {"properties": {"type": {"const": "str"}}},
 393                            "then": {"properties": {"default": {"type": "string"}}},
 394                        },
 395                    ],
 396                },
 397            },
 398        },
 399    )
 400
 401    @classmethod
 402    def apply_needs_field_default(cls, given_fields: list[FieldConfig]) -> None:
 403        field_default = next(
 404            _field.metadata["field_default"]
 405            for _field in fields(cls)
 406            if _field.name == "needs_fields"
 407        )
 408
 409        for _field in given_fields:
 410            for _default in field_default:
 411                if _default not in _field:
 412                    _field[_default] = field_default[
 413                        _default
 414                    ]  # dynamically assign keys
 415
 416    @classmethod
 417    def get_required_fields(cls, name: str) -> list[str] | None:
 418        _field = next(_field for _field in fields(cls) if _field.name is name)
 419        if _field.metadata:
 420            return cast(list[str], _field.metadata["required_fields"])
 421        return None
 422
 423    @classmethod
 424    def get_schema(cls, name: str) -> dict[str, Any] | None:
 425        _field = next(_field for _field in fields(cls) if _field.name is name)
 426        if _field.metadata and "schema" in _field.metadata:
 427            return cast(dict[str, Any], _field.metadata["schema"])
 428        return None
 429
 430    def check_schema(self) -> list[str]:
 431        errors = []
 432        for _field_name in self.field_names():
 433            schema = self.get_schema(_field_name)
 434            if schema is None:
 435                # no schema to check against; `validate` would raise TypeError
 436                continue
 437            value = getattr(self, _field_name)
 438            try:
 439                validate(
 440                    instance=value,
 441                    schema=schema,
 442                )  # validate has no type specified
 443            except ValidationError as e:
 444                if _field_name == "needs_fields":
 445                    need_field_name = value[e.path[0]]["name"]
 446                    errors.append(
 447                        f"Schema validation error in need_fields '{need_field_name}': {e.message}"
 448                    )
 449                else:
 450                    errors.append(
 451                        f"Schema validation error in field '{_field_name}': {e.message}"
 452                    )
 453        return errors
 454
 455    def check_required_fields(self) -> list[str]:
 456        errors = []
 457        required_fields = self.get_required_fields("needs_fields")
 458        if required_fields is None:
 459            errors.append("No required fields specified.")
 460            return errors
 461        given_field_names = [_field["name"] for _field in self.needs_fields]
 462        missing_fields = set(required_fields) - set(given_field_names)
 463        if len(missing_fields) != 0:
 464            errors.append(f"Missing required fields: {sorted(missing_fields)}")
 465
 466        return errors
 467
 468    def check_fields_mutually_exclusive(self) -> list[str]:
 469        errors = []
 470        needs_field_names = set()
 471        for _field in self.needs_fields:
 472            if _field["name"] in needs_field_names:
 473                errors.append(f"Field '{_field['name']}' is defined multiple times.")
 474            needs_field_names.add(_field["name"])
 475        return errors
 476
 477    def check_fields_default_order(self) -> list[str]:
 478        errors = []
 479        seen_default = False
 480        first_default_field = ""
 481        for _field in self.needs_fields:
 482            has_default = _field.get("default") is not None
 483            if has_default and not seen_default:
 484                seen_default = True
 485                first_default_field = _field["name"]
 486            elif not has_default and seen_default:
 487                errors.append(
 488                    f"Field '{_field['name']}' without a default follows "
 489                    f"field '{first_default_field}' which has a default. "
 490                    f"Fields without defaults must be defined before fields with defaults."
 491                )
 492        return errors
 493
 494    def check_fields_configuration(self) -> list[str]:
 495        return (
 496            self.check_schema()
 497            + self.check_required_fields()
 498            + self.check_fields_mutually_exclusive()
 499            + self.check_fields_default_order()
 500        )
 501
 502    def get_cnt_required_fields(self) -> int:
 503        cnt_required_fields = 0
 504        for _field in self.needs_fields:
 505            if _field.get("default") is None:
 506                cnt_required_fields += 1
 507        return cnt_required_fields
 508
 509    def get_pos_list_str(self) -> list[int]:
 510        pos_list_str = []
 511        for idx, _field in enumerate(self.needs_fields):
 512            if _field["type"] == "list[str]":
 513                pos_list_str.append(idx + 1)
 514        return pos_list_str
 515
 516
 517class AnalyseSectionConfigType(TypedDict, total=False):
 518    """Define typing for loading `analyse` section from the file."""
 519
 520    get_need_id_refs: bool
 521    get_oneline_needs: bool
 522    get_multiline_needs: bool
 523    outdir: str
 524    git_root: str
 525    need_id_refs: NeedIdRefsConfigType
 526    multiline_needs: MultilineNeedsConfigType
 527    oneline_comment_style: OneLineCommentStyleType
 528    preprocessor: dict[str, object]
 529
 530
 531class SourceAnalyseConfigType(TypedDict, total=False):
 532    """Define typing for its API configuration."""
 533
 534    src_files: list[Path]
 535    src_dir: Path
 536    comment_type: CommentType
 537    get_need_id_refs: bool
 538    get_oneline_needs: bool
 539    get_multiline_needs: bool
 540    git_root: Path | None
 541    need_id_refs_config: NeedIdRefsConfig
 542    multiline_needs_config: MultilineNeedsConfig
 543    oneline_comment_style: OneLineCommentStyle
 544    preprocessor: PreprocessorConfig | None
 545
 546
 547class ProjectsAnalyseConfigType(TypedDict, total=False):
 548    projects_config: dict[str, SourceAnalyseConfigType]
 549
 550
 551@dataclass
 552class SourceAnalyseConfig:
 553    @classmethod
 554    def field_names(cls) -> set[str]:
 555        return {item.name for item in fields(cls)}
 556
 557    src_files: list[Path] = field(
 558        default_factory=list,
 559        metadata={"schema": {"type": "array", "items": {"type": "string"}}},
 560    )
 561    """A list of source files to be  processed."""
 562    src_dir: Path = field(
 563        default_factory=lambda: Path("./"), metadata={"schema": {"type": "string"}}
 564    )
 565
 566    comment_type: CommentType = field(
 567        default=CommentType.cpp, metadata={"schema": {"type": "string"}}
 568    )
 569    """The type of comment to be processed."""
 570
 571    get_need_id_refs: bool = field(
 572        default=True, metadata={"schema": {"type": "boolean"}}
 573    )
 574    """Whether to extract need id references from comments"""
 575
 576    get_oneline_needs: bool = field(
 577        default=False, metadata={"schema": {"type": "boolean"}}
 578    )
 579    """Whether to extract oneline needs from comments"""
 580
 581    get_multiline_needs: bool = field(
 582        default=False, metadata={"schema": {"type": "boolean"}}
 583    )
 584    """Whether to extract multi-line needs (``@need`` … ``@endneed``) from comments"""
 585
 586    git_root: Path | None = field(
 587        default=None, metadata={"schema": {"type": ["string", "null"]}}
 588    )
 589    """Explicit path to the Git repository root. If not set, it will be auto-detected
 590    by traversing parent directories. Useful for Bazel builds or deeply nested configs."""
 591
 592    need_id_refs_config: NeedIdRefsConfig = field(default_factory=NeedIdRefsConfig)
 593    """Configuration for extracting need id references from comments."""
 594
 595    multiline_needs_config: MultilineNeedsConfig = field(
 596        default_factory=MultilineNeedsConfig
 597    )
 598    """Configuration for extracting multi-line needs from comments."""
 599
 600    oneline_comment_style: OneLineCommentStyle = field(
 601        default_factory=OneLineCommentStyle
 602    )
 603    """Configuration for extracting oneline needs from comments."""
 604
 605    preprocessor: PreprocessorConfig | None = field(default=None)
 606    """Opt-in libclang preprocessor engine. None => tree-sitter (default).
 607
 608    No flat ``metadata["schema"]`` here: this is a nested dataclass, like the
 609    sibling ``need_id_refs_config`` / ``multiline_needs_config`` /
 610    ``oneline_comment_style`` fields. ``check_schema`` only validates fields that
 611    declare a flat schema; giving this field one made it validate the constructed
 612    ``PreprocessorConfig`` instance against JSON type ``object`` and fail at
 613    ``config-inited``. Its structure is enforced by ``convert_analyse_config``.
 614    """
 615
 616    @classmethod
 617    def get_schema(cls, name: str) -> dict[str, Any] | None:
 618        _field = next(_field for _field in fields(cls) if _field.name is name)
 619        if _field.metadata and "schema" in _field.metadata:
 620            return cast(dict[str, Any], _field.metadata["schema"])
 621        return None
 622
 623    def check_schema(self) -> list[str]:
 624        errors = []
 625        for _field_name in self.field_names():
 626            schema = self.get_schema(_field_name)
 627            if not schema:
 628                continue
 629            value = getattr(self, _field_name)
 630            if isinstance(value, Path):  # adapt to json schema restriction
 631                value = str(value)
 632            if _field_name == "src_files" and isinstance(
 633                value, list
 634            ):  # adapt to json schema restriction
 635                value: list[str] = [
 636                    str(src_file) for src_file in value
 637                ]  # only for value adaptation
 638            try:
 639                validate(instance=value, schema=schema)
 640            except ValidationError as e:
 641                errors.append(
 642                    f"Schema validation error in field '{_field_name}': {e.message}"
 643                )
 644        return errors
 645
 646    def check_markers_mutually_exclusive(self) -> list[str]:
 647        errors = set()
 648        markers = set()
 649        markers.add(self.oneline_comment_style.start_sequence)
 650        markers.add(self.oneline_comment_style.end_sequence)
 651        # equality only: the default one-line start ``@`` is a PREFIX of ``@need``, and
 652        # what keeps the two apart is that a block's lines are hidden from the one-line
 653        # parser, not this check. The two words are checked only when the feature is
 654        # on: off, they are no markers of the project.
 655        multiline_markers = (
 656            (
 657                self.multiline_needs_config.start_sequence,
 658                self.multiline_needs_config.end_sequence,
 659            )
 660            if self.get_multiline_needs
 661            else ()
 662        )
 663        for marker in multiline_markers:
 664            if marker in markers:
 665                errors.add(f"Marker {marker} is defined multiple times")
 666            else:
 667                markers.add(marker)
 668
 669        # a reference marker listed twice is an error too, in whichever form (a string,
 670        # a table, or one of each): one text cannot name two fields
 671        for marker in self.need_id_refs_config.markers:
 672            if not isinstance(marker, str):
 673                continue  # reported by the schema check
 674            if marker in markers:
 675                errors.add(f"Marker {marker} is defined multiple times")
 676            else:
 677                markers.add(marker)
 678        return list(errors)
 679
 680    def check_fields_configuration(self) -> list[str]:
 681        errors: deque[str] = deque()
 682        if self.get_need_id_refs:
 683            need_id_refs_errors = self.need_id_refs_config.check_schema()
 684            if need_id_refs_errors:
 685                errors.appendleft("NeedIdRefs configuration errors:")
 686                errors.extend(need_id_refs_errors)
 687        if self.get_oneline_needs:
 688            oneline_needs_errors = (
 689                self.oneline_comment_style.check_fields_configuration()
 690            )
 691            if oneline_needs_errors:
 692                errors.appendleft("OneLineCommentStyle configuration errors:")
 693                errors.extend(oneline_needs_errors)
 694        if self.get_multiline_needs:
 695            multiline_errors = self.multiline_needs_config.check_fields_configuration()
 696            if multiline_errors:
 697                errors.appendleft("MultilineNeeds configuration errors:")
 698                errors.extend(multiline_errors)
 699        analyse_errors = self.check_markers_mutually_exclusive() + self.check_schema()
 700        if analyse_errors:
 701            errors.appendleft("analyse configuration errors:")
 702            errors.extend(analyse_errors)
 703        return list(errors)
 704
 705
 706# Shared ubCode project file, which other useblocks tools (sphinx-needs,
 707# ubCode checker, ...) read as well, so all tools see the same projects.
 708DEFAULT_CONFIG_TOML: str = "ubproject.toml"
 709
 710DEFAULT_REF_URL_FIELD: str = "code_url"
 711"""The need field ``@need-ids:`` references are attached to, unless a project's
 712``ref_url_field`` names another (``""`` disables the attach), or a marker table names
 713the field of its own references."""
 714
 715#: The table of the TOML file that both readers, the Sphinx extension and the CLI,
 716#: take their configuration from.
 717CODELINKS_TABLE: str = "codelinks"
 718
 719
 720def load_codelinks_table(path: Path) -> dict[str, object] | None:
 721    """Parse *path* and return its ``[codelinks]`` table, through ub-project.
 722
 723    The values are returned RAW: relative paths are anchored where they are used
 724    (the Sphinx extension anchors at ``confdir / Path(config_from_toml).parent``,
 725    unresolved), never here.
 726
 727    :param path: The TOML file.
 728    :return: The table, or ``None`` when the file has no ``codelinks`` key.
 729    :raises ub_project.ProjectConfigError: If the file cannot be read, is not UTF-8
 730        or not valid TOML, or if ``codelinks`` is not a table.
 731    :raises RecursionError: For a pathologically nested file, which ``load_toml``
 732        does not wrap -- why both callers also catch ``Exception``.
 733    """
 734    return select_table(load_toml(path), CODELINKS_TABLE, source=path)
 735
 736
 737class CodeLinksProjectConfigType(TypedDict, total=False):
 738    """TypedDict defining the configuration structure for individual SrcTrace projects.
 739
 740    Contains both user-provided configuration:
 741    - source_discover
 742    - remote_url_pattern
 743    - ref_url_field
 744    - analyse
 745    and runtime-generated configuration objects
 746    - source_discover_config
 747    - analyse_config
 748    """
 749
 750    source_discover: SourceDiscoverSectionConfigType
 751    remote_url_pattern: str
 752    ref_url_field: str
 753    analyse: AnalyseSectionConfigType
 754    source_discover_config: SourceDiscoverConfig
 755    analyse_config: SourceAnalyseConfig
 756
 757
 758class CodeLinksConfigType(TypedDict):
 759    config_from_toml: str | None
 760    set_local_url: bool
 761    local_url_field: str
 762    set_remote_url: bool
 763    remote_url_field: str
 764    outdir: Path
 765    projects: dict[str, CodeLinksProjectConfigType]
 766    debug_measurement: bool
 767    debug_filters: bool
 768
 769
 770@dataclass
 771class CodeLinksConfig:
 772    @classmethod
 773    def from_sphinx(cls, sphinx_config: _SphinxConfig) -> "CodeLinksConfig":
 774        obj = cls()
 775        src_trace_projects = getattr(sphinx_config, "src_trace_projects", None)
 776        if isinstance(src_trace_projects, dict):
 777            generate_project_configs(src_trace_projects)
 778        super().__setattr__(obj, "_sphinx_config", sphinx_config)
 779        return obj
 780
 781    def __getattribute__(self, name: str) -> Any:
 782        if name.startswith("__") or name == "_sphinx_config":
 783            return super().__getattribute__(name)
 784        sphinx_config = (
 785            object.__getattribute__(self, "_sphinx_config")
 786            if "_sphinx_config" in self.__dict__
 787            else None
 788        )
 789        if sphinx_config:
 790            return getattr(
 791                super().__getattribute__("_sphinx_config"), f"src_trace_{name}"
 792            )
 793
 794        return object.__getattribute__(self, name)
 795
 796    def __setattr__(self, name: str, value: Any) -> None:
 797        if name == "_sphinx_config" and "src_trace_projects" in value:
 798            src_trace_projects: dict[str, CodeLinksProjectConfigType] = value[
 799                "src_trace_projects"
 800            ]
 801            generate_project_configs(src_trace_projects)
 802
 803        if name.startswith("__") or name == "_sphinx_config":
 804            return super().__setattr__(name, value)
 805
 806        sphinx_config = (
 807            object.__getattribute__(self, "_sphinx_config")
 808            if "_sphinx_config" in self.__dict__
 809            else None
 810        )
 811
 812        if sphinx_config:
 813            setattr(
 814                super().__getattribute__("_sphinx_config"), f"src_trace_{name}", value
 815            )
 816
 817        if name == "outdir" and isinstance(value, str):
 818            # Ensure outdir is a Path object
 819            value = Path(value)
 820        return object.__setattr__(self, name, value)
 821
 822    @classmethod
 823    def add_config_values(cls, app: Sphinx) -> None:
 824        """Add all config values to Sphinx application"""
 825        for item in fields(cls):
 826            if item.default_factory is not MISSING:
 827                default = item.default_factory()
 828            elif item.default is not MISSING:
 829                default = item.default
 830            else:
 831                raise Exception(f"Field {item.name} has no default value or factory")
 832
 833            name = item.name
 834            app.add_config_value(
 835                f"src_trace_{name}",
 836                default,
 837                item.metadata["rebuild"],
 838                types=item.metadata["types"],
 839            )
 840
 841    @classmethod
 842    def field_names(cls) -> set[str]:
 843        return {item.name for item in fields(cls)}
 844
 845    @classmethod
 846    def get_schema(cls, name: str) -> dict[str, Any] | None:
 847        """Get the schema for a config item."""
 848        _field = next(field for field in fields(cls) if field.name is name)
 849        if _field.metadata and "schema" in _field.metadata:
 850            return _field.metadata["schema"]
 851        return None
 852
 853    config_from_toml: str | None = field(
 854        default=DEFAULT_CONFIG_TOML,
 855        metadata={
 856            "rebuild": "env",
 857            "types": (str, type(None)),
 858            "schema": {
 859                "type": ["string", "null"],
 860                "examples": [DEFAULT_CONFIG_TOML, None],
 861            },
 862        },
 863    )
 864    """Path to a TOML file to load configuration from.
 865
 866    Defaults to ``ubproject.toml`` next to :file:`conf.py`. The default is the
 867    value ``ubproject.toml`` exactly -- left unset, or written in conf.py as that
 868    string: missing, or without a ``[codelinks]`` table, it is silently ignored.
 869    Any other value, ``./ubproject.toml`` included, is an explicit file and warns
 870    in both cases. A file that exists but cannot be read or parsed warns
 871    (``codelinks.config``) either way, as any configured file did at 1.4.0.
 872    """
 873
 874    set_local_url: bool = field(
 875        default=False,
 876        metadata={
 877            "rebuild": "env",
 878            "types": (bool,),
 879            "schema": {
 880                "type": "boolean",
 881            },
 882        },
 883    )
 884    """Set the file URL in the extracted need."""
 885
 886    local_url_field: str = field(
 887        default="local-url",
 888        metadata={
 889            "rebuild": "env",
 890            "types": (str,),
 891            "schema": {
 892                "type": "string",
 893            },
 894        },
 895    )
 896    """The field name for the file URL in the extracted need."""
 897
 898    set_remote_url: bool = field(
 899        default=False,
 900        metadata={
 901            "rebuild": "env",
 902            "types": (bool,),
 903            "schema": {
 904                "type": "boolean",
 905            },
 906        },
 907    )
 908    remote_url_field: str = field(
 909        default="remote-url",
 910        metadata={
 911            "rebuild": "env",
 912            "types": (str,),
 913            "schema": {
 914                "type": "string",
 915            },
 916        },
 917    )
 918    """The field name for the remote URL in the extracted need."""
 919
 920    outdir: Path = field(
 921        default=Path("output"),
 922        metadata={"rebuild": "env", "types": (str), "schema": {"type": "string"}},
 923    )
 924    """The directory where  the generated artifacts and their caches will be stored."""
 925
 926    projects: dict[str, CodeLinksProjectConfigType] = field(
 927        default_factory=dict,
 928        metadata={
 929            "rebuild": "env",
 930            "types": (),
 931            "schema": {
 932                "type": "object",
 933                "additionalProperties": {
 934                    "type": "object",
 935                    "properties": {
 936                        "source_discover": {},
 937                        "analyse": {},
 938                        "remote_url_pattern": {},
 939                        "ref_url_field": {},
 940                        "source_discover_config": {},
 941                        "analyse_config": {},
 942                    },
 943                    "additionalProperties": False,
 944                },
 945            },
 946        },
 947    )
 948    """The configuration for the source tracing projects."""
 949
 950    debug_measurement: bool = field(
 951        default=False, metadata={"rebuild": "html", "types": (bool,)}
 952    )
 953    """If True, log runtime information for various functions."""
 954    debug_filters: bool = field(
 955        default=False, metadata={"rebuild": "html", "types": (bool,)}
 956    )
 957    """If True, log filter processing runtime information."""
 958
 959
 960def check_schema(config: CodeLinksConfig) -> list[str]:
 961    """Check only first layer's of schema, so that the nested dict is not validated here."""
 962    errors = []
 963    for _field_name in CodeLinksConfig.field_names():
 964        schema = CodeLinksConfig.get_schema(_field_name)
 965        if not schema:
 966            continue
 967        value = getattr(config, _field_name)
 968        if isinstance(value, Path):  # adapt to json schema restriction
 969            value = str(value)
 970        try:
 971            validate(instance=value, schema=schema)
 972        except ValidationError as e:
 973            errors.append(
 974                f"Schema validation error in filed '{_field_name}': {e.message}"
 975            )
 976    return errors
 977
 978
 979def check_project_configuration(
 980    config: CodeLinksConfig, link_types: Collection[str] = ()
 981) -> list[str]:
 982    """Check nested project configurations.
 983
 984    :param config: The configuration.
 985    :param link_types: The declared need link types, which no reference field may name
 986        (:func:`declared_link_types`; empty where no Sphinx-Needs configuration exists).
 987    """
 988    errors = []
 989
 990    for project_name, project_config in config.projects.items():
 991        project_errors: list[str] = []
 992
 993        # validate source_discover config
 994        src_discover_config: SourceDiscoverConfig | None = project_config.get(
 995            "source_discover_config"
 996        )
 997        src_discover_errors = []
 998        if src_discover_config:
 999            src_discover_errors.extend(src_discover_config.check_schema())
1000
1001        # validate analyse config
1002        analyse_config: SourceAnalyseConfig | None = project_config.get(
1003            "analyse_config"
1004        )
1005        analyse_errors = []
1006        if analyse_config:
1007            analyse_errors = analyse_config.check_fields_configuration()
1008
1009        # validate src-trace config
1010        if config.set_remote_url and "remote_url_pattern" not in project_config:
1011            project_errors.append(
1012                "remote_url_pattern must be given, as set_remote_url is enabled"
1013            )
1014
1015        if "remote_url_pattern" in project_config and not isinstance(
1016            project_config["remote_url_pattern"], str
1017        ):
1018            project_errors.append("remote_url_pattern must be a string")
1019
1020        ref_url_field = project_config.get("ref_url_field", DEFAULT_REF_URL_FIELD)
1021        if not isinstance(ref_url_field, str):
1022            project_errors.append("ref_url_field must be a string")
1023        # a field is checked where it can be written: URLs on, and ``ref_url_field``
1024        # while a plain-string marker exists, or a marker table's non-empty field
1025        if config.set_local_url or config.set_remote_url:
1026            refs_config = (
1027                analyse_config.need_id_refs_config
1028                if analyse_config
1029                else NeedIdRefsConfig()
1030            )
1031            has_plain_marker = any(
1032                isinstance(marker, str) and marker not in refs_config.fields
1033                for marker in refs_config.markers
1034            )
1035            if (
1036                has_plain_marker
1037                and isinstance(ref_url_field, str)
1038                and (
1039                    problem := reference_field_problem(
1040                        ref_url_field, config, link_types
1041                    )
1042                )
1043            ):
1044                project_errors.append(f"ref_url_field {ref_url_field!r} {problem}")
1045            for marker, field_name in refs_config.fields.items():
1046                if problem := reference_field_problem(field_name, config, link_types):
1047                    project_errors.append(
1048                        f"need_id_refs marker {marker!r}: field {field_name!r} {problem}"
1049                    )
1050
1051        if analyse_errors or src_discover_errors or project_errors:
1052            errors.append(f"Project '{project_name}' has the following errors:")
1053            errors.extend(analyse_errors)
1054            errors.extend(src_discover_errors)
1055            errors.extend(project_errors)
1056
1057    return errors
1058
1059
1060def config_base_dir(confdir: str | Path, config: CodeLinksConfig) -> Path:
1061    """The directory a project's relative paths are anchored at.
1062
1063    The configuration file's directory when ``src_trace_config_from_toml`` names one
1064    (itself anchored at ``confdir``), else ``confdir``: ``src_dir``, ``git_root`` and
1065    the preprocessor paths all resolve against it.
1066    """
1067    base = Path(confdir)
1068    if config.config_from_toml:
1069        base = anchor(Path(config.config_from_toml).parent, base)
1070    return base
1071
1072
1073def locate_src_dir(
1074    confdir: str | Path,
1075    config: CodeLinksConfig,
1076    discover_config: SourceDiscoverConfig,
1077) -> Path:
1078    """A project's source directory, anchored (see :func:`config_base_dir`) and resolved."""
1079    return anchor(discover_config.src_dir, config_base_dir(confdir, config)).resolve()
1080
1081
1082def git_root_problem(git_root: Path, src_dir: Path) -> str | None:
1083    """Why a configured ``git_root`` cannot be a project's git root, or ``None``.
1084
1085    Every source path is relative to the git root, so it must be ``src_dir`` or a
1086    directory above it (#2062). Both paths anchored and resolved by the caller: the
1087    Sphinx extension (``project_analysis.configured_git_root``) and ``codelinks
1088    analyse`` alike, each treating a value with a problem as unset.
1089    """
1090    try:
1091        if not git_root.exists():
1092            return f"git_root {git_root.as_posix()} does not exist"
1093        if not git_root.is_dir():
1094            return f"git_root {git_root.as_posix()} is not a directory"
1095    except OSError:
1096        return f"git_root {git_root.as_posix()} cannot be read"
1097    if not src_dir.is_relative_to(git_root):
1098        return (
1099            f"git_root {git_root.as_posix()} does not contain src_dir "
1100            f"{src_dir.as_posix()}"
1101        )
1102    return None
1103
1104
1105def git_root_warning(project: str, problem: str) -> str:
1106    """The one warning text for a project's ignored ``git_root`` (``codelinks.git_root``)."""
1107    return (
1108        f"project {project!r}: {problem}; it is ignored, and the repository root is "
1109        "detected from src_dir instead"
1110    )
1111
1112
1113def remote_url_pattern_warnings(config: CodeLinksConfig) -> list[str]:
1114    """Why a project's remote URL pattern will not render as one link, if it will not.
1115
1116    The ``remote-url`` value is the pattern filled in, and Sphinx-Needs splits every
1117    string-linked value on ``,`` and ``;`` before turning the parts into links. A
1118    pattern containing either -- gitweb's ``?p=repo.git;a=blob;f={path}`` is the
1119    classic -- therefore renders as several links, none of them right.
1120    """
1121    if not config.set_remote_url or not isinstance(config.projects, dict):
1122        return []
1123    warnings = []
1124    for name, project_config in config.projects.items():
1125        if not isinstance(project_config, dict):
1126            continue
1127        pattern = project_config.get("remote_url_pattern")
1128        if isinstance(pattern, str) and ("," in pattern or ";" in pattern):
1129            warnings.append(
1130                f"Project {name!r}: remote_url_pattern {pattern!r} contains ',' or "
1131                "';'. Sphinx-Needs splits string-linked values on ',' and ';', so this "
1132                "pattern's links will not render as one link."
1133            )
1134    return warnings
1135
1136
1137def _url_fields(config: CodeLinksConfig) -> set[str]:
1138    """The names of the URL fields the extension registers (when switched on)."""
1139    names: set[str] = set()
1140    if config.set_local_url:
1141        names.add(config.local_url_field)
1142    if config.set_remote_url:
1143        names.add(config.remote_url_field)
1144    return names
1145
1146
1147CORE_LINK_TYPES: frozenset[str] = frozenset({"links", "parent_needs"})
1148"""The link types every Sphinx-Needs project has."""
1149
1150
1151def declared_link_types(sphinx_config: _SphinxConfig) -> frozenset[str]:
1152    """The need link types a Sphinx configuration declares.
1153
1154    The core ones, ``needs_links``' keys and ``needs_extra_links``' ``option`` names:
1155    the link types Sphinx-Needs itself checks a field name against. Read from the
1156    configuration values, so it is complete once ``needs_from_toml`` has been loaded
1157    (early in ``config-inited``).
1158    """
1159    names = set(CORE_LINK_TYPES)
1160    links = getattr(sphinx_config, "needs_links", None)
1161    if isinstance(links, dict):
1162        names.update(name for name in links if isinstance(name, str))
1163    extra_links = getattr(sphinx_config, "needs_extra_links", None)
1164    if isinstance(extra_links, list | tuple):
1165        names.update(
1166            link["option"]
1167            for link in extra_links
1168            if isinstance(link, dict) and isinstance(link.get("option"), str)
1169        )
1170    return frozenset(names)
1171
1172
1173def reference_field_problem(
1174    field_name: str, config: CodeLinksConfig, link_types: Collection[str] = ()
1175) -> str | None:
1176    """Why a field cannot hold reference URLs, or ``None``.
1177
1178    The one check of every field references are attached to, ``ref_url_field`` and a
1179    marker table's ``field`` alike. ``""`` (attach nowhere) has no problem.
1180
1181    :param field_name: The field.
1182    :param config: The configuration, for the URL fields.
1183    :param link_types: The declared need link types (:func:`declared_link_types`).
1184    :return: The rest of a sentence naming the field, or ``None``.
1185    """
1186    if not field_name:
1187        return None
1188    if field_name in _url_fields(config):
1189        return "must differ from local_url_field and remote_url_field"
1190    if field_name in link_types:
1191        return "names a need link type, which cannot hold URLs"
1192    if is_back_link_name(field_name, link_types):
1193        return "names the back links of a need link type, which cannot hold URLs"
1194    return None
1195
1196
1197def is_back_link_name(field_name: str, link_types: Collection[str]) -> bool:
1198    """Whether ``field_name`` is the back links of one of ``link_types``
1199    (``<link type>_back``, as Sphinx-Needs names them), core or declared."""
1200    return field_name.endswith("_back") and field_name[: -len("_back")] in link_types
1201
1202
1203def need_id_refs_marker_fields(
1204    config: CodeLinksConfig, project_config: CodeLinksProjectConfigType
1205) -> dict[str, str]:
1206    """Each marker of a project whose references are attached, mapped to its field.
1207
1208    The gate: URLs are on (local or remote) and references are extracted. A marker
1209    given as a table takes its own ``field``; a marker string takes the project's
1210    ``ref_url_field``. A marker whose field is ``""``, not a string, or one of the URL
1211    fields attaches nothing and is left out -- the last two are configuration errors
1212    reported by :func:`check_project_configuration`, as is a field naming a link type.
1213    An empty mapping: the project attaches no reference.
1214    """
1215    if not (config.set_remote_url or config.set_local_url):
1216        return {}
1217    analyse_config = project_config.get("analyse_config")
1218    if analyse_config is None or not analyse_config.get_need_id_refs:
1219        return {}
1220    refs_config = analyse_config.need_id_refs_config
1221    ref_url_field = project_config.get("ref_url_field", DEFAULT_REF_URL_FIELD)
1222    url_fields = _url_fields(config)
1223    marker_fields: dict[str, str] = {}
1224    for marker in refs_config.markers:
1225        if not isinstance(marker, str):
1226            continue
1227        field_name = refs_config.fields.get(marker, ref_url_field)
1228        if isinstance(field_name, str) and field_name and field_name not in url_fields:
1229            marker_fields[marker] = field_name
1230    return marker_fields
1231
1232
1233def need_id_refs_fields(config: CodeLinksConfig) -> dict[str, dict[str, str]]:
1234    """Each project whose references are attached, mapped to its markers' fields
1235    (:func:`need_id_refs_marker_fields`)."""
1236    projects = config.projects
1237    if not isinstance(projects, dict):
1238        return {}
1239    fields_by_project: dict[str, dict[str, str]] = {}
1240    for name, project_config in projects.items():
1241        if not isinstance(project_config, dict):
1242            continue
1243        marker_fields = need_id_refs_marker_fields(config, project_config)
1244        if marker_fields:
1245            fields_by_project[name] = marker_fields
1246    return fields_by_project
1247
1248
1249def need_id_refs_field_names(config: CodeLinksConfig) -> set[str]:
1250    """Every field some project attaches references to: the fields to register."""
1251    return {
1252        field_name
1253        for marker_fields in need_id_refs_fields(config).values()
1254        for field_name in marker_fields.values()
1255    }
1256
1257
1258def check_configuration(
1259    config: CodeLinksConfig, link_types: Collection[str] = ()
1260) -> list[str]:
1261    errors = []
1262    errors.extend(check_schema(config))
1263    errors.extend(check_project_configuration(config, link_types))
1264    return errors
1265
1266
1267def convert_src_discovery_config(
1268    config_dict: SourceDiscoverSectionConfigType | None,
1269) -> SourceDiscoverConfig:
1270    if config_dict:
1271        src_discover_dict = {
1272            key: (Path(value) if key == "src_dir" and isinstance(value, str) else value)
1273            for key, value in config_dict.items()
1274        }
1275        src_discover_config = SourceDiscoverConfig(**src_discover_dict)  # ty: ignore[invalid-argument-type]
1276    else:
1277        src_discover_config = SourceDiscoverConfig()
1278
1279    return src_discover_config
1280
1281
1282def _validate_preprocessor_dict(preproc: dict[str, object]) -> None:
1283    """Validate the schema-less ``[preprocessor]`` TOML section.
1284
1285    The section has no ``TypedDict``, so a mistyped scalar would otherwise be
1286    coerced into garbage instead of reported: e.g. ``defines = "X"`` (a bare
1287    string) becomes ``list("X") == ["X"]`` — or worse, ``defines = "cpp17"``
1288    becomes ``["c", "p", "p", "1", "7"]`` → five bogus ``-D`` flags. Fail loud.
1289
1290    :param preproc: the raw ``[preprocessor]`` mapping from TOML.
1291    :raises TypeError: if a key has the wrong type.
1292    """
1293    for key in ("defines", "includes"):
1294        value = preproc.get(key)
1295        if value is not None and (
1296            not isinstance(value, list) or not all(isinstance(x, str) for x in value)
1297        ):
1298            raise TypeError(
1299                f"[preprocessor] {key} must be a list of strings, "
1300                f"got {type(value).__name__}: {value!r}"
1301            )
1302    for key in ("compile_commands", "std", "resource_dir"):
1303        value = preproc.get(key)
1304        if value is not None and not isinstance(value, str):
1305            raise TypeError(
1306                f"[preprocessor] {key} must be a string, "
1307                f"got {type(value).__name__}: {value!r}"
1308            )
1309
1310
1311REMOVED_ANALYSE_KEYS: dict[str, str] = {
1312    "get_rst": "get_multiline_needs",
1313    "marked_rst": "[analyse.multiline_needs]",
1314}
1315"""``[analyse]`` keys of the removed marked-rst blocks -> what replaces each."""
1316
1317
1318def check_removed_analyse_keys(config_dict: AnalyseSectionConfigType) -> None:
1319    """Refuse the keys of the removed ``@rst`` blocks with one message naming the cure.
1320
1321    Any other unknown key keeps failing as before, in the dataclass constructor. The one
1322    exception: ``get_rst = false`` alone asks for nothing the blocks did, and
1323    configuration scaffolds wrote it into ``ubproject.toml`` files ubCode reads too, so
1324    it passes here silently -- this runs on every configuration read -- and the build
1325    and the CLI warn once per project (:func:`get_rst_ignored_warning`).
1326
1327    :raises TypeError: through the channel every other configuration error of the
1328        section takes (``typer.BadParameter`` in the CLI, the ``config-inited`` error of
1329        a Sphinx build).
1330    """
1331    found = [key for key in REMOVED_ANALYSE_KEYS if key in config_dict]
1332    if not found:
1333        return
1334    removed = " and ".join(
1335        f"'{key}'" if key == "get_rst" else f"'[analyse.{key}]'" for key in found
1336    )
1337    replacements = " and ".join(REMOVED_ANALYSE_KEYS[key] for key in found)
1338    if _tolerated_get_rst(config_dict):
1339        return
1340    raise TypeError(
1341        f"analyse: {removed} {'is' if len(found) == 1 else 'are'} no longer supported: "
1342        "the marked-rst blocks were replaced by multi-line needs (the @need and "
1343        f"@endneed markers); use {replacements} instead"
1344    )
1345
1346
1347GET_RST_IGNORED = (
1348    "analyse: 'get_rst' is no longer read: the marked-rst blocks were replaced by "
1349    "multi-line needs (the @need and @endneed markers); get_rst = false asks for "
1350    "nothing and can be removed"
1351)
1352"""The warning for a tolerated ``get_rst = false``."""
1353
1354
1355def _tolerated_get_rst(config_dict: Any) -> bool:
1356    """Whether an ``[analyse]`` section's only removed key is ``get_rst = false``."""
1357    return (
1358        isinstance(config_dict, dict)
1359        and config_dict.get("get_rst") is False
1360        and not any(
1361            key in config_dict for key in REMOVED_ANALYSE_KEYS if key != "get_rst"
1362        )
1363    )
1364
1365
1366def get_rst_ignored_warning(name: str, project_config: Any) -> str | None:
1367    """The warning for a project whose ``[analyse]`` section sets ``get_rst = false``
1368    (tolerated, and dropped, by :func:`check_removed_analyse_keys`), else ``None``.
1369
1370    It names the project, as :func:`remote_url_pattern_warnings` does; the rest is
1371    :data:`GET_RST_IGNORED`. Called once per project where a configuration is loaded --
1372    a Sphinx build's ``config-inited``, the CLI before it analyses -- never per read
1373    of it.
1374    """
1375    if isinstance(project_config, dict) and _tolerated_get_rst(
1376        project_config.get("analyse")
1377    ):
1378        return f"Project {name!r}: {GET_RST_IGNORED}"
1379    return None
1380
1381
1382def convert_analyse_config(
1383    config_dict: AnalyseSectionConfigType | None,
1384    src_discover: SourceDiscover | None = None,
1385) -> SourceAnalyseConfig:
1386    analyse_config_dict: SourceAnalyseConfigType = {}
1387    if config_dict:
1388        check_removed_analyse_keys(config_dict)
1389        for k, v in config_dict.items():
1390            if k not in {
1391                "oneline_comment_style",
1392                "need_id_refs",
1393                "multiline_needs",
1394                "preprocessor",
1395                # tolerated (``check_removed_analyse_keys``) and ignored
1396                "get_rst",
1397            }:
1398                # Convert string paths to Path objects
1399                if k in {"src_dir", "git_root"} and isinstance(v, str):
1400                    analyse_config_dict[k] = Path(v)
1401                else:
1402                    # dynamical assignment
1403                    analyse_config_dict[k] = v  # ty: ignore[invalid-key]
1404
1405        # Get oneline_comment_style configuration
1406        oneline_comment_style_dict: OneLineCommentStyleType | None = config_dict.get(
1407            "oneline_comment_style"
1408        )
1409        oneline_comment_style: OneLineCommentStyle = (
1410            convert_oneline_comment_style_config(oneline_comment_style_dict)
1411        )
1412
1413        # Get need_id_refs configuration
1414        need_id_refs_config_dict: NeedIdRefsConfigType | None = config_dict.get(
1415            "need_id_refs"
1416        )
1417        need_id_refs_config = convert_need_id_refs_config(need_id_refs_config_dict)
1418
1419        # Get multiline_needs configuration
1420        multiline_needs_config_dict: MultilineNeedsConfigType | None = config_dict.get(
1421            "multiline_needs"
1422        )
1423        multiline_needs_config = convert_multiline_needs_config(
1424            multiline_needs_config_dict
1425        )
1426
1427        analyse_config_dict["need_id_refs_config"] = need_id_refs_config
1428        analyse_config_dict["multiline_needs_config"] = multiline_needs_config
1429        analyse_config_dict["oneline_comment_style"] = oneline_comment_style
1430
1431        preprocessor_dict = config_dict.get("preprocessor")
1432        if preprocessor_dict is not None:
1433            # The preprocessor section has no TypedDict; its values are dynamic
1434            # TOML (typed ``object``), so validate the shapes up front -- a mistyped
1435            # scalar would otherwise coerce into garbage flags.
1436            _validate_preprocessor_dict(preprocessor_dict)
1437            analyse_config_dict["preprocessor"] = PreprocessorConfig(
1438                compile_commands=(
1439                    Path(str(preprocessor_dict["compile_commands"]))
1440                    if preprocessor_dict.get("compile_commands")
1441                    else None
1442                ),
1443                defines=list(preprocessor_dict.get("defines", [])),  # ty: ignore[invalid-argument-type]
1444                includes=[Path(str(p)) for p in preprocessor_dict.get("includes", [])],  # ty: ignore[not-iterable]
1445                std=str(preprocessor_dict.get("std", DEFAULT_CPP_STD)),
1446                resource_dir=(
1447                    Path(str(preprocessor_dict["resource_dir"]))
1448                    if preprocessor_dict.get("resource_dir")
1449                    else None
1450                ),
1451            )
1452
1453    if src_discover:
1454        analyse_config_dict["src_files"] = src_discover.source_paths
1455        analyse_config_dict["src_dir"] = src_discover.src_discover_config.src_dir
1456        try:
1457            analyse_config_dict["comment_type"] = CommentType(
1458                src_discover.src_discover_config.comment_type
1459            )
1460        except ValueError:
1461            # If invalid comment_type, keep the string value
1462            # Validation will catch this error later
1463            comment_type_str: str = src_discover.src_discover_config.comment_type
1464            analyse_config_dict["comment_type"] = comment_type_str  # ty: ignore[invalid-assignment]
1465
1466    return SourceAnalyseConfig(**analyse_config_dict)
1467
1468
1469def convert_oneline_comment_style_config(
1470    config_dict: OneLineCommentStyleType | None,
1471) -> OneLineCommentStyle:
1472    if config_dict is None:
1473        oneline_comment_style = OneLineCommentStyle()
1474    else:
1475        try:
1476            oneline_comment_style = OneLineCommentStyle(**config_dict)
1477        except TypeError as e:
1478            raise TypeError(f"Invalid oneline comment style configuration: {e}") from e
1479    return oneline_comment_style
1480
1481
1482def convert_need_id_refs_config(
1483    config_dict: NeedIdRefsConfigType | None,
1484) -> NeedIdRefsConfig:
1485    if not config_dict:
1486        need_id_refs_config = NeedIdRefsConfig()
1487    else:
1488        try:
1489            # a marker table is resolved to its text by ``__post_init__``
1490            need_id_refs_config = NeedIdRefsConfig(**config_dict)  # ty: ignore[invalid-argument-type]
1491        except TypeError as e:
1492            raise TypeError(f"Invalid need_id_refs configuration: {e}") from e
1493    return need_id_refs_config
1494
1495
1496def convert_multiline_needs_config(
1497    config_dict: MultilineNeedsConfigType | None,
1498) -> MultilineNeedsConfig:
1499    if not config_dict:
1500        return MultilineNeedsConfig()
1501    try:
1502        return MultilineNeedsConfig(**config_dict)
1503    except TypeError as e:
1504        raise TypeError(f"Invalid multiline_needs configuration: {e}") from e
1505
1506
1507def generate_project_configs(
1508    project_configs: dict[str, CodeLinksProjectConfigType],
1509) -> None:
1510    """Generate configs of source discover and analyse from their classes dynamically."""
1511    for project_config in project_configs.values():
1512        # overwrite the config into different types on purpose
1513        # covert dicts to their own classes
1514        src_discover_section: SourceDiscoverSectionConfigType | None = cast(
1515            SourceDiscoverSectionConfigType,
1516            project_config.get("source_discover"),
1517        )
1518        source_discover_config = convert_src_discovery_config(src_discover_section)
1519        project_config["source_discover_config"] = source_discover_config
1520
1521        analyse_section_config: AnalyseSectionConfigType | None = cast(
1522            AnalyseSectionConfigType, project_config.get("analyse")
1523        )
1524        analyse_config = convert_analyse_config(analyse_section_config)
1525        analyse_config.get_oneline_needs = True  # force to get oneline_need
1526        # Copy comment_type from source_discover_config to analyse_config
1527        try:
1528            analyse_config.comment_type = CommentType(
1529                source_discover_config.comment_type
1530            )
1531        except ValueError:
1532            # If invalid comment_type, keep the string value
1533            # Validation will catch this error later
1534            analyse_config.comment_type = source_discover_config.comment_type  # ty: ignore[invalid-assignment]
1535        project_config["analyse_config"] = analyse_config