check_cmake_files.py 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. #!/usr/bin/env python
  2. # Copyright 2019 Google LLC
  3. #
  4. # Licensed under the Apache License, Version 2.0 (the "License");
  5. # you may not use this file except in compliance with the License.
  6. # You may obtain a copy of the License at
  7. #
  8. # http://www.apache.org/licenses/LICENSE-2.0
  9. #
  10. # Unless required by applicable law or agreed to in writing, software
  11. # distributed under the License is distributed on an "AS IS" BASIS,
  12. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13. # See the License for the specific language governing permissions and
  14. # limitations under the License.
  15. """Checks that source files are mentioned in CMakeLists.txt.
  16. Also checks that files mentioned in CMakeLists.txt exist on the filesystem.
  17. Note that this check needs to be able to run before anything has been built, so
  18. generated files must be excluded from the check. Add a "# NOLINT(generated)"
  19. comment to any line mentioning such a file to ensure they don't falsely trigger
  20. errors. This only needs to be done once within a file.
  21. """
  22. import argparse
  23. import collections
  24. import os
  25. import re
  26. import sys
  27. from lib import git
  28. # Directories relative to the repo root that will be scanned by default if no
  29. # arguments are passed.
  30. _DEFAULT_DIRS = [
  31. 'Firestore/core',
  32. 'Firestore/Example/Tests'
  33. 'Firestore/Source'
  34. ]
  35. # When scanning the filesystem, look for specific files or files with these
  36. # extensions.
  37. _INCLUDE_FILES = {'CMakeLists.txt'}
  38. _INCLUDE_EXTENSIONS = {'.c', '.cc', '.h', '.m', '.mm'}
  39. # When scanning the filesystem, exclude any files or directories with these
  40. # names.
  41. _EXCLUDE_DIRS = {'third_party', 'Pods', 'Protos'}
  42. _verbose = False
  43. def main(args):
  44. global _verbose
  45. parser = argparse.ArgumentParser(
  46. description='Check CMakeLists.txt file membership.')
  47. parser.add_argument('--verbose', '-v', action='store_true',
  48. help='Run verbosely')
  49. parser.add_argument('filenames', nargs='*', metavar='file_or_dir',
  50. help='Files and directories to scan')
  51. args = parser.parse_args(args)
  52. if args.verbose:
  53. _verbose = True
  54. scan_filenames = args.filenames
  55. if not scan_filenames:
  56. scan_filenames = default_args()
  57. filenames = find_source_files(scan_filenames)
  58. groups = group_by_cmakelists(filenames)
  59. errors = find_all_errors(groups)
  60. trace('checked %d files' % len(filenames))
  61. sys.exit(1 if errors else 0)
  62. def default_args():
  63. """Returns a default list of directories to scan.
  64. """
  65. toplevel = git.get_repo_root()
  66. return [os.path.join(toplevel, dirname) for dirname in _DEFAULT_DIRS]
  67. def find_source_files(roots):
  68. """Finds source files on the filesystem.
  69. Args:
  70. roots: A list of files or directories
  71. Returns:
  72. A list of filenames found in the roots, excluding those that are
  73. uninteresting.
  74. """
  75. result = []
  76. for root in roots:
  77. for parent, dirs, files in os.walk(root, topdown=True):
  78. # Prune directories known to be uninteresting
  79. dirs[:] = [d for d in dirs if d not in _EXCLUDE_DIRS]
  80. for filename in files:
  81. if filename in _INCLUDE_FILES or is_source_file(filename):
  82. result.append(os.path.join(parent, filename))
  83. return result
  84. _filename_pattern = re.compile(r'\b([A-Za-z0-9_/+]+\.)+(?:c|cc|h|m|mm)\b')
  85. _comment_pattern = re.compile(r'^(\s*)#')
  86. _check_pattern = re.compile(r'^\s*check_[A-Za-z0-9_]+\(.*\)$')
  87. _nolint_pattern = re.compile(r'NOLINT')
  88. def read_listed_source_files(filename):
  89. """Reads the contents of the given filename and finds all the filenames it
  90. finds in the file.
  91. Args:
  92. filename: A filename to read, typically some path to a CMakeLists.txt file.
  93. Returns:
  94. A pair of lists. The first list contains filenames mentioned in the file.
  95. The second contains files that have been ignored (by marking them NOLINT)
  96. in the file. Elements from the second list might also be present in the
  97. first list.
  98. """
  99. found = []
  100. ignored = []
  101. parent = os.path.dirname(filename)
  102. with open(filename, 'r') as fd:
  103. for line in fd.readlines():
  104. # Simple hack to exclude files mentioned in CMake checks
  105. if _check_pattern.match(line):
  106. continue
  107. ignore = bool(_nolint_pattern.search(line))
  108. if not ignore:
  109. # Exclude comments, but only on regular lines. This allows files to
  110. # include files in comments that mark them NOLINT.
  111. m = _comment_pattern.match(line)
  112. if m:
  113. line = m.group(1)
  114. for m in _filename_pattern.finditer(line):
  115. listed_filename = os.path.join(parent, m.group(0))
  116. if ignore:
  117. trace('ignoring %s' % listed_filename)
  118. ignored.append(listed_filename)
  119. else:
  120. found.append(listed_filename)
  121. found.sort()
  122. ignored.sort()
  123. return found, ignored
  124. class Group(object):
  125. """A comparison group.
  126. Groups include the location of a CMakeLists.txt file along with files that
  127. were found on the filesystem that should be mentioned in the file, files that
  128. were found in the CMakeLists.txt file, and any files that should be ignored.
  129. """
  130. def __init__(self):
  131. self.list_file = None
  132. self.fs_files = []
  133. self.list_files = []
  134. self.ignored_files = []
  135. def shorten(self):
  136. """Shorten filenames to make them relative to the directory containing the
  137. CMakeLists.txt file and make the file lists into sets.
  138. """
  139. prefix = os.path.dirname(self.list_file) + '/'
  140. self.fs_files = self._remove_prefix(prefix, self.fs_files)
  141. self.list_files = self._remove_prefix(prefix, self.list_files)
  142. self.ignored_files = self._remove_prefix(prefix, self.ignored_files)
  143. def _remove_prefix(self, prefix, filenames):
  144. result = []
  145. for filename in filenames:
  146. if not filename.startswith(prefix):
  147. raise Exception('Filename %s not in prefix %s' % (filename, prefix))
  148. result.append(filename[len(prefix):])
  149. return set(result)
  150. def __repr__(self):
  151. def files(items):
  152. return repr(sorted(list(items)))
  153. return """<Group %s
  154. fs=%s
  155. listed=%s
  156. ignored=%s>""" % (self.list_file,
  157. files(self.fs_files),
  158. files(self.list_files),
  159. files(self.ignored_files))
  160. def group_by_cmakelists(filenames):
  161. """Groups the given filenames by the nearest CMakeLists.txt
  162. Args:
  163. filenames: A list of filenames found on the filesystem.
  164. Returns:
  165. A list of filled-out Groups for evaluation.
  166. """
  167. filename_set = set(filenames)
  168. groups = collections.defaultdict(Group)
  169. for filename in filenames:
  170. if is_source_file(filename):
  171. for cmake_list in possible_cmake_lists_files(filename):
  172. if cmake_list in filename_set:
  173. groups[cmake_list].fs_files.append(filename)
  174. break
  175. elif os.path.basename(filename) == 'CMakeLists.txt':
  176. g = groups[filename]
  177. g.list_file = filename
  178. g.list_files, g.ignored_files = read_listed_source_files(filename)
  179. return sorted(list(groups.values()), key=lambda g: g.list_file)
  180. def find_all_errors(groups):
  181. """Finds errors in the given groups.
  182. Args:
  183. groups: A list of groups. Each group is shortened.
  184. Returns:
  185. A count of errors encountered; errors information is printed for the user.
  186. """
  187. errors = 0
  188. for group in groups:
  189. group.shorten()
  190. errors += find_errors(group)
  191. return errors
  192. def find_errors(group):
  193. """Evaluates whether or not a group has any errors.
  194. """
  195. in_both = group.fs_files.intersection(group.list_files)
  196. in_both = in_both | group.ignored_files
  197. just_fs = group.fs_files - in_both
  198. just_list = group.list_files - in_both
  199. if just_fs or just_list:
  200. sys.stderr.write('%s had errors:\n' % group.list_file)
  201. for filename in sorted(just_fs):
  202. sys.stderr.write(' %s: missing from CMakeLists.txt\n' % filename)
  203. for filename in sorted(just_list):
  204. sys.stderr.write(' %s: missing from filesystem\n' % filename)
  205. return len(just_fs) + len(just_list)
  206. def is_source_file(filename):
  207. ext = os.path.splitext(filename)[1]
  208. return ext in _INCLUDE_EXTENSIONS
  209. def possible_cmake_lists_files(filename):
  210. """Finds CMakeLists.txt files that might apply to the given filename.
  211. Args:
  212. filename: A source file
  213. Yields:
  214. A sequence of CMakeLists.txt filenames that might govern the source file,
  215. starting in the directory containing the given filename and working up
  216. toward the filesystem root. The filenames may point to files that don't
  217. exist.
  218. """
  219. while filename:
  220. filename = os.path.dirname(filename)
  221. yield os.path.join(filename, 'CMakeLists.txt')
  222. def trace(line):
  223. if _verbose:
  224. sys.stderr.write('%s\n' % line)
  225. if __name__ == '__main__':
  226. main(sys.argv[1:])