1from dataclasses import dataclass, field, fields
  2from enum import Enum
  3from pathlib import Path
  4from typing import Any, Required, TypedDict, cast
  5
  6from jsonschema import ValidationError, validate
  7
  8COMMENT_FILETYPE = {
  9    "cpp": ["c", "ci", "cpp", "cc", "cxx", "h", "hpp", "hxx", "hh", "ihl"],
 10    "python": ["py"],
 11    "cs": ["cs"],
 12    # ".mts"/".cts" are TypeScript's own ESM/CJS module variants. ".js"/".jsx"/
 13    # ".mjs"/".cjs" are JavaScript. All of these share the "ts" comment type:
 14    # comment syntax is identical across the family, and the analyse stage
 15    # picks the actual tree-sitter grammar per file from the suffix (the plain
 16    # TypeScript grammar for ".ts"/".mts"/".cts", the TSX grammar for
 17    # everything else — see utils.ts_grammar_key), so no separate
 18    # comment_type value is needed here.
 19    "ts": ["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"],
 20    "yaml": ["yml", "yaml"],
 21    "rust": ["rs"],
 22    "go": ["go"],
 23    "jsonc": ["jsonc", "json"],
 24    # Bash uses `#` line comments; zsh and ksh share the same comment syntax and
 25    # are scanned with the bash grammar. Fish is intentionally excluded: it is
 26    # not POSIX-compatible and no tree-sitter-fish distribution is published on
 27    # PyPI, so it cannot be wired in here. Track fish separately if a PyPI
 28    # grammar becomes available.
 29    "bash": ["sh", "bash", "zsh", "ksh"],
 30    # Scripts, script modules and module manifests: all three are PowerShell
 31    # source with `#` and `<# #>` comments.
 32    "powershell": ["ps1", "psm1", "psd1"],
 33    "vhdl": ["vhd", "vhdl"],
 34}
 35
 36
 37# Default ``exclude`` glob patterns applied to ``ts`` (TypeScript/JavaScript
 38# family) projects when their configuration does not set ``exclude``
 39# explicitly.
 40#
 41# ``src_dir`` defaults to ``"./"`` and the ``ts`` comment type claims ``.js``/
 42# ``.jsx``/``.mjs``/``.cjs`` in addition to TypeScript's own extensions, so a
 43# checked-in ``tsc``/bundler output directory (``dist/``, ``build/``, ...) is
 44# otherwise scanned as source alongside the ``.ts`` it was generated from,
 45# producing duplicate need ids for the same marker. These directory names are
 46# common generated-output or dependency locations across the JS/TS ecosystem,
 47# so excluding them by default avoids that duplication for most projects out
 48# of the box.
 49#
 50# ``**/lib/**`` is deliberately NOT in this list: it is ambiguous even within
 51# the JS/TS ecosystem, where many packages use ``lib/`` for hand-written
 52# source rather than as a ``tsc`` ``outDir``. Projects whose ``outDir`` is
 53# ``lib`` should add ``"**/lib/**"`` to their own ``exclude`` explicitly.
 54#
 55# This default is applied only for ``comment_type == "ts"``. Every other
 56# ``comment_type`` (``cpp``, ``python``, ``rust``, ``go``, ``yaml``, ``jsonc``,
 57# ``bash``, ``cs``, ...) defaults ``exclude`` to ``[]`` — unchanged from
 58# before this default existed. ``cpp`` projects in particular very commonly
 59# keep hand-written library source under ``lib/``, so a directory-name-based
 60# default that isn't scoped to the language family would silently drop
 61# markers there.
 62#
 63# Setting ``exclude`` explicitly in a project's configuration replaces this
 64# default outright (it is not merged) — including setting it to ``[]`` to
 65# scan everything.
 66TS_DEFAULT_EXCLUDE = [
 67    "**/node_modules/**",
 68    "**/dist/**",
 69    "**/build/**",
 70    "**/out/**",
 71    "**/coverage/**",
 72]
 73
 74
 75class CommentType(str, Enum):  # noqa: UP042  # StrEnum changes str(member), which reaches CLI warnings and error messages
 76    python = "python"
 77    cpp = "cpp"
 78    cs = "cs"
[docs] 79    # @Support TypeScript style comments, IMPL_TS_1, impl, [FE_TS]
 80    ts = "ts"
 81    yaml = "yaml"
[docs] 82    # @Support Rust style comments, IMPL_RUST_1, impl, [FE_RUST]
 83    rust = "rust"
[docs] 84    # @Support Go style comments, IMPL_GO_1, impl, [FE_GO]
 85    go = "go"
[docs] 86    # @Support JSONC style comments, IMPL_JSONC_1, impl, [FE_JSONC]
 87    jsonc = "jsonc"
