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