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