import re import os import shutil from sublime import ENCODED_POSITION from sublime import active_window from sublime import cache_path from sublime import load_resource from sublime import platform from sublime import status_message import sublime_plugin _DEBUG = bool(os.getenv('SUBLIME_PHPUNIT_DEBUG')) if _DEBUG: def debug_message(msg, *args): if args: msg = msg % args print('PHPUnit: ' + msg) else: # pragma: no cover def debug_message(msg, *args): pass def message(msg, *args): if args: msg = msg % args msg = 'PHPUnit: ' + msg print(msg) status_message(msg) def is_debug(view=None): if view: phpunit_debug = view.settings().get('phpunit.debug') return phpunit_debug or ( phpunit_debug is not False and view.settings().get('debug') ) else: return _DEBUG def get_window_setting(key, default=None, window=None): if not window: window = active_window() if window.settings().has(key): return window.settings().get(key) view = window.active_view() if view and view.settings().has(key): return view.settings().get(key) return default def set_window_setting(key, value, window): window.settings().set(key, value) def find_phpunit_configuration_file(file_name, folders): """ Find the first PHPUnit configuration file. Finds either phpunit.xml or phpunit.xml.dist, in {file_name} directory or the nearest common ancestor directory in {folders}. """ debug_message('find configuration for \'%s\' ...', file_name) debug_message(' found %d folders %s', len(folders) if folders else 0, folders) if file_name is None: return None if not isinstance(file_name, str): return None if not len(file_name) > 0: return None if folders is None: return None if not isinstance(folders, list): return None if not len(folders) > 0: return None ancestor_folders = [] # type: list common_prefix = os.path.commonprefix(folders) parent = os.path.dirname(file_name) while parent not in ancestor_folders and parent.startswith(common_prefix): ancestor_folders.append(parent) parent = os.path.dirname(parent) ancestor_folders.sort(reverse=True) debug_message(' found %d possible locations %s', len(ancestor_folders), ancestor_folders) candidate_configuration_file_names = ['phpunit.xml', 'phpunit.xml.dist'] debug_message(' looking for %s ...', candidate_configuration_file_names) for folder in ancestor_folders: for file_name in candidate_configuration_file_names: phpunit_configuration_file = os.path.join(folder, file_name) if os.path.isfile(phpunit_configuration_file): debug_message(' found configuration \'%s\'', phpunit_configuration_file) return phpunit_configuration_file debug_message(' no configuration found') return None def find_phpunit_working_directory(file_name, folders): configuration_file = find_phpunit_configuration_file(file_name, folders) if configuration_file: return os.path.dirname(configuration_file) def is_valid_php_identifier(string): return re.match('^[a-zA-Z_][a-zA-Z0-9_]*$', string) def has_test_case(view): """Return True if the view contains a valid PHPUnit test case.""" for php_class in find_php_classes(view): if php_class[-4:] == 'Test': return True return False def find_php_classes(view, with_namespace=False): """Return list of class names defined in the view.""" classes = [] namespace = None for namespace_region in view.find_by_selector('source.php entity.name.namespace'): namespace = view.substr(namespace_region) break # TODO handle files with multiple namespaces for class_as_region in view.find_by_selector('source.php entity.name.class - meta.use'): class_as_string = view.substr(class_as_region) if is_valid_php_identifier(class_as_string): if with_namespace: classes.append({ 'namespace': namespace, 'class': class_as_string }) else: classes.append(class_as_string) # BC: < 3114 if not classes: # pragma: no cover for class_as_region in view.find_by_selector('source.php entity.name.type.class - meta.use'): class_as_string = view.substr(class_as_region) if is_valid_php_identifier(class_as_string): classes.append(class_as_string) return classes def find_selected_test_methods(view): """ Return a list of selected test method names. Return an empty list if no selections found. Selection can be anywhere inside one or more test methods. """ method_names = [] function_regions = view.find_by_selector('entity.name.function') function_areas = [] # Only include areas that contain function declarations. for function_area in view.find_by_selector('meta.function'): for function_region in function_regions: if function_region.intersects(function_area): function_areas.append(function_area) for region in view.sel(): for i, area in enumerate(function_areas): if not area.a <= region.a <= area.b: continue if i not in function_regions and not area.intersects(function_regions[i]): continue word = view.substr(function_regions[i]) if is_valid_php_identifier(word): method_names.append(word) break # BC: < 3114 if not method_names: # pragma: no cover for region in view.sel(): word_region = view.word(region) word = view.substr(word_region) if not is_valid_php_identifier(word): return [] scope_score = view.score_selector(word_region.begin(), 'entity.name.function.php') if scope_score > 0: method_names.append(word) else: return [] ignore_methods = ['setup', 'teardown'] return [m for m in method_names if m.lower() not in ignore_methods] class Switchable: def __init__(self, location): self.location = location self.file = location[0] def file_encoded_position(self, view): window = view.window() file = self.location[0] row = self.location[2][0] col = self.location[2][1] # If the file we're switching to is already open, # then by default don't goto encoded position. for v in window.views(): if v.file_name() == self.location[0]: row = None col = None # If cursor is on a symbol like a class method, # then try find the relating test method or vice-versa, # and use that as the encoded position to jump to. symbol = view.substr(view.word(view.sel()[0].b)) if symbol: if symbol[:4] == 'test': symbol = symbol[4:] symbol = symbol[0].lower() + symbol[1:] else: symbol = 'test' + symbol[0].upper() + symbol[1:] locations = window.lookup_symbol_in_open_files(symbol) if locations: for location in locations: if location[0] == self.location[0]: row = location[2][0] col = location[2][1] break encoded_postion = '' if row: encoded_postion += ':' + str(row) if col: encoded_postion += ':' + str(col) return file + encoded_postion def refine_switchable_locations(locations, file): debug_message('refine location') if not file: return locations, False debug_message('file=%s', file) debug_message('locations=%s', locations) files = [] if file.endswith('Test.php'): file_is_test_case = True file = file.replace('Test.php', '.php') files.append(re.sub('(\\/)?[tT]ests\\/([uU]nit\\/)?', '/', file)) files.append(re.sub('(\\/)?[tT]ests\\/', '/src/', file)) else: file_is_test_case = False file = file.replace('.php', 'Test.php') files.append(file) files.append(re.sub('(\\/)?src\\/', '/', file)) files.append(re.sub('(\\/)?src\\/', '/test/', file)) debug_message('files=%s', files) if len(locations) > 1: common_prefix = os.path.commonprefix([l[0] for l in locations]) if common_prefix != '/': files = [file.replace(common_prefix, '') for file in files] for location in locations: loc_file = location[0] if not file_is_test_case: loc_file = re.sub('\\/[tT]ests\\/([uU]nit\\/)?', '/', loc_file) for file in files: if loc_file.endswith(file): return [location], True return locations, False def find_switchable(view, on_select=None): # Args: # view (View) # on_select (callable) # # Returns: # void window = view.window() if on_select is None: raise ValueError('a callable is required') file = view.file_name() debug_message('file=%s', file) classes = find_php_classes(view, with_namespace=True) if len(classes) == 0: return message('could not find a test case or class under test for %s', file) debug_message('file contains %s class %s', len(classes), classes) locations = [] # type: list for _class in classes: class_name = _class['class'] if class_name[-4:] == 'Test': symbol = class_name[:-4] else: symbol = class_name + 'Test' symbol_locations = window.lookup_symbol_in_index(symbol) locations += symbol_locations debug_message('class has %s location %s', len(locations), locations) def unique_locations(locations): locs = [] seen = set() # type: set for location in locations: if location[0] not in seen: seen.add(location[0]) locs.append(location) return locs locations = unique_locations(locations) if len(locations) == 0: if has_test_case(view): return message('could not find class under test for %s', file) else: return message('could not find test case for %s', file) def _on_select(index): if index == -1: return switchable = Switchable(locations[index]) if on_select is not None: on_select(switchable) locations, is_exact = refine_switchable_locations(locations=locations, file=file) debug_message('is_exact=%s', is_exact) debug_message('locations(%s)=%s', len(locations), locations) if is_exact and len(locations) == 1: return _on_select(0) window.show_quick_panel(['{}:{}'.format(l[1], l[2][0]) for l in locations], _on_select) def put_views_side_by_side(view_a, view_b): if view_a == view_b: return window = view_a.window() if window.num_groups() == 1: window.run_command('set_layout', { "cols": [0.0, 0.5, 1.0], "rows": [0.0, 1.0], "cells": [[0, 0, 1, 1], [1, 0, 2, 1]] }) view_a_index = window.get_view_index(view_a) view_b_index = window.get_view_index(view_b) if window.num_groups() <= 2 and view_a_index[0] == view_b_index[0]: if view_a_index[0] == 0: window.set_view_index(view_b, 1, 0) else: window.set_view_index(view_b, 0, 0) # Ensure focus is not lost from either view. window.focus_view(view_a) window.focus_view(view_b) def exec_file_regex(): if platform() == 'windows': return '((?:[a-zA-Z]\\:)?\\\\[a-zA-Z0-9 \\.\\/\\\\_-]+)(?: on line |\\:)([0-9]+)' else: return '(\\/[a-zA-Z0-9 \\.\\/_-]+)(?: on line |\\:)([0-9]+)' def is_file_executable(file): return os.path.isfile(file) and os.access(file, os.X_OK) def is_valid_php_version_file_version(version): return bool(re.match( '^(?:master|[1-9](?:\\.[0-9]+)?(?:snapshot|\\.[0-9]+(?:snapshot)?)|[1-9]\\.x|[1-9]\\.[0-9]+\\.x)$', version )) def build_cmd_options(options, cmd): for k, v in options.items(): if v: if len(k) == 1: if isinstance(v, list): for _v in v: cmd.append('-' + k) cmd.append(_v) else: cmd.append('-' + k) if v is not True: cmd.append(v) else: if k[-1] == '=': cmd.append('--' + k + v) else: cmd.append('--' + k) if v is not True: cmd.append(v) return cmd def build_filter_option_pattern(methods): test_methods = [m[4:] for m in methods if m.startswith('test')] if len(test_methods) == len(methods): methods = test_methods f = '::test' else: f = '::' f += '(' + '|'.join(sorted(methods)) + ')( with data set .+)?$' return f def filter_path(path): return os.path.expandvars(os.path.expanduser(path)) def _get_phpunit_executable(working_dir, include_composer_vendor_dir=True): debug_message('find phpunit executable composer=%s', include_composer_vendor_dir) if include_composer_vendor_dir: if platform() == 'windows': composer_phpunit_executable = os.path.join(working_dir, os.path.join('vendor', 'bin', 'phpunit.bat')) debug_message(' found \'%s\' (windows)', composer_phpunit_executable) else: composer_phpunit_executable = os.path.join(working_dir, os.path.join('vendor', 'bin', 'phpunit')) debug_message(' found \'%s\' (unix)', composer_phpunit_executable) if is_file_executable(composer_phpunit_executable): return composer_phpunit_executable debug_message(' Warning: \'%s\' is not executable!', composer_phpunit_executable) executable = shutil.which('phpunit') debug_message(' found \'%s\' (global)', executable) if executable: return executable else: raise ValueError('phpunit not found') def _get_php_executable(working_dir, php_versions_path, php_executable=None): php_version_file = os.path.join(working_dir, '.php-version') if os.path.isfile(php_version_file): with open(php_version_file, 'r') as f: php_version_number = f.read().strip() if not is_valid_php_version_file_version(php_version_number): raise ValueError("'%s' file contents is not a valid version number" % php_version_file) if not php_versions_path: raise ValueError("'phpunit.php_versions_path' is not set") php_versions_path = filter_path(php_versions_path) if not os.path.isdir(php_versions_path): raise ValueError("'phpunit.php_versions_path' '%s' does not exist or is not a valid directory" % php_versions_path) # noqa: E501 if platform() == 'windows': php_executable = os.path.join(php_versions_path, php_version_number, 'php.exe') else: php_executable = os.path.join(php_versions_path, php_version_number, 'bin', 'php') if not is_file_executable(php_executable): raise ValueError("php executable '%s' is not an executable file" % php_executable) return php_executable if php_executable: php_executable = filter_path(php_executable) if not is_file_executable(php_executable): raise ValueError("'phpunit.php_executable' '%s' is not an executable file" % php_executable) return php_executable class PHPUnit(): def __init__(self, window): self.window = window self.view = self.window.active_view() if not self.view: raise ValueError('view not found') debug_message('init %s', self.view.file_name()) def run(self, working_dir=None, file=None, options=None): debug_message('run working_dir=%s, file=%s, options=%s', working_dir, file, options) # Kill any currently running tests self.window.run_command('exec', {'kill': True}) env = {} cmd = [] try: if not working_dir: working_dir = find_phpunit_working_directory(self.view.file_name(), self.window.folders()) if not working_dir: raise ValueError('working directory not found') if not os.path.isdir(working_dir): raise ValueError('working directory does not exist or is not a valid directory') debug_message('working dir \'%s\'', working_dir) php_executable = self.get_php_executable(working_dir) if php_executable: env['PATH'] = os.path.dirname(php_executable) + os.pathsep + os.environ['PATH'] debug_message('php executable \'%s\'', php_executable) phpunit_executable = self.get_phpunit_executable(working_dir) cmd.append(phpunit_executable) debug_message('phpunit executable \'%s\'', phpunit_executable) options = self.filter_options(options) debug_message('options %s', options) cmd = build_cmd_options(options, cmd) if file: if os.path.isfile(file): file = os.path.relpath(file, working_dir) cmd.append(file) else: raise ValueError('test file \'%s\' not found' % file) except ValueError as e: status_message('PHPUnit: {}'.format(e)) print('PHPUnit: {}'.format(e)) return except Exception as e: status_message('PHPUnit: {}'.format(e)) print('PHPUnit: \'{}\''.format(e)) raise e debug_message('env %s', env) debug_message('cmd %s', cmd) if self.view.settings().get('phpunit.save_all_on_run'): # Write out every buffer in active # window that has changes and is # a real file on disk. for view in self.window.views(): if view.is_dirty() and view.file_name(): view.run_command('save') set_window_setting('phpunit._test_last', { 'working_dir': working_dir, 'file': file, 'options': options }, window=self.window) if self.view.settings().get('phpunit.strategy') == 'iterm': osx_iterm_script = os.path.join( os.path.dirname(os.path.realpath(__file__)), 'bin', 'osx_iterm') cmd = [osx_iterm_script] + cmd self.window.run_command('exec', { 'env': env, 'cmd': cmd, 'quiet': not is_debug(self.view), 'shell': False, 'working_dir': working_dir }) else: self.window.run_command('exec', { 'env': env, 'cmd': cmd, 'file_regex': exec_file_regex(), 'quiet': not is_debug(self.view), 'shell': False, 'syntax': 'Packages/{}/res/text-ui-result.sublime-syntax'.format(__name__.split('.')[0]), 'word_wrap': False, 'working_dir': working_dir }) panel = self.window.create_output_panel('exec') header_text = [] if env: header_text.append("env: {}\n".format(env)) header_text.append("{}\n\n".format(' '.join(cmd))) panel.run_command('insert', {'characters': ''.join(header_text)}) panel_settings = panel.settings() panel_settings.set('rulers', []) if self.view.settings().has('phpunit.text_ui_result_font_size'): panel_settings.set( 'font_size', self.view.settings().get('phpunit.text_ui_result_font_size') ) color_scheme = self.get_auto_generated_color_scheme() panel_settings.set('color_scheme', color_scheme) def run_last(self): last_test_args = get_window_setting('phpunit._test_last', window=self.window) if not last_test_args: return status_message('PHPUnit: no tests were run so far') self.run(**last_test_args) def run_file(self, options=None): if options is None: options = {} file = self.view.file_name() if not file: return status_message('PHPUnit: not a test file') if has_test_case(self.view): self.run(file=file, options=options) else: find_switchable( self.view, on_select=lambda switchable: self.run( file=switchable.file, options=options ) ) def run_nearest(self, options): file = self.view.file_name() if not file: return status_message('PHPUnit: not a test file') if has_test_case(self.view): if 'filter' not in options: selected_test_methods = find_selected_test_methods(self.view) if selected_test_methods: options['filter'] = build_filter_option_pattern(selected_test_methods) self.run(file=file, options=options) else: find_switchable( self.view, on_select=lambda switchable: self.run( file=switchable.file, options=options ) ) def show_results(self): self.window.run_command('show_panel', {'panel': 'output.exec'}) def cancel(self): self.window.run_command('exec', {'kill': True}) def open_coverage_report(self): working_dir = find_phpunit_working_directory(self.view.file_name(), self.window.folders()) if not working_dir: return status_message('PHPUnit: could not find a PHPUnit working directory') coverage_html_index_html_file = os.path.join(working_dir, 'build/coverage/index.html') if not os.path.exists(coverage_html_index_html_file): return status_message('PHPUnit: could not find PHPUnit HTML code coverage %s' % coverage_html_index_html_file) # noqa: E501 import webbrowser webbrowser.open_new_tab('file://' + coverage_html_index_html_file) def switch(self): def _on_switchable(switchable): self.window.open_file(switchable.file_encoded_position(self.view), ENCODED_POSITION) put_views_side_by_side(self.view, self.window.active_view()) find_switchable(self.view, on_select=_on_switchable) def visit(self): test_last = get_window_setting('phpunit._test_last', window=self.window) if test_last: if 'file' in test_last and 'working_dir' in test_last: if test_last['file']: file = os.path.join(test_last['working_dir'], test_last['file']) if os.path.isfile(file): return self.window.open_file(file) return status_message('PHPUnit: no tests were run so far') def toggle_option(self, option, value=None): options = get_window_setting('phpunit.options', default={}, window=self.window) if value is None: options[option] = not bool(options[option]) if option in options else True else: if option in options and options[option] == value: del options[option] else: options[option] = value set_window_setting('phpunit.options', options, window=self.window) def filter_options(self, options): if options is None: options = {} window_options = get_window_setting('phpunit.options', default={}, window=self.window) debug_message('window options %s', window_options) if window_options: for k, v in window_options.items(): if k not in options: options[k] = v view_options = self.view.settings().get('phpunit.options') debug_message('view options %s', view_options) if view_options: for k, v in view_options.items(): if k not in options: options[k] = v return options def get_php_executable(self, working_dir): versions_path = self.view.settings().get('phpunit.php_versions_path') executable = self.view.settings().get('phpunit.php_executable') return _get_php_executable(working_dir, versions_path, executable) def get_phpunit_executable(self, working_dir): composer = self.view.settings().get('phpunit.composer') debug_message('phpunit.composer = %s', composer) executable = self.view.settings().get('phpunit.executable') if executable: executable = filter_path(executable) debug_message('phpunit.executable = %s', executable) return executable return _get_phpunit_executable(working_dir, composer) def get_auto_generated_color_scheme(self): """Try to patch color scheme with default test result colors.""" color_scheme = self.view.settings().get('color_scheme') if color_scheme.endswith('.sublime-color-scheme'): return color_scheme try: color_scheme_resource = load_resource(color_scheme) if 'phpunitkit' in color_scheme_resource or 'PHPUnitKit' in color_scheme_resource: return color_scheme if 'region.greenish' in color_scheme_resource: return color_scheme cs_head, cs_tail = os.path.split(color_scheme) cs_package = os.path.split(cs_head)[1] cs_name = os.path.splitext(cs_tail)[0] file_name = cs_package + '__' + cs_name + '.hidden-tmTheme' abs_file = os.path.join(cache_path(), __name__.split('.')[0], 'color-schemes', file_name) rel_file = 'Cache/{}/color-schemes/{}'.format(__name__.split('.')[0], file_name) debug_message('auto generating color scheme = %s', rel_file) if not os.path.exists(os.path.dirname(abs_file)): os.makedirs(os.path.dirname(abs_file)) color_scheme_resource_partial = load_resource( 'Packages/{}/res/text-ui-result-theme-partial.txt'.format(__name__.split('.')[0])) with open(abs_file, 'w', encoding='utf8') as f: f.write(re.sub( '</array>\\s*' '((<!--\\s*)?<key>.*</key>\\s*<string>[^<]*</string>\\s*(-->\\s*)?)*' '</dict>\\s*</plist>\\s*' '$', color_scheme_resource_partial + '\\n</array></dict></plist>', color_scheme_resource )) return rel_file except Exception as e: print('PHPUnit: an error occurred trying to patch color' ' scheme with PHPUnit test results colors: {}'.format(str(e))) return color_scheme class PhpunitTestSuiteCommand(sublime_plugin.WindowCommand): def run(self, **kwargs): PHPUnit(self.window).run(options=kwargs) class PhpunitTestFileCommand(sublime_plugin.WindowCommand): def run(self, **kwargs): PHPUnit(self.window).run_file(options=kwargs) class PhpunitTestLastCommand(sublime_plugin.WindowCommand): def run(self): PHPUnit(self.window).run_last() class PhpunitTestNearestCommand(sublime_plugin.WindowCommand): def run(self, **kwargs): PHPUnit(self.window).run_nearest(options=kwargs) class PhpunitTestResultsCommand(sublime_plugin.WindowCommand): def run(self): PHPUnit(self.window).show_results() class PhpunitTestCancelCommand(sublime_plugin.WindowCommand): def run(self): PHPUnit(self.window).cancel() class PhpunitTestVisitCommand(sublime_plugin.WindowCommand): def run(self): PHPUnit(self.window).visit() class PhpunitTestSwitchCommand(sublime_plugin.WindowCommand): def run(self): PHPUnit(self.window).switch() class PhpunitToggleOptionCommand(sublime_plugin.WindowCommand): def run(self, option, value=None): PHPUnit(self.window).toggle_option(option, value) class PhpunitTestCoverageCommand(sublime_plugin.WindowCommand): def run(self): PHPUnit(self.window).open_coverage_report() class PhpunitEvents(sublime_plugin.EventListener): def on_post_save(self, view): file_name = view.file_name() if not file_name: return if not file_name.endswith('.php'): return on_post_save_events = view.settings().get('phpunit.on_post_save') if 'run_test_file' in on_post_save_events: PHPUnit(view.window()).run_file()