# -*- coding: utf-8 -*- # # Copyright © Spyder Project Contributors # Licensed under the terms of the MIT License # (see spyder/__init__.py for details) """ Language servers configuration widgets. """ # Standard library imports import json import re # Third party imports from qtpy.compat import to_qvariant from qtpy.QtCore import (Qt, Slot, QAbstractTableModel, QModelIndex, QSize) from qtpy.QtWidgets import (QAbstractItemView, QCheckBox, QComboBox, QDialog, QDialogButtonBox, QGroupBox, QGridLayout, QHBoxLayout, QLabel, QLineEdit, QSpinBox, QTableView, QVBoxLayout) # Local imports from spyder.config.base import _ from spyder.config.gui import get_font from spyder.plugins.completion.api import SUPPORTED_LANGUAGES from spyder.utils.misc import check_connection_port from spyder.utils.programs import find_program from spyder.widgets.helperwidgets import ItemDelegate from spyder.widgets.simplecodeeditor import SimpleCodeEditor LSP_LANGUAGE_NAME = {x.lower(): x for x in SUPPORTED_LANGUAGES} LANGUAGE_SET = {lang.lower() for lang in SUPPORTED_LANGUAGES} def iter_servers(get_option, set_option, remove_option): for language in LANGUAGE_SET: conf = get_option(language, default=None) if conf is not None: server = LSPServer(language=language, set_option=set_option, get_option=get_option, remove_option=remove_option) server.load() yield server class LSPServer(object): """Convenience class to store LSP Server configuration values.""" def __init__(self, language=None, cmd='', host='127.0.0.1', port=2084, args='', external=False, stdio=False, configurations={}, set_option=None, get_option=None, remove_option=None): self.index = 0 self.language = language if self.language in LSP_LANGUAGE_NAME: self.language = LSP_LANGUAGE_NAME[self.language] self.cmd = cmd self.args = args self.configurations = configurations self.port = port self.host = host self.external = external self.stdio = stdio self.set_option = set_option self.get_option = get_option self.remove_option = remove_option def __repr__(self): base_str = '[{0}] {1} {2} ({3}:{4})' fmt_args = [self.language, self.cmd, self.args, self.host, self.port] if self.stdio: base_str = '[{0}] {1} {2}' fmt_args = [self.language, self.cmd, self.args] if self.external: base_str = '[{0}] {1}:{2}' fmt_args = [self.language, self.host, self.port] return base_str.format(*fmt_args) def __str__(self): return self.__repr__() def __unicode__(self): return self.__repr__() def load(self): if self.language is not None: state = self.get_option(self.language.lower()) self.__dict__.update(state) def save(self): if self.language is not None: language = self.language.lower() dict_repr = dict(self.__dict__) dict_repr.pop('set_option') dict_repr.pop('get_option') dict_repr.pop('remove_option') self.set_option(language, dict_repr, recursive_notification=False) def delete(self): if self.language is not None: language = self.language.lower() self.remove_option(language) class LSPServerEditor(QDialog): DEFAULT_HOST = '127.0.0.1' DEFAULT_PORT = 2084 DEFAULT_CMD = '' DEFAULT_ARGS = '' DEFAULT_CONFIGURATION = '{}' DEFAULT_EXTERNAL = False DEFAULT_STDIO = False HOST_REGEX = re.compile(r'^\w+([.]\w+)*$') NON_EMPTY_REGEX = re.compile(r'^\S+$') JSON_VALID = _('Valid JSON') JSON_INVALID = _('Invalid JSON') MIN_SIZE = QSize(850, 600) INVALID_CSS = "QLineEdit {border: 1px solid red;}" VALID_CSS = "QLineEdit {border: 1px solid green;}" def __init__(self, parent, language=None, cmd='', host='127.0.0.1', port=2084, args='', external=False, stdio=False, configurations={}, get_option=None, set_option=None, remove_option=None, **kwargs): super(LSPServerEditor, self).__init__(parent) description = _( "To create a new server configuration, you need to select a " "programming language, set the command to start its associated " "server and enter any arguments that should be passed to it on " "startup. Additionally, you can set the server's hostname and " "port if connecting to an external server, " "or to a local one using TCP instead of stdio pipes." "

