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 has_include = bool(self.src_discover_config.include)
104 has_exclude = bool(self.src_discover_config.exclude)
105
106 if not has_include and not has_exclude:
107 return None
108
109 ob = OverrideBuilder(self.src_discover_config.src_dir)
110
111 if has_include:
112 for pattern in self.src_discover_config.include:
113 ob.add(pattern)
114
115 if has_exclude:
116 for pattern in self.src_discover_config.exclude:
117 ob.add(f"!{pattern}")
118
119 return ob
120
121 def _discover(self) -> list[Path]:
122 """Discover source files recursively in the given directory."""
123 src_dir = self.src_discover_config.src_dir
124 if not src_dir.is_dir():
125 return []
126
127 gitignore = self.src_discover_config.gitignore
128
129 builder = WalkBuilder(src_dir)
130 # Replicate the Rust ignore crate's standard_filters(gitignore)
131 # followed by hidden(false), matching ubc_codelinks behaviour.
132 builder.ignore(gitignore)
133 builder.parents(gitignore)
134 builder.git_ignore(gitignore)
135 builder.git_global(gitignore)
136 builder.git_exclude(gitignore)
137 builder.hidden(False)
138 builder.follow_links(self.src_discover_config.follow_links)
139
140 override_builder = self._build_overrides()
141 if override_builder is not None:
142 builder.overrides(override_builder.build())
143
144 boundary = (self.boundary or src_dir).resolve()
145 discovered_files = []
146 for entry in builder.build():
147 filepath = entry.path()
148 if not filepath.is_file():
149 continue
150 if self.file_types and filepath.suffix.lower() not in self.file_types:
151 continue
[docs]152 # @JSONC .json files require a leading comment, IMPL_JSONC_3, impl, [FE_JSONC]
153 # A plain ``.json`` file is only treated as JSONC when it opens with a
154 # comment; otherwise it is skipped under the ``jsonc`` comment type.
155 if (
156 self.src_discover_config.comment_type == CommentType.jsonc
157 and filepath.suffix.lower() == ".json"
158 and not _json_starts_with_comment(filepath)
159 ):
160 continue
161 # resolve() produces canonical absolute paths; follow_links only
162 # controls whether the walker descends into symlinked directories
163 resolved = filepath.resolve()
164 if not lies_within(resolved, boundary):
165 # a link out of the tree: no root-relative path, no copy (#2062)
166 if self.warn:
167 warn_outside_src_dir(filepath, resolved, boundary)
168 continue
169 discovered_files.append(resolved)
170
171 # a file reached through a symbolic link inside the tree resolves to a path
172 # already found: list each file once, or every caller analyses it once per
173 # path to it
174 sorted_filepaths = sorted(
175 dict.fromkeys(discovered_files),
176 key=lambda x: os.path.normcase(os.path.normpath(x)),
177 )
178 return sorted_filepaths