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