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