442 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			442 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
| # -*- 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']}))
 |