# # linter.py # Part of SublimeLinter3, a code checking framework for Sublime Text 3 # # Written by Ryan Hileman and Aparajita Fishman # # Project: https://github.com/SublimeLinter/SublimeLinter3 # License: MIT # """ This module exports linter-related classes. LinterMeta Metaclass for Linter classes that does setup when they are loaded. Linter The main base class for linters. """ from distutils.versionpredicate import VersionPredicate from fnmatch import fnmatch from functools import lru_cache import html.entities from numbers import Number import os import re import shlex import sublime from xml.sax.saxutils import unescape from . import highlight, persist, util # # Private constants # ARG_RE = re.compile(r'(?P@|--?)?(?P[@\w][\w\-]*)(?:(?P[=:])(?:(?P.)(?P\+)?)?)?') BASE_CLASSES = ('PythonLinter',) HTML_ENTITY_RE = re.compile(r'&(?:(?:#(x)?([0-9a-fA-F]{1,4}))|(\w+));') class LinterMeta(type): """Metaclass for Linter and its subclasses.""" def __init__(cls, name, bases, attrs): """ Initialize a Linter class. When a Linter subclass is loaded by Sublime Text, this method is called. We take this opportunity to do some transformations: - Compile regex patterns. - Convert strings to tuples where necessary. - Add a leading dot to the tempfile_suffix if necessary. - Build a map between defaults and linter arguments. - Add '@python' as an inline setting to PythonLinter subclasses. Finally, the class is registered as a linter for its configured syntax. """ if bases: setattr(cls, 'disabled', False) if name in ('PythonLinter', 'RubyLinter'): return cls.alt_name = cls.make_alt_name(name) cmd = attrs.get('cmd') if isinstance(cmd, str): setattr(cls, 'cmd', shlex.split(cmd)) syntax = attrs.get('syntax') try: if isinstance(syntax, str) and syntax[0] == '^': setattr(cls, 'syntax', re.compile(syntax)) except re.error as err: persist.printf( 'ERROR: {} disabled, error compiling syntax: {}' .format(name.lower(), str(err)) ) setattr(cls, 'disabled', True) if not cls.disabled: for regex in ('regex', 'comment_re', 'word_re', 'version_re'): attr = getattr(cls, regex) if isinstance(attr, str): if regex == 'regex' and cls.multiline: setattr(cls, 're_flags', cls.re_flags | re.MULTILINE) try: setattr(cls, regex, re.compile(attr, cls.re_flags)) except re.error as err: persist.printf( 'ERROR: {} disabled, error compiling {}: {}' .format(name.lower(), regex, str(err)) ) setattr(cls, 'disabled', True) if not cls.disabled: if not cls.syntax or (cls.cmd is not None and not cls.cmd) or not cls.regex: persist.printf('ERROR: {} disabled, not fully implemented'.format(name.lower())) setattr(cls, 'disabled', True) for attr in ('inline_settings', 'inline_overrides'): if attr in attrs and isinstance(attrs[attr], str): setattr(cls, attr, (attrs[attr],)) # If this class has its own defaults, create an args_map. # Otherwise we use the superclass' args_map. if 'defaults' in attrs and attrs['defaults']: cls.map_args(attrs['defaults']) if 'PythonLinter' in [base.__name__ for base in bases]: # Set attributes necessary for the @python inline setting inline_settings = list(getattr(cls, 'inline_settings') or []) setattr(cls, 'inline_settings', inline_settings + ['@python']) if persist.plugin_is_loaded: # If the plugin has already loaded, then we get here because # a linter was added or reloaded. In that case we run reinitialize. cls.reinitialize() if 'syntax' in attrs and name not in BASE_CLASSES: persist.register_linter(cls, name, attrs) def map_args(cls, defaults): """ Map plain setting names to args that will be passed to the linter executable. For each item in defaults, the key is matched with ARG_RE. If there is a match, the key is stripped of meta information and the match groups are stored as a dict under the stripped key. """ # Check if the settings specify an argument. # If so, add a mapping between the setting and the argument format, # then change the name in the defaults to the setting name. args_map = {} setattr(cls, 'defaults', {}) for name, value in defaults.items(): match = ARG_RE.match(name) if match: name = match.group('name') args_map[name] = match.groupdict() cls.defaults[name] = value setattr(cls, 'args_map', args_map) @staticmethod def make_alt_name(name): """Convert and return a camel-case name to lowercase with dashes.""" previous = name[0] alt_name = previous.lower() for c in name[1:]: if c.isupper() and previous.islower(): alt_name += '-' alt_name += c.lower() previous = c return alt_name @property def name(cls): """Return the class name lowercased.""" return cls.__name__.lower() class Linter(metaclass=LinterMeta): """ The base class for linters. Subclasses must at a minimum define the attributes syntax, cmd, and regex. """ # # Public attributes # # The syntax that the linter handles. May be a string or # list/tuple of strings. Names should be all lowercase. syntax = '' # A string, list, tuple or callable that returns a string, list or tuple, containing the # command line (with arguments) used to lint. cmd = '' # If the name of the executable cannot be determined by the first element of cmd # (for example when cmd is a method that dynamically generates the command line arguments), # this can be set to the name of the executable used to do linting. # # Once the executable's name is determined, its existence is checked in the user's path. # If it is not available, the linter is disabled. executable = None # If the executable is available, this is set to the full path of the executable. # If the executable is not available, it is set an empty string. # Subclasses should consider this read only. executable_path = None # Some linter plugins have version requirements as far as the linter executable. # The following three attributes can be defined to define the requirements. # version_args is a string/list/tuple that represents the args used to get # the linter executable's version as a string. version_args = None # A regex pattern or compiled regex used to match the numeric portion of the version # from the output of version_args. It must contain a named capture group called # "version" that captures only the version, including dots but excluding a prefix # such as "v". version_re = None # A string which describes the version requirements, suitable for passing to # the distutils.versionpredicate.VersionPredicate constructor, as documented here: # http://pydoc.org/2.5.1/distutils.versionpredicate.html # Only the version requirements (what is inside the parens) should be # specified here, do not include the package name or parens. version_requirement = None # A regex pattern used to extract information from the executable's output. regex = '' # Set to True if the linter outputs multiline error messages. When True, # regex will be created with the re.MULTILINE flag. Do NOT rely on setting # the re.MULTILINE flag within the regex yourself, this attribute must be set. multiline = False # If you want to set flags on the regex *other* than re.MULTILINE, set this. re_flags = 0 # The default type assigned to non-classified errors. Should be either # highlight.ERROR or highlight.WARNING. default_type = highlight.ERROR # Linters usually report errors with a line number, some with a column number # as well. In general, most linters report one-based line numbers and column # numbers. If a linter uses zero-based line numbers or column numbers, the # linter class should define this attribute accordingly. line_col_base = (1, 1) # If the linter executable cannot receive from stdin and requires a temp file, # set this attribute to the suffix of the temp file (with or without leading '.'). # If the suffix needs to be mapped to the syntax of a file, you may make this # a dict that maps syntax names (all lowercase, as used in the syntax attribute), # to tempfile suffixes. The syntax used to lookup the suffix is the mapped # syntax, after using "syntax_map" in settings. If the view's syntax is not # in this map, the class' syntax will be used. # # Some linters can only work from an actual disk file, because they # rely on an entire directory structure that cannot be realistically be copied # to a temp directory (e.g. javac). In such cases, set this attribute to '-', # which marks the linter as "file-only". That will disable the linter for # any views that are dirty. tempfile_suffix = None # Linters may output to both stdout and stderr. By default stdout and sterr are captured. # If a linter will never output anything useful on a stream (including when # there is an error within the linter), you can ignore that stream by setting # this attribute to the other stream. error_stream = util.STREAM_BOTH # Many linters look for a config file in the linted file’s directory and in # all parent directories up to the root directory. However, some of them # will not do this if receiving input from stdin, and others use temp files, # so looking in the temp file directory doesn’t work. If this attribute # is set to a tuple of a config file argument and the name of the config file, # the linter will automatically try to find the config file, and if it is found, # add the config file argument to the executed command. # # Example: config_file = ('--config', '.jshintrc') # config_file = None # Tab width tab_width = 1 # If a linter can be used with embedded code, you need to tell SublimeLinter # which portions of the source code contain the embedded code by specifying # the embedded scope selectors. This attribute maps syntax names # to embedded scope selectors. # # For example, the HTML syntax uses the scope `source.js.embedded.html` # for embedded JavaScript. To allow a JavaScript linter to lint that embedded # JavaScript, you would set this attribute to {'html': 'source.js.embedded.html'}. selectors = {} # If a linter reports a column position, SublimeLinter highlights the nearest # word at that point. You can customize the regex used to highlight words # by setting this to a pattern string or a compiled regex. word_re = None # If you want to provide default settings for the linter, set this attribute. # If a setting will be passed as an argument to the linter executable, # you may specify the format of the argument here and the setting will # automatically be passed as an argument to the executable. The format # specification is as follows: # # [[+]] # # - : Either empty, '@', '-' or '--'. # - : The name of the setting. # - : Either '=' or ':'. If is empty or '@', is ignored. # Otherwise, if '=', the setting value is joined with by '=' and # passed as a single argument. If ':', and the value are passed # as separate arguments. # - : If the argument accepts a list of values, specifies # the character used to delimit the list (usually ','). # - +: If the setting can be a list of values, but each value must be # passed as a separate argument, terminate the setting with '+'. # # After the format is parsed, the prefix and suffix are removed and the # setting is replaced with . defaults = None # Linters may define a list of settings that can be specified inline. # As with defaults, you can specify that an inline setting should be passed # as an argument by using a prefix and optional suffix. However, if # the same setting was already specified as an argument in defaults, # you do not need to use the prefix or suffix here. # # Within a file, the actual inline setting name is '-setting', where # is the lowercase name of the linter class. inline_settings = None # Many linters allow a list of options to be specified for a single setting. # For example, you can often specify a list of errors to ignore. # This attribute is like inline_settings, but inline values will override # existing values instead of replacing them, using the override_options method. inline_overrides = None # If the linter supports inline settings, you need to specify the regex that # begins a comment. comment_re should be an unanchored pattern (no ^) # that matches everything through the comment prefix, including leading whitespace. # # For example, to specify JavaScript comments, you would use the pattern: # r'\s*/[/*]' # and for python: # r'\s*#' comment_re = None # Some linters may want to turn a shebang into an inline setting. # To do so, set this attribute to a callback which receives the first line # of code and returns a tuple/list which contains the name and value for the # inline setting, or None if there is no match. shebang_match = None # # Internal class storage, do not set # RC_SEARCH_LIMIT = 3 errors = None highlight = None lint_settings = None env = None disabled = False executable_version = None @classmethod def initialize(cls): """ Perform class-level initialization. If subclasses override this, they should call super().initialize() first. """ pass @classmethod def reinitialize(cls): """ Perform class-level initialization after plugins have been loaded at startup. This occurs if a new linter plugin is added or reloaded after startup. Subclasses may override this to provide custom behavior, then they must call cls.initialize(). """ cls.initialize() def __init__(self, view, syntax): self.view = view self.syntax = syntax self.code = '' self.highlight = highlight.Highlight() self.ignore_matches = None @property def filename(self): """Return the view's file path or '' of unsaved.""" return self.view.file_name() or '' @property def name(self): """Return the class name lowercased.""" return self.__class__.__name__.lower() @classmethod def clear_settings_caches(cls): """Clear lru caches for this class' methods.""" cls.get_view_settings.cache_clear() cls.get_merged_settings.cache_clear() @classmethod def settings(cls): """Return the default settings for this linter, merged with the user settings.""" if cls.lint_settings is None: linters = persist.settings.get('linters', {}) cls.lint_settings = (cls.defaults or {}).copy() cls.lint_settings.update(linters.get(cls.name, {})) return cls.lint_settings @staticmethod def meta_settings(settings): """Return a dict with the items in settings whose keys begin with '@'.""" return {key: value for key, value in settings.items() if key.startswith('@')} @lru_cache(maxsize=None) def get_view_settings(self, no_inline=False): """ Return a union of all settings specific to this linter, related to the given view. The settings are merged in the following order: default settings user settings project settings user + project meta settings rc settings rc meta settings shebang or inline settings (overrides) """ settings = self.get_merged_settings() if not no_inline: inline_settings = {} if self.shebang_match: eol = self.code.find('\n') if eol != -1: setting = self.shebang_match(self.code[0:eol]) if setting is not None: inline_settings[setting[0]] = setting[1] if self.comment_re and (self.inline_settings or self.inline_overrides): inline_settings.update(util.inline_settings( self.comment_re, self.code, prefix=self.name, alt_prefix=self.alt_name )) settings = self.merge_inline_settings(settings.copy(), inline_settings) return settings @lru_cache(maxsize=None) def get_merged_settings(self): """ Return a union of all non-inline settings specific to this linter, related to the given view. The settings are merged in the following order: default settings user settings project settings user + project meta settings rc settings rc meta settings """ # Start with the overall project settings. Note that when # files are loaded during quick panel preview, it can happen # that they are linted without having a window. window = self.view.window() if window: data = window.project_data() or {} project_settings = data.get(persist.PLUGIN_NAME, {}) else: project_settings = {} # Merge global meta settings with project meta settings meta = self.meta_settings(persist.settings.settings) meta.update(self.meta_settings(project_settings)) # Get the linter's project settings, update them with meta settings project_settings = project_settings.get('linters', {}).get(self.name, {}) project_settings.update(meta) # Update the linter's settings with the project settings settings = self.merge_project_settings(self.settings().copy(), project_settings) # Update with rc settings self.merge_rc_settings(settings) return settings def merge_rc_settings(self, settings): """ Merge .sublimelinterrc settings with settings. Searches for .sublimelinterrc in, starting at the directory of the linter's view. The search is limited to rc_search_limit directories. If found, the meta settings and settings for this linter in the rc file are merged with settings. """ search_limit = persist.settings.get('rc_search_limit', self.RC_SEARCH_LIMIT) rc_settings = util.get_view_rc_settings(self.view, limit=search_limit) if rc_settings: meta = self.meta_settings(rc_settings) rc_settings = rc_settings.get('linters', {}).get(self.name, {}) rc_settings.update(meta) settings.update(rc_settings) def merge_inline_settings(self, view_settings, inline_settings): """ Return view settings merged with inline settings. view_settings is merged with inline_settings specified by the class attributes inline_settings and inline_overrides. view_settings is updated in place and returned. """ for setting, value in inline_settings.items(): if self.inline_settings and setting in self.inline_settings: view_settings[setting] = value elif self.inline_overrides and setting in self.inline_overrides: options = view_settings[setting] sep = self.args_map.get(setting, {}).get('sep') if sep: kwargs = {'sep': sep} options = options or '' else: kwargs = {} options = options or () view_settings[setting] = self.override_options(options, value, **kwargs) return view_settings def merge_project_settings(self, view_settings, project_settings): """ Return this linter's view settings merged with the current project settings. Subclasses may override this if they wish to do something more than replace view settings with inline settings of the same name. The settings object may be changed in place. """ view_settings.update(project_settings) return view_settings def override_options(self, options, overrides, sep=','): """ Return a list of options with overrides applied. If you want inline settings to override but not replace view settings, this method makes it easier. Given a set or sequence of options and some overrides, this method will do the following: - Copies options into a set. - Split overrides into a list if it's a string, using sep to split. - Iterates over each value in the overrides list: - If it begins with '+', the value (without '+') is added to the options set. - If it begins with '-', the value (without '-') is removed from the options set. - Otherwise the value is added to the options set. - The options set is converted to a list and returned. For example, given the options 'E101,E501,W' and the overrides '-E101,E202,-W,+W324', we would end up with 'E501,E202,W324'. """ if isinstance(options, str): options = options.split(sep) if options else () return_str = True else: return_str = False modified_options = set(options) if isinstance(overrides, str): overrides = overrides.split(sep) for override in overrides: if not override: continue elif override[0] == '+': modified_options.add(override[1:]) elif override[0] == '-': modified_options.discard(override[1:]) else: modified_options.add(override) if return_str: return sep.join(modified_options) else: return list(modified_options) @classmethod def assign(cls, view, linter_name=None, reset=False): """ Assign linters to a view. If reset is True, the list of linters for view is completely rebuilt. can_lint for each known linter class is called to determine if the linter class can lint the syntax for view. If so, a new instance of the linter class is assigned to the view, unless linter_name is non-empty and does not match the 'name' attribute of any of the view's linters. Each view has its own linters so that linters can store persistent data about a view. """ vid = view.id() persist.views[vid] = view syntax = persist.get_syntax(view) if not syntax: cls.remove(vid) return view_linters = persist.view_linters.get(vid, set()) linters = set() for name, linter_class in persist.linter_classes.items(): if not linter_class.disabled and linter_class.can_lint(syntax): if reset: instantiate = True else: linter = None for l in view_linters: if name == l.name: linter = l break if linter is None: instantiate = True else: # If there is an existing linter and no linter_name was passed, # leave it. If linter_name was passed, re-instantiate only if # the linter's name matches linter_name. instantiate = linter_name == linter.name if instantiate: linter = linter_class(view, syntax) linters.add(linter) if linters: persist.view_linters[vid] = linters elif reset and not linters and vid in persist.view_linters: del persist.view_linters[vid] @classmethod def remove(cls, vid): """Remove a the mapping between a view and its set of linters.""" if vid in persist.view_linters: for linters in persist.view_linters[vid]: linters.clear() del persist.view_linters[vid] @classmethod def reload(cls): """Assign new instances of linters to views.""" # Merge linter default settings with user settings for name, linter in persist.linter_classes.items(): linter.lint_settings = None for vid, linters in persist.view_linters.items(): for linter in linters: linter.clear() persist.view_linters[vid].remove(linter) linter_class = persist.linter_classes[linter.name] linter = linter_class(linter.view, linter.syntax) persist.view_linters[vid].add(linter) @classmethod def apply_to_all_highlights(cls, action): """Apply an action to the highlights of all views.""" def apply(view): highlights = persist.highlights.get(view.id()) if highlights: getattr(highlights, action)(view) util.apply_to_all_views(apply) @classmethod def clear_all(cls): """Clear highlights and errors in all views.""" cls.apply_to_all_highlights('reset') persist.errors.clear() @classmethod def redraw_all(cls): """Redraw all highlights in all views.""" cls.apply_to_all_highlights('redraw') @classmethod def text(cls, view): """Return the entire text of a view.""" return view.substr(sublime.Region(0, view.size())) @classmethod def get_view(cls, vid): """Return the view object with the given id.""" return persist.views.get(vid) @classmethod def get_linters(cls, vid): """Return a tuple of linters for the view with the given id.""" if vid in persist.view_linters: return tuple(persist.view_linters[vid]) return () @classmethod def get_selectors(cls, vid, syntax): """ Return scope selectors and linters for the view with the given id. For each linter assigned to the view with the given id, if it has selectors, return a tuple of the selector and the linter. """ selectors = [] for linter in cls.get_linters(vid): if syntax in linter.selectors: selectors.append((linter.selectors[syntax], linter)) if '*' in linter.selectors: selectors.append((linter.selectors['*'], linter)) return selectors @classmethod def lint_view(cls, view, filename, code, hit_time, callback): """ Lint the given view. This is the top level lint dispatcher. It is called asynchronously. The following checks are done for each linter assigned to the view: - Check if the linter has been disabled in settings. - Check if the filename matches any patterns in the "excludes" setting. If a linter fails the checks, it is disabled for this run. Otherwise, if the mapped syntax is not in the linter's selectors, the linter is run on the entirety of code. Then the set of selectors for all linters assigned to the view is aggregated, and for each selector, if it occurs in sections, the corresponding section is linted as embedded code. A list of the linters that ran is returned. """ if not code: return vid = view.id() linters = persist.view_linters.get(vid) if not linters: return disabled = set() syntax = persist.get_syntax(persist.views[vid]) for linter in linters: # First check to see if the linter can run in the current lint mode. if linter.tempfile_suffix == '-' and view.is_dirty(): disabled.add(linter) continue # Because get_view_settings is expensive, we use an lru_cache # to cache its results. Before each lint, reset the cache. linter.clear_settings_caches() view_settings = linter.get_view_settings(no_inline=True) # We compile the ignore matches for a linter on each run, # clear the cache first. linter.ignore_matches = None if view_settings.get('@disable'): disabled.add(linter) continue if filename: filename = os.path.realpath(filename) excludes = util.convert_type(view_settings.get('excludes', []), []) if excludes: matched = False for pattern in excludes: if fnmatch(filename, pattern): persist.debug( '{} skipped \'{}\', excluded by \'{}\'' .format(linter.name, filename, pattern) ) matched = True break if matched: disabled.add(linter) continue if syntax not in linter.selectors and '*' not in linter.selectors: linter.reset(code, view_settings) linter.lint(hit_time) selectors = Linter.get_selectors(vid, syntax) for selector, linter in selectors: if linter in disabled: continue linters.add(linter) regions = [] for region in view.find_by_selector(selector): regions.append(region) linter.reset(code, view_settings) errors = {} for region in regions: line_offset, col = view.rowcol(region.begin()) linter.highlight.move_to(line_offset, col) linter.code = code[region.begin():region.end()] linter.errors = {} linter.lint(hit_time) for line, line_errors in linter.errors.items(): errors[line + line_offset] = line_errors linter.errors = errors # Remove disabled linters linters = list(linters - disabled) # Merge our result back to the main thread callback(cls.get_view(vid), linters, hit_time) def compile_ignore_match(self, pattern): """Return the compiled pattern, log the error if compilation fails.""" try: return re.compile(pattern) except re.error as err: persist.printf( 'ERROR: {}: invalid ignore_match: "{}" ({})' .format(self.name, pattern, str(err)) ) return None def compiled_ignore_matches(self, ignore_match): """ Compile the "ignore_match" linter setting as an optimization. If it's a string, return a list with a single compiled regex. If it's a list, return a list of the compiled regexes. If it's a dict, return a list only of the regexes whose key matches the file's extension. """ if isinstance(ignore_match, str): regex = self.compile_ignore_match(ignore_match) return [regex] if regex else [] elif isinstance(ignore_match, list): matches = [] for match in ignore_match: regex = self.compile_ignore_match(match) if regex: matches.append(regex) return matches elif isinstance(ignore_match, dict): if not self.filename: return [] ext = os.path.splitext(self.filename)[1].lower() if not ext: return [] # Try to match the extension, then the extension without the dot ignore_match = ignore_match.get(ext, ignore_match.get(ext[1:])) if ignore_match: return self.compiled_ignore_matches(ignore_match) else: return [] else: return [] def reset(self, code, settings): """Reset a linter to work on the given code and filename.""" self.errors = {} self.code = code self.highlight = highlight.Highlight(self.code) if self.ignore_matches is None: ignore_match = settings.get('ignore_match') if ignore_match: self.ignore_matches = self.compiled_ignore_matches(ignore_match) else: self.ignore_matches = [] @classmethod def which(cls, cmd): """Call util.which with this class' module and return the result.""" return util.which(cmd, module=getattr(cls, 'module', None)) def get_cmd(self): """ Calculate and return a tuple/list of the command line to be executed. The cmd class attribute may be a string, a tuple/list, or a callable. If cmd is callable, it is called. If the result of the method is a string, it is parsed into a list with shlex.split. Otherwise the result of build_cmd is returned. """ if callable(self.cmd): cmd = self.cmd() if isinstance(cmd, str): cmd = shlex.split(cmd) return self.insert_args(cmd) else: return self.build_cmd() def build_cmd(self, cmd=None): """ Return a tuple with the command line to execute. We start with cmd or the cmd class attribute. If it is a string, it is parsed with shlex.split. If the first element of the command line matches [script]@python[version], and '@python' is in the aggregated view settings, util.which is called to determine the path to the script and given version of python. This allows settings to override the version of python used. Otherwise, if self.executable_path has already been calculated, that is used. If not, the executable path is located with util.which. If the path to the executable can be determined, a list of extra arguments is built with build_args. If the cmd contains '*', it is replaced with the extra argument list, otherwise the extra args are appended to cmd. """ cmd = cmd or self.cmd if isinstance(cmd, str): cmd = shlex.split(cmd) else: cmd = list(cmd) which = cmd[0] have_path, path = self.context_sensitive_executable_path(cmd) if have_path: # Returning None means the linter runs code internally if path == '': return None elif self.executable_path: path = self.executable_path if isinstance(path, (list, tuple)) and None in path: path = None else: path = self.which(which) if not path: persist.printf('ERROR: {} cannot locate \'{}\''.format(self.name, which)) return '' cmd[0:1] = util.convert_type(path, []) return self.insert_args(cmd) def context_sensitive_executable_path(self, cmd): """ Calculate the context-sensitive executable path, return a tuple of (have_path, path). Subclasses may override this to return a special path. """ return False, None def insert_args(self, cmd): """Insert user arguments into cmd and return the result.""" args = self.build_args(self.get_view_settings()) cmd = list(cmd) if '*' in cmd: i = cmd.index('*') if args: cmd[i:i + 1] = args else: cmd.pop(i) else: cmd += args return cmd def get_user_args(self, settings=None): """Return any args the user specifies in settings as a list.""" if settings is None: settings = self.get_merged_settings() args = settings.get('args', []) if isinstance(args, str): args = shlex.split(args) else: args = args[:] return args def build_args(self, settings): """ Return a list of args to add to cls.cmd. First any args specified in the "args" linter setting are retrieved. Then the args map (built by map_args during class construction) is iterated. For each item in the args map: - Check to see if the arg is in settings, which is the aggregated default/user/view settings. If arg is not in settings or is a meta setting (beginning with '@'), it is skipped. - If the arg has no prefix, it is skipped. - Get the setting value. If it is None or an empty string/list, skip this arg. - If the setting value is a non-empty list and the arg was specified as taking a single list of values, join the values. - If the setting value is a non-empty string or the boolean True, convert it into a single-element list with that value. Once a list of values is built, iterate over the values to build the args list: - Start with the prefix and arg name. - If the joiner is '=', join '=' and the value and append to the args. - If the joiner is ':', append the arg and value as separate args. Finally, if the config_file attribute is set and the user has not set the config_file arg in the linter's "args" setting, try to locate the config file and if found add the config file arg. Return the arg list. """ args = self.get_user_args(settings) args_map = getattr(self, 'args_map', {}) for setting, arg_info in args_map.items(): prefix = arg_info['prefix'] if setting not in settings or setting[0] == '@' or prefix is None: continue values = settings[setting] if values is None: continue elif isinstance(values, (list, tuple)): if values: # If the values can be passed as a single list, join them now if arg_info['sep'] and not arg_info['multiple']: values = [str(value) for value in values] values = [arg_info['sep'].join(values)] else: continue elif isinstance(values, str): if values: values = [values] else: continue elif isinstance(values, Number): if values is False: continue else: values = [values] else: # Unknown type continue for value in values: if prefix == '@': args.append(str(value)) else: arg = prefix + arg_info['name'] joiner = arg_info['joiner'] if joiner == '=': args.append('{}={}'.format(arg, value)) elif joiner == ':': args.append(arg) args.append(str(value)) if self.config_file: if self.config_file[0] not in args and self.filename: config = util.find_file( os.path.dirname(self.filename), self.config_file[1], aux_dirs=self.config_file[2:] ) if config: args += [self.config_file[0], config] return args def build_options(self, options, type_map, transform=None): """ Build a list of options to be passed directly to a linting method. This method is designed for use with linters that do linting directly in code and need to pass a dict of options. options is the starting dict of options. For each of the settings listed in self.args_map: - See if the setting name is in view settings. - If so, and the value is non-empty, see if the setting name is in type_map. If so, convert the value to the type of the value in type_map. - If transform is not None, pass the name to it and assign to the result. - Add the name/value pair to options. """ view_settings = self.get_view_settings() for name, info in self.args_map.items(): value = view_settings.get(name) if value: value = util.convert_type(value, type_map.get(name), sep=info.get('sep')) if value is not None: if transform: name = transform(name) options[name] = value def lint(self, hit_time): """ Perform the lint, retrieve the results, and add marks to the view. The flow of control is as follows: - Get the command line. If it is an empty string, bail. - Run the linter. - If the view has been modified since the original hit_time, stop. - Parse the linter output with the regex. - Highlight warnings and errors. """ if self.disabled: return if self.filename: cwd = os.getcwd() os.chdir(os.path.dirname(self.filename)) if self.cmd is None: cmd = None else: cmd = self.get_cmd() if cmd is not None and not cmd: return output = self.run(cmd, self.code) if self.filename: os.chdir(cwd) if not output: return # If the view has been modified since the lint was triggered, no point in continuing. if hit_time is not None and persist.last_hit_times.get(self.view.id(), 0) > hit_time: return if persist.debug_mode(): stripped_output = output.replace('\r', '').rstrip() persist.printf('{} output:\n{}'.format(self.name, stripped_output)) for match, line, col, error, warning, message, near in self.find_errors(output): if match and message and line is not None: if self.ignore_matches: ignore = False for ignore_match in self.ignore_matches: if ignore_match.match(message): ignore = True if persist.debug_mode(): persist.printf( '{} ({}): ignore_match: "{}" == "{}"' .format( self.name, os.path.basename(self.filename) or '', ignore_match.pattern, message ) ) break if ignore: continue if error: error_type = highlight.ERROR elif warning: error_type = highlight.WARNING else: error_type = self.default_type if col is not None: # Pin the column to the start/end line offsets start, end = self.highlight.full_line(line) col = max(min(col, (end - start) - 1), 0) # Adjust column numbers to match the linter's tabs if necessary if self.tab_width > 1: code_line = self.code[start:end] diff = 0 for i in range(len(code_line)): if code_line[i] == '\t': diff += (self.tab_width - 1) if col - diff <= i: col = i break if col is not None: self.highlight.range(line, col, near=near, error_type=error_type, word_re=self.word_re) elif near: col = self.highlight.near(line, near, error_type=error_type, word_re=self.word_re) else: if ( persist.settings.get('no_column_highlights_line') or persist.settings.get('gutter_theme') == 'none' ): pos = -1 else: pos = 0 self.highlight.range(line, pos, length=0, error_type=error_type, word_re=self.word_re) self.error(line, col, message, error_type) def draw(self): """Draw the marks from the last lint.""" self.highlight.draw(self.view) @staticmethod def clear_view(view): """Clear marks, status and all other cached error info for the given view.""" view.erase_status('sublimelinter') highlight.Highlight.clear(view) if view.id() in persist.errors: del persist.errors[view.id()] def clear(self): """Clear marks, status and all other cached error info for the given view.""" self.clear_view(self.view) # Helper methods @classmethod @lru_cache(maxsize=None) def can_lint(cls, syntax): """ Determine if a linter class can lint the given syntax. This method is called when a view has not had a linter assigned or when its syntax changes. The following tests must all pass for this method to return True: 1. syntax must match one of the syntaxes the linter defines. 2. If the linter uses an external executable, it must be available. 3. If there is a version requirement and the executable is available, its version must fulfill the requirement. 4. can_lint_syntax must return True. """ can = False syntax = syntax.lower() if cls.syntax: if isinstance(cls.syntax, (tuple, list)): can = syntax in cls.syntax elif cls.syntax == '*': can = True elif isinstance(cls.syntax, str): can = syntax == cls.syntax else: can = cls.syntax.match(syntax) is not None if can: if cls.executable_path is None: executable = '' if not callable(cls.cmd): if isinstance(cls.cmd, (tuple, list)): executable = (cls.cmd or [''])[0] elif isinstance(cls.cmd, str): executable = cls.cmd if not executable and cls.executable: executable = cls.executable if executable: cls.executable_path = cls.which(executable) or '' if ( cls.executable_path is None or (isinstance(cls.executable_path, (tuple, list)) and None in cls.executable_path) ): cls.executable_path = '' elif cls.cmd is None: cls.executable_path = '' else: cls.executable_path = '' status = None if cls.executable_path: can = cls.fulfills_version_requirement() if not can: status = '' # Warning was already printed if can: can = cls.can_lint_syntax(syntax) if can: settings = persist.settings disabled = ( settings.get('@disabled') or settings.get('linters', {}).get(cls.name, {}).get('@disable', False) ) status = '{} activated: {}{}'.format( cls.name, cls.executable_path, ' (disabled in settings)' if disabled else '' ) elif status is None: status = 'WARNING: {} deactivated, cannot locate \'{}\''.format(cls.name, executable) if status: persist.printf(status) return can @classmethod def can_lint_syntax(cls, syntax): """ Return whether a linter can lint a given syntax. Subclasses may override this if the built in mechanism in can_lint is not sufficient. When this method is called, cls.executable_path has been set. If it is '', that means the executable was not specified or could not be found. """ return cls.executable_path != '' @classmethod def fulfills_version_requirement(cls): """ Return whether the executable fulfills version_requirement. When this is called, cls.executable_path has been set. """ cls.executable_version = None if cls.executable_path == '': if callable(getattr(cls, 'get_module_version', None)): if not(cls.version_re and cls.version_requirement): return True cls.executable_version = cls.get_module_version() if cls.executable_version: persist.debug('{} version: {}'.format(cls.name, cls.executable_version)) else: persist.printf('WARNING: {} unable to determine module version'.format(cls.name)) else: return True elif not(cls.version_args is not None and cls.version_re and cls.version_requirement): return True if cls.executable_version is None: cls.executable_version = cls.get_executable_version() if cls.executable_version: predicate = VersionPredicate( '{} ({})'.format(cls.name.replace('-', '.'), cls.version_requirement) ) if predicate.satisfied_by(cls.executable_version): persist.debug( '{}: ({}) satisfied by {}' .format(cls.name, cls.version_requirement, cls.executable_version) ) return True else: persist.printf( 'WARNING: {} deactivated, version requirement ({}) not fulfilled by {}' .format(cls.name, cls.version_requirement, cls.executable_version) ) return False @classmethod def get_executable_version(cls): """Extract and return the string version of the linter executable.""" args = cls.version_args if isinstance(args, str): args = shlex.split(args) else: args = list(args) if isinstance(cls.executable_path, str): cmd = [cls.executable_path] else: cmd = list(cls.executable_path) cmd += args persist.debug('{} version query: {}'.format(cls.name, ' '.join(cmd))) version = util.communicate(cmd, output_stream=util.STREAM_BOTH) match = cls.version_re.search(version) if match: version = match.group('version') persist.debug('{} version: {}'.format(cls.name, version)) return version else: persist.printf('WARNING: no {} version could be extracted from:\n{}'.format(cls.name, version)) return None @staticmethod def replace_entity(match): """Return the character corresponding to an HTML entity.""" number = match.group(2) if number: hex = match.group(1) is not None result = chr(int(number, 16 if hex else 10)) else: entity = match.group(3) result = unescape(entity, html.entities.html5) return result def error(self, line, col, message, error_type): """Add a reference to an error/warning on the given line and column.""" self.highlight.line(line, error_type) # Some linters use html entities in error messages, decode them message = HTML_ENTITY_RE.sub(self.replace_entity, message) # Strip trailing CR, space and period message = ((col or 0), str(message).rstrip('\r .')) if line in self.errors: self.errors[line].append(message) else: self.errors[line] = [message] def find_errors(self, output): """ A generator which matches the linter's regex against the linter output. If multiline is True, split_match is called for each non-overlapping match of self.regex. If False, split_match is called for each line in output. """ if self.multiline: errors = self.regex.finditer(output) if errors: for error in errors: yield self.split_match(error) else: yield self.split_match(None) else: for line in output.splitlines(): yield self.split_match(self.regex.match(line.rstrip())) def split_match(self, match): """ Split a match into the standard elements of an error and return them. If subclasses need to modify the values returned by the regex, they should override this method, call super(), then modify the values and return them. """ if match: items = {'line': None, 'col': None, 'error': None, 'warning': None, 'message': '', 'near': None} items.update(match.groupdict()) line, col, error, warning, message, near = [ items[k] for k in ('line', 'col', 'error', 'warning', 'message', 'near') ] if line is not None: line = int(line) - self.line_col_base[0] if col is not None: if col.isdigit(): col = int(col) - self.line_col_base[1] else: col = len(col) return match, line, col, error, warning, message, near else: return match, None, None, None, None, '', None def run(self, cmd, code): """ Execute the linter's executable or built in code and return its output. If a linter uses built in code, it should override this method and return a string as the output. If a linter needs to do complicated setup or will use the tmpdir method, it will need to override this method. """ if persist.debug_mode(): persist.printf('{}: {} {}'.format(self.name, os.path.basename(self.filename or ''), cmd or '')) if self.tempfile_suffix: if self.tempfile_suffix != '-': return self.tmpfile(cmd, code, suffix=self.get_tempfile_suffix()) else: return self.communicate(cmd) else: return self.communicate(cmd, code) def get_tempfile_suffix(self): """Return the mapped tempfile_suffix.""" if self.tempfile_suffix: if isinstance(self.tempfile_suffix, dict): suffix = self.tempfile_suffix.get(persist.get_syntax(self.view), self.syntax) else: suffix = self.tempfile_suffix if suffix and suffix[0] != '.': suffix = '.' + suffix return suffix else: return '' # popen wrappers def communicate(self, cmd, code=''): """Run an external executable using stdin to pass code and return its output.""" if '@' in cmd: cmd[cmd.index('@')] = self.filename elif not code: cmd.append(self.filename) return util.communicate( cmd, code, output_stream=self.error_stream, env=self.env) def tmpfile(self, cmd, code, suffix=''): """Run an external executable using a temp file to pass code and return its output.""" return util.tmpfile( cmd, code, suffix or self.get_tempfile_suffix(), output_stream=self.error_stream, env=self.env) def tmpdir(self, cmd, files, code): """Run an external executable using a temp dir filled with files and return its output.""" return util.tmpdir( cmd, files, self.filename, code, output_stream=self.error_stream, env=self.env) def popen(self, cmd, env=None): """Run cmd in a subprocess with the given environment and return the output.""" return util.popen( cmd, env=env, extra_env=self.env, output_stream=self.error_stream)