214 lines
10 KiB
Python
214 lines
10 KiB
Python
import sublime, sublime_plugin, re, sys
|
|
from string import Template
|
|
|
|
class AbacusCommand(sublime_plugin.TextCommand):
|
|
"""
|
|
Main entry point. Find candidates for alignment,
|
|
calculate appropriate column widths, and then
|
|
perform a series of replacements.
|
|
"""
|
|
def run(self, edit):
|
|
candidates = []
|
|
separators = sublime.load_settings("Abacus.sublime-settings").get("com.khiltd.abacus.separators")
|
|
syntax_specific = self.view.settings().get("com.khiltd.abacus.separators", [])
|
|
indentor = Template("$indentation$left_col")
|
|
lg_aligner = Template("$left_col$separator")
|
|
rg_aligner = Template("$left_col$gutter$separator_padding$separator")
|
|
|
|
#Run through the separators accumulating alignment candidates
|
|
#starting with the longest ones i.e. '==' before '='.
|
|
longest_first = self.sort_separators(syntax_specific + [sep for sep in separators if sep["token"] not in [t["token"] for t in syntax_specific]])
|
|
|
|
#Favor those that lean right so assignments with slice notation in them
|
|
#get handled sanely
|
|
for separator in [righty for righty in longest_first if righty["gravity"] == "right"]:
|
|
self.find_candidates_for_separator(separator, candidates)
|
|
|
|
for separator in [lefty for lefty in longest_first if lefty["gravity"] == "left"]:
|
|
self.find_candidates_for_separator(separator, candidates)
|
|
|
|
#After accumulation is done, figure out what the minimum required
|
|
#indentation and column width is going to have to be to make every
|
|
#candidate happy.
|
|
max_indent, max_left_col_width = self.calc_left_col_width(candidates)
|
|
|
|
#Perform actual alignments based on gravitational affinity of separators
|
|
for candidate in candidates:
|
|
indent = 0
|
|
if not candidate["preserve_indent"]:
|
|
indent = max_indent
|
|
else:
|
|
indent = candidate["adjusted_indent"]
|
|
|
|
sep_width = len(candidate["separator"])
|
|
right_col = candidate["right_col"].strip()
|
|
left_col = indentor.substitute( indentation = " " * indent,
|
|
left_col = candidate["left_col"] )
|
|
#Marry the separator to the proper column
|
|
if candidate["gravity"] == "left":
|
|
#Separator sits flush left
|
|
left_col = lg_aligner.substitute(left_col = left_col,
|
|
separator = candidate["separator"] )
|
|
elif candidate["gravity"] == "right":
|
|
gutter_width = max_left_col_width + max_indent - len(left_col) - len(candidate["separator"])
|
|
#Push the separator ONE separator's width over the tab boundary
|
|
left_col = rg_aligner.substitute( left_col = left_col,
|
|
gutter = " " * gutter_width,
|
|
separator_padding = " " * sep_width,
|
|
separator = candidate["separator"] )
|
|
#Most sane people will want a space between the operator and the value.
|
|
right_col = " %s" % right_col
|
|
#Snap the left side together
|
|
left_col = left_col.ljust(max_indent + max_left_col_width)
|
|
candidate["replacement"] = "%s%s\n" % (left_col, right_col)
|
|
|
|
#Replace each line in its entirety
|
|
full_line = self.region_from_line_number(candidate["line"])
|
|
#sys.stdout.write(candidate["replacement"])
|
|
self.view.replace(edit, full_line, candidate["replacement"])
|
|
|
|
#Scroll and muck with the selection
|
|
if candidates:
|
|
self.view.sel().clear()
|
|
for region in [self.region_from_line_number(changed["line"]) for changed in candidates]:
|
|
start_of_right_col = region.begin() + max_indent + max_left_col_width
|
|
insertion_point = sublime.Region(start_of_right_col, start_of_right_col)
|
|
self.view.sel().add(insertion_point)
|
|
#self.view.show_at_center(insertion_point)
|
|
else:
|
|
sublime.status_message('Abacus - no alignment token found on selected line(s)')
|
|
|
|
def sort_separators(self, separators):
|
|
return sorted(separators, key=lambda sep: -len(sep["token"]))
|
|
|
|
def find_candidates_for_separator(self, separator, candidates):
|
|
"""
|
|
Given a particular separator, loop through every
|
|
line in the current selection looking for it and
|
|
add unique matches to a list.
|
|
"""
|
|
debug = sublime.load_settings("Abacus.sublime-settings").get("com.khiltd.abacus.debug")
|
|
token = separator["token"]
|
|
selection = self.view.sel()
|
|
new_candidates = []
|
|
for region in selection:
|
|
for line in self.view.lines(region):
|
|
line_no = self.view.rowcol(line.begin())[0]
|
|
|
|
#Never match a line more than once
|
|
if len([match for match in candidates if match["line"] == line_no]):
|
|
continue
|
|
|
|
#Collapse any string literals that might
|
|
#also contain our separator token so that
|
|
#we can reliably find the location of the
|
|
#real McCoy.
|
|
line_content = self.view.substr(line)
|
|
collapsed = line_content
|
|
|
|
for match in re.finditer(r"(\"[^\"]*(?<!\\)\"|'[^']*(?<!\\)'|\%(q|Q)?\{.*\})", line_content):
|
|
quoted_string = match.group(0)
|
|
collapsed = collapsed.replace(quoted_string, "\007" * len(quoted_string))
|
|
|
|
#Look for ':' but not '::', '=' but not '=>'
|
|
#And remember that quoted strings were collapsed
|
|
#up above!
|
|
token_pos = None
|
|
safe_token = re.escape(token)
|
|
token_matcher = re.compile(r"(?<![^a-zA-Z0-9_ \007])%s(?![^a-zA-Z0-9_# \007])" % (safe_token))
|
|
potential_matches = [m for m in token_matcher.finditer(collapsed)]
|
|
|
|
if debug:
|
|
print("Pattern:")
|
|
print(token_matcher.pattern)
|
|
print("Matches:")
|
|
print(potential_matches)
|
|
|
|
if len(potential_matches):
|
|
#Split on the first/last occurrence of the token
|
|
if separator["gravity"] == "right":
|
|
token_pos = potential_matches[-1].start()
|
|
elif separator["gravity"] == "left":
|
|
token_pos = potential_matches[0].start()
|
|
|
|
#Do you see what I see?
|
|
if debug:
|
|
sys.stdout.write("%s\n" % line_content.encode("ascii", "ignore"))
|
|
sys.stdout.write(" " * token_pos)
|
|
sys.stdout.write("^\n")
|
|
|
|
#Now we can slice
|
|
left_col = self.detab(line_content[:token_pos]).rstrip()
|
|
right_col = self.detab(line_content[token_pos + len(token):])
|
|
sep = line_content[token_pos:token_pos + len(token)]
|
|
initial_indent = re.match("\s+", left_col) or 0
|
|
|
|
if initial_indent:
|
|
initial_indent = len(initial_indent.group(0))
|
|
#Align to tab boundary
|
|
if initial_indent % self.tab_width >= self.tab_width / 2:
|
|
initial_indent = self.snap_to_next_boundary(initial_indent, self.tab_width)
|
|
else:
|
|
initial_indent -= initial_indent % self.tab_width
|
|
candidate = { "line": line_no,
|
|
"original": line_content,
|
|
"separator": sep,
|
|
"gravity": separator["gravity"],
|
|
"adjusted_indent": initial_indent,
|
|
"preserve_indent": separator.get("preserve_indentation", False),
|
|
"left_col": left_col.lstrip(),
|
|
"right_col": right_col.rstrip() }
|
|
new_candidates.append(candidate)
|
|
#Poke more stuff in the accumulator
|
|
candidates.extend(new_candidates)
|
|
|
|
def calc_left_col_width(self, candidates):
|
|
"""
|
|
Given a list of lines we've already matched against
|
|
one or more separators, loop through them all to
|
|
normalize their indentation and determine the minimum
|
|
possible column width that will accomodate them all
|
|
when aligned to a tab stop boundary.
|
|
"""
|
|
max_width = 0
|
|
max_indent = 0
|
|
max_sep_width = 0
|
|
|
|
for candidate in candidates:
|
|
max_indent = max([candidate["adjusted_indent"], max_indent])
|
|
max_sep_width = max([len(candidate["separator"]), max_sep_width])
|
|
max_width = max([len(candidate["left_col"].rstrip()), max_width])
|
|
|
|
max_width += max_sep_width
|
|
|
|
#Bump up to the next multiple of tab_width
|
|
max_width = self.snap_to_next_boundary(max_width, self.tab_width)
|
|
|
|
return max_indent, max_width
|
|
|
|
@property
|
|
def tab_width(self):
|
|
"""
|
|
Exceptionally inefficient
|
|
"""
|
|
return int(self.view.settings().get('tab_size', 4))
|
|
|
|
def detab(self, input):
|
|
"""
|
|
Goodbye tabs!
|
|
"""
|
|
return input.expandtabs(self.tab_width)
|
|
|
|
def region_from_line_number(self, line_number):
|
|
"""
|
|
Given a zero-based line number, return a region
|
|
encompassing it (including the newline).
|
|
"""
|
|
return self.view.full_line(self.view.text_point(line_number, 0))
|
|
|
|
def snap_to_next_boundary(self, value, interval):
|
|
"""
|
|
Alignment voodoo
|
|
"""
|
|
return value + (interval - value % interval)
|