From f414a322a47265f595e659d6f469dfe727b220b6 Mon Sep 17 00:00:00 2001 From: Tom van Ommeren Date: Wed, 21 Oct 2015 05:48:20 +0200 Subject: [PATCH 1/5] hook up most of the remaining tests --- application_commands.py | 2 +- event_listeners.py | 44 ++++-- req.py | 8 + response.py | 4 +- span.py | 75 ---------- stack_ide.py | 259 +++++++++++++++++++-------------- stack_ide_manager.py | 14 +- test/stubs/backend.py | 10 ++ test/test_commands.py | 125 ++++++++++++++++ test/test_listeners.py | 97 ++++++++++++ test/test_response.py | 6 +- test/test_stack_ide_manager.py | 4 +- test/test_stackide.py | 60 ++++++++ test/test_utility.py | 15 ++ test/test_win.py | 94 ++++++++++++ text_commands.py | 31 ++-- utility.py | 9 +- watchdog.py | 10 +- win.py | 13 +- window_commands.py | 6 +- 20 files changed, 644 insertions(+), 242 deletions(-) delete mode 100644 span.py create mode 100644 test/stubs/backend.py create mode 100644 test/test_commands.py create mode 100644 test/test_listeners.py create mode 100644 test/test_stackide.py create mode 100644 test/test_win.py diff --git a/application_commands.py b/application_commands.py index 8cb9d04..973c4c7 100644 --- a/application_commands.py +++ b/application_commands.py @@ -1,6 +1,6 @@ import sublime_plugin -from SublimeStackIDE.stack_ide_manager import * +from SublimeStackIDE.stack_ide_manager import StackIDEManager class RestartStackIde(sublime_plugin.ApplicationCommand): diff --git a/event_listeners.py b/event_listeners.py index 16269a7..d9091f4 100644 --- a/event_listeners.py +++ b/event_listeners.py @@ -1,12 +1,16 @@ -import sublime_plugin, sublime +try: + import sublime_plugin, sublime +except ImportError: + from test.stubs import sublime, sublime_plugin -from SublimeStackIDE.utility import * -from SublimeStackIDE.req import * -from SublimeStackIDE.log import * -from SublimeStackIDE.win import * -from SublimeStackIDE.stack_ide import * -from SublimeStackIDE.stack_ide_manager import * -from SublimeStackIDE import response as res +import sys, os +sys.path.append(os.path.dirname(os.path.realpath(__file__))) + +from utility import is_haskell_view, relative_view_file_name, span_from_view_selection +from req import Req +from win import Win +from stack_ide_manager import StackIDEManager, send_request +import response as res class StackIDESaveListener(sublime_plugin.EventListener): @@ -15,6 +19,10 @@ class StackIDESaveListener(sublime_plugin.EventListener): then request a report of source errors. """ def on_post_save(self, view): + + if not is_haskell_view(view): + return + if not StackIDEManager.is_running(view.window()): return @@ -26,17 +34,21 @@ class StackIDETypeAtCursorHandler(sublime_plugin.EventListener): time it changes position. """ def on_selection_modified(self, view): - if not view: + + if not is_haskell_view(view): return - if not StackIDEManager.is_running(view.window()): + + window = view.window() + if not StackIDEManager.is_running(window): return + # Only try to get types for views into files # (rather than e.g. the find field or the console pane) if view.file_name(): # Uncomment to see the scope at the cursor: # Log.debug(view.scope_name(view.sel()[0].begin())) request = Req.get_exp_types(span_from_view_selection(view)) - send_request(view, request, Win(view).highlight_type) + send_request(window, request, Win(window).highlight_type) class StackIDEAutocompleteHandler(sublime_plugin.EventListener): @@ -48,14 +60,20 @@ def __init__(self): self.returned_completions = [] def on_query_completions(self, view, prefix, locations): - if not StackIDEManager.is_running(view.window()): + + if not is_haskell_view(view): return + + window = view.window() + if not StackIDEManager.is_running(window): + return + # Check if this completion query is due to our refreshing the completions list # after receiving a response from stack-ide, and if so, don't send # another request for completions. if not view.settings().get("refreshing_auto_complete"): request = Req.get_autocompletion(filepath=relative_view_file_name(view),prefix=prefix) - send_request(view, request, Win(view).update_completions) + send_request(window, request, Win(window).update_completions) # Clear the flag to uninhibit future completion queries view.settings().set("refreshing_auto_complete", False) diff --git a/req.py b/req.py index e05fda7..a120cd4 100644 --- a/req.py +++ b/req.py @@ -10,6 +10,10 @@ def update_session_includes(filepaths): ] } + @staticmethod + def update_session(): + return { "tag":"RequestUpdateSession", "contents": []} + @staticmethod def get_source_errors(): return {"tag": "RequestGetSourceErrors", "contents":[]} @@ -22,6 +26,10 @@ def get_exp_types(exp_span): def get_exp_info(exp_span): return { "tag": "RequestGetSpanInfo", "contents": exp_span} + @staticmethod + def get_shutdown(): + return {"tag":"RequestShutdownSession", "contents":[]} + @staticmethod def get_autocompletion(filepath,prefix): return { diff --git a/response.py b/response.py index 9f0f983..d0db338 100644 --- a/response.py +++ b/response.py @@ -30,9 +30,9 @@ def parse_update_session(contents): progress = contents.get('contents') return str(progress.get("progressParsedMsg")) elif tag == "UpdateStatusDone": - return "Session started." + return " " elif tag == "UpdateStatusRequiredRestart": - return "Restarting session..." + return "Starting session..." def parse_source_errors(contents): diff --git a/span.py b/span.py deleted file mode 100644 index 0fead0e..0000000 --- a/span.py +++ /dev/null @@ -1,75 +0,0 @@ -try: - import sublime -except ImportError: - from test.stubs import sublime - -import os, sys -sys.path.append(os.path.dirname(os.path.realpath(__file__))) - -from utility import * - -class Span: - """ - Represents the Stack-IDE 'span' type - """ - - class InView: - """ - When a span corresponds to a file being displayed in a view, - this object holds the position of the span inside that view. - """ - - def __init__(self, view, from_point, to_point, region): - self.view = view - self.from_point = from_point - self.to_point = to_point - self.region = region - - @classmethod - def get_full_path(cls, span, window): - file_path = span.get("spanFilePath") - if file_path == None: - return None - full_path = first_folder(window) + "/" + file_path - return full_path - - @classmethod - def from_json(cls, span, window): - full_path = Span.get_full_path(span, window) - if full_path == None: - return None - from_line = span.get("spanFromLine") - from_column = span.get("spanFromColumn") - to_line = span.get("spanToLine") - to_column = span.get("spanToColumn") - - view = window.find_open_file(full_path) - if view is None: - in_view = None - else: - from_point = view.text_point(from_line - 1, from_column - 1) - to_point = view.text_point(to_line - 1, to_column - 1) - region = sublime.Region(from_point, to_point) - - in_view = Span.InView(view, from_point, to_point, region) - - return Span(from_line, from_column, to_line, to_column, full_path, in_view) - - def __init__(self, from_line, from_column, to_line, to_column, full_path, in_view): - self.from_line = from_line - self.from_column = from_column - self.to_line = to_line - self.to_column = to_column - self.full_path = full_path - self.in_view = in_view - - def as_error_message(self, error): - kind = error.get("errorKind") - error_msg = error.get("errorMsg") - - return "{file}:{from_line}:{from_column}: {kind}:\n{msg}".format( - file=self.full_path, - from_line=self.from_line, - from_column=self.from_column, - kind=kind, - msg=error_msg) diff --git a/stack_ide.py b/stack_ide.py index c40f615..9a90176 100644 --- a/stack_ide.py +++ b/stack_ide.py @@ -11,15 +11,14 @@ sys.path.append(os.path.dirname(os.path.realpath(__file__))) -from span import * -from utility import * -from req import * -from log import * -from win import * +from utility import first_folder +from req import Req +from log import Log +from win import Win import response as res # Make sure Popen hides the console on Windows. -# We don't need this on other platforms +# We don't need this on other platforms # (and it would cause an error) CREATE_NO_WINDOW = 0 if os.name == 'nt': @@ -27,90 +26,77 @@ class StackIDE: - def __init__(self, window, settings): + complaints_shown = set() + + def __init__(self, window, settings, backend=None): self.window = window - self.settings = settings + self.conts = {} # Map from uuid to response handler self.is_alive = True self.is_active = False self.process = None - self.boot_ide_backend() + folder = first_folder(window) + (project_in, project_name) = os.path.split(folder) + self.project_name = project_name + + self.alt_env = os.environ.copy() + add_to_PATH = settings.add_to_PATH + if len(add_to_PATH) > 0: + self.alt_env["PATH"] = os.pathsep.join(add_to_PATH + [self.alt_env.get("PATH","")]) + + if backend is None: + self._backend = boot_ide_backend(folder, self.alt_env, self.handle_response) + else: + self._backend = backend self.is_active = True self.include_targets = set() + sublime.set_timeout_async(self.load_initial_targets, 0) - def update_new_include_targets(self, filepaths): - for filepath in filepaths: - self.include_targets.add(filepath) - return list(self.include_targets) def send_request(self, request, response_handler = None): - if self.process: + """ + Associates requests with handlers and passes them on to the process. + """ + if self._backend: if response_handler is not None: seq_id = str(uuid.uuid4()) self.conts[seq_id] = response_handler request = request.copy() request['seq'] = seq_id - try: - Log.debug("Sending request: ", request) - encodedString = json.JSONEncoder().encode(request) + "\n" - self.process.stdin.write(bytes(encodedString, 'UTF-8')) - self.process.stdin.flush() - except BrokenPipeError as e: - Log.error("stack-ide unexpectedly died:",e) - - # self.die() - # Ideally we would like to die(), so that, if the error is transient, - # we attempt to reconnect on the next check_windows() call. The problem - # is that the stack-ide (ide-backend, actually) is not cleaning up those - # session.* directories and they would keep accumulating, one per second! - # So instead we do: - self.is_active = False + self._backend.send_request(request) else: Log.error("Couldn't send request, no process!", request) - def boot_ide_backend(self): - """ - Start up a stack-ide subprocess for the window, and a thread to consume its stdout. - """ - Log.normal("Launching stack-ide instance for ", first_folder(self.window)) - - # Assumes the library target name is the same as the project dir - (project_in, project_name) = os.path.split(first_folder(self.window)) - - # Extend the search path if indicated - alt_env = os.environ.copy() - if len(self.settings.add_to_PATH) > 0: - alt_env["PATH"] = os.pathsep.join(self.settings.add_to_PATH + [alt_env.get("PATH","")]) - - Log.debug("Calling stack with PATH:", alt_env['PATH'] if alt_env else os.environ['PATH']) + @classmethod + def complain(cls,complaint_id,msg): + """ + we don't do it again (until reset) + """ + if complaint_id not in cls.complaints_shown: + cls.complaints_shown.add(complaint_id) + sublime.error_message(msg) - self.process = subprocess.Popen(["stack", "ide", "start", project_name], - stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - cwd=first_folder(self.window), env=alt_env, - creationflags=CREATE_NO_WINDOW - ) - - self.stdoutThread = threading.Thread(target=self.read_stdout) - self.stdoutThread.start() - self.stderrThread = threading.Thread(target=self.read_stderr) - self.stderrThread.start() + def load_initial_targets(self): + """ + Get the initial list of files to check + """ - # Asynchronously get the initial list of files to check - def load_initial_targets(): + proc = subprocess.Popen(["stack", "ide", "load-targets", self.project_name], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + cwd=first_folder(self.window), env=self.alt_env, + universal_newlines=True, + creationflags=CREATE_NO_WINDOW) + outs, errs = proc.communicate() + initial_targets = outs.splitlines() + sublime.set_timeout(lambda: self.update_files(initial_targets), 0) - proc = subprocess.Popen(["stack", "ide", "load-targets", project_name], - stdout=subprocess.PIPE, stderr=subprocess.PIPE, - cwd=first_folder(self.window), env=alt_env, - universal_newlines=True, - creationflags=CREATE_NO_WINDOW - ) - outs, errs = proc.communicate() - initial_targets = outs.splitlines() - sublime.set_timeout(lambda: self.update_files(initial_targets), 0) - sublime.set_timeout_async(load_initial_targets, 0) + def update_new_include_targets(self, filepaths): + for filepath in filepaths: + self.include_targets.add(filepath) + return list(self.include_targets) def update_files(self, filenames): new_include_targets = self.update_new_include_targets(filenames) @@ -121,7 +107,7 @@ def end(self): """ Ask stack-ide to shut down. """ - self.send_request({"tag":"RequestShutdownSession", "contents":[]}) + self.send_request(Req.get_shutdown()) self.die() def die(self): @@ -131,52 +117,10 @@ def die(self): self.is_alive = False self.is_active = False - - def read_stderr(self): - """ - Reads any errors from the stack-ide process. - """ - while self.process.poll() is None: - try: - Log.warning("Stack-IDE error: ", self.process.stderr.readline().decode('UTF-8')) - except: - Log.error("Stack-IDE stderr process ending due to exception: ", sys.exc_info()) - return; - Log.normal("Stack-IDE stderr process ended.") - - def read_stdout(self): - """ - Reads JSON responses from stack-ide and dispatch them to - various main thread handlers. - """ - while self.process.poll() is None: - try: - raw = self.process.stdout.readline().decode('UTF-8') - if not raw: - return - - - data = None - try: - data = json.loads(raw) - except: - Log.debug("Got a non-JSON response: ", raw) - continue - - self.handle_response(data) - - except: - Log.warning("Stack-IDE stdout process ending due to exception: ", sys.exc_info()) - self.process.terminate() - self.process = None - return; - Log.normal("Stack-IDE stdout process ended.") - def handle_response(self, data): """ Handles JSON responses from the backend """ - Log.debug("Got response: ", data) tag = data.get("tag") @@ -209,6 +153,9 @@ def handle_response(self, data): elif tag == "ResponseUpdateSession": self._handle_update_session(contents) + elif tag == "ResponseLog": + Log.debug(contents.rstrip()) + else: Log.normal("Unhandled response: ", data) @@ -232,5 +179,99 @@ def __del__(self): finally: self.process = None +def boot_ide_backend(folder, env, response_handler): + """ + Start up a stack-ide subprocess for the window, and a thread to consume its stdout. + """ + + # Assumes the library target name is the same as the project dir + (project_in, project_name) = os.path.split(folder) + + Log.debug("Calling stack with PATH:", env['PATH'] if env else os.environ['PATH']) + + process = subprocess.Popen(["stack", "ide", "start", project_name], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + cwd=folder, env=env, + universal_newlines=True, + creationflags=CREATE_NO_WINDOW + ) + + return JsonProcessBackend(process, response_handler) + + +class JsonProcessBackend: + """ + Handles process communication with JSON. + """ + def __init__(self, process, response_handler): + self._process = process + self._response_handler = response_handler + self.stdoutThread = threading.Thread(target=self.read_stdout) + self.stdoutThread.start() + self.stderrThread = threading.Thread(target=self.read_stderr) + self.stderrThread.start() + + def send_request(self, request): + + try: + Log.debug("Sending request: ", request) + encodedString = json.JSONEncoder().encode(request) + "\n" + self._process.stdin.write(bytes(encodedString, 'UTF-8')) + self._process.stdin.flush() + except BrokenPipeError as e: + Log.error("stack-ide unexpectedly died:",e) + + # self.die() + # Ideally we would like to die(), so that, if the error is transient, + # we attempt to reconnect on the next check_windows() call. The problem + # is that the stack-ide (ide-backend, actually) is not cleaning up those + # session.* directories and they would keep accumulating, one per second! + # So instead we do: + self.is_active = False + + + def read_stderr(self): + """ + Reads any errors from the stack-ide process. + """ + while self._process.poll() is None: + + try: + error = self._process.stderr.readline().decode('UTF-8') + if len(error) > 0: + Log.warning("Stack-IDE error: ", error) + except: + Log.error("Stack-IDE stderr process ending due to exception: ", sys.exc_info()) + return + + Log.debug("Stack-IDE stderr process ended.") + + def read_stdout(self): + """ + Reads JSON responses from stack-ide and dispatch them to + various main thread handlers. + """ + while self._process.poll() is None: + try: + raw = self._process.stdout.readline().decode('UTF-8') + if not raw: + return + + data = None + try: + data = json.loads(raw) + except: + Log.debug("Got a non-JSON response: ", raw) + continue + + #todo: try catch ? + self._response_handler(data) + + except: + Log.warning("Stack-IDE stdout process ending due to exception: ", sys.exc_info()) + self._process.terminate() + self._process = None + return + Log.info("Stack-IDE stdout process ended.") diff --git a/stack_ide_manager.py b/stack_ide_manager.py index 5d4b621..197be31 100644 --- a/stack_ide_manager.py +++ b/stack_ide_manager.py @@ -4,20 +4,19 @@ sys.path.append(os.path.dirname(os.path.realpath(__file__))) -from stack_ide import * +from stack_ide import StackIDE from log import Log -from utility import * +from utility import first_folder,expected_cabalfile,has_cabal_file, is_stack_project try: import sublime except ImportError: from test.stubs import sublime -def send_request(view_or_window, request, on_response = None): +def send_request(window, request, on_response = None): """ Sends the given request to the (view's) window's stack-ide instance, optionally handling its response """ - window = get_window(view_or_window) if StackIDEManager.is_running(window): StackIDEManager.for_window(window).send_request(request, on_response) @@ -75,6 +74,10 @@ class StackIDEManager: complaints_shown = set() settings = None + @classmethod + def getinstances(cls): + return cls.ide_backend_instances + @classmethod def check_windows(cls): """ @@ -109,7 +112,6 @@ def check_windows(cls): updated_instances[win_id] = instance StackIDEManager.ide_backend_instances = updated_instances - # Thw windows remaining in current_windows are new, so they have no instance. # We try to create one for them for window in current_windows.values(): @@ -120,7 +122,7 @@ def check_windows(cls): def is_running(cls, window): if not window: return False - return StackIDEManager.for_window(window) is not None + return cls.for_window(window) is not None @classmethod diff --git a/test/stubs/backend.py b/test/stubs/backend.py new file mode 100644 index 0000000..ced28cc --- /dev/null +++ b/test/stubs/backend.py @@ -0,0 +1,10 @@ +class FakeBackend(): + + def __init__(self, response={}): + self.response = response + self.handler = None + + def send_request(self, req): + self.response["seq"] = req.get("seq") + if not self.handler is None: + self.handler(self.response) diff --git a/test/test_commands.py b/test/test_commands.py new file mode 100644 index 0000000..b75c6f3 --- /dev/null +++ b/test/test_commands.py @@ -0,0 +1,125 @@ +import unittest +from unittest.mock import MagicMock, Mock, ANY +from stack_ide_manager import StackIDEManager +import stack_ide as stackide +from .mocks import mock_window, mock_view, cur_dir +from text_commands import ClearErrorPanelCommand, UpdateErrorPanelCommand, ShowHsTypeAtCursorCommand, ShowHsInfoAtCursorCommand, CopyHsTypeAtCursorCommand, GotoDefinitionAtCursorCommand +from .stubs import sublime +from .stubs.backend import FakeBackend +from settings import Settings + +type_info = "FilePath -> IO String" +span = { + "spanFromLine": 1, + "spanFromColumn": 1, + "spanToLine": 1, + "spanToColumn": 5 +} +exp_types_response = {"tag": "", "contents": [[type_info, span]]} +someFunc_span_info = {'contents': [[{'contents': {'idProp': {'idDefinedIn': {'moduleName': 'Lib', 'modulePackage': {'packageVersion': None, 'packageName': 'main', 'packageKey': 'main'}}, 'idSpace': 'VarName', 'idType': 'IO ()', 'idDefSpan': {'contents': {'spanFromLine': 9, 'spanFromColumn': 1, 'spanToColumn': 9, 'spanFilePath': 'src/Lib.hs', 'spanToLine': 9}, 'tag': 'ProperSpan'}, 'idName': 'someFunc', 'idHomeModule': None}, 'idScope': {'idImportQual': '', 'idImportedFrom': {'moduleName': 'Lib', 'modulePackage': {'packageVersion': None, 'packageName': 'main', 'packageKey': 'main'}}, 'idImportSpan': {'contents': {'spanFromLine': 3, 'spanFromColumn': 1, 'spanToColumn': 11, 'spanFilePath': 'app/Main.hs', 'spanToLine': 3}, 'tag': 'ProperSpan'}, 'tag': 'Imported'}}, 'tag': 'SpanId'}, {'spanFromLine': 7, 'spanFromColumn': 27, 'spanToColumn': 35, 'spanFilePath': 'app/Main.hs', 'spanToLine': 7}]], 'seq': '724752c9-a7bf-4658-834a-3ff7df64e7e5', 'tag': 'ResponseGetSpanInfo'} +putStrLn_span_info = {'contents': [[{'contents': {'idProp': {'idDefinedIn': {'moduleName': 'System.IO', 'modulePackage': {'packageVersion': '4.8.1.0', 'packageName': 'base', 'packageKey': 'base'}}, 'idSpace': 'VarName', 'idType': 'String -> IO ()', 'idDefSpan': {'contents': '', 'tag': 'TextSpan'}, 'idName': 'putStrLn', 'idHomeModule': {'moduleName': 'System.IO', 'modulePackage': {'packageVersion': '4.8.1.0', 'packageName': 'base', 'packageKey': 'base'}}}, 'idScope': {'idImportQual': '', 'idImportedFrom': {'moduleName': 'Prelude', 'modulePackage': {'packageVersion': '4.8.1.0', 'packageName': 'base', 'packageKey': 'base'}}, 'idImportSpan': {'contents': {'spanFromLine': 1, 'spanFromColumn': 8, 'spanToColumn': 12, 'spanFilePath': 'app/Main.hs', 'spanToLine': 1}, 'tag': 'ProperSpan'}, 'tag': 'Imported'}}, 'tag': 'SpanId'}, {'spanFromLine': 7, 'spanFromColumn': 41, 'spanToColumn': 49, 'spanFilePath': 'app/Main.hs', 'spanToLine': 7}]], 'seq': '6ee8d949-82bd-491d-8b79-ffcaa3e65fde', 'tag': 'ResponseGetSpanInfo'} +test_settings = Settings("none", [], False) + +class CommandTests(unittest.TestCase): + + def test_can_clear_panel(self): + cmd = ClearErrorPanelCommand() + cmd.view = MagicMock() + cmd.run(None) + cmd.view.erase.assert_called_with(ANY, ANY) + + def test_can_update_panel(self): + cmd = UpdateErrorPanelCommand() + cmd.view = MagicMock() + cmd.view.size = Mock(return_value=0) + cmd.run(None, 'message') + cmd.view.insert.assert_called_with(ANY, 0, "message\n\n") + + def test_can_show_type_at_cursor(self): + cmd = ShowHsTypeAtCursorCommand() + window = mock_window([cur_dir + '/projects/helloworld']) + cmd.view = mock_view('src/Main.hs', window) + cmd.view.show_popup = Mock() + + backend = FakeBackend(exp_types_response) + instance = stackide.StackIDE(cmd.view.window(), test_settings, backend) + backend.handler = instance.handle_response + + StackIDEManager.ide_backend_instances[ + cmd.view.window().id()] = instance + + cmd.run(None) + cmd.view.show_popup.assert_called_with(type_info) + + def test_can_copy_type_at_cursor(self): + cmd = CopyHsTypeAtCursorCommand() + window = mock_window([cur_dir + '/projects/helloworld']) + cmd.view = mock_view('src/Main.hs', window) + + backend = FakeBackend(exp_types_response) + instance = stackide.StackIDE(window, test_settings, backend) + backend.handler = instance.handle_response + + StackIDEManager.ide_backend_instances[ + window.id()] = instance + + cmd.run(None) + self.assertEqual(sublime.clipboard, type_info) + + def test_can_request_show_info_at_cursor(self): + cmd = ShowHsInfoAtCursorCommand() + window = mock_window([cur_dir + '/projects/helloworld']) + cmd.view = mock_view('src/Main.hs', window) + cmd.view.show_popup = Mock() + + backend = FakeBackend(someFunc_span_info) + instance = stackide.StackIDE(cmd.view.window(), test_settings, backend) + backend.handler = instance.handle_response + + StackIDEManager.ide_backend_instances[ + window.id()] = instance + + cmd.run(None) + cmd.view.show_popup.assert_called_with("someFunc :: IO () (Defined in src/Lib.hs:9:1)") + + def test_show_info_from_module(self): + cmd = ShowHsInfoAtCursorCommand() + window = mock_window([cur_dir + '/projects/helloworld']) + cmd.view = mock_view('src/Main.hs', window) + cmd.view.show_popup = Mock() + + backend = FakeBackend(putStrLn_span_info) + instance = stackide.StackIDE(cmd.view.window(), test_settings, backend) + backend.handler = instance.handle_response + + StackIDEManager.ide_backend_instances[ + window.id()] = instance + + cmd.run(None) + cmd.view.show_popup.assert_called_with("putStrLn :: String -> IO () (Imported from Prelude)") + + def test_goto_definition_at_cursor(self): + global cur_dir + cmd = GotoDefinitionAtCursorCommand() + window = mock_window([cur_dir + '/projects/helloworld']) + window.open_file = Mock() + cmd.view = mock_view('src/Main.hs', window) + + backend = FakeBackend(someFunc_span_info) + instance = stackide.StackIDE(cmd.view.window(), test_settings, backend) + backend.handler = instance.handle_response + + StackIDEManager.ide_backend_instances[ + window.id()] = instance + + cmd.run(None) + window.open_file.assert_called_with(cur_dir + "/projects/helloworld/src/Lib.hs:9:1", sublime.ENCODED_POSITION) + + def test_goto_definition_of_module(self): + global cur_dir + cmd = GotoDefinitionAtCursorCommand() + window = mock_window([cur_dir + '/projects/helloworld']) + cmd.view = mock_view('src/Main.hs', window) + window.status_message = Mock() + cmd._handle_response(putStrLn_span_info.get('contents')) + self.assertEqual("Cannot navigate to putStrLn, it is imported from Prelude", sublime.current_status) diff --git a/test/test_listeners.py b/test/test_listeners.py new file mode 100644 index 0000000..755997f --- /dev/null +++ b/test/test_listeners.py @@ -0,0 +1,97 @@ +import unittest +from unittest.mock import MagicMock, Mock, ANY +from event_listeners import StackIDESaveListener, StackIDETypeAtCursorHandler, StackIDEAutocompleteHandler +from req import Req +from .stubs.backend import FakeBackend +from .stubs import sublime +from stack_ide import StackIDE +from stack_ide_manager import StackIDEManager +from .mocks import mock_window, mock_view, cur_dir +from settings import Settings +import utility as util + +test_settings = Settings("none", [], False) +type_info = "FilePath -> IO String" +span = { + "spanFromLine": 1, + "spanFromColumn": 1, + "spanToLine": 1, + "spanToColumn": 5 +} +exp_types_response = {"tag": "", "contents": [[type_info, span]]} +request_include_targets = {'contents': [{'contents': {'contents': ['src/Main.hs'], 'tag': 'TargetsInclude'}, 'tag': 'RequestUpdateTargets'}], 'tag': 'RequestUpdateSession'} + +class ListenerTests(unittest.TestCase): + + def test_requests_update_on_save(self): + listener = StackIDESaveListener() + + window = mock_window([cur_dir + '/projects/helloworld']) + view = mock_view('src/Main.hs', window) + + backend = MagicMock() + backend.send_request = Mock() + + instance = StackIDE(window, test_settings, backend) + # backend.handler = instance.handle_response + + StackIDEManager.ide_backend_instances[ + window.id()] = instance + + listener.on_post_save(view) + backend.send_request.assert_any_call(request_include_targets) + + def test_ignores_non_haskell_views(self): + listener = StackIDESaveListener() + + window = mock_window([cur_dir + '/projects/helloworld']) + view = mock_view('src/Main.hs', window) + view.match_selector.return_value = False + + backend = MagicMock() + backend.send_request = Mock() + + instance = StackIDE(window, test_settings, backend) + + StackIDEManager.ide_backend_instances[ + window.id()] = instance + + listener.on_post_save(view) + + self.assertFalse(backend.send_request.called) + + def test_type_at_cursor_tests(self): + listener = StackIDETypeAtCursorHandler() + + window = mock_window([cur_dir + '/projects/helloworld']) + view = mock_view('src/Main.hs', window) + + backend = FakeBackend(exp_types_response) + instance = StackIDE(window, test_settings, backend) + backend.handler = instance.handle_response + + StackIDEManager.ide_backend_instances[ + window.id()] = instance + + listener.on_selection_modified(view) + view.set_status.assert_called_with("type_at_cursor", type_info) + view.add_regions.assert_called_with("type_at_cursor", ANY, "storage.type", "", sublime.DRAW_OUTLINED) + + def test_request_completions(self): + window = mock_window([cur_dir + '/projects/helloworld']) + view = mock_view('src/Main.hs', window) + + listener = StackIDEAutocompleteHandler() + backend = MagicMock() + + view.settings().get = Mock(return_value=False) + instance = StackIDE(window, test_settings, backend) + StackIDEManager.ide_backend_instances[ + window.id()] = instance + + listener.on_query_completions(view, 'm', []) #locations not used. + + req = Req.get_autocompletion(filepath=util.relative_view_file_name(view),prefix="m") + req['seq'] = ANY + + backend.send_request.assert_called_with(req) diff --git a/test/test_response.py b/test/test_response.py index 08dffa9..8ea7c29 100644 --- a/test/test_response.py +++ b/test/test_response.py @@ -3,8 +3,6 @@ source_errors = {'seq': 'd0599c00-0b77-441c-8947-b3882cab298c', 'tag': 'ResponseGetSourceErrors', 'contents': [{'errorSpan': {'tag': 'ProperSpan', 'contents': {'spanFromColumn': 22, 'spanFromLine': 11, 'spanFilePath': 'src/Lib.hs', 'spanToColumn': 28, 'spanToLine': 11}}, 'errorKind': 'KindError', 'errorMsg': 'Couldn\'t match expected type ‘Integer’ with actual type ‘[Char]’\nIn the first argument of ‘greet’, namely ‘"You!"’\nIn the second argument of ‘($)’, namely ‘greet "You!"’\nIn a stmt of a \'do\' block: putStrLn $ greet "You!"'}, {'errorSpan': {'tag': 'ProperSpan', 'contents': {'spanFromColumn': 24, 'spanFromLine': 15, 'spanFilePath': 'src/Lib.hs', 'spanToColumn': 25, 'spanToLine': 15}}, 'errorKind': 'KindError', 'errorMsg': 'Couldn\'t match expected type ‘[Char]’ with actual type ‘Integer’\nIn the second argument of ‘(++)’, namely ‘s’\nIn the expression: "Hello, " ++ s'}]} many_completions = {'tag': 'ResponseGetAutocompletion', 'contents': [{'idProp': {'idSpace': 'VarName', 'idDefinedIn': {'moduleName': 'GHC.List', 'modulePackage': {'packageName': 'base', 'packageKey': 'base', 'packageVersion': '4.8.1.0'}}, 'idHomeModule': {'moduleName': 'GHC.OldList', 'modulePackage': {'packageName': 'base', 'packageKey': 'base', 'packageVersion': '4.8.1.0'}}, 'idType': None, 'idName': '!!', 'idDefSpan': {'tag': 'TextSpan', 'contents': ''}}, 'idScope': {'tag': 'Imported', 'idImportedFrom': {'moduleName': 'Data.List', 'modulePackage': {'packageName': 'base', 'packageKey': 'base', 'packageVersion': '4.8.1.0'}}, 'idImportSpan': {'tag': 'ProperSpan', 'contents': {'spanFilePath': 'app/Main.hs', 'spanFromLine': 4, 'spanToLine': 4, 'spanFromColumn': 1, 'spanToColumn': 17}}, 'idImportQual': ''}}, {'idProp': {'idSpace': 'VarName', 'idDefinedIn': {'moduleName': 'GHC.Base', 'modulePackage': {'packageName': 'base', 'packageKey': 'base', 'packageVersion': '4.8.1.0'}}, 'idHomeModule': {'moduleName': 'Data.Function', 'modulePackage': {'packageName': 'base', 'packageKey': 'base', 'packageVersion': '4.8.1.0'}}, 'idType': '(a -> b) -> a -> b', 'idName': '$', 'idDefSpan': {'tag': 'TextSpan', 'contents': ''}}, 'idScope': {'tag': 'WiredIn', 'contents': []}}, {'idProp': {'idSpace': 'VarName', 'idDefinedIn': {'moduleName': 'GHC.Base', 'modulePackage': {'packageName': 'base', 'packageKey': 'base', 'packageVersion': '4.8.1.0'}}, 'idHomeModule': {'moduleName': 'Prelude', 'modulePackage': {'packageName': 'base', 'packageKey': 'base', 'packageVersion': '4.8.1.0'}}, 'idType': None, 'idName': '$!', 'idDefSpan': {'tag': 'TextSpan', 'contents': ''}}, 'idScope': {'tag': 'Imported', 'idImportedFrom': {'moduleName': 'Prelude', 'modulePackage': {'packageName': 'base', 'packageKey': 'base', 'packageVersion': '4.8.1.0'}}, 'idImportSpan': {'tag': 'ProperSpan', 'contents': {'spanFilePath': 'app/Main.hs', 'spanFromLine': 1, 'spanToLine': 1, 'spanFromColumn': 1, 'spanToColumn': 1}}, 'idImportQual': ''}}, {'idProp': {'idSpace': 'VarName', 'idDefinedIn': {'moduleName': 'GHC.Classes', 'modulePackage': {'packageName': 'ghc-prim', 'packageKey': 'ghc-prim', 'packageVersion': '0.4.0.0'}}, 'idHomeModule': {'moduleName': 'Data.Bool', 'modulePackage': {'packageName': 'base', 'packageKey': 'base', 'packageVersion': '4.8.1.0'}}, 'idType': None, 'idName': '&&', 'idDefSpan': {'tag': 'TextSpan', 'contents': ''}}, 'idScope': {'tag': 'Imported', 'idImportedFrom': {'moduleName': 'Prelude', 'modulePackage': {'packageName': 'base', 'packageKey': 'base', 'packageVersion': '4.8.1.0'}}, 'idImportSpan': {'tag': 'ProperSpan', 'contents': {'spanFilePath': 'app/Main.hs', 'spanFromLine': 1, 'spanToLine': 1, 'spanFromColumn': 1, 'spanToColumn': 1}}, 'idImportQual': ''}}, {'idProp': {'idSpace': 'VarName', 'idDefinedIn': {'moduleName': 'GHC.Num', 'modulePackage': {'packageName': 'base', 'packageKey': 'base', 'packageVersion': '4.8.1.0'}}, 'idHomeModule': {'moduleName': 'Prelude', 'modulePackage': {'packageName': 'base', 'packageKey': 'base', 'packageVersion': '4.8.1.0'}}, 'idType': None, 'idName': '*', 'idDefSpan': {'tag': 'TextSpan', 'contents': ''}}, 'idScope': {'tag': 'Imported', 'idImportedFrom': {'moduleName': 'Prelude', 'modulePackage': {'packageName': 'base', 'packageKey': 'base', 'packageVersion': '4.8.1.0'}}, 'idImportSpan': {'tag': 'ProperSpan', 'contents': {'spanFilePath': 'app/Main.hs', 'spanFromLine': 1, 'spanToLine': 1, 'spanFromColumn': 1, 'spanToColumn': 1}}, 'idImportQual': ''}}, {'idProp': {'idSpace': 'VarName', 'idDefinedIn': {'moduleName': 'GHC.Float', 'modulePackage': {'packageName': 'base', 'packageKey': 'base', 'packageVersion': '4.8.1.0'}}, 'idHomeModule': {'moduleName': 'Prelude', 'modulePackage': {'packageName': 'base', 'packageKey': 'base', 'packageVersion': '4.8.1.0'}}, 'idType': None, 'idName': '**', 'idDefSpan': {'tag': 'TextSpan', 'contents': ''}}, 'idScope': {'tag': 'Imported', 'idImportedFrom': {'moduleName': 'Prelude', 'modulePackage': {'packageName': 'base', 'packageKey': 'base', 'packageVersion': '4.8.1.0'}}, 'idImportSpan': {'tag': 'ProperSpan', 'contents': {'spanFilePath': 'app/Main.hs', 'spanFromLine': 1, 'spanToLine': 1, 'spanFromColumn': 1, 'spanToColumn': 1}}, 'idImportQual': ''}}, {'idProp': {'idSpace': 'VarName', 'idDefinedIn': {'moduleName': 'GHC.Base', 'modulePackage': {'packageName': 'base', 'packageKey': 'base', 'packageVersion': '4.8.1.0'}}, 'idHomeModule': {'moduleName': 'Control.Applicative', 'modulePackage': {'packageName': 'base', 'packageKey': 'base', 'packageVersion': '4.8.1.0'}}, 'idType': None, 'idName': '*>', 'idDefSpan': {'tag': 'TextSpan', 'contents': ''}}, 'idScope': {'tag': 'Imported', 'idImportedFrom': {'moduleName': 'Prelude', 'modulePackage': {'packageName': 'base', 'packageKey': 'base', 'packageVersion': '4.8.1.0'}}, 'idImportSpan': {'tag': 'ProperSpan', 'contents': {'spanFilePath': 'app/Main.hs', 'spanFromLine': 1, 'spanToLine': 1, 'spanFromColumn': 1, 'spanToColumn': 1}}, 'idImportQual': ''}}, {'idProp': {'idSpace': 'VarName', 'idDefinedIn': {'moduleName': 'GHC.Num', 'modulePackage': {'packageName': 'base', 'packageKey': 'base', 'packageVersion': '4.8.1.0'}}, 'idHomeModule': {'moduleName': 'Prelude', 'modulePackage': {'packageName': 'base', 'packageKey': 'base', 'packageVersion': '4.8.1.0'}}, 'idType': None, 'idName': '+', 'idDefSpan': {'tag': 'TextSpan', 'contents': ''}}, 'idScope': {'tag': 'Imported', 'idImportedFrom': {'moduleName': 'Prelude', 'modulePackage': {'packageName': 'base', 'packageKey': 'base', 'packageVersion': '4.8.1.0'}}, 'idImportSpan': {'tag': 'ProperSpan', 'contents': {'spanFilePath': 'app/Main.hs', 'spanFromLine': 1, 'spanToLine': 1, 'spanFromColumn': 1, 'spanToColumn': 1}}, 'idImportQual': ''}}]} -someFunc_span_info = {'contents': [[{'contents': {'idProp': {'idDefinedIn': {'moduleName': 'Lib', 'modulePackage': {'packageVersion': None, 'packageName': 'main', 'packageKey': 'main'}}, 'idSpace': 'VarName', 'idType': 'IO ()', 'idDefSpan': {'contents': {'spanFromLine': 9, 'spanFromColumn': 1, 'spanToColumn': 9, 'spanFilePath': 'src/Lib.hs', 'spanToLine': 9}, 'tag': 'ProperSpan'}, 'idName': 'someFunc', 'idHomeModule': None}, 'idScope': {'idImportQual': '', 'idImportedFrom': {'moduleName': 'Lib', 'modulePackage': {'packageVersion': None, 'packageName': 'main', 'packageKey': 'main'}}, 'idImportSpan': {'contents': {'spanFromLine': 3, 'spanFromColumn': 1, 'spanToColumn': 11, 'spanFilePath': 'app/Main.hs', 'spanToLine': 3}, 'tag': 'ProperSpan'}, 'tag': 'Imported'}}, 'tag': 'SpanId'}, {'spanFromLine': 7, 'spanFromColumn': 27, 'spanToColumn': 35, 'spanFilePath': 'app/Main.hs', 'spanToLine': 7}]], 'seq': '724752c9-a7bf-4658-834a-3ff7df64e7e5', 'tag': 'ResponseGetSpanInfo'} -putStrLn_span_info = {'contents': [[{'contents': {'idProp': {'idDefinedIn': {'moduleName': 'System.IO', 'modulePackage': {'packageVersion': '4.8.1.0', 'packageName': 'base', 'packageKey': 'base'}}, 'idSpace': 'VarName', 'idType': 'String -> IO ()', 'idDefSpan': {'contents': '', 'tag': 'TextSpan'}, 'idName': 'putStrLn', 'idHomeModule': {'moduleName': 'System.IO', 'modulePackage': {'packageVersion': '4.8.1.0', 'packageName': 'base', 'packageKey': 'base'}}}, 'idScope': {'idImportQual': '', 'idImportedFrom': {'moduleName': 'Prelude', 'modulePackage': {'packageVersion': '4.8.1.0', 'packageName': 'base', 'packageKey': 'base'}}, 'idImportSpan': {'contents': {'spanFromLine': 1, 'spanFromColumn': 8, 'spanToColumn': 12, 'spanFilePath': 'app/Main.hs', 'spanToLine': 1}, 'tag': 'ProperSpan'}, 'tag': 'Imported'}}, 'tag': 'SpanId'}, {'spanFromLine': 7, 'spanFromColumn': 41, 'spanToColumn': 49, 'spanFilePath': 'app/Main.hs', 'spanToLine': 7}]], 'seq': '6ee8d949-82bd-491d-8b79-ffcaa3e65fde', 'tag': 'ResponseGetSpanInfo'} readFile_exp_types = {'tag': 'ResponseGetExpTypes', 'contents': [['FilePath -> IO String', {'spanToColumn': 25, 'spanToLine': 10, 'spanFromColumn': 17, 'spanFromLine': 10, 'spanFilePath': 'src/Lib.hs'}], ['IO String', {'spanToColumn': 36, 'spanToLine': 10, 'spanFromColumn': 17, 'spanFromLine': 10, 'spanFilePath': 'src/Lib.hs'}], ['IO ()', {'spanToColumn': 28, 'spanToLine': 11, 'spanFromColumn': 12, 'spanFromLine': 9, 'spanFilePath': 'src/Lib.hs'}]], 'seq': 'fd3eb2a5-e390-4ad7-be72-8b2e82441a95'} status_progress_restart = {'contents': {'contents': [], 'tag': 'UpdateStatusRequiredRestart'}, 'tag': 'ResponseUpdateSession'} status_progress_1 = {'contents': {'contents': {'progressParsedMsg': 'Compiling Lib', 'progressNumSteps': 2, 'progressStep': 1, 'progressOrigMsg': '[1 of 2] Compiling Lib ( /Users/tomv/Projects/Personal/haskell/helloworld/src/Lib.hs, interpreted )'}, 'tag': 'UpdateStatusProgress'}, 'tag': 'ResponseUpdateSession'} @@ -59,7 +57,7 @@ def test_parse_completions(self): def test_parse_update_session(self): - self.assertEqual('Restarting session...', res.parse_update_session(status_progress_restart.get('contents'))) + self.assertEqual('Starting session...', res.parse_update_session(status_progress_restart.get('contents'))) self.assertEqual('Compiling Lib', res.parse_update_session(status_progress_1.get('contents'))) self.assertEqual('Compiling Main', res.parse_update_session(status_progress_2.get('contents'))) - self.assertEqual('Session started.', res.parse_update_session(status_progress_done.get('contents'))) + self.assertEqual(' ', res.parse_update_session(status_progress_done.get('contents'))) diff --git a/test/test_stack_ide_manager.py b/test/test_stack_ide_manager.py index e03a705..60e38a6 100644 --- a/test/test_stack_ide_manager.py +++ b/test/test_stack_ide_manager.py @@ -50,7 +50,7 @@ def test_launch_window_with_helloworld_project(self): self.assertIsInstance(instance, stackide.StackIDE) instance.end() - @patch('stack_ide.StackIDE.boot_ide_backend', side_effect=FileNotFoundError()) + @patch('stack_ide.boot_ide_backend', side_effect=FileNotFoundError()) def test_launch_window_stack_not_found(self, boot_mock): instance = manager.configure_instance( mock_window([cur_dir + '/projects/helloworld']), settings) @@ -59,7 +59,7 @@ def test_launch_window_stack_not_found(self, boot_mock): instance.reason, "instance init failed -- stack not found") self.assertRegex(sublime.current_error, "Could not find program 'stack'!") - @patch('stack_ide.StackIDE.boot_ide_backend', side_effect=Exception()) + @patch('stack_ide.boot_ide_backend', side_effect=Exception()) def test_launch_window_stack_not_found(self, boot_mock): instance = manager.configure_instance( mock_window([cur_dir + '/projects/helloworld']), settings) diff --git a/test/test_stackide.py b/test/test_stackide.py new file mode 100644 index 0000000..be13cbb --- /dev/null +++ b/test/test_stackide.py @@ -0,0 +1,60 @@ +import unittest +from unittest.mock import Mock, MagicMock +import stack_ide as stackide +from .stubs import sublime +from .stubs.backend import FakeBackend +from .mocks import mock_window, cur_dir +from settings import Settings +from req import Req + +test_settings = Settings("none", [], False) + + +class StackIDETests(unittest.TestCase): + + def test_can_create(self): + instance = stackide.StackIDE( + mock_window([cur_dir + '/mocks/helloworld/']), test_settings, FakeBackend()) + self.assertIsNotNone(instance) + self.assertTrue(instance.is_active) + self.assertTrue(instance.is_alive) + + def test_can_send_source_errors_request(self): + backend = FakeBackend() + backend.send_request = Mock() + instance = stackide.StackIDE( + mock_window([cur_dir + '/mocks/helloworld/']), test_settings, backend) + self.assertIsNotNone(instance) + self.assertTrue(instance.is_active) + self.assertTrue(instance.is_alive) + req = Req.get_source_errors() + instance.send_request(req) + backend.send_request.assert_called_with(req) + + def test_handle_welcome_stack_ide_outdated(self): + + backend = MagicMock() + welcome = { + "tag": "ResponseWelcome", + "contents": [0, 0, 0] + } + + instance = stackide.StackIDE(mock_window([cur_dir + '/mocks/helloworld/']), test_settings, backend) + instance.handle_response(welcome) + self.assertEqual(sublime.current_error, "Please upgrade stack-ide to a newer version.") + + + def test_can_shutdown(self): + backend = FakeBackend() + backend.send_request = Mock() + instance = stackide.StackIDE( + mock_window([cur_dir + '/mocks/helloworld/']), test_settings, backend) + self.assertIsNotNone(instance) + self.assertTrue(instance.is_active) + self.assertTrue(instance.is_alive) + instance.end() + self.assertFalse(instance.is_active) + self.assertFalse(instance.is_alive) + backend.send_request.assert_called_with( + Req.get_shutdown()) + diff --git a/test/test_utility.py b/test/test_utility.py index 98a93a0..b7b9aa2 100644 --- a/test/test_utility.py +++ b/test/test_utility.py @@ -8,3 +8,18 @@ def test_get_relative_filename(self): window = mock_window([cur_dir + '/projects/helloworld']) view = mock_view('src/Main.hs', window) self.assertEqual('src/Main.hs', utility.relative_view_file_name(view)) + + def test_is_haskell_view(self): + window = mock_window([cur_dir + '/projects/helloworld']) + view = mock_view('src/Main.hs', window) + self.assertTrue(utility.is_haskell_view(view)) + + def test_span_from_view_selection(self): + window = mock_window([cur_dir + '/projects/helloworld']) + view = mock_view('src/Main.hs', window) + span = utility.span_from_view_selection(view) + self.assertEqual(1, span['spanFromLine']) + self.assertEqual(1, span['spanToLine']) + self.assertEqual(1, span['spanFromColumn']) + self.assertEqual(1, span['spanToColumn']) + self.assertEqual('src/Main.hs', span['spanFilePath']) diff --git a/test/test_win.py b/test/test_win.py new file mode 100644 index 0000000..88aa822 --- /dev/null +++ b/test/test_win.py @@ -0,0 +1,94 @@ +import unittest +from unittest.mock import MagicMock, Mock, ANY +from win import Win +from .stubs import sublime +from .mocks import mock_view, mock_window, cur_dir +from utility import relative_view_file_name + +class WinTests(unittest.TestCase): + + def test_highlight_type_clear(self): + window = mock_window([cur_dir + '/projects/helloworld']) + view = mock_view('src/Main.hs', window) + + Win(window).highlight_type([]) + view.set_status.assert_called_with("type_at_cursor", "") + view.add_regions.assert_called_with("type_at_cursor", [], "storage.type", "", sublime.DRAW_OUTLINED) + + def test_highlight_no_errors(self): + window = mock_window([cur_dir + '/projects/helloworld']) + view = mock_view('src/Main.hs', window) + + window.run_command = Mock() + + panel = MagicMock() + + window.create_output_panel = Mock(return_value=panel) + errors = [] + Win(window).handle_source_errors(errors) + window.create_output_panel.assert_called_with("hide_errors") + + panel.settings().set.assert_called_with("result_file_regex", "^(..[^:]*):([0-9]+):?([0-9]+)?:? (.*)$") + window.run_command.assert_any_call("hide_panel", {"panel": "output.hide_errors"}) + panel.run_command.assert_called_with("clear_error_panel") + panel.set_read_only.assert_any_call(False) + + view.add_regions.assert_any_call("errors", [], "invalid", "dot", sublime.DRAW_OUTLINED) + view.add_regions.assert_any_call("warnings", [], "comment", "dot", sublime.DRAW_OUTLINED) + + window.run_command.assert_called_with("hide_panel", {"panel": "output.hide_errors"}) + panel.set_read_only.assert_any_call(True) + + def test_highlight_errors_and_warnings(self): + + window = mock_window([cur_dir + '/projects/helloworld']) + view = mock_view('src/Main.hs', window) + + window.run_command = Mock() + panel = MagicMock() + window.create_output_panel = Mock(return_value=panel) + error = { + "errorKind": "KindError", + "errorMsg": "", + "errorSpan": { + "tag": "ProperSpan", + "contents": { + "spanFilePath": relative_view_file_name(view), + "spanFromLine": 1, + "spanFromColumn": 1, + "spanToLine": 1, + "spanToColumn": 5 + } + } + } + warning = { + "errorKind": "KindWarning", + "errorMsg": "", + "errorSpan": { + "tag": "ProperSpan", + "contents": { + "spanFilePath": relative_view_file_name(view), + "spanFromLine": 1, + "spanFromColumn": 1, + "spanToLine": 1, + "spanToColumn": 5 + } + } + } + errors = [error, warning] + Win(window).handle_source_errors(errors) + window.create_output_panel.assert_called_with("hide_errors") + + panel.settings().set.assert_called_with("result_file_regex", "^(..[^:]*):([0-9]+):?([0-9]+)?:? (.*)$") + window.run_command.assert_any_call("hide_panel", {"panel": "output.hide_errors"}) + # panel.run_command.assert_any_call("clear_error_panel") + panel.set_read_only.assert_any_call(False) + + # panel should have received messages + panel.run_command.assert_any_call("update_error_panel", {"message": "src/Main.hs:1:1: KindError:\n"}) + panel.run_command.assert_any_call("update_error_panel", {"message": "src/Main.hs:1:1: KindWarning:\n"}) + + view.add_regions.assert_called_with("warnings", [ANY], "comment", "dot", sublime.DRAW_OUTLINED) + view.add_regions.assert_any_call('errors', [ANY], 'invalid', 'dot', 2) + window.run_command.assert_called_with("show_panel", {"panel": "output.hide_errors"}) + panel.set_read_only.assert_any_call(True) diff --git a/text_commands.py b/text_commands.py index 5b95d1f..01c887f 100644 --- a/text_commands.py +++ b/text_commands.py @@ -1,10 +1,15 @@ -import sublime, sublime_plugin +try: + import sublime, sublime_plugin +except ImportError: + from test.stubs import sublime, sublime_plugin -from SublimeStackIDE.utility import * -from SublimeStackIDE.req import * -from SublimeStackIDE.stack_ide import * -from SublimeStackIDE.stack_ide_manager import * -from SublimeStackIDE import response as res +import os, sys +sys.path.append(os.path.dirname(os.path.realpath(__file__))) + +from utility import span_from_view_selection, first_folder +from req import Req +from stack_ide_manager import send_request +from response import parse_span_info_response, parse_exp_types class ClearErrorPanelCommand(sublime_plugin.TextCommand): """ @@ -27,10 +32,10 @@ class ShowHsTypeAtCursorCommand(sublime_plugin.TextCommand): """ def run(self,edit): request = Req.get_exp_types(span_from_view_selection(self.view)) - send_request(self.view,request, self._handle_response) + send_request(self.view.window(),request, self._handle_response) def _handle_response(self,response): - types = list(res.parse_exp_types(response)) + types = list(parse_exp_types(response)) if types: (type, span) = types[0] # types are ordered by relevance? self.view.show_popup(type) @@ -43,14 +48,14 @@ class ShowHsInfoAtCursorCommand(sublime_plugin.TextCommand): """ def run(self,edit): request = Req.get_exp_info(span_from_view_selection(self.view)) - send_request(self.view, request, self._handle_response) + send_request(self.view.window(), request, self._handle_response) def _handle_response(self,response): if len(response) < 1: return - infos = res.parse_span_info_response(response) + infos = parse_span_info_response(response) (props, scope), span = next(infos) if not props.defSpan is None: @@ -70,7 +75,7 @@ class GotoDefinitionAtCursorCommand(sublime_plugin.TextCommand): """ def run(self,edit): request = Req.get_exp_info(span_from_view_selection(self.view)) - send_request(self.view,request, self._handle_response) + send_request(self.view.window(),request, self._handle_response) def _handle_response(self,response): @@ -96,10 +101,10 @@ class CopyHsTypeAtCursorCommand(sublime_plugin.TextCommand): """ def run(self,edit): request = Req.get_exp_types(span_from_view_selection(self.view)) - send_request(self.view,request, self._handle_response) + send_request(self.view.window(), request, self._handle_response) def _handle_response(self,response): - types = list(res.parse_exp_types(response)) + types = list(parse_exp_types(response)) if types: (type, span) = types[0] # types are ordered by relevance? sublime.set_clipboard(type) diff --git a/utility.py b/utility.py index 55d1f64..96a45b9 100644 --- a/utility.py +++ b/utility.py @@ -47,15 +47,12 @@ def relative_view_file_name(view): """ return view.file_name().replace(first_folder(view.window()) + "/", "") -def get_window(view_or_window): - """ - Accepts a View or a Window and returns the Window - """ - return view_or_window.window() if hasattr(view_or_window, 'window') else view_or_window - def span_from_view_selection(view): return span_from_view_region(view, view.sel()[0]) +def is_haskell_view(view): + return view.match_selector(view.sel()[0].begin(), "source.haskell") + def view_region_from_span(view, span): """ Maps a SourceSpan to a Region for a given view. diff --git a/watchdog.py b/watchdog.py index dc81e9d..c220687 100644 --- a/watchdog.py +++ b/watchdog.py @@ -1,6 +1,14 @@ -from SublimeStackIDE.stack_ide import * +import threading + +try: + import sublime +except ImportError: + from test.stubs import sublime + from SublimeStackIDE.stack_ide_manager import StackIDEManager from SublimeStackIDE.settings import Settings +from log import Log +from win import Win ############################# diff --git a/win.py b/win.py index bbab2b9..809a0f8 100644 --- a/win.py +++ b/win.py @@ -1,11 +1,12 @@ from itertools import groupby +import os + try: import sublime except ImportError: from test.stubs import sublime -from utility import * -from span import * -from response import * +from utility import first_folder, view_region_from_span +from response import parse_source_errors, parse_exp_types class Win: """ @@ -14,8 +15,8 @@ class Win: show_popup = False - def __init__(self,view_or_window): - self.window = get_window(view_or_window) + def __init__(self,window): + self.window = window def update_completions(self, completions): """ @@ -75,7 +76,7 @@ def handle_source_errors(self, source_errors): error_panel.set_read_only(True) - file_errors = filter(lambda error: error.span, errors) + file_errors = list(filter(lambda error: error.span, errors)) # First, make sure we have views open for each error need_load_wait = False paths = set(error.span.filePath for error in file_errors) diff --git a/window_commands.py b/window_commands.py index dfae927..9fb7934 100644 --- a/window_commands.py +++ b/window_commands.py @@ -1,11 +1,9 @@ try: import sublime_plugin - from stack_ide import * - from stack_ide_manager import * except ImportError: from test.stubs import sublime_plugin - from stack_ide import * - from stack_ide_manager import * + +from stack_ide_manager import StackIDEManager class UpdateCompletionsCommand(sublime_plugin.WindowCommand): """ From ce9a4ed7d7b488848a9d6d6a71128cf8c98b65c0 Mon Sep 17 00:00:00 2001 From: Tom van Ommeren Date: Wed, 21 Oct 2015 06:24:58 +0200 Subject: [PATCH 2/5] fixed super annoying import bug --- stack_ide_manager.py | 4 ++-- watchdog.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/stack_ide_manager.py b/stack_ide_manager.py index 197be31..b0cee6e 100644 --- a/stack_ide_manager.py +++ b/stack_ide_manager.py @@ -55,7 +55,7 @@ def configure_instance(window, settings): except FileNotFoundError as e: instance = NoStackIDE("instance init failed -- stack not found") Log.error(e) - cls.complain('stack-not-found', + StackIDEManager.complain('stack-not-found', "Could not find program 'stack'!\n\n" "Make sure that 'stack' and 'stack-ide' are both installed. " "If they are not on the system path, edit the 'add_to_PATH' " @@ -122,7 +122,7 @@ def check_windows(cls): def is_running(cls, window): if not window: return False - return cls.for_window(window) is not None + return StackIDEManager.for_window(window) is not None @classmethod diff --git a/watchdog.py b/watchdog.py index c220687..3930d23 100644 --- a/watchdog.py +++ b/watchdog.py @@ -5,10 +5,10 @@ except ImportError: from test.stubs import sublime -from SublimeStackIDE.stack_ide_manager import StackIDEManager -from SublimeStackIDE.settings import Settings +from settings import Settings from log import Log from win import Win +from stack_ide_manager import StackIDEManager ############################# From 408e7a202b2aa70b63990e6d08f0cca10d1d66b9 Mon Sep 17 00:00:00 2001 From: Tom van Ommeren Date: Wed, 21 Oct 2015 10:41:51 +0200 Subject: [PATCH 3/5] add watchdog / manager tests --- test/test_stack_ide_manager.py | 147 ++++++++++++++++++++++++++++----- 1 file changed, 125 insertions(+), 22 deletions(-) diff --git a/test/test_stack_ide_manager.py b/test/test_stack_ide_manager.py index 60e38a6..f213c08 100644 --- a/test/test_stack_ide_manager.py +++ b/test/test_stack_ide_manager.py @@ -1,13 +1,116 @@ import unittest -from unittest.mock import patch -import stack_ide_manager as manager +from unittest.mock import patch, MagicMock +from stack_ide_manager import NoStackIDE, StackIDEManager, configure_instance import stack_ide as stackide from .mocks import mock_window, cur_dir from .stubs import sublime +from .stubs.backend import FakeBackend from log import Log +from req import Req from settings import Settings +import watchdog as wd + +test_settings = Settings("none", [], False) + +class WatchdogTests(unittest.TestCase): + + def test_managed_by_plugin_events(self): + + self.assertIsNone(wd.watchdog) + + wd.plugin_loaded() + + self.assertIsNotNone(wd.watchdog) + + wd.plugin_unloaded() + + self.assertIsNone(wd.watchdog) + + + +class StackIDEManagerTests(unittest.TestCase): + + + def test_defaults(self): + + StackIDEManager.check_windows() + self.assertEqual(0, len(StackIDEManager.ide_backend_instances)) + + + def test_creates_initial_window(self): + + sublime.create_window('.') + + StackIDEManager.check_windows() + self.assertEqual(1, len(StackIDEManager.ide_backend_instances)) + + sublime.destroy_windows() + + def test_monitors_closed_windows(self): + + sublime.create_window('.') + StackIDEManager.check_windows() + self.assertEqual(1, len(StackIDEManager.ide_backend_instances)) + sublime.destroy_windows() + StackIDEManager.check_windows() + self.assertEqual(0, len(StackIDEManager.ide_backend_instances)) + + def test_monitors_new_windows(self): + + StackIDEManager.check_windows() + self.assertEqual(0, len(StackIDEManager.ide_backend_instances)) + sublime.create_window('.') + StackIDEManager.check_windows() + self.assertEqual(1, len(StackIDEManager.ide_backend_instances)) + sublime.destroy_windows() + + def test_retains_live_instances(self): + window = sublime.create_window('.') + StackIDEManager.check_windows() + self.assertEqual(1, len(StackIDEManager.ide_backend_instances)) + + # substitute a 'live' instance + instance = stackide.StackIDE(window, test_settings, FakeBackend()) + StackIDEManager.ide_backend_instances[window.id()] = instance + + # instance should still exist. + StackIDEManager.check_windows() + self.assertEqual(1, len(StackIDEManager.ide_backend_instances)) + self.assertEqual(instance, StackIDEManager.ide_backend_instances[window.id()]) + + sublime.destroy_windows() + + def test_kills_live_orphans(self): + window = sublime.create_window('.') + StackIDEManager.check_windows() + self.assertEqual(1, len(StackIDEManager.ide_backend_instances)) + + # substitute a 'live' instance + backend = MagicMock() + instance = stackide.StackIDE(window, test_settings, backend) + StackIDEManager.ide_backend_instances[window.id()] = instance + + # close the window + sublime.destroy_windows() + + # instance should be killed + StackIDEManager.check_windows() + self.assertEqual(0, len(StackIDEManager.ide_backend_instances)) + self.assertFalse(instance.is_alive) + backend.send_request.assert_called_with(Req.get_shutdown()) + + + def test_retains_existing_instances(self): + StackIDEManager.check_windows() + self.assertEqual(0, len(StackIDEManager.ide_backend_instances)) + sublime.create_window('.') + StackIDEManager.check_windows() + self.assertEqual(1, len(StackIDEManager.ide_backend_instances)) + StackIDEManager.check_windows() + self.assertEqual(1, len(StackIDEManager.ide_backend_instances)) + sublime.destroy_windows() + -settings = Settings("none", [], False) class LaunchTests(unittest.TestCase): @@ -20,49 +123,49 @@ def setUp(self): def test_launch_window_without_folder(self): - instance = manager.configure_instance(mock_window([]), settings) - self.assertIsInstance(instance, manager.NoStackIDE) + instance = configure_instance(mock_window([]), test_settings) + self.assertIsInstance(instance, NoStackIDE) self.assertRegex(instance.reason, "No folder to monitor.*") def test_launch_window_with_empty_folder(self): - instance = manager.configure_instance( - mock_window([cur_dir + '/projects/empty_project']), settings) - self.assertIsInstance(instance, manager.NoStackIDE) + instance = configure_instance( + mock_window([cur_dir + '/projects/empty_project']), test_settings) + self.assertIsInstance(instance, NoStackIDE) self.assertRegex(instance.reason, "No cabal file found.*") def test_launch_window_with_cabal_folder(self): - instance = manager.configure_instance( - mock_window([cur_dir + '/projects/cabal_project']), settings) - self.assertIsInstance(instance, manager.NoStackIDE) + instance = configure_instance( + mock_window([cur_dir + '/projects/cabal_project']), test_settings) + self.assertIsInstance(instance, NoStackIDE) self.assertRegex(instance.reason, "No stack.yaml in path.*") def test_launch_window_with_wrong_cabal_file(self): - instance = manager.configure_instance( - mock_window([cur_dir + '/projects/cabalfile_wrong_project']), settings) - self.assertIsInstance(instance, manager.NoStackIDE) + instance = configure_instance( + mock_window([cur_dir + '/projects/cabalfile_wrong_project']), test_settings) + self.assertIsInstance(instance, NoStackIDE) self.assertRegex( instance.reason, "cabalfile_wrong_project.cabal not found.*") @unittest.skip("Actually starts a stack ide, slow and won't work on Travis") def test_launch_window_with_helloworld_project(self): - instance = manager.configure_instance( - mock_window([cur_dir + '/projects/helloworld']), settings) + instance = configure_instance( + mock_window([cur_dir + '/projects/helloworld']), test_settings) self.assertIsInstance(instance, stackide.StackIDE) instance.end() @patch('stack_ide.boot_ide_backend', side_effect=FileNotFoundError()) def test_launch_window_stack_not_found(self, boot_mock): - instance = manager.configure_instance( - mock_window([cur_dir + '/projects/helloworld']), settings) - self.assertIsInstance(instance, manager.NoStackIDE) + instance = configure_instance( + mock_window([cur_dir + '/projects/helloworld']), test_settings) + self.assertIsInstance(instance, NoStackIDE) self.assertRegex( instance.reason, "instance init failed -- stack not found") self.assertRegex(sublime.current_error, "Could not find program 'stack'!") @patch('stack_ide.boot_ide_backend', side_effect=Exception()) def test_launch_window_stack_not_found(self, boot_mock): - instance = manager.configure_instance( - mock_window([cur_dir + '/projects/helloworld']), settings) - self.assertIsInstance(instance, manager.NoStackIDE) + instance = configure_instance( + mock_window([cur_dir + '/projects/helloworld']), test_settings) + self.assertIsInstance(instance, NoStackIDE) self.assertRegex( instance.reason, "instance init failed -- unknown error") From ab42a7836cad96c504599ea47253471b600a54e8 Mon Sep 17 00:00:00 2001 From: Tom van Ommeren Date: Wed, 21 Oct 2015 10:55:45 +0200 Subject: [PATCH 4/5] extract complain into utilities --- stack_ide.py | 14 ++------------ stack_ide_manager.py | 18 +++--------------- test/test_utility.py | 11 +++++++++++ utility.py | 16 ++++++++++++++++ 4 files changed, 32 insertions(+), 27 deletions(-) diff --git a/stack_ide.py b/stack_ide.py index 9a90176..1487f7b 100644 --- a/stack_ide.py +++ b/stack_ide.py @@ -11,7 +11,7 @@ sys.path.append(os.path.dirname(os.path.realpath(__file__))) -from utility import first_folder +from utility import first_folder, complain from req import Req from log import Log from win import Win @@ -26,7 +26,6 @@ class StackIDE: - complaints_shown = set() def __init__(self, window, settings, backend=None): self.window = window @@ -68,15 +67,6 @@ def send_request(self, request, response_handler = None): else: Log.error("Couldn't send request, no process!", request) - @classmethod - def complain(cls,complaint_id,msg): - """ - we don't do it again (until reset) - """ - if complaint_id not in cls.complaints_shown: - cls.complaints_shown.add(complaint_id) - sublime.error_message(msg) - def load_initial_targets(self): """ @@ -142,7 +132,7 @@ def handle_response(self, data): version_got = tuple(contents) if type(contents) is list else contents if expected_version > version_got: Log.error("Old stack-ide protocol:", version_got, '\n', 'Want version:', expected_version) - StackIDE.complain("wrong-stack-ide-version", + complain("wrong-stack-ide-version", "Please upgrade stack-ide to a newer version.") elif expected_version < version_got: Log.warning("stack-ide protocol may have changed:", version_got) diff --git a/stack_ide_manager.py b/stack_ide_manager.py index b0cee6e..5b00a00 100644 --- a/stack_ide_manager.py +++ b/stack_ide_manager.py @@ -6,7 +6,7 @@ from stack_ide import StackIDE from log import Log -from utility import first_folder,expected_cabalfile,has_cabal_file, is_stack_project +from utility import first_folder,expected_cabalfile,has_cabal_file, is_stack_project, complain, reset_complaints try: import sublime except ImportError: @@ -55,7 +55,7 @@ def configure_instance(window, settings): except FileNotFoundError as e: instance = NoStackIDE("instance init failed -- stack not found") Log.error(e) - StackIDEManager.complain('stack-not-found', + complain('stack-not-found', "Could not find program 'stack'!\n\n" "Make sure that 'stack' and 'stack-ide' are both installed. " "If they are not on the system path, edit the 'add_to_PATH' " @@ -71,7 +71,6 @@ def configure_instance(window, settings): class StackIDEManager: ide_backend_instances = {} - complaints_shown = set() settings = None @classmethod @@ -146,24 +145,13 @@ def reset(cls, settings): """ Log.normal("Resetting StackIDE") cls.kill_all() - cls.complaints_shown = set() + reset_complaints() cls.settings = settings @classmethod def configure(cls, settings): cls.settings = settings - @classmethod - def complain(cls,complaint_id,msg): - """ - Show the msg as an error message (on a modal pop-up). The complaint_id is - used to decide when we have already complained about something, so that - we don't do it again (until reset) - """ - if complaint_id not in cls.complaints_shown: - cls.complaints_shown.add(complaint_id) - sublime.error_message(msg) - class NoStackIDE: """ diff --git a/test/test_utility.py b/test/test_utility.py index b7b9aa2..55e765f 100644 --- a/test/test_utility.py +++ b/test/test_utility.py @@ -1,6 +1,7 @@ import unittest from test.mocks import mock_view, mock_window, cur_dir import utility +from .stubs import sublime class UtilTests(unittest.TestCase): @@ -23,3 +24,13 @@ def test_span_from_view_selection(self): self.assertEqual(1, span['spanFromColumn']) self.assertEqual(1, span['spanToColumn']) self.assertEqual('src/Main.hs', span['spanFilePath']) + + def test_complaints_not_repeated(self): + utility.complain('complaint', 'waaaah') + self.assertEqual(sublime.current_error, 'waaaah') + utility.complain('complaint', 'waaaah 2') + self.assertEqual(sublime.current_error, 'waaaah') + utility.reset_complaints() + utility.complain('complaint', 'waaaah 2') + self.assertEqual(sublime.current_error, 'waaaah 2') + diff --git a/utility.py b/utility.py index 96a45b9..5c6b6e5 100644 --- a/utility.py +++ b/utility.py @@ -10,6 +10,22 @@ from log import Log +complaints_shown = set() +def complain(id, text): + """ + Show the msg as an error message (on a modal pop-up). The complaint_id is + used to decide when we have already complained about something, so that + we don't do it again (until reset) + """ + if id not in complaints_shown: + complaints_shown.add(id) + sublime.error_message(text) + +def reset_complaints(): + global complaints_shown + complaints_shown = set() + + def first_folder(window): """ We only support running one stack-ide instance per window currently, From ec53f1648acf92686bfdd99a4334c358e81d379f Mon Sep 17 00:00:00 2001 From: Tom van Ommeren Date: Wed, 21 Oct 2015 11:42:30 +0200 Subject: [PATCH 5/5] fix bad merge --- stack_ide.py | 1 - 1 file changed, 1 deletion(-) diff --git a/stack_ide.py b/stack_ide.py index 1487f7b..f684a46 100644 --- a/stack_ide.py +++ b/stack_ide.py @@ -182,7 +182,6 @@ def boot_ide_backend(folder, env, response_handler): process = subprocess.Popen(["stack", "ide", "start", project_name], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=folder, env=env, - universal_newlines=True, creationflags=CREATE_NO_WINDOW )