454 lines
16 KiB
Python
454 lines
16 KiB
Python
'''
|
|
Provides both a trailing spaces highlighter and a deletion command.
|
|
|
|
See README.md for details.
|
|
|
|
@author: Jean-Denis Vauguet <jd@vauguet.fr>, Oktay Acikalin <ok@ryotic.de>
|
|
@license: MIT (http://www.opensource.org/licenses/mit-license.php)
|
|
@since: 2011-02-25
|
|
'''
|
|
|
|
import sublime
|
|
import sublime_plugin
|
|
import difflib
|
|
import codecs
|
|
|
|
DEFAULT_MAX_FILE_SIZE = 1048576
|
|
DEFAULT_IS_ENABLED = True
|
|
DEFAULT_MODIFIED_LINES_ONLY = False
|
|
|
|
# Global settings object and flags.
|
|
# Flags duplicate some of the (core) JSON settings, in case the settings file has
|
|
# been corrupted or is empty (ST2 really dislikes that!)
|
|
ts_settings_filename = "trailing_spaces.sublime-settings"
|
|
ts_settings = None
|
|
trailing_spaces_live_matching = DEFAULT_IS_ENABLED
|
|
trim_modified_lines_only = DEFAULT_MODIFIED_LINES_ONLY
|
|
startup_queue = []
|
|
on_disk = None
|
|
|
|
|
|
# Private: Loads settings and sets whether the plugin (live matching) is enabled.
|
|
#
|
|
# Returns nothing.
|
|
def plugin_loaded():
|
|
global ts_settings_filename, ts_settings, trailing_spaces_live_matching
|
|
global current_highlighting_scope, trim_modified_lines_only, startup_queue
|
|
global DEFAULT_COLOR_SCOPE_NAME, on_disk
|
|
|
|
ts_settings = sublime.load_settings(ts_settings_filename)
|
|
trailing_spaces_live_matching = bool(ts_settings.get("trailing_spaces_enabled",
|
|
DEFAULT_IS_ENABLED))
|
|
current_highlighting_scope = ts_settings.get("trailing_spaces_highlight_color",
|
|
"invalid")
|
|
DEFAULT_COLOR_SCOPE_NAME = current_highlighting_scope
|
|
trim_modified_lines_only = bool(ts_settings.get("trailing_spaces_modified_lines_only",
|
|
DEFAULT_MODIFIED_LINES_ONLY))
|
|
|
|
if trailing_spaces_live_matching:
|
|
for view in startup_queue:
|
|
match_trailing_spaces(view)
|
|
else:
|
|
current_highlighting_scope = ""
|
|
if ts_settings.get("trailing_spaces_highlight_color") != current_highlighting_scope:
|
|
persist_settings()
|
|
|
|
|
|
# Private: Updates user's settings with in-memory values.
|
|
#
|
|
# Allows for persistent settings from the menu.
|
|
#
|
|
# Returns nothing.
|
|
def persist_settings():
|
|
sublime.save_settings(ts_settings_filename)
|
|
|
|
|
|
# Private: Determine if the view is a "Find results" view.
|
|
#
|
|
# view - the view, you know
|
|
#
|
|
# Returns True or False.
|
|
def is_find_results(view):
|
|
return view.settings().get('syntax') and "Find Results" in view.settings().get('syntax')
|
|
|
|
|
|
# Private: Get the regions matching trailing spaces.
|
|
#
|
|
# As the core regexp matches lines, the regions are, well, "per lines".
|
|
#
|
|
# view - the view, you know
|
|
#
|
|
# Returns both the list of regions which map to trailing spaces and the list of
|
|
# regions which are to be highlighted, as a list [matched, highlightable].
|
|
def find_trailing_spaces(view):
|
|
sel = view.sel()[0]
|
|
line = view.line(sel.b)
|
|
include_empty_lines = bool(ts_settings.get("trailing_spaces_include_empty_lines",
|
|
DEFAULT_IS_ENABLED))
|
|
include_current_line = bool(ts_settings.get("trailing_spaces_include_current_line",
|
|
DEFAULT_IS_ENABLED))
|
|
regexp = ts_settings.get("trailing_spaces_regexp") + "$"
|
|
no_empty_lines_regexp = "(?<=\S)%s$" % regexp
|
|
|
|
offending_lines = view.find_all(regexp if include_empty_lines else no_empty_lines_regexp)
|
|
|
|
if include_current_line:
|
|
return [offending_lines, offending_lines]
|
|
else:
|
|
current_offender = view.find(regexp if include_empty_lines else no_empty_lines_regexp, line.a)
|
|
removal = False if current_offender == None else line.intersects(current_offender)
|
|
highlightable = [i for i in offending_lines if i != current_offender] if removal else offending_lines
|
|
return [offending_lines, highlightable]
|
|
|
|
|
|
# Private: Find the fraking trailing spaces in the view and flags them as such!
|
|
#
|
|
# It will refresh highlighted regions as well. Does not execute if the
|
|
# document's size exceeds the file_max_size setting, or if the fired in a view
|
|
# which is not a legacy document (helper/build views and so on).
|
|
#
|
|
# view - the view, you know
|
|
#
|
|
# Returns nothing.
|
|
def match_trailing_spaces(view):
|
|
if ts_settings is None:
|
|
startup_queue.append(view)
|
|
return
|
|
|
|
# Silently pass if file is too big.
|
|
if max_size_exceeded(view):
|
|
return
|
|
|
|
if not is_find_results(view):
|
|
(matched, highlightable) = find_trailing_spaces(view)
|
|
add_trailing_spaces_regions(view, matched)
|
|
highlight_trailing_spaces_regions(view, highlightable)
|
|
|
|
|
|
# Private: Checks whether the document is bigger than the max_size setting.
|
|
#
|
|
# view - the view, you know
|
|
#
|
|
# Returns True or False.
|
|
def max_size_exceeded(view):
|
|
return view.size() > ts_settings.get('trailing_spaces_file_max_size',
|
|
DEFAULT_MAX_FILE_SIZE)
|
|
|
|
|
|
# Private: Marks specified regions as trailing spaces.
|
|
#
|
|
# view - the view, you know
|
|
# regions - regions qualified as trailing spaces
|
|
#
|
|
# Returns nothing.
|
|
def add_trailing_spaces_regions(view, regions):
|
|
view.erase_regions('TrailingSpacesMatchedRegions')
|
|
view.add_regions('TrailingSpacesMatchedRegions',
|
|
regions,
|
|
"",
|
|
"",
|
|
sublime.HIDE_ON_MINIMAP)
|
|
|
|
|
|
# Private: Highlights specified regions as trailing spaces.
|
|
#
|
|
# It will use the scope enforced by the state of the toggable highlighting.
|
|
#
|
|
# view - the view, you know
|
|
# regions - regions qualified as trailing spaces
|
|
#
|
|
# Returns nothing.
|
|
def highlight_trailing_spaces_regions(view, regions):
|
|
view.erase_regions("TrailingSpacesHighlightedRegions")
|
|
view.add_regions('TrailingSpacesHighlightedRegions',
|
|
regions,
|
|
current_highlighting_scope or "",
|
|
"",
|
|
sublime.HIDE_ON_MINIMAP)
|
|
|
|
|
|
# Private: Toggles highlighting of all trailing spaces in the view.
|
|
#
|
|
# It has no effect is the plugin is disabled.
|
|
#
|
|
# view - the view, you know
|
|
#
|
|
# Returns True (highlighting was turned on) or False (turned off).
|
|
def toggle_highlighting(view):
|
|
global current_highlighting_scope
|
|
|
|
# If the scope is that of an invisible, there is nothing to toggle.
|
|
if DEFAULT_COLOR_SCOPE_NAME == "":
|
|
return "disabled!"
|
|
|
|
# If performing live, highlighted trailing regions must be updated
|
|
# internally.
|
|
if not trailing_spaces_live_matching:
|
|
(matched, highlightable) = find_trailing_spaces(view)
|
|
highlight_trailing_spaces_regions(view, highlightable)
|
|
|
|
scope = DEFAULT_COLOR_SCOPE_NAME if current_highlighting_scope == "" else ""
|
|
current_highlighting_scope = scope
|
|
highlight_trailing_spaces_regions(view, view.get_regions('TrailingSpacesHighlightedRegions'))
|
|
return "off" if current_highlighting_scope == "" else "on"
|
|
|
|
|
|
# Clear all the highlighted regions in all views.
|
|
#
|
|
# FIXME: this is not used! Delete?
|
|
#
|
|
# window - the window, you know
|
|
#
|
|
# Returns nothing.
|
|
def clear_trailing_spaces_highlight(window):
|
|
for view in window.views():
|
|
view.erase_regions('TrailingSpacesMatchedRegions')
|
|
|
|
|
|
# Find edited lines since last save, as line numbers, based on diff.
|
|
#
|
|
# It uses a Differ object to compute the diff between the file as red on the
|
|
# disk, and the current buffer (which may differ from the disk's state). See
|
|
# http://docs.python.org/2/library/difflib.html for details about diff codes.
|
|
#
|
|
# It relies on a full diff, so it may be expensive computation for very large
|
|
# files (diff generation + looping through all lines).
|
|
#
|
|
# old - a buffer of lines, as in "old version"
|
|
# new - a buffer of lines, as in "new version"
|
|
#
|
|
# Returns the list of edited line numbers.
|
|
def modified_lines_as_numbers(old, new):
|
|
d = difflib.Differ()
|
|
diffs = d.compare(old, new)
|
|
|
|
# Pretty Naive Algorithm (tm):
|
|
# - split off the "Differ code", to check whether:
|
|
# - the line is in either in both files or just b: increment the line number
|
|
# - the line is only in b: it qualifies as an edited line!
|
|
# Starting from -1 as ST2 is internally 0-based for lines.
|
|
lineNum = -1
|
|
edited_lines = []
|
|
for line in diffs:
|
|
code = line[:2]
|
|
# those lines with "? " are not real! watch out!
|
|
if code in (" ", "+ "):
|
|
lineNum += 1
|
|
if code == "+ ":
|
|
edited_lines.append(lineNum)
|
|
|
|
return False if not edited_lines else edited_lines
|
|
|
|
|
|
# Private: Find the dirty lines.
|
|
#
|
|
# view - the view, you know
|
|
#
|
|
# Returns the list of regions matching dirty lines.
|
|
def get_modified_lines(view):
|
|
try:
|
|
on_disk
|
|
on_buffer = view.substr(sublime.Region(0, view.size())).splitlines()
|
|
except UnicodeDecodeError:
|
|
sublime.status_message("File format incompatible with this feature (UTF-8 files only)")
|
|
return
|
|
|
|
lines = []
|
|
line_numbers = modified_lines_as_numbers(on_disk, on_buffer)
|
|
if line_numbers:
|
|
lines = [view.full_line(view.text_point(number,0)) for number in line_numbers]
|
|
return lines
|
|
|
|
|
|
# Private: Finds the trailing spaces regions to be deleted.
|
|
#
|
|
# It abides by the user settings: while in mode "Only Modified Lines", it returns
|
|
# the subset of trailing spaces regions which are within dirty lines; otherwise, it
|
|
# returns all trailing spaces regions for the document.
|
|
#
|
|
# view - the view, you know
|
|
#
|
|
# Returns a list of regions to be deleted.
|
|
def find_regions_to_delete(view):
|
|
# If the plugin has been running in the background, regions have been matched.
|
|
# Otherwise, we must find trailing spaces right now!
|
|
if trailing_spaces_live_matching:
|
|
regions = view.get_regions('TrailingSpacesMatchedRegions')
|
|
else:
|
|
(regions, highlightable) = find_trailing_spaces(view)
|
|
|
|
# Filtering is required in case triming is restricted to dirty regions only.
|
|
if trim_modified_lines_only:
|
|
modified_lines = get_modified_lines(view)
|
|
|
|
# If there are no dirty lines, don't do nothing.
|
|
if not modified_lines:
|
|
return
|
|
|
|
# Super-private: filters trailing spaces regions to dirty lines only.
|
|
#
|
|
# As one cannot perform a smart find_all within arbitrary boundaries, we must do some
|
|
# extra work:
|
|
# - we want to loop through the modified lines set, not the whole trailing regions
|
|
# - but we need a way to match modified lines with trailings to those very regions
|
|
#
|
|
# Hence the reversed dict on regions: keys are the text_point of the begining of
|
|
# each region, values are the region's actual boundaries. As a Region is unhashable,
|
|
# trailing regions are being recreated later on from those two values.
|
|
#
|
|
# We loop then loop through the modified lines: for each line, we get its begining
|
|
# text_point, and check whether it matches a line with trailing spaces in the
|
|
# reversed dict. If so, this is a match (a modified line with trailing spaces), so
|
|
# we can re-create and store a Region for the relevant trailing spaces boundaries.
|
|
#
|
|
# Returns the filtered list of trailing spaces regions for the modified lines set.
|
|
def only_those_with_trailing_spaces():
|
|
regions_by_begin = {}
|
|
matches = []
|
|
for region in regions:
|
|
begin = view.line(region).begin()
|
|
regions_by_begin[begin] = (region.begin(), region.end())
|
|
|
|
for line in modified_lines:
|
|
text_point = line.begin()
|
|
if text_point in regions_by_begin:
|
|
matches.append(sublime.Region(regions_by_begin[text_point][0], regions_by_begin[text_point][1]))
|
|
|
|
return matches
|
|
|
|
regions = only_those_with_trailing_spaces()
|
|
|
|
return regions
|
|
|
|
# Private: Deletes the trailing spaces regions.
|
|
#
|
|
# view - the view, you know
|
|
# edit - the Edit object spawned by the deletion command
|
|
#
|
|
# Returns the number of deleted regions.
|
|
def delete_trailing_regions(view, edit):
|
|
regions = find_regions_to_delete(view)
|
|
|
|
if regions:
|
|
# Trick: reversing the regions takes care of the growing offset while
|
|
# deleting the successive regions.
|
|
regions.reverse()
|
|
for r in regions:
|
|
view.erase(edit, r)
|
|
return len(regions)
|
|
else:
|
|
return 0
|
|
|
|
|
|
# Public: Toggles the highlighting on or off.
|
|
class ToggleTrailingSpacesCommand(sublime_plugin.WindowCommand):
|
|
def run(self):
|
|
view = self.window.active_view()
|
|
if max_size_exceeded(view):
|
|
sublime.status_message("File is too big, trailing spaces handling disabled.")
|
|
return
|
|
|
|
state = toggle_highlighting(view)
|
|
ts_settings.set("trailing_spaces_highlight_color", current_highlighting_scope)
|
|
persist_settings()
|
|
sublime.status_message('Highlighting of trailing spaces is %s' % state)
|
|
|
|
def is_checked(self):
|
|
return current_highlighting_scope != ""
|
|
|
|
|
|
# Public: Toggles "Modified Lines Only" mode on or off.
|
|
class ToggleTrailingSpacesModifiedLinesOnlyCommand(sublime_plugin.WindowCommand):
|
|
def run(self):
|
|
global trim_modified_lines_only
|
|
|
|
was_on = ts_settings.get("trailing_spaces_modified_lines_only")
|
|
ts_settings.set("trailing_spaces_modified_lines_only", not was_on)
|
|
persist_settings()
|
|
|
|
# TODO: use ts_settings.add_on_change() when it lands in ST3
|
|
trim_modified_lines_only = ts_settings.get('trailing_spaces_modified_lines_only')
|
|
message = "Let's trim trailing spaces everywhere" if was_on \
|
|
else "Let's trim trailing spaces only on modified lines"
|
|
sublime.status_message(message)
|
|
|
|
def is_checked(self):
|
|
return ts_settings.get("trailing_spaces_modified_lines_only")
|
|
|
|
|
|
# Public: Matches and highlights trailing spaces on key events, according to the
|
|
# current settings.
|
|
class TrailingSpacesListener(sublime_plugin.EventListener):
|
|
def on_modified(self, view):
|
|
if trailing_spaces_live_matching:
|
|
match_trailing_spaces(view)
|
|
|
|
def on_activated(self, view):
|
|
if trailing_spaces_live_matching:
|
|
match_trailing_spaces(view)
|
|
|
|
def on_selection_modified(self, view):
|
|
if trailing_spaces_live_matching:
|
|
match_trailing_spaces(view)
|
|
|
|
def on_activated(self, view):
|
|
self.freeze_last_version(view)
|
|
if trailing_spaces_live_matching:
|
|
match_trailing_spaces(view)
|
|
|
|
def on_pre_save(self, view):
|
|
self.freeze_last_version(view)
|
|
if ts_settings.get("trailing_spaces_trim_on_save"):
|
|
view.run_command("delete_trailing_spaces")
|
|
|
|
# Toggling messes with what is red from the disk, and it breaks the diff
|
|
# used when modified_lines_only is true. Honestly, I don't know why (yet).
|
|
# Anyway, let's cache the persisted version of the document's buffer for
|
|
# later use on specific event, so that we always have a decent version of
|
|
# "what's on the disk" to work with.
|
|
def freeze_last_version(self, view):
|
|
global on_disk
|
|
|
|
file_name = view.file_name()
|
|
# For some reasons, the on_activated hook gets fired on a ghost document
|
|
# from time to time.
|
|
if file_name:
|
|
on_disk = codecs.open(file_name, "r", "utf-8").read().splitlines()
|
|
|
|
|
|
# Public: Deletes the trailing spaces.
|
|
class DeleteTrailingSpacesCommand(sublime_plugin.TextCommand):
|
|
def run(self, edit):
|
|
if max_size_exceeded(self.view):
|
|
sublime.status_message("File is too big, trailing spaces handling disabled.")
|
|
return
|
|
|
|
deleted = delete_trailing_regions(self.view, edit)
|
|
|
|
if deleted:
|
|
if ts_settings.get("trailing_spaces_save_after_trim") \
|
|
and not ts_settings.get("trailing_spaces_trim_on_save"):
|
|
sublime.set_timeout(lambda: self.save(self.view), 10)
|
|
|
|
msg_parts = {"nbRegions": deleted,
|
|
"plural": 's' if deleted > 1 else ''}
|
|
message = "Deleted %(nbRegions)s trailing spaces region%(plural)s" % msg_parts
|
|
else:
|
|
message = "No trailing spaces to delete!"
|
|
|
|
sublime.status_message(message)
|
|
|
|
def save(self, view):
|
|
if view.file_name() is None:
|
|
view.run_command('prompt_save_as')
|
|
else:
|
|
view.run_command('save')
|
|
|
|
|
|
# ST3 features a plugin_loaded hook which is called when ST's API is ready.
|
|
#
|
|
# We must therefore call our init callback manually on ST2. It must be the last
|
|
# thing in this plugin (thanks, beloved contributors!).
|
|
if not int(sublime.version()) > 3000:
|
|
plugin_loaded()
|