diff --git a/Default.sublime-keymap b/Default.sublime-keymap index cdb3ccd..1187d82 100644 --- a/Default.sublime-keymap +++ b/Default.sublime-keymap @@ -1,3 +1,3 @@ [ - { "keys": ["super+shift+c"], "command": "show_ruby_coverage"} + { "keys": ["super+shift+c"], "command": "toggle_ruby_coverage"} ] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a154859 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015 Kevin Yank + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/Main.sublime-menu b/Main.sublime-menu new file mode 100644 index 0000000..7780587 --- /dev/null +++ b/Main.sublime-menu @@ -0,0 +1,29 @@ +[ + { + "id": "preferences", + "children": [ + { + "caption": "Package Settings", + "mnemonic": "P", + "id": "package-settings", + "children": [ + { + "caption": "SublimeRubyCoverage", + "children": [ + { + "command": "open_file", + "args": {"file": "${packages}/SublimeRubyCoverage/SublimeRubyCoverage.sublime-settings"}, + "caption": "Settings – Default" + }, { + "command": "open_file", + "args": {"file": "${packages}/User/SublimeRubyCoverage.sublime-settings"}, + "caption": "Settings – User" + } + ] + } + ] + } + ] + } + +] diff --git a/README.md b/README.md index b1d95a1..9bd9a61 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,43 @@ -SublimeRubyCoverage -==================== +Sublime Ruby Coverage +===================== -A plugin for Sublime Text 2 that can highlight lines of Ruby lacking test coverage. +A plugin for Sublime Text 3 for visualising SimpleCov code coverage data in your editor. + +Features +-------- + +* Toggle highlighting of covered (green) and uncovered (red) lines of code. + * Shade of green indicates coverage level (with configurable thresholds). + * Highlight colors configurable. +* View whole file and current line coverage statistics in the status bar. + * Can be disabled in user settings. +* View list of all covered files in project, from least to most coverage. + * Includes color-coded coverage graph (colors configurable). + * Supports wide and compact layouts depending on window width. Installation ------------ -You will need to setup [simplecov-sublime-ruby-coverage](http://github.com/integrum/simplecov-sublime-ruby-coverage) in your project. +First, you must have [SimpleCov](https://github.com/colszowka/simplecov) installed and configured for your project. -Set up [Sublime Package Control](http://wbond.net/sublime_packages/package_control) -if you don't have it yet. +Next, install and set up the [simplecov-json](https://github.com/vicentllongo/simplecov-json) formatter. If you’re using SimpleCov 0.9 or later, you have the option of using multiple formatters, so you can continue to generate the default HTML report along with the JSON report required by this package. -Go to Tools > Command Palette. -Type `Package Control: Install Package` and hit enter. -Type `Ruby Coverage` and hit enter. +Finally, install Sublime Ruby Coverage using [Package Control](https://packagecontrol.io): +1. With Package Control installed, go to Tools > Command Palette. +2. Select the **Package Control: Install Package** command and hit Enter. +3. Type **Ruby Coverage** and hit Enter. Usage ----- -To set color of the marks, add the following to your **color scheme** settings array: +Run your tests to generate a **coverage/coverage.json** file in your project. Then: - - name - coverage.uncovered - scope - coverage.uncovered - settings - - foreground - #ffff33 - - +* Move your cursor around in one of the project’s Ruby files to see file and line coverage info in the status bar. +* Open Command Palette and choose **Ruby Coverage: Toggle Coverage Highlight** to display file coverage as green and red colored highlights. By default, lines covered once are highlighted in dark green, lines covered twice are highlighted in brighter green, and lines covered 50 or more times are displayed in very bright green. Invoke the command again to turn highlights off. +* Open Command Palette and choose **Ruby Coverage: Show Project Coverage** to open a panel containing a list of covered Ruby files in your project, from least to most coverage, with a color-coded bar graph indicating the coverage for each file. Ignoring Files -------------- -Add a .covignore file to your project root in order to add custom ignores. - -Highlighting lines missing coverage ------------------------------------ - -When you open a .rb file, -SublimeRubyCoverage tries to find coverage information -and highlight all uncovered lines with an outline. - -It does this by looking in all parent directories -until it finds a `coverage/sublime-ruby-coverage` directory as produced by [simplecov-sublime-ruby-coverage](http://github.com/integrum/simplecov-sublime-ruby-coverage). -The coverage file is expected to have as many lines as the source file, with each line containing a 1 if the line is covered or a 0 if it is not. - -You can force a reload of the coverage information -and redraw of the outlines -by running the `show_ruby_coverage` command, -bound to super+shift+c by default. +Common “non-code” Ruby files, such as spec files, are ignored automatically. Add a .covignore file to your project root in order to add additional, custom ignores. diff --git a/SublimeRubyCoverage.py b/SublimeRubyCoverage.py deleted file mode 100644 index 9ff85e8..0000000 --- a/SublimeRubyCoverage.py +++ /dev/null @@ -1,95 +0,0 @@ -import os -import sublime -import sublime_plugin -import re -PLUGIN_FILE = os.path.abspath(__file__) - -def find_project_root(file_path): - """Project Root is defined as the parent directory that contains a directory called 'coverage'""" - if os.access(os.path.join(file_path, 'coverage'), os.R_OK): - return file_path - - parent, current = os.path.split(file_path) - if current: - return find_project_root(parent) - -def explode_path(path): - first, second = os.path.split(path) - if second: - return explode_path(first) + [second] - else: - return [first] - -class SublimeRubyCoverageListener(sublime_plugin.EventListener): - """Event listener to highlight uncovered lines when a Ruby file is loaded.""" - - def on_load(self, view): - if 'source.ruby' not in view.scope_name(0): - return - - view.run_command('show_ruby_coverage') - -class ShowRubyCoverageCommand(sublime_plugin.TextCommand): - """Highlight uncovered lines in the current file based on a previous coverage run.""" - - def run(self, edit): - view = self.view - filename = view.file_name() - if not filename: - return - - if self.file_exempt(filename): - return - - project_root = find_project_root(filename) - if not project_root: - print('Could not find coverage directory.') - return - - relative_file_path = os.path.relpath(filename, project_root) - - coverage_filename = '-'.join(explode_path(relative_file_path))[1:].replace(".rb", "_rb.csv").replace(".y", "_y.csv") - coverage_filepath = os.path.join(project_root, 'coverage', 'sublime-ruby-coverage', coverage_filename) - - # Clean up - view.erase_status('SublimeRubyCoverage') - view.erase_regions('SublimeRubyCoverage') - - outlines = [] - - try: - with open(coverage_filepath) as coverage_file: - for current_line, line in enumerate(coverage_file): - if line.strip() != '1': - region = view.full_line(view.text_point(current_line, 0)) - outlines.append(region) - except IOError as e: - # highlight the entire view - outlines.append(sublime.Region(0,view.size())) - view.set_status('SublimeRubyCoverage', 'UNCOVERED!') - if view.window(): - sublime.status_message("Oh dear. We can't seem to find the coverage file. We tried looking here: " + coverage_filepath + ", but then we gave up.") - - # update highlighted regions - if outlines: - view.add_regions('SublimeRubyCoverage', outlines, - 'coverage.uncovered', 'bookmark', sublime.HIDDEN) - - def file_exempt(self, filename): - normalized_filename = os.path.normpath(filename).replace('\\', '/') - print(normalized_filename) - - exempt = [r'/test/', r'/spec/', r'/features/', r'Gemfile$', r'Rakefile$', r'\.rake$', - r'\.gemspec'] - - root = find_project_root(self.view.file_name()) - ignore = os.path.join(root, '.covignore') - if os.path.isfile(ignore): - for path in open(ignore).read().rstrip("\n").split("\n"): - exempt.append(path) - - for pattern in exempt: - print(pattern) - if re.compile(pattern).search(normalized_filename) is not None: - return True - return False diff --git a/SublimeRubyCoverage.sublime-commands b/SublimeRubyCoverage.sublime-commands new file mode 100644 index 0000000..b3daf93 --- /dev/null +++ b/SublimeRubyCoverage.sublime-commands @@ -0,0 +1,4 @@ +[ + { "caption": "Ruby Coverage: Toggle Coverage Highlight", "command": "toggle_ruby_coverage"}, + { "caption": "Ruby Coverage: Show Project Coverage", "command": "show_project_ruby_coverage"} +] diff --git a/SublimeRubyCoverage.sublime-settings b/SublimeRubyCoverage.sublime-settings new file mode 100644 index 0000000..7f63452 --- /dev/null +++ b/SublimeRubyCoverage.sublime-settings @@ -0,0 +1,55 @@ +{ + /* + Change this to `true` to scroll to the first uncovered line + automatically when you activate the coverage view. + */ + "auto_scoll_to_uncovered": false, + + /* + Sets the coverage levels at which coverage color + shades are applied to a line. + */ + "coverage_levels": { + "covered": 1, + "more_covered": 2, + "most_covered": 50 + }, + + "colors": { + /* + Colors used to display coverage. + */ + "coverage": { + "uncovered_foreground": "#F9F9F4", + "uncovered_background": "#A83732", + "covered_foreground": "#F9F9F4", + "covered_background": "#287020", + "covered_foreground_bold": "#F9F9F4", + "covered_background_bold": "#37A832", + "covered_foreground_extrabold": "#F9F9F4", + "covered_background_extrabold": "#43D53E" + }, + + /* + Colors used in coverage bar graph. + */ + "graph": { + "0": "#FB0109", + "10": "#FB130A", + "20": "#FB360A", + "30": "#FB5B0A", + "40": "#FB7A0A", + "50": "#FD920A", + "60": "#FDB70B", + "70": "#FEE00A", + "80": "#FEFE0B", + "90": "#90FE09", + "100": "#36FF07" + } + }, + + /* + Change this to `false` to suppress coverage status in status bar. + */ + "coverage_status_in_status_bar": true +} diff --git a/common/json_coverage_reader.py b/common/json_coverage_reader.py new file mode 100644 index 0000000..592e529 --- /dev/null +++ b/common/json_coverage_reader.py @@ -0,0 +1,80 @@ +import os +import json +import re + +class JsonCoverageReader: + """ + For any file in a project with JSON SimpleCov coverage data, + makes whole-project, whole-file and line-specific coverage data available. + """ + + def __init__(self, filename): + """ Load coverage data given the filename for any file in the project. """ + self.project_root = get_project_root(filename) + self.coverage = self.get_coverage_data() if self.project_root else None + + def get_project_coverage(self): + coverage_data = dict(self.coverage) + coverage_data['files'] = list(map(self.make_filename_relative, coverage_data['files'])) + coverage_data['files'].sort(key=lambda file: file['covered_percent']) + return coverage_data + + def get_file_coverage(self, filename): + if self.coverage is None or self.is_file_exempt(filename): + return + + coverage_files = self.coverage['files'] + for coverage_file in coverage_files: + if coverage_file['filename'] == filename: + return coverage_file + + def is_file_exempt(self, filename): + normalized_filename = os.path.normpath(filename).replace('\\', '/') + + exempt = [r'/test/', r'/spec/', r'/features/', r'Gemfile$', r'Rakefile$', r'\.rake$', + r'\.gemspec'] + + ignore = os.path.join(self.project_root, '.covignore') + if os.path.isfile(ignore): + for path in open(ignore).read().rstrip("\n").split("\n"): + exempt.append(path) + + for pattern in exempt: + if re.compile(pattern).search(normalized_filename) is not None: + return True + return False + + def get_coverage_data(self): + coverage_filename = self.get_coverage_filename() + if not coverage_filename: + return + + return json.load(open(coverage_filename)) + + def make_filename_relative(self, file): + file['filename'] = os.path.relpath(file['filename'], self.project_root) + return file + + def get_coverage_filename(self): + if not self.project_root: + return + + coverage_filename = os.path.join(self.project_root, 'coverage', 'coverage.json') + if not os.access(coverage_filename, os.R_OK): + print('Could not find coverage.json file.') + return + + return coverage_filename + +def get_project_root(filename): + """the parent directory that contains a directory called 'coverage'""" + coverage_directory = os.path.join(filename, 'coverage') + if os.access(coverage_directory, os.R_OK): + return filename + + parent, current = os.path.split(filename) + if not current: + print('Could not find coverage directory.') + return + + return get_project_root(parent) diff --git a/common/theme_generator.py b/common/theme_generator.py new file mode 100644 index 0000000..2ff32b9 --- /dev/null +++ b/common/theme_generator.py @@ -0,0 +1,89 @@ +""" +Given the resource path to a Sublime theme file, generate a new +theme and allow the consumer to augment this theme and apply it +to a view. + +(Hat tip to GitSavvy for this technique!) +""" + +import os +from xml.etree import ElementTree + +import sublime + + +STYLES_HEADER = """ + + +""" + +STYLE_TEMPLATE = """ + + name + {name} + scope + {scope} + settings + +{properties} + + +""" + +PROPERTY_TEMPLATE = """ + {key} + {value} +""" + + +class ThemeGenerator(): + + """ + Given the path to a `.tmTheme` file, parse it, allow transformations + on the data, save it, and apply the transformed theme to a view. + """ + + def __init__(self, original_color_scheme): + color_scheme_xml = sublime.load_resource(original_color_scheme) + self.plist = ElementTree.XML(color_scheme_xml) + self.styles = self.plist.find("./dict/array") + + def add_scoped_style(self, name, scope, **kwargs): + """ + Add scope-specific styles to the theme. A unique name should be provided + as well as a scope corresponding to regions of text. Any keyword arguments + will be used as key and value for the newly-defined style. + """ + properties = "".join(PROPERTY_TEMPLATE.format(key=k, value=v) for k, v in kwargs.items()) + new_style = STYLE_TEMPLATE.format(name=name, scope=scope, properties=properties) + self.styles.append(ElementTree.XML(new_style)) + + def get_new_theme_path(self, name): + """ + Save the transformed theme to disk and return the path to that theme, + relative to the Sublime packages directory. + """ + if not os.path.exists(os.path.join(sublime.packages_path(), "User", "SublimeRubyCoverage")): + os.makedirs(os.path.join(sublime.packages_path(), "User", "SublimeRubyCoverage")) + + path_in_packages = os.path.join("User", + "SublimeRubyCoverage", + "SublimeRubyCoverage.{}.tmTheme".format(name)) + + full_path = os.path.join(sublime.packages_path(), path_in_packages) + + with open(full_path, "wb") as out_f: + out_f.write(STYLES_HEADER.encode("utf-8")) + out_f.write(ElementTree.tostring(self.plist, encoding="utf-8")) + + return path_in_packages + + def apply_new_theme(self, name, target_view): + """ + Apply the transformed theme to the specified target view. + """ + path_in_packages = self.get_new_theme_path(name) + + # Sublime expects `/`-delimited paths, even in Windows. + theme_path = os.path.join("Packages", path_in_packages).replace("\\", "/") + target_view.settings().set("color_scheme", theme_path) diff --git a/ruby_coverage_status.py b/ruby_coverage_status.py new file mode 100644 index 0000000..7cd7892 --- /dev/null +++ b/ruby_coverage_status.py @@ -0,0 +1,72 @@ +import os +import sublime +import sublime_plugin + +from .common.json_coverage_reader import JsonCoverageReader + +STATUS_KEY = 'ruby-coverage-status' + +class RubyCoverageStatusListener(sublime_plugin.EventListener): + """Show coverage statistics in status bar.""" + + def on_load(self, view): + self.on_selection_modified(view) + + def on_selection_modified(self, view): + if 'source.ruby' not in view.scope_name(0): + return + + self.view = view + if sublime.load_settings('SublimeRubyCoverage.sublime-settings').get('coverage_status_in_status_bar'): + sublime.set_timeout_async(self.update_status, 0) + else: + self.erase_status() + + def update_status(self): + view = self.view + view.set_status(STATUS_KEY, self.get_view_coverage_status()) + + def erase_status(self): + view = self.view + view.erase_status(STATUS_KEY) + + def get_view_coverage_status(self): + view = self.view + + filename = view.file_name() + if not filename: + self.erase_status() + + r = JsonCoverageReader(filename) + coverage = r.get_file_coverage(filename) if r else None + if coverage is None: + return 'File not covered' + + line_number = self.get_line_number() + if line_number is None: + self.erase_status() + + file_coverage = "File covered {:.1f}% ({}/{})".format( + coverage['covered_percent'], + coverage['covered_lines'], + coverage['lines_of_code'] + ) + + line_coverage = coverage['coverage'][line_number] if len(coverage['coverage']) > line_number else None + if line_coverage is None: + line_coverage = 'Line not executable' + elif line_coverage > 0: + line_coverage = 'Line covered × {}'.format(line_coverage) + else: + line_coverage = 'Line not covered' + + return file_coverage + ', ' + line_coverage + + def get_line_number(self): + view = self.view + + regions = view.sel() + if len(regions) > 1: + return + + return view.rowcol(regions[0].a)[0] diff --git a/show_project_ruby_coverage.py b/show_project_ruby_coverage.py new file mode 100644 index 0000000..e5f316a --- /dev/null +++ b/show_project_ruby_coverage.py @@ -0,0 +1,201 @@ +import os +import sublime +from sublime_plugin import TextCommand + +from .common.json_coverage_reader import JsonCoverageReader +from .common.theme_generator import ThemeGenerator + +PANEL_NAME = 'ruby-coverage-project' + +class ShowProjectRubyCoverage(TextCommand): + """Show coverage of all files in current file's project in a panel.""" + + def run(self, edit): + self.get_project_coverage() + self.create_output_panel() + self.display_project_coverage(edit) + + def get_project_coverage(self): + filename = self.view.file_name() + + if filename is None: + window_folders = sublime.active_window().folders() + if not window_folders or not os.path.isdir(window_folders[0]): + return None + filename = window_folders[0] + + r = JsonCoverageReader(filename) + self.coverage = r.get_project_coverage() if r else None + + def create_output_panel(self): + self.panel = self.view.window().create_output_panel(PANEL_NAME) + + def display_project_coverage(self, edit): + panel = self.panel + panel.show(0) + self.view.window().run_command("show_panel", {"panel": "output.{}".format(PANEL_NAME)}) + + output, regions = self.format_project_coverage() + + panel.set_read_only(False) + panel.erase(edit, sublime.Region(0, panel.size())) + panel.insert(edit, 0, output) + panel.set_read_only(True) + + self.augment_color_scheme() + self.apply_regions(regions) + + def format_project_coverage(self): + panel = self.panel + files = self.coverage['files'] + + viewport_width = int(panel.viewport_extent()[0] / panel.em_width()) - 3 + max_filename_length = len(max(files, key=lambda file: len(file['filename']))['filename']) + coverage_length = len(' 99.9%') + graph_width = viewport_width - max_filename_length - coverage_length - 2 + + if graph_width > 10: + return self.format_project_coverage_full(files, viewport_width, max_filename_length, coverage_length) + else: + return self.format_project_coverage_compact(files, viewport_width, max_filename_length, coverage_length) + + def format_project_coverage_compact(self, files, viewport_width, max_filename_length, coverage_length): + max_filename_length = max(max_filename_length, viewport_width - coverage_length) + graph_width = max_filename_length + + output = '' + graph_regions = [[], [], [], [], [], [], [], [], [], [], []] + for file in files: + graph_bar_width = int(file['covered_percent'] / 100.0 * graph_width) + + filename = file['filename'].ljust(max_filename_length) + decimal_places = 1 if file['covered_percent'] < 100 else 0 + coverage = ('{:>' + str(coverage_length - 1) + '.' + str(decimal_places) + 'f}%').format(file['covered_percent']) + + graph_region_start = len(output) + output += '{}{}\n'.format(filename, coverage).ljust(viewport_width-1) + + decile = int(file['covered_percent'] / 10) + graph_regions[decile].append(sublime.Region(graph_region_start, graph_region_start + graph_bar_width)) + + return output, graph_regions + + def format_project_coverage_full(self, files, viewport_width, max_filename_length, coverage_length): + graph_width = viewport_width - max_filename_length - coverage_length - 2 + + output = '' + graph_regions = [[], [], [], [], [], [], [], [], [], [], []] + for file in files: + graph_bar_width = int(file['covered_percent'] / 100.0 * graph_width) + + filename = file['filename'].ljust(max_filename_length) + decimal_places = 1 if file['covered_percent'] < 100 else 0 + coverage = ('{:>' + str(coverage_length - 1) + '.' + str(decimal_places) + 'f}%').format(file['covered_percent']) + + graph_region_start = len(output) + max_filename_length + len(coverage) + 1 + output += '{}{} ┃'.format(filename, coverage).ljust(viewport_width - 1) + '┃\n' + + decile = int(file['covered_percent'] / 10) + graph_regions[decile].append(sublime.Region(graph_region_start + 1, graph_region_start + graph_bar_width)) + + return output, graph_regions + + def apply_regions(self, regions): + view = self.panel + + for decile, decile_regions in list(enumerate(regions)): + decile_percent = decile * 10 + view.add_regions('coverage-graph-{}'.format(decile_percent), + decile_regions, + 'coverage.graph.{}'.format(decile_percent)) + + def augment_color_scheme(self): + """ + Generate a new color scheme from the original with additional coverage- + related style rules added. Save this color scheme to disk and set it as + the target view's active color scheme. + + (Hat tip to GitSavvy for this technique!) + """ + view = self.panel + + settings = view.settings() + self.restore_color_scheme() + original_color_scheme = settings.get("color_scheme") + settings.set("ruby_coverage.original_color_scheme", original_color_scheme) + colors = sublime.load_settings("SublimeRubyCoverage.sublime-settings").get("colors") + themeGenerator = ThemeGenerator(original_color_scheme) + themeGenerator.add_scoped_style( + "Coverage bar graph 0-9%", + "coverage.graph.0", + foreground = colors["graph"]["0"], + background = "#1B1E22" + ) + themeGenerator.add_scoped_style( + "Coverage bar graph 10-19%", + "coverage.graph.10", + foreground = colors["graph"]["10"], + background = "#1B1E22" + ) + themeGenerator.add_scoped_style( + "Coverage bar graph 20-29%", + "coverage.graph.20", + foreground = colors["graph"]["20"], + background = "#1B1E22" + ) + themeGenerator.add_scoped_style( + "Coverage bar graph 30-39%", + "coverage.graph.30", + foreground = colors["graph"]["30"], + background = "#1B1E22" + ) + themeGenerator.add_scoped_style( + "Coverage bar graph 40-49%", + "coverage.graph.40", + foreground = colors["graph"]["40"], + background = "#1B1E22" + ) + themeGenerator.add_scoped_style( + "Coverage bar graph 50-59%", + "coverage.graph.50", + foreground = colors["graph"]["50"], + background = "#1B1E22" + ) + themeGenerator.add_scoped_style( + "Coverage bar graph 60-69%", + "coverage.graph.60", + foreground = colors["graph"]["60"], + background = "#1B1E22" + ) + themeGenerator.add_scoped_style( + "Coverage bar graph 70-79%", + "coverage.graph.70", + foreground = colors["graph"]["70"], + background = "#1B1E22" + ) + themeGenerator.add_scoped_style( + "Coverage bar graph 80-89%", + "coverage.graph.80", + foreground = colors["graph"]["80"], + background = "#1B1E22" + ) + themeGenerator.add_scoped_style( + "Coverage bar graph 90-99%", + "coverage.graph.90", + foreground = colors["graph"]["90"], + background = "#1B1E22" + ) + themeGenerator.add_scoped_style( + "Coverage bar graph 100%", + "coverage.graph.100", + foreground = colors["graph"]["100"], + background = "#1B1E22" + ) + themeGenerator.apply_new_theme("ruby-coverage-graph", view) + + def restore_color_scheme(self): + settings = self.panel.settings() + original_color_scheme = settings.get("ruby_coverage.original_color_scheme") + if original_color_scheme: + settings.set("color_scheme", original_color_scheme) + settings.erase("ruby_coverage.original_color_scheme") diff --git a/toggle_ruby_coverage.py b/toggle_ruby_coverage.py new file mode 100644 index 0000000..4def7fa --- /dev/null +++ b/toggle_ruby_coverage.py @@ -0,0 +1,179 @@ +import os +import sublime +import sublime_plugin + +from .common.theme_generator import ThemeGenerator +from .common.json_coverage_reader import JsonCoverageReader + +class ToggleRubyCoverageCommand(sublime_plugin.TextCommand): + """Show/hide coverage of current file based on a previous coverage run.""" + + def is_enabled(self): + return 'source.ruby' in self.view.scope_name(0) + + def run(self, edit): + if 'source.ruby' not in self.view.scope_name(0): + return + + settings = self.view.settings() + if settings.has('ruby_coverage.visible'): + self.hide_coverage() + settings.erase('ruby_coverage.visible') + else: + filename = self.get_filename() + coverage = self.get_coverage(filename) + self.show_coverage(filename, coverage) + settings.set('ruby_coverage.visible', True) + if self.is_auto_scroll_enabled(): + self.scroll_to_uncovered() + + def get_filename(self): + return self.view.file_name() + + def get_coverage(self, filename): + view = self.view + + r = JsonCoverageReader(filename) + coverage = r.get_file_coverage(filename) if r else None + return coverage + + def show_coverage(self, filename, coverage): + view = self.view + + self.augment_color_scheme() + + if coverage is None: + self.show_no_coverage() + return + + uncovered_regions = [] + covered_regions = [] + more_covered_regions = [] + most_covered_regions = [] + + coverage_levels = sublime.load_settings("SublimeRubyCoverage.sublime-settings").get("coverage_levels") + current_coverage_regions = None + self.reset_coverage_lines() + for line_number, line_coverage in list(enumerate(coverage['coverage'])): + if line_coverage is None: + self.add_coverage_line(line_number, None) + elif line_coverage >= coverage_levels['most_covered']: + self.add_coverage_line(line_number, most_covered_regions) + elif line_coverage >= coverage_levels['more_covered']: + self.add_coverage_line(line_number, more_covered_regions) + elif line_coverage >= coverage_levels['covered']: + self.add_coverage_line(line_number, covered_regions) + else: + self.add_coverage_line(line_number, uncovered_regions) + self.add_coverage_line(line_number + 1, None) + + view.add_regions('ruby-coverage-uncovered-lines', uncovered_regions, + 'coverage.uncovered') + view.add_regions('ruby-coverage-covered-lines', covered_regions, + 'coverage.covered') + view.add_regions('ruby-coverage-more-covered-lines', more_covered_regions, + 'coverage.covered.more') + view.add_regions('ruby-coverage-most-covered-lines', most_covered_regions, + 'coverage.covered.most') + + def reset_coverage_lines(self): + self.current_coverage_regions = None + self.current_region_start = None + + def add_coverage_line(self, line_number, line_coverage_regions): + view = self.view + if self.current_coverage_regions is not line_coverage_regions: + if self.current_coverage_regions is not None: + current_region_end = view.full_line(view.text_point(line_number - 1, 0)).end() + self.current_coverage_regions.append(sublime.Region(self.current_region_start, current_region_end)) + self.current_region_start = view.text_point(line_number, 0) + self.current_coverage_regions = line_coverage_regions + + def show_no_coverage(self): + view = self.view + view.add_regions('ruby-coverage-uncovered-lines', + [sublime.Region(0, view.size())], + 'coverage.uncovered') + if view.window(): + sublime.status_message('No coverage data for this file.') + + def hide_coverage(self): + view = self.view + self.restore_color_scheme() + view.erase_status('SublimeRubyCoverage') + view.erase_regions('ruby-coverage-uncovered-lines') + view.erase_regions('ruby-coverage-covered-lines') + view.erase_regions('ruby-coverage-more-covered-lines') + view.erase_regions('ruby-coverage-most-covered-lines') + + def is_auto_scroll_enabled(self): + settings = sublime.load_settings("SublimeRubyCoverage.sublime-settings") + return settings.get("auto_scoll_to_uncovered", False) + + def scroll_to_uncovered(self): + view = self.view + regions = view.sel() + if len(regions) > 1 or regions[0].size() > 0: + return + + uncovered_lines = view.get_regions('ruby-coverage-uncovered-lines') + if uncovered_lines and len(uncovered_lines) > 0: + first_uncovered = uncovered_lines[0].a + + view.sel().clear() + view.sel().add(sublime.Region(first_uncovered, first_uncovered)) + view.show_at_center(first_uncovered) + + def augment_color_scheme(self): + """ + Generate a new color scheme from the original with additional coverage- + related style rules added. Save this color scheme to disk and set it as + the target view's active color scheme. + + (Hat tip to GitSavvy for this technique!) + """ + view = self.view + colors = sublime.load_settings("SublimeRubyCoverage.sublime-settings").get("colors") + file_ext = self.get_filename_ext() + + settings = view.settings() + original_color_scheme = settings.get("color_scheme") + settings.set("ruby_coverage.original_color_scheme", original_color_scheme) + themeGenerator = ThemeGenerator(original_color_scheme) + themeGenerator.add_scoped_style( + "SublimeRubyCoverage Uncovered Line", + "coverage.uncovered", + background = colors["coverage"]["uncovered_background"], + foreground = colors["coverage"]["uncovered_foreground"] + ) + themeGenerator.add_scoped_style( + "SublimeRubyCoverage Covered Line", + "coverage.covered", + background = colors["coverage"]["covered_background"], + foreground = colors["coverage"]["covered_foreground"] + ) + themeGenerator.add_scoped_style( + "SublimeRubyCoverage More Covered Line", + "coverage.covered.more", + background = colors["coverage"]["covered_background_bold"], + foreground = colors["coverage"]["covered_foreground_bold"] + ) + themeGenerator.add_scoped_style( + "SublimeRubyCoverage Most Covered Line", + "coverage.covered.most", + background = colors["coverage"]["covered_background_extrabold"], + foreground = colors["coverage"]["covered_foreground_extrabold"] + ) + themeGenerator.apply_new_theme("ruby-coverage-view." + file_ext, view) + + def get_filename_ext(self): + filename = os.path.basename(self.get_filename()) + period_delimited_segments = filename.split(".") + return '' if len(period_delimited_segments) < 2 else period_delimited_segments[-1] + + def restore_color_scheme(self): + settings = self.view.settings() + original_color_scheme = settings.get("ruby_coverage.original_color_scheme") + if original_color_scheme: + settings.set("color_scheme", original_color_scheme) + settings.erase("ruby_coverage.original_color_scheme")