blob: 118e8249d055801e3b4fd99f5431dc1485c2f59e [file] [log] [blame]
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Copyright 2019 The Chromium OS Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Linter for checking GN files used in platform2 projects."""
# This linter utilizes the token tree parser of the gn binary.
# For example,
#
# executable("my_target") {
# sources = [ "foo.cc", "bar.cc" ]
# }
#
# is parsed to a token tree by gn-format subcommand, like this:
#
# BLOCK
# FUNCTION(executable)
# LIST
# LITERAL("my_target")
# BLOCK
# BINARY(=)
# IDENTIFIER(sources)
# LIST
# LITERAL("foo.cc")
# LITERAL("bar.cc")
#
# The above example is expressed as a JSON like this and imported into dict:
# {
# "type": "BLOCK",
# "child": [ {
# "type": "FUNCTION",
# "value": "executable"
# "child": [ {
# "type": "LIST",
# "child": [ {
# "type": "LITERAL"],
# "value": "\"my_target\""
# } ]
# }, {
# "type": "BLOCK",
# "child": [ {
# "type": "BINARY",
# "value": "=",
# "child": [ {
# "type": "IDENTIFIER",
# "value": "sources"
# }, {
# "type": "LIST",
# "child": [ {
# "type": "LITERAL",
# "value": "\"foo.cc\""
# }, {
# "type": "LITERAL",
# "value": "\"bar.cc\""
# } ]
# } ]
# } ]
# } ]
# } ]
# }
# The tree structure is expressed by "child" key having list of nodes in it.
# Every dict in the nested structure represents a single node.
from __future__ import print_function
import collections
import json
import os
import re
import subprocess
import sys
# Find chromite!
chromite_root = os.path.join(os.path.dirname(os.path.realpath(__file__)),
'..', '..', '..')
sys.path.insert(0, chromite_root)
# pylint: disable=wrong-import-position
from chromite.lib import commandline
from chromite.lib import cros_build_lib
from chromite.lib import cros_logging as logging
# pylint: enable=wrong-import-position
# Object holding the result of a lint check.
LintResult = collections.namedtuple('LintResult', (
# The name of the linter checking.
'linter',
# The file the issue was found in.
'file',
# The message for this check.
'msg',
# The type of result -- logging.ERROR or logging.WARNING.
'type',
))
def FilterFiles(files, extensions):
"""Filter out |files| based on |extensions|."""
for f in files:
# Chop of the leading period as we'll get back ".bin".
extension = os.path.splitext(f)[1][1:]
if extension not in extensions or os.path.basename(f).startswith('.'):
logging.debug('Skipping %s', f)
continue
yield f
def FilterPaths(paths, extensions):
"""Walk |paths| recursively and filter out content in it."""
for path in paths:
if os.path.isdir(path):
for root, _, files in os.walk(path):
for gnfile in FilterFiles(files, extensions):
yield os.path.join(root, gnfile)
else:
for gnfile in FilterFiles([path], extensions):
yield gnfile
def GetParser():
"""Return an argument parser."""
parser = commandline.ArgumentParser(description=__doc__)
parser.add_argument('--extensions', default='gn,gni',
help='Comma delimited file extensions to check. '
'(default: %(default)s)')
parser.add_argument('files', nargs='*',
help='Files to run lint.')
return parser
def CheckFormat(gnfile):
"""Check if the .gn file is formatted in the standard way by gn format."""
issues = []
linter = 'CheckFormat'
try:
gn_path = os.path.join(chromite_root, 'chroot', 'usr', 'bin', 'gn')
result = cros_build_lib.run([gn_path, 'format', '--dry-run', gnfile],
check=False, debug_level=logging.DEBUG)
except cros_build_lib.RunCommandError as e:
issues.append(LintResult(
linter, gnfile, 'Failed to run gn format: %s' % e, logging.ERROR))
else:
if result.returncode == 0:
# successful format, matches on disk.
pass
elif result.returncode == 1:
issues.append(LintResult(
linter, gnfile, 'General failure while running gn format '
'(e.g. parse error)', logging.ERROR))
elif result.returncode == 2:
issues.append(LintResult(
linter, gnfile,
'Needs reformatting. Run following command: %s format %s' %
(gn_path, gnfile), logging.ERROR))
else:
issues.append(LintResult(
linter, gnfile, 'Unknown error with gn format: '
'returncode=%i error=%s output=%s' %
(result.returncode, result.stderr, result.stdout), logging.ERROR))
return issues
def WalkGn(functor, node):
"""Walk the token tree under |node|, calling |functor| on each node.
Args:
functor: A function to be applied.
node: A dict representing a token subtree containing the target nodes.
"""
if not isinstance(node, dict):
logging.warning('Reached non-dict node. Skipping: %s', node)
return
functor(node)
for n in node.get('child', []):
WalkGn(functor, n)
def Unquote(string_with_quotes):
"""Returns the content of a quoted string.
Args:
string_with_quotes: String containing double quote characters at the start
and the end.
Returns:
String with the double-quote characters stripped, or the original string
if it's not quoted.
"""
if (len(string_with_quotes) < 2 or
not string_with_quotes.startswith('"') or
not string_with_quotes.endswith('"')):
logging.error('Quoted string expected, but found: %s', string_with_quotes)
return string_with_quotes
return string_with_quotes[1:-1]
def ExtractLiteralAssignment(node, target_variable_names,
operators=None):
"""Returns list of literals assigned, added or removed to either variable.
If |node| assigns, adds or removes string literal values by a list to either
of the target variable, returns list of all strings literals. Otherwise
returns an empty list.
Args:
node: A dict representing a token subtree.
target_variable_names: List of strings representing variable names to be
detected for its modification.
operators: Optional list of assignment operators to detect. Defaults to
['=', '+=', '-='].
Returns:
List of strings used with the assignment operators to either variable.
"""
if operators is None:
operators = ['=', '+=', '-=']
if node.get('type') != 'BINARY' or node.get('value') not in operators:
return []
# Detected pattern is like:
# BINARY(=)
# IDENTIFIER(ldflags)
# LIST
# LITERAL("-l")
child = node.get('child')
# BINARY assignment node should have LHS and RHS.
if not isinstance(child, list) or len(child) != 2:
logging.warning('Unexpected tree structure. Skipping: %s', node)
return []
if child[0].get('value') not in target_variable_names:
return []
if child[1].get('type') != 'LIST':
return []
literals = []
for element in child[1].get('child'):
if element.get('type') != 'LITERAL':
continue
# Literal nodes of a string value contains double quotes.
literals.append(Unquote(element.get('value')))
return literals
def FindAllLiteralAssignments(node, target_variable_names,
operators=None):
"""Lists all potential literal assignment to variable."""
literals = []
def CheckNode(node):
literals.extend(ExtractLiteralAssignment(
node, target_variable_names, operators))
WalkGn(CheckNode, node)
return literals
ANY_CONFIGS = ['configs', 'public_configs', 'all_dependent_configs']
def GnLintLibFlags(gndata):
"""-lfoo flags belong in 'libs' and not 'ldflags'.
Args:
gndata: A dict representing a token tree.
Returns:
List of detected LintResult.
"""
def CheckNode(node):
flags = ExtractLiteralAssignment(node, ['ldflags'])
for flag in flags:
if flag.startswith('-l'):
issues.append(('Libraries should be specified by "libs", '
'not -l flags in "ldflags": %s') % flag)
issues = []
WalkGn(CheckNode, gndata)
return issues
def GnLintVisibilityFlags(gndata):
"""Packages should not change -fvisibility settings.
Args:
gndata: A dict representing a token tree.
Returns:
List of detected LintResult.
"""
def CheckNode(node):
flags = ExtractLiteralAssignment(node, ['cflags', 'cflags_c', 'cflags_cc'])
for flag in flags:
if flag.startswith('-fvisibility'):
issues.append('do not use -fvisibility; to export symbols, use '
'brillo/brillo_export.h instead')
configs = ExtractLiteralAssignment(node, ANY_CONFIGS)
if '//common-mk:visibility_default' in configs:
issues.append('do not use //common-mk:visibility_default; to export '
'symbols, use brillo/brillo_export.h instead')
issues = []
WalkGn(CheckNode, gndata)
return issues
def GnLintDefineFlags(gndata):
"""-D flags should be in 'defines', not cflags.
Args:
gndata: A dict representing a token tree.
Returns:
List of detected LintResult.
"""
def CheckNode(node):
flags = ExtractLiteralAssignment(node, ['cflags', 'cflags_c', 'cflags_cc'])
for flag in flags:
if flag.startswith('-D'):
issues.append('-D flags should be in "defines": %s' % flag)
issues = []
WalkGn(CheckNode, gndata)
return issues
def GnLintDefines(gndata):
"""Flags in 'defines' should have valid names.
Args:
gndata: A dict representing a token tree.
Returns:
List of detected LintResult.
"""
def CheckNode(node):
flags = ExtractLiteralAssignment(node, ['defines'])
for flag in flags:
if flag.startswith('-D'):
# People sometimes typo the name.
if flag.startswith('-D'):
issues.append('defines do not use -D prefixes: use "%s" instead of '
'"%s"' % (flag[2:], flag))
else:
# Make sure the name is valid CPP.
name = flag.split('=', 1)[0]
if not re.match(r'^[a-zA-Z0-9_]+$', name):
issues.append('invalid define name: %s' % (name,))
issues = []
WalkGn(CheckNode, gndata)
return issues
def GnLintCommonTesting(gndata):
"""Packages should use //common-mk:test instead of -lgtest/-lgmock.
Args:
gndata: A dict representing a token tree.
Returns:
List of detected LintResult.
"""
def CheckNode(node):
flags = ExtractLiteralAssignment(node, ['libs'])
if 'gtest' in flags or 'gmock' in flags:
issues.append('use //common-mk:test for tests instead of '
'linking against -lgtest/-lgmock directly')
issues = []
WalkGn(CheckNode, gndata)
return issues
# Helper functions for GnLintStaticSharedLibMixing.
def IsFunctionNode(node):
"""Returns True if the node type is FUNCTION."""
if not isinstance(node, dict):
logging.warning('Reached non-dict node. Skipping: %s', node)
return False
return node.get('type') == 'FUNCTION'
def GnLintStaticSharedLibMixing(gndata):
"""Static libs linked into shared libs need special PIC handling.
Normally static libs are built using PIE because they only get linked into
PIEs. But if someone tries linking the static libs into a shared lib, we
need to make sure the code is built using PIC.
Note: We don't do an inverse check (PIC static libs not used by shared libs)
as the static libs might be installed by the ebuild. Not that we want to
encourage that situation, but it is what it is ...
Args:
gndata: A dict representing a token tree of a GN file.
Returns:
List of detected issues.
"""
# Record static_libs that build as PIE, and all the deps of shared_libs.
# Afterwards, we'll sanity check all the shared lib deps.
pie_static_libs = []
shared_lib_deps = {}
def ProcessFunctionNode(node):
"""Scans content of a function node and memorize if PIC/PIE."""
if not IsFunctionNode(node):
return
child = node.get('child', [])
if len(child) != 2:
return
# 1st child of FUNCTION node is the name of the function.
# We only check for a simple literal node name.
# For example:
# FUNCTION(static_library)
# LIST
# LITERAL("my_static_library")
# BLOCK
# BINARY(+=)
# IDENTIFIER(configs)
# LIST
# LITERAL("//common-mk:pic")
# BINARY(-=)
# IDENTIFIER(configs)
# LIST
# LITERAL("//common-mk:pie")
name_expression, block = child
if len(name_expression.get('child', [])) != 1:
return
name_literal = name_expression['child'][0]
if name_literal.get('type') != 'LITERAL':
return
name = name_literal.get('value')
if name is None:
return
name = Unquote(name)
target_type = node.get('value')
if target_type == 'static_library':
configs = FindAllLiteralAssignments(block, ANY_CONFIGS, ['+='])
removed_configs = FindAllLiteralAssignments(block, ANY_CONFIGS, ['-='])
if ('//common-mk:pie' not in removed_configs or
'//common-mk:pic' not in configs):
pie_static_libs.append(name)
elif target_type == 'shared_library':
assert name not in shared_lib_deps, 'duplicate target: %s' % name
deps = ExtractLiteralAssignment(block.get('child')[0], 'deps')
shared_lib_deps[name] = [
t.lstrip(':') for t in deps if t.startswith(':')]
# We build up the full state first rather than check it as we go as gyp
# files do not force target ordering.
WalkGn(ProcessFunctionNode, gndata)
# Now with the full state, run the checks.
ret = []
for pie_lib in pie_static_libs:
# Pull out all shared libs that depend on static PIE libs.
dependency_libs = [
shared_lib for shared_lib, deps in shared_lib_deps.items()
if pie_lib in deps
]
if dependency_libs:
ret.append(('static library "%(pie)s" must be compiled as PIC, not PIE, '
'because it is linked into the shared libraries %(pic)s; '
'add this to the "%(pie)s" target to fix:\n'
'configs += [\"//common-mk:pic\"]\n'
'configs -= [\"//common-mk:pie\"]')
% {'pie': pie_lib, 'pic': dependency_libs})
return ret
# The regex used to find gnlint options in the file.
# This matches the regex pylint uses.
OPTIONS_RE = re.compile(r'^\s*#.*\bgnlint:\s*([^\n;]+)', flags=re.MULTILINE)
# Object holding linter settings.
LintSettings = collections.namedtuple('LintSettings', (
# Linters to skip.
'skip',
# Problems we found in the lint settings themselves.
'issues',
))
# The regex used to find unit test source files having wrong suffix.
UNITTEST_SOURCE_RE = re.compile(r'_unittest\.(cc|c|h)$')
def GnLintSourceFileNames(gndata):
"""Enforce various filename conventions."""
ret = []
def CheckNode(node):
sources = ExtractLiteralAssignment(node, ['sources'])
for path in sources:
# Enforce xxx_test.cc naming.
if UNITTEST_SOURCE_RE.search(path):
ret.append('%s: rename unittest file to "%s"' %
(path, path.replace('_unittest', '_test')))
WalkGn(CheckNode, gndata)
return ret
# It's not easy to auto-discover pkg-config files as we don't require a chroot
# or a fully installed sysroot to run this linter. Plus, there's no clean way
# to correlate -lfoo names with pkg-config .pc file names. List the packages
# that we tend to use in platform2 projects.
KNOWN_PC_FILES = {
'blkid': 'blkid',
'cap': 'libcap',
'crypto': 'libcrypto',
'dbus-1': 'dbus-1',
'dbus-c++-1': 'dbus-c++-1',
'dbus-glib-1': 'dbus-glib-1',
'expat': 'expat',
'fuse': 'fuse',
'glib-2.0': 'glib-2.0',
'gobject-2.0': 'gobject-2.0',
'gthread-2.0': 'gthread-2.0',
'minijail': 'libminijail',
'pcre': 'libpcre',
'pcrecpp': 'libpcrecpp',
'pcreposix': 'libpcreposix',
'protobuf': 'protobuf',
'protobuf-lite': 'protobuf-lite',
'ssl': 'libssl',
'udev': 'libudev',
'usb-1.0': 'libusb-1.0',
'uuid': 'uuid',
'vboot_host': 'vboot_host',
'z': 'zlib',
}
KNOWN_PC_LIBS = frozenset(KNOWN_PC_FILES.keys())
def GnLintPkgConfigs(gndata):
"""Use pkg-config files for known libs instead of adding to libs."""
ret = []
def CheckNode(node):
# detect addition to libraries.
# ldflags is already detected as errors by GnLintLibFlags.
for v in KNOWN_PC_LIBS & set(ExtractLiteralAssignment(node, ['libs'])):
ret.append(('use pkg-config instead: delete "%s" from "libs" and add '
'"%s" to either "pkg_deps", "public_pkg_deps", or '
'"all_dependent_pkg_deps"') % (v, KNOWN_PC_FILES[v]))
WalkGn(CheckNode, gndata)
return ret
def ParseOptions(options, name=None):
"""Parse out the linter settings from |options|.
Currently we support:
disable=<linter name>
Args:
options: A list of linter options (e.g. ['foo=bar']).
name: The file we're parsing.
Returns:
A LintSettings object.
"""
skip = set()
issues = []
# Parse all the gnlint directives.
for option in options:
key, value = option.split('=', 1)
key = key.strip()
value = value.strip()
# Parse each sub-option.
if key == 'disable':
skip.update(x.strip() for x in value.split(','))
else:
issues.append(
LintResult('ParseOptions', name,
'unknown gnlint option: %s' % (key,), logging.ERROR))
# Validate the options.
all_linters = FindLinters([])
bad_linters = skip - set(all_linters.keys())
if bad_linters:
issues.append(
LintResult('ParseOptions', name, 'unknown linters: %s' % (bad_linters,),
logging.ERROR))
return LintSettings(skip, issues)
_ALL_LINTERS = {
'GnLintLibFlags': GnLintLibFlags,
'GnLintVisibilityFlags': GnLintVisibilityFlags,
'GnLintDefineFlags': GnLintDefineFlags,
'GnLintDefines': GnLintDefines,
'GnLintCommonTesting': GnLintCommonTesting,
'GnLintStaticSharedLibMixing': GnLintStaticSharedLibMixing,
'GnLintSourceFileNames': GnLintSourceFileNames,
'GnLintPkgConfigs': GnLintPkgConfigs,
}
def FindLinters(skip):
"""Return all linters excluding ones in |skip|.
Args:
skip: A string list of linter names to be skipped.
Returns:
A dict of linters, in which the key is the name and the value is the
linter function.
"""
return {name: f for name, f in _ALL_LINTERS.items() if name not in skip}
def RunLinters(name, gndata, settings=None):
"""Run linters agains |gndata|.
Args:
name: A string representing the filename. For printing issues.
gndata: A dict representing a token tree. See the comment in "Linters and
its helper functions" section for details.
settings: An optional LintSettings object.
Returns:
List of detected LintResult.
"""
issues = []
if settings is None:
settings = ParseOptions([])
issues += settings.issues
for linter_name, linter in FindLinters(settings.skip).items():
for result in linter(gndata):
issues.append(LintResult(linter_name, name, result, logging.ERROR))
return issues
def GetGnPath():
"""Get path to gn executable.
Returns:
Path string.
"""
return os.path.join(chromite_root, 'chroot', 'usr', 'bin', 'gn')
def CheckGnFile(gnfile):
"""Check |gnfile| for common mistakes.
Args:
gnfile: A string representing the filename of the GN file.
Returns:
List of detected LintResult.
"""
if not os.path.exists(gnfile):
# The file has been deleted.
return
issues = []
issues += CheckFormat(gnfile)
# Parse the gnlint flags in the file.
with open(gnfile) as fp:
data = fp.read()
settings = ParseOptions(OPTIONS_RE.findall(data))
issues += settings.issues
# Parse and check
gn_path = GetGnPath()
if not os.path.exists(gn_path):
logging.error(
'gn command not found: "%s"; make sure it is installed.', gn_path)
sys.exit(1)
try:
command_result = cros_build_lib.run(
[gn_path, 'format', '--dump-tree=json', gnfile],
stderr=subprocess.STDOUT, stdout=True, debug_level=logging.DEBUG)
except cros_build_lib.RunCommandError as e:
issues.append(LintResult('gn.input.CheckedEval', gnfile,
'Failed to run gn format: %s' % e, logging.ERROR))
return issues
try:
data = json.loads(command_result.stdout)
except Exception as e:
issues.append(LintResult('gn.input.CheckedEval', gnfile,
'invalid format: %s' % e, logging.ERROR))
return issues
issues += RunLinters(gnfile, data, settings)
return issues
def main(argv):
parser = GetParser()
opts = parser.parse_args(argv)
if not opts.files:
logging.warning('No files provided to lint. Doing nothing.')
return 0
extensions = set(opts.extensions.split(','))
num_files = 0
for gnfile in FilterPaths(opts.files, extensions):
logging.debug('Checking %s', gnfile)
issues = CheckGnFile(gnfile)
if issues:
logging.error('**** %s: found %i issue(s)', gnfile, len(issues))
for issue in issues:
logging.log(issue.type, '%s: %s', issue.linter, issue.msg)
num_files += 1
if num_files:
logging.error('%i file(s) failed linting', num_files)
return 1 if num_files else 0
if __name__ == '__main__':
commandline.ScriptWrapperMain(lambda _: main)