xref: /aosp_15_r20/external/angle/build/locale_tool.py (revision 8975f5c5ed3d1c378011245431ada316dfb6f244)
1#!/usr/bin/env vpython3
2# Copyright 2019 The Chromium Authors
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6"""Helper script used to manage locale-related files in Chromium.
7
8This script is used to check, and potentially fix, many locale-related files
9in your Chromium workspace, such as:
10
11  - GRIT input files (.grd) and the corresponding translations (.xtb).
12
13  - BUILD.gn files listing Android localized resource string resource .xml
14    generated by GRIT for all supported Chrome locales. These correspond to
15    <output> elements that use the type="android" attribute.
16
17The --scan-dir <dir> option can be used to check for all files under a specific
18directory, and the --fix-inplace option can be used to try fixing any file
19that doesn't pass the check.
20
21This can be very handy to avoid tedious and repetitive work when adding new
22translations / locales to the Chrome code base, since this script can update
23said input files for you.
24
25Important note: checks and fix may fail on some input files. For example
26remoting/resources/remoting_strings.grd contains an in-line comment element
27inside its <outputs> section that breaks the script. The check will fail, and
28trying to fix it too, but at least the file will not be modified.
29"""
30
31
32import argparse
33import json
34import os
35import re
36import shutil
37import subprocess
38import sys
39import unittest
40
41# Assume this script is under build/
42_SCRIPT_DIR = os.path.dirname(__file__)
43_SCRIPT_NAME = os.path.join(_SCRIPT_DIR, os.path.basename(__file__))
44_TOP_SRC_DIR = os.path.join(_SCRIPT_DIR, '..')
45
46# Need to import android/gyp/util/resource_utils.py here.
47sys.path.insert(0, os.path.join(_SCRIPT_DIR, 'android/gyp'))
48
49from util import build_utils
50from util import resource_utils
51
52
53# This locale is the default and doesn't have translations.
54_DEFAULT_LOCALE = 'en-US'
55
56# Misc terminal codes to provide human friendly progress output.
57_CONSOLE_CODE_MOVE_CURSOR_TO_COLUMN_0 = '\x1b[0G'
58_CONSOLE_CODE_ERASE_LINE = '\x1b[K'
59_CONSOLE_START_LINE = (
60    _CONSOLE_CODE_MOVE_CURSOR_TO_COLUMN_0 + _CONSOLE_CODE_ERASE_LINE)
61
62##########################################################################
63##########################################################################
64#####
65#####    G E N E R I C   H E L P E R   F U N C T I O N S
66#####
67##########################################################################
68##########################################################################
69
70def _FixChromiumLangAttribute(lang):
71  """Map XML "lang" attribute values to Chromium locale names."""
72  _CHROMIUM_LANG_FIXES = {
73      'en': 'en-US',  # For now, Chromium doesn't have an 'en' locale.
74      'iw': 'he',  # 'iw' is the obsolete form of ISO 639-1 for Hebrew
75      'no': 'nb',  # 'no' is used by the Translation Console for Norwegian (nb).
76  }
77  return _CHROMIUM_LANG_FIXES.get(lang, lang)
78
79
80def _FixTranslationConsoleLocaleName(locale):
81  _FIXES = {
82      'nb': 'no',  # Norwegian.
83      'he': 'iw',  # Hebrew
84  }
85  return _FIXES.get(locale, locale)
86
87
88def _CompareLocaleLists(list_a, list_expected, list_name):
89  """Compare two lists of locale names. Print errors if they differ.
90
91  Args:
92    list_a: First list of locales.
93    list_expected: Second list of locales, as expected.
94    list_name: Name of list printed in error messages.
95  Returns:
96    On success, return False. On error, print error messages and return True.
97  """
98  errors = []
99  missing_locales = sorted(set(list_a) - set(list_expected))
100  if missing_locales:
101    errors.append('Missing locales: %s' % missing_locales)
102
103  extra_locales = sorted(set(list_expected) - set(list_a))
104  if extra_locales:
105    errors.append('Unexpected locales: %s' % extra_locales)
106
107  if errors:
108    print('Errors in %s definition:' % list_name)
109    for error in errors:
110      print('  %s\n' % error)
111    return True
112
113  return False
114
115
116def _BuildIntervalList(input_list, predicate):
117  """Find ranges of contiguous list items that pass a given predicate.
118
119  Args:
120    input_list: An input list of items of any type.
121    predicate: A function that takes a list item and return True if it
122      passes a given test.
123  Returns:
124    A list of (start_pos, end_pos) tuples, where all items in
125    [start_pos, end_pos) pass the predicate.
126  """
127  result = []
128  size = len(input_list)
129  start = 0
130  while True:
131    # Find first item in list that passes the predicate.
132    while start < size and not predicate(input_list[start]):
133      start += 1
134
135    if start >= size:
136      return result
137
138    # Find first item in the rest of the list that does not pass the
139    # predicate.
140    end = start + 1
141    while end < size and predicate(input_list[end]):
142      end += 1
143
144    result.append((start, end))
145    start = end + 1
146
147
148def _SortListSubRange(input_list, start, end, key_func):
149  """Sort an input list's sub-range according to a specific key function.
150
151  Args:
152    input_list: An input list.
153    start: Sub-range starting position in list.
154    end: Sub-range limit position in list.
155    key_func: A function that extracts a sort key from a line.
156  Returns:
157    A copy of |input_list|, with all items in [|start|, |end|) sorted
158    according to |key_func|.
159  """
160  result = input_list[:start]
161  inputs = []
162  for pos in xrange(start, end):
163    line = input_list[pos]
164    key = key_func(line)
165    inputs.append((key, line))
166
167  for _, line in sorted(inputs):
168    result.append(line)
169
170  result += input_list[end:]
171  return result
172
173
174def _SortElementsRanges(lines, element_predicate, element_key):
175  """Sort all elements of a given type in a list of lines by a given key.
176
177  Args:
178    lines: input lines.
179    element_predicate: predicate function to select elements to sort.
180    element_key: lambda returning a comparison key for each element that
181      passes the predicate.
182  Returns:
183    A new list of input lines, with lines [start..end) sorted.
184  """
185  intervals = _BuildIntervalList(lines, element_predicate)
186  for start, end in intervals:
187    lines = _SortListSubRange(lines, start, end, element_key)
188
189  return lines
190
191
192def _ProcessFile(input_file, locales, check_func, fix_func):
193  """Process a given input file, potentially fixing it.
194
195  Args:
196    input_file: Input file path.
197    locales: List of Chrome locales to consider / expect.
198    check_func: A lambda called to check the input file lines with
199      (input_lines, locales) argument. It must return an list of error
200      messages, or None on success.
201    fix_func: None, or a lambda called to fix the input file lines with
202      (input_lines, locales). It must return the new list of lines for
203      the input file, and may raise an Exception in case of error.
204  Returns:
205    True at the moment.
206  """
207  print('%sProcessing %s...' % (_CONSOLE_START_LINE, input_file), end=' ')
208  sys.stdout.flush()
209  with open(input_file) as f:
210    input_lines = f.readlines()
211  errors = check_func(input_file, input_lines, locales)
212  if errors:
213    print('\n%s%s' % (_CONSOLE_START_LINE, '\n'.join(errors)))
214    if fix_func:
215      try:
216        input_lines = fix_func(input_file, input_lines, locales)
217        output = ''.join(input_lines)
218        with open(input_file, 'wt') as f:
219          f.write(output)
220        print('Fixed %s.' % input_file)
221      except Exception as e:  # pylint: disable=broad-except
222        print('Skipped %s: %s' % (input_file, e))
223
224  return True
225
226
227def _ScanDirectoriesForFiles(scan_dirs, file_predicate):
228  """Scan a directory for files that match a given predicate.
229
230  Args:
231    scan_dir: A list of top-level directories to start scan in.
232    file_predicate: lambda function which is passed the file's base name
233      and returns True if its full path, relative to |scan_dir|, should be
234      passed in the result.
235  Returns:
236    A list of file full paths.
237  """
238  result = []
239  for src_dir in scan_dirs:
240    for root, _, files in os.walk(src_dir):
241      result.extend(os.path.join(root, f) for f in files if file_predicate(f))
242  return result
243
244
245def _WriteFile(file_path, file_data):
246  """Write |file_data| to |file_path|."""
247  with open(file_path, 'w') as f:
248    f.write(file_data)
249
250
251def _FindGnExecutable():
252  """Locate the real GN executable used by this Chromium checkout.
253
254  This is needed because the depot_tools 'gn' wrapper script will look
255  for .gclient and other things we really don't need here.
256
257  Returns:
258    Path of real host GN executable from current Chromium src/ checkout.
259  """
260  # Simply scan buildtools/*/gn and return the first one found so we don't
261  # have to guess the platform-specific sub-directory name (e.g. 'linux64'
262  # for 64-bit Linux machines).
263  buildtools_dir = os.path.join(_TOP_SRC_DIR, 'buildtools')
264  for subdir in os.listdir(buildtools_dir):
265    subdir_path = os.path.join(buildtools_dir, subdir)
266    if not os.path.isdir(subdir_path):
267      continue
268    gn_path = os.path.join(subdir_path, 'gn')
269    if os.path.exists(gn_path):
270      return gn_path
271  return None
272
273
274def _PrettyPrintListAsLines(input_list, available_width, trailing_comma=False):
275  result = []
276  input_str = ', '.join(input_list)
277  while len(input_str) > available_width:
278    pos = input_str.rfind(',', 0, available_width)
279    result.append(input_str[:pos + 1])
280    input_str = input_str[pos + 1:].lstrip()
281  if trailing_comma and input_str:
282    input_str += ','
283  result.append(input_str)
284  return result
285
286
287class _PrettyPrintListAsLinesTest(unittest.TestCase):
288
289  def test_empty_list(self):
290    self.assertListEqual([''], _PrettyPrintListAsLines([], 10))
291
292  def test_wrapping(self):
293    input_list = ['foo', 'bar', 'zoo', 'tool']
294    self.assertListEqual(
295        _PrettyPrintListAsLines(input_list, 8),
296        ['foo,', 'bar,', 'zoo,', 'tool'])
297    self.assertListEqual(
298        _PrettyPrintListAsLines(input_list, 12), ['foo, bar,', 'zoo, tool'])
299    self.assertListEqual(
300        _PrettyPrintListAsLines(input_list, 79), ['foo, bar, zoo, tool'])
301
302  def test_trailing_comma(self):
303    input_list = ['foo', 'bar', 'zoo', 'tool']
304    self.assertListEqual(
305        _PrettyPrintListAsLines(input_list, 8, trailing_comma=True),
306        ['foo,', 'bar,', 'zoo,', 'tool,'])
307    self.assertListEqual(
308        _PrettyPrintListAsLines(input_list, 12, trailing_comma=True),
309        ['foo, bar,', 'zoo, tool,'])
310    self.assertListEqual(
311        _PrettyPrintListAsLines(input_list, 79, trailing_comma=True),
312        ['foo, bar, zoo, tool,'])
313
314
315##########################################################################
316##########################################################################
317#####
318#####    L O C A L E S   L I S T S
319#####
320##########################################################################
321##########################################################################
322
323# Various list of locales that will be extracted from build/config/locales.gni
324# Do not use these directly, use ChromeLocales(), and IosUnsupportedLocales()
325# instead to access these lists.
326_INTERNAL_CHROME_LOCALES = []
327_INTERNAL_IOS_UNSUPPORTED_LOCALES = []
328
329
330def ChromeLocales():
331  """Return the list of all locales supported by Chrome."""
332  if not _INTERNAL_CHROME_LOCALES:
333    _ExtractAllChromeLocalesLists()
334  return _INTERNAL_CHROME_LOCALES
335
336
337def IosUnsupportedLocales():
338  """Return the list of locales that are unsupported on iOS."""
339  if not _INTERNAL_IOS_UNSUPPORTED_LOCALES:
340    _ExtractAllChromeLocalesLists()
341  return _INTERNAL_IOS_UNSUPPORTED_LOCALES
342
343
344def _PrepareTinyGnWorkspace(work_dir, out_subdir_name='out'):
345  """Populate an empty directory with a tiny set of working GN config files.
346
347  This allows us to run 'gn gen <out> --root <work_dir>' as fast as possible
348  to generate files containing the locales list. This takes about 300ms on
349  a decent machine, instead of more than 5 seconds when running the equivalent
350  commands from a real Chromium workspace, which requires regenerating more
351  than 23k targets.
352
353  Args:
354    work_dir: target working directory.
355    out_subdir_name: Name of output sub-directory.
356  Returns:
357    Full path of output directory created inside |work_dir|.
358  """
359  # Create top-level .gn file that must point to the BUILDCONFIG.gn.
360  _WriteFile(os.path.join(work_dir, '.gn'),
361             'buildconfig = "//BUILDCONFIG.gn"\n')
362  # Create BUILDCONFIG.gn which must set a default toolchain. Also add
363  # all variables that may be used in locales.gni in a declare_args() block.
364  _WriteFile(
365      os.path.join(work_dir, 'BUILDCONFIG.gn'),
366      r'''set_default_toolchain("toolchain")
367declare_args () {
368  is_ios = false
369  is_android = true
370}
371''')
372
373  # Create fake toolchain required by BUILDCONFIG.gn.
374  os.mkdir(os.path.join(work_dir, 'toolchain'))
375  _WriteFile(os.path.join(work_dir, 'toolchain', 'BUILD.gn'),
376             r'''toolchain("toolchain") {
377  tool("stamp") {
378    command = "touch {{output}}"  # Required by action()
379  }
380}
381''')
382
383  # Create top-level BUILD.gn, GN requires at least one target to build so do
384  # that with a fake action which will never be invoked. Also write the locales
385  # to misc files in the output directory.
386  _WriteFile(
387      os.path.join(work_dir, 'BUILD.gn'), r'''import("//locales.gni")
388
389action("create_foo") {   # fake action to avoid GN complaints.
390  script = "//build/create_foo.py"
391  inputs = []
392  outputs = [ "$target_out_dir/$target_name" ]
393}
394
395# Write the locales lists to files in the output directory.
396_filename = root_build_dir + "/foo"
397write_file(_filename + ".locales", locales, "json")
398write_file(_filename + ".ios_unsupported_locales",
399            ios_unsupported_locales,
400            "json")
401''')
402
403  # Copy build/config/locales.gni to the workspace, as required by BUILD.gn.
404  shutil.copyfile(os.path.join(_TOP_SRC_DIR, 'build', 'config', 'locales.gni'),
405                  os.path.join(work_dir, 'locales.gni'))
406
407  # Create output directory.
408  out_path = os.path.join(work_dir, out_subdir_name)
409  os.mkdir(out_path)
410
411  # And ... we're good.
412  return out_path
413
414
415# Set this global variable to the path of a given temporary directory
416# before calling _ExtractAllChromeLocalesLists() if you want to debug
417# the locales list extraction process.
418_DEBUG_LOCALES_WORK_DIR = None
419
420
421def _ReadJsonList(file_path):
422  """Read a JSON file that must contain a list, and return it."""
423  with open(file_path) as f:
424    data = json.load(f)
425    assert isinstance(data, list), "JSON file %s is not a list!" % file_path
426  return [item.encode('utf8') for item in data]
427
428
429def _ExtractAllChromeLocalesLists():
430  with build_utils.TempDir() as tmp_path:
431    if _DEBUG_LOCALES_WORK_DIR:
432      tmp_path = _DEBUG_LOCALES_WORK_DIR
433      build_utils.DeleteDirectory(tmp_path)
434      build_utils.MakeDirectory(tmp_path)
435
436    out_path = _PrepareTinyGnWorkspace(tmp_path, 'out')
437
438    # NOTE: The file suffixes used here should be kept in sync with
439    # build/config/locales.gni
440    gn_executable = _FindGnExecutable()
441    try:
442      subprocess.check_output(
443          [gn_executable, 'gen', out_path, '--root=' + tmp_path])
444    except subprocess.CalledProcessError as e:
445      print(e.output)
446      raise e
447
448    global _INTERNAL_CHROME_LOCALES
449    _INTERNAL_CHROME_LOCALES = _ReadJsonList(
450        os.path.join(out_path, 'foo.locales'))
451
452    global _INTERNAL_IOS_UNSUPPORTED_LOCALES
453    _INTERNAL_IOS_UNSUPPORTED_LOCALES = _ReadJsonList(
454        os.path.join(out_path, 'foo.ios_unsupported_locales'))
455
456
457##########################################################################
458##########################################################################
459#####
460#####    G R D   H E L P E R   F U N C T I O N S
461#####
462##########################################################################
463##########################################################################
464
465# Technical note:
466#
467# Even though .grd files are XML, an xml parser library is not used in order
468# to preserve the original file's structure after modification. ElementTree
469# tends to re-order attributes in each element when re-writing an XML
470# document tree, which is undesirable here.
471#
472# Thus simple line-based regular expression matching is used instead.
473#
474
475# Misc regular expressions used to match elements and their attributes.
476_RE_OUTPUT_ELEMENT = re.compile(r'<output (.*)\s*/>')
477_RE_TRANSLATION_ELEMENT = re.compile(r'<file( | .* )path="(.*\.xtb)".*/>')
478_RE_FILENAME_ATTRIBUTE = re.compile(r'filename="([^"]*)"')
479_RE_LANG_ATTRIBUTE = re.compile(r'lang="([^"]*)"')
480_RE_PATH_ATTRIBUTE = re.compile(r'path="([^"]*)"')
481_RE_TYPE_ANDROID_ATTRIBUTE = re.compile(r'type="android"')
482
483
484
485def _IsGritInputFile(input_file):
486  """Returns True iff this is a GRIT input file."""
487  return input_file.endswith('.grd')
488
489
490def _GetXmlLangAttribute(xml_line):
491  """Extract the lang attribute value from an XML input line."""
492  m = _RE_LANG_ATTRIBUTE.search(xml_line)
493  if not m:
494    return None
495  return m.group(1)
496
497
498class _GetXmlLangAttributeTest(unittest.TestCase):
499  TEST_DATA = {
500      '': None,
501      'foo': None,
502      'lang=foo': None,
503      'lang="foo"': 'foo',
504      '<something lang="foo bar" />': 'foo bar',
505      '<file lang="fr-CA" path="path/to/strings_fr-CA.xtb" />': 'fr-CA',
506  }
507
508  def test_GetXmlLangAttribute(self):
509    for test_line, expected in self.TEST_DATA.items():
510      self.assertEquals(_GetXmlLangAttribute(test_line), expected)
511
512
513def _SortGrdElementsRanges(grd_lines, element_predicate):
514  """Sort all .grd elements of a given type by their lang attribute."""
515  return _SortElementsRanges(grd_lines, element_predicate, _GetXmlLangAttribute)
516
517
518def _CheckGrdElementRangeLang(grd_lines, start, end, wanted_locales):
519  """Check the element 'lang' attributes in specific .grd lines range.
520
521  This really checks the following:
522    - Each item has a correct 'lang' attribute.
523    - There are no duplicated lines for the same 'lang' attribute.
524    - That there are no extra locales that Chromium doesn't want.
525    - That no wanted locale is missing.
526
527  Args:
528    grd_lines: Input .grd lines.
529    start: Sub-range start position in input line list.
530    end: Sub-range limit position in input line list.
531    wanted_locales: Set of wanted Chromium locale names.
532  Returns:
533    List of error message strings for this input. Empty on success.
534  """
535  errors = []
536  locales = set()
537  for pos in xrange(start, end):
538    line = grd_lines[pos]
539    lang = _GetXmlLangAttribute(line)
540    if not lang:
541      errors.append('%d: Missing "lang" attribute in <output> element' % pos +
542                    1)
543      continue
544    cr_locale = _FixChromiumLangAttribute(lang)
545    if cr_locale in locales:
546      errors.append(
547          '%d: Redefinition of <output> for "%s" locale' % (pos + 1, lang))
548    locales.add(cr_locale)
549
550  extra_locales = locales.difference(wanted_locales)
551  if extra_locales:
552    errors.append('%d-%d: Extra locales found: %s' % (start + 1, end + 1,
553                                                      sorted(extra_locales)))
554
555  missing_locales = wanted_locales.difference(locales)
556  if missing_locales:
557    errors.append('%d-%d: Missing locales: %s' % (start + 1, end + 1,
558                                                  sorted(missing_locales)))
559
560  return errors
561
562
563##########################################################################
564##########################################################################
565#####
566#####    G R D   A N D R O I D   O U T P U T S
567#####
568##########################################################################
569##########################################################################
570
571def _IsGrdAndroidOutputLine(line):
572  """Returns True iff this is an Android-specific <output> line."""
573  m = _RE_OUTPUT_ELEMENT.search(line)
574  if m:
575    return 'type="android"' in m.group(1)
576  return False
577
578assert _IsGrdAndroidOutputLine('  <output type="android"/>')
579
580# Many of the functions below have unused arguments due to genericity.
581# pylint: disable=unused-argument
582
583def _CheckGrdElementRangeAndroidOutputFilename(grd_lines, start, end,
584                                               wanted_locales):
585  """Check all <output> elements in specific input .grd lines range.
586
587  This really checks the following:
588    - Filenames exist for each listed locale.
589    - Filenames are well-formed.
590
591  Args:
592    grd_lines: Input .grd lines.
593    start: Sub-range start position in input line list.
594    end: Sub-range limit position in input line list.
595    wanted_locales: Set of wanted Chromium locale names.
596  Returns:
597    List of error message strings for this input. Empty on success.
598  """
599  errors = []
600  for pos in xrange(start, end):
601    line = grd_lines[pos]
602    lang = _GetXmlLangAttribute(line)
603    if not lang:
604      continue
605    cr_locale = _FixChromiumLangAttribute(lang)
606
607    m = _RE_FILENAME_ATTRIBUTE.search(line)
608    if not m:
609      errors.append('%d: Missing filename attribute in <output> element' % pos +
610                    1)
611    else:
612      filename = m.group(1)
613      if not filename.endswith('.xml'):
614        errors.append(
615            '%d: Filename should end with ".xml": %s' % (pos + 1, filename))
616
617      dirname = os.path.basename(os.path.dirname(filename))
618      prefix = ('values-%s' % resource_utils.ToAndroidLocaleName(cr_locale)
619                if cr_locale != _DEFAULT_LOCALE else 'values')
620      if dirname != prefix:
621        errors.append(
622            '%s: Directory name should be %s: %s' % (pos + 1, prefix, filename))
623
624  return errors
625
626
627def _CheckGrdAndroidOutputElements(grd_file, grd_lines, wanted_locales):
628  """Check all <output> elements related to Android.
629
630  Args:
631    grd_file: Input .grd file path.
632    grd_lines: List of input .grd lines.
633    wanted_locales: set of wanted Chromium locale names.
634  Returns:
635    List of error message strings. Empty on success.
636  """
637  intervals = _BuildIntervalList(grd_lines, _IsGrdAndroidOutputLine)
638  errors = []
639  for start, end in intervals:
640    errors += _CheckGrdElementRangeLang(grd_lines, start, end, wanted_locales)
641    errors += _CheckGrdElementRangeAndroidOutputFilename(grd_lines, start, end,
642                                                         wanted_locales)
643  return errors
644
645
646def _AddMissingLocalesInGrdAndroidOutputs(grd_file, grd_lines, wanted_locales):
647  """Fix an input .grd line by adding missing Android outputs.
648
649  Args:
650    grd_file: Input .grd file path.
651    grd_lines: Input .grd line list.
652    wanted_locales: set of Chromium locale names.
653  Returns:
654    A new list of .grd lines, containing new <output> elements when needed
655    for locales from |wanted_locales| that were not part of the input.
656  """
657  intervals = _BuildIntervalList(grd_lines, _IsGrdAndroidOutputLine)
658  for start, end in reversed(intervals):
659    locales = set()
660    for pos in xrange(start, end):
661      lang = _GetXmlLangAttribute(grd_lines[pos])
662      locale = _FixChromiumLangAttribute(lang)
663      locales.add(locale)
664
665    missing_locales = wanted_locales.difference(locales)
666    if not missing_locales:
667      continue
668
669    src_locale = 'bg'
670    src_lang_attribute = 'lang="%s"' % src_locale
671    src_line = None
672    for pos in xrange(start, end):
673      if src_lang_attribute in grd_lines[pos]:
674        src_line = grd_lines[pos]
675        break
676
677    if not src_line:
678      raise Exception(
679          'Cannot find <output> element with "%s" lang attribute' % src_locale)
680
681    line_count = end - 1
682    for locale in missing_locales:
683      android_locale = resource_utils.ToAndroidLocaleName(locale)
684      dst_line = src_line.replace(
685          'lang="%s"' % src_locale, 'lang="%s"' % locale).replace(
686              'values-%s/' % src_locale, 'values-%s/' % android_locale)
687      grd_lines.insert(line_count, dst_line)
688      line_count += 1
689
690  # Sort the new <output> elements.
691  return _SortGrdElementsRanges(grd_lines, _IsGrdAndroidOutputLine)
692
693
694##########################################################################
695##########################################################################
696#####
697#####    G R D   T R A N S L A T I O N S
698#####
699##########################################################################
700##########################################################################
701
702
703def _IsTranslationGrdOutputLine(line):
704  """Returns True iff this is an output .xtb <file> element."""
705  m = _RE_TRANSLATION_ELEMENT.search(line)
706  return m is not None
707
708
709class _IsTranslationGrdOutputLineTest(unittest.TestCase):
710
711  def test_GrdTranslationOutputLines(self):
712    _VALID_INPUT_LINES = [
713        '<file path="foo/bar.xtb" />',
714        '<file path="foo/bar.xtb"/>',
715        '<file lang="fr-CA" path="translations/aw_strings_fr-CA.xtb"/>',
716        '<file lang="fr-CA" path="translations/aw_strings_fr-CA.xtb" />',
717        '  <file path="translations/aw_strings_ar.xtb" lang="ar" />',
718    ]
719    _INVALID_INPUT_LINES = ['<file path="foo/bar.xml" />']
720
721    for line in _VALID_INPUT_LINES:
722      self.assertTrue(
723          _IsTranslationGrdOutputLine(line),
724          '_IsTranslationGrdOutputLine() returned False for [%s]' % line)
725
726    for line in _INVALID_INPUT_LINES:
727      self.assertFalse(
728          _IsTranslationGrdOutputLine(line),
729          '_IsTranslationGrdOutputLine() returned True for [%s]' % line)
730
731
732def _CheckGrdTranslationElementRange(grd_lines, start, end,
733                                     wanted_locales):
734  """Check all <translations> sub-elements in specific input .grd lines range.
735
736  This really checks the following:
737    - Each item has a 'path' attribute.
738    - Each such path value ends up with '.xtb'.
739
740  Args:
741    grd_lines: Input .grd lines.
742    start: Sub-range start position in input line list.
743    end: Sub-range limit position in input line list.
744    wanted_locales: Set of wanted Chromium locale names.
745  Returns:
746    List of error message strings for this input. Empty on success.
747  """
748  errors = []
749  for pos in xrange(start, end):
750    line = grd_lines[pos]
751    lang = _GetXmlLangAttribute(line)
752    if not lang:
753      continue
754    m = _RE_PATH_ATTRIBUTE.search(line)
755    if not m:
756      errors.append('%d: Missing path attribute in <file> element' % pos +
757                    1)
758    else:
759      filename = m.group(1)
760      if not filename.endswith('.xtb'):
761        errors.append(
762            '%d: Path should end with ".xtb": %s' % (pos + 1, filename))
763
764  return errors
765
766
767def _CheckGrdTranslations(grd_file, grd_lines, wanted_locales):
768  """Check all <file> elements that correspond to an .xtb output file.
769
770  Args:
771    grd_file: Input .grd file path.
772    grd_lines: List of input .grd lines.
773    wanted_locales: set of wanted Chromium locale names.
774  Returns:
775    List of error message strings. Empty on success.
776  """
777  wanted_locales = wanted_locales - set([_DEFAULT_LOCALE])
778  intervals = _BuildIntervalList(grd_lines, _IsTranslationGrdOutputLine)
779  errors = []
780  for start, end in intervals:
781    errors += _CheckGrdElementRangeLang(grd_lines, start, end, wanted_locales)
782    errors += _CheckGrdTranslationElementRange(grd_lines, start, end,
783                                              wanted_locales)
784  return errors
785
786
787# Regular expression used to replace the lang attribute inside .xtb files.
788_RE_TRANSLATIONBUNDLE = re.compile('<translationbundle lang="(.*)">')
789
790
791def _CreateFakeXtbFileFrom(src_xtb_path, dst_xtb_path, dst_locale):
792  """Create a fake .xtb file.
793
794  Args:
795    src_xtb_path: Path to source .xtb file to copy from.
796    dst_xtb_path: Path to destination .xtb file to write to.
797    dst_locale: Destination locale, the lang attribute in the source file
798      will be substituted with this value before its lines are written
799      to the destination file.
800  """
801  with open(src_xtb_path) as f:
802    src_xtb_lines = f.readlines()
803
804  def replace_xtb_lang_attribute(line):
805    m = _RE_TRANSLATIONBUNDLE.search(line)
806    if not m:
807      return line
808    return line[:m.start(1)] + dst_locale + line[m.end(1):]
809
810  dst_xtb_lines = [replace_xtb_lang_attribute(line) for line in src_xtb_lines]
811  with build_utils.AtomicOutput(dst_xtb_path) as tmp:
812    tmp.writelines(dst_xtb_lines)
813
814
815def _AddMissingLocalesInGrdTranslations(grd_file, grd_lines, wanted_locales):
816  """Fix an input .grd line by adding missing Android outputs.
817
818  This also creates fake .xtb files from the one provided for 'en-GB'.
819
820  Args:
821    grd_file: Input .grd file path.
822    grd_lines: Input .grd line list.
823    wanted_locales: set of Chromium locale names.
824  Returns:
825    A new list of .grd lines, containing new <output> elements when needed
826    for locales from |wanted_locales| that were not part of the input.
827  """
828  wanted_locales = wanted_locales - set([_DEFAULT_LOCALE])
829  intervals = _BuildIntervalList(grd_lines, _IsTranslationGrdOutputLine)
830  for start, end in reversed(intervals):
831    locales = set()
832    for pos in xrange(start, end):
833      lang = _GetXmlLangAttribute(grd_lines[pos])
834      locale = _FixChromiumLangAttribute(lang)
835      locales.add(locale)
836
837    missing_locales = wanted_locales.difference(locales)
838    if not missing_locales:
839      continue
840
841    src_locale = 'en-GB'
842    src_lang_attribute = 'lang="%s"' % src_locale
843    src_line = None
844    for pos in xrange(start, end):
845      if src_lang_attribute in grd_lines[pos]:
846        src_line = grd_lines[pos]
847        break
848
849    if not src_line:
850      raise Exception(
851          'Cannot find <file> element with "%s" lang attribute' % src_locale)
852
853    src_path = os.path.join(
854        os.path.dirname(grd_file),
855        _RE_PATH_ATTRIBUTE.search(src_line).group(1))
856
857    line_count = end - 1
858    for locale in missing_locales:
859      dst_line = src_line.replace(
860          'lang="%s"' % src_locale, 'lang="%s"' % locale).replace(
861              '_%s.xtb' % src_locale, '_%s.xtb' % locale)
862      grd_lines.insert(line_count, dst_line)
863      line_count += 1
864
865      dst_path = src_path.replace('_%s.xtb' % src_locale, '_%s.xtb' % locale)
866      _CreateFakeXtbFileFrom(src_path, dst_path, locale)
867
868
869  # Sort the new <output> elements.
870  return _SortGrdElementsRanges(grd_lines, _IsTranslationGrdOutputLine)
871
872
873##########################################################################
874##########################################################################
875#####
876#####    G N   A N D R O I D   O U T P U T S
877#####
878##########################################################################
879##########################################################################
880
881_RE_GN_VALUES_LIST_LINE = re.compile(
882    r'^\s*".*values(\-([A-Za-z0-9-]+))?/.*\.xml",\s*$')
883
884def _IsBuildGnInputFile(input_file):
885  """Returns True iff this is a BUILD.gn file."""
886  return os.path.basename(input_file) == 'BUILD.gn'
887
888
889def _GetAndroidGnOutputLocale(line):
890  """Check a GN list, and return its Android locale if it is an output .xml"""
891  m = _RE_GN_VALUES_LIST_LINE.match(line)
892  if not m:
893    return None
894
895  if m.group(1):  # First group is optional and contains group 2.
896    return m.group(2)
897
898  return resource_utils.ToAndroidLocaleName(_DEFAULT_LOCALE)
899
900
901def _IsAndroidGnOutputLine(line):
902  """Returns True iff this is an Android-specific localized .xml output."""
903  return _GetAndroidGnOutputLocale(line) != None
904
905
906def _CheckGnOutputsRangeForLocalizedStrings(gn_lines, start, end):
907  """Check that a range of GN lines corresponds to localized strings.
908
909  Special case: Some BUILD.gn files list several non-localized .xml files
910  that should be ignored by this function, e.g. in
911  components/cronet/android/BUILD.gn, the following appears:
912
913    inputs = [
914      ...
915      "sample/res/layout/activity_main.xml",
916      "sample/res/layout/dialog_url.xml",
917      "sample/res/values/dimens.xml",
918      "sample/res/values/strings.xml",
919      ...
920    ]
921
922  These are non-localized strings, and should be ignored. This function is
923  used to detect them quickly.
924  """
925  for pos in xrange(start, end):
926    if not 'values/' in gn_lines[pos]:
927      return True
928  return False
929
930
931def _CheckGnOutputsRange(gn_lines, start, end, wanted_locales):
932  if not _CheckGnOutputsRangeForLocalizedStrings(gn_lines, start, end):
933    return []
934
935  errors = []
936  locales = set()
937  for pos in xrange(start, end):
938    line = gn_lines[pos]
939    android_locale = _GetAndroidGnOutputLocale(line)
940    assert android_locale != None
941    cr_locale = resource_utils.ToChromiumLocaleName(android_locale)
942    if cr_locale in locales:
943      errors.append('%s: Redefinition of output for "%s" locale' %
944                    (pos + 1, android_locale))
945    locales.add(cr_locale)
946
947  extra_locales = locales.difference(wanted_locales)
948  if extra_locales:
949    errors.append('%d-%d: Extra locales: %s' % (start + 1, end + 1,
950                                                sorted(extra_locales)))
951
952  missing_locales = wanted_locales.difference(locales)
953  if missing_locales:
954    errors.append('%d-%d: Missing locales: %s' % (start + 1, end + 1,
955                                                  sorted(missing_locales)))
956
957  return errors
958
959
960def _CheckGnAndroidOutputs(gn_file, gn_lines, wanted_locales):
961  intervals = _BuildIntervalList(gn_lines, _IsAndroidGnOutputLine)
962  errors = []
963  for start, end in intervals:
964    errors += _CheckGnOutputsRange(gn_lines, start, end, wanted_locales)
965  return errors
966
967
968def _AddMissingLocalesInGnAndroidOutputs(gn_file, gn_lines, wanted_locales):
969  intervals = _BuildIntervalList(gn_lines, _IsAndroidGnOutputLine)
970  # NOTE: Since this may insert new lines to each interval, process the
971  # list in reverse order to maintain valid (start,end) positions during
972  # the iteration.
973  for start, end in reversed(intervals):
974    if not _CheckGnOutputsRangeForLocalizedStrings(gn_lines, start, end):
975      continue
976
977    locales = set()
978    for pos in xrange(start, end):
979      lang = _GetAndroidGnOutputLocale(gn_lines[pos])
980      locale = resource_utils.ToChromiumLocaleName(lang)
981      locales.add(locale)
982
983    missing_locales = wanted_locales.difference(locales)
984    if not missing_locales:
985      continue
986
987    src_locale = 'bg'
988    src_values = 'values-%s/' % resource_utils.ToAndroidLocaleName(src_locale)
989    src_line = None
990    for pos in xrange(start, end):
991      if src_values in gn_lines[pos]:
992        src_line = gn_lines[pos]
993        break
994
995    if not src_line:
996      raise Exception(
997          'Cannot find output list item with "%s" locale' % src_locale)
998
999    line_count = end - 1
1000    for locale in missing_locales:
1001      if locale == _DEFAULT_LOCALE:
1002        dst_line = src_line.replace('values-%s/' % src_locale, 'values/')
1003      else:
1004        dst_line = src_line.replace(
1005            'values-%s/' % src_locale,
1006            'values-%s/' % resource_utils.ToAndroidLocaleName(locale))
1007      gn_lines.insert(line_count, dst_line)
1008      line_count += 1
1009
1010    gn_lines = _SortListSubRange(
1011        gn_lines, start, line_count,
1012        lambda line: _RE_GN_VALUES_LIST_LINE.match(line).group(1))
1013
1014  return gn_lines
1015
1016
1017##########################################################################
1018##########################################################################
1019#####
1020#####    T R A N S L A T I O N   E X P E C T A T I O N S
1021#####
1022##########################################################################
1023##########################################################################
1024
1025_EXPECTATIONS_FILENAME = 'translation_expectations.pyl'
1026
1027# Technical note: the format of translation_expectations.pyl
1028# is a 'Python literal', which defines a python dictionary, so should
1029# be easy to parse. However, when modifying it, care should be taken
1030# to respect the line comments and the order of keys within the text
1031# file.
1032
1033
1034def _ReadPythonLiteralFile(pyl_path):
1035  """Read a .pyl file into a Python data structure."""
1036  with open(pyl_path) as f:
1037    pyl_content = f.read()
1038  # Evaluate as a Python data structure, use an empty global
1039  # and local dictionary.
1040  return eval(pyl_content, dict(), dict())
1041
1042
1043def _UpdateLocalesInExpectationLines(pyl_lines,
1044                                     wanted_locales,
1045                                     available_width=79):
1046  """Update the locales list(s) found in an expectations file.
1047
1048  Args:
1049    pyl_lines: Iterable of input lines from the file.
1050    wanted_locales: Set or list of new locale names.
1051    available_width: Optional, number of character colums used
1052      to word-wrap the new list items.
1053  Returns:
1054    New list of updated lines.
1055  """
1056  locales_list = ['"%s"' % loc for loc in sorted(wanted_locales)]
1057  result = []
1058  line_count = len(pyl_lines)
1059  line_num = 0
1060  DICT_START = '"languages": ['
1061  while line_num < line_count:
1062    line = pyl_lines[line_num]
1063    line_num += 1
1064    result.append(line)
1065    # Look for start of "languages" dictionary.
1066    pos = line.find(DICT_START)
1067    if pos < 0:
1068      continue
1069
1070    start_margin = pos
1071    start_line = line_num
1072    # Skip over all lines from the list.
1073    while (line_num < line_count and
1074           not pyl_lines[line_num].rstrip().endswith('],')):
1075      line_num += 1
1076      continue
1077
1078    if line_num == line_count:
1079      raise Exception('%d: Missing list termination!' % start_line)
1080
1081    # Format the new list according to the new margin.
1082    locale_width = available_width - (start_margin + 2)
1083    locale_lines = _PrettyPrintListAsLines(
1084        locales_list, locale_width, trailing_comma=True)
1085    for locale_line in locale_lines:
1086      result.append(' ' * (start_margin + 2) + locale_line)
1087    result.append(' ' * start_margin + '],')
1088    line_num += 1
1089
1090  return result
1091
1092
1093class _UpdateLocalesInExpectationLinesTest(unittest.TestCase):
1094
1095  def test_simple(self):
1096    self.maxDiff = 1000
1097    input_text = r'''
1098# This comment should be preserved
1099# 23456789012345678901234567890123456789
1100{
1101  "android_grd": {
1102    "languages": [
1103      "aa", "bb", "cc", "dd", "ee",
1104      "ff", "gg", "hh", "ii", "jj",
1105      "kk"],
1106  },
1107  # Example with bad indentation in input.
1108  "another_grd": {
1109         "languages": [
1110  "aa", "bb", "cc", "dd", "ee", "ff", "gg", "hh", "ii", "jj", "kk",
1111      ],
1112  },
1113}
1114'''
1115    expected_text = r'''
1116# This comment should be preserved
1117# 23456789012345678901234567890123456789
1118{
1119  "android_grd": {
1120    "languages": [
1121      "A2", "AA", "BB", "CC", "DD",
1122      "E2", "EE", "FF", "GG", "HH",
1123      "I2", "II", "JJ", "KK",
1124    ],
1125  },
1126  # Example with bad indentation in input.
1127  "another_grd": {
1128         "languages": [
1129           "A2", "AA", "BB", "CC", "DD",
1130           "E2", "EE", "FF", "GG", "HH",
1131           "I2", "II", "JJ", "KK",
1132         ],
1133  },
1134}
1135'''
1136    input_lines = input_text.splitlines()
1137    test_locales = ([
1138        'AA', 'BB', 'CC', 'DD', 'EE', 'FF', 'GG', 'HH', 'II', 'JJ', 'KK', 'A2',
1139        'E2', 'I2'
1140    ])
1141    expected_lines = expected_text.splitlines()
1142    self.assertListEqual(
1143        _UpdateLocalesInExpectationLines(input_lines, test_locales, 40),
1144        expected_lines)
1145
1146  def test_missing_list_termination(self):
1147    input_lines = r'''
1148  "languages": ['
1149    "aa", "bb", "cc", "dd"
1150'''.splitlines()
1151    with self.assertRaises(Exception) as cm:
1152      _UpdateLocalesInExpectationLines(input_lines, ['a', 'b'], 40)
1153
1154    self.assertEqual(str(cm.exception), '2: Missing list termination!')
1155
1156
1157def _UpdateLocalesInExpectationFile(pyl_path, wanted_locales):
1158  """Update all locales listed in a given expectations file.
1159
1160  Args:
1161    pyl_path: Path to .pyl file to update.
1162    wanted_locales: List of locales that need to be written to
1163      the file.
1164  """
1165  tc_locales = {
1166      _FixTranslationConsoleLocaleName(locale)
1167      for locale in set(wanted_locales) - set([_DEFAULT_LOCALE])
1168  }
1169
1170  with open(pyl_path) as f:
1171    input_lines = [l.rstrip() for l in f.readlines()]
1172
1173  updated_lines = _UpdateLocalesInExpectationLines(input_lines, tc_locales)
1174  with build_utils.AtomicOutput(pyl_path) as f:
1175    f.writelines('\n'.join(updated_lines) + '\n')
1176
1177
1178##########################################################################
1179##########################################################################
1180#####
1181#####    C H E C K   E V E R Y T H I N G
1182#####
1183##########################################################################
1184##########################################################################
1185
1186# pylint: enable=unused-argument
1187
1188
1189def _IsAllInputFile(input_file):
1190  return _IsGritInputFile(input_file) or _IsBuildGnInputFile(input_file)
1191
1192
1193def _CheckAllFiles(input_file, input_lines, wanted_locales):
1194  errors = []
1195  if _IsGritInputFile(input_file):
1196    errors += _CheckGrdTranslations(input_file, input_lines, wanted_locales)
1197    errors += _CheckGrdAndroidOutputElements(
1198        input_file, input_lines, wanted_locales)
1199  elif _IsBuildGnInputFile(input_file):
1200    errors += _CheckGnAndroidOutputs(input_file, input_lines, wanted_locales)
1201  return errors
1202
1203
1204def _AddMissingLocalesInAllFiles(input_file, input_lines, wanted_locales):
1205  if _IsGritInputFile(input_file):
1206    lines = _AddMissingLocalesInGrdTranslations(
1207        input_file, input_lines, wanted_locales)
1208    lines = _AddMissingLocalesInGrdAndroidOutputs(
1209        input_file, lines, wanted_locales)
1210  elif _IsBuildGnInputFile(input_file):
1211    lines = _AddMissingLocalesInGnAndroidOutputs(
1212        input_file, input_lines, wanted_locales)
1213  return lines
1214
1215
1216##########################################################################
1217##########################################################################
1218#####
1219#####    C O M M A N D   H A N D L I N G
1220#####
1221##########################################################################
1222##########################################################################
1223
1224class _Command(object):
1225  """A base class for all commands recognized by this script.
1226
1227  Usage is the following:
1228    1) Derived classes must re-define the following class-based fields:
1229       - name: Command name (e.g. 'list-locales')
1230       - description: Command short description.
1231       - long_description: Optional. Command long description.
1232         NOTE: As a convenience, if the first character is a newline,
1233         it will be omitted in the help output.
1234
1235    2) Derived classes for commands that take arguments should override
1236       RegisterExtraArgs(), which receives a corresponding argparse
1237       sub-parser as argument.
1238
1239    3) Derived classes should implement a Run() command, which can read
1240       the current arguments from self.args.
1241  """
1242  name = None
1243  description = None
1244  long_description = None
1245
1246  def __init__(self):
1247    self._parser = None
1248    self.args = None
1249
1250  def RegisterExtraArgs(self, subparser):
1251    pass
1252
1253  def RegisterArgs(self, parser):
1254    subp = parser.add_parser(
1255        self.name, help=self.description,
1256        description=self.long_description or self.description,
1257        formatter_class=argparse.RawDescriptionHelpFormatter)
1258    self._parser = subp
1259    subp.set_defaults(command=self)
1260    group = subp.add_argument_group('%s arguments' % self.name)
1261    self.RegisterExtraArgs(group)
1262
1263  def ProcessArgs(self, args):
1264    self.args = args
1265
1266
1267class _ListLocalesCommand(_Command):
1268  """Implement the 'list-locales' command to list locale lists of interest."""
1269  name = 'list-locales'
1270  description = 'List supported Chrome locales'
1271  long_description = r'''
1272List locales of interest, by default this prints all locales supported by
1273Chrome, but `--type=ios_unsupported` can be used for the list of locales
1274unsupported on iOS.
1275
1276These values are extracted directly from build/config/locales.gni.
1277
1278Additionally, use the --as-json argument to print the list as a JSON list,
1279instead of the default format (which is a space-separated list of locale names).
1280'''
1281
1282  # Maps type argument to a function returning the corresponding locales list.
1283  TYPE_MAP = {
1284      'all': ChromeLocales,
1285      'ios_unsupported': IosUnsupportedLocales,
1286  }
1287
1288  def RegisterExtraArgs(self, group):
1289    group.add_argument(
1290        '--as-json',
1291        action='store_true',
1292        help='Output as JSON list.')
1293    group.add_argument(
1294        '--type',
1295        choices=tuple(self.TYPE_MAP.viewkeys()),
1296        default='all',
1297        help='Select type of locale list to print.')
1298
1299  def Run(self):
1300    locale_list = self.TYPE_MAP[self.args.type]()
1301    if self.args.as_json:
1302      print('[%s]' % ", ".join("'%s'" % loc for loc in locale_list))
1303    else:
1304      print(' '.join(locale_list))
1305
1306
1307class _CheckInputFileBaseCommand(_Command):
1308  """Used as a base for other _Command subclasses that check input files.
1309
1310  Subclasses should also define the following class-level variables:
1311
1312  - select_file_func:
1313      A predicate that receives a file name (not path) and return True if it
1314      should be selected for inspection. Used when scanning directories with
1315      '--scan-dir <dir>'.
1316
1317  - check_func:
1318  - fix_func:
1319      Two functions passed as parameters to _ProcessFile(), see relevant
1320      documentation in this function's definition.
1321  """
1322  select_file_func = None
1323  check_func = None
1324  fix_func = None
1325
1326  def RegisterExtraArgs(self, group):
1327    group.add_argument(
1328      '--scan-dir',
1329      action='append',
1330      help='Optional directory to scan for input files recursively.')
1331    group.add_argument(
1332      'input',
1333      nargs='*',
1334      help='Input file(s) to check.')
1335    group.add_argument(
1336      '--fix-inplace',
1337      action='store_true',
1338      help='Try to fix the files in-place too.')
1339    group.add_argument(
1340      '--add-locales',
1341      help='Space-separated list of additional locales to use')
1342
1343  def Run(self):
1344    args = self.args
1345    input_files = []
1346    if args.input:
1347      input_files = args.input
1348    if args.scan_dir:
1349      input_files.extend(_ScanDirectoriesForFiles(
1350          args.scan_dir, self.select_file_func.__func__))
1351    locales = ChromeLocales()
1352    if args.add_locales:
1353      locales.extend(args.add_locales.split(' '))
1354
1355    locales = set(locales)
1356
1357    for input_file in input_files:
1358      _ProcessFile(input_file,
1359                   locales,
1360                   self.check_func.__func__,
1361                   self.fix_func.__func__ if args.fix_inplace else None)
1362    print('%sDone.' % (_CONSOLE_START_LINE))
1363
1364
1365class _CheckGrdAndroidOutputsCommand(_CheckInputFileBaseCommand):
1366  name = 'check-grd-android-outputs'
1367  description = (
1368      'Check the Android resource (.xml) files outputs in GRIT input files.')
1369  long_description = r'''
1370Check the Android .xml files outputs in one or more input GRIT (.grd) files
1371for the following conditions:
1372
1373    - Each item has a correct 'lang' attribute.
1374    - There are no duplicated lines for the same 'lang' attribute.
1375    - That there are no extra locales that Chromium doesn't want.
1376    - That no wanted locale is missing.
1377    - Filenames exist for each listed locale.
1378    - Filenames are well-formed.
1379'''
1380  select_file_func = _IsGritInputFile
1381  check_func = _CheckGrdAndroidOutputElements
1382  fix_func = _AddMissingLocalesInGrdAndroidOutputs
1383
1384
1385class _CheckGrdTranslationsCommand(_CheckInputFileBaseCommand):
1386  name = 'check-grd-translations'
1387  description = (
1388      'Check the translation (.xtb) files outputted by .grd input files.')
1389  long_description = r'''
1390Check the translation (.xtb) file outputs in one or more input GRIT (.grd) files
1391for the following conditions:
1392
1393    - Each item has a correct 'lang' attribute.
1394    - There are no duplicated lines for the same 'lang' attribute.
1395    - That there are no extra locales that Chromium doesn't want.
1396    - That no wanted locale is missing.
1397    - Each item has a 'path' attribute.
1398    - Each such path value ends up with '.xtb'.
1399'''
1400  select_file_func = _IsGritInputFile
1401  check_func = _CheckGrdTranslations
1402  fix_func = _AddMissingLocalesInGrdTranslations
1403
1404
1405class _CheckGnAndroidOutputsCommand(_CheckInputFileBaseCommand):
1406  name = 'check-gn-android-outputs'
1407  description = 'Check the Android .xml file lists in GN build files.'
1408  long_description = r'''
1409Check one or more BUILD.gn file, looking for lists of Android resource .xml
1410files, and checking that:
1411
1412  - There are no duplicated output files in the list.
1413  - Each output file belongs to a wanted Chromium locale.
1414  - There are no output files for unwanted Chromium locales.
1415'''
1416  select_file_func = _IsBuildGnInputFile
1417  check_func = _CheckGnAndroidOutputs
1418  fix_func = _AddMissingLocalesInGnAndroidOutputs
1419
1420
1421class _CheckAllCommand(_CheckInputFileBaseCommand):
1422  name = 'check-all'
1423  description = 'Check everything.'
1424  long_description = 'Equivalent to calling all other check-xxx commands.'
1425  select_file_func = _IsAllInputFile
1426  check_func = _CheckAllFiles
1427  fix_func = _AddMissingLocalesInAllFiles
1428
1429
1430class _UpdateExpectationsCommand(_Command):
1431  name = 'update-expectations'
1432  description = 'Update translation expectations file.'
1433  long_description = r'''
1434Update %s files to match the current list of locales supported by Chromium.
1435This is especially useful to add new locales before updating any GRIT or GN
1436input file with the --add-locales option.
1437''' % _EXPECTATIONS_FILENAME
1438
1439  def RegisterExtraArgs(self, group):
1440    group.add_argument(
1441        '--add-locales',
1442        help='Space-separated list of additional locales to use.')
1443
1444  def Run(self):
1445    locales = ChromeLocales()
1446    add_locales = self.args.add_locales
1447    if add_locales:
1448      locales.extend(add_locales.split(' '))
1449
1450    expectation_paths = [
1451        'tools/gritsettings/translation_expectations.pyl',
1452        'clank/tools/translation_expectations.pyl',
1453    ]
1454    missing_expectation_files = []
1455    for path in enumerate(expectation_paths):
1456      file_path = os.path.join(_TOP_SRC_DIR, path)
1457      if not os.path.exists(file_path):
1458        missing_expectation_files.append(file_path)
1459        continue
1460      _UpdateLocalesInExpectationFile(file_path, locales)
1461
1462    if missing_expectation_files:
1463      sys.stderr.write('WARNING: Missing file(s): %s\n' %
1464                       (', '.join(missing_expectation_files)))
1465
1466
1467class _UnitTestsCommand(_Command):
1468  name = 'unit-tests'
1469  description = 'Run internal unit-tests for this script'
1470
1471  def RegisterExtraArgs(self, group):
1472    group.add_argument(
1473        '-v', '--verbose', action='count', help='Increase test verbosity.')
1474    group.add_argument('args', nargs=argparse.REMAINDER)
1475
1476  def Run(self):
1477    argv = [_SCRIPT_NAME] + self.args.args
1478    unittest.main(argv=argv, verbosity=self.args.verbose)
1479
1480
1481# List of all commands supported by this script.
1482_COMMANDS = [
1483    _ListLocalesCommand,
1484    _CheckGrdAndroidOutputsCommand,
1485    _CheckGrdTranslationsCommand,
1486    _CheckGnAndroidOutputsCommand,
1487    _CheckAllCommand,
1488    _UpdateExpectationsCommand,
1489    _UnitTestsCommand,
1490]
1491
1492
1493def main(argv):
1494  parser = argparse.ArgumentParser(
1495      description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
1496
1497  subparsers = parser.add_subparsers()
1498  commands = [clazz() for clazz in _COMMANDS]
1499  for command in commands:
1500    command.RegisterArgs(subparsers)
1501
1502  if not argv:
1503    argv = ['--help']
1504
1505  args = parser.parse_args(argv)
1506  args.command.ProcessArgs(args)
1507  args.command.Run()
1508
1509
1510if __name__ == "__main__":
1511  main(sys.argv[1:])
1512