diff --git a/Commands/Default.sublime-commands b/Commands/Default.sublime-commands index 62d964bd7..5299b104d 100644 --- a/Commands/Default.sublime-commands +++ b/Commands/Default.sublime-commands @@ -60,9 +60,12 @@ "args": {} }, { - "caption": "LSP: Show Diagnostics", - "command": "lsp_show_diagnostics_panel", - "args": {} + "caption": "LSP: Toggle Panel: Language Servers", + "command": "lsp_toggle_server_panel", + }, + { + "caption": "LSP: Toggle Panel: Diagnostics", + "command": "lsp_show_diagnostics_panel" }, { "caption": "LSP: Clear Diagnostics", diff --git a/Keymaps/Default (Linux).sublime-keymap b/Keymaps/Default (Linux).sublime-keymap index e595d3744..719febb11 100644 --- a/Keymaps/Default (Linux).sublime-keymap +++ b/Keymaps/Default (Linux).sublime-keymap @@ -13,7 +13,10 @@ { "keys": ["ctrl+."], "command": "lsp_code_actions", "context": [{"key": "setting.lsp_active"}]}, // Show/Hide Diagnostics Panel - { "keys": ["ctrl+alt+m"], "command": "lsp_show_diagnostics_panel", "context": [{"key": "setting.lsp_active"}]}, + { "keys": ["ctrl+alt+m"], "command": "lsp_toggle_panel", "args": {"panel_type": "diagnostics"}, "context": [{"key": "setting.lsp_active"}]}, + + // Show/Hide Language Server Logs Panel + // { "keys": ["UNBOUND"], "command": "lsp_toggle_panel", "args": {"panel_type": "language servers"}, "context": [{"key": "setting.lsp_active"}]}, // Trigger Signature Help { "keys": ["ctrl+alt+space"], "command": "noop", "context": [{ "key": "lsp.signature_help", "operator": "equal", "operand": 0}] }, diff --git a/Keymaps/Default (OSX).sublime-keymap b/Keymaps/Default (OSX).sublime-keymap index 2efd4df20..bf5b01ea8 100644 --- a/Keymaps/Default (OSX).sublime-keymap +++ b/Keymaps/Default (OSX).sublime-keymap @@ -13,7 +13,10 @@ { "keys": ["super+."], "command": "lsp_code_actions", "context": [{"key": "setting.lsp_active"}]}, // Show/Hide Diagnostics Panel - { "keys": ["super+alt+m"], "command": "lsp_show_diagnostics_panel", "context": [{"key": "setting.lsp_active"}]}, + { "keys": ["super+alt+m"], "command": "lsp_toggle_panel", "args": {"panel_type": "diagnostics"}, "context": [{"key": "setting.lsp_active"}]}, + + // Show/Hide Language Server Logs Panel + // { "keys": ["UNBOUND"], "command": "lsp_toggle_panel", "args": {"panel_type": "language servers"}, "context": [{"key": "setting.lsp_active"}]}, // Trigger Signature Help { "keys": ["super+alt+space"], "command": "noop", "context": [{ "key": "lsp.signature_help", "operator": "equal", "operand": 0}] }, diff --git a/Keymaps/Default (Windows).sublime-keymap b/Keymaps/Default (Windows).sublime-keymap index e595d3744..719febb11 100644 --- a/Keymaps/Default (Windows).sublime-keymap +++ b/Keymaps/Default (Windows).sublime-keymap @@ -13,7 +13,10 @@ { "keys": ["ctrl+."], "command": "lsp_code_actions", "context": [{"key": "setting.lsp_active"}]}, // Show/Hide Diagnostics Panel - { "keys": ["ctrl+alt+m"], "command": "lsp_show_diagnostics_panel", "context": [{"key": "setting.lsp_active"}]}, + { "keys": ["ctrl+alt+m"], "command": "lsp_toggle_panel", "args": {"panel_type": "diagnostics"}, "context": [{"key": "setting.lsp_active"}]}, + + // Show/Hide Language Server Logs Panel + // { "keys": ["UNBOUND"], "command": "lsp_toggle_panel", "args": {"panel_type": "language servers"}, "context": [{"key": "setting.lsp_active"}]}, // Trigger Signature Help { "keys": ["ctrl+alt+space"], "command": "noop", "context": [{ "key": "lsp.signature_help", "operator": "equal", "operand": 0}] }, diff --git a/LSP.sublime-settings b/LSP.sublime-settings index 30a762cec..be27c8fdb 100644 --- a/LSP.sublime-settings +++ b/LSP.sublime-settings @@ -82,13 +82,21 @@ // Show verbose debug messages in the sublime console. "log_debug": false, - // Show notifications from language servers in the console. + // Show messages from language servers in the Language Servers output panel. + // This output panel can be toggled from the command palette with the + // command "LSP: Toggle Panel: Language Servers". "log_server": true, - // Show language server stderr output in the console. + // Show language server stderr output in the Language Servers output panel. + // This output panel can be toggled from the command palette with the + // command "LSP: Toggle Panel: Language Servers". "log_stderr": false, - // Show full JSON-RPC responses in the console + // Show full JSON-RPC requests/responses/notifications in the Language Servers + // output panel. Note that if the payload is very large, SublimeText will not + // highlight the log line. + // This output panel can be toggled from the command palette with the + // command "LSP: Toggle Panel: Language Servers". "log_payloads": false, // User clients configuration can be used to diff --git a/Syntaxes/ServerLog.sublime-syntax b/Syntaxes/ServerLog.sublime-syntax new file mode 100644 index 000000000..ffda8e4a2 --- /dev/null +++ b/Syntaxes/ServerLog.sublime-syntax @@ -0,0 +1,92 @@ +%YAML 1.2 +--- +# [Subl]: https://www.sublimetext.com/docs/3/syntax.html +# [LSP]: https://github.com/Microsoft/language-server-protocol/blob/master/protocol.md +hidden: true +scope: output.lsp.log + +variables: + method: '[[:alpha:]][[:alnum:]/]*' + servername: '[[:alnum:]_-]+' + +contexts: + main: + - match: ^({{servername}})(:) + captures: + 1: variable.function.lsp + 2: punctuation.separator.lsp + push: + - meta_scope: meta.block.lsp + - match: $ + pop: true + - match: '^::' + scope: punctuation.accessor.lsp + push: + - - meta_scope: meta.group.lsp + - match: $ + pop: true + # responses + - - match: \d+ + scope: constant.numeric.integer.decimal.lsp + set: + - match: ':' + scope: punctuation.separator.lsp + set: maybe-payload + - match: '' + pop: true + # notifications or requests + - match: (?=\w) + set: + # requests + - - match: \( + scope: punctuation.section.parens.begin.lsp + set: + - match: \d+ + scope: constant.numeric.integer.decimal.lsp + set: + - match: \) + scope: punctuation.section.parens.end.lsp + set: + - match: ':' + scope: punctuation.separator.lsp + set: maybe-payload + - match: '' + pop: true + # notifications + - match: ':' + scope: punctuation.separator.lsp + set: maybe-payload + - match: '' + pop: true + - - match: '{{method}}' + scope: keyword.control.lsp + pop: true + # language server name + - - match: \S+ + scope: variable.function.lsp + pop: true + # arrows + - - match: --> + scope: storage.modifier.lsp + pop: true + - match: <-- + scope: storage.modifier.lsp + pop: true + - match: ==> + scope: storage.modifier.lsp + pop: true + - match: unhandled + scope: invalid.deprecated.lsp + pop: true + + maybe-payload: + - match: \s*(?=\S) + set: + - match: $ + pop: true + - include: scope:source.python#constants # e.g. shutdown request + - include: scope:source.python#lists + - include: scope:source.python#dictionaries-and-sets + - match: '' + pop: true +... diff --git a/boot.py b/boot.py index 88a088338..f7c92fa6e 100644 --- a/boot.py +++ b/boot.py @@ -4,6 +4,7 @@ from .plugin.core.panels import * from .plugin.core.registry import LspRestartClientCommand from .plugin.core.documents import * +from .plugin.panels import * from .plugin.edit import * from .plugin.completion import * from .plugin.diagnostics import * diff --git a/dependencies.json b/dependencies.json index fe8d67aca..d145060b6 100644 --- a/dependencies.json +++ b/dependencies.json @@ -10,4 +10,4 @@ "pyyaml" ] } -} \ No newline at end of file +} diff --git a/plugin/core/clients.py b/plugin/core/clients.py index 0184a40bf..8b13a9182 100644 --- a/plugin/core/clients.py +++ b/plugin/core/clients.py @@ -42,7 +42,8 @@ def start_window_config(window: sublime.Window, config: ClientConfig, on_pre_initialize: 'Callable[[Session], None]', on_post_initialize: 'Callable[[Session], None]', - on_post_exit: 'Callable[[str], None]') -> 'Optional[Session]': + on_post_exit: 'Callable[[str], None]', + on_stderr_log: 'Optional[Callable[[str], None]]') -> 'Optional[Session]': args, env = get_window_env(window, config) config.binary_args = args return create_session(config=config, @@ -51,7 +52,8 @@ def start_window_config(window: sublime.Window, settings=settings, on_pre_initialize=on_pre_initialize, on_post_initialize=on_post_initialize, - on_post_exit=lambda config_name: on_session_ended(window, config_name, on_post_exit)) + on_post_exit=lambda config_name: on_session_ended(window, config_name, on_post_exit), + on_stderr_log=on_stderr_log) def on_session_ended(window: sublime.Window, config_name: str, on_post_exit_handler: 'Callable[[str], None]') -> None: diff --git a/plugin/core/logging.py b/plugin/core/logging.py index 2282767d1..2801b2604 100644 --- a/plugin/core/logging.py +++ b/plugin/core/logging.py @@ -8,7 +8,6 @@ log_debug = False log_exceptions = True -log_server = True def set_debug_logging(logging_enabled: bool) -> None: @@ -21,11 +20,6 @@ def set_exception_logging(logging_enabled: bool) -> None: log_exceptions = logging_enabled -def set_server_logging(logging_enabled: bool) -> None: - global log_server - log_server = logging_enabled - - def debug(*args: 'Any') -> None: """Print args to the console if the "debug" setting is True.""" if log_debug: @@ -39,11 +33,6 @@ def exception_log(message: str, ex: Exception) -> None: print(''.join(traceback.format_exception(ex.__class__, ex, ex_traceback))) -def server_log(server_name: str, *args: 'Any') -> None: - if log_server: - printf(*args, prefix=server_name) - - def printf(*args: 'Any', prefix: str = 'LSP') -> None: """Print args to the console, prefixed by the plugin name.""" print(prefix + ":", *args) diff --git a/plugin/core/main.py b/plugin/core/main.py index 3ac31103d..8bab6f722 100644 --- a/plugin/core/main.py +++ b/plugin/core/main.py @@ -1,13 +1,13 @@ import sublime -from .settings import settings, load_settings, unload_settings -from .logging import set_debug_logging, set_server_logging -from .registry import windows, load_handlers, unload_sessions -from .panels import destroy_output_panels +from .logging import set_debug_logging, set_exception_logging +from .panels import destroy_output_panels, ensure_panel, PanelName from .popups import popups +from .registry import windows, load_handlers, unload_sessions +from .settings import settings, load_settings, unload_settings +from ..color import remove_color_boxes from ..diagnostics import DiagnosticsPresenter from ..highlights import remove_highlights -from ..color import remove_color_boxes try: from typing import Any, List, Dict, Tuple, Callable, Optional, Set @@ -16,12 +16,18 @@ pass +def ensure_server_panel(window: sublime.Window) -> 'Optional[sublime.View]': + return ensure_panel(window, PanelName.LanguageServers, "", "", "Packages/LSP/Syntaxes/ServerLog.sublime-syntax") + + def startup() -> None: load_settings() - set_debug_logging(settings.log_debug) - set_server_logging(settings.log_server) popups.load_css() + set_debug_logging(settings.log_debug) + set_exception_logging(True) windows.set_diagnostics_ui(DiagnosticsPresenter) + windows.set_server_panel_factory(ensure_server_panel) + windows.set_settings_factory(settings) load_handlers() sublime.status_message("LSP initialized") start_active_window() diff --git a/plugin/core/panels.py b/plugin/core/panels.py index 44af457a4..c82bec574 100644 --- a/plugin/core/panels.py +++ b/plugin/core/panels.py @@ -1,30 +1,50 @@ +from contextlib import contextmanager import sublime import sublime_plugin try: - from typing import Optional, Any - assert Optional, Any + from typing import Optional, Any, List, Generator + assert Optional and Any and List and Generator except ImportError: pass +# about 80 chars per line implies maintaining a buffer of about 40kb per window +SERVER_PANEL_MAX_LINES = 500 + + OUTPUT_PANEL_SETTINGS = { "auto_indent": False, "draw_indent_guides": False, "draw_white_space": "None", + "fold_buttons": True, "gutter": True, - 'is_widget': True, + "is_widget": True, "line_numbers": False, + "lsp_active": True, "margin": 3, "match_brackets": False, + "rulers": [], "scroll_past_end": False, "tab_size": 4, "translate_tabs_to_spaces": False, - "word_wrap": False, - "fold_buttons": True + "word_wrap": False } +class PanelName: + Diagnostics = "diagnostics" + References = "references" + LanguageServers = "language servers" + + +@contextmanager +def mutable(view: sublime.View) -> 'Generator': + view.set_read_only(False) + yield + view.set_read_only(True) + + def create_output_panel(window: sublime.Window, name: str) -> 'Optional[sublime.View]': panel = window.create_output_panel(name) settings = panel.settings() @@ -34,8 +54,12 @@ def create_output_panel(window: sublime.Window, name: str) -> 'Optional[sublime. def destroy_output_panels(window: sublime.Window) -> None: - for panel_name in ["references", "diagnostics"]: - window.destroy_output_panel(panel_name) + for field in filter(lambda a: not a.startswith('__'), PanelName.__dict__.keys()): + panel_name = getattr(PanelName, field) + panel = window.find_output_panel(panel_name) + if panel and panel.is_valid(): + panel.settings().set("syntax", "Packages/Text/Plain text.tmLanguage") + window.destroy_output_panel(panel_name) def create_panel(window: sublime.Window, name: str, result_file_regex: str, result_line_regex: str, @@ -50,6 +74,8 @@ def create_panel(window: sublime.Window, name: str, result_file_regex: str, resu # settings, so that it'll be picked up as a result buffer # see: Packages/Default/exec.py#L228-L230 panel = window.create_output_panel(name) + # All our panels are read-only + panel.set_read_only(True) return panel @@ -64,9 +90,8 @@ class LspClearPanelCommand(sublime_plugin.TextCommand): """ def run(self, edit: sublime.Edit) -> None: - self.view.set_read_only(False) - self.view.erase(edit, sublime.Region(0, self.view.size())) - self.view.set_read_only(True) + with mutable(self.view): + self.view.erase(edit, sublime.Region(0, self.view.size())) class LspUpdatePanelCommand(sublime_plugin.TextCommand): @@ -78,8 +103,26 @@ def run(self, edit: sublime.Edit, characters: 'Optional[str]' = "") -> None: # Clear folds self.view.unfold(sublime.Region(0, self.view.size())) - self.view.replace(edit, sublime.Region(0, self.view.size()), characters or "") + with mutable(self.view): + self.view.replace(edit, sublime.Region(0, self.view.size()), characters or "") # Clear the selection selection = self.view.sel() selection.clear() + + +class LspUpdateServerPanelCommand(sublime_plugin.TextCommand): + def run(self, edit: sublime.Edit, prefix: str, message: str) -> None: + with mutable(self.view): + self.view.insert(edit, self.view.size(), "{}: {}\n".format(prefix, message)) + total_lines, _ = self.view.rowcol(self.view.size()) + point = 0 # Starting from point 0 in the panel ... + regions = [] # type: List[sublime.Region] + for _ in range(0, max(0, total_lines - SERVER_PANEL_MAX_LINES)): + # ... collect all regions that span an entire line ... + region = self.view.full_line(point) + regions.append(region) + point = region.b + for region in reversed(regions): + # ... and erase them in reverse order + self.view.erase(edit, region) diff --git a/plugin/core/process.py b/plugin/core/process.py index ab5519891..5ebc16131 100644 --- a/plugin/core/process.py +++ b/plugin/core/process.py @@ -1,4 +1,4 @@ -from .logging import debug, exception_log, server_log +from .logging import debug, exception_log import os import shutil import subprocess @@ -32,8 +32,12 @@ def add_extension_if_missing(server_binary_args: 'List[str]') -> 'List[str]': return server_binary_args -def start_server(server_binary_args: 'List[str]', working_dir: 'Optional[str]', - env: 'Dict[str,str]', attach_stderr: bool) -> 'Optional[subprocess.Popen]': +def start_server( + server_binary_args: 'List[str]', + working_dir: 'Optional[str]', + env: 'Dict[str,str]', + on_stderr_log: 'Optional[Callable[[str], None]]' +) -> 'Optional[subprocess.Popen]': si = None if os.name == "nt": server_binary_args = add_extension_if_missing(server_binary_args) @@ -42,9 +46,9 @@ def start_server(server_binary_args: 'List[str]', working_dir: 'Optional[str]', debug("starting " + str(server_binary_args)) - stderr_destination = subprocess.PIPE if attach_stderr else subprocess.DEVNULL + stderr_destination = subprocess.PIPE if on_stderr_log else subprocess.DEVNULL - return subprocess.Popen( + process = subprocess.Popen( server_binary_args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, @@ -53,14 +57,19 @@ def start_server(server_binary_args: 'List[str]', working_dir: 'Optional[str]', env=env, startupinfo=si) + if on_stderr_log is not None: + attach_logger(process, process.stderr, on_stderr_log) -def attach_logger(process: 'subprocess.Popen', stream: 'IO[Any]') -> None: - threading.Thread(target=log_stream, args=(process, stream)).start() + return process -def log_stream(process: 'subprocess.Popen', stream: 'IO[Any]') -> None: +def attach_logger(process: 'subprocess.Popen', stream: 'IO[Any]', log_callback: 'Callable[[str], None]') -> None: + threading.Thread(target=log_stream, args=(process, stream, log_callback)).start() + + +def log_stream(process: 'subprocess.Popen', stream: 'IO[Any]', log_callback: 'Callable[[str], None]') -> None: """ - Reads any errors from the LSP process. + Read lines from a stream and invoke the log_callback on the result """ running = True while running: @@ -70,7 +79,7 @@ def log_stream(process: 'subprocess.Popen', stream: 'IO[Any]') -> None: content = stream.readline() if not content: break - server_log('server', content.decode('UTF-8', 'replace').strip()) + log_callback(content.decode('UTF-8', 'replace').strip()) except IOError as err: exception_log("Failure reading stream", err) return diff --git a/plugin/core/rpc.py b/plugin/core/rpc.py index fddfaadc5..de8cfd3df 100644 --- a/plugin/core/rpc.py +++ b/plugin/core/rpc.py @@ -1,13 +1,9 @@ import json -import socket -import time -from .transports import TCPTransport, StdioTransport, Transport -from .process import attach_logger +from .transports import StdioTransport, Transport try: import subprocess - from typing import Any, List, Dict, Tuple, Callable, Optional, Union - # from mypy_extensions import TypedDict - assert Any and List and Dict and Tuple and Callable and Optional and Union and subprocess + from typing import Any, List, Dict, Tuple, Callable, Optional, Union, Mapping + assert Any and List and Dict and Tuple and Callable and Optional and Union and subprocess and Mapping except ImportError: pass @@ -20,43 +16,14 @@ TCP_CONNECT_TIMEOUT = 5 DEFAULT_SYNC_REQUEST_TIMEOUT = 1.0 -# RequestDict = TypedDict('RequestDict', {'id': 'Union[str,int]', 'method': str, 'params': 'Optional[Any]'}) - def format_request(payload: 'Dict[str, Any]') -> str: """Converts the request into json""" return json.dumps(payload, sort_keys=False) -def attach_tcp_client(tcp_port: int, process: 'subprocess.Popen', settings: Settings) -> 'Optional[Client]': - if settings.log_stderr: - attach_logger(process, process.stdout) - - host = "localhost" - start_time = time.time() - debug('connecting to {}:{}'.format(host, tcp_port)) - - while time.time() - start_time < TCP_CONNECT_TIMEOUT: - try: - sock = socket.create_connection((host, tcp_port)) - transport = TCPTransport(sock) - - client = Client(transport, settings) - client.set_transport_failure_handler(lambda: try_terminate_process(process)) - return client - except ConnectionRefusedError: - pass - - process.kill() - raise Exception("Timeout connecting to socket") - - def attach_stdio_client(process: 'subprocess.Popen', settings: Settings) -> 'Client': transport = StdioTransport(process) - - # TODO: process owner can take care of this outside client? - if settings.log_stderr: - attach_logger(process, process.stderr) client = Client(transport, settings) client.set_transport_failure_handler(lambda: try_terminate_process(process)) return client @@ -69,11 +36,76 @@ def try_terminate_process(process: 'subprocess.Popen') -> None: pass # process can be terminated already +class Direction: + Incoming = '<--' + Outgoing = '-->' + OutgoingBlocking = '==>' + + +class PreformattedPayloadLogger: + + def __init__(self, settings: Settings, server_name: str, sink: 'Callable[[str], None]') -> None: + self.settings = settings + self.server_name = server_name + self.sink = sink + + def log(self, message: str, params: 'Any', log_payload: bool) -> None: + if log_payload: + message = "{}: {}".format(message, params) + self.sink(message) + + def format_response(self, direction: str, request_id: int) -> str: + return "{} {} {}".format(direction, self.server_name, request_id) + + def format_request(self, direction: str, method: str, request_id: int) -> str: + return "{} {} {}({})".format(direction, self.server_name, method, request_id) + + def format_notification(self, direction: str, method: str) -> str: + return "{} {} {}".format(direction, self.server_name, method) + + def outgoing_response(self, request_id: int, params: 'Any') -> None: + if not self.settings.log_debug: + return + self.log(self.format_response(Direction.Outgoing, request_id), params, self.settings.log_payloads) + + def outgoing_request(self, request_id: int, method: str, params: 'Any', blocking: bool) -> None: + if not self.settings.log_debug: + return + direction = Direction.OutgoingBlocking if blocking else Direction.Outgoing + self.log(self.format_request(direction, method, request_id), params, self.settings.log_payloads) + + def outgoing_notification(self, method: str, params: 'Any') -> None: + if not self.settings.log_debug: + return + log_payload = self.settings.log_payloads \ + and method != "textDocument/didChange" \ + and method != "textDocument/didOpen" + self.log(self.format_notification(Direction.Outgoing, method), params, log_payload) + + def incoming_response(self, request_id: int, params: 'Any') -> None: + if not self.settings.log_debug: + return + self.log(self.format_response(Direction.Incoming, request_id), params, self.settings.log_payloads) + + def incoming_request(self, request_id: int, method: str, params: 'Any', unhandled: bool) -> None: + if not self.settings.log_debug: + return + direction = "unhandled" if unhandled else Direction.Incoming + self.log(self.format_request(direction, method, request_id), params, self.settings.log_payloads) + + def incoming_notification(self, method: str, params: 'Any', unhandled: bool) -> None: + if not self.settings.log_debug or method == "window/logMessage": + return + direction = "unhandled" if unhandled else Direction.Incoming + self.log(self.format_notification(direction, method), params, self.settings.log_payloads) + + class Client(object): def __init__(self, transport: Transport, settings: Settings) -> None: self.transport = transport # type: Optional[Transport] self.transport.start(self.receive_payload, self.on_transport_closed) self.request_id = 0 + self.logger = PreformattedPayloadLogger(settings, "server", debug) self._response_handlers = {} # type: Dict[int, Tuple[Optional[Callable], Optional[Callable[[Any], None]]]] self._request_handlers = {} # type: Dict[str, Callable] self._notification_handlers = {} # type: Dict[str, Callable] @@ -84,7 +116,6 @@ def __init__(self, transport: Transport, settings: Settings) -> None: self._crash_handler = None # type: Optional[Callable] self._transport_fail_handler = None # type: Optional[Callable] self._error_display_handler = lambda msg: debug(msg) - self.settings = settings def send_request( self, @@ -94,7 +125,7 @@ def send_request( ) -> 'None': self.request_id += 1 if self.transport is not None: - debug(' --> ' + request.method) + self.logger.outgoing_request(self.request_id, request.method, request.params, blocking=False) self._response_handlers[self.request_id] = (handler, error_handler) self.send_payload(request.to_payload(self.request_id)) else: @@ -111,9 +142,9 @@ def execute_request(self, request: Request, timeout: float = DEFAULT_SYNC_REQUES debug('unable to send', request.method) return None - debug(' ==> ' + request.method) self.request_id += 1 request_id = self.request_id + self.logger.outgoing_request(request_id, request.method, request.params, blocking=True) self.send_payload(request.to_payload(request_id)) result = None try: @@ -128,12 +159,13 @@ def execute_request(self, request: Request, timeout: float = DEFAULT_SYNC_REQUES def send_notification(self, notification: Notification) -> None: if self.transport is not None: - debug(' --> ' + notification.method) + self.logger.outgoing_notification(notification.method, notification.params) self.send_payload(notification.to_payload()) else: debug('unable to send', notification.method) def send_response(self, response: Response) -> None: + self.logger.outgoing_response(response.request_id, response.result) self.send_payload(response.to_payload()) def exit(self) -> None: @@ -174,10 +206,7 @@ def receive_payload(self, message: str) -> None: try: if "method" in payload: - if "id" in payload: - self.handle("request", payload, self._request_handlers, payload.get("id")) - else: - self.handle("notification", payload, self._notification_handlers) + self.request_or_notification_handler(payload) elif "id" in payload: self.response_handler(payload) else: @@ -195,21 +224,19 @@ def response_handler(self, response: 'Dict[str, Any]') -> None: # This response handler *must not* run from the same thread that does a sync request # because of the usage of the condition variable below. request_id = int(response["id"]) - if self.settings.log_payloads: - debug(' ' + str(response.get("result", None))) handler, error_handler = self._response_handlers.pop(request_id, (None, None)) if "result" in response and "error" not in response: + result = response["result"] + self.logger.incoming_response(request_id, result) if handler: - handler(response["result"]) + handler(result) else: with self._sync_request_cvar: - self._sync_request_results[request_id] = response["result"] + self._sync_request_results[request_id] = result # At most one thread is waiting on the result. self._sync_request_cvar.notify() elif "result" not in response and "error" in response: error = response["error"] - if self.settings.log_payloads: - debug('ERR: ' + str(error)) if error_handler: error_handler(error) else: @@ -223,18 +250,28 @@ def on_request(self, request_method: str, handler: 'Callable') -> None: def on_notification(self, notification_method: str, handler: 'Callable') -> None: self._notification_handlers[notification_method] = handler - def handle(self, typestr: str, message: 'Dict[str, Any]', handlers: 'Dict[str, Callable]', *args: 'Any') -> None: - method = message.get("method", "") - params = message.get("params") - if method != "window/logMessage": - debug('<-- ' + method) - if self.settings.log_payloads and params: - debug(' ' + str(params)) + def request_or_notification_handler(self, payload: 'Mapping[str, Any]') -> None: + method = payload["method"] # type: str + params = payload.get("params") + request_id = payload.get("id") # type: Union[str, int, None] + if request_id is not None: + request_id_int = int(request_id) + + def log(method: str, params: 'Any', unhandled: bool) -> None: + nonlocal request_id_int + self.logger.incoming_request(request_id_int, method, params, unhandled) + + self.handle(request_id_int, method, params, "request", self._request_handlers, log) + else: + self.handle(None, method, params, "notification", self._notification_handlers, + self.logger.incoming_notification) + + def handle(self, request_id: 'Optional[int]', method: str, params: 'Any', typestr: str, + handlers: 'Mapping[str, Callable]', log: 'Callable[[str, Any, bool], None]') -> None: handler = handlers.get(method) + log(method, params, handler is None) if handler: try: - handler(params, *args) + handler(params) if request_id is None else handler(params, request_id) except Exception as err: exception_log("Error handling {} {}".format(typestr, method), err) - else: - debug("Unhandled {}".format(typestr), method) diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index 872ea1f27..ed7e1cdbd 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -20,6 +20,7 @@ def create_session(config: ClientConfig, on_pre_initialize: 'Optional[Callable[[Session], None]]' = None, on_post_initialize: 'Optional[Callable[[Session], None]]' = None, on_post_exit: 'Optional[Callable[[str], None]]' = None, + on_stderr_log: 'Optional[Callable[[str], None]]' = None, bootstrap_client: 'Optional[Any]' = None) -> 'Optional[Session]': def with_client(client: Client) -> 'Session': @@ -42,7 +43,7 @@ def with_client(client: Client) -> 'Session': server_args = list(s.replace("{port}", str(tcp_port)) for s in config.binary_args) working_dir = workspace_folders[0].path if workspace_folders else None - process = start_server(server_args, working_dir, env, settings.log_stderr) + process = start_server(server_args, working_dir, env, on_stderr_log) if process: if config.tcp_mode == "host": client_socket, address = socket.accept() diff --git a/plugin/core/test_mocks.py b/plugin/core/test_mocks.py index 0f75a5180..33f93eaf9 100644 --- a/plugin/core/test_mocks.py +++ b/plugin/core/test_mocks.py @@ -5,7 +5,7 @@ from .types import ClientConfig from .types import LanguageConfig from .types import Settings -from .windows import ViewLike +from .types import ViewLike import os try: @@ -71,6 +71,7 @@ def __init__(self, file_name): self._settings = MockSublimeSettings({"syntax": "Plain Text"}) self._status = dict() # type: Dict[str, str] self._text = "asdf" + self.commands = [] # type: List[Tuple[str, Dict[str, Any]]] def file_name(self): return self._file_name @@ -102,6 +103,9 @@ def score_selector(self, region, scope: str) -> int: def buffer_id(self): return 1 + def run_command(self, command_name: str, command_args: 'Dict[str, Any]') -> None: + self.commands.append((command_name, command_args)) + class MockHandlerDispatcher(object): def __init__(self, can_start: bool = True) -> None: @@ -314,5 +318,8 @@ def set_error_display_handler(self, handler: 'Callable') -> None: def set_crash_handler(self, handler: 'Callable') -> None: pass + def set_log_payload_handler(self, handler: 'Callable') -> None: + pass + def exit(self) -> None: pass diff --git a/plugin/core/test_process.py b/plugin/core/test_process.py index fdd1994c8..b46b39ac9 100644 --- a/plugin/core/test_process.py +++ b/plugin/core/test_process.py @@ -1,12 +1,9 @@ from .process import log_stream -from contextlib import contextmanager from io import BytesIO -from io import StringIO from subprocess import Popen from unittest import TestCase from unittest.mock import MagicMock import os -import sys try: from typing import Iterator @@ -16,17 +13,6 @@ pass -@contextmanager -def captured_output() -> 'Iterator[Tuple[StringIO, StringIO]]': - new_out, new_err = StringIO(), StringIO() - old_out, old_err = sys.stdout, sys.stderr - try: - sys.stdout, sys.stderr = new_out, new_err - yield sys.stdout, sys.stderr - finally: - sys.stdout, sys.stderr = old_out, old_err - - class ProcessTests(TestCase): def test_log_stream_encoding_utf8(self): @@ -34,7 +20,11 @@ def test_log_stream_encoding_utf8(self): process = Popen(args=['cmd.exe' if os.name == 'nt' else 'bash'], bufsize=1024) process.poll = MagicMock(return_value=None) # type: ignore text = '\U00010000' - with captured_output() as (out, err): - log_stream(process, BytesIO(text.encode(encoding))) - self.assertEqual(out.getvalue().strip(), 'server: {}'.format(text)) - self.assertEqual(err.getvalue().strip(), '') + message = "" + + def log_callback(msg: str) -> None: + nonlocal message + message = msg + + log_stream(process, BytesIO(text.encode(encoding)), log_callback) + self.assertEqual(message.strip(), text) diff --git a/plugin/core/test_windows.py b/plugin/core/test_windows.py index fb325e22d..4253c25a3 100644 --- a/plugin/core/test_windows.py +++ b/plugin/core/test_windows.py @@ -34,7 +34,8 @@ def mock_start_session(window: MockWindow, config: ClientConfig, on_pre_initialize: 'Callable[[Session], None]', on_post_initialize: 'Callable[[Session], None]', - on_post_exit: 'Callable[[str], None]') -> 'Optional[Session]': + on_post_exit: 'Callable[[str], None]', + on_stderr_log: 'Optional[Callable[[str], None]]') -> 'Optional[Session]': return create_session( config=TEST_CONFIG, workspace_folders=workspace_folders, @@ -43,7 +44,8 @@ def mock_start_session(window: MockWindow, bootstrap_client=MockClient(), on_pre_initialize=on_pre_initialize, on_post_initialize=on_post_initialize, - on_post_exit=on_post_exit) + on_post_exit=on_post_exit, + on_stderr_log=on_stderr_log) class WindowRegistryTests(unittest.TestCase): @@ -52,6 +54,7 @@ def test_can_get_window_state(self): windows = WindowRegistry(TestGlobalConfigs(), TestDocumentHandlerFactory(), mock_start_session, test_sublime, MockHandlerDispatcher()) + windows.set_settings_factory(MockSettings()) test_window = MockWindow() wm = windows.lookup(test_window) self.assertIsNotNone(wm) @@ -62,6 +65,7 @@ def test_removes_window_state(self): windows = WindowRegistry(TestGlobalConfigs(), TestDocumentHandlerFactory(), mock_start_session, test_sublime, MockHandlerDispatcher()) + windows.set_settings_factory(MockSettings()) wm = windows.lookup(test_window) wm.start_active_views() @@ -79,7 +83,7 @@ class WindowManagerTests(unittest.TestCase): def test_can_start_active_views(self): docs = MockDocuments() - wm = WindowManager(MockWindow([[MockView(__file__)]]), MockConfigs(), docs, + wm = WindowManager(MockWindow([[MockView(__file__)]]), MockSettings(), MockConfigs(), docs, DiagnosticsStorage(None), mock_start_session, test_sublime, MockHandlerDispatcher()) wm.start_active_views() @@ -90,8 +94,8 @@ def test_can_start_active_views(self): def test_can_open_supported_view(self): docs = MockDocuments() window = MockWindow([[]]) - wm = WindowManager(window, MockConfigs(), docs, DiagnosticsStorage(None), mock_start_session, test_sublime, - MockHandlerDispatcher()) + wm = WindowManager(window, MockSettings(), MockConfigs(), docs, DiagnosticsStorage(None), mock_start_session, + test_sublime, MockHandlerDispatcher()) wm.start_active_views() self.assertIsNone(wm.get_session(TEST_CONFIG.name, __file__)) @@ -109,7 +113,7 @@ def test_can_open_supported_view(self): def test_can_restart_sessions(self): docs = MockDocuments() - wm = WindowManager(MockWindow([[MockView(__file__)]]), MockConfigs(), docs, + wm = WindowManager(MockWindow([[MockView(__file__)]]), MockSettings(), MockConfigs(), docs, DiagnosticsStorage(None), mock_start_session, test_sublime, MockHandlerDispatcher()) wm.start_active_views() @@ -130,7 +134,7 @@ def test_can_restart_sessions(self): def test_ends_sessions_when_closed(self): docs = MockDocuments() test_window = MockWindow([[MockView(__file__)]]) - wm = WindowManager(test_window, MockConfigs(), docs, + wm = WindowManager(test_window, MockSettings(), MockConfigs(), docs, DiagnosticsStorage(None), mock_start_session, test_sublime, MockHandlerDispatcher()) wm.start_active_views() @@ -150,7 +154,7 @@ def test_ends_sessions_when_closed(self): def test_ends_sessions_when_quick_switching(self): docs = MockDocuments() test_window = MockWindow([[MockView(__file__)]], folders=[os.path.dirname(__file__)]) - wm = WindowManager(test_window, MockConfigs(), docs, + wm = WindowManager(test_window, MockSettings(), MockConfigs(), docs, DiagnosticsStorage(None), mock_start_session, test_sublime, MockHandlerDispatcher()) wm.start_active_views() @@ -177,7 +181,7 @@ def test_ends_sessions_when_quick_switching(self): def test_offers_restart_on_crash(self): docs = MockDocuments() - wm = WindowManager(MockWindow([[MockView(__file__)]]), MockConfigs(), docs, + wm = WindowManager(MockWindow([[MockView(__file__)]]), MockSettings(), MockConfigs(), docs, DiagnosticsStorage(None), mock_start_session, test_sublime, MockHandlerDispatcher()) wm.start_active_views() @@ -199,7 +203,7 @@ def test_offers_restart_on_crash(self): def test_invokes_language_handler(self): docs = MockDocuments() dispatcher = MockHandlerDispatcher() - wm = WindowManager(MockWindow([[MockView(__file__)]]), MockConfigs(), docs, + wm = WindowManager(MockWindow([[MockView(__file__)]]), MockSettings(), MockConfigs(), docs, DiagnosticsStorage(None), mock_start_session, test_sublime, dispatcher) wm.start_active_views() @@ -219,8 +223,8 @@ def test_returns_closest_workspace_folder(self): file_path = __file__ top_folder = os.path.dirname(__file__) parent_folder = os.path.dirname(top_folder) - wm = WindowManager(MockWindow([[MockView(__file__)]], [top_folder, parent_folder]), MockConfigs(), docs, - DiagnosticsStorage(None), mock_start_session, test_sublime, + wm = WindowManager(MockWindow([[MockView(__file__)]], [top_folder, parent_folder]), MockSettings(), + MockConfigs(), docs, DiagnosticsStorage(None), mock_start_session, test_sublime, dispatcher) wm.start_active_views() self.assertEqual(top_folder, wm.get_project_path(file_path)) diff --git a/plugin/core/types.py b/plugin/core/types.py index b837854c4..c1e1069b4 100644 --- a/plugin/core/types.py +++ b/plugin/core/types.py @@ -123,6 +123,9 @@ def sel(self) -> 'Any': def score_selector(self, region: 'Any', scope: str) -> int: ... + def run_command(self, command_name: str, command_args: 'Dict[str, Any]') -> None: + ... + class WindowLike(Protocol): def id(self) -> int: diff --git a/plugin/core/windows.py b/plugin/core/windows.py index 9caa278f5..e11ae6ad1 100644 --- a/plugin/core/windows.py +++ b/plugin/core/windows.py @@ -1,10 +1,10 @@ from .diagnostics import DiagnosticsStorage -from .logging import debug, server_log +from .logging import debug from .types import (ClientStates, ClientConfig, WindowLike, ViewLike, LanguageConfig, config_supports_syntax, ConfigRegistry, GlobalConfigs, Settings) -from .protocol import Notification, Response from .edit import parse_workspace_edit +from .protocol import Notification, Response from .sessions import Session from .url import filename_to_uri from .workspace import ( @@ -21,7 +21,6 @@ assert Optional and List and Callable and Dict and Session and Any and ModuleType and Iterator and Union assert LanguageConfig except ImportError: - pass Protocol = object # type: ignore @@ -87,6 +86,7 @@ def get_active_views(window: WindowLike) -> 'List[ViewLike]': class DocumentState: """Stores version count for documents open in a language service""" + def __init__(self, path: str) -> None: self.path = path self.version = 0 @@ -318,15 +318,22 @@ def notify_did_change(self, view: ViewLike) -> None: session.client.send_notification(Notification.didChange(params)) +def extract_message(params: 'Any') -> str: + return params.get("message", "???") if isinstance(params, dict) else "???" + + class WindowManager(object): - def __init__(self, window: WindowLike, configs: ConfigRegistry, documents: DocumentHandler, + def __init__(self, window: WindowLike, settings: Settings, configs: ConfigRegistry, documents: DocumentHandler, diagnostics: DiagnosticsStorage, session_starter: 'Callable', sublime: 'Any', - handler_dispatcher: LanguageHandlerListener, on_closed: 'Optional[Callable]' = None) -> None: + handler_dispatcher: LanguageHandlerListener, on_closed: 'Optional[Callable]' = None, + server_panel_factory: 'Optional[Callable]' = None) -> None: self._window = window + self._settings = settings self._configs = configs self.diagnostics = diagnostics self.documents = documents + self.server_panel_factory = server_panel_factory self._sessions = dict() # type: Dict[str, List[Session]] self._start_session = session_starter self._sublime = sublime @@ -442,7 +449,8 @@ def _start_client(self, config: ClientConfig, file_path: str) -> None: config, # config self._handle_pre_initialize, # on_pre_initialize self._handle_post_initialize, # on_post_initialize - self._handle_post_exit) # on_post_exit + self._handle_post_exit, # on_post_exit + lambda msg: self._handle_stderr_log(config.name, msg)) # on_stderr_log except Exception as e: message = "\n\n".join([ "Could not start {}", @@ -513,22 +521,29 @@ def _get_session_config(self, params: 'Dict[str, Any]', session: Session, client client.send_response(Response(request_id, items)) + def _payload_log_sink(self, message: str) -> None: + self._sublime.set_timeout_async(lambda: self._handle_server_message(":", message), 0) + def _handle_pre_initialize(self, session: 'Session') -> None: client = session.client client.set_crash_handler(lambda: self._handle_server_crash(session.config)) client.set_error_display_handler(self._window.status_message) + if self.server_panel_factory: + client.logger.server_name = session.config.name + client.logger.sink = self._payload_log_sink + client.on_request( "window/showMessageRequest", lambda params, request_id: self._handle_message_request(params, client, request_id)) client.on_notification( "window/showMessage", - lambda params: self._sublime.message_dialog(params.get("message"))) + lambda params: self._handle_show_message(session.config.name, params)) client.on_notification( "window/logMessage", - lambda params: server_log(session.config.name, params.get("message", "???") if params else "???")) + lambda params: self._handle_log_message(session.config.name, params)) def _handle_post_initialize(self, session: 'Session') -> None: client = session.client @@ -609,6 +624,24 @@ def _handle_server_crash(self, config: ClientConfig) -> None: if result == self._sublime.DIALOG_YES: self.restart_sessions() + def _handle_server_message(self, name: str, message: str) -> None: + if not self.server_panel_factory: + return + panel = self.server_panel_factory(self._window) + if not panel: + return debug("no server panel for window", self._window.id()) + panel.run_command("lsp_update_server_panel", {"prefix": name, "message": message}) + + def _handle_log_message(self, name: str, params: 'Any') -> None: + self._handle_server_message(name, extract_message(params)) + + def _handle_stderr_log(self, name: str, message: str) -> None: + if self._settings.log_stderr: + self._handle_server_message(name, message) + + def _handle_show_message(self, name: str, params: 'Any') -> None: + self._sublime.status_message("{}: {}".format(name, extract_message(params))) + class WindowRegistry(object): def __init__(self, configs: GlobalConfigs, documents: 'Any', @@ -620,20 +653,38 @@ def __init__(self, configs: GlobalConfigs, documents: 'Any', self._sublime = sublime self._handler_dispatcher = handler_dispatcher self._diagnostics_ui_class = None # type: Optional[Callable] + self._server_panel_factory = None # type: Optional[Callable] + self._settings = None # type: Optional[Settings] def set_diagnostics_ui(self, ui_class: 'Any') -> None: self._diagnostics_ui_class = ui_class + def set_server_panel_factory(self, factory: 'Callable') -> None: + self._server_panel_factory = factory + + def set_settings_factory(self, settings: Settings) -> None: + self._settings = settings + def lookup(self, window: 'Any') -> WindowManager: state = self._windows.get(window.id()) if state is None: + if not self._settings: + raise RuntimeError("no settings") window_configs = self._configs.for_window(window) window_documents = self._documents.for_window(window, window_configs) diagnostics_ui = self._diagnostics_ui_class(window, window_documents) if self._diagnostics_ui_class else None - state = WindowManager(window, window_configs, window_documents, DiagnosticsStorage(diagnostics_ui), - self._session_starter, self._sublime, - self._handler_dispatcher, lambda: self._on_closed(window)) + state = WindowManager( + window=window, + settings=self._settings, + configs=window_configs, + documents=window_documents, + diagnostics=DiagnosticsStorage(diagnostics_ui), + session_starter=self._session_starter, + sublime=self._sublime, + handler_dispatcher=self._handler_dispatcher, + on_closed=lambda: self._on_closed(window), + server_panel_factory=self._server_panel_factory) self._windows[window.id()] = state return state diff --git a/plugin/diagnostics.py b/plugin/diagnostics.py index 278c6359e..ee4f1d42e 100644 --- a/plugin/diagnostics.py +++ b/plugin/diagnostics.py @@ -110,18 +110,6 @@ def clear_diagnostics_status(self) -> None: self.has_status = False -class LspShowDiagnosticsPanelCommand(sublime_plugin.WindowCommand): - def run(self) -> None: - ensure_diagnostics_panel(self.window) - active_panel = self.window.active_panel() - is_active_panel = (active_panel == "output.diagnostics") - - if is_active_panel: - self.window.run_command("hide_panel", {"panel": "output.diagnostics"}) - else: - self.window.run_command("show_panel", {"panel": "output.diagnostics"}) - - class LspClearDiagnosticsCommand(sublime_plugin.WindowCommand): def run(self) -> None: windows.lookup(self.window).diagnostics.clear() @@ -342,9 +330,7 @@ def end_file(self, file_path: str) -> None: def end(self) -> None: assert self._panel, "must have a panel now!" self._panel.settings().set("result_base_dir", self._base_dir) - self._panel.set_read_only(False) self._panel.run_command("lsp_update_panel", {"characters": "\n".join(self._to_render)}) - self._panel.set_read_only(True) def format_diagnostic(self, diagnostic: Diagnostic) -> str: location = "{:>8}:{:<4}".format( diff --git a/plugin/panels.py b/plugin/panels.py new file mode 100644 index 000000000..8e9b78071 --- /dev/null +++ b/plugin/panels.py @@ -0,0 +1,23 @@ +from .core.main import ensure_server_panel +from .diagnostics import ensure_diagnostics_panel +from .core.panels import PanelName +from sublime_plugin import WindowCommand +from sublime import Window + + +def toggle_output_panel(window: Window, panel_type: str) -> None: + panel_name = "output.{}".format(panel_type) + command = "{}_panel".format("hide" if window.active_panel() == panel_name else "show") + window.run_command(command, {"panel": panel_name}) + + +class LspToggleServerPanelCommand(WindowCommand): + def run(self) -> None: + ensure_server_panel(self.window) + toggle_output_panel(self.window, PanelName.LanguageServers) + + +class LspShowDiagnosticsPanelCommand(WindowCommand): + def run(self) -> None: + ensure_diagnostics_panel(self.window) + toggle_output_panel(self.window, PanelName.Diagnostics) diff --git a/plugin/references.py b/plugin/references.py index ba86028dd..66925fd4a 100644 --- a/plugin/references.py +++ b/plugin/references.py @@ -140,7 +140,6 @@ def show_references_panel(self, references_by_file: 'Dict[str, List[Tuple[Point, base_dir = windows.lookup(window).get_project_path(self.view.file_name() or "") panel.settings().set("result_base_dir", base_dir) - panel.set_read_only(False) panel.run_command("lsp_clear_panel") window.run_command("show_panel", {"panel": "output.references"}) panel.run_command('append', { @@ -152,7 +151,6 @@ def show_references_panel(self, references_by_file: 'Dict[str, List[Tuple[Point, # highlight all word occurrences regions = panel.find_all(r"\b{}\b".format(self.word)) panel.add_regions('ReferenceHighlight', regions, 'comment', flags=sublime.DRAW_OUTLINED) - panel.set_read_only(True) def get_selected_file_path(self, index: int) -> str: return self.get_full_path(self.reflist[index][0]) diff --git a/tests/test_configs.py b/tests/test_configs.py index 5f21b77f8..d709a6b22 100644 --- a/tests/test_configs.py +++ b/tests/test_configs.py @@ -59,6 +59,7 @@ def test_defaults(self): class WindowConfigTests(DeferrableTestCase): def setUp(self): + windows._windows.clear() self.view = sublime.active_window().open_file(test_file_path) def test_window_without_configs(self): diff --git a/tests/test_server_panel_circular.py b/tests/test_server_panel_circular.py new file mode 100644 index 000000000..27b739ac7 --- /dev/null +++ b/tests/test_server_panel_circular.py @@ -0,0 +1,45 @@ +from LSP.plugin.core.panels import SERVER_PANEL_MAX_LINES +from LSP.plugin.core.main import ensure_server_panel +from setup import TextDocumentTestCase +import sublime + + +class LspServerPanelTests(TextDocumentTestCase): + + def setUp(self): + super().setUp() + window = self.view.window() + if window is None: + self.skipTest("window is None!") + return + panel = ensure_server_panel(window) + if panel is None: + self.skipTest("panel is None!") + return + panel.run_command("lsp_clear_panel") + self.panel = panel + + def assert_total_lines_equal(self, expected_total_lines): + actual_total_lines = len(self.panel.split_by_newlines(sublime.Region(0, self.panel.size()))) + self.assertEqual(actual_total_lines, expected_total_lines) + + def update_panel(self, msg: str) -> None: + self.panel.run_command("lsp_update_server_panel", {"prefix": "test", "message": msg}) + + def test_server_panel_circular_behavior(self): + n = SERVER_PANEL_MAX_LINES + for i in range(0, n + 1): + self.assert_total_lines_equal(max(1, i)) + self.update_panel(str(i)) + self.update_panel("overflow") + self.assert_total_lines_equal(n) + self.update_panel("overflow") + self.assert_total_lines_equal(n) + self.update_panel("one\ntwo\nthree") + self.assert_total_lines_equal(n) + line_regions = self.panel.split_by_newlines(sublime.Region(0, self.panel.size())) + self.assertEqual(self.panel.substr(line_regions[0]), "test: 6") + self.assertEqual(len(line_regions), n) + self.assertEqual(self.panel.substr(line_regions[n - 3]), "test: one") + self.assertEqual(self.panel.substr(line_regions[n - 2]), "two") + self.assertEqual(self.panel.substr(line_regions[n - 1]), "three")