1import os
2from pathlib import Path
3
4from ignore import WalkBuilder
5from ignore.overrides import OverrideBuilder
6
7from sphinx_codelinks.logger import get_logger
8from sphinx_codelinks.source_discover.config import (
9 COMMENT_FILETYPE,
10 CommentType,
11 SourceDiscoverConfig,
12)
13
14logger = get_logger(__name__)
15
16
17def lies_within(path: Path, directory: Path) -> bool:
18 """Whether ``path`` lies below ``directory``, both resolved.
19
20 Strings, not ``Path.is_relative_to``, which costs as much as the walk itself over
21 thousands of files. ``normcase`` folds case on Windows; on a case-insensitive POSIX
22 file system (APFS) a differently-spelled ancestor still counts as outside --
23 harmless, the file is traced under its own spelling.
24 """
25 prefix = os.path.join(os.path.normcase(str(directory)), "")
26 return os.path.normcase(str(path)).startswith(prefix)
27
28
29def warn_outside_src_dir(walked: Path, resolved: Path, src_dir: Path) -> None:
30 """Say that ``walked`` -- the path discovery reached, the link -- is not traced:
31 it resolves to outside ``src_dir``. POSIX on every platform.
32
33 The walked path leads the message rather than being its location: the CLI prints
34 the message alone, and Sphinx reads a location without a ``:`` as a document
35 name (``<path>.rst``) -- while a Windows path has one. It is normalised for the
36 message only (``src/../x.cpp`` reads ``x.cpp``).
37 """
38 shown = Path(os.path.normpath(walked)).as_posix()
39 logger.warning(
40 f"{shown} resolves to {resolved.as_posix()}, outside src_dir "
41 f"{src_dir.as_posix()}: not traced (widen src_dir to cover it, or exclude "
42 "the link)",
43 subtype="outside_src_dir",
44 )
45
46
47def _json_starts_with_comment(filepath: Path, sample_size: int = 256) -> bool:
48 """Return True if a ``.json`` file's first non-whitespace content is a comment.
49
50 Used to decide whether a ``.json`` file should be treated as JSONC. Per
51 https://jsonc.org/#filename-extension a ``.json`` file should only be treated as
52 JSONC when it opens with a comment (e.g. the mode line ``// -*- mode: jsonc -*-``).
53 """
54 try:
55 with filepath.open("rb") as f:
56 chunk = f.read(sample_size)
57 except OSError:
58 return False
59 # strip a leading UTF-8 BOM, then leading whitespace
60 text = chunk.removeprefix(b"\xef\xbb\xbf").lstrip()
61 return text.startswith((b"//", b"/*"))
62
63
[docs] 64# @Source code file discovery with gitignore support, IMPL_DISC_1, impl, [FE_DISCOVERY, FE_CLI_DISCOVER]
65class SourceDiscover:
66 """The source files below ``src_discover_config.src_dir``, as ``source_paths``.
67
68 A file that resolves to outside ``boundary`` -- a symbolic link out of the tree, or
69 a file below a followed directory link -- is not listed, and a warning names
70 its walked path (``codelinks.outside_src_dir``) unless ``warn`` is false: every
71 record and copy is relative to the source directory, which cannot hold it.
72
73 :param src_discover_config: What to walk, and how.
74 :param boundary: The directory every listed file lies below; the walked directory
75 when ``None``. A ``src-trace`` scope passes its project's ``src_dir``, which
76 its ``:directory:`` lies below.
77 :param warn: Whether to warn about a file outside ``boundary``.
78 """
79
80 def __init__(
81 self,
82 src_discover_config: SourceDiscoverConfig,
83 *,
84 boundary: Path | None = None,
85 warn: bool = True,
86 ):
87 self.src_discover_config = src_discover_config
88 self.boundary = boundary
89 self.warn = warn
90 # normalize the file types to lower case with leading dot
91 self.file_types = {
92 f".{ext}" for ext in COMMENT_FILETYPE[src_discover_config.comment_type]
93 }
94
95 self.source_paths = self._discover()
96
97 def _build_overrides(self) -> OverrideBuilder | None:
98 """Build an OverrideBuilder for include/exclude patterns.
99
100 Include patterns are added as whitelist globs.
101 Exclude patterns are added as negated globs (prefixed with ``!``).
102 """
103 include = self.src_discover_config.include
104 # ``SourceDiscoverConfig.__post_init__`` always resolves ``exclude``
105 # to a concrete list (never leaves it ``None``); the ``| None`` on
106 # the field itself only exists to detect "not explicitly set".
107 exclude = self.src_discover_config.exclude
108
109 if not include and not exclude:
110 return None
111
112 ob = OverrideBuilder(self.src_discover_config.src_dir)
113
114 if include:
115 for pattern in include:
116 ob.add(pattern)
117
118 if exclude:
119 for pattern in exclude:
120 ob.add(f"!{pattern}")
121
122 return ob
123
124 def _discover(self) -> list[Path]:
125 """Discover source files recursively in the given directory."""
126 src_dir = self.src_discover_config.src_dir
127 if not src_dir.is_dir():
128 return []
129
130 gitignore = self.src_discover_config.gitignore
131
132 builder = WalkBuilder(src_dir)
133 # Replicate the Rust ignore crate's standard_filters(gitignore)
134 # followed by hidden(false), matching ubc_codelinks behaviour.
135 builder.ignore(gitignore)
136 builder.parents(gitignore)
137 builder.git_ignore(gitignore)
138 builder.git_global(gitignore)
139 builder.git_exclude(gitignore)
140 builder.hidden(False)
141 builder.follow_links(self.src_discover_config.follow_links)
142
143 override_builder = self._build_overrides()
144 if override_builder is not None:
145 builder.overrides(override_builder.build())
146
147 boundary = (self.boundary or src_dir).resolve()
148 discovered_files = []
149 for entry in builder.build():
150 filepath = entry.path()
151 if not filepath.is_file():
152 continue
153 if self.file_types and filepath.suffix.lower() not in self.file_types:
154 continue
[docs]155 # @JSONC .json files require a leading comment, IMPL_JSONC_3, impl, [FE_JSONC]
156 # A plain ``.json`` file is only treated as JSONC when it opens with a
157 # comment; otherwise it is skipped under the ``jsonc`` comment type.
158 if (
159 self.src_discover_config.comment_type == CommentType.jsonc
160 and filepath.suffix.lower() == ".json"
161 and not _json_starts_with_comment(filepath)
162 ):
163 continue
164 # resolve() produces canonical absolute paths; follow_links only
165 # controls whether the walker descends into symlinked directories
166 resolved = filepath.resolve()
167 if not lies_within(resolved, boundary):
168 # a link out of the tree: no root-relative path, no copy (#2062)
169 if self.warn:
170 warn_outside_src_dir(filepath, resolved, boundary)
171 continue
172 discovered_files.append(resolved)
173
174 # a file reached through a symbolic link inside the tree resolves to a path
175 # already found: list each file once, or every caller analyses it once per
176 # path to it
177 sorted_filepaths = sorted(
178 dict.fromkeys(discovered_files),
179 key=lambda x: os.path.normcase(os.path.normpath(x)),
180 )
181 return sorted_filepaths