1# Copyright 2023 The Pigweed Authors 2# 3# Licensed under the Apache License, Version 2.0 (the "License"); you may not 4# use this file except in compliance with the License. You may obtain a copy of 5# the License at 6# 7# https://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12# License for the specific language governing permissions and limitations under 13# the License. 14"""Utilities for manipulating GN labels and paths.""" 15 16from __future__ import annotations 17 18import re 19 20from pathlib import PurePosixPath 21 22 23class MalformedGnError(Exception): 24 """Raised when creating a GN object fails.""" 25 26 27class GnPath: 28 """Represents a GN source path to a file in the source tree.""" 29 30 def __init__( 31 self, 32 base: str | PurePosixPath | GnPath, 33 bazel: str | None = None, 34 gn: str | None = None, # pylint: disable=invalid-name 35 ) -> None: 36 """Creates a GN source path. 37 38 Args: 39 base: A base GN source path. Other parameters are used to define 40 this object relative to the base. 41 bazel: A Bazel path relative to `base`. 42 gn: A GN source path relative to `base`. 43 """ 44 self._path: PurePosixPath 45 base_path = _as_path(base) 46 if bazel: 47 self._from_bazel(base_path, bazel) 48 elif gn: 49 self._from_gn(base_path, gn) 50 else: 51 self._path = base_path 52 53 def __str__(self) -> str: 54 return str(self._path) 55 56 def _from_bazel(self, base_path: PurePosixPath, label: str) -> None: 57 """Populates this object using a Bazel file label. 58 59 A Bazel label looks like: 60 //[{package-path}][:{relative-path}] 61 e.g. 62 "//foo" => "$base/foo" 63 "//:bar/baz.txt" => "$base/bar/baz.txt" 64 "//foo:bar/baz.txt" => "$base/foo/bar/baz.txt" 65 """ 66 match = re.match(r'//([^():]*)(?::([^():]+))?', label) 67 if not match: 68 raise MalformedGnError(f'invalid path: {label}') 69 groups = filter(None, match.groups()) 70 self._path = base_path.joinpath(*groups) 71 72 def _from_gn(self, base_path: PurePosixPath, path: str) -> None: 73 """Populates this object using a GN label. 74 75 Source-relative paths interpreted relative to `base`. Source-absolute 76 paths are used directly. 77 """ 78 if path.startswith('//'): 79 self._path = PurePosixPath(path) 80 else: 81 self._path = base_path.joinpath(path) 82 83 def path(self) -> str: 84 """Returns the object's path.""" 85 return str(self._path) 86 87 def file(self) -> str: 88 """Like GN's `get_path_info(..., "file")`.""" 89 return self._path.name 90 91 def name(self) -> str: 92 """Like GN's `get_path_info(..., "name")`.""" 93 return self._path.stem 94 95 def extension(self) -> str: 96 """Like GN's `get_path_info(..., "extension")`.""" 97 suffix = self._path.suffix 98 return suffix[1:] if suffix.startswith('.') else suffix 99 100 def dir(self) -> str: 101 """Like GN's `get_path_info(..., "dir")`.""" 102 return str(self._path.parent) 103 104 105class GnLabel: 106 """Represents a GN dependency. 107 108 See https://gn.googlesource.com/gn/+/main/docs/reference.md#labels. 109 """ 110 111 def __init__( 112 self, 113 base: str | PurePosixPath | GnLabel, 114 public: bool = False, 115 bazel: str | None = None, 116 gn: str | None = None, # pylint: disable=invalid-name 117 ) -> None: 118 """Creates a GN label. 119 120 Args: 121 base: A base GN label. Other parameters are used to define this 122 object relative to the base. 123 public: When this label is used to refer to a GN `dep`, this flag 124 indicates if it should be a `public_dep`. 125 bazel: A Bazel label relative to `base`. 126 gn: A GN label relative to `base`. 127 """ 128 self._name: str 129 self._path: PurePosixPath 130 self._toolchain: str | None = None 131 self._public: bool = public 132 self._repo: str | None = None 133 base_path = _as_path(base) 134 if bazel: 135 self._from_bazel(base_path, bazel) 136 elif gn: 137 self._from_gn(base_path, gn) 138 elif ':' in str(base_path): 139 parts = str(base_path).split(':') 140 self._path = PurePosixPath(':'.join(parts[:-1])) 141 self._name = parts[-1] 142 elif isinstance(base, GnLabel): 143 self._path = base._path 144 self._name = base._name 145 else: 146 self._path = base_path 147 self._name = self._path.name 148 149 def __str__(self): 150 return self.with_toolchain() if self._toolchain else self.no_toolchain() 151 152 def __eq__(self, other): 153 return str(self) == str(other) 154 155 def __hash__(self): 156 return hash(str(self)) 157 158 def _from_bazel(self, base: PurePosixPath, label: str): 159 """Populates this object using a Bazel label.""" 160 match = re.match(r'(?:@([^():/]*))?//(.+)', label) 161 if not match: 162 raise MalformedGnError(f'invalid label: {label}') 163 self._repo = match[1] 164 if self._repo: 165 self._from_gn(PurePosixPath('$repo'), match[2]) 166 else: 167 self._from_gn(base, match[2]) 168 169 def _from_gn(self, base: PurePosixPath, label: str): 170 """Populates this object using a GN label.""" 171 if label.startswith('//') or label.startswith('$'): 172 path = label 173 else: 174 path = str(base.joinpath(label)) 175 if ':' in path: 176 parts = path.split(':') 177 self._path = PurePosixPath(':'.join(parts[:-1])) 178 self._name = parts[-1] 179 else: 180 self._path = PurePosixPath(path) 181 self._name = self._path.name 182 parts = [] 183 for part in self._path.parts: 184 if part == '..' and parts and parts[-1] != '..': 185 parts.pop() 186 else: 187 parts.append(part) 188 self._path = PurePosixPath(*parts) 189 190 def name(self) -> str: 191 """Like GN's `get_label_info(..., "name"`).""" 192 return self._name 193 194 def dir(self) -> str: 195 """Like GN's `get_label_info(..., "dir"`).""" 196 return str(self._path) 197 198 def no_toolchain(self) -> str: 199 """Like GN's `get_label_info(..., "label_no_toolchain"`).""" 200 if self._path == PurePosixPath(): 201 return f':{self._name}' 202 name = f':{self._name}' if self._name != self._path.name else '' 203 return f'{self._path}{name}' 204 205 def with_toolchain(self) -> str: 206 """Like GN's `get_label_info(..., "label_with_toolchain"`).""" 207 toolchain = self._toolchain if self._toolchain else 'default_toolchain' 208 if self._path == PurePosixPath(): 209 return f':{self._name}({toolchain})' 210 name = f':{self._name}' if self._name != self._path.name else '' 211 return f'{self._path}{name}({toolchain})' 212 213 def public(self) -> bool: 214 """Returns whether this is a public dep.""" 215 return self._public 216 217 def repo(self) -> str: 218 """Returns the label's repo, if any.""" 219 return self._repo or '' 220 221 def resolve_repo(self, repo: str) -> None: 222 """Replaces the repo placeholder with the given value.""" 223 if self._path and self._path.parts[0] == '$repo': 224 self._path = PurePosixPath( 225 '$dir_pw_third_party', repo, *self._path.parts[1:] 226 ) 227 228 def relative_to(self, start: str | PurePosixPath | GnLabel) -> str: 229 """Returns a label string relative to the given starting label.""" 230 start_path = _as_path(start) 231 if not start: 232 return self.no_toolchain() 233 if self._path == start_path: 234 return f':{self._name}' 235 path = _relative_to(self._path, start_path) 236 name = f':{self._name}' if self._name != self._path.name else '' 237 return f'{path}{name}' 238 239 def joinlabel(self, relative: str) -> GnLabel: 240 """Creates a new label by extending the current label.""" 241 return GnLabel(self._path.joinpath(relative)) 242 243 244class GnVisibility: 245 """Represents a GN visibility scope.""" 246 247 def __init__( 248 self, 249 base: str | PurePosixPath | GnLabel, 250 label: str | PurePosixPath | GnLabel, 251 bazel: str | None = None, 252 gn: str | None = None, # pylint: disable=invalid-name 253 ) -> None: 254 """Creates a GN visibility scope. 255 256 Args: 257 base: A base GN label. Other parameters are used to define this 258 object relative to the base. 259 label: The label of the directory in which this scope is being 260 defined. 261 bazel: An absolute Bazel visibility label. 262 gn: A GN visibility label. 263 """ 264 self._scope: GnLabel 265 label_path = _as_path(label) 266 if bazel: 267 self._from_bazel(_as_path(base), label_path, bazel) 268 elif gn: 269 self._from_gn(label_path, gn) 270 else: 271 self._scope = GnLabel(label) 272 273 def __str__(self): 274 return str(self._scope) 275 276 def _from_bazel( 277 self, base: PurePosixPath, label: PurePosixPath, scope: str 278 ): 279 """Populates this object using a Bazel visibility label.""" 280 if scope == '//visibility:public': 281 self._scope = GnLabel('//*') 282 elif scope == '//visibility:private': 283 self._scope = GnLabel(label, gn=':*') 284 elif not (match := re.match(r'//([^():]*):([^():]+)', scope)): 285 raise MalformedGnError(f'invalid visibility scope: {scope}') 286 elif match[2] == '__subpackages__': 287 self._scope = GnLabel(base, gn=f'{match[1]}/*') 288 elif match[2] == '__pkg__': 289 self._scope = GnLabel(base, gn=f'{match[1]}:*') 290 else: 291 raise MalformedGnError(f'unsupported visibility scope: {scope}') 292 293 def _from_gn(self, label: PurePosixPath, scope: str): 294 """Populates this object using a GN visibility scope.""" 295 self._scope = GnLabel(label, gn=scope) 296 297 def relative_to(self, start: str | PurePosixPath | GnLabel) -> str: 298 """Returns a label string relative to the given starting label.""" 299 return self._scope.relative_to(start) 300 301 def within(self, other: GnVisibility) -> bool: 302 """Returns whether this scope is a subset of another.""" 303 as_label = GnLabel(str(other)) 304 if as_label.name() == '*': 305 _path = self._scope.dir() 306 other_path = as_label.dir() 307 if other_path == '//*': 308 return True 309 if other_path.endswith('*'): 310 parent = PurePosixPath(other_path).parent 311 return PurePosixPath(_path).is_relative_to(parent) 312 return _path == other_path 313 return str(self) == str(other) 314 315 316def _as_path(item: str | GnPath | GnLabel | PurePosixPath) -> PurePosixPath: 317 """Converts an argument to be a PurePosixPath. 318 319 Args: 320 label: A string, path, or label to be converted to a PurePosixPath. 321 """ 322 if isinstance(item, str): 323 return PurePosixPath(item) 324 if isinstance(item, GnPath): 325 return PurePosixPath(item.path()) 326 if isinstance(item, GnLabel): 327 return PurePosixPath(item.dir()) 328 return item 329 330 331def _relative_to( 332 path: str | PurePosixPath, start: str | PurePosixPath 333) -> PurePosixPath: 334 """Like `PosixPath._relative_to`, but can ascend directories as well.""" 335 if not start: 336 return PurePosixPath(path) 337 _path = PurePosixPath(path) 338 _start = PurePosixPath(start) 339 if _path.parts[0] != _start.parts[0]: 340 return _path 341 ascend = PurePosixPath() 342 while not _path.is_relative_to(_start): 343 if _start.parent == PurePosixPath(): 344 break 345 _start = _start.parent 346 ascend = ascend.joinpath('..') 347 return ascend.joinpath(_path.relative_to(_start)) 348