mirror of
https://github.com/sstent/sublime-text-3.git
synced 2026-01-25 14:41:38 +00:00
1170 lines
37 KiB
Python
1170 lines
37 KiB
Python
# coding: utf-8
|
|
#
|
|
# commands.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 implements the Sublime Text commands provided by SublimeLinter."""
|
|
|
|
import datetime
|
|
from fnmatch import fnmatch
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
from textwrap import TextWrapper
|
|
from threading import Thread
|
|
import time
|
|
|
|
import sublime
|
|
import sublime_plugin
|
|
|
|
from .lint import highlight, linter, persist, util
|
|
|
|
|
|
def error_command(method):
|
|
"""
|
|
A decorator that executes method only if the current view has errors.
|
|
|
|
This decorator is meant to be used only with the run method of
|
|
sublime_plugin.TextCommand subclasses.
|
|
|
|
A wrapped version of method is returned.
|
|
|
|
"""
|
|
|
|
def run(self, edit, **kwargs):
|
|
vid = self.view.id()
|
|
|
|
if vid in persist.errors and persist.errors[vid]:
|
|
method(self, self.view, persist.errors[vid], **kwargs)
|
|
else:
|
|
sublime.message_dialog('No lint errors.')
|
|
|
|
return run
|
|
|
|
|
|
def select_line(view, line):
|
|
"""Change view's selection to be the given line."""
|
|
point = view.text_point(line, 0)
|
|
sel = view.sel()
|
|
sel.clear()
|
|
sel.add(view.line(point))
|
|
|
|
|
|
class SublimelinterLintCommand(sublime_plugin.TextCommand):
|
|
|
|
"""A command that lints the current view if it has a linter."""
|
|
|
|
def is_enabled(self):
|
|
"""
|
|
Return True if the current view can be linted.
|
|
|
|
If the view has *only* file-only linters, it can be linted
|
|
only if the view is not dirty.
|
|
|
|
Otherwise it can be linted.
|
|
|
|
"""
|
|
|
|
has_non_file_only_linter = False
|
|
|
|
vid = self.view.id()
|
|
linters = persist.view_linters.get(vid, [])
|
|
|
|
for lint in linters:
|
|
if lint.tempfile_suffix != '-':
|
|
has_non_file_only_linter = True
|
|
break
|
|
|
|
if not has_non_file_only_linter:
|
|
return not self.view.is_dirty()
|
|
|
|
return True
|
|
|
|
def run(self, edit):
|
|
"""Lint the current view."""
|
|
from .sublimelinter import SublimeLinter
|
|
SublimeLinter.shared_plugin().lint(self.view.id())
|
|
|
|
|
|
class HasErrorsCommand:
|
|
|
|
"""
|
|
A mixin class for sublime_plugin.TextCommand subclasses.
|
|
|
|
Inheriting from this class will enable the command only if the current view has errors.
|
|
|
|
"""
|
|
|
|
def is_enabled(self):
|
|
"""Return True if the current view has errors."""
|
|
vid = self.view.id()
|
|
return vid in persist.errors and len(persist.errors[vid]) > 0
|
|
|
|
|
|
class GotoErrorCommand(sublime_plugin.TextCommand):
|
|
|
|
"""A superclass for commands that go to the next/previous error."""
|
|
|
|
def goto_error(self, view, errors, direction='next'):
|
|
"""Go to the next/previous error in view."""
|
|
sel = view.sel()
|
|
|
|
if len(sel) == 0:
|
|
sel.add(sublime.Region(0, 0))
|
|
|
|
saved_sel = tuple(sel)
|
|
empty_selection = len(sel) == 1 and sel[0].empty()
|
|
|
|
# sublime.Selection() changes the view's selection, get the point first
|
|
point = sel[0].begin() if direction == 'next' else sel[-1].end()
|
|
|
|
regions = sublime.Selection(view.id())
|
|
regions.clear()
|
|
|
|
for error_type in (highlight.WARNING, highlight.ERROR):
|
|
regions.add_all(view.get_regions(highlight.MARK_KEY_FORMAT.format(error_type)))
|
|
|
|
region_to_select = None
|
|
|
|
# If going forward, find the first region beginning after the point.
|
|
# If going backward, find the first region ending before the point.
|
|
# If nothing is found in the given direction, wrap to the first/last region.
|
|
if direction == 'next':
|
|
for region in regions:
|
|
if (
|
|
(point == region.begin() and empty_selection and not region.empty())
|
|
or (point < region.begin())
|
|
):
|
|
region_to_select = region
|
|
break
|
|
else:
|
|
for region in reversed(regions):
|
|
if (
|
|
(point == region.end() and empty_selection and not region.empty())
|
|
or (point > region.end())
|
|
):
|
|
region_to_select = region
|
|
break
|
|
|
|
# If there is only one error line and the cursor is in that line, we cannot move.
|
|
# Otherwise wrap to the first/last error line unless settings disallow that.
|
|
if region_to_select is None and ((len(regions) > 1 or not regions[0].contains(point))):
|
|
if persist.settings.get('wrap_find', True):
|
|
region_to_select = regions[0] if direction == 'next' else regions[-1]
|
|
|
|
if region_to_select is not None:
|
|
self.select_lint_region(self.view, region_to_select)
|
|
else:
|
|
sel.clear()
|
|
sel.add_all(saved_sel)
|
|
sublime.message_dialog('No {0} lint error.'.format(direction))
|
|
|
|
@classmethod
|
|
def select_lint_region(cls, view, region):
|
|
"""
|
|
Select and scroll to the first marked region that contains region.
|
|
|
|
If none are found, the beginning of region is used. The view is
|
|
centered on the calculated region and the region is selected.
|
|
|
|
"""
|
|
|
|
marked_region = cls.find_mark_within(view, region)
|
|
|
|
if marked_region is None:
|
|
marked_region = sublime.Region(region.begin(), region.begin())
|
|
|
|
sel = view.sel()
|
|
sel.clear()
|
|
sel.add(marked_region)
|
|
|
|
# There is a bug in ST3 that prevents the selection from changing
|
|
# when a quick panel is open and the viewport does not change position,
|
|
# so we call our own custom method that works around that.
|
|
util.center_region_in_view(marked_region, view)
|
|
|
|
@classmethod
|
|
def find_mark_within(cls, view, region):
|
|
"""Return the nearest marked region that contains region, or None if none found."""
|
|
|
|
marks = view.get_regions(highlight.MARK_KEY_FORMAT.format(highlight.WARNING))
|
|
marks.extend(view.get_regions(highlight.MARK_KEY_FORMAT.format(highlight.ERROR)))
|
|
marks.sort(key=sublime.Region.begin)
|
|
|
|
for mark in marks:
|
|
if mark.contains(region):
|
|
return mark
|
|
|
|
return None
|
|
|
|
|
|
class SublimelinterGotoErrorCommand(GotoErrorCommand):
|
|
|
|
"""A command that selects the next/previous error."""
|
|
|
|
@error_command
|
|
def run(self, view, errors, **kwargs):
|
|
"""Run the command."""
|
|
self.goto_error(view, errors, **kwargs)
|
|
|
|
|
|
class SublimelinterShowAllErrors(sublime_plugin.TextCommand):
|
|
|
|
"""A command that shows a quick panel with all of the errors in the current view."""
|
|
|
|
@error_command
|
|
def run(self, view, errors):
|
|
"""Run the command."""
|
|
self.errors = errors
|
|
self.points = []
|
|
options = []
|
|
|
|
for lineno, line_errors in sorted(errors.items()):
|
|
line = view.substr(view.full_line(view.text_point(lineno, 0))).rstrip('\n\r')
|
|
|
|
# Strip whitespace from the front of the line, but keep track of how much was
|
|
# stripped so we can adjust the column.
|
|
diff = len(line)
|
|
line = line.lstrip()
|
|
diff -= len(line)
|
|
|
|
max_prefix_len = 40
|
|
|
|
for column, message in sorted(line_errors):
|
|
# Keep track of the line and column
|
|
point = view.text_point(lineno, column)
|
|
self.points.append(point)
|
|
|
|
# If there are more than max_prefix_len characters before the adjusted column,
|
|
# lop off the excess and insert an ellipsis.
|
|
column = max(column - diff, 0)
|
|
|
|
if column > max_prefix_len:
|
|
visible_line = '...' + line[column - max_prefix_len:]
|
|
column = max_prefix_len + 3 # 3 for ...
|
|
else:
|
|
visible_line = line
|
|
|
|
# Insert an arrow at the column in the stripped line
|
|
code = visible_line[:column] + '➜' + visible_line[column:]
|
|
options.append(['{} {}'.format(lineno + 1, message), code])
|
|
|
|
self.viewport_pos = view.viewport_position()
|
|
self.selection = list(view.sel())
|
|
|
|
view.window().show_quick_panel(
|
|
options,
|
|
on_select=self.select_error,
|
|
on_highlight=self.select_error
|
|
)
|
|
|
|
def select_error(self, index):
|
|
"""Completion handler for the quick panel. Selects the indexed error."""
|
|
if index != -1:
|
|
point = self.points[index]
|
|
GotoErrorCommand.select_lint_region(self.view, sublime.Region(point, point))
|
|
else:
|
|
self.view.set_viewport_position(self.viewport_pos)
|
|
self.view.sel().clear()
|
|
self.view.sel().add_all(self.selection)
|
|
|
|
|
|
class SublimelinterToggleSettingCommand(sublime_plugin.WindowCommand):
|
|
|
|
"""Command that toggles a setting."""
|
|
|
|
def __init__(self, window):
|
|
super().__init__(window)
|
|
|
|
def is_visible(self, **args):
|
|
"""Return True if the opposite of the setting is True."""
|
|
if args.get('checked', False):
|
|
return True
|
|
|
|
if persist.settings.has_setting(args['setting']):
|
|
setting = persist.settings.get(args['setting'], None)
|
|
return setting is not None and setting is not args['value']
|
|
else:
|
|
return args['value'] is not None
|
|
|
|
def is_checked(self, **args):
|
|
"""Return True if the setting should be checked."""
|
|
if args.get('checked', False):
|
|
setting = persist.settings.get(args['setting'], False)
|
|
return setting is True
|
|
else:
|
|
return False
|
|
|
|
def run(self, **args):
|
|
"""Toggle the setting if value is boolean, or remove it if None."""
|
|
|
|
if 'value' in args:
|
|
if args['value'] is None:
|
|
persist.settings.pop(args['setting'])
|
|
else:
|
|
persist.settings.set(args['setting'], args['value'], changed=True)
|
|
else:
|
|
setting = persist.settings.get(args['setting'], False)
|
|
persist.settings.set(args['setting'], not setting, changed=True)
|
|
|
|
persist.settings.save()
|
|
|
|
|
|
class ChooseSettingCommand(sublime_plugin.WindowCommand):
|
|
|
|
"""An abstract base class for commands that choose a setting from a list."""
|
|
|
|
def __init__(self, window, setting=None, preview=False):
|
|
super().__init__(window)
|
|
self.setting = setting
|
|
self._settings = None
|
|
self.preview = preview
|
|
|
|
def description(self, **args):
|
|
"""Return the visible description of the command, used in menus."""
|
|
return args.get('value', None)
|
|
|
|
def is_checked(self, **args):
|
|
"""Return whether this command should be checked in a menu."""
|
|
if 'value' not in args:
|
|
return False
|
|
|
|
item = self.transform_setting(args['value'], matching=True)
|
|
setting = self.setting_value(matching=True)
|
|
return item == setting
|
|
|
|
def _get_settings(self):
|
|
"""Return the list of settings."""
|
|
if self._settings is None:
|
|
self._settings = self.get_settings()
|
|
|
|
return self._settings
|
|
|
|
settings = property(_get_settings)
|
|
|
|
def get_settings(self):
|
|
"""Return the list of settings. Subclasses must override this."""
|
|
raise NotImplementedError
|
|
|
|
def transform_setting(self, setting, matching=False):
|
|
"""
|
|
Transform the display text for setting to the form it is stored in.
|
|
|
|
By default, returns a lowercased copy of setting.
|
|
|
|
"""
|
|
return setting.lower()
|
|
|
|
def setting_value(self, matching=False):
|
|
"""Return the current value of the setting."""
|
|
return self.transform_setting(persist.settings.get(self.setting, ''), matching=matching)
|
|
|
|
def on_highlight(self, index):
|
|
"""If preview is on, set the selected setting."""
|
|
if self.preview:
|
|
self.set(index)
|
|
|
|
def choose(self, **kwargs):
|
|
"""
|
|
Choose or set the setting.
|
|
|
|
If 'value' is in kwargs, the setting is set to the corresponding value.
|
|
Otherwise the list of available settings is built via get_settings
|
|
and is displayed in a quick panel. The current value of the setting
|
|
is initially selected in the quick panel.
|
|
|
|
"""
|
|
|
|
if 'value' in kwargs:
|
|
setting = self.transform_setting(kwargs['value'])
|
|
else:
|
|
setting = self.setting_value(matching=True)
|
|
|
|
index = 0
|
|
|
|
for i, s in enumerate(self.settings):
|
|
if isinstance(s, (tuple, list)):
|
|
s = self.transform_setting(s[0])
|
|
else:
|
|
s = self.transform_setting(s)
|
|
|
|
if s == setting:
|
|
index = i
|
|
break
|
|
|
|
if 'value' in kwargs:
|
|
self.set(index)
|
|
else:
|
|
self.previous_setting = self.setting_value()
|
|
|
|
self.window.show_quick_panel(
|
|
self.settings,
|
|
on_select=self.set,
|
|
selected_index=index,
|
|
on_highlight=self.on_highlight)
|
|
|
|
def set(self, index):
|
|
"""Set the value of the setting."""
|
|
|
|
if index == -1:
|
|
if self.settings_differ(self.previous_setting, self.setting_value()):
|
|
self.update_setting(self.previous_setting)
|
|
|
|
return
|
|
|
|
setting = self.selected_setting(index)
|
|
|
|
if isinstance(setting, (tuple, list)):
|
|
setting = setting[0]
|
|
|
|
setting = self.transform_setting(setting)
|
|
|
|
if not self.settings_differ(persist.settings.get(self.setting, ''), setting):
|
|
return
|
|
|
|
self.update_setting(setting)
|
|
|
|
def update_setting(self, value):
|
|
"""Update the setting with the given value."""
|
|
persist.settings.set(self.setting, value, changed=True)
|
|
self.setting_was_changed(value)
|
|
persist.settings.save()
|
|
|
|
def settings_differ(self, old_setting, new_setting):
|
|
"""Return whether two setting values differ."""
|
|
if isinstance(new_setting, (tuple, list)):
|
|
new_setting = new_setting[0]
|
|
|
|
new_setting = self.transform_setting(new_setting)
|
|
return new_setting != old_setting
|
|
|
|
def selected_setting(self, index):
|
|
"""
|
|
Return the selected setting by index.
|
|
|
|
Subclasses may override this if they want to return something other
|
|
than the indexed value from self.settings.
|
|
|
|
"""
|
|
|
|
return self.settings[index]
|
|
|
|
def setting_was_changed(self, setting):
|
|
"""
|
|
Do something after the setting value is changed but before settings are saved.
|
|
|
|
Subclasses may override this if further action is necessary after
|
|
the setting's value is changed.
|
|
|
|
"""
|
|
pass
|
|
|
|
|
|
def choose_setting_command(setting, preview):
|
|
"""Return a decorator that provides common methods for concrete subclasses of ChooseSettingCommand."""
|
|
|
|
def decorator(cls):
|
|
def init(self, window):
|
|
super(cls, self).__init__(window, setting, preview)
|
|
|
|
def run(self, **kwargs):
|
|
"""Run the command."""
|
|
self.choose(**kwargs)
|
|
|
|
cls.setting = setting
|
|
cls.__init__ = init
|
|
cls.run = run
|
|
return cls
|
|
|
|
return decorator
|
|
|
|
|
|
@choose_setting_command('lint_mode', preview=False)
|
|
class SublimelinterChooseLintModeCommand(ChooseSettingCommand):
|
|
|
|
"""A command that selects a lint mode from a list."""
|
|
|
|
def get_settings(self):
|
|
"""Return a list of the lint modes."""
|
|
return [[name.capitalize(), description] for name, description in persist.LINT_MODES]
|
|
|
|
def setting_was_changed(self, setting):
|
|
"""Update all views when the lint mode changes."""
|
|
if setting == 'background':
|
|
from .sublimelinter import SublimeLinter
|
|
SublimeLinter.lint_all_views()
|
|
else:
|
|
linter.Linter.clear_all()
|
|
|
|
|
|
@choose_setting_command('mark_style', preview=True)
|
|
class SublimelinterChooseMarkStyleCommand(ChooseSettingCommand):
|
|
|
|
"""A command that selects a mark style from a list."""
|
|
|
|
def get_settings(self):
|
|
"""Return a list of the mark styles."""
|
|
return highlight.mark_style_names()
|
|
|
|
|
|
@choose_setting_command('gutter_theme', preview=True)
|
|
class SublimelinterChooseGutterThemeCommand(ChooseSettingCommand):
|
|
|
|
"""A command that selects a gutter theme from a list."""
|
|
|
|
def get_settings(self):
|
|
"""
|
|
Return a list of all available gutter themes, with 'None' at the end.
|
|
|
|
Whether the theme is colorized and is a SublimeLinter or user theme
|
|
is indicated below the theme name.
|
|
|
|
"""
|
|
|
|
settings = self.find_gutter_themes()
|
|
settings.append(['None', 'Do not display gutter marks'])
|
|
self.themes.append('none')
|
|
|
|
return settings
|
|
|
|
def find_gutter_themes(self):
|
|
"""
|
|
Find all SublimeLinter.gutter-theme resources.
|
|
|
|
For each found resource, if it doesn't match one of the patterns
|
|
from the "gutter_theme_excludes" setting, return the base name
|
|
of resource and info on whether the theme is a standard theme
|
|
or a user theme, as well as whether it is colorized.
|
|
|
|
The list of paths to the resources is appended to self.themes.
|
|
|
|
"""
|
|
|
|
self.themes = []
|
|
settings = []
|
|
gutter_themes = sublime.find_resources('*.gutter-theme')
|
|
excludes = persist.settings.get('gutter_theme_excludes', [])
|
|
pngs = sublime.find_resources('*.png')
|
|
|
|
for theme in gutter_themes:
|
|
# Make sure the theme has error.png and warning.png
|
|
exclude = False
|
|
parent = os.path.dirname(theme)
|
|
|
|
for name in ('error', 'warning'):
|
|
if '{}/{}.png'.format(parent, name) not in pngs:
|
|
exclude = True
|
|
|
|
if exclude:
|
|
continue
|
|
|
|
# Now see if the theme name is in gutter_theme_excludes
|
|
name = os.path.splitext(os.path.basename(theme))[0]
|
|
|
|
for pattern in excludes:
|
|
if fnmatch(name, pattern):
|
|
exclude = True
|
|
break
|
|
|
|
if exclude:
|
|
continue
|
|
|
|
self.themes.append(theme)
|
|
|
|
try:
|
|
info = json.loads(sublime.load_resource(theme))
|
|
colorize = info.get('colorize', False)
|
|
except ValueError:
|
|
colorize = False
|
|
|
|
std_theme = theme.startswith('Packages/SublimeLinter/gutter-themes/')
|
|
|
|
settings.append([
|
|
name,
|
|
'{}{}'.format(
|
|
'SublimeLinter theme' if std_theme else 'User theme',
|
|
' (colorized)' if colorize else ''
|
|
)
|
|
])
|
|
|
|
# Sort self.themes and settings in parallel using the zip trick
|
|
settings, self.themes = zip(*sorted(zip(settings, self.themes)))
|
|
|
|
# zip returns tuples, convert back to lists
|
|
settings = list(settings)
|
|
self.themes = list(self.themes)
|
|
|
|
return settings
|
|
|
|
def selected_setting(self, index):
|
|
"""Return the theme name with the given index."""
|
|
return self.themes[index]
|
|
|
|
def transform_setting(self, setting, matching=False):
|
|
"""
|
|
Return a transformed version of setting.
|
|
|
|
For gutter themes, setting is a Packages-relative path
|
|
to a .gutter-theme file.
|
|
|
|
If matching == False, return the original setting text,
|
|
gutter theme settings are not lowercased.
|
|
|
|
If matching == True, return the base name of the filename
|
|
without the .gutter-theme extension.
|
|
|
|
"""
|
|
|
|
if matching:
|
|
return os.path.splitext(os.path.basename(setting))[0]
|
|
else:
|
|
return setting
|
|
|
|
|
|
class SublimelinterToggleLinterCommand(sublime_plugin.WindowCommand):
|
|
|
|
"""A command that toggles, enables, or disables linter plugins."""
|
|
|
|
def __init__(self, window):
|
|
super().__init__(window)
|
|
self.linters = {}
|
|
|
|
def is_visible(self, **args):
|
|
"""Return True if the command would show any linters."""
|
|
which = args['which']
|
|
|
|
if self.linters.get(which) is None:
|
|
linters = []
|
|
settings = persist.settings.get('linters', {})
|
|
|
|
for linter in persist.linter_classes:
|
|
linter_settings = settings.get(linter, {})
|
|
disabled = linter_settings.get('@disable')
|
|
|
|
if which == 'all':
|
|
include = True
|
|
linter = [linter, 'disabled' if disabled else 'enabled']
|
|
else:
|
|
include = (
|
|
which == 'enabled' and not disabled or
|
|
which == 'disabled' and disabled
|
|
)
|
|
|
|
if include:
|
|
linters.append(linter)
|
|
|
|
linters.sort()
|
|
self.linters[which] = linters
|
|
|
|
return len(self.linters[which]) > 0
|
|
|
|
def run(self, **args):
|
|
"""Run the command."""
|
|
self.which = args['which']
|
|
|
|
if self.linters[self.which]:
|
|
self.window.show_quick_panel(self.linters[self.which], self.on_done)
|
|
|
|
def on_done(self, index):
|
|
"""Completion handler for quick panel, toggle the enabled state of the chosen linter."""
|
|
if index != -1:
|
|
linter = self.linters[self.which][index]
|
|
|
|
if isinstance(linter, list):
|
|
linter = linter[0]
|
|
|
|
settings = persist.settings.get('linters', {})
|
|
linter_settings = settings.get(linter, {})
|
|
linter_settings['@disable'] = not linter_settings.get('@disable', False)
|
|
persist.settings.set('linters', settings, changed=True)
|
|
persist.settings.save()
|
|
|
|
self.linters = {}
|
|
|
|
|
|
class SublimelinterCreateLinterPluginCommand(sublime_plugin.WindowCommand):
|
|
|
|
"""A command that creates a new linter plugin."""
|
|
|
|
def run(self):
|
|
"""Run the command."""
|
|
if not sublime.ok_cancel_dialog(
|
|
'You will be asked for the linter name. Please enter the name '
|
|
'of the linter binary (including dashes), NOT the name of the language being linted. '
|
|
'For example, to lint CSS with csslint, the linter name is '
|
|
'“csslint”, NOT “css”.',
|
|
'I understand'
|
|
):
|
|
return
|
|
|
|
self.window.show_input_panel(
|
|
'Linter name:',
|
|
'',
|
|
on_done=self.copy_linter,
|
|
on_change=None,
|
|
on_cancel=None)
|
|
|
|
def copy_linter(self, name):
|
|
"""Copy the template linter to a new linter with the given name."""
|
|
|
|
self.name = name
|
|
self.fullname = 'SublimeLinter-contrib-{}'.format(name)
|
|
self.dest = os.path.join(sublime.packages_path(), self.fullname)
|
|
|
|
if os.path.exists(self.dest):
|
|
sublime.error_message('The plugin “{}” already exists.'.format(self.fullname))
|
|
return
|
|
|
|
src = os.path.join(sublime.packages_path(), persist.PLUGIN_DIRECTORY, 'linter-plugin-template')
|
|
self.temp_dir = None
|
|
|
|
try:
|
|
self.temp_dir = tempfile.mkdtemp()
|
|
self.temp_dest = os.path.join(self.temp_dir, self.fullname)
|
|
shutil.copytree(src, self.temp_dest)
|
|
|
|
self.get_linter_language(name, self.configure_linter)
|
|
|
|
except Exception as ex:
|
|
if self.temp_dir and os.path.exists(self.temp_dir):
|
|
shutil.rmtree(self.temp_dir)
|
|
|
|
sublime.error_message('An error occurred while copying the template plugin: {}'.format(str(ex)))
|
|
|
|
def configure_linter(self, language):
|
|
"""Fill out the template and move the linter into Packages."""
|
|
|
|
try:
|
|
if language is None:
|
|
return
|
|
|
|
if not self.fill_template(self.temp_dir, self.name, self.fullname, language):
|
|
return
|
|
|
|
git = util.which('git')
|
|
|
|
if git:
|
|
subprocess.call((git, 'init', self.temp_dest))
|
|
|
|
shutil.move(self.temp_dest, self.dest)
|
|
|
|
util.open_directory(self.dest)
|
|
self.wait_for_open(self.dest)
|
|
|
|
except Exception as ex:
|
|
sublime.error_message('An error occurred while configuring the plugin: {}'.format(str(ex)))
|
|
|
|
finally:
|
|
if self.temp_dir and os.path.exists(self.temp_dir):
|
|
shutil.rmtree(self.temp_dir)
|
|
|
|
def get_linter_language(self, name, callback):
|
|
"""Get the language (python, node, etc.) on which the linter is based."""
|
|
|
|
languages = ['javascript', 'python', 'ruby', 'other']
|
|
items = ['Select the language on which the linter is based:']
|
|
|
|
for language in languages:
|
|
items.append(' ' + language.capitalize())
|
|
|
|
def on_done(index):
|
|
language = languages[index - 1] if index > 0 else None
|
|
callback(language)
|
|
|
|
self.window.show_quick_panel(items, on_done)
|
|
|
|
def fill_template(self, template_dir, name, fullname, language):
|
|
"""Replace placeholders and fill template files in template_dir, return success."""
|
|
|
|
# Read per-language info
|
|
path = os.path.join(os.path.dirname(__file__), 'create_linter_info.json')
|
|
|
|
with open(path, mode='r', encoding='utf-8') as f:
|
|
try:
|
|
info = json.load(f)
|
|
except Exception as err:
|
|
persist.printf(err)
|
|
sublime.error_message('A configuration file could not be opened, the linter cannot be created.')
|
|
return False
|
|
|
|
info = info.get(language, {})
|
|
extra_attributes = []
|
|
comment_re = info.get('comment_re', 'None')
|
|
extra_attributes.append('comment_re = ' + comment_re)
|
|
|
|
attributes = info.get('attributes', [])
|
|
|
|
for attr in attributes:
|
|
extra_attributes.append(attr.format(name))
|
|
|
|
extra_attributes = '\n '.join(extra_attributes)
|
|
|
|
if extra_attributes:
|
|
extra_attributes += '\n'
|
|
|
|
extra_steps = info.get('extra_steps', '')
|
|
|
|
if isinstance(extra_steps, list):
|
|
extra_steps = '\n\n'.join(extra_steps)
|
|
|
|
if extra_steps:
|
|
extra_steps = '\n' + extra_steps + '\n'
|
|
|
|
platform = info.get('platform', language.capitalize())
|
|
|
|
# Replace placeholders
|
|
placeholders = {
|
|
'__linter__': name,
|
|
'__user__': util.get_user_fullname(),
|
|
'__year__': str(datetime.date.today().year),
|
|
'__class__': self.camel_case(name),
|
|
'__superclass__': info.get('superclass', 'Linter'),
|
|
'__cmd__': '{}@python'.format(name) if language == 'python' else name,
|
|
'__extra_attributes__': extra_attributes,
|
|
'__platform__': platform,
|
|
'__install__': info['installer'].format(name),
|
|
'__extra_install_steps__': extra_steps
|
|
}
|
|
|
|
for root, dirs, files in os.walk(template_dir):
|
|
for filename in files:
|
|
extension = os.path.splitext(filename)[1]
|
|
|
|
if extension in ('.py', '.md', '.txt'):
|
|
path = os.path.join(root, filename)
|
|
|
|
with open(path, encoding='utf-8') as f:
|
|
text = f.read()
|
|
|
|
for placeholder, value in placeholders.items():
|
|
text = text.replace(placeholder, value)
|
|
|
|
with open(path, mode='w', encoding='utf-8') as f:
|
|
f.write(text)
|
|
|
|
return True
|
|
|
|
def camel_case(self, name):
|
|
"""Convert and return a name in the form foo-bar to FooBar."""
|
|
camel_name = name[0].capitalize()
|
|
i = 1
|
|
|
|
while i < len(name):
|
|
if name[i] == '-' and i < len(name) - 1:
|
|
camel_name += name[i + 1].capitalize()
|
|
i += 1
|
|
else:
|
|
camel_name += name[i]
|
|
|
|
i += 1
|
|
|
|
return camel_name
|
|
|
|
def wait_for_open(self, dest):
|
|
"""Wait for new linter window to open in another thread."""
|
|
|
|
def open_linter_py():
|
|
"""Wait until the new linter window has opened and open linter.py."""
|
|
|
|
start = datetime.datetime.now()
|
|
|
|
while True:
|
|
time.sleep(0.25)
|
|
delta = datetime.datetime.now() - start
|
|
|
|
# Wait a maximum of 5 seconds
|
|
if delta.seconds > 5:
|
|
break
|
|
|
|
window = sublime.active_window()
|
|
folders = window.folders()
|
|
|
|
if folders and folders[0] == dest:
|
|
window.open_file(os.path.join(dest, 'linter.py'))
|
|
break
|
|
|
|
sublime.set_timeout_async(open_linter_py, 0)
|
|
|
|
|
|
class SublimelinterPackageControlCommand(sublime_plugin.WindowCommand):
|
|
|
|
"""
|
|
Abstract superclass for Package Control utility commands.
|
|
|
|
Only works if git is installed.
|
|
|
|
"""
|
|
|
|
TAG_RE = re.compile(r'(?P<major>\d+)\.(?P<minor>\d+)\.(?P<release>\d+)(?:\+\d+)?')
|
|
|
|
def __init__(self, window):
|
|
super().__init__(window)
|
|
self.git = ''
|
|
|
|
def is_visible(self, paths=[]):
|
|
"""Return True if any eligible plugin directories are selected."""
|
|
|
|
if self.git == '':
|
|
self.git = util.which('git')
|
|
|
|
if self.git:
|
|
for path in paths:
|
|
if self.is_eligible_path(path):
|
|
return True
|
|
|
|
return False
|
|
|
|
def is_eligible_path(self, path):
|
|
"""
|
|
Return True if path is an eligible directory.
|
|
|
|
A directory is eligible if it is a direct child of Packages,
|
|
has a messages subdirectory, and has messages.json.
|
|
|
|
"""
|
|
packages_path = sublime.packages_path()
|
|
|
|
return (
|
|
os.path.isdir(path) and
|
|
os.path.dirname(path) == packages_path and
|
|
os.path.isdir(os.path.join(path, 'messages')) and
|
|
os.path.isfile(os.path.join(path, 'messages.json'))
|
|
)
|
|
|
|
def get_current_tag(self):
|
|
"""
|
|
Return the most recent tag components.
|
|
|
|
A tuple of (major, minor, release) is returned, or (1, 0, 0) if there are no tags.
|
|
If the most recent tag does not conform to semver, return (None, None, None).
|
|
|
|
"""
|
|
|
|
tag = util.communicate(['git', 'describe', '--tags', '--abbrev=0']).strip()
|
|
|
|
if not tag:
|
|
return (1, 0, 0)
|
|
|
|
match = self.TAG_RE.match(tag)
|
|
|
|
if match:
|
|
return (int(match.group('major')), int(match.group('minor')), int(match.group('release')))
|
|
else:
|
|
return None
|
|
|
|
|
|
class SublimelinterNewPackageControlMessageCommand(SublimelinterPackageControlCommand):
|
|
|
|
"""
|
|
This command automates the process of creating new Package Control release messages.
|
|
|
|
It creates a new entry in messages.json for the next version
|
|
and creates a new file named messages/<version>.txt.
|
|
|
|
"""
|
|
|
|
COMMIT_MSG_RE = re.compile(r'{{{{(.+?)}}}}')
|
|
|
|
def __init__(self, window):
|
|
super().__init__(window)
|
|
|
|
def run(self, paths=[]):
|
|
"""Run the command."""
|
|
|
|
for path in paths:
|
|
if self.is_eligible_path(path):
|
|
self.make_new_version_message(path)
|
|
|
|
def make_new_version_message(self, path):
|
|
"""Make a new version message for the repo at the given path."""
|
|
|
|
try:
|
|
cwd = os.getcwd()
|
|
os.chdir(path)
|
|
|
|
version = self.get_current_tag()
|
|
|
|
if version[0] is None:
|
|
return
|
|
|
|
messages_path = os.path.join(path, 'messages.json')
|
|
message_path = self.rewrite_messages_json(messages_path, version)
|
|
|
|
if os.path.exists(message_path):
|
|
os.remove(message_path)
|
|
|
|
with open(message_path, mode='w', encoding='utf-8') as f:
|
|
header = '{} {}'.format(
|
|
os.path.basename(path),
|
|
os.path.splitext(os.path.basename(message_path))[0])
|
|
f.write('{}\n{}\n'.format(header, '-' * (len(header) + 1)))
|
|
f.write(self.get_commit_messages_since(version))
|
|
|
|
self.window.run_command('open_file', args={'file': message_path})
|
|
|
|
except Exception:
|
|
import traceback
|
|
traceback.print_exc()
|
|
finally:
|
|
os.chdir(cwd)
|
|
|
|
def rewrite_messages_json(self, messages_path, tag):
|
|
"""Add an entry in messages.json for tag, return relative path to the file."""
|
|
|
|
with open(messages_path, encoding='utf-8') as f:
|
|
messages = json.load(f)
|
|
|
|
major, minor, release = tag
|
|
release += 1
|
|
tag = '{}.{}.{}'.format(major, minor, release)
|
|
message_path = os.path.join('messages', '{}.txt'.format(tag))
|
|
messages[tag] = message_path
|
|
message_path = os.path.join(os.path.dirname(messages_path), message_path)
|
|
|
|
with open(messages_path, mode='w', encoding='utf-8') as f:
|
|
messages_json = '{\n'
|
|
sorted_messages = []
|
|
|
|
if 'install' in messages:
|
|
install_message = messages.pop('install')
|
|
sorted_messages.append(' "install": "{}"'.format(install_message))
|
|
|
|
keys = sorted(map(self.sortable_tag, messages.keys()))
|
|
|
|
for _, key in keys:
|
|
sorted_messages.append(' "{}": "{}"'.format(key, messages[key]))
|
|
|
|
messages_json += ',\n'.join(sorted_messages)
|
|
messages_json += '\n}\n'
|
|
f.write(messages_json)
|
|
|
|
return message_path
|
|
|
|
def sortable_tag(self, tag):
|
|
"""Return a version tag in a sortable form."""
|
|
|
|
if tag == 'install':
|
|
return (tag, tag)
|
|
|
|
major, minor, release = tag.split('.')
|
|
|
|
if '+' in release:
|
|
release, update = release.split('+')
|
|
update = '+{:04}'.format(int(update))
|
|
else:
|
|
update = ''
|
|
|
|
return ('{:04}.{:04}.{:04}{}'.format(int(major), int(minor), int(release), update), tag)
|
|
|
|
def get_commit_messages_since(self, version):
|
|
"""Return a formatted list of commit messages since the given tagged version."""
|
|
|
|
tag = '{}.{}.{}'.format(*version)
|
|
output = util.communicate([
|
|
'git', 'log',
|
|
'--pretty=format:{{{{%w(0,0,0)%s %b}}}}',
|
|
'--reverse', tag + '..'
|
|
])
|
|
|
|
# Split the messages, they are bounded by {{{{ }}}}
|
|
messages = []
|
|
|
|
for match in self.COMMIT_MSG_RE.finditer(output):
|
|
messages.append(match.group(1).strip())
|
|
|
|
# Wrap the messages
|
|
wrapper = TextWrapper(initial_indent='- ', subsequent_indent=' ')
|
|
messages = list(map(lambda msg: '\n'.join(wrapper.wrap(msg)), messages))
|
|
return '\n\n'.join(messages) + '\n'
|
|
|
|
|
|
class SublimelinterReportCommand(sublime_plugin.WindowCommand):
|
|
|
|
"""
|
|
A command that displays a report of all errors.
|
|
|
|
The scope of the report is all open files in the current window,
|
|
all files in all folders in the current window, or both.
|
|
|
|
"""
|
|
|
|
def run(self, on='files'):
|
|
"""Run the command. on determines the scope of the report."""
|
|
|
|
output = self.window.new_file()
|
|
output.set_name('{} Error Report'.format(persist.PLUGIN_NAME))
|
|
output.set_scratch(True)
|
|
|
|
from .sublimelinter import SublimeLinter
|
|
self.plugin = SublimeLinter.shared_plugin()
|
|
|
|
if on == 'files' or on == 'both':
|
|
for view in self.window.views():
|
|
self.report(output, view)
|
|
|
|
if on == 'folders' or on == 'both':
|
|
for folder in self.window.folders():
|
|
self.folder(output, folder)
|
|
|
|
def folder(self, output, folder):
|
|
"""Report on all files in a folder."""
|
|
|
|
for root, dirs, files in os.walk(folder):
|
|
for name in files:
|
|
path = os.path.join(root, name)
|
|
|
|
# Ignore files over 256K to speed things up a bit
|
|
if os.stat(path).st_size < 256 * 1024:
|
|
# TODO: not implemented
|
|
pass
|
|
|
|
def report(self, output, view):
|
|
"""Write a report on the given view to output."""
|
|
|
|
def finish_lint(view, linters, hit_time):
|
|
if not linters:
|
|
return
|
|
|
|
def insert(edit):
|
|
if not any(l.errors for l in linters):
|
|
return
|
|
|
|
filename = os.path.basename(linters[0].filename or 'untitled')
|
|
out = '\n{}:\n'.format(filename)
|
|
|
|
for linter in sorted(linters, key=lambda linter: linter.name):
|
|
if linter.errors:
|
|
out += '\n {}:\n'.format(linter.name)
|
|
items = sorted(linter.errors.items())
|
|
|
|
# Get the highest line number so we know how much padding numbers need
|
|
highest_line = items[-1][0]
|
|
width = 1
|
|
|
|
while highest_line >= 10:
|
|
highest_line /= 10
|
|
width += 1
|
|
|
|
for line, messages in items:
|
|
for col, message in messages:
|
|
out += ' {:>{width}}: {}\n'.format(line, message, width=width)
|
|
|
|
output.insert(edit, output.size(), out)
|
|
|
|
persist.edits[output.id()].append(insert)
|
|
output.run_command('sublimelinter_edit')
|
|
|
|
kwargs = {'self': self.plugin, 'view_id': view.id(), 'callback': finish_lint}
|
|
|
|
from .sublimelinter import SublimeLinter
|
|
Thread(target=SublimeLinter.lint, kwargs=kwargs).start()
|