#!/usr/bin/env python # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. from __future__ import absolute_import import argparse import fileinput import os import re import subprocess import sys from bashate import messages MESSAGES = messages.MESSAGES def not_continuation(line): return not re.search('\\\\$', line) def check_for_do(line, report): if not_continuation(line): match = re.match('^\s*(for|while|until)\s', line) if match: operator = match.group(1).strip() if operator == "for": # "for i in ..." and "for ((" is bash, but # "for (" is likely from an embedded awk script, # so skip it if re.search('for \([^\(]', line): return if not re.search(';\s*do$', line): report.print_error((MESSAGES['E010'].msg % operator), line) def check_if_then(line, report): if not_continuation(line): if re.search('^\s*(el)?if \[', line): if not re.search(';\s*then$', line): report.print_error(MESSAGES['E011'].msg, line) def check_no_trailing_whitespace(line, report): if re.search('[ \t]+$', line): report.print_error(MESSAGES['E001'].msg, line) def check_no_long_lines(line, report): if len(line.rstrip("\r\n")) > 79: report.print_error(MESSAGES['E006'].msg, line) def check_indents(line, report): m = re.search('^(?P[ \t]+)', line) if m: if re.search('\t', m.group('indent')): report.print_error(MESSAGES['E002'].msg, line) elif (len(m.group('indent')) % 4) != 0: report.print_error(MESSAGES['E003'].msg, line) def check_function_decl(line, report): failed = False if line.startswith("function"): if not re.search('^function [\w-]* \{$', line): failed = True else: # catch the case without "function", e.g. # things like '^foo() {' if re.search('^\s*?\(\)\s*?\{', line): failed = True if failed: report.print_error(MESSAGES['E020'].msg, line) def starts_multiline(line): m = re.search("[^<]<<\s*(?P\w+)", line) return m.group('token') if m else False def end_of_multiline(line, token): return token and re.search("^%s\s*$" % token, line) def check_arithmetic(line, report): if "$[" in line: report.print_error(MESSAGES['E041'].msg, line) def check_local_subshell(line, report): if line.lstrip().startswith('local ') and \ ("=$(" in line or "=`" in line): report.print_error(MESSAGES['E042'].msg, line) def check_hashbang(line, filename, report): # this check only runs on the first line # maybe this should check for shell? if not line.startswith("#!") and not filename.endswith(".sh"): report.print_error(MESSAGES['E005'].msg, line) def check_syntax(filename, report): # get bash to check the syntax, parse the output for line numbers # and syntax errors to send to the report. syntax_pattern = re.compile('^.*?: line ([0-9]+): (.*)$') bash_environment = os.environ bash_environment['LC_ALL'] = 'C' proc = subprocess.Popen( ['bash', '-n', filename], stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=bash_environment) outputs = proc.communicate() if proc.returncode != 0: syntax_errors = [ line for line in outputs[1].split('\n') if 'syntax error' in line] for line in syntax_errors: groups = syntax_pattern.match(line).groups() error_message = groups[1] lineno = int(groups[0]) msg = '%s: %s' % (MESSAGES['E040'].msg, error_message) report.print_error(msg, filename=filename, filelineno=lineno) class BashateRun(object): def __init__(self): self.error_count = 0 self.error_list = None self.ignore_list = None self.warning_count = 0 self.warning_list = None def register_ignores(self, ignores): if ignores: self.ignore_list = '^(' + '|'.join(ignores.split(',')) + ')' def register_warnings(self, warnings): if warnings: self.warning_list = '^(' + '|'.join(warnings.split(',')) + ')' def register_errors(self, errors): if errors: self.error_list = '^(' + '|'.join(errors.split(',')) + ')' def should_ignore(self, error): return self.ignore_list and re.search(self.ignore_list, error) def should_warn(self, error): # if in the errors list, overrides warning level if self.error_list and re.search(self.error_list, error): return False if messages.is_default_warning(error): return True return self.warning_list and re.search(self.warning_list, error) def print_error(self, error, line='', filename=None, filelineno=None): if self.should_ignore(error): return warn = self.should_warn(error) if not filename: filename = fileinput.filename() if not filelineno: filelineno = fileinput.filelineno() if warn: self.warning_count = self.warning_count + 1 else: self.error_count = self.error_count + 1 self.log_error(error, line, filename, filelineno, warn) def log_error(self, error, line, filename, filelineno, warn=False): print("[%(warn)s] %(error)s: '%(line)s'" % {'warn': "W" if warn else "E", 'error': error, 'line': line.rstrip('\n')}) print(" - %s : L%s" % (filename, filelineno)) def check_files(self, files, verbose): in_multiline = False multiline_start = 0 multiline_line = "" logical_line = "" token = False prev_file = None prev_line = "" prev_lineno = 0 # NOTE(mrodden): magic; replace with proper # report class when necessary report = self for fname in files: # simple syntax checking, as files can pass style but still cause # syntax errors when you try to run them. check_syntax(fname, report) for line in fileinput.input(fname): if fileinput.isfirstline(): # if in_multiline when the new file starts then we didn't # find the end of a heredoc in the last file. if in_multiline: report.print_error( MESSAGES['E012'].msg, multiline_line, filename=prev_file, filelineno=multiline_start) in_multiline = False # last line of a previous file should always end with a # newline if prev_file and not prev_line.endswith('\n'): report.print_error( MESSAGES['E004'].msg, prev_line, filename=prev_file, filelineno=prev_lineno) prev_file = fileinput.filename() check_hashbang(line, fileinput.filename(), report) if verbose: print("Running bashate on %s" % fileinput.filename()) # NOTE(sdague): multiline processing of heredocs is interesting if not in_multiline: logical_line = line token = starts_multiline(line) if token: in_multiline = True multiline_start = fileinput.filelineno() multiline_line = line continue else: logical_line = logical_line + line if not end_of_multiline(line, token): continue else: in_multiline = False # Don't run any tests on comment lines if logical_line.lstrip().startswith('#'): prev_line = logical_line prev_lineno = fileinput.filelineno() continue # Strip trailing comments. From bash: # # a word beginning with # causes that word and all # remaining characters on that line to be ignored. # ... # A character that, when unquoted, separates # words. One of the following: | & ; ( ) < > space # tab # # for simplicity, we strip inline comments by # matching just '#'. ll_split = logical_line.split(' #', 1) if len(ll_split) > 1: logical_line = ll_split[0].rstrip() check_no_trailing_whitespace(logical_line, report) check_no_long_lines(logical_line, report) check_indents(logical_line, report) check_for_do(logical_line, report) check_if_then(logical_line, report) check_function_decl(logical_line, report) check_arithmetic(logical_line, report) check_local_subshell(logical_line, report) prev_line = logical_line prev_lineno = fileinput.filelineno() def main(): parser = argparse.ArgumentParser( description='A bash script style checker') parser.add_argument('files', metavar='file', nargs='*', help='files to scan for errors') parser.add_argument('-i', '--ignore', help='Rules to ignore') parser.add_argument('-w', '--warn', help='Rules to always warn (rather than error)') parser.add_argument('-e', '--error', help='Rules to always error (rather than warn)') parser.add_argument('-v', '--verbose', action='store_true', default=False) parser.add_argument('-s', '--show', action='store_true', default=False) opts = parser.parse_args() if opts.show: messages.print_messages() sys.exit(0) files = opts.files if not files: parser.print_usage() return 1 run = BashateRun() run.register_ignores(opts.ignore) run.register_warnings(opts.warn) run.register_errors(opts.error) try: run.check_files(files, opts.verbose) except IOError as e: print("bashate: %s" % e) return 1 if run.warning_count > 0: print("%d bashate warning(s) found" % run.warning_count) if run.error_count > 0: print("%d bashate error(s) found" % run.error_count) return 1 return 0 if __name__ == "__main__": sys.exit(main())