mirror of
https://github.com/sstent/sublime-text-3.git
synced 2026-01-25 14:41:38 +00:00
450 lines
14 KiB
Python
450 lines
14 KiB
Python
#
|
|
# highlight.py
|
|
# Part of SublimeLinter3, a code checking framework for Sublime Text 3
|
|
#
|
|
# Written by Ryan Hileman and Aparajita Fishman
|
|
#
|
|
# Project: https://github.com/SublimeLinter/SublimeLinter3
|
|
# License: MIT
|
|
#
|
|
|
|
"""
|
|
This module implements highlighting code with marks.
|
|
|
|
The following classes are exported:
|
|
|
|
HighlightSet
|
|
Highlight
|
|
|
|
|
|
The following constants are exported:
|
|
|
|
WARNING - name of warning type
|
|
ERROR - name of error type
|
|
|
|
MARK_KEY_FORMAT - format string for key used to mark code regions
|
|
GUTTER_MARK_KEY_FORMAT - format string for key used to mark gutter mark regions
|
|
MARK_SCOPE_FORMAT - format string used for color scheme scope names
|
|
|
|
"""
|
|
|
|
import re
|
|
import sublime
|
|
from . import persist
|
|
|
|
#
|
|
# Error types
|
|
#
|
|
WARNING = 'warning'
|
|
ERROR = 'error'
|
|
|
|
MARK_KEY_FORMAT = 'sublimelinter-{}-marks'
|
|
GUTTER_MARK_KEY_FORMAT = 'sublimelinter-{}-gutter-marks'
|
|
MARK_SCOPE_FORMAT = 'sublimelinter.mark.{}'
|
|
|
|
UNDERLINE_FLAGS = sublime.DRAW_NO_FILL | sublime.DRAW_NO_OUTLINE | sublime.DRAW_EMPTY_AS_OVERWRITE
|
|
|
|
MARK_STYLES = {
|
|
'outline': sublime.DRAW_NO_FILL,
|
|
'fill': sublime.DRAW_NO_OUTLINE,
|
|
'solid underline': sublime.DRAW_SOLID_UNDERLINE | UNDERLINE_FLAGS,
|
|
'squiggly underline': sublime.DRAW_SQUIGGLY_UNDERLINE | UNDERLINE_FLAGS,
|
|
'stippled underline': sublime.DRAW_STIPPLED_UNDERLINE | UNDERLINE_FLAGS,
|
|
'none': sublime.HIDDEN
|
|
}
|
|
|
|
WORD_RE = re.compile(r'^([-\w]+)')
|
|
NEAR_RE_TEMPLATE = r'(?<!"){}({}){}(?!")'
|
|
|
|
|
|
def mark_style_names():
|
|
"""Return the keys from MARK_STYLES, sorted and capitalized, with None at the end."""
|
|
names = list(MARK_STYLES)
|
|
names.remove('none')
|
|
names.sort()
|
|
names.append('none')
|
|
return [name.capitalize() for name in names]
|
|
|
|
|
|
class HighlightSet:
|
|
|
|
"""This class maintains a set of Highlight objects and performs bulk operations on them."""
|
|
|
|
def __init__(self):
|
|
self.all = set()
|
|
|
|
def add(self, highlight):
|
|
"""Add a Highlight to the set."""
|
|
self.all.add(highlight)
|
|
|
|
def draw(self, view):
|
|
"""
|
|
Draw all of the Highlight objects in our set.
|
|
|
|
Rather than draw each Highlight object individually, the marks in each
|
|
object are aggregated into a new Highlight object, and that object
|
|
is then drawn for the given view.
|
|
|
|
"""
|
|
|
|
if not self.all:
|
|
return
|
|
|
|
all = Highlight()
|
|
|
|
for highlight in self.all:
|
|
all.update(highlight)
|
|
|
|
all.draw(view)
|
|
|
|
@staticmethod
|
|
def clear(view):
|
|
"""Clear all marks in the given view."""
|
|
for error_type in (WARNING, ERROR):
|
|
view.erase_regions(MARK_KEY_FORMAT.format(error_type))
|
|
view.erase_regions(GUTTER_MARK_KEY_FORMAT.format(error_type))
|
|
|
|
def redraw(self, view):
|
|
"""Redraw all marks in the given view."""
|
|
self.clear(view)
|
|
self.draw(view)
|
|
|
|
def reset(self, view):
|
|
"""Clear all marks in the given view and reset the list of marks in our Highlights."""
|
|
self.clear(view)
|
|
|
|
for highlight in self.all:
|
|
highlight.reset()
|
|
|
|
|
|
class Highlight:
|
|
|
|
"""This class maintains error marks and knows how to draw them."""
|
|
|
|
def __init__(self, code=''):
|
|
self.code = code
|
|
self.marks = {WARNING: [], ERROR: []}
|
|
self.mark_style = 'outline'
|
|
self.mark_flags = MARK_STYLES[self.mark_style]
|
|
|
|
# Every line that has a mark is kept in this dict, so we know which
|
|
# lines to mark in the gutter.
|
|
self.lines = {}
|
|
|
|
# These are used when highlighting embedded code, for example JavaScript
|
|
# or CSS within an HTML file. The embedded code is linted as if it begins
|
|
# at (0, 0), but we need to keep track of where the actual start is within the source.
|
|
self.line_offset = 0
|
|
self.char_offset = 0
|
|
|
|
# Linting runs asynchronously on a snapshot of the code. Marks are added to the code
|
|
# during that asynchronous linting, and the markup code needs to calculate character
|
|
# positions given a line + column. By the time marks are added, the actual buffer
|
|
# may have changed, so we can't reliably use the plugin API to calculate character
|
|
# positions. The solution is to calculate and store the character positions for
|
|
# every line when this object is created, then reference that when needed.
|
|
self.newlines = newlines = [0]
|
|
last = -1
|
|
|
|
while True:
|
|
last = code.find('\n', last + 1)
|
|
|
|
if last == -1:
|
|
break
|
|
|
|
newlines.append(last + 1)
|
|
|
|
newlines.append(len(code))
|
|
|
|
@staticmethod
|
|
def strip_quotes(text):
|
|
"""Return text stripped of enclosing single/double quotes."""
|
|
first = text[0]
|
|
|
|
if first in ('\'', '"') and text[-1] == first:
|
|
text = text[1:-1]
|
|
|
|
return text
|
|
|
|
def full_line(self, line):
|
|
"""
|
|
Return the start/end character positions for the given line.
|
|
|
|
This returns *real* character positions (relative to the beginning of self.code)
|
|
base on the *virtual* line number (adjusted by the self.line_offset).
|
|
|
|
"""
|
|
|
|
# The first line of the code needs the character offset
|
|
if line == 0:
|
|
char_offset = self.char_offset
|
|
else:
|
|
char_offset = 0
|
|
|
|
line += self.line_offset
|
|
start = self.newlines[line] + char_offset
|
|
|
|
end = self.newlines[min(line + 1, len(self.newlines) - 1)]
|
|
|
|
return start, end
|
|
|
|
def range(self, line, pos, length=-1, near=None, error_type=ERROR, word_re=None):
|
|
"""
|
|
Mark a range of text.
|
|
|
|
line and pos should be zero-based. The pos and length argument can be used to control marking:
|
|
|
|
- If pos < 0, the entire line is marked and length is ignored.
|
|
|
|
- If near is not None, it is stripped of quotes and length = len(near)
|
|
|
|
- If length < 0, the nearest word starting at pos is marked, and if
|
|
no word is matched, the character at pos is marked.
|
|
|
|
- If length == 0, no text is marked, but a gutter mark will appear on that line.
|
|
|
|
error_type determines what type of error mark will be drawn (ERROR or WARNING).
|
|
|
|
When length < 0, this method attempts to mark the closest word at pos on the given line.
|
|
If you want to customize the word matching regex, pass it in word_re.
|
|
|
|
If the error_type is WARNING and an identical ERROR region exists, it is not added.
|
|
If the error_type is ERROR and an identical WARNING region exists, the warning region
|
|
is removed and the error region is added.
|
|
|
|
"""
|
|
|
|
start, end = self.full_line(line)
|
|
|
|
if pos < 0:
|
|
pos = 0
|
|
length = (end - start) - 1
|
|
elif near is not None:
|
|
near = self.strip_quotes(near)
|
|
length = len(near)
|
|
elif length < 0:
|
|
code = self.code[start:end][pos:]
|
|
match = (word_re or WORD_RE).search(code)
|
|
|
|
if match:
|
|
length = len(match.group())
|
|
else:
|
|
length = 1
|
|
|
|
pos += start
|
|
region = sublime.Region(pos, pos + length)
|
|
other_type = ERROR if error_type == WARNING else WARNING
|
|
i_offset = 0
|
|
|
|
for i, mark in enumerate(self.marks[other_type].copy()):
|
|
if mark.a == region.a and mark.b == region.b:
|
|
if error_type == WARNING:
|
|
return
|
|
else:
|
|
self.marks[other_type].pop(i - i_offset)
|
|
i_offset += 1
|
|
|
|
self.marks[error_type].append(region)
|
|
|
|
def regex(self, line, regex, error_type=ERROR,
|
|
line_match=None, word_match=None, word_re=None):
|
|
"""
|
|
Mark a range of text that matches a regex.
|
|
|
|
line, error_type and word_re are the same as in range().
|
|
|
|
line_match may be a string pattern or a compiled regex.
|
|
If provided, it must have a named group called 'match' that
|
|
determines which part of the source line will be considered
|
|
for marking.
|
|
|
|
word_match may be a string pattern or a compiled regex.
|
|
If provided, it must have a named group called 'mark' that
|
|
determines which part of the source line will actually be marked.
|
|
Multiple portions of the source line may match.
|
|
|
|
"""
|
|
|
|
offset = 0
|
|
|
|
start, end = self.full_line(line)
|
|
line_text = self.code[start:end]
|
|
|
|
if line_match:
|
|
match = re.match(line_match, line_text)
|
|
|
|
if match:
|
|
line_text = match.group('match')
|
|
offset = match.start('match')
|
|
else:
|
|
return
|
|
|
|
it = re.finditer(regex, line_text)
|
|
results = [
|
|
result.span('mark')
|
|
for result in it
|
|
if word_match is None or result.group('mark') == word_match
|
|
]
|
|
|
|
for start, end in results:
|
|
self.range(line, start + offset, end - start, error_type=error_type)
|
|
|
|
def near(self, line, near, error_type=ERROR, word_re=None):
|
|
"""
|
|
Mark a range of text near a given word.
|
|
|
|
line, error_type and word_re are the same as in range().
|
|
|
|
If near is enclosed by quotes, they are stripped. The first occurrence
|
|
of near in the given line of code is matched. If the first and last
|
|
characters of near are word characters, a match occurs only if near
|
|
is a complete word.
|
|
|
|
The position at which near is found is returned, or zero if there
|
|
is no match.
|
|
|
|
"""
|
|
|
|
if not near:
|
|
return
|
|
|
|
start, end = self.full_line(line)
|
|
text = self.code[start:end]
|
|
near = self.strip_quotes(near)
|
|
|
|
# Add \b fences around the text if it begins/ends with a word character
|
|
fence = ['', '']
|
|
|
|
for i, pos in enumerate((0, -1)):
|
|
if near[pos].isalnum() or near[pos] == '_':
|
|
fence[i] = r'\b'
|
|
|
|
pattern = NEAR_RE_TEMPLATE.format(fence[0], re.escape(near), fence[1])
|
|
match = re.search(pattern, text)
|
|
|
|
if match:
|
|
start = match.start(1)
|
|
else:
|
|
start = -1
|
|
|
|
if start != -1:
|
|
self.range(line, start, len(near), error_type=error_type, word_re=word_re)
|
|
return start
|
|
else:
|
|
return 0
|
|
|
|
def update(self, other):
|
|
"""
|
|
Update this object with another Highlight.
|
|
|
|
It is assumed that other.code == self.code.
|
|
|
|
other's marks and error positions are merged, and this
|
|
object takes the newlines array from other.
|
|
|
|
"""
|
|
|
|
for error_type in (WARNING, ERROR):
|
|
self.marks[error_type].extend(other.marks[error_type])
|
|
|
|
# Errors override warnings on the same line
|
|
for line, error_type in other.lines.items():
|
|
current_type = self.lines.get(line)
|
|
|
|
if current_type is None or current_type == WARNING:
|
|
self.lines[line] = error_type
|
|
|
|
self.newlines = other.newlines
|
|
|
|
def set_mark_style(self):
|
|
"""Setup the mark style and flags based on settings."""
|
|
self.mark_style = persist.settings.get('mark_style', 'outline')
|
|
self.mark_flags = MARK_STYLES[self.mark_style]
|
|
|
|
if not persist.settings.get('show_marks_in_minimap'):
|
|
self.mark_flags |= sublime.HIDE_ON_MINIMAP
|
|
|
|
def draw(self, view):
|
|
"""
|
|
Draw code and gutter marks in the given view.
|
|
|
|
Error, warning and gutter marks are drawn with separate regions,
|
|
since each one potentially needs a different color.
|
|
|
|
"""
|
|
self.set_mark_style()
|
|
|
|
gutter_regions = {WARNING: [], ERROR: []}
|
|
draw_gutter_marks = persist.settings.get('gutter_theme') != 'None'
|
|
|
|
if draw_gutter_marks:
|
|
# We use separate regions for the gutter marks so we can use
|
|
# a scope that will not colorize the gutter icon, and to ensure
|
|
# that errors will override warnings.
|
|
for line, error_type in self.lines.items():
|
|
region = sublime.Region(self.newlines[line], self.newlines[line])
|
|
gutter_regions[error_type].append(region)
|
|
|
|
for error_type in (WARNING, ERROR):
|
|
if self.marks[error_type]:
|
|
view.add_regions(
|
|
MARK_KEY_FORMAT.format(error_type),
|
|
self.marks[error_type],
|
|
MARK_SCOPE_FORMAT.format(error_type),
|
|
flags=self.mark_flags
|
|
)
|
|
|
|
if draw_gutter_marks and gutter_regions[error_type]:
|
|
if persist.gutter_marks['colorize']:
|
|
scope = MARK_SCOPE_FORMAT.format(error_type)
|
|
else:
|
|
scope = 'sublimelinter.gutter-mark'
|
|
|
|
view.add_regions(
|
|
GUTTER_MARK_KEY_FORMAT.format(error_type),
|
|
gutter_regions[error_type],
|
|
scope,
|
|
icon=persist.gutter_marks[error_type]
|
|
)
|
|
|
|
@staticmethod
|
|
def clear(view):
|
|
"""Clear all marks in the given view."""
|
|
for error_type in (WARNING, ERROR):
|
|
view.erase_regions(MARK_KEY_FORMAT.format(error_type))
|
|
view.erase_regions(GUTTER_MARK_KEY_FORMAT.format(error_type))
|
|
|
|
def reset(self):
|
|
"""
|
|
Clear the list of marks maintained by this object.
|
|
|
|
This method does not clear the marks, only the list.
|
|
The next time this object is used to draw, the marks will be cleared.
|
|
|
|
"""
|
|
for error_type in (WARNING, ERROR):
|
|
del self.marks[error_type][:]
|
|
self.lines.clear()
|
|
|
|
def line(self, line, error_type):
|
|
"""Record the given line as having the given error type."""
|
|
line += self.line_offset
|
|
|
|
# Errors override warnings, if it's already an error leave it
|
|
if self.lines.get(line) == ERROR:
|
|
return
|
|
|
|
self.lines[line] = error_type
|
|
|
|
def move_to(self, line, char_offset):
|
|
"""
|
|
Move the highlight to the given line and character offset.
|
|
|
|
The character offset is relative to the start of the line.
|
|
This method is used to create virtual line numbers
|
|
and character positions when linting embedded code.
|
|
|
|
"""
|
|
self.line_offset = line
|
|
self.char_offset = char_offset
|