1from collections import deque
  2from dataclasses import MISSING, dataclass, field, fields
  3from enum import Enum
  4from pathlib import Path
  5from typing import Any, Literal, TypedDict, cast
  6
  7from jsonschema import ValidationError, validate
  8from sphinx.application import Sphinx
  9from sphinx.config import Config as _SphinxConfig
 10
 11from sphinx_codelinks.source_discover.config import (
 12    CommentType,
 13    SourceDiscoverConfig,
 14    SourceDiscoverSectionConfigType,
 15)
 16from sphinx_codelinks.source_discover.source_discover import SourceDiscover
 17
 18UNIX_NEWLINE = "\n"
 19
 20
 21COMMENT_MARKERS = {
[docs] 22    # @Support C and C++ style comments, IMPL_C_1, impl, [FE_C_SUPPORT, FE_CPP]
 23    CommentType.cpp: ["//", "/*"],
[docs] 24    # @Support Python style comments, IMPL_PY_1, impl, [FE_PY]
 25    CommentType.python: ["#"],
 26    CommentType.cs: ["//", "/*", "///"],
[docs] 27    # @Support Go style comments, IMPL_GO_2, impl, [FE_GO]
 28    CommentType.go: ["//", "/*"],
 29}
 30ESCAPE = "\\"
 31
 32
 33class CommentCategory(str, Enum):
 34    comment = "comment"
 35    docstring = "expression_statement"
 36
 37
 38class NeedIdRefsConfigType(TypedDict):
 39    markers: list[str]
 40
 41
 42@dataclass
 43class NeedIdRefsConfig:
 44    @classmethod
 45    def field_names(cls) -> set[str]:
 46        return {item.name for item in fields(cls)}
 47
 48    markers: list[str] = field(
 49        default_factory=lambda: ["@need-ids:"],
 50        metadata={"schema": {"type": "array", "items": {"type": "string"}}},
 51    )
 52    """The markers to extract need ids from"""
 53
 54    @classmethod
 55    def get_schema(cls, name: str) -> dict[str, Any] | None:  # type: ignore[explicit-any]
 56        _field = next(_field for _field in fields(cls) if _field.name is name)
 57        if _field.metadata and "schema" in _field.metadata:
 58            return cast(dict[str, Any], _field.metadata["schema"])  # type: ignore[explicit-any]
 59        return None
 60
 61    def check_schema(self) -> list[str]:
 62        errors = []
 63        for _field_name in self.field_names():
 64            schema = self.get_schema(_field_name)
 65            value = getattr(self, _field_name)
 66            try:
 67                validate(instance=value, schema=schema)  # type: ignore[arg-type]  # validate has no type
 68            except ValidationError as e:
 69                errors.append(
 70                    f"Schema validation error in field '{_field_name}': {e.message}"
 71                )
 72        return errors
 73
 74
 75class MarkedRstConfigType(TypedDict):
 76    start_sequence: str
 77    end_sequence: str
 78
 79
 80@dataclass
 81class MarkedRstConfig:
 82    @classmethod
 83    def field_names(cls) -> set[str]:
 84        return {item.name for item in fields(cls)}
 85
 86    start_sequence: str = field(default="@rst", metadata={"schema": {"type": "string"}})
 87    """Chars sequence to indicate the start of the rst text."""
 88    end_sequence: str = field(
 89        default="@endrst", metadata={"schema": {"type": "string"}}
 90    )
 91    """Chars sequence to indicate the end of the rst text."""
 92
 93    @classmethod
 94    def get_schema(cls, name: str) -> dict[str, Any] | None:  # type: ignore[explicit-any]
 95        _field = next(_field for _field in fields(cls) if _field.name is name)
 96        if _field.metadata and "schema" in _field.metadata:
 97            return cast(dict[str, Any], _field.metadata["schema"])  # type: ignore[explicit-any]
 98        return None
 99
