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)
 |