xref: /aosp_15_r20/external/angle/build/gn_helpers.py (revision 8975f5c5ed3d1c378011245431ada316dfb6f244)
1# Copyright 2014 The Chromium Authors
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
5"""Helper functions useful when writing scripts that integrate with GN.
6
7The main functions are ToGNString() and FromGNString(), to convert between
8serialized GN veriables and Python variables.
9
10To use in an arbitrary Python file in the build:
11
12  import os
13  import sys
14
15  sys.path.append(os.path.join(os.path.dirname(__file__),
16                               os.pardir, os.pardir, 'build'))
17  import gn_helpers
18
19Where the sequence of parameters to join is the relative path from your source
20file to the build directory.
21"""
22
23import json
24import os
25import re
26import shutil
27import sys
28
29
30_CHROMIUM_ROOT = os.path.abspath(
31    os.path.join(os.path.dirname(__file__), os.pardir))
32
33BUILD_VARS_FILENAME = 'build_vars.json'
34IMPORT_RE = re.compile(r'^import\("//(\S+)"\)')
35
36
37class GNError(Exception):
38  pass
39
40
41# Computes ASCII code of an element of encoded Python 2 str / Python 3 bytes.
42_Ord = ord if sys.version_info.major < 3 else lambda c: c
43
44
45def _TranslateToGnChars(s):
46  for decoded_ch in s.encode('utf-8'):  # str in Python 2, bytes in Python 3.
47    code = _Ord(decoded_ch)  # int
48    if code in (34, 36, 92):  # For '"', '$', or '\\'.
49      yield '\\' + chr(code)
50    elif 32 <= code < 127:
51      yield chr(code)
52    else:
53      yield '$0x%02X' % code
54
55
56def ToGNString(value, pretty=False):
57  """Returns a stringified GN equivalent of a Python value.
58
59  Args:
60    value: The Python value to convert.
61    pretty: Whether to pretty print. If true, then non-empty lists are rendered
62        recursively with one item per line, with indents. Otherwise lists are
63        rendered without new line.
64  Returns:
65    The stringified GN equivalent to |value|.
66
67  Raises:
68    GNError: |value| cannot be printed to GN.
69  """
70
71  if sys.version_info.major < 3:
72    basestring_compat = basestring
73  else:
74    basestring_compat = str
75
76  # Emits all output tokens without intervening whitespaces.
77  def GenerateTokens(v, level):
78    if isinstance(v, basestring_compat):
79      yield '"' + ''.join(_TranslateToGnChars(v)) + '"'
80
81    elif isinstance(v, bool):
82      yield 'true' if v else 'false'
83
84    elif isinstance(v, int):
85      yield str(v)
86
87    elif isinstance(v, list):
88      yield '['
89      for i, item in enumerate(v):
90        if i > 0:
91          yield ','
92        for tok in GenerateTokens(item, level + 1):
93          yield tok
94      yield ']'
95
96    elif isinstance(v, dict):
97      if level > 0:
98        yield '{'
99      for key in sorted(v):
100        if not isinstance(key, basestring_compat):
101          raise GNError('Dictionary key is not a string.')
102        if not key or key[0].isdigit() or not key.replace('_', '').isalnum():
103          raise GNError('Dictionary key is not a valid GN identifier.')
104        yield key  # No quotations.
105        yield '='
106        for tok in GenerateTokens(v[key], level + 1):
107          yield tok
108      if level > 0:
109        yield '}'
110
111    else:  # Not supporting float: Add only when needed.
112      raise GNError('Unsupported type when printing to GN.')
113
114  can_start = lambda tok: tok and tok not in ',}]='
115  can_end = lambda tok: tok and tok not in ',{[='
116
117  # Adds whitespaces, trying to keep everything (except dicts) in 1 line.
118  def PlainGlue(gen):
119    prev_tok = None
120    for i, tok in enumerate(gen):
121      if i > 0:
122        if can_end(prev_tok) and can_start(tok):
123          yield '\n'  # New dict item.
124        elif prev_tok == '[' and tok == ']':
125          yield '  '  # Special case for [].
126        elif tok != ',':
127          yield ' '
128      yield tok
129      prev_tok = tok
130
131  # Adds whitespaces so non-empty lists can span multiple lines, with indent.
132  def PrettyGlue(gen):
133    prev_tok = None
134    level = 0
135    for i, tok in enumerate(gen):
136      if i > 0:
137        if can_end(prev_tok) and can_start(tok):
138          yield '\n' + '  ' * level  # New dict item.
139        elif tok == '=' or prev_tok in '=':
140          yield ' '  # Separator before and after '=', on same line.
141      if tok in ']}':
142        level -= 1
143      # Exclude '[]' and '{}' cases.
144      if int(prev_tok == '[') + int(tok == ']') == 1 or \
145         int(prev_tok == '{') + int(tok == '}') == 1:
146        yield '\n' + '  ' * level
147      yield tok
148      if tok in '[{':
149        level += 1
150      if tok == ',':
151        yield '\n' + '  ' * level
152      prev_tok = tok
153
154  token_gen = GenerateTokens(value, 0)
155  ret = ''.join((PrettyGlue if pretty else PlainGlue)(token_gen))
156  # Add terminating '\n' for dict |value| or multi-line output.
157  if isinstance(value, dict) or '\n' in ret:
158    return ret + '\n'
159  return ret
160
161
162def FromGNString(input_string):
163  """Converts the input string from a GN serialized value to Python values.
164
165  For details on supported types see GNValueParser.Parse() below.
166
167  If your GN script did:
168    something = [ "file1", "file2" ]
169    args = [ "--values=$something" ]
170  The command line would look something like:
171    --values="[ \"file1\", \"file2\" ]"
172  Which when interpreted as a command line gives the value:
173    [ "file1", "file2" ]
174
175  You can parse this into a Python list using GN rules with:
176    input_values = FromGNValues(options.values)
177  Although the Python 'ast' module will parse many forms of such input, it
178  will not handle GN escaping properly, nor GN booleans. You should use this
179  function instead.
180
181
182  A NOTE ON STRING HANDLING:
183
184  If you just pass a string on the command line to your Python script, or use
185  string interpolation on a string variable, the strings will not be quoted:
186    str = "asdf"
187    args = [ str, "--value=$str" ]
188  Will yield the command line:
189    asdf --value=asdf
190  The unquoted asdf string will not be valid input to this function, which
191  accepts only quoted strings like GN scripts. In such cases, you can just use
192  the Python string literal directly.
193
194  The main use cases for this is for other types, in particular lists. When
195  using string interpolation on a list (as in the top example) the embedded
196  strings will be quoted and escaped according to GN rules so the list can be
197  re-parsed to get the same result.
198  """
199  parser = GNValueParser(input_string)
200  return parser.Parse()
201
202
203def FromGNArgs(input_string):
204  """Converts a string with a bunch of gn arg assignments into a Python dict.
205
206  Given a whitespace-separated list of
207
208    <ident> = (integer | string | boolean | <list of the former>)
209
210  gn assignments, this returns a Python dict, i.e.:
211
212    FromGNArgs('foo=true\nbar=1\n') -> { 'foo': True, 'bar': 1 }.
213
214  Only simple types and lists supported; variables, structs, calls
215  and other, more complicated things are not.
216
217  This routine is meant to handle only the simple sorts of values that
218  arise in parsing --args.
219  """
220  parser = GNValueParser(input_string)
221  return parser.ParseArgs()
222
223
224def UnescapeGNString(value):
225  """Given a string with GN escaping, returns the unescaped string.
226
227  Be careful not to feed with input from a Python parsing function like
228  'ast' because it will do Python unescaping, which will be incorrect when
229  fed into the GN unescaper.
230
231  Args:
232    value: Input string to unescape.
233  """
234  result = ''
235  i = 0
236  while i < len(value):
237    if value[i] == '\\':
238      if i < len(value) - 1:
239        next_char = value[i + 1]
240        if next_char in ('$', '"', '\\'):
241          # These are the escaped characters GN supports.
242          result += next_char
243          i += 1
244        else:
245          # Any other backslash is a literal.
246          result += '\\'
247    else:
248      result += value[i]
249    i += 1
250  return result
251
252
253def _IsDigitOrMinus(char):
254  return char in '-0123456789'
255
256
257class GNValueParser(object):
258  """Duplicates GN parsing of values and converts to Python types.
259
260  Normally you would use the wrapper function FromGNValue() below.
261
262  If you expect input as a specific type, you can also call one of the Parse*
263  functions directly. All functions throw GNError on invalid input.
264  """
265
266  def __init__(self, string, checkout_root=_CHROMIUM_ROOT):
267    self.input = string
268    self.cur = 0
269    self.checkout_root = checkout_root
270
271  def IsDone(self):
272    return self.cur == len(self.input)
273
274  def ReplaceImports(self):
275    """Replaces import(...) lines with the contents of the imports.
276
277    Recurses on itself until there are no imports remaining, in the case of
278    nested imports.
279    """
280    lines = self.input.splitlines()
281    if not any(line.startswith('import(') for line in lines):
282      return
283    for line in lines:
284      if not line.startswith('import('):
285        continue
286      regex_match = IMPORT_RE.match(line)
287      if not regex_match:
288        raise GNError('Not a valid import string: %s' % line)
289      import_path = os.path.join(self.checkout_root, regex_match.group(1))
290      with open(import_path) as f:
291        imported_args = f.read()
292      self.input = self.input.replace(line, imported_args)
293    # Call ourselves again if we've just replaced an import() with additional
294    # imports.
295    self.ReplaceImports()
296
297
298  def _ConsumeWhitespace(self):
299    while not self.IsDone() and self.input[self.cur] in ' \t\n':
300      self.cur += 1
301
302  def ConsumeCommentAndWhitespace(self):
303    self._ConsumeWhitespace()
304
305    # Consume each comment, line by line.
306    while not self.IsDone() and self.input[self.cur] == '#':
307      # Consume the rest of the comment, up until the end of the line.
308      while not self.IsDone() and self.input[self.cur] != '\n':
309        self.cur += 1
310      # Move the cursor to the next line (if there is one).
311      if not self.IsDone():
312        self.cur += 1
313
314      self._ConsumeWhitespace()
315
316  def Parse(self):
317    """Converts a string representing a printed GN value to the Python type.
318
319    See additional usage notes on FromGNString() above.
320
321    * GN booleans ('true', 'false') will be converted to Python booleans.
322
323    * GN numbers ('123') will be converted to Python numbers.
324
325    * GN strings (double-quoted as in '"asdf"') will be converted to Python
326      strings with GN escaping rules. GN string interpolation (embedded
327      variables preceded by $) are not supported and will be returned as
328      literals.
329
330    * GN lists ('[1, "asdf", 3]') will be converted to Python lists.
331
332    * GN scopes ('{ ... }') are not supported.
333
334    Raises:
335      GNError: Parse fails.
336    """
337    result = self._ParseAllowTrailing()
338    self.ConsumeCommentAndWhitespace()
339    if not self.IsDone():
340      raise GNError("Trailing input after parsing:\n  " + self.input[self.cur:])
341    return result
342
343  def ParseArgs(self):
344    """Converts a whitespace-separated list of ident=literals to a dict.
345
346    See additional usage notes on FromGNArgs(), above.
347
348    Raises:
349      GNError: Parse fails.
350    """
351    d = {}
352
353    self.ReplaceImports()
354    self.ConsumeCommentAndWhitespace()
355
356    while not self.IsDone():
357      ident = self._ParseIdent()
358      self.ConsumeCommentAndWhitespace()
359      if self.input[self.cur] != '=':
360        raise GNError("Unexpected token: " + self.input[self.cur:])
361      self.cur += 1
362      self.ConsumeCommentAndWhitespace()
363      val = self._ParseAllowTrailing()
364      self.ConsumeCommentAndWhitespace()
365      d[ident] = val
366
367    return d
368
369  def _ParseAllowTrailing(self):
370    """Internal version of Parse() that doesn't check for trailing stuff."""
371    self.ConsumeCommentAndWhitespace()
372    if self.IsDone():
373      raise GNError("Expected input to parse.")
374
375    next_char = self.input[self.cur]
376    if next_char == '[':
377      return self.ParseList()
378    elif next_char == '{':
379      return self.ParseScope()
380    elif _IsDigitOrMinus(next_char):
381      return self.ParseNumber()
382    elif next_char == '"':
383      return self.ParseString()
384    elif self._ConstantFollows('true'):
385      return True
386    elif self._ConstantFollows('false'):
387      return False
388    else:
389      raise GNError("Unexpected token: " + self.input[self.cur:])
390
391  def _ParseIdent(self):
392    ident = ''
393
394    next_char = self.input[self.cur]
395    if not next_char.isalpha() and not next_char=='_':
396      raise GNError("Expected an identifier: " + self.input[self.cur:])
397
398    ident += next_char
399    self.cur += 1
400
401    next_char = self.input[self.cur]
402    while next_char.isalpha() or next_char.isdigit() or next_char=='_':
403      ident += next_char
404      self.cur += 1
405      next_char = self.input[self.cur]
406
407    return ident
408
409  def ParseNumber(self):
410    self.ConsumeCommentAndWhitespace()
411    if self.IsDone():
412      raise GNError('Expected number but got nothing.')
413
414    begin = self.cur
415
416    # The first character can include a negative sign.
417    if not self.IsDone() and _IsDigitOrMinus(self.input[self.cur]):
418      self.cur += 1
419    while not self.IsDone() and self.input[self.cur].isdigit():
420      self.cur += 1
421
422    number_string = self.input[begin:self.cur]
423    if not len(number_string) or number_string == '-':
424      raise GNError('Not a valid number.')
425    return int(number_string)
426
427  def ParseString(self):
428    self.ConsumeCommentAndWhitespace()
429    if self.IsDone():
430      raise GNError('Expected string but got nothing.')
431
432    if self.input[self.cur] != '"':
433      raise GNError('Expected string beginning in a " but got:\n  ' +
434                    self.input[self.cur:])
435    self.cur += 1  # Skip over quote.
436
437    begin = self.cur
438    while not self.IsDone() and self.input[self.cur] != '"':
439      if self.input[self.cur] == '\\':
440        self.cur += 1  # Skip over the backslash.
441        if self.IsDone():
442          raise GNError('String ends in a backslash in:\n  ' + self.input)
443      self.cur += 1
444
445    if self.IsDone():
446      raise GNError('Unterminated string:\n  ' + self.input[begin:])
447
448    end = self.cur
449    self.cur += 1  # Consume trailing ".
450
451    return UnescapeGNString(self.input[begin:end])
452
453  def ParseList(self):
454    self.ConsumeCommentAndWhitespace()
455    if self.IsDone():
456      raise GNError('Expected list but got nothing.')
457
458    # Skip over opening '['.
459    if self.input[self.cur] != '[':
460      raise GNError('Expected [ for list but got:\n  ' + self.input[self.cur:])
461    self.cur += 1
462    self.ConsumeCommentAndWhitespace()
463    if self.IsDone():
464      raise GNError('Unterminated list:\n  ' + self.input)
465
466    list_result = []
467    previous_had_trailing_comma = True
468    while not self.IsDone():
469      if self.input[self.cur] == ']':
470        self.cur += 1  # Skip over ']'.
471        return list_result
472
473      if not previous_had_trailing_comma:
474        raise GNError('List items not separated by comma.')
475
476      list_result += [ self._ParseAllowTrailing() ]
477      self.ConsumeCommentAndWhitespace()
478      if self.IsDone():
479        break
480
481      # Consume comma if there is one.
482      previous_had_trailing_comma = self.input[self.cur] == ','
483      if previous_had_trailing_comma:
484        # Consume comma.
485        self.cur += 1
486        self.ConsumeCommentAndWhitespace()
487
488    raise GNError('Unterminated list:\n  ' + self.input)
489
490  def ParseScope(self):
491    self.ConsumeCommentAndWhitespace()
492    if self.IsDone():
493      raise GNError('Expected scope but got nothing.')
494
495    # Skip over opening '{'.
496    if self.input[self.cur] != '{':
497      raise GNError('Expected { for scope but got:\n ' + self.input[self.cur:])
498    self.cur += 1
499    self.ConsumeCommentAndWhitespace()
500    if self.IsDone():
501      raise GNError('Unterminated scope:\n ' + self.input)
502
503    scope_result = {}
504    while not self.IsDone():
505      if self.input[self.cur] == '}':
506        self.cur += 1
507        return scope_result
508
509      ident = self._ParseIdent()
510      self.ConsumeCommentAndWhitespace()
511      if self.input[self.cur] != '=':
512        raise GNError("Unexpected token: " + self.input[self.cur:])
513      self.cur += 1
514      self.ConsumeCommentAndWhitespace()
515      val = self._ParseAllowTrailing()
516      self.ConsumeCommentAndWhitespace()
517      scope_result[ident] = val
518
519    raise GNError('Unterminated scope:\n ' + self.input)
520
521  def _ConstantFollows(self, constant):
522    """Checks and maybe consumes a string constant at current input location.
523
524    Param:
525      constant: The string constant to check.
526
527    Returns:
528      True if |constant| follows immediately at the current location in the
529      input. In this case, the string is consumed as a side effect. Otherwise,
530      returns False and the current position is unchanged.
531    """
532    end = self.cur + len(constant)
533    if end > len(self.input):
534      return False  # Not enough room.
535    if self.input[self.cur:end] == constant:
536      self.cur = end
537      return True
538    return False
539
540
541def ReadBuildVars(output_directory):
542  """Parses $output_directory/build_vars.json into a dict."""
543  with open(os.path.join(output_directory, BUILD_VARS_FILENAME)) as f:
544    return json.load(f)
545
546
547def CreateBuildCommand(output_directory):
548  """Returns [cmd, -C, output_directory].
549
550  Where |cmd| is one of: siso ninja, ninja, or autoninja.
551  """
552  suffix = '.bat' if sys.platform.startswith('win32') else ''
553  # Prefer the version on PATH, but fallback to known version if PATH doesn't
554  # have one (e.g. on bots).
555  if not shutil.which(f'autoninja{suffix}'):
556    third_party_prefix = os.path.join(_CHROMIUM_ROOT, 'third_party')
557    ninja_prefix = os.path.join(third_party_prefix, 'ninja', '')
558    siso_prefix = os.path.join(third_party_prefix, 'siso', 'cipd', '')
559    # Also - bots configure reclient manually, and so do not use the "auto"
560    # wrappers.
561    ninja_cmd = [f'{ninja_prefix}ninja{suffix}']
562    siso_cmd = [f'{siso_prefix}siso{suffix}', 'ninja']
563  else:
564    ninja_cmd = [f'autoninja{suffix}']
565    siso_cmd = list(ninja_cmd)
566
567  if output_directory and os.path.abspath(output_directory) != os.path.abspath(
568      os.curdir):
569    ninja_cmd += ['-C', output_directory]
570    siso_cmd += ['-C', output_directory]
571  siso_deps = os.path.exists(os.path.join(output_directory, '.siso_deps'))
572  ninja_deps = os.path.exists(os.path.join(output_directory, '.ninja_deps'))
573  if siso_deps and ninja_deps:
574    raise Exception('Found both .siso_deps and .ninja_deps in '
575                    f'{output_directory}. Not sure which build tool to use. '
576                    'Please delete one, or better, run "gn clean".')
577  if siso_deps:
578    return siso_cmd
579  return ninja_cmd
580