100    def check_schema(self) -> list[str]:
101        errors = []
102        for _field_name in self.field_names():
103            schema = self.get_schema(_field_name)
104            value = getattr(self, _field_name)
105            try:
106                validate(instance=value, schema=schema)  # type: ignore[arg-type]  # validate has no type
107            except ValidationError as e:
108                errors.append(
109                    f"Schema validation error in field '{_field_name}': {e.message}"
110                )
111        return errors
112
113    def check_sequence_mutually_exclusive(self) -> list[str]:
114        errors = []
115        if self.start_sequence == self.end_sequence:
116            errors.append("start_sequence and end_sequence cannot be the same.")
117        return errors
118
119    def check_fields_configuration(self) -> list[str]:
120        return self.check_schema() + self.check_sequence_mutually_exclusive()
121
122
123class FieldConfig(TypedDict, total=False):
124    name: str
125    type: Literal["str", "list[str]"]
126    default: str | list[str] | None
127
128
129class OneLineCommentStyleType(TypedDict):
130    start_sequence: str
131    end_sequence: str
132    field_split_char: str
133    needs_fields: list[FieldConfig]
134
135
136@dataclass
137class OneLineCommentStyle:
138    def __setattr__(self, name: str, value: Any) -> None:  # type: ignore[explicit-any]
139        if name == "needs_fields":
140            # apply default to fields
141            self.apply_needs_field_default(value)
142        return super().__setattr__(name, value)
143
144    @classmethod
145    def field_names(cls) -> set[str]:
146        return {item.name for item in fields(cls)}
147
148    start_sequence: str = field(default="@", metadata={"schema": {"type": "string"}})
149    """Chars sequence to indicate the start of the one-line comment."""
150
151    end_sequence: str = field(
152        default=UNIX_NEWLINE, metadata={"schema": {"type": "string"}}
153    )
154    """Chars sequence to indicate the end of the one-line comment."""
155
156    field_split_char: str = field(default=",", metadata={"schema": {"type": "string"}})
157    """Char sequence to split the fields."""
158
159    needs_fields: list[FieldConfig] = field(
160        default_factory=lambda: [
161            {"name": "title"},
162            {"name": "id"},
163            {"name": "type", "default": "impl"},
164            {"name": "links", "type": "list[str]", "default": []},
165        ],
166        metadata={
167            "required_fields": ["title", "type"],
168            "field_default": {
169                "type": "str",
170            },
171            "schema": {
172                "type": "array",
173                "items": {
174                    "type": "object",
175                    "properties": {
176                        "name": {"type": "string"},
177                        "type": {
178                            "type": "string",
179                            "enum": ["str", "list[str]"],
180                            "default": "str",
181                        },
182                        "default": {
183                            "anyOf": [
184                                {"type": "string"},
185                                {"type": "array", "items": {"type": "string"}},
186                            ]
187                        },
188                    },
189                    "required": ["name"],
190                    "additionalProperties": False,
191                    "allOf": [
192                        {
193                            "if": {"properties": {"type": {"const": "list[str]"}}},
194                            "then": {
195                                "properties": {
196                                    "default": {
197                                        "type": "array",
198                                        "items": {"type": "string"},
199                                    }
200                                }
201                            },
202                        },
203                        {
204                            "if": {"properties": {"type": {"const": "str"}}},
205                            "then": {"properties": {"default": {"type": "string"}}},
206                        },
207                    ],
208                },
209            },
210        },
211    )
212
213    @classmethod
214    def apply_needs_field_default(cls, given_fields: list[FieldConfig]) -> None:
215        field_default = next(
216            _field.metadata["field_default"]
217            for _field in fields(cls)
218            if _field.name == "needs_fields"
219        )
220
221        for _field in given_fields:
222            for _default in field_default:
223                if _default not in _field:
224                    _field[_default] = field_default[_default]  # type: ignore[literal-required]  # dynamically assign keys
225
226    @classmethod
227    def get_required_fields(cls, name: str) -> list[str] | None:
228        _field = next(_field for _field in fields(cls) if _field.name is name)
229        if _field.metadata:
230            return cast(list[str], _field.metadata["required_fields"])
231        return None
232
233    @classmethod
234    def get_schema(cls, name: str) -> dict[str, Any] | None:  # type: ignore[explicit-any]
235        _field = next(_field for _field in fields(cls) if _field.name is name)
236        if _field.metadata and "schema" in _field.metadata:
237            return cast(dict[str, Any], _field.metadata["schema"])  # type: ignore[explicit-any]
238        return None
239
240    def check_schema(self) -> list[str]:
241        errors = []
242        for _field_name in self.field_names():
243            schema = self.get_schema(_field_name)
244            value = getattr(self, _field_name)
245            try:
246                validate(instance=value, schema=schema)  # type: ignore[arg-type]  # validate has no type specified
247            except ValidationError as e:
248                if _field_name == "needs_fields":
249                    need_field_name = value[e.path[0]]["name"]
250                    errors.append(
251                        f"Schema validation error in need_fields '{need_field_name}': {e.message}"
252                    )
253                else:
254                    errors.append(
255                        f"Schema validation error in field '{_field_name}': {e.message}"
256                    )
257        return errors
258
259    def check_required_fields(self) -> list[str]:
260        errors = []
261        required_fields = self.get_required_fields("needs_fields")
262        if required_fields is None:
263            errors.append("No required fields specified.")
264            return errors
265        given_field_names = [_field["name"] for _field in self.needs_fields]
266        missing_fields = set(required_fields) - set(given_field_names)
267        if len(missing_fields) != 0:
268            errors.append(f"Missing required fields: {sorted(missing_fields)}")
269
270        return errors
271
272    def check_fields_mutually_exclusive(self) -> list[str]:
273        errors = []
274        needs_field_names = set()
275        for _field in self.needs_fields:
276            if _field["name"] in needs_field_names:
277                errors.append(f"Field '{_field['name']}' is defined multiple times.")
278            needs_field_names.add(_field["name"])
279        return errors
280
281    def check_fields_default_order(self) -> list[str]:
282        errors = []
283        seen_default = False
284        first_default_field = ""
285        for _field in self.needs_fields:
286            has_default = _field.get("default") is not None
287            if has_default and not seen_default:
288                seen_default = True
289                first_default_field = _field["name"]
290            elif not has_default and seen_default:
291                errors.append(
292                    f"Field '{_field['name']}' without a default follows "
293                    f"field '{first_default_field}' which has a default. "
294                    f"Fields without defaults must be defined before fields with defaults."
295                )
296        return errors
297
298    def check_fields_configuration(self) -> list[str]:
299        return (
300            self.check_schema()
301            + self.check_required_fields()
302            + self.check_fields_mutually_exclusive()
303            + self.check_fields_default_order()
304        )
305
306    def get_cnt_required_fields(self) -> int:
307        cnt_required_fields = 0
308        for _field in self.needs_fields:
309            if _field.get("default") is None:
310                cnt_required_fields += 1
311        return cnt_required_fields
312
313    def get_pos_list_str(self) -> list[int]:
314        pos_list_str = []
315        for idx, _field in enumerate(self.needs_fields):
316            if _field["type"] == "list[str]":
317                pos_list_str.append(idx + 1)
318        return pos_list_str
319
320
321class AnalyseSectionConfigType(TypedDict, total=False):
322    """Define typing for loading `analyse` section from the file."""
323
324    get_need_id_refs: bool
325    get_oneline_needs: bool
326    get_rst: bool
327    outdir: str
328    git_root: str
329    need_id_refs: NeedIdRefsConfigType
330    marked_rst: MarkedRstConfigType
331    oneline_comment_style: OneLineCommentStyleType
332
333
334class SourceAnalyseConfigType(TypedDict, total=False):
335    """Define typing for its API configuration."""
336
337    src_files: list[Path]
338    src_dir: Path
339    comment_type: CommentType
340    get_need_id_refs: bool
341    get_oneline_needs: bool
342    get_rst: bool
343    git_root: Path | None
344    need_id_refs_config: NeedIdRefsConfig
345    marked_rst_config: MarkedRstConfig
346    oneline_comment_style: OneLineCommentStyle
347
348
349class ProjectsAnalyseConfigType(TypedDict, total=False):
350    projects_config: dict[str, SourceAnalyseConfigType]
351
352
353@dataclass
354class SourceAnalyseConfig:
355    @classmethod
356    def field_names(cls) -> set[str]:
357        return {item.name for item in fields(cls)}
358
359    src_files: list[Path] = field(
360        default_factory=list,
361        metadata={"schema": {"type": "array", "items": {"type": "string"}}},
362    )
363    """A list of source files to be  processed."""
364    src_dir: Path = field(
365        default_factory=lambda: Path("./"), metadata={"schema": {"type": "string"}}
366    )
367
368    comment_type: CommentType = field(
369        default=CommentType.cpp, metadata={"schema": {"type": "string"}}
370    )
371    """The type of comment to be processed."""
372
373    get_need_id_refs: bool = field(
374        default=True, metadata={"schema": {"type": "boolean"}}
375    )
376    """Whether to extract need id references from comments"""
377
378    get_oneline_needs: bool = field(
379        default=False, metadata={"schema": {"type": "boolean"}}
380    )
381    """Whether to extract oneline needs from comments"""
382
383    get_rst: bool = field(default=False, metadata={"schema": {"type": "boolean"}})
384    """Whether to extract rst texts from comments"""
385
386    git_root: Path | None = field(
387        default=None, metadata={"schema": {"type": ["string", "null"]}}
388    )
389    """Explicit path to the Git repository root. If not set, it will be auto-detected
390    by traversing parent directories. Useful for Bazel builds or deeply nested configs."""
391
392    need_id_refs_config: NeedIdRefsConfig = field(default_factory=NeedIdRefsConfig)
393    """Configuration for extracting need id references from comments."""
394
395    marked_rst_config: MarkedRstConfig = field(default_factory=MarkedRstConfig)
396    """Configuration for extracting rst texts from comments."""
397
398    oneline_comment_style: OneLineCommentStyle = field(
399        default_factory=OneLineCommentStyle
400    )
401    """Configuration for extracting oneline needs from comments."""
402
403    @classmethod
404    def get_schema(cls, name: str) -> dict[str, Any] | None:  # type: ignore[explicit-any]
405        _field = next(_field for _field in fields(cls) if _field.name is name)
406        if _field.metadata and "schema" in _field.metadata:
407            return cast(dict[str, Any], _field.metadata["schema"])  # type: ignore[explicit-any]
408        return None
409
410    def check_schema(self) -> list[str]:
411        errors = []
412        for _field_name in self.field_names():
413            schema = self.get_schema(_field_name)
414            if not schema:
415                continue
416            value = getattr(self, _field_name)
417            if isinstance(value, Path):  # adapt to json schema restriction
418                value = str(value)
419            if _field_name == "src_files" and isinstance(
420                value, list
421            ):  # adapt to json schema restriction
422                value: list[str] = [str(src_file) for src_file in value]  # type: ignore[no-redef] # only for value adaptation
423            try:
424                validate(instance=value, schema=schema)
425            except ValidationError as e:
426                errors.append(
427                    f"Schema validation error in field '{_field_name}': {e.message}"
428                )
429        return errors
430
431    def check_markers_mutually_exclusive(self) -> list[str]:
432        errors = set()
433        markers = set()
434        markers.add(self.oneline_comment_style.start_sequence)
435        markers.add(self.oneline_comment_style.end_sequence)
436        if self.marked_rst_config.start_sequence in markers:
437            errors.add(
438                f"Marker {self.marked_rst_config.start_sequence} is defined multiple times"
439            )
440        else:
441            markers.add(self.marked_rst_config.start_sequence)
442        if self.marked_rst_config.end_sequence in markers:
443            errors.add(
444                f"Marker {self.marked_rst_config.end_sequence} is defined multiple times"
445            )
446        else:
447            markers.add(self.marked_rst_config.end_sequence)
448
449        for marker in self.need_id_refs_config.markers:
450            if marker in markers:
451                errors.add(f"Marker {marker} is defined multiple times")
452            else:
453                markers.add(marker)
454        return list(errors)
455
456    def check_fields_configuration(self) -> list[str]:
457        errors: deque[str] = deque()
458        if self.get_need_id_refs:
459            need_id_refs_errors = self.need_id_refs_config.check_schema()
460            if need_id_refs_errors:
461                errors.appendleft("NeedIdRefs configuration errors:")
462                errors.extend(need_id_refs_errors)
463        if self.get_oneline_needs:
464            oneline_needs_errors = (
465                self.oneline_comment_style.check_fields_configuration()
466            )
467            if oneline_needs_errors:
468                errors.appendleft("OneLineCommentStyle configuration errors:")
469                errors.extend(oneline_needs_errors)
470        if self.get_rst:
471            marked_rst_errors = self.marked_rst_config.check_fields_configuration()
472            if marked_rst_errors:
473                errors.appendleft("MarkedRst configuration errors:")
474                errors.extend(self.marked_rst_config.check_fields_configuration())
475        analyse_errors = self.check_markers_mutually_exclusive() + self.check_schema()
476        if analyse_errors:
477            errors.appendleft("analyse configuration errors:")
478            errors.extend(analyse_errors)
479        return list(errors)
480
481
482SRC_TRACE_CACHE: str = "src_trace_cache"
483
484
485class SourceTracingLineHref:
486    """Global class for the mapping between source file line numbers and Sphinx documentation links."""
487
488    def __init__(self) -> None:
489        self.mappings: dict[str, dict[int, str]] = {}
490
491
492file_lineno_href = SourceTracingLineHref()
493
494
495class CodeLinksProjectConfigType(TypedDict, total=False):
496    """TypedDict defining the configuration structure for individual SrcTrace projects.
497
498    Contains both user-provided configuration:
499    - source_discover
500    - remote_url_pattern
501    - analyse
502    and runtime-generated configuration objects
503    - source_discover_config
504    - analyse_config
505    """
506
507    source_discover: SourceDiscoverSectionConfigType
508    remote_url_pattern: str
509    analyse: AnalyseSectionConfigType
510    source_discover_config: SourceDiscoverConfig
511    analyse_config: SourceAnalyseConfig
512
513
514class CodeLinksConfigType(TypedDict):
515    config_from_toml: str | None
516    set_local_url: bool
517    local_url_field: str
518    set_remote_url: bool
519    remote_url_field: str
520    outdir: Path
521    projects: dict[str, CodeLinksProjectConfigType]
522    debug_measurement: bool
523    debug_filters: bool
524
525
526@dataclass
527class CodeLinksConfig:
528    @classmethod
529    def from_sphinx(cls, sphinx_config: _SphinxConfig) -> "CodeLinksConfig":
530        obj = cls()
531        super().__setattr__(obj, "_sphinx_config", sphinx_config)
532        return obj
533
534    def __getattribute__(self, name: str) -> Any:  # type: ignore[explicit-any]
535        if name.startswith("__") or name == "_sphinx_config":
536            return super().__getattribute__(name)
537        sphinx_config = (
538            object.__getattribute__(self, "_sphinx_config")
539            if "_sphinx_config" in self.__dict__
540            else None
541        )
542        if sphinx_config:
543            return getattr(
544                super().__getattribute__("_sphinx_config"), f"src_trace_{name}"
545            )
546
547        return object.__getattribute__(self, name)
548
549    def __setattr__(self, name: str, value: Any) -> None:  # type: ignore[explicit-any]
550        if name == "_sphinx_config" and "src_trace_projects" in value:
551            src_trace_projects: dict[str, CodeLinksProjectConfigType] = value[
552                "src_trace_projects"
553            ]
554            generate_project_configs(src_trace_projects)
555
556        if name.startswith("__") or name == "_sphinx_config":
557            return super().__setattr__(name, value)
558
559        sphinx_config = (
560            object.__getattribute__(self, "_sphinx_config")
561            if "_sphinx_config" in self.__dict__
562            else None
563        )
564
565        if sphinx_config:
566            setattr(
567                super().__getattribute__("_sphinx_config"), f"src_trace_{name}", value
568            )
569
570        if name == "outdir" and isinstance(value, str):
571            # Ensure outdir is a Path object
572            value = Path(value)
573        return object.__setattr__(self, name, value)
574
575    @classmethod
576    def add_config_values(cls, app: Sphinx) -> None:
577        """Add all config values to Sphinx application"""
578        for item in fields(cls):
579            if item.default_factory is not MISSING:
580                default = item.default_factory()
581            elif item.default is not MISSING:
582                default = item.default
583            else:
584                raise Exception(f"Field {item.name} has no default value or factory")
585
586            name = item.name
587            app.add_config_value(
588                f"src_trace_{name}",
589                default,
590                item.metadata["rebuild"],
591                types=item.metadata["types"],
592            )
593
594    @classmethod
595    def field_names(cls) -> set[str]:
596        return {item.name for item in fields(cls)}
597
598    @classmethod
599    def get_schema(cls, name: str) -> dict[str, Any] | None:  # type: ignore[explicit-any]
600        """Get the schema for a config item."""
601        _field = next(field for field in fields(cls) if field.name is name)
602        if _field.metadata and "schema" in _field.metadata:
603            return _field.metadata["schema"]  # type: ignore[no-any-return]
604        return None
605
606    config_from_toml: str | None = field(
607        default=None,
608        metadata={
609            "rebuild": "env",
610            "types": (str, type(None)),
611            "schema": {
612                "type": ["string", "null"],
613                "examples": ["config.toml", None],
614            },
615        },
616    )
617    """Path to a TOML file to load configuration from."""
618
619    set_local_url: bool = field(
620        default=False,
621        metadata={
622            "rebuild": "env",
623            "types": (bool,),
624            "schema": {
625                "type": "boolean",
626            },
627        },
628    )
629    """Set the file URL in the extracted need."""
630
631    local_url_field: str = field(
632        default="local-url",
633        metadata={
634            "rebuild": "env",
635            "types": (str,),
636            "schema": {
637                "type": "string",
638            },
639        },
640    )
641    """The field name for the file URL in the extracted need."""
642
643    set_remote_url: bool = field(
644        default=False,
645        metadata={
646            "rebuild": "env",
647            "types": (bool,),
648            "schema": {
649                "type": "boolean",
650            },
651        },
652    )
653    remote_url_field: str = field(
654        default="remote-url",
655        metadata={
656            "rebuild": "env",
657            "types": (str,),
658            "schema": {
659                "type": "string",
660            },
661        },
662    )
663    """The field name for the remote URL in the extracted need."""
664
665    outdir: Path = field(
666        default=Path("output"),
667        metadata={"rebuild": "env", "types": (str), "schema": {"type": "string"}},
668    )
669    """The directory where  the generated artifacts and their caches will be stored."""
670
671    projects: dict[str, CodeLinksProjectConfigType] = field(
672        default_factory=dict,
673        metadata={
674            "rebuild": "env",
675            "types": (),
676            "schema": {
677                "type": "object",
678                "additionalProperties": {
679                    "type": "object",
680                    "properties": {
681                        "source_discover": {},
682                        "analyse": {},
683                        "remote_url_pattern": {},
684                        "source_discover_config": {},
685                        "analyse_config": {},
686                    },
687                    "additionalProperties": False,
688                },
689            },
690        },
691    )
692    """The configuration for the source tracing projects."""
693
694    debug_measurement: bool = field(
695        default=False, metadata={"rebuild": "html", "types": (bool,)}
696    )
697    """If True, log runtime information for various functions."""
698    debug_filters: bool = field(
699        default=False, metadata={"rebuild": "html", "types": (bool,)}
700    )
701    """If True, log filter processing runtime information."""
702
703
704def check_schema(config: CodeLinksConfig) -> list[str]:
705    """Check only first layer's of schema, so that the nested dict is not validated here."""
706    errors = []
707    for _field_name in CodeLinksConfig.field_names():
708        schema = CodeLinksConfig.get_schema(_field_name)
709        if not schema:
710            continue
711        value = getattr(config, _field_name)
712        if isinstance(value, Path):  # adapt to json schema restriction
713            value = str(value)
714        try:
715            validate(instance=value, schema=schema)
716        except ValidationError as e:
717            errors.append(
718                f"Schema validation error in filed '{_field_name}': {e.message}"
719            )
720    return errors
721
722
723def check_project_configuration(config: CodeLinksConfig) -> list[str]:
724    """Check nested project configurations"""
725    errors = []
726
727    for project_name, project_config in config.projects.items():
728        project_errors: list[str] = []
729
730        # validate source_discover config
731        src_discover_config: SourceDiscoverConfig | None = project_config.get(
732            "source_discover_config"
733        )
734        src_discover_errors = []
735        if src_discover_config:
736            src_discover_errors.extend(src_discover_config.check_schema())
737
738        # validate analyse config
739        analyse_config: SourceAnalyseConfig | None = project_config.get(
740            "analyse_config"
741        )
742        analyse_errors = []
743        if analyse_config:
744            analyse_errors = analyse_config.check_fields_configuration()
745
746        # validate src-trace config
747        if config.set_remote_url and "remote_url_pattern" not in project_config:
748            project_errors.append(
749                "remote_url_pattern must be given, as set_remote_url is enabled"
750            )
751
752        if "remote_url_pattern" in project_config and not isinstance(
753            project_config["remote_url_pattern"], str
754        ):
755            project_errors.append("remote_url_pattern must be a string")
756
757        if analyse_errors or src_discover_errors or project_errors:
758            errors.append(f"Project '{project_name}' has the following errors:")
759            errors.extend(analyse_errors)
760            errors.extend(src_discover_errors)
761            errors.extend(project_errors)
762
763    return errors
764
765
766def check_configuration(config: CodeLinksConfig) -> list[str]:
767    errors = []
768    errors.extend(check_schema(config))
769    errors.extend(check_project_configuration(config))
770    return errors
771
772
773def convert_src_discovery_config(
774    config_dict: SourceDiscoverSectionConfigType | None,
775) -> SourceDiscoverConfig:
776    if config_dict:
777        src_discover_dict = {
778            key: (Path(value) if key == "src_dir" and isinstance(value, str) else value)
779            for key, value in config_dict.items()
780        }
781        src_discover_config = SourceDiscoverConfig(**src_discover_dict)  # type: ignore[arg-type] # mypy is confused by dynamic assignment
782    else:
783        src_discover_config = SourceDiscoverConfig()
784
785    return src_discover_config
786
787
788def convert_analyse_config(
789    config_dict: AnalyseSectionConfigType | None,
790    src_discover: SourceDiscover | None = None,
791) -> SourceAnalyseConfig:
792    analyse_config_dict: SourceAnalyseConfigType = {}
793    if config_dict:
794        for k, v in config_dict.items():
795            if k not in {"online_comment_style", "need_id_refs", "marked_rst"}:
796                # Convert string paths to Path objects
797                if k in {"src_dir", "git_root"} and isinstance(v, str):
798                    analyse_config_dict[k] = Path(v)  # type: ignore[literal-required]
799                else:
800                    analyse_config_dict[k] = v  # type: ignore[literal-required]  # dynamical assignment
801
802        # Get oneline_comment_style configuration
803        oneline_comment_style_dict: OneLineCommentStyleType | None = config_dict.get(
804            "oneline_comment_style"
805        )
806        oneline_comment_style: OneLineCommentStyle = (
807            convert_oneline_comment_style_config(oneline_comment_style_dict)
808        )
809
810        # Get need_id_refs configuration
811        need_id_refs_config_dict: NeedIdRefsConfigType | None = config_dict.get(
812            "need_id_refs"
813        )
814        need_id_refs_config = convert_need_id_refs_config(need_id_refs_config_dict)
815
816        # Get marked_rst configuration
817        marked_rst_config_dict: MarkedRstConfigType | None = config_dict.get(
818            "marked_rst"
819        )
820        marked_rst_config = convert_marked_rst_config(marked_rst_config_dict)
821
822        analyse_config_dict["need_id_refs_config"] = need_id_refs_config
823        analyse_config_dict["marked_rst_config"] = marked_rst_config
824        analyse_config_dict["oneline_comment_style"] = oneline_comment_style
825
826    if src_discover:
827        analyse_config_dict["src_files"] = src_discover.source_paths
828        analyse_config_dict["src_dir"] = src_discover.src_discover_config.src_dir
829        try:
830            analyse_config_dict["comment_type"] = CommentType(
831                src_discover.src_discover_config.comment_type
832            )
833        except ValueError:
834            # If invalid comment_type, keep the string value
835            # Validation will catch this error later
836            comment_type_str: str = src_discover.src_discover_config.comment_type
837            analyse_config_dict["comment_type"] = comment_type_str  # type: ignore[typeddict-item]
838
839    return SourceAnalyseConfig(**analyse_config_dict)
840
841
842def convert_oneline_comment_style_config(
843    config_dict: OneLineCommentStyleType | None,
844) -> OneLineCommentStyle:
845    if config_dict is None:
846        oneline_comment_style = OneLineCommentStyle()
847    else:
848        try:
849            oneline_comment_style = OneLineCommentStyle(**config_dict)
850        except TypeError as e:
851            raise TypeError(f"Invalid oneline comment style configuration: {e}") from e
852    return oneline_comment_style
853
854
855def convert_need_id_refs_config(
856    config_dict: NeedIdRefsConfigType | None,
857) -> NeedIdRefsConfig:
858    if not config_dict:
859        need_id_refs_config = NeedIdRefsConfig()
860    else:
861        try:
862            need_id_refs_config = NeedIdRefsConfig(**config_dict)
863        except TypeError as e:
864            raise TypeError(f"Invalid oneline comment style configuration: {e}") from e
865    return need_id_refs_config
866
867
868def convert_marked_rst_config(
869    config_dict: MarkedRstConfigType | None,
870) -> MarkedRstConfig:
871    if not config_dict:
872        marked_rst_config = MarkedRstConfig()
873    else:
874        try:
875            marked_rst_config = MarkedRstConfig(**config_dict)
876        except TypeError as e:
877            raise TypeError(f"Invalid oneline comment style configuration: {e}") from e
878    return marked_rst_config
879
880
881def generate_project_configs(
882    project_configs: dict[str, CodeLinksProjectConfigType],
883) -> None:
884    """Generate configs of source discover and analyse from their classes dynamically."""
885    for project_config in project_configs.values():
886        # overwrite the config into different types on purpose
887        # covert dicts to their own classes
888        src_discover_section: SourceDiscoverSectionConfigType | None = cast(
889            SourceDiscoverSectionConfigType,
890            project_config.get("source_discover"),
891        )
892        source_discover_config = convert_src_discovery_config(src_discover_section)
893        project_config["source_discover_config"] = source_discover_config
894
895        analyse_section_config: AnalyseSectionConfigType | None = cast(
896            AnalyseSectionConfigType, project_config.get("analyse")
897        )
898        analyse_config = convert_analyse_config(analyse_section_config)
899        analyse_config.get_oneline_needs = True  # force to get oneline_need
900        # Copy comment_type from source_discover_config to analyse_config
901        try:
902            analyse_config.comment_type = CommentType(
903                source_discover_config.comment_type
904            )
905        except ValueError:
906            # If invalid comment_type, keep the string value
907            # Validation will catch this error later
908            analyse_config.comment_type = source_discover_config.comment_type  # type: ignore[assignment]
909        project_config["analyse_config"] = analyse_config