feat(SublimeText2.EditorPackages): cache packages
This commit is contained in:
@@ -0,0 +1,441 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
## TODO: Implement TODO_IGNORE setting (http://mdeering.com/posts/004-get-your-textmate-todos-and-fixmes-under-control)
|
||||
## TODO: Make the output clickable (å la find results)
|
||||
## TODO: Occasional NoneType bug
|
||||
## todo: Make the sections foldable (define them as regions?)
|
||||
|
||||
""""""
|
||||
|
||||
from collections import namedtuple
|
||||
from datetime import datetime
|
||||
import functools
|
||||
import fnmatch
|
||||
from itertools import groupby
|
||||
import logging
|
||||
from os import path, walk
|
||||
import re
|
||||
import threading
|
||||
|
||||
import sublime
|
||||
import sublime_plugin
|
||||
|
||||
|
||||
DEBUG = True
|
||||
|
||||
DEFAULT_SETTINGS = {
|
||||
'result_title': 'TODO Results',
|
||||
|
||||
'core_patterns': {
|
||||
'TODO': r'TODO[\s]*?:+(?P<todo>.*)$',
|
||||
'NOTE': r'NOTE[\s]*?:+(?P<note>.*)$',
|
||||
'FIXME': r'FIX ?ME[\s]*?:+(?P<fixme>.*)$',
|
||||
'CHANGED': r'CHANGED[\s]*?:+(?P<changed>.*)$'
|
||||
},
|
||||
|
||||
'patterns': {}
|
||||
}
|
||||
|
||||
Message = namedtuple('Message', 'type, msg')
|
||||
|
||||
## LOGGING SETUP
|
||||
try:
|
||||
from logging import NullHandler
|
||||
except ImportError:
|
||||
class NullHandler(logging.Handler):
|
||||
def handle(self, record):
|
||||
pass
|
||||
|
||||
def emit(self, record):
|
||||
pass
|
||||
|
||||
def createLock(self):
|
||||
self.lock = None
|
||||
|
||||
log = logging.getLogger('SublimeTODO')
|
||||
log.handlers = [] ## hack to prevent extraneous handlers on ST2 auto-reload
|
||||
log.addHandler(NullHandler())
|
||||
log.setLevel(logging.INFO)
|
||||
if DEBUG:
|
||||
log.addHandler(logging.StreamHandler())
|
||||
log.setLevel(logging.DEBUG)
|
||||
|
||||
|
||||
def do_when(conditional, callback, *args, **kwargs):
|
||||
if conditional():
|
||||
return callback(*args, **kwargs)
|
||||
sublime.set_timeout(functools.partial(do_when, conditional, callback, *args, **kwargs), 50)
|
||||
|
||||
|
||||
class Settings(dict):
|
||||
"""Combine default and user settings"""
|
||||
def __init__(self, user_settings):
|
||||
settings = DEFAULT_SETTINGS.copy()
|
||||
settings.update(user_settings)
|
||||
## Combine core_patterns and patterns
|
||||
settings['core_patterns'].update(settings['patterns'])
|
||||
settings['patterns'] = settings.pop('core_patterns')
|
||||
super(Settings, self).__init__(settings)
|
||||
|
||||
|
||||
class ThreadProgress(object):
|
||||
def __init__(self, thread, message, success_message, file_counter):
|
||||
self.thread = thread
|
||||
self.message = message
|
||||
self.success_message = success_message
|
||||
self.file_counter = file_counter
|
||||
self.addend = 1
|
||||
self.size = 8
|
||||
sublime.set_timeout(lambda: self.run(0), 100)
|
||||
|
||||
def run(self, i):
|
||||
if not self.thread.is_alive():
|
||||
if hasattr(self.thread, 'result') and not self.thread.result:
|
||||
sublime.status_message('')
|
||||
return
|
||||
sublime.status_message(self.success_message)
|
||||
return
|
||||
|
||||
before = i % self.size
|
||||
after = (self.size - 1) - before
|
||||
sublime.status_message('%s [%s=%s] (%s files scanned)' % \
|
||||
(self.message, ' ' * before, ' ' * after, self.file_counter))
|
||||
if not after:
|
||||
self.addend = -1
|
||||
if not before:
|
||||
self.addend = 1
|
||||
i += self.addend
|
||||
sublime.set_timeout(lambda: self.run(i), 100)
|
||||
|
||||
|
||||
|
||||
class TodoExtractor(object):
|
||||
def __init__(self, settings, filepaths, dirpaths, ignored_dirs, ignored_file_patterns,
|
||||
file_counter):
|
||||
self.filepaths = filepaths
|
||||
self.dirpaths = dirpaths
|
||||
self.patterns = settings['patterns']
|
||||
self.settings = settings
|
||||
self.file_counter = file_counter
|
||||
self.ignored_dirs = ignored_dirs
|
||||
self.ignored_files = ignored_file_patterns
|
||||
self.log = logging.getLogger('SublimeTODO.extractor')
|
||||
|
||||
def iter_files(self):
|
||||
""""""
|
||||
seen_paths_ = []
|
||||
files = self.filepaths
|
||||
dirs = self.dirpaths
|
||||
exclude_dirs = self.ignored_dirs
|
||||
|
||||
for filepath in files:
|
||||
pth = path.realpath(path.abspath(filepath))
|
||||
if pth not in seen_paths_:
|
||||
seen_paths_.append(pth)
|
||||
yield pth
|
||||
|
||||
for dirpath in dirs:
|
||||
dirpath = path.abspath(dirpath)
|
||||
for dirpath, dirnames, filenames in walk(dirpath):
|
||||
## remove excluded dirs
|
||||
## TODO: These are not patterns. Consider making them glob patterns
|
||||
for dir in exclude_dirs:
|
||||
if dir in dirnames:
|
||||
self.log.debug(u'Ignoring dir: {0}'.format(dir))
|
||||
dirnames.remove(dir)
|
||||
|
||||
for filepath in filenames:
|
||||
pth = path.join(dirpath, filepath)
|
||||
pth = path.realpath(path.abspath(pth))
|
||||
if pth not in seen_paths_:
|
||||
seen_paths_.append(pth)
|
||||
yield pth
|
||||
|
||||
def filter_files(self, files):
|
||||
""""""
|
||||
exclude_patterns = [re.compile(patt) for patt in self.ignored_files]
|
||||
for filepath in files:
|
||||
if any(patt.match(filepath) for patt in exclude_patterns):
|
||||
continue
|
||||
yield filepath
|
||||
|
||||
def search_targets(self):
|
||||
"""Yield filtered filepaths for message extraction"""
|
||||
return self.filter_files(self.iter_files())
|
||||
|
||||
def extract(self):
|
||||
""""""
|
||||
message_patterns = '|'.join(self.patterns.values())
|
||||
case_sensitivity = 0 if self.settings.get('case_sensitive', False) else re.IGNORECASE
|
||||
patt = re.compile(message_patterns, case_sensitivity)
|
||||
for filepath in self.search_targets():
|
||||
try:
|
||||
f = open(filepath)
|
||||
self.log.debug(u'Scanning {0}'.format(filepath))
|
||||
for linenum, line in enumerate(f):
|
||||
for mo in patt.finditer(line):
|
||||
## Remove the non-matched groups
|
||||
matches = [Message(msg_type, msg) for msg_type, msg in mo.groupdict().iteritems() if msg]
|
||||
for match in matches:
|
||||
yield {'filepath': filepath, 'linenum': linenum + 1, 'match': match}
|
||||
except IOError:
|
||||
## Probably a broken symlink
|
||||
f = None
|
||||
finally:
|
||||
self.file_counter.increment()
|
||||
if f is not None:
|
||||
f.close()
|
||||
|
||||
|
||||
class TodoRenderer(object):
|
||||
def __init__(self, settings, window, file_counter):
|
||||
self.window = window
|
||||
self.settings = settings
|
||||
self.file_counter = file_counter
|
||||
|
||||
@property
|
||||
def view_name(self):
|
||||
"""The name of the new results view. Defined in settings."""
|
||||
return self.settings['result_title']
|
||||
|
||||
@property
|
||||
def header(self):
|
||||
hr = u'+ {0} +'.format('-' * 76)
|
||||
return u'{hr}\n| TODOS @ {0:<68} |\n| {1:<76} |\n{hr}\n'.format(
|
||||
datetime.now().strftime('%A %d %B %Y %H:%M').decode("utf-8"),
|
||||
u'{0} files scanned'.format(self.file_counter),
|
||||
hr=hr)
|
||||
|
||||
@property
|
||||
def view(self):
|
||||
existing_results = [v for v in self.window.views()
|
||||
if v.name() == self.view_name and v.is_scratch()]
|
||||
if existing_results:
|
||||
v = existing_results[0]
|
||||
else:
|
||||
v = self.window.new_file()
|
||||
v.set_name(self.view_name)
|
||||
v.set_scratch(True)
|
||||
v.settings().set('todo_results', True)
|
||||
return v
|
||||
|
||||
def format(self, messages):
|
||||
"""Yield lines for rendering into results view. Includes headers and
|
||||
blank lines.
|
||||
Lines are returned in the form (type, content, [data]) where type is either
|
||||
'header', 'whitespace' or 'result'
|
||||
"""
|
||||
key_func = lambda m: m['match'].type
|
||||
messages = sorted(messages, key=key_func)
|
||||
|
||||
for message_type, matches in groupby(messages, key=key_func):
|
||||
matches = list(matches)
|
||||
if matches:
|
||||
yield ('header', u'\n## {0} ({1})'.format(message_type.upper().decode('utf8', 'ignore'), len(matches)), {})
|
||||
for idx, m in enumerate(matches, 1):
|
||||
msg = m['match'].msg.decode('utf8', 'ignore') ## Don't know the file encoding
|
||||
filepath = path.basename(m['filepath'])
|
||||
line = u"{idx}. {filepath}:{linenum} {msg}".format(
|
||||
idx=idx, filepath=filepath, linenum=m['linenum'], msg=msg)
|
||||
yield ('result', line, m)
|
||||
|
||||
def render_to_view(self, formatted_results):
|
||||
"""This blocks the main thread, so make it quick"""
|
||||
## Header
|
||||
result_view = self.view
|
||||
edit = result_view.begin_edit()
|
||||
result_view.erase(edit, sublime.Region(0, result_view.size()))
|
||||
result_view.insert(edit, result_view.size(), self.header)
|
||||
result_view.end_edit(edit)
|
||||
|
||||
## Region : match_dicts
|
||||
regions = {}
|
||||
|
||||
## Result sections
|
||||
for linetype, line, data in formatted_results:
|
||||
edit = result_view.begin_edit()
|
||||
insert_point = result_view.size()
|
||||
result_view.insert(edit, insert_point, line)
|
||||
if linetype == 'result':
|
||||
rgn = sublime.Region(insert_point, result_view.size())
|
||||
regions[rgn] = data
|
||||
result_view.insert(edit, result_view.size(), u'\n')
|
||||
result_view.end_edit(edit)
|
||||
|
||||
result_view.add_regions('results', regions.keys(), '')
|
||||
|
||||
## Store {Region : data} map in settings
|
||||
## TODO: Abstract this out to a storage class Storage.get(region) ==> data dict
|
||||
## Region() cannot be stored in settings, so convert to a primitive type
|
||||
# d_ = regions
|
||||
d_ = dict(('{0},{1}'.format(k.a, k.b), v) for k, v in regions.iteritems())
|
||||
result_view.settings().set('result_regions', d_)
|
||||
|
||||
## Set syntax and settings
|
||||
result_view.set_syntax_file('Packages/SublimeTODO/todo_results.hidden-tmLanguage')
|
||||
result_view.settings().set('line_padding_bottom', 2)
|
||||
result_view.settings().set('line_padding_top', 2)
|
||||
result_view.settings().set('word_wrap', False)
|
||||
result_view.settings().set('command_mode', True)
|
||||
self.window.focus_view(result_view)
|
||||
|
||||
|
||||
class WorkerThread(threading.Thread):
|
||||
def __init__(self, extractor, renderer):
|
||||
self.extractor = extractor
|
||||
self.renderer = renderer
|
||||
threading.Thread.__init__(self)
|
||||
|
||||
def run(self):
|
||||
## Extract in this thread
|
||||
todos = self.extractor.extract()
|
||||
rendered = list(self.renderer.format(todos))
|
||||
|
||||
## Render into new window in main thread
|
||||
def render():
|
||||
self.renderer.render_to_view(rendered)
|
||||
sublime.set_timeout(render, 10)
|
||||
|
||||
|
||||
class FileScanCounter(object):
|
||||
"""Thread-safe counter used to update the status bar"""
|
||||
def __init__(self):
|
||||
self.ct = 0
|
||||
self.lock = threading.RLock()
|
||||
self.log = logging.getLogger('SublimeTODO')
|
||||
|
||||
def __call__(self, filepath):
|
||||
self.log.debug(u'Scanning %s' % filepath)
|
||||
self.increment()
|
||||
|
||||
def __str__(self):
|
||||
with self.lock:
|
||||
return '%d' % self.ct
|
||||
|
||||
def increment(self):
|
||||
with self.lock:
|
||||
self.ct += 1
|
||||
|
||||
def reset(self):
|
||||
with self.lock:
|
||||
self.ct = 0
|
||||
|
||||
|
||||
class TodoCommand(sublime_plugin.TextCommand):
|
||||
|
||||
def search_paths(self, window, open_files_only=False):
|
||||
"""Return (filepaths, dirpaths)"""
|
||||
return (
|
||||
[view.file_name() for view in window.views() if view.file_name()],
|
||||
window.folders() if not open_files_only else []
|
||||
)
|
||||
|
||||
def run(self, edit, open_files_only=False):
|
||||
window = self.view.window()
|
||||
settings = Settings(self.view.settings().get('todo', {}))
|
||||
|
||||
|
||||
## TODO: Cleanup this init code. Maybe move it to the settings object
|
||||
filepaths, dirpaths = self.search_paths(window, open_files_only=open_files_only)
|
||||
|
||||
ignored_dirs = settings.get('folder_exclude_patterns', [])
|
||||
## Get exclude patterns from global settings
|
||||
## Is there really no better way to access global settings?
|
||||
global_settings = sublime.load_settings('Global.sublime-settings')
|
||||
ignored_dirs.extend(global_settings.get('folder_exclude_patterns', []))
|
||||
|
||||
exclude_file_patterns = settings.get('file_exclude_patterns', [])
|
||||
exclude_file_patterns.extend(global_settings.get('file_exclude_patterns', []))
|
||||
exclude_file_patterns.extend(global_settings.get('binary_file_patterns', []))
|
||||
exclude_file_patterns = [fnmatch.translate(patt) for patt in exclude_file_patterns]
|
||||
|
||||
file_counter = FileScanCounter()
|
||||
extractor = TodoExtractor(settings, filepaths, dirpaths, ignored_dirs,
|
||||
exclude_file_patterns, file_counter)
|
||||
renderer = TodoRenderer(settings, window, file_counter)
|
||||
|
||||
worker_thread = WorkerThread(extractor, renderer)
|
||||
worker_thread.start()
|
||||
ThreadProgress(worker_thread, 'Finding TODOs', '', file_counter)
|
||||
|
||||
|
||||
class NavigateResults(sublime_plugin.TextCommand):
|
||||
DIRECTION = {'forward': 1, 'backward': -1}
|
||||
STARTING_POINT = {'forward': -1, 'backward': 0}
|
||||
|
||||
def __init__(self, view):
|
||||
super(NavigateResults, self).__init__(view)
|
||||
|
||||
def run(self, edit, direction):
|
||||
view = self.view
|
||||
settings = view.settings()
|
||||
results = self.view.get_regions('results')
|
||||
if not results:
|
||||
sublime.status_message('No results to navigate')
|
||||
return
|
||||
|
||||
##NOTE: numbers stored in settings are coerced to floats or longs
|
||||
selection = int(settings.get('selected_result', self.STARTING_POINT[direction]))
|
||||
selection = selection + self.DIRECTION[direction]
|
||||
try:
|
||||
target = results[selection]
|
||||
except IndexError:
|
||||
target = results[0]
|
||||
selection = 0
|
||||
|
||||
settings.set('selected_result', selection)
|
||||
## Create a new region for highlighting
|
||||
target = target.cover(target)
|
||||
view.add_regions('selection', [target], 'selected', 'dot')
|
||||
view.show(target)
|
||||
|
||||
|
||||
class ClearSelection(sublime_plugin.TextCommand):
|
||||
def run(self, edit):
|
||||
self.view.erase_regions('selection')
|
||||
self.view.settings().erase('selected_result')
|
||||
|
||||
|
||||
class GotoComment(sublime_plugin.TextCommand):
|
||||
def __init__(self, *args):
|
||||
self.log = logging.getLogger('SublimeTODO.nav')
|
||||
super(GotoComment, self).__init__(*args)
|
||||
|
||||
def run(self, edit):
|
||||
## Get the idx of selected result region
|
||||
selection = int(self.view.settings().get('selected_result', -1))
|
||||
## Get the region
|
||||
selected_region = self.view.get_regions('results')[selection]
|
||||
## Convert region to key used in result_regions (this is tedious, but
|
||||
## there is no other way to store regions with associated data)
|
||||
data = self.view.settings().get('result_regions')['{0},{1}'.format(selected_region.a, selected_region.b)]
|
||||
self.log.debug(u'Goto comment at {filepath}:{linenum}'.format(**data))
|
||||
new_view = self.view.window().open_file(data['filepath'])
|
||||
do_when(lambda: not new_view.is_loading(), lambda: new_view.run_command("goto_line", {"line": data['linenum']}))
|
||||
|
||||
|
||||
class MouseGotoComment(sublime_plugin.TextCommand):
|
||||
def __init__(self, *args):
|
||||
self.log = logging.getLogger('SublimeTODO.nav')
|
||||
super(MouseGotoComment, self).__init__(*args)
|
||||
|
||||
def highlight(self, region):
|
||||
target = region.cover(region)
|
||||
self.view.add_regions('selection', [target], 'selected', 'dot')
|
||||
self.view.show(target)
|
||||
|
||||
def get_result_region(self, pos):
|
||||
line = self.view.line(pos)
|
||||
return line
|
||||
|
||||
def run(self, edit):
|
||||
if not self.view.settings().get('result_regions'):
|
||||
return
|
||||
## get selected line
|
||||
pos = self.view.sel()[0].end()
|
||||
result = self.get_result_region(pos)
|
||||
self.highlight(result)
|
||||
data = self.view.settings().get('result_regions')['{0},{1}'.format(result.a, result.b)]
|
||||
self.log.debug(u'Goto comment at {filepath}:{linenum}'.format(**data))
|
||||
new_view = self.view.window().open_file(data['filepath'])
|
||||
do_when(lambda: not new_view.is_loading(), lambda: new_view.run_command("goto_line", {"line": data['linenum']}))
|
Reference in New Issue
Block a user