" "Note: You can use the placeholders {host} and " "{port} in the server arguments field to automatically " "fill in the respective values.
" ) self.parent = parent self.external = external self.set_option = set_option self.get_option = get_option self.remove_option = remove_option # Widgets self.server_settings_description = QLabel(description) self.lang_cb = QComboBox(self) self.external_cb = QCheckBox(_('External server'), self) self.host_label = QLabel(_('Host:')) self.host_input = QLineEdit(self) self.port_label = QLabel(_('Port:')) self.port_spinner = QSpinBox(self) self.cmd_label = QLabel(_('Command:')) self.cmd_input = QLineEdit(self) self.args_label = QLabel(_('Arguments:')) self.args_input = QLineEdit(self) self.json_label = QLabel(self.JSON_VALID, self) self.conf_label = QLabel(_('Server Configuration:')) self.conf_input = SimpleCodeEditor(None) self.bbox = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) self.button_ok = self.bbox.button(QDialogButtonBox.Ok) self.button_cancel = self.bbox.button(QDialogButtonBox.Cancel) # Widget setup self.setMinimumSize(self.MIN_SIZE) self.setWindowTitle(_('LSP server editor')) self.server_settings_description.setWordWrap(True) self.lang_cb.setToolTip( _('Programming language provided by the LSP server')) self.lang_cb.addItem(_('Select a language')) self.lang_cb.addItems(SUPPORTED_LANGUAGES) self.button_ok.setEnabled(False) if language is not None: idx = SUPPORTED_LANGUAGES.index(language) self.lang_cb.setCurrentIndex(idx + 1) self.button_ok.setEnabled(True) self.host_input.setPlaceholderText('127.0.0.1') self.host_input.setText(host) self.host_input.textChanged.connect(lambda _: self.validate()) self.port_spinner.setToolTip(_('TCP port number of the server')) self.port_spinner.setMinimum(1) self.port_spinner.setMaximum(60000) self.port_spinner.setValue(port) self.port_spinner.valueChanged.connect(lambda _: self.validate()) self.cmd_input.setText(cmd) self.cmd_input.setPlaceholderText('/absolute/path/to/command') self.args_input.setToolTip( _('Additional arguments required to start the server')) self.args_input.setText(args) self.args_input.setPlaceholderText(r'--host {host} --port {port}') self.conf_input.setup_editor( language='json', color_scheme=get_option('selected', section='appearance'), wrap=False, highlight_current_line=True, font=get_font() ) self.conf_input.set_language('json') self.conf_input.setToolTip(_('Additional LSP server configuration ' 'set at runtime. JSON required')) try: conf_text = json.dumps(configurations, indent=4, sort_keys=True) except Exception: conf_text = '{}' self.conf_input.set_text(conf_text) self.external_cb.setToolTip( _('Check if the server runs on a remote location')) self.external_cb.setChecked(external) self.stdio_cb = QCheckBox(_('Use stdio pipes for communication'), self) self.stdio_cb.setToolTip(_('Check if the server communicates ' 'using stdin/out pipes')) self.stdio_cb.setChecked(stdio) # Layout setup hlayout = QHBoxLayout() general_vlayout = QVBoxLayout() general_vlayout.addWidget(self.server_settings_description) vlayout = QVBoxLayout() lang_group = QGroupBox(_('Language')) lang_layout = QVBoxLayout() lang_layout.addWidget(self.lang_cb) lang_group.setLayout(lang_layout) vlayout.addWidget(lang_group) server_group = QGroupBox(_('Language server')) server_layout = QGridLayout() server_layout.addWidget(self.cmd_label, 0, 0) server_layout.addWidget(self.cmd_input, 0, 1) server_layout.addWidget(self.args_label, 1, 0) server_layout.addWidget(self.args_input, 1, 1) server_group.setLayout(server_layout) vlayout.addWidget(server_group) address_group = QGroupBox(_('Server address')) host_layout = QVBoxLayout() host_layout.addWidget(self.host_label) host_layout.addWidget(self.host_input) port_layout = QVBoxLayout() port_layout.addWidget(self.port_label) port_layout.addWidget(self.port_spinner) conn_info_layout = QHBoxLayout() conn_info_layout.addLayout(host_layout) conn_info_layout.addLayout(port_layout) address_group.setLayout(conn_info_layout) vlayout.addWidget(address_group) advanced_group = QGroupBox(_('Advanced')) advanced_layout = QVBoxLayout() advanced_layout.addWidget(self.external_cb) advanced_layout.addWidget(self.stdio_cb) advanced_group.setLayout(advanced_layout) vlayout.addWidget(advanced_group) conf_layout = QVBoxLayout() conf_layout.addWidget(self.conf_label) conf_layout.addWidget(self.conf_input) conf_layout.addWidget(self.json_label) vlayout.addStretch() hlayout.addLayout(vlayout, 2) hlayout.addLayout(conf_layout, 3) general_vlayout.addLayout(hlayout) general_vlayout.addWidget(self.bbox) self.setLayout(general_vlayout) self.form_status(False) # Signals if not external: self.cmd_input.textChanged.connect(lambda x: self.validate()) self.external_cb.stateChanged.connect(self.set_local_options) self.stdio_cb.stateChanged.connect(self.set_stdio_options) self.lang_cb.currentIndexChanged.connect(self.lang_selection_changed) self.conf_input.textChanged.connect(self.validate) self.bbox.accepted.connect(self.accept) self.bbox.rejected.connect(self.reject) # Final setup if language is not None: self.form_status(True) self.validate() if stdio: self.set_stdio_options(True) if external: self.set_local_options(True) @Slot() def validate(self): host_text = self.host_input.text() cmd_text = self.cmd_input.text() if host_text not in ['127.0.0.1', 'localhost']: self.external = True self.external_cb.setChecked(True) if not self.HOST_REGEX.match(host_text): self.button_ok.setEnabled(False) self.host_input.setStyleSheet(self.INVALID_CSS) if bool(host_text): self.host_input.setToolTip(_('Hostname must be valid')) else: self.host_input.setToolTip( _('Hostname or IP address of the host on which the server ' 'is running. Must be non empty.')) else: self.host_input.setStyleSheet(self.VALID_CSS) self.host_input.setToolTip(_('Hostname is valid')) self.button_ok.setEnabled(True) if not self.external: if not self.NON_EMPTY_REGEX.match(cmd_text): self.button_ok.setEnabled(False) self.cmd_input.setStyleSheet(self.INVALID_CSS) self.cmd_input.setToolTip( _('Command used to start the LSP server locally. Must be ' 'non empty')) return if find_program(cmd_text) is None: self.button_ok.setEnabled(False) self.cmd_input.setStyleSheet(self.INVALID_CSS) self.cmd_input.setToolTip(_('Program was not found ' 'on your system')) else: self.cmd_input.setStyleSheet(self.VALID_CSS) self.cmd_input.setToolTip(_('Program was found on your ' 'system')) self.button_ok.setEnabled(True) else: port = int(self.port_spinner.text()) response = check_connection_port(host_text, port) if not response: self.button_ok.setEnabled(False) try: json.loads(self.conf_input.toPlainText()) try: self.json_label.setText(self.JSON_VALID) except Exception: pass except ValueError: try: self.json_label.setText(self.JSON_INVALID) self.button_ok.setEnabled(False) except Exception: pass def form_status(self, status): self.host_input.setEnabled(status) self.port_spinner.setEnabled(status) self.external_cb.setEnabled(status) self.stdio_cb.setEnabled(status) self.cmd_input.setEnabled(status) self.args_input.setEnabled(status) self.conf_input.setEnabled(status) self.json_label.setVisible(status) @Slot() def lang_selection_changed(self): idx = self.lang_cb.currentIndex() if idx == 0: self.set_defaults() self.form_status(False) self.button_ok.setEnabled(False) else: server = self.parent.get_server_by_lang(SUPPORTED_LANGUAGES[idx - 1]) self.form_status(True) if server is not None: self.host_input.setText(server.host) self.port_spinner.setValue(server.port) self.external_cb.setChecked(server.external) self.stdio_cb.setChecked(server.stdio) self.cmd_input.setText(server.cmd) self.args_input.setText(server.args) self.conf_input.set_text(json.dumps(server.configurations)) self.json_label.setText(self.JSON_VALID) self.button_ok.setEnabled(True) else: self.set_defaults() def set_defaults(self): self.cmd_input.setStyleSheet('') self.host_input.setStyleSheet('') self.host_input.setText(self.DEFAULT_HOST) self.port_spinner.setValue(self.DEFAULT_PORT) self.external_cb.setChecked(self.DEFAULT_EXTERNAL) self.stdio_cb.setChecked(self.DEFAULT_STDIO) self.cmd_input.setText(self.DEFAULT_CMD) self.args_input.setText(self.DEFAULT_ARGS) self.conf_input.set_text(self.DEFAULT_CONFIGURATION) self.json_label.setText(self.JSON_VALID) @Slot(bool) @Slot(int) def set_local_options(self, enabled): self.external = enabled self.cmd_input.setEnabled(True) self.args_input.setEnabled(True) if enabled: self.cmd_input.setEnabled(False) self.cmd_input.setStyleSheet('') self.args_input.setEnabled(False) self.stdio_cb.stateChanged.disconnect() self.stdio_cb.setChecked(False) self.stdio_cb.setEnabled(False) else: self.cmd_input.setEnabled(True) self.args_input.setEnabled(True) self.stdio_cb.setEnabled(True) self.stdio_cb.setChecked(False) self.stdio_cb.stateChanged.connect(self.set_stdio_options) try: self.validate() except Exception: pass @Slot(bool) @Slot(int) def set_stdio_options(self, enabled): self.stdio = enabled if enabled: self.cmd_input.setEnabled(True) self.args_input.setEnabled(True) self.external_cb.stateChanged.disconnect() self.external_cb.setChecked(False) self.external_cb.setEnabled(False) self.host_input.setStyleSheet('') self.host_input.setEnabled(False) self.port_spinner.setEnabled(False) else: self.cmd_input.setEnabled(True) self.args_input.setEnabled(True) self.external_cb.setChecked(False) self.external_cb.setEnabled(True) self.external_cb.stateChanged.connect(self.set_local_options) self.host_input.setEnabled(True) self.port_spinner.setEnabled(True) try: self.validate() except Exception: pass def get_options(self): language_idx = self.lang_cb.currentIndex() language = SUPPORTED_LANGUAGES[language_idx - 1] host = self.host_input.text() port = int(self.port_spinner.value()) external = self.external_cb.isChecked() stdio = self.stdio_cb.isChecked() args = self.args_input.text() cmd = self.cmd_input.text() configurations = json.loads(self.conf_input.toPlainText()) server = LSPServer(language=language.lower(), cmd=cmd, args=args, host=host, port=port, external=external, stdio=stdio, configurations=configurations, get_option=self.get_option, set_option=self.set_option, remove_option=self.remove_option) return server LANGUAGE, ADDR, CMD = [0, 1, 2] class LSPServersModel(QAbstractTableModel): def __init__(self, parent, text_color=None, text_color_highlight=None): QAbstractTableModel.__init__(self) self._parent = parent self.servers = [] self.server_map = {} # self.scores = [] self.rich_text = [] self.normal_text = [] self.letters = '' self.label = QLabel() self.widths = [] # Needed to compensate for the HTMLDelegate color selection unawareness palette = parent.palette() if text_color is None: self.text_color = palette.text().color().name() else: self.text_color = text_color if text_color_highlight is None: self.text_color_highlight = \ palette.highlightedText().color().name() else: self.text_color_highlight = text_color_highlight def sortByName(self): """Qt Override.""" self.servers = sorted(self.servers, key=lambda x: x.language) self.reset() def flags(self, index): """Qt Override.""" if not index.isValid(): return Qt.ItemIsEnabled return Qt.ItemFlags(QAbstractTableModel.flags(self, index)) def data(self, index, role=Qt.DisplayRole): """Qt Override.""" row = index.row() if not index.isValid() or not (0 <= row < len(self.servers)): return to_qvariant() server = self.servers[row] column = index.column() if role == Qt.DisplayRole: if column == LANGUAGE: return to_qvariant(server.language) elif column == ADDR: text = '{0}:{1}'.format(server.host, server.port) return to_qvariant(text) elif column == CMD: text = ' {{0}} {{1}}' text = text.format(self.text_color) if server.external: text = ' External server' return to_qvariant(text.format(server.cmd, server.args)) elif role == Qt.TextAlignmentRole: return to_qvariant(int(Qt.AlignHCenter | Qt.AlignVCenter)) return to_qvariant() def headerData(self, section, orientation, role=Qt.DisplayRole): """Qt Override.""" if role == Qt.TextAlignmentRole: if orientation == Qt.Horizontal: return to_qvariant(int(Qt.AlignHCenter | Qt.AlignVCenter)) return to_qvariant(int(Qt.AlignRight | Qt.AlignVCenter)) if role != Qt.DisplayRole: return to_qvariant() if orientation == Qt.Horizontal: if section == LANGUAGE: return to_qvariant(_("Language")) elif section == ADDR: return to_qvariant(_("Address")) elif section == CMD: return to_qvariant(_("Command to execute")) return to_qvariant() def rowCount(self, index=QModelIndex()): """Qt Override.""" return len(self.servers) def columnCount(self, index=QModelIndex()): """Qt Override.""" return 3 def row(self, row_num): """Get row based on model index. Needed for the custom proxy model.""" return self.servers[row_num] def reset(self): """"Reset model to take into account new search letters.""" self.beginResetModel() self.endResetModel() class LSPServerTable(QTableView): def __init__(self, parent, text_color=None): QTableView.__init__(self, parent) self._parent = parent self.delete_queue = [] self.source_model = LSPServersModel(self, text_color=text_color) self.setModel(self.source_model) self.setItemDelegateForColumn(CMD, ItemDelegate(self)) self.setSelectionBehavior(QAbstractItemView.SelectRows) self.setSelectionMode(QAbstractItemView.SingleSelection) self.setSortingEnabled(True) self.setEditTriggers(QAbstractItemView.AllEditTriggers) self.selectionModel().selectionChanged.connect(self.selection) self.verticalHeader().hide() self.load_servers() def focusOutEvent(self, e): """Qt Override.""" # self.source_model.update_active_row() # self._parent.delete_btn.setEnabled(False) super(LSPServerTable, self).focusOutEvent(e) def focusInEvent(self, e): """Qt Override.""" super(LSPServerTable, self).focusInEvent(e) self.selectRow(self.currentIndex().row()) def selection(self, index): """Update selected row.""" self.update() self.isActiveWindow() self._parent.delete_btn.setEnabled(True) def adjust_cells(self): """Adjust column size based on contents.""" self.resizeColumnsToContents() fm = self.horizontalHeader().fontMetrics() names = [fm.width(s.cmd) for s in self.source_model.servers] if names: self.setColumnWidth(CMD, max(names)) self.horizontalHeader().setStretchLastSection(True) def get_server_by_lang(self, lang): return self.source_model.server_map.get(lang) def load_servers(self): servers = list(iter_servers(self._parent.get_option, self._parent.set_option, self._parent.remove_option)) for i, server in enumerate(servers): server.index = i server.language = LSP_LANGUAGE_NAME[server.language.lower()] server_map = {x.language: x for x in servers} self.source_model.servers = servers self.source_model.server_map = server_map self.source_model.reset() self.adjust_cells() self.sortByColumn(LANGUAGE, Qt.AscendingOrder) def save_servers(self): language_set = set({}) for server in self.source_model.servers: language_set |= {server.language.lower()} server.save() while len(self.delete_queue) > 0: server = self.delete_queue.pop(0) language_set |= {server.language.lower()} server.delete() return language_set def delete_server(self, idx): server = self.source_model.servers.pop(idx) self.delete_queue.append(server) self.source_model.server_map.pop(server.language) self.source_model.reset() self.adjust_cells() self.sortByColumn(LANGUAGE, Qt.AscendingOrder) def delete_server_by_lang(self, language): idx = next((i for i, x in enumerate(self.source_model.servers) if x.language == language), None) if idx is not None: self.delete_server(idx) def show_editor(self, new_server=False): server = LSPServer(get_option=self._parent.get_option, set_option=self._parent.set_option, remove_option=self._parent.remove_option) if not new_server: idx = self.currentIndex().row() server = self.source_model.row(idx) dialog = LSPServerEditor(self, **server.__dict__) if dialog.exec_(): server = dialog.get_options() self.source_model.server_map[server.language] = server self.source_model.servers = list( self.source_model.server_map.values()) self.source_model.reset() self.adjust_cells() self.sortByColumn(LANGUAGE, Qt.AscendingOrder) self._parent.set_modified(True) def next_row(self): """Move to next row from currently selected row.""" row = self.currentIndex().row() rows = self.source_model.rowCount() if row + 1 == rows: row = -1 self.selectRow(row + 1) def previous_row(self): """Move to previous row from currently selected row.""" row = self.currentIndex().row() rows = self.source_model.rowCount() if row == 0: row = rows self.selectRow(row - 1) def keyPressEvent(self, event): """Qt Override.""" key = event.key() if key in [Qt.Key_Enter, Qt.Key_Return]: self.show_editor() elif key in [Qt.Key_Backtab]: self.parent().reset_btn.setFocus() elif key in [Qt.Key_Up, Qt.Key_Down, Qt.Key_Left, Qt.Key_Right]: super(LSPServerTable, self).keyPressEvent(event) else: super(LSPServerTable, self).keyPressEvent(event) def mouseDoubleClickEvent(self, event): """Qt Override.""" self.show_editor()