# -*- coding: utf-8 -*- # base_linter.py - base class for linters import os import os.path import json import re import subprocess import sublime # If the linter uses an executable that takes stdin, use this input method. INPUT_METHOD_STDIN = 1 # If the linter uses an executable that does not take stdin but you wish to use # a temp file so that the current view can be linted interactively, use this input method. # If the current view has been saved, the tempfile will have the same name as the # view's file, which is necessary for some linters. INPUT_METHOD_TEMP_FILE = 2 # If the linter uses an executable that does not take stdin and you wish to have # linting occur only on file load and save, use this input method. INPUT_METHOD_FILE = 3 CONFIG = { # The display language name for this linter. 'language': '', # Linters may either use built in code or use an external executable. This item may have # one of the following values: # # string - An external command (or path to a command) to execute # None - The linter is considered to be built in # # Alternately, your linter class may define the method get_executable(), # which should return the three-tuple (, , ): # must be a boolean than indicates whether the executable is available and usable. # If is True, must be one of: # - A command string (or path to a command) if an external executable will be used # - None if built in code will be used # - False if no suitable executable can be found or the linter should be disabled # for some other reason. # is the message that will be shown in the console when the linter is # loaded, to aid the user in knowing what the status of the linter is. If None or an empty string, # a default message will be returned based on the value of . Otherwise it # must be a string. 'executable': None, # If an external executable is being used, this item specifies the arguments # used when checking the existence of the executable to determine if the linter can be enabled. # If more than one argument needs to be passed, use a tuple/list. # Defaults to '-v' if this item is missing. 'test_existence_args': '-v', # If an external executable is being used, this item specifies the arguments to be passed # when linting. If there is more than one argument, use a tuple/list. # If the input method is anything other than INPUT_METHOD_STDIN, put a {filename} placeholder in # the args where the filename should go. # # Alternately, if your linter class may define the method get_lint_args(), which should return # None for no arguments or a tuple/list for one or more arguments. 'lint_args': None, # If an external executable is being used, the method used to pass input to it. Defaults to STDIN. 'input_method': INPUT_METHOD_STDIN } TEMPFILES_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__.encode('utf-8')), u'..', u'.tempfiles')) JSON_MULTILINE_COMMENT_RE = re.compile(r'\/\*[\s\S]*?\*\/') JSON_SINGLELINE_COMMENT_RE = re.compile(r'\/\/[^\n\r]*') if not os.path.exists(TEMPFILES_DIR): os.mkdir(TEMPFILES_DIR) class BaseLinter(object): '''A base class for linters. Your linter module needs to do the following: - Set the relevant values in CONFIG - Override built_in_check() if it uses a built in linter. You may return whatever value you want, this value will be passed to parse_errors(). - Override parse_errors() and populate the relevant lists/dicts. The errors argument passed to parse_errors() is the output of the executable run through strip(). If you do subclass and override __init__, be sure to call super(MyLinter, self).__init__(config). ''' JSC_PATH = '/System/Library/Frameworks/JavaScriptCore.framework/Versions/A/Resources/jsc' LIB_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__.encode('utf-8')), u'libs')) JAVASCRIPT_ENGINES = ['node', 'jsc'] JAVASCRIPT_ENGINE_NAMES = {'node': 'node.js', 'jsc': 'JavaScriptCore'} JAVASCRIPT_ENGINE_WRAPPERS_PATH = os.path.join(LIB_PATH, 'jsengines') def __init__(self, config): self.language = config['language'] self.enabled = False self.executable = config.get('executable', None) self.test_existence_args = config.get('test_existence_args', ['-v']) self.js_engine = None if isinstance(self.test_existence_args, basestring): self.test_existence_args = (self.test_existence_args,) self.input_method = config.get('input_method', INPUT_METHOD_STDIN) self.filename = None self.lint_args = config.get('lint_args', []) if isinstance(self.lint_args, basestring): self.lint_args = [self.lint_args] def check_enabled(self, view): if hasattr(self, 'get_executable'): try: self.enabled, self.executable, message = self.get_executable(view) if self.enabled and not message: message = 'using "{0}"'.format(self.executable) if self.executable else 'built in' except Exception as ex: self.enabled = False message = unicode(ex) else: self.enabled, message = self._check_enabled(view) return (self.enabled, message or '') def _check_enabled(self, view): if self.executable is None: return (True, 'built in') elif isinstance(self.executable, basestring): self.executable = self.get_mapped_executable(view, self.executable) elif isinstance(self.executable, bool) and self.executable == False: return (False, 'unknown error') else: return (False, 'bad type for CONFIG["executable"]') # If we get this far, the executable is external. Test that it can be executed # and capture stdout and stderr so they don't end up in the system log. try: args = [self.executable] args.extend(self.test_existence_args) subprocess.Popen(args, startupinfo=self.get_startupinfo(), stdout=subprocess.PIPE, stderr=subprocess.STDOUT).communicate() except OSError: return (False, '"{0}" cannot be found'.format(self.executable)) return (True, 'using "{0}" for executable'.format(self.executable)) def _get_lint_args(self, view, code, filename): if hasattr(self, 'get_lint_args'): return self.get_lint_args(view, code, filename) or [] else: lintArgs = self.lint_args or [] settings = view.settings().get('SublimeLinter', {}).get(self.language, {}) if settings: args = settings.get('lint_args', []) lintArgs.extend(args) cwd = settings.get('working_directory').encode('utf-8') if cwd and os.path.isabs(cwd) and os.path.isdir(cwd): os.chdir(cwd) return [arg.format(filename=filename) for arg in lintArgs] def built_in_check(self, view, code, filename): return '' def executable_check(self, view, code, filename): args = [self.executable] tempfilePath = None if self.input_method == INPUT_METHOD_STDIN: args.extend(self._get_lint_args(view, code, filename)) elif self.input_method == INPUT_METHOD_TEMP_FILE: if filename: filename = os.path.basename(filename) else: filename = u'view{0}'.format(view.id()) tempfilePath = os.path.join(TEMPFILES_DIR, filename) with open(tempfilePath, 'w') as f: f.write(code) args.extend(self._get_lint_args(view, code, tempfilePath)) code = u'' elif self.input_method == INPUT_METHOD_FILE: args.extend(self._get_lint_args(view, code, filename)) code = u'' else: return u'' try: process = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, startupinfo=self.get_startupinfo()) process.stdin.write(code) result = process.communicate()[0] finally: if tempfilePath: os.remove(tempfilePath) return result.strip() def parse_errors(self, view, errors, lines, errorUnderlines, violationUnderlines, warningUnderlines, errorMessages, violationMessages, warningMessages): pass def add_message(self, lineno, lines, message, messages): # Assume lineno is one-based, ST2 wants zero-based line numbers lineno -= 1 lines.add(lineno) message = message[0].upper() + message[1:] # Remove trailing period from error message if message[-1] == '.': message = message[:-1] if lineno in messages: messages[lineno].append(message) else: messages[lineno] = [message] def underline_range(self, view, lineno, position, underlines, length=1): # Assume lineno is one-based, ST2 wants zero-based line numbers lineno -= 1 line = view.full_line(view.text_point(lineno, 0)) position += line.begin() for i in xrange(length): underlines.append(sublime.Region(position + i)) def underline_regex(self, view, lineno, regex, lines, underlines, wordmatch=None, linematch=None): # Assume lineno is one-based, ST2 wants zero-based line numbers lineno -= 1 lines.add(lineno) offset = 0 line = view.full_line(view.text_point(lineno, 0)) lineText = view.substr(line) if linematch: match = re.match(linematch, lineText) if match: lineText = match.group('match') offset = match.start('match') else: return iters = re.finditer(regex, lineText) results = [(result.start('underline'), result.end('underline')) for result in iters if not wordmatch or result.group('underline') == wordmatch] # Make the lineno one-based again for underline_range lineno += 1 for start, end in results: self.underline_range(view, lineno, start + offset, underlines, end - start) def underline_word(self, view, lineno, position, underlines): # Assume lineno is one-based, ST2 wants zero-based line numbers lineno -= 1 line = view.full_line(view.text_point(lineno, 0)) position += line.begin() word = view.word(position) underlines.append(word) def run(self, view, code, filename=None): self.filename = filename if self.executable is None: errors = self.built_in_check(view, code, filename) else: errors = self.executable_check(view, code, filename) lines = set() errorUnderlines = [] # leave this here for compatibility with original plugin errorMessages = {} violationUnderlines = [] violationMessages = {} warningUnderlines = [] warningMessages = {} self.parse_errors(view, errors, lines, errorUnderlines, violationUnderlines, warningUnderlines, errorMessages, violationMessages, warningMessages) return lines, errorUnderlines, violationUnderlines, warningUnderlines, errorMessages, violationMessages, warningMessages def get_mapped_executable(self, view, default): map = view.settings().get('sublimelinter_executable_map') if map: lang = self.language.lower() if lang in map: return map[lang].encode('utf-8') return default def get_startupinfo(self): info = None if os.name == 'nt': info = subprocess.STARTUPINFO() info.dwFlags |= subprocess.STARTF_USESHOWWINDOW info.wShowWindow = subprocess.SW_HIDE return info def execute_get_output(self, args): try: return subprocess.Popen(args, self.get_startupinfo()).communicate()[0] except: return '' def jsc_path(self): '''Return the path to JavaScriptCore. Use this method in case the path has to be dynamically calculated in the future.''' return self.JSC_PATH def find_file(self, filename, view): '''Find a file with the given name, starting in the view's directory, then ascending the file hierarchy up to root.''' path = view.file_name().encode('utf-8') # quit if the view is temporary if not path: return None dirname = os.path.dirname(path) while True: path = os.path.join(dirname, filename) if os.path.isfile(path): with open(path, 'r') as f: return f.read() # if we hit root, quit parent = os.path.dirname(dirname) if parent == dirname: return None else: dirname = parent def strip_json_comments(self, json_str): stripped_json = JSON_MULTILINE_COMMENT_RE.sub('', json_str) stripped_json = JSON_SINGLELINE_COMMENT_RE.sub('', stripped_json) return json.dumps(json.loads(stripped_json)) def get_javascript_args(self, view, linter, code): path = os.path.join(self.LIB_PATH, linter) options = self.get_javascript_options(view) if options == None: options = json.dumps(view.settings().get('%s_options' % linter) or {}) self.get_javascript_engine(view) engine = self.js_engine if (engine['name'] == 'jsc'): args = [engine['wrapper'], '--', path + os.path.sep, str(code.count('\n')), options] else: args = [engine['wrapper'], path + os.path.sep, options] return args def get_javascript_options(self, view): '''Subclasses should override this if they want to provide options for a Javascript-based linter. If the subclass cannot provide options, it should return None (or not return anything).''' return None def get_javascript_engine(self, view): if self.js_engine == None: for engine in self.JAVASCRIPT_ENGINES: if engine == 'node': try: path = self.get_mapped_executable(view, 'node') subprocess.call([path, u'-v'], startupinfo=self.get_startupinfo()) self.js_engine = { 'name': engine, 'path': path, 'wrapper': os.path.join(self.JAVASCRIPT_ENGINE_WRAPPERS_PATH, engine + '.js'), } break except OSError: pass elif engine == 'jsc': if os.path.exists(self.jsc_path()): self.js_engine = { 'name': engine, 'path': self.jsc_path(), 'wrapper': os.path.join(self.JAVASCRIPT_ENGINE_WRAPPERS_PATH, engine + '.js'), } break if self.js_engine != None: return (True, self.js_engine['path'], 'using {0}'.format(self.JAVASCRIPT_ENGINE_NAMES[self.js_engine['name']])) # Didn't find an engine, tell the user engine_list = ', '.join(self.JAVASCRIPT_ENGINE_NAMES.values()) return (False, '', 'One of the following Javascript engines must be installed: ' + engine_list)