# Copyright (C) 2018 Apple Inc. All rights reserved. # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions # are met: # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # 2. Redistributions in binary form must reproduce the above copyright # notice, this list of conditions and the following disclaimer in the # documentation and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS'' AND # ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE # DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR # ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL # DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. import os import re import sublime import sublime_plugin import subprocess import webbrowser global s_settings def plugin_loaded(): global s_settings s_settings = Settings() def plugin_unloaded(): s_settings.unload() class Settings(object): def __init__(self): self._settings = sublime.load_settings('CopyWebKitPermalink.sublime-settings') self._cache = {} def unload(self): for key in self._cache: self._settings.clear_on_change(key) @property def include_revision(self): return self._get('include_revision', default=True) @include_revision.setter def include_revision(self, value): self._set('include_revision', value) @property def automatically_open_in_browser(self): return self._get('automatically_open_in_browser', default=False) @automatically_open_in_browser.setter def automatically_open_in_browser(self, value): self._set('automatically_open_in_browser', value) def _get(self, key, default=None): if key not in self._cache: self._settings.add_on_change(key, lambda : self._cache.pop(key, None)) self._cache[key] = self._settings.get(key, default) return self._cache[key] def _set(self, key, value): if key in self._cache and self._cache[key] == value: return self._cache[key] = value self._settings.set(key, value) class CopyWebKitPermalinkCommand(sublime_plugin.TextCommand): def run(self, edit, annotate_blame=False): if not self.is_enabled(): return document_path = self.view.file_name() self._last_svn_info = None self._directory_in_checkout = document_path if os.path.isdir(document_path) else os.path.dirname(document_path) self.determine_vcs_from_path(document_path) if s_settings.include_revision and not self.path_is_in_webkit_checkout(document_path): return line_number, _ = self.view.rowcol(self.view.sel()[0].begin()) # Zero-based line_number = line_number + 1 path = self.path_relative_to_repository_root_for_path(document_path) revision_info = self.revision_info_for_path(document_path) permalink = self.permalink_for_path(path, line_number, revision_info, annotate_blame) sublime.set_clipboard(permalink) if s_settings.automatically_open_in_browser: webbrowser.open(permalink) def is_enabled(self): return len(self.view.sel()) > 0 and bool(self.view.file_name()) def is_visible(self): return self.is_enabled() def description(self): return 'Copy WebKit Permalink' def determine_vcs_from_path(self, path): if not os.path.isdir(path): path = os.path.dirname(path) self._is_svn = False self._is_git = False self._is_git_svn = False if self.is_svn_directory(path): self._is_svn = True return if self.is_git_svn_directory(path): self._is_git = True self._is_git_svn = True return if self.is_git_directory(path): self._is_git = True return def path_is_in_webkit_checkout(self, path): repository_url = self.revision_info_for_path(path).get('repository_url', '') return bool(re.match(r'\w+:\/\/\w+\.webkit.org', repository_url)) def git_path_relative_to_repository_root_for_path(self, path): return subprocess.check_output(['git', 'ls-tree', '--full-name', '--name-only', 'HEAD', path], cwd=self._directory_in_checkout).decode('utf-8').rstrip() def svn_path_relative_to_repository_root_for_path(self, path): return self.svn_info_for_path(path)['path'] def path_relative_to_repository_root_for_path(self, path): if self._is_svn: return self.svn_path_relative_to_repository_root_for_path(path) if self._is_git: return self.git_path_relative_to_repository_root_for_path(path) return '' def revision_info_for_path(self, path): if s_settings.include_revision: if self._is_svn or self._is_git_svn: return self.svn_revision_info_for_path(path) if self._is_git: return self.git_revision_info_for_path(path) return {} def svn_revision_info_for_path(self, path): svn_info = self.svn_info_for_path(path) return {'branch': svn_info['branch'], 'revision': svn_info['revision'], 'repository_url': svn_info['repositoryRoot']} def git_revision_info_for_path(self, path): repository_url = subprocess.check_output(['git', 'remote', 'get-url', 'origin'], cwd=self._directory_in_checkout).decode('utf-8').rstrip() revision = subprocess.check_output(['git', 'log', '-1', '--format', '%H', path], cwd=self._directory_in_checkout).decode('utf-8').rstrip() branch = subprocess.check_output(['git', 'symbolic-ref', '-q', 'HEAD'], cwd=self._directory_in_checkout).decode('utf-8').rstrip() branch = re.sub(r'^refs\/heads\/', '', branch) or 'master' return {branch, revision, repository_url} def svn_info_for_path(self, path): if self._last_svn_info and self._last_svn_info['path'] == path: # FIXME: We should also ensure that the checkout directory for the cached SVN info is # the same as the specified checkout directory. return self._last_svn_info svn_info_command = ['svn', 'info'] if self._is_git_svn: svn_info_command = ['git'] + svn_info_command output = subprocess.check_output(svn_info_command + [path], cwd=self._directory_in_checkout).decode('utf-8').rstrip() if not output: return {} temp = {} lines = output.splitlines() for line in lines: key, value = line.split(': ', 1) if key and value: temp[key] = value svn_info = { 'pathAsURL': temp['URL'], 'repositoryRoot': temp['Repository Root'], 'revision': temp['Revision'], } branch = svn_info['pathAsURL'].replace(svn_info['repositoryRoot'] + '/', '') branch = branch[0:branch.find('/')] svn_info['branch'] = branch # Although tempting to use temp['Path'] we cannot because it is relative to self._directory_in_checkout. # And self._directory_in_checkout may not be the top-level checkout directory. We need to compute the # relative path with respect to the top-level checkout directory. svn_info['path'] = svn_info['pathAsURL'].replace('{}/{}/'.format(svn_info['repositoryRoot'], branch), '') self._last_svn_info = svn_info return svn_info @staticmethod def permalink_for_path(path, line_number, revision_info, annotate_blame): revision = '&rev=' + str(revision_info['revision']) if 'revision' in revision_info else '' line_number = '#L' + str(line_number) if line_number else '' branch = revision_info.get('branch', 'trunk') annotate_blame = '&annotate=blame' if annotate_blame else '' return 'https://trac.webkit.org/browser/{}/{}?{}{}{}'.format(branch, path, revision, annotate_blame, line_number) @staticmethod def is_svn_directory(directory): try: subprocess.check_call(['svn', 'info'], cwd=directory) except: return False return True @staticmethod def is_git_directory(directory): try: subprocess.check_call(['git', 'rev-parse'], cwd=directory) except: return False return True @staticmethod def is_git_svn_directory(directory): try: return bool(subprocess.check_output(['git', 'config', '--get', 'svn-remote.svn.fetch'], cwd=directory, stderr=subprocess.STDOUT).decode('utf-8').rstrip()) except: return False