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"""Writes a formatted BUILD.gn _file.""" 15 16import os 17import subprocess 18 19from datetime import datetime 20from pathlib import Path, PurePath, PurePosixPath 21from types import TracebackType 22from typing import IO, Iterable, Iterator, Type 23 24from pw_build.gn_config import GnConfig, GN_CONFIG_FLAGS 25from pw_build.gn_target import GnTarget 26from pw_build.gn_utils import GnLabel, GnPath, MalformedGnError 27 28COPYRIGHT_HEADER = f''' 29# Copyright {datetime.now().year} The Pigweed Authors 30# 31# Licensed under the Apache License, Version 2.0 (the "License"); you may not 32# use this file except in compliance with the License. You may obtain a copy of 33# the License at 34# 35# https://www.apache.org/licenses/LICENSE-2.0 36# 37# Unless required by applicable law or agreed to in writing, software 38# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 39# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 40# License for the specific language governing permissions and limitations under 41# the License. 42 43# DO NOT MANUALLY EDIT!''' 44 45 46class GnWriter: 47 """Represents a partial BUILD.gn file being constructed. 48 49 Except for testing , callers should prefer using `GnFile`. That 50 object wraps this one, and ensures that the GN file produced includes the 51 relevant copyright header and is formatted correctly. 52 53 Attributes: 54 repos: A mapping of repository names to build args. These are used to 55 replace repository names when writing labels. 56 aliases: A mapping of label names to build args. These can be used to 57 rewrite labels with alternate names, e.g. "gtest" to "googletest". 58 """ 59 60 def __init__(self, file: IO) -> None: 61 self._file: IO = file 62 self._scopes: list[str] = [] 63 self._margin: str = '' 64 self._needs_blank: bool = False 65 self.repos: dict[str, str] = {} 66 self.aliases: dict[str, str] = {} 67 68 def write_comment(self, comment: str | None = None) -> None: 69 """Adds a GN comment. 70 71 Args: 72 comment: The comment string to write. 73 """ 74 if not comment: 75 self.write('#') 76 return 77 while len(comment) > 78: 78 index = comment.rfind(' ', 0, 78) 79 if index < 0: 80 break 81 self.write(f'# {comment[:index]}') 82 comment = comment[index + 1 :] 83 self.write(f'# {comment}') 84 85 def write_import(self, gni: str | PurePosixPath | GnPath) -> None: 86 """Adds a GN import. 87 88 Args: 89 gni: The source-relative path to a GN import file. 90 """ 91 self._needs_blank = False 92 self.write(f'import("{str(gni)}")') 93 self._needs_blank = True 94 95 def write_imports(self, imports: Iterable[str]) -> None: 96 """Adds a list of GN imports. 97 98 Args: 99 imports: A list of GN import files. 100 """ 101 for gni in imports: 102 self.write_import(gni) 103 104 def write_config(self, config: GnConfig) -> None: 105 """Adds a GN config. 106 107 Args: 108 config: The GN config data to write. 109 """ 110 if not config: 111 return 112 if not config.label: 113 raise MalformedGnError('missing label for `config`') 114 self.write_target_start('config', config.label.name()) 115 for flag in GN_CONFIG_FLAGS: 116 self.write_list(flag, config.get(flag)) 117 self.write_end() 118 119 def write_target(self, target: GnTarget) -> None: 120 """Write a GN target. 121 122 Args: 123 target: The GN target data to write. 124 """ 125 self.write_comment( 126 f'Generated from //{target.package()}:{target.name()}' 127 ) 128 self.write_target_start(target.type(), target.name()) 129 130 # GN use no `visibility` to indicate publicly visibile. 131 scopes = filter(lambda s: str(s) != '//*', target.visibility) 132 visibility = [target.make_relative(scope) for scope in scopes] 133 self.write_list('visibility', visibility) 134 135 if not target.check_includes: 136 self.write('check_includes = false') 137 self.write_list('public', [str(path) for path in target.public]) 138 self.write_list('sources', [str(path) for path in target.sources]) 139 self.write_list('inputs', [str(path) for path in target.inputs]) 140 141 for flag in GN_CONFIG_FLAGS: 142 self.write_list(flag, target.config.get(flag)) 143 self._write_relative('public_configs', target, target.public_configs) 144 self._write_relative('configs', target, target.configs) 145 self._write_relative('remove_configs', target, target.remove_configs) 146 147 self._write_relative('public_deps', target, target.public_deps) 148 self._write_relative('deps', target, target.deps) 149 self.write_end() 150 151 def _write_relative( 152 self, var_name: str, target: GnTarget, labels: Iterable[GnLabel] 153 ) -> None: 154 """Write a list of labels relative to a target. 155 156 Args: 157 var_name: The name of the GN list variable. 158 target: The GN target to rebase the labels to. 159 labels: The labels to write to the list. 160 """ 161 self.write_list(var_name, self._resolve(target, labels)) 162 163 def _resolve( 164 self, target: GnTarget, labels: Iterable[GnLabel] 165 ) -> Iterator[str]: 166 """Returns rewritten labels. 167 168 If this label has a repo, it must be a key in this object's `repos` and 169 will be replaced by the corresponding value. If this label is a key in 170 this object's `aliases`, it will be replaced by the corresponding value. 171 172 Args: 173 labels: The labels to resolve. 174 """ 175 for label in labels: 176 repo = label.repo() 177 if repo: 178 label.resolve_repo(self.repos[repo]) 179 label = GnLabel(self.aliases.get(str(label), str(label))) 180 yield target.make_relative(label) 181 182 def write_target_start( 183 self, target_type: str, target_name: str | None = None 184 ) -> None: 185 """Begins a GN target of the given type. 186 187 Args: 188 target_type: The type of the GN target. 189 target_name: The name of the GN target. 190 """ 191 if target_name: 192 self.write(f'{target_type}("{target_name}") {{') 193 self._indent(target_name) 194 else: 195 self.write(f'{target_type}() {{') 196 self._indent(target_type) 197 198 def write_list( 199 self, var_name: str, items: Iterable[str], reorder: bool = True 200 ) -> None: 201 """Adds a named GN list of the given items, if non-empty. 202 203 Args: 204 var_name: The name of the GN list variable. 205 items: The list items to write as strings. 206 reorder: If true, the list is sorted lexicographically. 207 """ 208 items = list(items) 209 if not items: 210 return 211 self.write(f'{var_name} = [') 212 self._indent(var_name) 213 if reorder: 214 items = sorted(items) 215 for item in items: 216 self.write(f'"{str(item)}",') 217 self._outdent() 218 self.write(']') 219 220 def write_scope(self, var_name: str) -> None: 221 """Begins a named GN scope. 222 223 Args: 224 var_name: The name of the GN scope variable. 225 """ 226 self.write(f'{var_name} = {{') 227 self._indent(var_name) 228 229 def write_if(self, cond: str) -> None: 230 """Begins a GN 'if' condition. 231 232 Args: 233 cond: The conditional expression. 234 """ 235 self.write(f'if ({cond}) {{') 236 self._indent(cond) 237 238 def write_else_if(self, cond: str) -> None: 239 """Adds another GN 'if' condition to a previous 'if' condition. 240 241 Args: 242 cond: The conditional expression. 243 """ 244 self._outdent() 245 self.write(f'}} else if ({cond}) {{') 246 self._indent(cond) 247 248 def write_else(self) -> None: 249 """Adds a GN 'else' clause to a previous 'if' condition.""" 250 last = self._outdent() 251 self.write('} else {') 252 self._indent(f'!({last})') 253 254 def write_end(self) -> None: 255 """Ends a target, scope, or 'if' condition'.""" 256 self._outdent() 257 self.write('}') 258 self._needs_blank = True 259 260 def write_blank(self) -> None: 261 """Adds a blank line.""" 262 print('', file=self._file) 263 self._needs_blank = False 264 265 def write_preformatted(self, preformatted: str) -> None: 266 """Adds text with minimal formatting. 267 268 The only formatting applied to the given text is to strip any leading 269 whitespace. This allows calls to be more readable by allowing 270 preformatted text to start on a new line, e.g. 271 272 _write_preformatted(''' 273 preformatted line 1 274 preformatted line 2 275 preformatted line 3''') 276 277 Args: 278 preformatted: The text to write. 279 """ 280 print(preformatted.lstrip(), file=self._file) 281 282 def write(self, text: str) -> None: 283 """Writes to the file, appropriately indented. 284 285 Args: 286 text: The text to indent and write. 287 """ 288 if self._needs_blank: 289 self.write_blank() 290 print(f'{self._margin}{text}', file=self._file) 291 292 def _indent(self, scope: str) -> None: 293 """Increases the current margin. 294 295 Saves the scope of indent to aid in debugging. For example, trying to 296 use incorrect code such as 297 298 ``` 299 self.write_if('foo') 300 self.write_comment('bar') 301 self.write_else_if('baz') 302 ``` 303 304 will throw an exception due to the missing `write_end`. The exception 305 will note that 'baz' was opened but not closed. 306 307 Args: 308 scope: The name of the scope (for debugging). 309 """ 310 self._scopes.append(scope) 311 self._margin += ' ' 312 313 def _outdent(self) -> str: 314 """Decreases the current margin.""" 315 if not self._scopes: 316 raise MalformedGnError('scope closed unexpectedly') 317 last = self._scopes.pop() 318 self._margin = self._margin[2:] 319 self._needs_blank = False 320 return last 321 322 def seal(self) -> None: 323 """Instructs the object that no more writes will occur.""" 324 if self._scopes: 325 raise MalformedGnError(f'unclosed scope(s): {self._scopes}') 326 327 328def gn_format(gn_file: Path) -> None: 329 """Calls `gn format` on a BUILD.gn or GN import file.""" 330 subprocess.check_call(['gn', 'format', gn_file]) 331 332 333class GnFile: 334 """Represents an open BUILD.gn file that is formatted on close. 335 336 Typical usage: 337 338 with GnFile('/path/to/BUILD.gn', 'my-package') as build_gn: 339 build_gn.write_... 340 341 where "write_..." refers to any of the "write" methods of `GnWriter`. 342 """ 343 344 def __init__(self, pathname: PurePath, package: str | None = None) -> None: 345 if pathname.name != 'BUILD.gn' and pathname.suffix != '.gni': 346 raise MalformedGnError(f'invalid GN filename: {pathname}') 347 os.makedirs(pathname.parent, exist_ok=True) 348 self._pathname: PurePath = pathname 349 self._package: str | None = package 350 self._file: IO 351 self._writer: GnWriter 352 353 def __enter__(self) -> GnWriter: 354 """Opens the GN file.""" 355 self._file = open(self._pathname, 'w+') 356 self._writer = GnWriter(self._file) 357 self._writer.write_preformatted(COPYRIGHT_HEADER) 358 file = PurePath(*PurePath(__file__).parts[-2:]) 359 self._writer.write_comment( 360 f'This file was automatically generated by {file}' 361 ) 362 if self._package: 363 self._writer.write_comment( 364 f'It contains GN build targets for {self._package}.' 365 ) 366 self._writer.write_blank() 367 return self._writer 368 369 def __exit__( 370 self, 371 exc_type: Type[BaseException] | None, 372 exc_val: BaseException | None, 373 exc_tb: TracebackType | None, 374 ) -> None: 375 """Closes the GN file and formats it.""" 376 self._file.close() 377 gn_format(Path(self._pathname)) 378