[docs] 88    # @Support Bash style comments, IMPL_BASH_1, impl, [FE_BASH]
 89    bash = "bash"
[docs] 90    # @Support PowerShell style comments, IMPL_PS_1, impl, [FE_POWERSHELL]
 91    powershell = "powershell"
[docs] 92    # @Support VHDL style comments, IMPL_VHDL_1, impl, [FE_VHDL]
 93    vhdl = "vhdl"
 94
 95
 96def default_exclude_for_comment_type(comment_type: str) -> list[str]:
 97    """Resolve the default ``exclude`` patterns for a given ``comment_type``.
 98
 99    Only the ``ts`` (TypeScript/JavaScript) family gets a non-empty default —
100    see ``TS_DEFAULT_EXCLUDE`` for why. Every other ``comment_type`` defaults
101    to ``[]``, i.e. no default exclusion at all, matching the behavior before
102    a default was ever introduced.
103    """
104    if comment_type == CommentType.ts:
105        return list(TS_DEFAULT_EXCLUDE)
106    return []
107
108
109class SourceDiscoverSectionConfigType(TypedDict, total=False):
110    """Define typing for loading configuration from TOML files"""
111
112    src_dir: Required[str]
113    exclude: list[str]
114    include: list[str]
115    gitignore: bool
116    follow_links: bool
117    comment_type: CommentType
118
119
120class SourceDiscoverConfigType(TypedDict, total=False):
121    """Define typing for its API configuration"""
122
123    src_dir: Required[Path]
124    exclude: list[str]
125    include: list[str]
126    gitignore: bool
127    follow_links: bool
128    comment_type: CommentType
129
130
131@dataclass
132class SourceDiscoverConfig:
133    @classmethod
134    def field_names(cls) -> set[str]:
135        return {item.name for item in fields(cls)}
136
137    src_dir: Path = field(
138        default_factory=lambda: Path("./"), metadata={"schema": {"type": "string"}}
139    )
140    """The root of the source directory."""
141
142    exclude: list[str] | None = field(
143        default=None,
144        metadata={"schema": {"type": "array", "items": {"type": "string"}}},
145    )
146    """The glob pattern to exclude files.
147
148    Leave unset (``None``) to get the ``comment_type``-derived default
149    resolved in ``__post_init__`` (see ``default_exclude_for_comment_type``):
150    ``TS_DEFAULT_EXCLUDE`` for ``comment_type == "ts"``, ``[]`` for every
151    other ``comment_type``. Set this explicitly — including to ``[]`` — to
152    replace that default outright; it is never merged with it.
153    """
154
155    include: list[str] = field(
156        default_factory=list,
157        metadata={"schema": {"type": "array", "items": {"type": "string"}}},
158    )
159    """The glob pattern to include files."""
160
161    gitignore: bool = field(default=True, metadata={"schema": {"type": "boolean"}})
162    """Whether to respect .gitignore to exclude files."""
163
164    follow_links: bool = field(default=False, metadata={"schema": {"type": "boolean"}})
165    """Whether to follow symbolic links during file discovery."""
166
167    comment_type: str = field(
168        default="cpp",
169        metadata={
170            "schema": {
171                "type": "string",
172                "enum": sorted(COMMENT_FILETYPE),
173            }
174        },
175    )
176    """The file types to discover."""
177
178    def __post_init__(self) -> None:
179        # ``None`` means "the user didn't set exclude" (a dataclass field
180        # default can't otherwise be told apart from an explicit ``[]``) —
181        # resolve it to the comment_type-derived default. An explicit value,
182        # including ``[]``, is left untouched.
183        if self.exclude is None:
184            self.exclude = default_exclude_for_comment_type(self.comment_type)
185
186    @classmethod
187    def get_schema(cls, name: str) -> dict[str, Any] | None:
188        _field = next(_field for _field in fields(cls) if _field.name is name)
189        if _field.metadata and "schema" in _field.metadata:
190            return cast(dict[str, Any], _field.metadata["schema"])
191        return None
192
193    def check_schema(self) -> list[str]:
194        errors = []
195        for _field_name in self.field_names():
196            schema = self.get_schema(_field_name)
197            if schema is None:
198                # no schema to check against; `validate` would raise TypeError
199                continue
200            value = getattr(self, _field_name)
201            if isinstance(value, Path):  # adapt to json schema restriction
202                value = str(value)
203            try:
204                validate(
205                    instance=value,
206                    schema=schema,
207                )  # validate has no type specified
208            except ValidationError as e:
209                errors.append(
210                    f"Schema validation error in field '{_field_name}': {e.message}"
211                )
212        return errors