diff --git a/plugins/code/completers/lsp_completer/provider_response_cache.py b/plugins/code/completers/lsp_completer/provider_response_cache.py deleted file mode 100644 index 58288d1..0000000 --- a/plugins/code/completers/lsp_completer/provider_response_cache.py +++ /dev/null @@ -1,55 +0,0 @@ -# Python imports -from concurrent.futures import ThreadPoolExecutor - -# Lib imports -import gi -gi.require_version('GtkSource', '4') - -from gi.repository import GtkSource - -# Application imports -from libs.event_factory import Code_Event_Types - -from core.widgets.code.completion_providers.provider_response_cache_base import ProviderResponseCacheBase - - - -class ProviderResponseCache(ProviderResponseCacheBase): - def __init__(self): - super(ProviderResponseCache, self).__init__() - - self.executor = ThreadPoolExecutor(max_workers = 1) - self.matchers: dict = {} - - - def process_file_load(self, event: Code_Event_Types.AddedNewFileEvent): - buffer = event.file.buffer - self.executor.submit(self._handle_change, buffer) - - def process_file_close(self, event: Code_Event_Types.RemovedFileEvent): - ... - - def process_file_save(self, event: Code_Event_Types.SavedFileEvent): - ... - - def process_file_change(self, event: Code_Event_Types.TextChangedEvent): - buffer = event.file.buffer - self.executor.submit(self._handle_change, buffer) - - def _handle_change(self, buffer): - ... - - - def filter(self, word: str) -> list[dict]: - return [] - - def filter_with_context(self, context: GtkSource.CompletionContext) -> list[dict]: - proposals = [ - { - "label": "LSP Class", - "text": "LSP Code", - "info": "A test LSP completion item..." - } - ] - - return proposals diff --git a/plugins/code/commands/prettify_json/__init__.py b/plugins/code/event-watchers/file_state_watcher/__init__.py similarity index 100% rename from plugins/code/commands/prettify_json/__init__.py rename to plugins/code/event-watchers/file_state_watcher/__init__.py diff --git a/plugins/code/commands/prettify_json/__main__.py b/plugins/code/event-watchers/file_state_watcher/__main__.py similarity index 100% rename from plugins/code/commands/prettify_json/__main__.py rename to plugins/code/event-watchers/file_state_watcher/__main__.py diff --git a/plugins/code/event-watchers/file_state_watcher/manifest.json b/plugins/code/event-watchers/file_state_watcher/manifest.json new file mode 100644 index 0000000..1cfc512 --- /dev/null +++ b/plugins/code/event-watchers/file_state_watcher/manifest.json @@ -0,0 +1,7 @@ +{ + "name": "File State Watcher", + "author": "ITDominator", + "version": "0.0.1", + "support": "", + "requests": {} +} diff --git a/plugins/code/event-watchers/file_state_watcher/plugin.py b/plugins/code/event-watchers/file_state_watcher/plugin.py new file mode 100644 index 0000000..43faf62 --- /dev/null +++ b/plugins/code/event-watchers/file_state_watcher/plugin.py @@ -0,0 +1,32 @@ +# Python imports + +# Lib imports + +# Application imports +from libs.event_factory import Event_Factory, Code_Event_Types + +from plugins.plugin_types import PluginCode + +from .watcher_checks import * + + + +class Plugin(PluginCode): + def __init__(self): + super(Plugin, self).__init__() + + + def _controller_message(self, event: Code_Event_Types.CodeEvent): + if isinstance(event, Code_Event_Types.TextChangedEvent): + event.file.check_file_on_disk() + + if event.file.is_deleted(): + file_is_deleted(event) + elif event.file.is_externally_modified(): + file_is_externally_modified(event) + + def load(self): + ... + + def run(self): + ... diff --git a/plugins/code/event-watchers/file_state_watcher/watcher_checks.py b/plugins/code/event-watchers/file_state_watcher/watcher_checks.py new file mode 100644 index 0000000..6ec9fea --- /dev/null +++ b/plugins/code/event-watchers/file_state_watcher/watcher_checks.py @@ -0,0 +1,32 @@ +# Python imports + +# Lib imports +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk + +# Application imports +from libs.event_factory import Event_Factory, Code_Event_Types + + + +def file_is_deleted(event): + event.file.was_deleted = True + event = Event_Factory.create_event( + "file_externally_deleted", + file = event.file, + buffer = event.buffer + ) + self.emit(event) + + +def file_is_externally_modified(event): +# event = Event_Factory.create_event( +# "file_externally_modified", +# file = event.file, +# buffer = event.buffer +# ) +# self.emit(event) + + ... + diff --git a/plugins/code/completers/lsp_completer/__init__.py b/plugins/code/event-watchers/prettify_json/__init__.py similarity index 100% rename from plugins/code/completers/lsp_completer/__init__.py rename to plugins/code/event-watchers/prettify_json/__init__.py diff --git a/plugins/code/completers/lsp_completer/__main__.py b/plugins/code/event-watchers/prettify_json/__main__.py similarity index 100% rename from plugins/code/completers/lsp_completer/__main__.py rename to plugins/code/event-watchers/prettify_json/__main__.py diff --git a/plugins/code/commands/prettify_json/manifest.json b/plugins/code/event-watchers/prettify_json/manifest.json similarity index 100% rename from plugins/code/commands/prettify_json/manifest.json rename to plugins/code/event-watchers/prettify_json/manifest.json diff --git a/plugins/code/completers/lsp_completer/plugin.py b/plugins/code/event-watchers/prettify_json/plugin.py similarity index 53% rename from plugins/code/completers/lsp_completer/plugin.py rename to plugins/code/event-watchers/prettify_json/plugin.py index 379dd73..4938f24 100644 --- a/plugins/code/completers/lsp_completer/plugin.py +++ b/plugins/code/event-watchers/prettify_json/plugin.py @@ -2,7 +2,9 @@ # Lib imports import gi + gi.require_version('Gtk', '3.0') + from gi.repository import Gtk # Application imports @@ -10,7 +12,7 @@ from libs.event_factory import Event_Factory, Code_Event_Types from plugins.plugin_types import PluginCode -from .provider import Provider +from .prettify_json import add_prettify_json @@ -18,25 +20,17 @@ class Plugin(PluginCode): def __init__(self): super(Plugin, self).__init__() - self.provider: Provider = None - def _controller_message(self, event: Code_Event_Types.CodeEvent): - ... + if isinstance(event, Code_Event_Types.PopulateSourceViewPopupEvent): + language = event.buffer.get_language() + if not language: return + + if "json" == language.get_id(): + add_prettify_json(event.buffer, event.menu) def load(self): - self.provider = Provider() - - event = Event_Factory.create_event( - "register_provider", - provider_name = "LSP Completer", - provider = self.provider, - language_ids = [] - ) - self.emit_to("completion", event) + ... def run(self): ... - - def generate_plugin_element(self): - ... diff --git a/plugins/code/commands/prettify_json/plugin.py b/plugins/code/event-watchers/prettify_json/prettify_json.py similarity index 53% rename from plugins/code/commands/prettify_json/plugin.py rename to plugins/code/event-watchers/prettify_json/prettify_json.py index de44951..0e62dc6 100644 --- a/plugins/code/commands/prettify_json/plugin.py +++ b/plugins/code/event-watchers/prettify_json/prettify_json.py @@ -9,13 +9,10 @@ gi.require_version('Gtk', '3.0') from gi.repository import Gtk # Application imports -from libs.event_factory import Event_Factory, Code_Event_Types - -from plugins.plugin_types import PluginCode -def _load_prettify_json(buffer, menu): +def add_prettify_json(buffer, menu): menu.append( Gtk.SeparatorMenuItem() ) def on_prettify_json(menuitem, buffer): @@ -32,24 +29,3 @@ def _load_prettify_json(buffer, menu): item = Gtk.MenuItem(label = "Prettify JSON") item.connect("activate", on_prettify_json, buffer) menu.append(item) - - - -class Plugin(PluginCode): - def __init__(self): - super(Plugin, self).__init__() - - - def _controller_message(self, event: Code_Event_Types.CodeEvent): - if isinstance(event, Code_Event_Types.PopulateSourceViewPopupEvent): - language = event.buffer.get_language() - if not language: return - - if language.get_id() == "json": - _load_prettify_json(event.buffer, event.menu) - - def load(self): - ... - - def run(self): - ... diff --git a/plugins/code/ui/lsp_manager/AGENTS.md b/plugins/code/ui/lsp_manager/AGENTS.md new file mode 100644 index 0000000..7e5ab76 --- /dev/null +++ b/plugins/code/ui/lsp_manager/AGENTS.md @@ -0,0 +1,159 @@ +# AGENTS.md - LSP Manager Plugin + +## Project Overview + +This is a Newton editor plugin providing LSP (Language Server Protocol) code completion via WebSocket. Written in Python using GTK3/GtkSource. + +## Build/Lint/Test Commands + +### Running Tests + +```bash +# Run all websocket library tests +python -m unittest discover -s libs/websocket/tests + +# Run a single test file +python -m unittest libs.websocket.tests.test_websocket + +# Run a single test +python -m unittest libs.websocket.tests.test_websocket.WebSocketTest.test_default_timeout +``` + +### Environment + +- Python 3.x +- GTK 3.0 with GtkSource 4 +- No build system (pyproject.toml/setup.py) - direct execution + +## Code Style Guidelines + +### Import Organization + +Always use three-section ordering: +```python +# Python imports +import json +from os import path + +# Lib imports +import gi +gi.require_version('Gtk', '3.0') +gi.require_version('GtkSource', '4') +from gi.repository import Gtk + +# Application imports +from libs.event_factory import Event_Factory +from plugins.plugin_types import PluginCode +from .lsp_manager import LSPManager +``` + +### Formatting + +- 4-space indentation +- Line length: ~100 chars (soft limit) +- No trailing whitespace +- Use f-strings for string formatting: `f"{variable} text"` + +### Naming Conventions + +- **Classes**: PascalCase (`LSPManager`, `ProviderResponseCache`) +- **Methods/functions**: snake_case (`create_client`, `load_lsp_servers_config`) +- **Private members**: leading underscore (`_setup_styling`, `_init_params`) +- **Constants**: SCREAMING_SNAKE_CASE + +### Type Hints + +- Use Python 3.x type hints +- Common types: `str`, `int`, `dict`, `list`, `bool` +- For untyped parameters, use `any`: + ```python + def execute(view: any, *args, **kwargs) + ``` + +### Error Handling + +- Use try/except blocks with specific exception types +- Use `logger.error()` for logging errors with context +- Return early on failure conditions: + ```python + if not lang_id: return + if not lang_id in self.servers_config: return + ``` + +### Class Structure + +```python +class LSPManager(Gtk.Dialog): + def __init__(self): + super(LSPManager, self).__init__() + self._setup_styling() + self._setup_signals() + self._subscribe_to_events() + self._load_widgets() + + def _setup_styling(self): + ... + + def _setup_signals(self): + ... +``` + +### GTK Patterns + +- Use `gi.require_version()` before importing GTK modules +- Use `GLib.idle_add()` for deferred UI updates +- Connect signals with `widget.connect("signal_name", handler)` + +### Empty Methods + +Use ellipsis for stub/placeholder methods: +```python +def _subscribe_to_events(self): + ... +``` + +### Logging + +- Import `logger` from application (available globally) +- Use `logger.debug()`, `logger.error()` for appropriate levels +- Include context in error messages: + ```python + logger.error( f"LSP Controller: {_LSP_INIT_CONFIG}\n\t\t{repr(e)}" ) + ``` + +### Inheritance Patterns + +```python +class Provider(GObject.GObject, GtkSource.CompletionProvider): + __gtype_name__ = 'LSPProvider' + + def do_get_name(self): + return "LSP Code Completion" +``` + +### Testing Patterns (websocket library) + +- Use `unittest.TestCase` +- Skip tests with decorators: + ```python + @unittest.skipUnless(TEST_WITH_INTERNET, "Internet-requiring tests are disabled") + def test_remote(self): + ... + ``` +- Environment variables for test configuration: + - `TEST_WITH_INTERNET=1` - enable internet tests + - `LOCAL_WS_SERVER_PORT=9999` - enable local server tests + +### File Organization + +- Main plugin: `plugin.py` +- Core logic: `lsp_manager.py`, `provider.py`, `provider_response_cache.py` +- Controllers: `controllers/` directory +- Config: `configs/` directory +- Embedded libs: `libs/websocket/` + +### Anti-Patterns to Avoid + +- Avoid bare `except:` - use specific exceptions +- Avoid global mutable state where possible +- Don't commit secrets or credentials diff --git a/plugins/code/ui/lsp_manager/__init__.py b/plugins/code/ui/lsp_manager/__init__.py new file mode 100644 index 0000000..d36fa8c --- /dev/null +++ b/plugins/code/ui/lsp_manager/__init__.py @@ -0,0 +1,3 @@ +""" + Pligin Module +""" diff --git a/plugins/code/ui/lsp_manager/__main__.py b/plugins/code/ui/lsp_manager/__main__.py new file mode 100644 index 0000000..a576329 --- /dev/null +++ b/plugins/code/ui/lsp_manager/__main__.py @@ -0,0 +1,3 @@ +""" + Pligin Package +""" diff --git a/plugins/code/ui/lsp_manager/configs/initialize-params-slim.json b/plugins/code/ui/lsp_manager/configs/initialize-params-slim.json new file mode 100644 index 0000000..e018372 --- /dev/null +++ b/plugins/code/ui/lsp_manager/configs/initialize-params-slim.json @@ -0,0 +1,151 @@ +{ + "_description": "The parameters sent by the client when initializing the language server with the \"initialize\" request. More details at https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#initialize", + "processId": "os.getpid()", + "clientInfo": { + "name": "LSP Manager", + "version": "0.0.1" + }, + "locale": "en", + "rootPath": "repository_absolute_path", + "rootUri": "pathlib.Path(repository_absolute_path).as_uri()", + "capabilities": { + "textDocument": { + "completion": { + "dynamicRegistration": true, + "contextSupport": true, + "completionItem": { + "snippetSupport": false, + "commitCharactersSupport": true, + "documentationFormat": [ + "markdown", + "plaintext" + ], + "deprecatedSupport": true, + "preselectSupport": true, + "tagSupport": { + "valueSet": [ + 1 + ] + }, + "insertReplaceSupport": false, + "resolveSupport": { + "properties": [ + "documentation", + "detail", + "additionalTextEdits" + ] + }, + "insertTextModeSupport": { + "valueSet": [ + 1, + 2 + ] + }, + "labelDetailsSupport": true + }, + "insertTextMode": 2, + "completionItemKind": { + "valueSet": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25 + ] + }, + "completionList": { + "itemDefaults": [ + "commitCharacters", + "editRange", + "insertTextFormat", + "insertTextMode" + ] + } + }, + "hover": { + "dynamicRegistration": true, + "contentFormat": [ + "markdown", + "plaintext" + ] + }, + "signatureHelp": { + "dynamicRegistration": true, + "signatureInformation": { + "documentationFormat": [ + "markdown", + "plaintext" + ], + "parameterInformation": { + "labelOffsetSupport": true + }, + "activeParameterSupport": true + }, + "contextSupport": true + }, + "definition": { + "dynamicRegistration": true, + "linkSupport": true + }, + "references": { + "dynamicRegistration": true + }, + "typeDefinition": { + "dynamicRegistration": true, + "linkSupport": true + }, + "implementation": { + "dynamicRegistration": true, + "linkSupport": true + }, + "colorProvider": { + "dynamicRegistration": true + }, + "declaration": { + "dynamicRegistration": true, + "linkSupport": true + }, + "callHierarchy": { + "dynamicRegistration": true + }, + "inlayHint": { + "dynamicRegistration": true, + "resolveSupport": { + "properties": [ + "tooltip", + "textEdits", + "label.tooltip", + "label.location", + "label.command" + ] + } + }, + "diagnostic": { + "dynamicRegistration": true, + "relatedDocumentSupport": false + } + } + }, + "trace": "verbose", + "workspaceFolders": "[\n {\n \"uri\": pathlib.Path(repository_absolute_path).as_uri(),\n \"name\": os.path.basename(repository_absolute_path),\n }\n ]" +} \ No newline at end of file diff --git a/plugins/code/ui/lsp_manager/configs/lsp-servers-config.json b/plugins/code/ui/lsp_manager/configs/lsp-servers-config.json new file mode 100644 index 0000000..d8db07e --- /dev/null +++ b/plugins/code/ui/lsp_manager/configs/lsp-servers-config.json @@ -0,0 +1,365 @@ +{ + "java": { + "info": "https://download.eclipse.org/jdtls/", + "info-init-options": "https://github.com/eclipse-jdtls/eclipse.jdt.ls/wiki/Running-the-JAVA-LS-server-from-the-command-line", + "info-import-build": "https://www.javahotchocolate.com/tutorials/build-path.html", + "info-external-class-paths": "https://github.com/eclipse-jdtls/eclipse.jdt.ls/issues/3291", + "link": "https://download.eclipse.org/jdtls/milestones/?d", + "command": "lsp-ws-proxy --listen 4114 -- jdtls", + "alt-command": "lsp-ws-proxy -- jdtls", + "alt-command2": "java-language-server", + "socket": "ws://127.0.0.1:9999/java", + "socket-two": "ws://127.0.0.1:9999/?name=jdtls", + "alt-socket": "ws://127.0.0.1:9999/?name=java-language-server", + "initialization-options": { + "bundles": [ + "intellicode-core.jar" + ], + "workspaceFolders": [ + "file://{workspace.folder}" + ], + "extendedClientCapabilities": { + "classFileContentsSupport": true, + "executeClientCommandSupport": false + }, + "settings": { + "java": { + "autobuild": { + "enabled": true + }, + "jdt": { + "ls": { + "javac": { + "enabled": true + }, + "java": { + "home": "{user.home}/Portable_Apps/sdks/javasdk/jdk-22.0.2" + }, + "lombokSupport": { + "enabled": true + }, + "protobufSupport":{ + "enabled": true + }, + "androidSupport": { + "enabled": true + } + } + }, + "configuration": { + "updateBuildConfiguration": "automatic", + "maven": { + "userSettings": "{user.home}/.config/jdtls/settings.xml", + "globalSettings": "{user.home}/.config/jdtls/settings.xml" + }, + "runtimes": [ + { + "name": "JavaSE-17", + "path": "/usr/lib/jvm/java-17-openjdk", + "javadoc": "https://docs.oracle.com/en/java/javase/17/docs/api/", + "default": false + }, + { + "name": "JavaSE-22", + "path": "{user.home}/Portable_Apps/sdks/javasdk/jdk-22.0.2", + "javadoc": "https://docs.oracle.com/en/java/javase/22/docs/api/", + "default": true + } + ] + }, + "classPath": [ + "{user.home}/.config/jdtls/m2/repository/**/*-sources.jar", + "lib/**/*-sources.jar" + ], + "docPath": [ + "{user.home}/.config/jdtls/m2/repository/**/*-javadoc.jar", + "lib/**/*-javadoc.jar" + ], + "project": { + "encoding": "ignore", + "outputPath": "bin", + "referencedLibraries": [ + "{user.home}/.config/jdtls/m2/repository/**/*.jar", + "lib/**/*.jar" + ], + "importOnFirstTimeStartup": "automatic", + "importHint": true, + "resourceFilters": [ + "node_modules", + "\\.git" + ], + "sourcePaths": [ + "src", + "{user.home}/.config/jdtls/m2/repository/**/*.jar" + ] + }, + "sources": { + "organizeImports": { + "starThreshold": 99, + "staticStarThreshold": 99 + } + }, + "imports": { + "gradle": { + "wrapper": { + "checksums": [] + } + } + }, + "import": { + "maven": { + "enabled": true, + "offline": { + "enabled": false + }, + "disableTestClasspathFlag": false + }, + "gradle": { + "enabled": false, + "wrapper": { + "enabled": true + }, + "version": "", + "home": "abs(static/gradle-7.3.3)", + "java": { + "home": "abs(static/launch_jres/17.0.6-linux-x86_64)" + }, + "offline": { + "enabled": false + }, + "arguments": [], + "jvmArguments": [], + "user": { + "home": "" + }, + "annotationProcessing": { + "enabled": true + } + }, + "exclusions": [ + "**/node_modules/**", + "**/.metadata/**", + "**/archetype-resources/**", + "**/META-INF/maven/**" + ], + "generatesMetadataFilesAtProjectRoot": false + }, + "maven": { + "downloadSources": true, + "updateSnapshots": true + }, + "silentNotification": true, + "contentProvider": { + "preferred": "fernflower" + }, + "signatureHelp": { + "enabled": true, + "description": { + "enabled": true + } + }, + "completion": { + "enabled": true, + "engine": "ecj", + "matchCase": "firstletter", + "maxResults": 25, + "guessMethodArguments": true, + "lazyResolveTextEdit": { + "enabled": true + }, + "postfix": { + "enabled": true + }, + "favoriteStaticMembers": [ + "org.junit.Assert.*", + "org.junit.Assume.*", + "org.junit.jupiter.api.Assertions.*", + "org.junit.jupiter.api.Assumptions.*", + "org.junit.jupiter.api.DynamicContainer.*", + "org.junit.jupiter.api.DynamicTest.*" + ], + "importOrder": [ + "#", + "java", + "javax", + "org", + "com" + ] + }, + "references": { + "includeAccessors": true, + "includeDecompiledSources": true + }, + "codeGeneration": { + "toString": { + "template": "${object.className}{${member.name()}=${member.value}, ${otherMembers}}" + }, + "insertionLocation": "afterCursor", + "useBlocks": true + }, + "implementationsCodeLens": { + "enabled": true + }, + "referencesCodeLens": { + "enabled": true + }, + "progressReports": { + "enabled": false + }, + "saveActions": { + "organizeImports": true + } + } + } + } + }, + "python": { + "info": "https://github.com/python-lsp/python-lsp-server", + "command": "lsp-ws-proxy -- pylsp", + "alt-command": "pylsp", + "alt-command2": "lsp-ws-proxy --listen 4114 -- pylsp", + "alt-command3": "pylsp --ws --port 4114", + "socket": "ws://127.0.0.1:9999/python", + "socket-two": "ws://127.0.0.1:9999/?name=pylsp", + "initialization-options": { + "pylsp": { + "rope": { + "ropeFolder": "{user.home}/.config/newton/lsps/ropeproject" + }, + "plugins": { + "ruff": { + "enabled": true, + "extendSelect": ["I"], + "lineLength": 80 + }, + "pycodestyle": { + "enabled": false + }, + "pyflakes": { + "enabled": false + }, + "pylint": { + "enabled": true + }, + "mccabe": { + "enabled": false + }, + "pylsp_rope": { + "rename": false + }, + "rope_rename": { + "enabled": false + }, + "rope_autoimport": { + "enabled": true + }, + "rope_completion": { + "enabled": false, + "eager": false + }, + "jedi_rename": { + "enabled": true + }, + "jedi_completion": { + "enabled": true, + "include_class_objects": true, + "include_function_objects": true, + "fuzzy": false + }, + "jedi": { + "root_dir": "file://{workspace.folder}", + "extra_paths": [ + "{user.home}/Portable_Apps/py-venvs/pylsp-venv/venv/lib/python3.10/site-packages" + ] + } + } + } + } + }, + "python - jedi-language-server": { + "hidden": true, + "info": "https://pypi.org/project/jedi-language-server/", + "command": "jedi-language-server", + "alt-command": "lsp-ws-proxy --listen 3030 -- jedi-language-server", + "socket": "ws://127.0.0.1:9999/python", + "socket-two": "ws://127.0.0.1:9999/?name=jedi-language-server", + "initialization-options": { + "jediSettings": { + "autoImportModules": [], + "caseInsensitiveCompletion": true, + "debug": false + }, + "completion": { + "disableSnippets": false, + "resolveEagerly": false, + "ignorePatterns": [] + }, + "markupKindPreferred": "markdown", + "workspace": { + "extraPaths": [ + "{user.home}/Portable_Apps/py-venvs/pylsp-venv/venv/lib/python3.10/site-packages" + ], + "environmentPath": "{user.home}/Portable_Apps/py-venvs/gtk-apps-venv/venv/bin/python", + "symbols": { + "ignoreFolders": [ + ".nox", + ".tox", + ".venv", + "__pycache__", + "venv" + ], + "maxSymbols": 20 + } + } + } + }, + "cpp": { + "info": "https://clangd.llvm.org/", + "command": "lsp-ws-proxy -- clangd", + "alt-command": "clangd", + "socket": "ws://127.0.0.1:9999/cpp", + "socket-two": "ws://127.0.0.1:9999/?name=clangd", + "initialization-options": {} + }, + "c": { + "hidden": true, + "info": "https://clangd.llvm.org/", + "command": "lsp-ws-proxy -- clangd", + "alt-command": "clangd", + "socket": "ws://127.0.0.1:9999/c", + "socket-two": "ws://127.0.0.1:9999/?name=clangd", + "initialization-options": {} + }, + "go": { + "info": "https://pkg.go.dev/golang.org/x/tools/gopls#section-readme", + "command": "lsp-ws-proxy -- gopls", + "alt-command": "gopls", + "socket": "ws://127.0.0.1:9999/go", + "socket-two": "ws://127.0.0.1:9999/?name=gopls", + "initialization-options": {} + }, + "typescript": { + "info": "https://github.com/typescript-language-server/typescript-language-server", + "command": "lsp-ws-proxy -- typescript-language-server", + "alt-command": "typescript-language-server --stdio", + "socket": "ws://127.0.0.1:9999/typescript", + "socket-two": "ws://127.0.0.1:9999/?name=ts", + "initialization-options": {} + }, + "sh": { + "info": "", + "command": "", + "alt-command": "", + "socket": "ws://127.0.0.1:9999/bash", + "socket-two": "ws://127.0.0.1:9999/?name=shell", + "initialization-options": {} + }, + "lua": { + "info": "https://github.com/LuaLS/lua-language-server", + "command": "lsp-ws-proxy -- lua-language-server", + "alt-command": "lua-language-server", + "socket": "ws://127.0.0.1:9999/lua", + "socket-two": "ws://127.0.0.1:9999/?name=lua", + "initialization-options": {} + } +} \ No newline at end of file diff --git a/plugins/code/ui/lsp_manager/controllers/__init__.py b/plugins/code/ui/lsp_manager/controllers/__init__.py new file mode 100644 index 0000000..a3fad54 --- /dev/null +++ b/plugins/code/ui/lsp_manager/controllers/__init__.py @@ -0,0 +1,3 @@ +""" + Plugin Controller Module +""" \ No newline at end of file diff --git a/plugins/code/ui/lsp_manager/controllers/lsp_controller.py b/plugins/code/ui/lsp_manager/controllers/lsp_controller.py new file mode 100644 index 0000000..5ca022d --- /dev/null +++ b/plugins/code/ui/lsp_manager/controllers/lsp_controller.py @@ -0,0 +1,67 @@ +# Python imports +import threading + +# Lib imports +import gi +from gi.repository import GLib + +# Application imports +from libs.dto.code.lsp.lsp_messages import get_message_str +from libs.dto.code.lsp.lsp_message_structs import LSPResponseTypes, ClientRequest, ClientNotification +from .lsp_controller_websocket import LSPControllerWebsocket + + + +class LSPController(LSPControllerWebsocket): + def __init__(self): + super(LSPController, self).__init__() + + # https://github.com/microsoft/multilspy/tree/main/src/multilspy/language_servers + # initialize-params-slim.json was created off of jedi_language_server one + # self._init_params = settings_manager.get_lsp_init_data() + + self._language: str = "" + self._init_params: dict = {} + self._event_history: dict[str] = {} + + try: + from os import path + import json + + _USER_HOME = path.expanduser('~') + _SCRIPT_PTH = path.dirname( path.realpath(__file__) ) + _LSP_INIT_CONFIG = f"{_SCRIPT_PTH}/../configs/initialize-params-slim.json" + with open(_LSP_INIT_CONFIG) as file: + data = file.read().replace("{user.home}", _USER_HOME) + self._init_params = json.loads(data) + except Exception as e: + logger.error( f"LSP Controller: {_LSP_INIT_CONFIG}\n\t\t{repr(e)}" ) + + self._message_id: int = -1 + self._socket = None + self.read_lock = threading.Lock() + self.write_lock = threading.Lock() + + def set_language(self, language): + self._language = language + + def set_socket(self, socket: str): + self._socket = socket + + def unset_socket(self): + self._socket = None + + def send_notification(self, method: str, params: {} = {}): + self._send_message( ClientNotification(method, params) ) + + def send_request(self, method: str, params: {} = {}): + self._message_id += 1 + self._event_history[self._message_id] = method + self._send_message( ClientRequest(self._message_id, method, params) ) + + def get_event_by_id(self, message_id: int): + if not message_id in self._event_history: return + return self._event_history[message_id] + + def handle_lsp_response(self, lsp_response: LSPResponseTypes): + raise NotImplementedError diff --git a/plugins/code/ui/lsp_manager/controllers/lsp_controller_base.py b/plugins/code/ui/lsp_manager/controllers/lsp_controller_base.py new file mode 100644 index 0000000..3a5c3ad --- /dev/null +++ b/plugins/code/ui/lsp_manager/controllers/lsp_controller_base.py @@ -0,0 +1,19 @@ +# Python imports + +# Lib imports + +# Application imports +from .lsp_controller_events import LSPControllerEvents +from libs.dto.code.lsp.lsp_message_structs import ClientRequest, ClientNotification + + + +class LSPControllerBase(LSPControllerEvents): + def _send_message(self, data: ClientRequest or ClientNotification): + raise NotImplementedError + + def start_client(self): + raise NotImplementedError + + def stop_client(self): + raise NotImplementedError diff --git a/plugins/code/ui/lsp_manager/controllers/lsp_controller_events.py b/plugins/code/ui/lsp_manager/controllers/lsp_controller_events.py new file mode 100644 index 0000000..5d83eff --- /dev/null +++ b/plugins/code/ui/lsp_manager/controllers/lsp_controller_events.py @@ -0,0 +1,121 @@ +# Python imports +import os + +# Lib imports +from gi.repository import GLib + +# Application imports +from libs.dto.code.lsp.lsp_messages import get_message_obj +from libs.dto.code.lsp.lsp_messages import didopen_notification +from libs.dto.code.lsp.lsp_messages import didsave_notification +from libs.dto.code.lsp.lsp_messages import didclose_notification +from libs.dto.code.lsp.lsp_messages import didchange_notification +from libs.dto.code.lsp.lsp_messages import completion_request +from libs.dto.code.lsp.lsp_messages import definition_request +from libs.dto.code.lsp.lsp_messages import references_request +from libs.dto.code.lsp.lsp_messages import symbols_request + + + +class LSPControllerEvents: + def send_initialize_message(self, init_ops: dict, workspace_file: str, workspace_uri: str): + folder_name = os.path.basename(workspace_file) + + self._init_params["processId"] = None + self._init_params["rootPath"] = workspace_file + self._init_params["rootUri"] = workspace_uri + self._init_params["workspaceFolders"] = [ + { + "name": folder_name, + "uri": workspace_uri + } + ] + + self._init_params["initializationOptions"] = init_ops + self.send_request("initialize", self._init_params) + + def send_initialized_message(self): + self.send_notification("initialized") + + def _lsp_did_open(self, data: dict): + method = "textDocument/didOpen" + params = didopen_notification["params"] + + params["textDocument"]["uri"] = data["uri"] + params["textDocument"]["languageId"] = data["language_id"] + params["textDocument"]["text"] = data["text"] + + GLib.idle_add( self.send_notification, method, params ) + + def _lsp_did_save(self, data: dict): + method = "textDocument/didSave" + params = didsave_notification["params"] + + params["textDocument"]["uri"] = data["uri"] + params["text"] = data["text"] + + GLib.idle_add( self.send_notification, method, params ) + + def _lsp_did_close(self, data: dict): + method = "textDocument/didClose" + params = didclose_notification["params"] + + params["textDocument"]["uri"] = data["uri"] + + GLib.idle_add( self.send_notification, method, params ) + + def _lsp_did_change(self, data: dict): + method = "textDocument/didChange" + params = didchange_notification["params"] + + params["textDocument"]["uri"] = data["uri"] + params["textDocument"]["languageId"] = data["language_id"] + params["textDocument"]["version"] = data["version"] + + contentChanges = params["contentChanges"][0] + contentChanges["text"] = data["text"] + + GLib.idle_add( self.send_notification, method, params ) + + # def _lsp_did_change(self, data: dict): + # method = "textDocument/didChange" + # params = didchange_notification_range["params"] + + # params["textDocument"]["uri"] = data["uri"] + # params["textDocument"]["languageId"] = data["language_id"] + # params["textDocument"]["version"] = data["version"] + + # contentChanges = params["contentChanges"][0] + # start = contentChanges["range"]["start"] + # end = contentChanges["range"]["end"] + # contentChanges["text"] = data["text"] + # start["line"] = data["line"] + # start["character"] = 0 + # end["line"] = data["line"] + # end["character"] = data["column"] + + # GLib.idle_add( self.send_notification, method, params ) + + def _lsp_definition(self, data: dict): + method = "textDocument/definition" + params = definition_request["params"] + + params["textDocument"]["uri"] = data["uri"] + params["textDocument"]["languageId"] = data["language_id"] + params["textDocument"]["version"] = data["version"] + params["position"]["line"] = data["line"] + params["position"]["character"] = data["column"] + + GLib.idle_add( self.send_request, method, params ) + + def _lsp_completion(self, data: dict): + method = "textDocument/completion" + params = completion_request["params"] + + params["textDocument"]["uri"] = data["uri"] + params["textDocument"]["languageId"] = data["language_id"] + params["textDocument"]["version"] = data["version"] + params["position"]["line"] = data["line"] + params["position"]["character"] = data["column"] + + GLib.idle_add( self.send_request, method, params ) diff --git a/plugins/code/ui/lsp_manager/controllers/lsp_controller_websocket.py b/plugins/code/ui/lsp_manager/controllers/lsp_controller_websocket.py new file mode 100644 index 0000000..ed57896 --- /dev/null +++ b/plugins/code/ui/lsp_manager/controllers/lsp_controller_websocket.py @@ -0,0 +1,57 @@ +# Python imports +import traceback +import subprocess + +# Lib imports +from gi.repository import GLib + +# Application imports +# from libs import websockets +from libs.dto.code.lsp.lsp_messages import LEN_HEADER, TYPE_HEADER, get_message_str, get_message_obj +from libs.dto.code.lsp.lsp_message_structs import \ + LSPResponseTypes, ClientRequest, ClientNotification, LSPResponseRequest, LSPResponseNotification, LSPIDResponseNotification + +from .lsp_controller_base import LSPControllerBase +from .websocket_client import WebsocketClient + + + +class LSPControllerWebsocket(LSPControllerBase): + def _send_message(self, data: ClientRequest or ClientNotification): + if not data: return + + message_str = get_message_str(data) + message_size = len(message_str) + message = f"Content-Length: {message_size}\r\n\r\n{message_str}" + + logger.debug(f"Client: {message_str}") + self.ws_client.send(message_str) + + def start_client(self): + self.ws_client = WebsocketClient() + self.ws_client.set_socket(self._socket) + self.ws_client.set_callback(self._monitor_lsp_response) + self.ws_client.start_client() + + return self.ws_client + + def stop_client(self): + if not hasattr(self, "ws_client"): return + self.ws_client.close_client() + + def _monitor_lsp_response(self, data: None or {}): + if not data: return + + message = get_message_obj(data) + keys = message.keys() + lsp_response = None + + if "result" in keys: + lsp_response = LSPResponseRequest(**get_message_obj(data)) + + if "method" in keys: + lsp_response = LSPResponseNotification(**get_message_obj(data)) if not "id" in keys else LSPIDResponseNotification( **get_message_obj(data) ) + + if not lsp_response: return + + GLib.idle_add(self.handle_lsp_response, lsp_response) \ No newline at end of file diff --git a/plugins/code/ui/lsp_manager/controllers/websocket_client.py b/plugins/code/ui/lsp_manager/controllers/websocket_client.py new file mode 100644 index 0000000..65b673d --- /dev/null +++ b/plugins/code/ui/lsp_manager/controllers/websocket_client.py @@ -0,0 +1,62 @@ +# Python imports +import json +import threading + +# Lib imports + +# Application imports +from ..libs import websocket + + + +class WebsocketClient: + def __init__(self): + self.ws = None + self._socket = None + self._connected = threading.Event() + + + def set_socket(self, socket: str): + self._socket = socket + + def unset_socket(self): + self._socket = None + + def send(self, message: str): + self.ws.send(message) + + def on_message(self, ws, message: dict): + self.respond(message) + + def on_error(self, ws, error: str): + logger.debug(f"WS Error: {error}") + + def on_close(self, ws, close_status_code: int, close_msg: str): + logger.debug("WS Closed...") + + def on_open(self, ws): + self._connected.set() + logger.debug("WS opened connection...") + + def wait_for_connection(self, timeout: float = 5.0) -> bool: + return self._connected.wait(timeout) + + def set_callback(self, callback: object): + self.respond = callback + + def close_client(self): + self.ws.close() + + @daemon_threaded + def start_client(self): + if not self._socket: + raise Exception("Socket address isn't set so cannot start WebsocketClient listener...") + + # websocket.enableTrace(True) + self.ws = websocket.WebSocketApp(self._socket, + on_open = self.on_open, + on_message = self.on_message, + on_error = self.on_error, + on_close = self.on_close) + + self.ws.run_forever(reconnect = 0.5) \ No newline at end of file diff --git a/plugins/code/ui/lsp_manager/libs/__init__.py b/plugins/code/ui/lsp_manager/libs/__init__.py new file mode 100644 index 0000000..3c1f1c5 --- /dev/null +++ b/plugins/code/ui/lsp_manager/libs/__init__.py @@ -0,0 +1,3 @@ +""" + Pligin Libs Module +""" diff --git a/plugins/code/ui/lsp_manager/libs/websocket/__init__.py b/plugins/code/ui/lsp_manager/libs/websocket/__init__.py new file mode 100644 index 0000000..eed90cc --- /dev/null +++ b/plugins/code/ui/lsp_manager/libs/websocket/__init__.py @@ -0,0 +1,27 @@ +""" +__init__.py +websocket - WebSocket client library for Python + +Copyright 2024 engn33r + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +from ._abnf import * +from ._app import WebSocketApp as WebSocketApp, setReconnect as setReconnect +from ._core import * +from ._exceptions import * +from ._logging import * +from ._socket import * + +__version__ = "1.8.0" diff --git a/plugins/code/ui/lsp_manager/libs/websocket/_abnf.py b/plugins/code/ui/lsp_manager/libs/websocket/_abnf.py new file mode 100644 index 0000000..d7754e0 --- /dev/null +++ b/plugins/code/ui/lsp_manager/libs/websocket/_abnf.py @@ -0,0 +1,453 @@ +import array +import os +import struct +import sys +from threading import Lock +from typing import Callable, Optional, Union + +from ._exceptions import WebSocketPayloadException, WebSocketProtocolException +from ._utils import validate_utf8 + +""" +_abnf.py +websocket - WebSocket client library for Python + +Copyright 2024 engn33r + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +try: + # If wsaccel is available, use compiled routines to mask data. + # wsaccel only provides around a 10% speed boost compared + # to the websocket-client _mask() implementation. + # Note that wsaccel is unmaintained. + from wsaccel.xormask import XorMaskerSimple + + def _mask(mask_value: array.array, data_value: array.array) -> bytes: + mask_result: bytes = XorMaskerSimple(mask_value).process(data_value) + return mask_result + +except ImportError: + # wsaccel is not available, use websocket-client _mask() + native_byteorder = sys.byteorder + + def _mask(mask_value: array.array, data_value: array.array) -> bytes: + datalen = len(data_value) + int_data_value = int.from_bytes(data_value, native_byteorder) + int_mask_value = int.from_bytes( + mask_value * (datalen // 4) + mask_value[: datalen % 4], native_byteorder + ) + return (int_data_value ^ int_mask_value).to_bytes(datalen, native_byteorder) + + +__all__ = [ + "ABNF", + "continuous_frame", + "frame_buffer", + "STATUS_NORMAL", + "STATUS_GOING_AWAY", + "STATUS_PROTOCOL_ERROR", + "STATUS_UNSUPPORTED_DATA_TYPE", + "STATUS_STATUS_NOT_AVAILABLE", + "STATUS_ABNORMAL_CLOSED", + "STATUS_INVALID_PAYLOAD", + "STATUS_POLICY_VIOLATION", + "STATUS_MESSAGE_TOO_BIG", + "STATUS_INVALID_EXTENSION", + "STATUS_UNEXPECTED_CONDITION", + "STATUS_BAD_GATEWAY", + "STATUS_TLS_HANDSHAKE_ERROR", +] + +# closing frame status codes. +STATUS_NORMAL = 1000 +STATUS_GOING_AWAY = 1001 +STATUS_PROTOCOL_ERROR = 1002 +STATUS_UNSUPPORTED_DATA_TYPE = 1003 +STATUS_STATUS_NOT_AVAILABLE = 1005 +STATUS_ABNORMAL_CLOSED = 1006 +STATUS_INVALID_PAYLOAD = 1007 +STATUS_POLICY_VIOLATION = 1008 +STATUS_MESSAGE_TOO_BIG = 1009 +STATUS_INVALID_EXTENSION = 1010 +STATUS_UNEXPECTED_CONDITION = 1011 +STATUS_SERVICE_RESTART = 1012 +STATUS_TRY_AGAIN_LATER = 1013 +STATUS_BAD_GATEWAY = 1014 +STATUS_TLS_HANDSHAKE_ERROR = 1015 + +VALID_CLOSE_STATUS = ( + STATUS_NORMAL, + STATUS_GOING_AWAY, + STATUS_PROTOCOL_ERROR, + STATUS_UNSUPPORTED_DATA_TYPE, + STATUS_INVALID_PAYLOAD, + STATUS_POLICY_VIOLATION, + STATUS_MESSAGE_TOO_BIG, + STATUS_INVALID_EXTENSION, + STATUS_UNEXPECTED_CONDITION, + STATUS_SERVICE_RESTART, + STATUS_TRY_AGAIN_LATER, + STATUS_BAD_GATEWAY, +) + + +class ABNF: + """ + ABNF frame class. + See http://tools.ietf.org/html/rfc5234 + and http://tools.ietf.org/html/rfc6455#section-5.2 + """ + + # operation code values. + OPCODE_CONT = 0x0 + OPCODE_TEXT = 0x1 + OPCODE_BINARY = 0x2 + OPCODE_CLOSE = 0x8 + OPCODE_PING = 0x9 + OPCODE_PONG = 0xA + + # available operation code value tuple + OPCODES = ( + OPCODE_CONT, + OPCODE_TEXT, + OPCODE_BINARY, + OPCODE_CLOSE, + OPCODE_PING, + OPCODE_PONG, + ) + + # opcode human readable string + OPCODE_MAP = { + OPCODE_CONT: "cont", + OPCODE_TEXT: "text", + OPCODE_BINARY: "binary", + OPCODE_CLOSE: "close", + OPCODE_PING: "ping", + OPCODE_PONG: "pong", + } + + # data length threshold. + LENGTH_7 = 0x7E + LENGTH_16 = 1 << 16 + LENGTH_63 = 1 << 63 + + def __init__( + self, + fin: int = 0, + rsv1: int = 0, + rsv2: int = 0, + rsv3: int = 0, + opcode: int = OPCODE_TEXT, + mask_value: int = 1, + data: Union[str, bytes, None] = "", + ) -> None: + """ + Constructor for ABNF. Please check RFC for arguments. + """ + self.fin = fin + self.rsv1 = rsv1 + self.rsv2 = rsv2 + self.rsv3 = rsv3 + self.opcode = opcode + self.mask_value = mask_value + if data is None: + data = "" + self.data = data + self.get_mask_key = os.urandom + + def validate(self, skip_utf8_validation: bool = False) -> None: + """ + Validate the ABNF frame. + + Parameters + ---------- + skip_utf8_validation: skip utf8 validation. + """ + if self.rsv1 or self.rsv2 or self.rsv3: + raise WebSocketProtocolException("rsv is not implemented, yet") + + if self.opcode not in ABNF.OPCODES: + raise WebSocketProtocolException("Invalid opcode %r", self.opcode) + + if self.opcode == ABNF.OPCODE_PING and not self.fin: + raise WebSocketProtocolException("Invalid ping frame.") + + if self.opcode == ABNF.OPCODE_CLOSE: + l = len(self.data) + if not l: + return + if l == 1 or l >= 126: + raise WebSocketProtocolException("Invalid close frame.") + if l > 2 and not skip_utf8_validation and not validate_utf8(self.data[2:]): + raise WebSocketProtocolException("Invalid close frame.") + + code = 256 * int(self.data[0]) + int(self.data[1]) + if not self._is_valid_close_status(code): + raise WebSocketProtocolException("Invalid close opcode %r", code) + + @staticmethod + def _is_valid_close_status(code: int) -> bool: + return code in VALID_CLOSE_STATUS or (3000 <= code < 5000) + + def __str__(self) -> str: + return f"fin={self.fin} opcode={self.opcode} data={self.data}" + + @staticmethod + def create_frame(data: Union[bytes, str], opcode: int, fin: int = 1) -> "ABNF": + """ + Create frame to send text, binary and other data. + + Parameters + ---------- + data: str + data to send. This is string value(byte array). + If opcode is OPCODE_TEXT and this value is unicode, + data value is converted into unicode string, automatically. + opcode: int + operation code. please see OPCODE_MAP. + fin: int + fin flag. if set to 0, create continue fragmentation. + """ + if opcode == ABNF.OPCODE_TEXT and isinstance(data, str): + data = data.encode("utf-8") + # mask must be set if send data from client + return ABNF(fin, 0, 0, 0, opcode, 1, data) + + def format(self) -> bytes: + """ + Format this object to string(byte array) to send data to server. + """ + if any(x not in (0, 1) for x in [self.fin, self.rsv1, self.rsv2, self.rsv3]): + raise ValueError("not 0 or 1") + if self.opcode not in ABNF.OPCODES: + raise ValueError("Invalid OPCODE") + length = len(self.data) + if length >= ABNF.LENGTH_63: + raise ValueError("data is too long") + + frame_header = chr( + self.fin << 7 + | self.rsv1 << 6 + | self.rsv2 << 5 + | self.rsv3 << 4 + | self.opcode + ).encode("latin-1") + if length < ABNF.LENGTH_7: + frame_header += chr(self.mask_value << 7 | length).encode("latin-1") + elif length < ABNF.LENGTH_16: + frame_header += chr(self.mask_value << 7 | 0x7E).encode("latin-1") + frame_header += struct.pack("!H", length) + else: + frame_header += chr(self.mask_value << 7 | 0x7F).encode("latin-1") + frame_header += struct.pack("!Q", length) + + if not self.mask_value: + if isinstance(self.data, str): + self.data = self.data.encode("utf-8") + return frame_header + self.data + mask_key = self.get_mask_key(4) + return frame_header + self._get_masked(mask_key) + + def _get_masked(self, mask_key: Union[str, bytes]) -> bytes: + s = ABNF.mask(mask_key, self.data) + + if isinstance(mask_key, str): + mask_key = mask_key.encode("utf-8") + + return mask_key + s + + @staticmethod + def mask(mask_key: Union[str, bytes], data: Union[str, bytes]) -> bytes: + """ + Mask or unmask data. Just do xor for each byte + + Parameters + ---------- + mask_key: bytes or str + 4 byte mask. + data: bytes or str + data to mask/unmask. + """ + if data is None: + data = "" + + if isinstance(mask_key, str): + mask_key = mask_key.encode("latin-1") + + if isinstance(data, str): + data = data.encode("latin-1") + + return _mask(array.array("B", mask_key), array.array("B", data)) + + +class frame_buffer: + _HEADER_MASK_INDEX = 5 + _HEADER_LENGTH_INDEX = 6 + + def __init__( + self, recv_fn: Callable[[int], int], skip_utf8_validation: bool + ) -> None: + self.recv = recv_fn + self.skip_utf8_validation = skip_utf8_validation + # Buffers over the packets from the layer beneath until desired amount + # bytes of bytes are received. + self.recv_buffer: list = [] + self.clear() + self.lock = Lock() + + def clear(self) -> None: + self.header: Optional[tuple] = None + self.length: Optional[int] = None + self.mask_value: Union[bytes, str, None] = None + + def has_received_header(self) -> bool: + return self.header is None + + def recv_header(self) -> None: + header = self.recv_strict(2) + b1 = header[0] + fin = b1 >> 7 & 1 + rsv1 = b1 >> 6 & 1 + rsv2 = b1 >> 5 & 1 + rsv3 = b1 >> 4 & 1 + opcode = b1 & 0xF + b2 = header[1] + has_mask = b2 >> 7 & 1 + length_bits = b2 & 0x7F + + self.header = (fin, rsv1, rsv2, rsv3, opcode, has_mask, length_bits) + + def has_mask(self) -> Union[bool, int]: + if not self.header: + return False + header_val: int = self.header[frame_buffer._HEADER_MASK_INDEX] + return header_val + + def has_received_length(self) -> bool: + return self.length is None + + def recv_length(self) -> None: + bits = self.header[frame_buffer._HEADER_LENGTH_INDEX] + length_bits = bits & 0x7F + if length_bits == 0x7E: + v = self.recv_strict(2) + self.length = struct.unpack("!H", v)[0] + elif length_bits == 0x7F: + v = self.recv_strict(8) + self.length = struct.unpack("!Q", v)[0] + else: + self.length = length_bits + + def has_received_mask(self) -> bool: + return self.mask_value is None + + def recv_mask(self) -> None: + self.mask_value = self.recv_strict(4) if self.has_mask() else "" + + def recv_frame(self) -> ABNF: + with self.lock: + # Header + if self.has_received_header(): + self.recv_header() + (fin, rsv1, rsv2, rsv3, opcode, has_mask, _) = self.header + + # Frame length + if self.has_received_length(): + self.recv_length() + length = self.length + + # Mask + if self.has_received_mask(): + self.recv_mask() + mask_value = self.mask_value + + # Payload + payload = self.recv_strict(length) + if has_mask: + payload = ABNF.mask(mask_value, payload) + + # Reset for next frame + self.clear() + + frame = ABNF(fin, rsv1, rsv2, rsv3, opcode, has_mask, payload) + frame.validate(self.skip_utf8_validation) + + return frame + + def recv_strict(self, bufsize: int) -> bytes: + shortage = bufsize - sum(map(len, self.recv_buffer)) + while shortage > 0: + # Limit buffer size that we pass to socket.recv() to avoid + # fragmenting the heap -- the number of bytes recv() actually + # reads is limited by socket buffer and is relatively small, + # yet passing large numbers repeatedly causes lots of large + # buffers allocated and then shrunk, which results in + # fragmentation. + bytes_ = self.recv(min(16384, shortage)) + self.recv_buffer.append(bytes_) + shortage -= len(bytes_) + + unified = b"".join(self.recv_buffer) + + if shortage == 0: + self.recv_buffer = [] + return unified + else: + self.recv_buffer = [unified[bufsize:]] + return unified[:bufsize] + + +class continuous_frame: + def __init__(self, fire_cont_frame: bool, skip_utf8_validation: bool) -> None: + self.fire_cont_frame = fire_cont_frame + self.skip_utf8_validation = skip_utf8_validation + self.cont_data: Optional[list] = None + self.recving_frames: Optional[int] = None + + def validate(self, frame: ABNF) -> None: + if not self.recving_frames and frame.opcode == ABNF.OPCODE_CONT: + raise WebSocketProtocolException("Illegal frame") + if self.recving_frames and frame.opcode in ( + ABNF.OPCODE_TEXT, + ABNF.OPCODE_BINARY, + ): + raise WebSocketProtocolException("Illegal frame") + + def add(self, frame: ABNF) -> None: + if self.cont_data: + self.cont_data[1] += frame.data + else: + if frame.opcode in (ABNF.OPCODE_TEXT, ABNF.OPCODE_BINARY): + self.recving_frames = frame.opcode + self.cont_data = [frame.opcode, frame.data] + + if frame.fin: + self.recving_frames = None + + def is_fire(self, frame: ABNF) -> Union[bool, int]: + return frame.fin or self.fire_cont_frame + + def extract(self, frame: ABNF) -> tuple: + data = self.cont_data + self.cont_data = None + frame.data = data[1] + if ( + not self.fire_cont_frame + and data[0] == ABNF.OPCODE_TEXT + and not self.skip_utf8_validation + and not validate_utf8(frame.data) + ): + raise WebSocketPayloadException(f"cannot decode: {repr(frame.data)}") + return data[0], frame diff --git a/plugins/code/ui/lsp_manager/libs/websocket/_app.py b/plugins/code/ui/lsp_manager/libs/websocket/_app.py new file mode 100644 index 0000000..9fee765 --- /dev/null +++ b/plugins/code/ui/lsp_manager/libs/websocket/_app.py @@ -0,0 +1,677 @@ +import inspect +import selectors +import socket +import threading +import time +from typing import Any, Callable, Optional, Union + +from . import _logging +from ._abnf import ABNF +from ._core import WebSocket, getdefaulttimeout +from ._exceptions import ( + WebSocketConnectionClosedException, + WebSocketException, + WebSocketTimeoutException, +) +from ._ssl_compat import SSLEOFError +from ._url import parse_url + +""" +_app.py +websocket - WebSocket client library for Python + +Copyright 2024 engn33r + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +__all__ = ["WebSocketApp"] + +RECONNECT = 0 + + +def setReconnect(reconnectInterval: int) -> None: + global RECONNECT + RECONNECT = reconnectInterval + + +class DispatcherBase: + """ + DispatcherBase + """ + + def __init__(self, app: Any, ping_timeout: Union[float, int, None]) -> None: + self.app = app + self.ping_timeout = ping_timeout + + def timeout(self, seconds: Union[float, int, None], callback: Callable) -> None: + time.sleep(seconds) + callback() + + def reconnect(self, seconds: int, reconnector: Callable) -> None: + try: + _logging.info( + f"reconnect() - retrying in {seconds} seconds [{len(inspect.stack())} frames in stack]" + ) + time.sleep(seconds) + reconnector(reconnecting=True) + except KeyboardInterrupt as e: + _logging.info(f"User exited {e}") + raise e + + +class Dispatcher(DispatcherBase): + """ + Dispatcher + """ + + def read( + self, + sock: socket.socket, + read_callback: Callable, + check_callback: Callable, + ) -> None: + sel = selectors.DefaultSelector() + sel.register(self.app.sock.sock, selectors.EVENT_READ) + try: + while self.app.keep_running: + if sel.select(self.ping_timeout): + if not read_callback(): + break + check_callback() + finally: + sel.close() + + +class SSLDispatcher(DispatcherBase): + """ + SSLDispatcher + """ + + def read( + self, + sock: socket.socket, + read_callback: Callable, + check_callback: Callable, + ) -> None: + sock = self.app.sock.sock + sel = selectors.DefaultSelector() + sel.register(sock, selectors.EVENT_READ) + try: + while self.app.keep_running: + if self.select(sock, sel): + if not read_callback(): + break + check_callback() + finally: + sel.close() + + def select(self, sock, sel: selectors.DefaultSelector): + sock = self.app.sock.sock + if sock.pending(): + return [ + sock, + ] + + r = sel.select(self.ping_timeout) + + if len(r) > 0: + return r[0][0] + + +class WrappedDispatcher: + """ + WrappedDispatcher + """ + + def __init__(self, app, ping_timeout: Union[float, int, None], dispatcher) -> None: + self.app = app + self.ping_timeout = ping_timeout + self.dispatcher = dispatcher + dispatcher.signal(2, dispatcher.abort) # keyboard interrupt + + def read( + self, + sock: socket.socket, + read_callback: Callable, + check_callback: Callable, + ) -> None: + self.dispatcher.read(sock, read_callback) + self.ping_timeout and self.timeout(self.ping_timeout, check_callback) + + def timeout(self, seconds: float, callback: Callable) -> None: + self.dispatcher.timeout(seconds, callback) + + def reconnect(self, seconds: int, reconnector: Callable) -> None: + self.timeout(seconds, reconnector) + + +class WebSocketApp: + """ + Higher level of APIs are provided. The interface is like JavaScript WebSocket object. + """ + + def __init__( + self, + url: str, + header: Union[list, dict, Callable, None] = None, + on_open: Optional[Callable[[WebSocket], None]] = None, + on_reconnect: Optional[Callable[[WebSocket], None]] = None, + on_message: Optional[Callable[[WebSocket, Any], None]] = None, + on_error: Optional[Callable[[WebSocket, Any], None]] = None, + on_close: Optional[Callable[[WebSocket, Any, Any], None]] = None, + on_ping: Optional[Callable] = None, + on_pong: Optional[Callable] = None, + on_cont_message: Optional[Callable] = None, + keep_running: bool = True, + get_mask_key: Optional[Callable] = None, + cookie: Optional[str] = None, + subprotocols: Optional[list] = None, + on_data: Optional[Callable] = None, + socket: Optional[socket.socket] = None, + ) -> None: + """ + WebSocketApp initialization + + Parameters + ---------- + url: str + Websocket url. + header: list or dict or Callable + Custom header for websocket handshake. + If the parameter is a callable object, it is called just before the connection attempt. + The returned dict or list is used as custom header value. + This could be useful in order to properly setup timestamp dependent headers. + on_open: function + Callback object which is called at opening websocket. + on_open has one argument. + The 1st argument is this class object. + on_reconnect: function + Callback object which is called at reconnecting websocket. + on_reconnect has one argument. + The 1st argument is this class object. + on_message: function + Callback object which is called when received data. + on_message has 2 arguments. + The 1st argument is this class object. + The 2nd argument is utf-8 data received from the server. + on_error: function + Callback object which is called when we get error. + on_error has 2 arguments. + The 1st argument is this class object. + The 2nd argument is exception object. + on_close: function + Callback object which is called when connection is closed. + on_close has 3 arguments. + The 1st argument is this class object. + The 2nd argument is close_status_code. + The 3rd argument is close_msg. + on_cont_message: function + Callback object which is called when a continuation + frame is received. + on_cont_message has 3 arguments. + The 1st argument is this class object. + The 2nd argument is utf-8 string which we get from the server. + The 3rd argument is continue flag. if 0, the data continue + to next frame data + on_data: function + Callback object which is called when a message received. + This is called before on_message or on_cont_message, + and then on_message or on_cont_message is called. + on_data has 4 argument. + The 1st argument is this class object. + The 2nd argument is utf-8 string which we get from the server. + The 3rd argument is data type. ABNF.OPCODE_TEXT or ABNF.OPCODE_BINARY will be came. + The 4th argument is continue flag. If 0, the data continue + keep_running: bool + This parameter is obsolete and ignored. + get_mask_key: function + A callable function to get new mask keys, see the + WebSocket.set_mask_key's docstring for more information. + cookie: str + Cookie value. + subprotocols: list + List of available sub protocols. Default is None. + socket: socket + Pre-initialized stream socket. + """ + self.url = url + self.header = header if header is not None else [] + self.cookie = cookie + + self.on_open = on_open + self.on_reconnect = on_reconnect + self.on_message = on_message + self.on_data = on_data + self.on_error = on_error + self.on_close = on_close + self.on_ping = on_ping + self.on_pong = on_pong + self.on_cont_message = on_cont_message + self.keep_running = False + self.get_mask_key = get_mask_key + self.sock: Optional[WebSocket] = None + self.last_ping_tm = float(0) + self.last_pong_tm = float(0) + self.ping_thread: Optional[threading.Thread] = None + self.stop_ping: Optional[threading.Event] = None + self.ping_interval = float(0) + self.ping_timeout: Union[float, int, None] = None + self.ping_payload = "" + self.subprotocols = subprotocols + self.prepared_socket = socket + self.has_errored = False + self.has_done_teardown = False + self.has_done_teardown_lock = threading.Lock() + + def send(self, data: Union[bytes, str], opcode: int = ABNF.OPCODE_TEXT) -> None: + """ + send message + + Parameters + ---------- + data: str + Message to send. If you set opcode to OPCODE_TEXT, + data must be utf-8 string or unicode. + opcode: int + Operation code of data. Default is OPCODE_TEXT. + """ + + if not self.sock or self.sock.send(data, opcode) == 0: + raise WebSocketConnectionClosedException("Connection is already closed.") + + def send_text(self, text_data: str) -> None: + """ + Sends UTF-8 encoded text. + """ + if not self.sock or self.sock.send(text_data, ABNF.OPCODE_TEXT) == 0: + raise WebSocketConnectionClosedException("Connection is already closed.") + + def send_bytes(self, data: Union[bytes, bytearray]) -> None: + """ + Sends a sequence of bytes. + """ + if not self.sock or self.sock.send(data, ABNF.OPCODE_BINARY) == 0: + raise WebSocketConnectionClosedException("Connection is already closed.") + + def close(self, **kwargs) -> None: + """ + Close websocket connection. + """ + self.keep_running = False + if self.sock: + self.sock.close(**kwargs) + self.sock = None + + def _start_ping_thread(self) -> None: + self.last_ping_tm = self.last_pong_tm = float(0) + self.stop_ping = threading.Event() + self.ping_thread = threading.Thread(target=self._send_ping) + self.ping_thread.daemon = True + self.ping_thread.start() + + def _stop_ping_thread(self) -> None: + if self.stop_ping: + self.stop_ping.set() + if self.ping_thread and self.ping_thread.is_alive(): + self.ping_thread.join(3) + self.last_ping_tm = self.last_pong_tm = float(0) + + def _send_ping(self) -> None: + if self.stop_ping.wait(self.ping_interval) or self.keep_running is False: + return + while not self.stop_ping.wait(self.ping_interval) and self.keep_running is True: + if self.sock: + self.last_ping_tm = time.time() + try: + _logging.debug("Sending ping") + self.sock.ping(self.ping_payload) + except Exception as e: + _logging.debug(f"Failed to send ping: {e}") + + def run_forever( + self, + sockopt: tuple = None, + sslopt: dict = None, + ping_interval: Union[float, int] = 0, + ping_timeout: Union[float, int, None] = None, + ping_payload: str = "", + http_proxy_host: str = None, + http_proxy_port: Union[int, str] = None, + http_no_proxy: list = None, + http_proxy_auth: tuple = None, + http_proxy_timeout: Optional[float] = None, + skip_utf8_validation: bool = False, + host: str = None, + origin: str = None, + dispatcher=None, + suppress_origin: bool = False, + proxy_type: str = None, + reconnect: int = None, + ) -> bool: + """ + Run event loop for WebSocket framework. + + This loop is an infinite loop and is alive while websocket is available. + + Parameters + ---------- + sockopt: tuple + Values for socket.setsockopt. + sockopt must be tuple + and each element is argument of sock.setsockopt. + sslopt: dict + Optional dict object for ssl socket option. + ping_interval: int or float + Automatically send "ping" command + every specified period (in seconds). + If set to 0, no ping is sent periodically. + ping_timeout: int or float + Timeout (in seconds) if the pong message is not received. + ping_payload: str + Payload message to send with each ping. + http_proxy_host: str + HTTP proxy host name. + http_proxy_port: int or str + HTTP proxy port. If not set, set to 80. + http_no_proxy: list + Whitelisted host names that don't use the proxy. + http_proxy_timeout: int or float + HTTP proxy timeout, default is 60 sec as per python-socks. + http_proxy_auth: tuple + HTTP proxy auth information. tuple of username and password. Default is None. + skip_utf8_validation: bool + skip utf8 validation. + host: str + update host header. + origin: str + update origin header. + dispatcher: Dispatcher object + customize reading data from socket. + suppress_origin: bool + suppress outputting origin header. + proxy_type: str + type of proxy from: http, socks4, socks4a, socks5, socks5h + reconnect: int + delay interval when reconnecting + + Returns + ------- + teardown: bool + False if the `WebSocketApp` is closed or caught KeyboardInterrupt, + True if any other exception was raised during a loop. + """ + + if reconnect is None: + reconnect = RECONNECT + + if ping_timeout is not None and ping_timeout <= 0: + raise WebSocketException("Ensure ping_timeout > 0") + if ping_interval is not None and ping_interval < 0: + raise WebSocketException("Ensure ping_interval >= 0") + if ping_timeout and ping_interval and ping_interval <= ping_timeout: + raise WebSocketException("Ensure ping_interval > ping_timeout") + if not sockopt: + sockopt = () + if not sslopt: + sslopt = {} + if self.sock: + raise WebSocketException("socket is already opened") + + self.ping_interval = ping_interval + self.ping_timeout = ping_timeout + self.ping_payload = ping_payload + self.has_done_teardown = False + self.keep_running = True + + def teardown(close_frame: ABNF = None): + """ + Tears down the connection. + + Parameters + ---------- + close_frame: ABNF frame + If close_frame is set, the on_close handler is invoked + with the statusCode and reason from the provided frame. + """ + + # teardown() is called in many code paths to ensure resources are cleaned up and on_close is fired. + # To ensure the work is only done once, we use this bool and lock. + with self.has_done_teardown_lock: + if self.has_done_teardown: + return + self.has_done_teardown = True + + self._stop_ping_thread() + self.keep_running = False + if self.sock: + self.sock.close() + close_status_code, close_reason = self._get_close_args( + close_frame if close_frame else None + ) + self.sock = None + + # Finally call the callback AFTER all teardown is complete + self._callback(self.on_close, close_status_code, close_reason) + + def setSock(reconnecting: bool = False) -> None: + if reconnecting and self.sock: + self.sock.shutdown() + + self.sock = WebSocket( + self.get_mask_key, + sockopt=sockopt, + sslopt=sslopt, + fire_cont_frame=self.on_cont_message is not None, + skip_utf8_validation=skip_utf8_validation, + enable_multithread=True, + ) + + self.sock.settimeout(getdefaulttimeout()) + try: + header = self.header() if callable(self.header) else self.header + + self.sock.connect( + self.url, + header=header, + cookie=self.cookie, + http_proxy_host=http_proxy_host, + http_proxy_port=http_proxy_port, + http_no_proxy=http_no_proxy, + http_proxy_auth=http_proxy_auth, + http_proxy_timeout=http_proxy_timeout, + subprotocols=self.subprotocols, + host=host, + origin=origin, + suppress_origin=suppress_origin, + proxy_type=proxy_type, + socket=self.prepared_socket, + ) + + _logging.info("Websocket connected") + + if self.ping_interval: + self._start_ping_thread() + + if reconnecting and self.on_reconnect: + self._callback(self.on_reconnect) + else: + self._callback(self.on_open) + + dispatcher.read(self.sock.sock, read, check) + except ( + WebSocketConnectionClosedException, + ConnectionRefusedError, + KeyboardInterrupt, + SystemExit, + Exception, + ) as e: + handleDisconnect(e, reconnecting) + + def read() -> bool: + if not self.keep_running: + return teardown() + + try: + op_code, frame = self.sock.recv_data_frame(True) + except ( + WebSocketConnectionClosedException, + KeyboardInterrupt, + SSLEOFError, + ) as e: + if custom_dispatcher: + return handleDisconnect(e, bool(reconnect)) + else: + raise e + + if op_code == ABNF.OPCODE_CLOSE: + return teardown(frame) + elif op_code == ABNF.OPCODE_PING: + self._callback(self.on_ping, frame.data) + elif op_code == ABNF.OPCODE_PONG: + self.last_pong_tm = time.time() + self._callback(self.on_pong, frame.data) + elif op_code == ABNF.OPCODE_CONT and self.on_cont_message: + self._callback(self.on_data, frame.data, frame.opcode, frame.fin) + self._callback(self.on_cont_message, frame.data, frame.fin) + else: + data = frame.data + if op_code == ABNF.OPCODE_TEXT and not skip_utf8_validation: + data = data.decode("utf-8") + self._callback(self.on_data, data, frame.opcode, True) + self._callback(self.on_message, data) + + return True + + def check() -> bool: + if self.ping_timeout: + has_timeout_expired = ( + time.time() - self.last_ping_tm > self.ping_timeout + ) + has_pong_not_arrived_after_last_ping = ( + self.last_pong_tm - self.last_ping_tm < 0 + ) + has_pong_arrived_too_late = ( + self.last_pong_tm - self.last_ping_tm > self.ping_timeout + ) + + if ( + self.last_ping_tm + and has_timeout_expired + and ( + has_pong_not_arrived_after_last_ping + or has_pong_arrived_too_late + ) + ): + raise WebSocketTimeoutException("ping/pong timed out") + return True + + def handleDisconnect( + e: Union[ + WebSocketConnectionClosedException, + ConnectionRefusedError, + KeyboardInterrupt, + SystemExit, + Exception, + ], + reconnecting: bool = False, + ) -> bool: + self.has_errored = True + self._stop_ping_thread() + if not reconnecting: + self._callback(self.on_error, e) + + if isinstance(e, (KeyboardInterrupt, SystemExit)): + teardown() + # Propagate further + raise + + if reconnect: + _logging.info(f"{e} - reconnect") + if custom_dispatcher: + _logging.debug( + f"Calling custom dispatcher reconnect [{len(inspect.stack())} frames in stack]" + ) + dispatcher.reconnect(reconnect, setSock) + else: + _logging.error(f"{e} - goodbye") + teardown() + + custom_dispatcher = bool(dispatcher) + dispatcher = self.create_dispatcher( + ping_timeout, dispatcher, parse_url(self.url)[3] + ) + + try: + setSock() + if not custom_dispatcher and reconnect: + while self.keep_running: + _logging.debug( + f"Calling dispatcher reconnect [{len(inspect.stack())} frames in stack]" + ) + dispatcher.reconnect(reconnect, setSock) + except (KeyboardInterrupt, Exception) as e: + _logging.info(f"tearing down on exception {e}") + teardown() + finally: + if not custom_dispatcher: + # Ensure teardown was called before returning from run_forever + teardown() + + return self.has_errored + + def create_dispatcher( + self, + ping_timeout: Union[float, int, None], + dispatcher: Optional[DispatcherBase] = None, + is_ssl: bool = False, + ) -> Union[Dispatcher, SSLDispatcher, WrappedDispatcher]: + if dispatcher: # If custom dispatcher is set, use WrappedDispatcher + return WrappedDispatcher(self, ping_timeout, dispatcher) + timeout = ping_timeout or 10 + if is_ssl: + return SSLDispatcher(self, timeout) + return Dispatcher(self, timeout) + + def _get_close_args(self, close_frame: ABNF) -> list: + """ + _get_close_args extracts the close code and reason from the close body + if it exists (RFC6455 says WebSocket Connection Close Code is optional) + """ + # Need to catch the case where close_frame is None + # Otherwise the following if statement causes an error + if not self.on_close or not close_frame: + return [None, None] + + # Extract close frame status code + if close_frame.data and len(close_frame.data) >= 2: + close_status_code = 256 * int(close_frame.data[0]) + int( + close_frame.data[1] + ) + reason = close_frame.data[2:] + if isinstance(reason, bytes): + reason = reason.decode("utf-8") + return [close_status_code, reason] + else: + # Most likely reached this because len(close_frame_data.data) < 2 + return [None, None] + + def _callback(self, callback, *args) -> None: + if callback: + try: + callback(self, *args) + + except Exception as e: + _logging.error(f"error from callback {callback}: {e}") + if self.on_error: + self.on_error(self, e) diff --git a/plugins/code/ui/lsp_manager/libs/websocket/_cookiejar.py b/plugins/code/ui/lsp_manager/libs/websocket/_cookiejar.py new file mode 100644 index 0000000..7480e5f --- /dev/null +++ b/plugins/code/ui/lsp_manager/libs/websocket/_cookiejar.py @@ -0,0 +1,75 @@ +import http.cookies +from typing import Optional + +""" +_cookiejar.py +websocket - WebSocket client library for Python + +Copyright 2024 engn33r + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + + +class SimpleCookieJar: + def __init__(self) -> None: + self.jar: dict = {} + + def add(self, set_cookie: Optional[str]) -> None: + if set_cookie: + simple_cookie = http.cookies.SimpleCookie(set_cookie) + + for v in simple_cookie.values(): + if domain := v.get("domain"): + if not domain.startswith("."): + domain = f".{domain}" + cookie = ( + self.jar.get(domain) + if self.jar.get(domain) + else http.cookies.SimpleCookie() + ) + cookie.update(simple_cookie) + self.jar[domain.lower()] = cookie + + def set(self, set_cookie: str) -> None: + if set_cookie: + simple_cookie = http.cookies.SimpleCookie(set_cookie) + + for v in simple_cookie.values(): + if domain := v.get("domain"): + if not domain.startswith("."): + domain = f".{domain}" + self.jar[domain.lower()] = simple_cookie + + def get(self, host: str) -> str: + if not host: + return "" + + cookies = [] + for domain, _ in self.jar.items(): + host = host.lower() + if host.endswith(domain) or host == domain[1:]: + cookies.append(self.jar.get(domain)) + + return "; ".join( + filter( + None, + sorted( + [ + f"{k}={v.value}" + for cookie in filter(None, cookies) + for k, v in cookie.items() + ] + ), + ) + ) diff --git a/plugins/code/ui/lsp_manager/libs/websocket/_core.py b/plugins/code/ui/lsp_manager/libs/websocket/_core.py new file mode 100644 index 0000000..f940ed0 --- /dev/null +++ b/plugins/code/ui/lsp_manager/libs/websocket/_core.py @@ -0,0 +1,647 @@ +import socket +import struct +import threading +import time +from typing import Optional, Union + +# websocket modules +from ._abnf import ABNF, STATUS_NORMAL, continuous_frame, frame_buffer +from ._exceptions import WebSocketProtocolException, WebSocketConnectionClosedException +from ._handshake import SUPPORTED_REDIRECT_STATUSES, handshake +from ._http import connect, proxy_info +from ._logging import debug, error, trace, isEnabledForError, isEnabledForTrace +from ._socket import getdefaulttimeout, recv, send, sock_opt +from ._ssl_compat import ssl +from ._utils import NoLock + +""" +_core.py +websocket - WebSocket client library for Python + +Copyright 2024 engn33r + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +__all__ = ["WebSocket", "create_connection"] + + +class WebSocket: + """ + Low level WebSocket interface. + + This class is based on the WebSocket protocol `draft-hixie-thewebsocketprotocol-76 `_ + + We can connect to the websocket server and send/receive data. + The following example is an echo client. + + >>> import websocket + >>> ws = websocket.WebSocket() + >>> ws.connect("ws://echo.websocket.events") + >>> ws.recv() + 'echo.websocket.events sponsored by Lob.com' + >>> ws.send("Hello, Server") + 19 + >>> ws.recv() + 'Hello, Server' + >>> ws.close() + + Parameters + ---------- + get_mask_key: func + A callable function to get new mask keys, see the + WebSocket.set_mask_key's docstring for more information. + sockopt: tuple + Values for socket.setsockopt. + sockopt must be tuple and each element is argument of sock.setsockopt. + sslopt: dict + Optional dict object for ssl socket options. See FAQ for details. + fire_cont_frame: bool + Fire recv event for each cont frame. Default is False. + enable_multithread: bool + If set to True, lock send method. + skip_utf8_validation: bool + Skip utf8 validation. + """ + + def __init__( + self, + get_mask_key=None, + sockopt=None, + sslopt=None, + fire_cont_frame: bool = False, + enable_multithread: bool = True, + skip_utf8_validation: bool = False, + **_, + ): + """ + Initialize WebSocket object. + + Parameters + ---------- + sslopt: dict + Optional dict object for ssl socket options. See FAQ for details. + """ + self.sock_opt = sock_opt(sockopt, sslopt) + self.handshake_response = None + self.sock: Optional[socket.socket] = None + + self.connected = False + self.get_mask_key = get_mask_key + # These buffer over the build-up of a single frame. + self.frame_buffer = frame_buffer(self._recv, skip_utf8_validation) + self.cont_frame = continuous_frame(fire_cont_frame, skip_utf8_validation) + + if enable_multithread: + self.lock = threading.Lock() + self.readlock = threading.Lock() + else: + self.lock = NoLock() + self.readlock = NoLock() + + def __iter__(self): + """ + Allow iteration over websocket, implying sequential `recv` executions. + """ + while True: + yield self.recv() + + def __next__(self): + return self.recv() + + def next(self): + return self.__next__() + + def fileno(self): + return self.sock.fileno() + + def set_mask_key(self, func): + """ + Set function to create mask key. You can customize mask key generator. + Mainly, this is for testing purpose. + + Parameters + ---------- + func: func + callable object. the func takes 1 argument as integer. + The argument means length of mask key. + This func must return string(byte array), + which length is argument specified. + """ + self.get_mask_key = func + + def gettimeout(self) -> Union[float, int, None]: + """ + Get the websocket timeout (in seconds) as an int or float + + Returns + ---------- + timeout: int or float + returns timeout value (in seconds). This value could be either float/integer. + """ + return self.sock_opt.timeout + + def settimeout(self, timeout: Union[float, int, None]): + """ + Set the timeout to the websocket. + + Parameters + ---------- + timeout: int or float + timeout time (in seconds). This value could be either float/integer. + """ + self.sock_opt.timeout = timeout + if self.sock: + self.sock.settimeout(timeout) + + timeout = property(gettimeout, settimeout) + + def getsubprotocol(self): + """ + Get subprotocol + """ + if self.handshake_response: + return self.handshake_response.subprotocol + else: + return None + + subprotocol = property(getsubprotocol) + + def getstatus(self): + """ + Get handshake status + """ + if self.handshake_response: + return self.handshake_response.status + else: + return None + + status = property(getstatus) + + def getheaders(self): + """ + Get handshake response header + """ + if self.handshake_response: + return self.handshake_response.headers + else: + return None + + def is_ssl(self): + try: + return isinstance(self.sock, ssl.SSLSocket) + except: + return False + + headers = property(getheaders) + + def connect(self, url, **options): + """ + Connect to url. url is websocket url scheme. + ie. ws://host:port/resource + You can customize using 'options'. + If you set "header" list object, you can set your own custom header. + + >>> ws = WebSocket() + >>> ws.connect("ws://echo.websocket.events", + ... header=["User-Agent: MyProgram", + ... "x-custom: header"]) + + Parameters + ---------- + header: list or dict + Custom http header list or dict. + cookie: str + Cookie value. + origin: str + Custom origin url. + connection: str + Custom connection header value. + Default value "Upgrade" set in _handshake.py + suppress_origin: bool + Suppress outputting origin header. + host: str + Custom host header string. + timeout: int or float + Socket timeout time. This value is an integer or float. + If you set None for this value, it means "use default_timeout value" + http_proxy_host: str + HTTP proxy host name. + http_proxy_port: str or int + HTTP proxy port. Default is 80. + http_no_proxy: list + Whitelisted host names that don't use the proxy. + http_proxy_auth: tuple + HTTP proxy auth information. Tuple of username and password. Default is None. + http_proxy_timeout: int or float + HTTP proxy timeout, default is 60 sec as per python-socks. + redirect_limit: int + Number of redirects to follow. + subprotocols: list + List of available subprotocols. Default is None. + socket: socket + Pre-initialized stream socket. + """ + self.sock_opt.timeout = options.get("timeout", self.sock_opt.timeout) + self.sock, addrs = connect( + url, self.sock_opt, proxy_info(**options), options.pop("socket", None) + ) + + try: + self.handshake_response = handshake(self.sock, url, *addrs, **options) + for _ in range(options.pop("redirect_limit", 3)): + if self.handshake_response.status in SUPPORTED_REDIRECT_STATUSES: + url = self.handshake_response.headers["location"] + self.sock.close() + self.sock, addrs = connect( + url, + self.sock_opt, + proxy_info(**options), + options.pop("socket", None), + ) + self.handshake_response = handshake( + self.sock, url, *addrs, **options + ) + self.connected = True + except: + if self.sock: + self.sock.close() + self.sock = None + raise + + def send(self, payload: Union[bytes, str], opcode: int = ABNF.OPCODE_TEXT) -> int: + """ + Send the data as string. + + Parameters + ---------- + payload: str + Payload must be utf-8 string or unicode, + If the opcode is OPCODE_TEXT. + Otherwise, it must be string(byte array). + opcode: int + Operation code (opcode) to send. + """ + + frame = ABNF.create_frame(payload, opcode) + return self.send_frame(frame) + + def send_text(self, text_data: str) -> int: + """ + Sends UTF-8 encoded text. + """ + return self.send(text_data, ABNF.OPCODE_TEXT) + + def send_bytes(self, data: Union[bytes, bytearray]) -> int: + """ + Sends a sequence of bytes. + """ + return self.send(data, ABNF.OPCODE_BINARY) + + def send_frame(self, frame) -> int: + """ + Send the data frame. + + >>> ws = create_connection("ws://echo.websocket.events") + >>> frame = ABNF.create_frame("Hello", ABNF.OPCODE_TEXT) + >>> ws.send_frame(frame) + >>> cont_frame = ABNF.create_frame("My name is ", ABNF.OPCODE_CONT, 0) + >>> ws.send_frame(frame) + >>> cont_frame = ABNF.create_frame("Foo Bar", ABNF.OPCODE_CONT, 1) + >>> ws.send_frame(frame) + + Parameters + ---------- + frame: ABNF frame + frame data created by ABNF.create_frame + """ + if self.get_mask_key: + frame.get_mask_key = self.get_mask_key + data = frame.format() + length = len(data) + if isEnabledForTrace(): + trace(f"++Sent raw: {repr(data)}") + trace(f"++Sent decoded: {frame.__str__()}") + with self.lock: + while data: + l = self._send(data) + data = data[l:] + + return length + + def send_binary(self, payload: bytes) -> int: + """ + Send a binary message (OPCODE_BINARY). + + Parameters + ---------- + payload: bytes + payload of message to send. + """ + return self.send(payload, ABNF.OPCODE_BINARY) + + def ping(self, payload: Union[str, bytes] = ""): + """ + Send ping data. + + Parameters + ---------- + payload: str + data payload to send server. + """ + if isinstance(payload, str): + payload = payload.encode("utf-8") + self.send(payload, ABNF.OPCODE_PING) + + def pong(self, payload: Union[str, bytes] = ""): + """ + Send pong data. + + Parameters + ---------- + payload: str + data payload to send server. + """ + if isinstance(payload, str): + payload = payload.encode("utf-8") + self.send(payload, ABNF.OPCODE_PONG) + + def recv(self) -> Union[str, bytes]: + """ + Receive string data(byte array) from the server. + + Returns + ---------- + data: string (byte array) value. + """ + with self.readlock: + opcode, data = self.recv_data() + if opcode == ABNF.OPCODE_TEXT: + data_received: Union[bytes, str] = data + if isinstance(data_received, bytes): + return data_received.decode("utf-8") + elif isinstance(data_received, str): + return data_received + elif opcode == ABNF.OPCODE_BINARY: + data_binary: bytes = data + return data_binary + else: + return "" + + def recv_data(self, control_frame: bool = False) -> tuple: + """ + Receive data with operation code. + + Parameters + ---------- + control_frame: bool + a boolean flag indicating whether to return control frame + data, defaults to False + + Returns + ------- + opcode, frame.data: tuple + tuple of operation code and string(byte array) value. + """ + opcode, frame = self.recv_data_frame(control_frame) + return opcode, frame.data + + def recv_data_frame(self, control_frame: bool = False) -> tuple: + """ + Receive data with operation code. + + If a valid ping message is received, a pong response is sent. + + Parameters + ---------- + control_frame: bool + a boolean flag indicating whether to return control frame + data, defaults to False + + Returns + ------- + frame.opcode, frame: tuple + tuple of operation code and string(byte array) value. + """ + while True: + frame = self.recv_frame() + if isEnabledForTrace(): + trace(f"++Rcv raw: {repr(frame.format())}") + trace(f"++Rcv decoded: {frame.__str__()}") + if not frame: + # handle error: + # 'NoneType' object has no attribute 'opcode' + raise WebSocketProtocolException(f"Not a valid frame {frame}") + elif frame.opcode in ( + ABNF.OPCODE_TEXT, + ABNF.OPCODE_BINARY, + ABNF.OPCODE_CONT, + ): + self.cont_frame.validate(frame) + self.cont_frame.add(frame) + + if self.cont_frame.is_fire(frame): + return self.cont_frame.extract(frame) + + elif frame.opcode == ABNF.OPCODE_CLOSE: + self.send_close() + return frame.opcode, frame + elif frame.opcode == ABNF.OPCODE_PING: + if len(frame.data) < 126: + self.pong(frame.data) + else: + raise WebSocketProtocolException("Ping message is too long") + if control_frame: + return frame.opcode, frame + elif frame.opcode == ABNF.OPCODE_PONG: + if control_frame: + return frame.opcode, frame + + def recv_frame(self): + """ + Receive data as frame from server. + + Returns + ------- + self.frame_buffer.recv_frame(): ABNF frame object + """ + return self.frame_buffer.recv_frame() + + def send_close(self, status: int = STATUS_NORMAL, reason: bytes = b""): + """ + Send close data to the server. + + Parameters + ---------- + status: int + Status code to send. See STATUS_XXX. + reason: str or bytes + The reason to close. This must be string or UTF-8 bytes. + """ + if status < 0 or status >= ABNF.LENGTH_16: + raise ValueError("code is invalid range") + self.connected = False + self.send(struct.pack("!H", status) + reason, ABNF.OPCODE_CLOSE) + + def close(self, status: int = STATUS_NORMAL, reason: bytes = b"", timeout: int = 3): + """ + Close Websocket object + + Parameters + ---------- + status: int + Status code to send. See VALID_CLOSE_STATUS in ABNF. + reason: bytes + The reason to close in UTF-8. + timeout: int or float + Timeout until receive a close frame. + If None, it will wait forever until receive a close frame. + """ + if not self.connected: + return + if status < 0 or status >= ABNF.LENGTH_16: + raise ValueError("code is invalid range") + + try: + self.connected = False + self.send(struct.pack("!H", status) + reason, ABNF.OPCODE_CLOSE) + sock_timeout = self.sock.gettimeout() + self.sock.settimeout(timeout) + start_time = time.time() + while timeout is None or time.time() - start_time < timeout: + try: + frame = self.recv_frame() + if frame.opcode != ABNF.OPCODE_CLOSE: + continue + if isEnabledForError(): + recv_status = struct.unpack("!H", frame.data[0:2])[0] + if recv_status >= 3000 and recv_status <= 4999: + debug(f"close status: {repr(recv_status)}") + elif recv_status != STATUS_NORMAL: + error(f"close status: {repr(recv_status)}") + break + except: + break + self.sock.settimeout(sock_timeout) + self.sock.shutdown(socket.SHUT_RDWR) + except: + pass + + self.shutdown() + + def abort(self): + """ + Low-level asynchronous abort, wakes up other threads that are waiting in recv_* + """ + if self.connected: + self.sock.shutdown(socket.SHUT_RDWR) + + def shutdown(self): + """ + close socket, immediately. + """ + if self.sock: + self.sock.close() + self.sock = None + self.connected = False + + def _send(self, data: Union[str, bytes]): + return send(self.sock, data) + + def _recv(self, bufsize): + try: + return recv(self.sock, bufsize) + except WebSocketConnectionClosedException: + if self.sock: + self.sock.close() + self.sock = None + self.connected = False + raise + + +def create_connection(url: str, timeout=None, class_=WebSocket, **options): + """ + Connect to url and return websocket object. + + Connect to url and return the WebSocket object. + Passing optional timeout parameter will set the timeout on the socket. + If no timeout is supplied, + the global default timeout setting returned by getdefaulttimeout() is used. + You can customize using 'options'. + If you set "header" list object, you can set your own custom header. + + >>> conn = create_connection("ws://echo.websocket.events", + ... header=["User-Agent: MyProgram", + ... "x-custom: header"]) + + Parameters + ---------- + class_: class + class to instantiate when creating the connection. It has to implement + settimeout and connect. It's __init__ should be compatible with + WebSocket.__init__, i.e. accept all of it's kwargs. + header: list or dict + custom http header list or dict. + cookie: str + Cookie value. + origin: str + custom origin url. + suppress_origin: bool + suppress outputting origin header. + host: str + custom host header string. + timeout: int or float + socket timeout time. This value could be either float/integer. + If set to None, it uses the default_timeout value. + http_proxy_host: str + HTTP proxy host name. + http_proxy_port: str or int + HTTP proxy port. If not set, set to 80. + http_no_proxy: list + Whitelisted host names that don't use the proxy. + http_proxy_auth: tuple + HTTP proxy auth information. tuple of username and password. Default is None. + http_proxy_timeout: int or float + HTTP proxy timeout, default is 60 sec as per python-socks. + enable_multithread: bool + Enable lock for multithread. + redirect_limit: int + Number of redirects to follow. + sockopt: tuple + Values for socket.setsockopt. + sockopt must be a tuple and each element is an argument of sock.setsockopt. + sslopt: dict + Optional dict object for ssl socket options. See FAQ for details. + subprotocols: list + List of available subprotocols. Default is None. + skip_utf8_validation: bool + Skip utf8 validation. + socket: socket + Pre-initialized stream socket. + """ + sockopt = options.pop("sockopt", []) + sslopt = options.pop("sslopt", {}) + fire_cont_frame = options.pop("fire_cont_frame", False) + enable_multithread = options.pop("enable_multithread", True) + skip_utf8_validation = options.pop("skip_utf8_validation", False) + websock = class_( + sockopt=sockopt, + sslopt=sslopt, + fire_cont_frame=fire_cont_frame, + enable_multithread=enable_multithread, + skip_utf8_validation=skip_utf8_validation, + **options, + ) + websock.settimeout(timeout if timeout is not None else getdefaulttimeout()) + websock.connect(url, **options) + return websock diff --git a/plugins/code/ui/lsp_manager/libs/websocket/_exceptions.py b/plugins/code/ui/lsp_manager/libs/websocket/_exceptions.py new file mode 100644 index 0000000..cd196e4 --- /dev/null +++ b/plugins/code/ui/lsp_manager/libs/websocket/_exceptions.py @@ -0,0 +1,94 @@ +""" +_exceptions.py +websocket - WebSocket client library for Python + +Copyright 2024 engn33r + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + + +class WebSocketException(Exception): + """ + WebSocket exception class. + """ + + pass + + +class WebSocketProtocolException(WebSocketException): + """ + If the WebSocket protocol is invalid, this exception will be raised. + """ + + pass + + +class WebSocketPayloadException(WebSocketException): + """ + If the WebSocket payload is invalid, this exception will be raised. + """ + + pass + + +class WebSocketConnectionClosedException(WebSocketException): + """ + If remote host closed the connection or some network error happened, + this exception will be raised. + """ + + pass + + +class WebSocketTimeoutException(WebSocketException): + """ + WebSocketTimeoutException will be raised at socket timeout during read/write data. + """ + + pass + + +class WebSocketProxyException(WebSocketException): + """ + WebSocketProxyException will be raised when proxy error occurred. + """ + + pass + + +class WebSocketBadStatusException(WebSocketException): + """ + WebSocketBadStatusException will be raised when we get bad handshake status code. + """ + + def __init__( + self, + message: str, + status_code: int, + status_message=None, + resp_headers=None, + resp_body=None, + ): + super().__init__(message) + self.status_code = status_code + self.resp_headers = resp_headers + self.resp_body = resp_body + + +class WebSocketAddressException(WebSocketException): + """ + If the websocket address info cannot be found, this exception will be raised. + """ + + pass diff --git a/plugins/code/ui/lsp_manager/libs/websocket/_handshake.py b/plugins/code/ui/lsp_manager/libs/websocket/_handshake.py new file mode 100644 index 0000000..8e3cd4d --- /dev/null +++ b/plugins/code/ui/lsp_manager/libs/websocket/_handshake.py @@ -0,0 +1,203 @@ +""" +_handshake.py +websocket - WebSocket client library for Python + +Copyright 2024 engn33r + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import hashlib +import hmac +import os +from base64 import encodebytes as base64encode +from http import HTTPStatus + +from ._cookiejar import SimpleCookieJar +from ._exceptions import WebSocketException, WebSocketBadStatusException +from ._http import read_headers +from ._logging import dump, error +from ._socket import send + +__all__ = ["handshake_response", "handshake", "SUPPORTED_REDIRECT_STATUSES"] + +# websocket supported version. +VERSION = 13 + +SUPPORTED_REDIRECT_STATUSES = ( + HTTPStatus.MOVED_PERMANENTLY, + HTTPStatus.FOUND, + HTTPStatus.SEE_OTHER, + HTTPStatus.TEMPORARY_REDIRECT, + HTTPStatus.PERMANENT_REDIRECT, +) +SUCCESS_STATUSES = SUPPORTED_REDIRECT_STATUSES + (HTTPStatus.SWITCHING_PROTOCOLS,) + +CookieJar = SimpleCookieJar() + + +class handshake_response: + def __init__(self, status: int, headers: dict, subprotocol): + self.status = status + self.headers = headers + self.subprotocol = subprotocol + CookieJar.add(headers.get("set-cookie")) + + +def handshake( + sock, url: str, hostname: str, port: int, resource: str, **options +) -> handshake_response: + headers, key = _get_handshake_headers(resource, url, hostname, port, options) + + header_str = "\r\n".join(headers) + send(sock, header_str) + dump("request header", header_str) + + status, resp = _get_resp_headers(sock) + if status in SUPPORTED_REDIRECT_STATUSES: + return handshake_response(status, resp, None) + success, subproto = _validate(resp, key, options.get("subprotocols")) + if not success: + raise WebSocketException("Invalid WebSocket Header") + + return handshake_response(status, resp, subproto) + + +def _pack_hostname(hostname: str) -> str: + # IPv6 address + if ":" in hostname: + return f"[{hostname}]" + return hostname + + +def _get_handshake_headers( + resource: str, url: str, host: str, port: int, options: dict +) -> tuple: + headers = [f"GET {resource} HTTP/1.1", "Upgrade: websocket"] + if port in [80, 443]: + hostport = _pack_hostname(host) + else: + hostport = f"{_pack_hostname(host)}:{port}" + if options.get("host"): + headers.append(f'Host: {options["host"]}') + else: + headers.append(f"Host: {hostport}") + + # scheme indicates whether http or https is used in Origin + # The same approach is used in parse_url of _url.py to set default port + scheme, url = url.split(":", 1) + if not options.get("suppress_origin"): + if "origin" in options and options["origin"] is not None: + headers.append(f'Origin: {options["origin"]}') + elif scheme == "wss": + headers.append(f"Origin: https://{hostport}") + else: + headers.append(f"Origin: http://{hostport}") + + key = _create_sec_websocket_key() + + # Append Sec-WebSocket-Key & Sec-WebSocket-Version if not manually specified + if not options.get("header") or "Sec-WebSocket-Key" not in options["header"]: + headers.append(f"Sec-WebSocket-Key: {key}") + else: + key = options["header"]["Sec-WebSocket-Key"] + + if not options.get("header") or "Sec-WebSocket-Version" not in options["header"]: + headers.append(f"Sec-WebSocket-Version: {VERSION}") + + if not options.get("connection"): + headers.append("Connection: Upgrade") + else: + headers.append(options["connection"]) + + if subprotocols := options.get("subprotocols"): + headers.append(f'Sec-WebSocket-Protocol: {",".join(subprotocols)}') + + if header := options.get("header"): + if isinstance(header, dict): + header = [": ".join([k, v]) for k, v in header.items() if v is not None] + headers.extend(header) + + server_cookie = CookieJar.get(host) + client_cookie = options.get("cookie", None) + + if cookie := "; ".join(filter(None, [server_cookie, client_cookie])): + headers.append(f"Cookie: {cookie}") + + headers.extend(("", "")) + return headers, key + + +def _get_resp_headers(sock, success_statuses: tuple = SUCCESS_STATUSES) -> tuple: + status, resp_headers, status_message = read_headers(sock) + if status not in success_statuses: + content_len = resp_headers.get("content-length") + if content_len: + response_body = sock.recv( + int(content_len) + ) # read the body of the HTTP error message response and include it in the exception + else: + response_body = None + raise WebSocketBadStatusException( + f"Handshake status {status} {status_message} -+-+- {resp_headers} -+-+- {response_body}", + status, + status_message, + resp_headers, + response_body, + ) + return status, resp_headers + + +_HEADERS_TO_CHECK = { + "upgrade": "websocket", + "connection": "upgrade", +} + + +def _validate(headers, key: str, subprotocols) -> tuple: + subproto = None + for k, v in _HEADERS_TO_CHECK.items(): + r = headers.get(k, None) + if not r: + return False, None + r = [x.strip().lower() for x in r.split(",")] + if v not in r: + return False, None + + if subprotocols: + subproto = headers.get("sec-websocket-protocol", None) + if not subproto or subproto.lower() not in [s.lower() for s in subprotocols]: + error(f"Invalid subprotocol: {subprotocols}") + return False, None + subproto = subproto.lower() + + result = headers.get("sec-websocket-accept", None) + if not result: + return False, None + result = result.lower() + + if isinstance(result, str): + result = result.encode("utf-8") + + value = f"{key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11".encode("utf-8") + hashed = base64encode(hashlib.sha1(value).digest()).strip().lower() + + if hmac.compare_digest(hashed, result): + return True, subproto + else: + return False, None + + +def _create_sec_websocket_key() -> str: + randomness = os.urandom(16) + return base64encode(randomness).decode("utf-8").strip() diff --git a/plugins/code/ui/lsp_manager/libs/websocket/_http.py b/plugins/code/ui/lsp_manager/libs/websocket/_http.py new file mode 100644 index 0000000..7765330 --- /dev/null +++ b/plugins/code/ui/lsp_manager/libs/websocket/_http.py @@ -0,0 +1,374 @@ +""" +_http.py +websocket - WebSocket client library for Python + +Copyright 2024 engn33r + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import errno +import os +import socket +from base64 import encodebytes as base64encode + +from ._exceptions import ( + WebSocketAddressException, + WebSocketException, + WebSocketProxyException, +) +from ._logging import debug, dump, trace +from ._socket import DEFAULT_SOCKET_OPTION, recv_line, send +from ._ssl_compat import HAVE_SSL, ssl +from ._url import get_proxy_info, parse_url + +__all__ = ["proxy_info", "connect", "read_headers"] + +try: + from python_socks._errors import * + from python_socks._types import ProxyType + from python_socks.sync import Proxy + + HAVE_PYTHON_SOCKS = True +except: + HAVE_PYTHON_SOCKS = False + + class ProxyError(Exception): + pass + + class ProxyTimeoutError(Exception): + pass + + class ProxyConnectionError(Exception): + pass + + +class proxy_info: + def __init__(self, **options): + self.proxy_host = options.get("http_proxy_host", None) + if self.proxy_host: + self.proxy_port = options.get("http_proxy_port", 0) + self.auth = options.get("http_proxy_auth", None) + self.no_proxy = options.get("http_no_proxy", None) + self.proxy_protocol = options.get("proxy_type", "http") + # Note: If timeout not specified, default python-socks timeout is 60 seconds + self.proxy_timeout = options.get("http_proxy_timeout", None) + if self.proxy_protocol not in [ + "http", + "socks4", + "socks4a", + "socks5", + "socks5h", + ]: + raise ProxyError( + "Only http, socks4, socks5 proxy protocols are supported" + ) + else: + self.proxy_port = 0 + self.auth = None + self.no_proxy = None + self.proxy_protocol = "http" + + +def _start_proxied_socket(url: str, options, proxy) -> tuple: + if not HAVE_PYTHON_SOCKS: + raise WebSocketException( + "Python Socks is needed for SOCKS proxying but is not available" + ) + + hostname, port, resource, is_secure = parse_url(url) + + if proxy.proxy_protocol == "socks4": + rdns = False + proxy_type = ProxyType.SOCKS4 + # socks4a sends DNS through proxy + elif proxy.proxy_protocol == "socks4a": + rdns = True + proxy_type = ProxyType.SOCKS4 + elif proxy.proxy_protocol == "socks5": + rdns = False + proxy_type = ProxyType.SOCKS5 + # socks5h sends DNS through proxy + elif proxy.proxy_protocol == "socks5h": + rdns = True + proxy_type = ProxyType.SOCKS5 + + ws_proxy = Proxy.create( + proxy_type=proxy_type, + host=proxy.proxy_host, + port=int(proxy.proxy_port), + username=proxy.auth[0] if proxy.auth else None, + password=proxy.auth[1] if proxy.auth else None, + rdns=rdns, + ) + + sock = ws_proxy.connect(hostname, port, timeout=proxy.proxy_timeout) + + if is_secure: + if HAVE_SSL: + sock = _ssl_socket(sock, options.sslopt, hostname) + else: + raise WebSocketException("SSL not available.") + + return sock, (hostname, port, resource) + + +def connect(url: str, options, proxy, socket): + # Use _start_proxied_socket() only for socks4 or socks5 proxy + # Use _tunnel() for http proxy + # TODO: Use python-socks for http protocol also, to standardize flow + if proxy.proxy_host and not socket and proxy.proxy_protocol != "http": + return _start_proxied_socket(url, options, proxy) + + hostname, port_from_url, resource, is_secure = parse_url(url) + + if socket: + return socket, (hostname, port_from_url, resource) + + addrinfo_list, need_tunnel, auth = _get_addrinfo_list( + hostname, port_from_url, is_secure, proxy + ) + if not addrinfo_list: + raise WebSocketException(f"Host not found.: {hostname}:{port_from_url}") + + sock = None + try: + sock = _open_socket(addrinfo_list, options.sockopt, options.timeout) + if need_tunnel: + sock = _tunnel(sock, hostname, port_from_url, auth) + + if is_secure: + if HAVE_SSL: + sock = _ssl_socket(sock, options.sslopt, hostname) + else: + raise WebSocketException("SSL not available.") + + return sock, (hostname, port_from_url, resource) + except: + if sock: + sock.close() + raise + + +def _get_addrinfo_list(hostname, port: int, is_secure: bool, proxy) -> tuple: + phost, pport, pauth = get_proxy_info( + hostname, + is_secure, + proxy.proxy_host, + proxy.proxy_port, + proxy.auth, + proxy.no_proxy, + ) + try: + # when running on windows 10, getaddrinfo without socktype returns a socktype 0. + # This generates an error exception: `_on_error: exception Socket type must be stream or datagram, not 0` + # or `OSError: [Errno 22] Invalid argument` when creating socket. Force the socket type to SOCK_STREAM. + if not phost: + addrinfo_list = socket.getaddrinfo( + hostname, port, 0, socket.SOCK_STREAM, socket.SOL_TCP + ) + return addrinfo_list, False, None + else: + pport = pport and pport or 80 + # when running on windows 10, the getaddrinfo used above + # returns a socktype 0. This generates an error exception: + # _on_error: exception Socket type must be stream or datagram, not 0 + # Force the socket type to SOCK_STREAM + addrinfo_list = socket.getaddrinfo( + phost, pport, 0, socket.SOCK_STREAM, socket.SOL_TCP + ) + return addrinfo_list, True, pauth + except socket.gaierror as e: + raise WebSocketAddressException(e) + + +def _open_socket(addrinfo_list, sockopt, timeout): + err = None + for addrinfo in addrinfo_list: + family, socktype, proto = addrinfo[:3] + sock = socket.socket(family, socktype, proto) + sock.settimeout(timeout) + for opts in DEFAULT_SOCKET_OPTION: + sock.setsockopt(*opts) + for opts in sockopt: + sock.setsockopt(*opts) + + address = addrinfo[4] + err = None + while not err: + try: + sock.connect(address) + except socket.error as error: + sock.close() + error.remote_ip = str(address[0]) + try: + eConnRefused = ( + errno.ECONNREFUSED, + errno.WSAECONNREFUSED, + errno.ENETUNREACH, + ) + except AttributeError: + eConnRefused = (errno.ECONNREFUSED, errno.ENETUNREACH) + if error.errno not in eConnRefused: + raise error + err = error + continue + else: + break + else: + continue + break + else: + if err: + raise err + + return sock + + +def _wrap_sni_socket(sock: socket.socket, sslopt: dict, hostname, check_hostname): + context = sslopt.get("context", None) + if not context: + context = ssl.SSLContext(sslopt.get("ssl_version", ssl.PROTOCOL_TLS_CLIENT)) + # Non default context need to manually enable SSLKEYLOGFILE support by setting the keylog_filename attribute. + # For more details see also: + # * https://docs.python.org/3.8/library/ssl.html?highlight=sslkeylogfile#context-creation + # * https://docs.python.org/3.8/library/ssl.html?highlight=sslkeylogfile#ssl.SSLContext.keylog_filename + context.keylog_filename = os.environ.get("SSLKEYLOGFILE", None) + + if sslopt.get("cert_reqs", ssl.CERT_NONE) != ssl.CERT_NONE: + cafile = sslopt.get("ca_certs", None) + capath = sslopt.get("ca_cert_path", None) + if cafile or capath: + context.load_verify_locations(cafile=cafile, capath=capath) + elif hasattr(context, "load_default_certs"): + context.load_default_certs(ssl.Purpose.SERVER_AUTH) + if sslopt.get("certfile", None): + context.load_cert_chain( + sslopt["certfile"], + sslopt.get("keyfile", None), + sslopt.get("password", None), + ) + + # Python 3.10 switch to PROTOCOL_TLS_CLIENT defaults to "cert_reqs = ssl.CERT_REQUIRED" and "check_hostname = True" + # If both disabled, set check_hostname before verify_mode + # see https://github.com/liris/websocket-client/commit/b96a2e8fa765753e82eea531adb19716b52ca3ca#commitcomment-10803153 + if sslopt.get("cert_reqs", ssl.CERT_NONE) == ssl.CERT_NONE and not sslopt.get( + "check_hostname", False + ): + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + else: + context.check_hostname = sslopt.get("check_hostname", True) + context.verify_mode = sslopt.get("cert_reqs", ssl.CERT_REQUIRED) + + if "ciphers" in sslopt: + context.set_ciphers(sslopt["ciphers"]) + if "cert_chain" in sslopt: + certfile, keyfile, password = sslopt["cert_chain"] + context.load_cert_chain(certfile, keyfile, password) + if "ecdh_curve" in sslopt: + context.set_ecdh_curve(sslopt["ecdh_curve"]) + + return context.wrap_socket( + sock, + do_handshake_on_connect=sslopt.get("do_handshake_on_connect", True), + suppress_ragged_eofs=sslopt.get("suppress_ragged_eofs", True), + server_hostname=hostname, + ) + + +def _ssl_socket(sock: socket.socket, user_sslopt: dict, hostname): + sslopt: dict = {"cert_reqs": ssl.CERT_REQUIRED} + sslopt.update(user_sslopt) + + cert_path = os.environ.get("WEBSOCKET_CLIENT_CA_BUNDLE") + if ( + cert_path + and os.path.isfile(cert_path) + and user_sslopt.get("ca_certs", None) is None + ): + sslopt["ca_certs"] = cert_path + elif ( + cert_path + and os.path.isdir(cert_path) + and user_sslopt.get("ca_cert_path", None) is None + ): + sslopt["ca_cert_path"] = cert_path + + if sslopt.get("server_hostname", None): + hostname = sslopt["server_hostname"] + + check_hostname = sslopt.get("check_hostname", True) + sock = _wrap_sni_socket(sock, sslopt, hostname, check_hostname) + + return sock + + +def _tunnel(sock: socket.socket, host, port: int, auth) -> socket.socket: + debug("Connecting proxy...") + connect_header = f"CONNECT {host}:{port} HTTP/1.1\r\n" + connect_header += f"Host: {host}:{port}\r\n" + + # TODO: support digest auth. + if auth and auth[0]: + auth_str = auth[0] + if auth[1]: + auth_str += f":{auth[1]}" + encoded_str = base64encode(auth_str.encode()).strip().decode().replace("\n", "") + connect_header += f"Proxy-Authorization: Basic {encoded_str}\r\n" + connect_header += "\r\n" + dump("request header", connect_header) + + send(sock, connect_header) + + try: + status, _, _ = read_headers(sock) + except Exception as e: + raise WebSocketProxyException(str(e)) + + if status != 200: + raise WebSocketProxyException(f"failed CONNECT via proxy status: {status}") + + return sock + + +def read_headers(sock: socket.socket) -> tuple: + status = None + status_message = None + headers: dict = {} + trace("--- response header ---") + + while True: + line = recv_line(sock) + line = line.decode("utf-8").strip() + if not line: + break + trace(line) + if not status: + status_info = line.split(" ", 2) + status = int(status_info[1]) + if len(status_info) > 2: + status_message = status_info[2] + else: + kv = line.split(":", 1) + if len(kv) != 2: + raise WebSocketException("Invalid header") + key, value = kv + if key.lower() == "set-cookie" and headers.get("set-cookie"): + headers["set-cookie"] = headers.get("set-cookie") + "; " + value.strip() + else: + headers[key.lower()] = value.strip() + + trace("-----------------------") + + return status, headers, status_message diff --git a/plugins/code/ui/lsp_manager/libs/websocket/_logging.py b/plugins/code/ui/lsp_manager/libs/websocket/_logging.py new file mode 100644 index 0000000..0f673d3 --- /dev/null +++ b/plugins/code/ui/lsp_manager/libs/websocket/_logging.py @@ -0,0 +1,106 @@ +import logging + +""" +_logging.py +websocket - WebSocket client library for Python + +Copyright 2024 engn33r + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +_logger = logging.getLogger("websocket") +try: + from logging import NullHandler +except ImportError: + + class NullHandler(logging.Handler): + def emit(self, record) -> None: + pass + + +_logger.addHandler(NullHandler()) + +_traceEnabled = False + +__all__ = [ + "enableTrace", + "dump", + "error", + "warning", + "debug", + "trace", + "isEnabledForError", + "isEnabledForDebug", + "isEnabledForTrace", +] + + +def enableTrace( + traceable: bool, + handler: logging.StreamHandler = logging.StreamHandler(), + level: str = "DEBUG", +) -> None: + """ + Turn on/off the traceability. + + Parameters + ---------- + traceable: bool + If set to True, traceability is enabled. + """ + global _traceEnabled + _traceEnabled = traceable + if traceable: + _logger.addHandler(handler) + _logger.setLevel(getattr(logging, level)) + + +def dump(title: str, message: str) -> None: + if _traceEnabled: + _logger.debug(f"--- {title} ---") + _logger.debug(message) + _logger.debug("-----------------------") + + +def error(msg: str) -> None: + _logger.error(msg) + + +def warning(msg: str) -> None: + _logger.warning(msg) + + +def debug(msg: str) -> None: + _logger.debug(msg) + + +def info(msg: str) -> None: + _logger.info(msg) + + +def trace(msg: str) -> None: + if _traceEnabled: + _logger.debug(msg) + + +def isEnabledForError() -> bool: + return _logger.isEnabledFor(logging.ERROR) + + +def isEnabledForDebug() -> bool: + return _logger.isEnabledFor(logging.DEBUG) + + +def isEnabledForTrace() -> bool: + return _traceEnabled diff --git a/plugins/code/ui/lsp_manager/libs/websocket/_socket.py b/plugins/code/ui/lsp_manager/libs/websocket/_socket.py new file mode 100644 index 0000000..81094ff --- /dev/null +++ b/plugins/code/ui/lsp_manager/libs/websocket/_socket.py @@ -0,0 +1,188 @@ +import errno +import selectors +import socket +from typing import Union + +from ._exceptions import ( + WebSocketConnectionClosedException, + WebSocketTimeoutException, +) +from ._ssl_compat import SSLError, SSLWantReadError, SSLWantWriteError +from ._utils import extract_error_code, extract_err_message + +""" +_socket.py +websocket - WebSocket client library for Python + +Copyright 2024 engn33r + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +DEFAULT_SOCKET_OPTION = [(socket.SOL_TCP, socket.TCP_NODELAY, 1)] +if hasattr(socket, "SO_KEEPALIVE"): + DEFAULT_SOCKET_OPTION.append((socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)) +if hasattr(socket, "TCP_KEEPIDLE"): + DEFAULT_SOCKET_OPTION.append((socket.SOL_TCP, socket.TCP_KEEPIDLE, 30)) +if hasattr(socket, "TCP_KEEPINTVL"): + DEFAULT_SOCKET_OPTION.append((socket.SOL_TCP, socket.TCP_KEEPINTVL, 10)) +if hasattr(socket, "TCP_KEEPCNT"): + DEFAULT_SOCKET_OPTION.append((socket.SOL_TCP, socket.TCP_KEEPCNT, 3)) + +_default_timeout = None + +__all__ = [ + "DEFAULT_SOCKET_OPTION", + "sock_opt", + "setdefaulttimeout", + "getdefaulttimeout", + "recv", + "recv_line", + "send", +] + + +class sock_opt: + def __init__(self, sockopt: list, sslopt: dict) -> None: + if sockopt is None: + sockopt = [] + if sslopt is None: + sslopt = {} + self.sockopt = sockopt + self.sslopt = sslopt + self.timeout = None + + +def setdefaulttimeout(timeout: Union[int, float, None]) -> None: + """ + Set the global timeout setting to connect. + + Parameters + ---------- + timeout: int or float + default socket timeout time (in seconds) + """ + global _default_timeout + _default_timeout = timeout + + +def getdefaulttimeout() -> Union[int, float, None]: + """ + Get default timeout + + Returns + ---------- + _default_timeout: int or float + Return the global timeout setting (in seconds) to connect. + """ + return _default_timeout + + +def recv(sock: socket.socket, bufsize: int) -> bytes: + if not sock: + raise WebSocketConnectionClosedException("socket is already closed.") + + def _recv(): + try: + return sock.recv(bufsize) + except SSLWantReadError: + pass + except socket.error as exc: + error_code = extract_error_code(exc) + if error_code not in [errno.EAGAIN, errno.EWOULDBLOCK]: + raise + + sel = selectors.DefaultSelector() + sel.register(sock, selectors.EVENT_READ) + + r = sel.select(sock.gettimeout()) + sel.close() + + if r: + return sock.recv(bufsize) + + try: + if sock.gettimeout() == 0: + bytes_ = sock.recv(bufsize) + else: + bytes_ = _recv() + except TimeoutError: + raise WebSocketTimeoutException("Connection timed out") + except socket.timeout as e: + message = extract_err_message(e) + raise WebSocketTimeoutException(message) + except SSLError as e: + message = extract_err_message(e) + if isinstance(message, str) and "timed out" in message: + raise WebSocketTimeoutException(message) + else: + raise + + if not bytes_: + raise WebSocketConnectionClosedException("Connection to remote host was lost.") + + return bytes_ + + +def recv_line(sock: socket.socket) -> bytes: + line = [] + while True: + c = recv(sock, 1) + line.append(c) + if c == b"\n": + break + return b"".join(line) + + +def send(sock: socket.socket, data: Union[bytes, str]) -> int: + if isinstance(data, str): + data = data.encode("utf-8") + + if not sock: + raise WebSocketConnectionClosedException("socket is already closed.") + + def _send(): + try: + return sock.send(data) + except SSLWantWriteError: + pass + except socket.error as exc: + error_code = extract_error_code(exc) + if error_code is None: + raise + if error_code not in [errno.EAGAIN, errno.EWOULDBLOCK]: + raise + + sel = selectors.DefaultSelector() + sel.register(sock, selectors.EVENT_WRITE) + + w = sel.select(sock.gettimeout()) + sel.close() + + if w: + return sock.send(data) + + try: + if sock.gettimeout() == 0: + return sock.send(data) + else: + return _send() + except socket.timeout as e: + message = extract_err_message(e) + raise WebSocketTimeoutException(message) + except Exception as e: + message = extract_err_message(e) + if isinstance(message, str) and "timed out" in message: + raise WebSocketTimeoutException(message) + else: + raise diff --git a/plugins/code/ui/lsp_manager/libs/websocket/_ssl_compat.py b/plugins/code/ui/lsp_manager/libs/websocket/_ssl_compat.py new file mode 100644 index 0000000..7d98126 --- /dev/null +++ b/plugins/code/ui/lsp_manager/libs/websocket/_ssl_compat.py @@ -0,0 +1,49 @@ +""" +_ssl_compat.py +websocket - WebSocket client library for Python + +Copyright 2024 engn33r + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +__all__ = [ + "HAVE_SSL", + "ssl", + "SSLError", + "SSLEOFError", + "SSLWantReadError", + "SSLWantWriteError", +] + +try: + import ssl + from ssl import SSLError, SSLEOFError, SSLWantReadError, SSLWantWriteError + + HAVE_SSL = True +except ImportError: + # dummy class of SSLError for environment without ssl support + class SSLError(Exception): + pass + + class SSLEOFError(Exception): + pass + + class SSLWantReadError(Exception): + pass + + class SSLWantWriteError(Exception): + pass + + ssl = None + HAVE_SSL = False diff --git a/plugins/code/ui/lsp_manager/libs/websocket/_url.py b/plugins/code/ui/lsp_manager/libs/websocket/_url.py new file mode 100644 index 0000000..9021317 --- /dev/null +++ b/plugins/code/ui/lsp_manager/libs/websocket/_url.py @@ -0,0 +1,190 @@ +import os +import socket +import struct +from typing import Optional +from urllib.parse import unquote, urlparse +from ._exceptions import WebSocketProxyException + +""" +_url.py +websocket - WebSocket client library for Python + +Copyright 2024 engn33r + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +__all__ = ["parse_url", "get_proxy_info"] + + +def parse_url(url: str) -> tuple: + """ + parse url and the result is tuple of + (hostname, port, resource path and the flag of secure mode) + + Parameters + ---------- + url: str + url string. + """ + if ":" not in url: + raise ValueError("url is invalid") + + scheme, url = url.split(":", 1) + + parsed = urlparse(url, scheme="http") + if parsed.hostname: + hostname = parsed.hostname + else: + raise ValueError("hostname is invalid") + port = 0 + if parsed.port: + port = parsed.port + + is_secure = False + if scheme == "ws": + if not port: + port = 80 + elif scheme == "wss": + is_secure = True + if not port: + port = 443 + else: + raise ValueError("scheme %s is invalid" % scheme) + + if parsed.path: + resource = parsed.path + else: + resource = "/" + + if parsed.query: + resource += f"?{parsed.query}" + + return hostname, port, resource, is_secure + + +DEFAULT_NO_PROXY_HOST = ["localhost", "127.0.0.1"] + + +def _is_ip_address(addr: str) -> bool: + try: + socket.inet_aton(addr) + except socket.error: + return False + else: + return True + + +def _is_subnet_address(hostname: str) -> bool: + try: + addr, netmask = hostname.split("/") + return _is_ip_address(addr) and 0 <= int(netmask) < 32 + except ValueError: + return False + + +def _is_address_in_network(ip: str, net: str) -> bool: + ipaddr: int = struct.unpack("!I", socket.inet_aton(ip))[0] + netaddr, netmask = net.split("/") + netaddr: int = struct.unpack("!I", socket.inet_aton(netaddr))[0] + + netmask = (0xFFFFFFFF << (32 - int(netmask))) & 0xFFFFFFFF + return ipaddr & netmask == netaddr + + +def _is_no_proxy_host(hostname: str, no_proxy: Optional[list]) -> bool: + if not no_proxy: + if v := os.environ.get("no_proxy", os.environ.get("NO_PROXY", "")).replace( + " ", "" + ): + no_proxy = v.split(",") + if not no_proxy: + no_proxy = DEFAULT_NO_PROXY_HOST + + if "*" in no_proxy: + return True + if hostname in no_proxy: + return True + if _is_ip_address(hostname): + return any( + [ + _is_address_in_network(hostname, subnet) + for subnet in no_proxy + if _is_subnet_address(subnet) + ] + ) + for domain in [domain for domain in no_proxy if domain.startswith(".")]: + if hostname.endswith(domain): + return True + return False + + +def get_proxy_info( + hostname: str, + is_secure: bool, + proxy_host: Optional[str] = None, + proxy_port: int = 0, + proxy_auth: Optional[tuple] = None, + no_proxy: Optional[list] = None, + proxy_type: str = "http", +) -> tuple: + """ + Try to retrieve proxy host and port from environment + if not provided in options. + Result is (proxy_host, proxy_port, proxy_auth). + proxy_auth is tuple of username and password + of proxy authentication information. + + Parameters + ---------- + hostname: str + Websocket server name. + is_secure: bool + Is the connection secure? (wss) looks for "https_proxy" in env + instead of "http_proxy" + proxy_host: str + http proxy host name. + proxy_port: str or int + http proxy port. + no_proxy: list + Whitelisted host names that don't use the proxy. + proxy_auth: tuple + HTTP proxy auth information. Tuple of username and password. Default is None. + proxy_type: str + Specify the proxy protocol (http, socks4, socks4a, socks5, socks5h). Default is "http". + Use socks4a or socks5h if you want to send DNS requests through the proxy. + """ + if _is_no_proxy_host(hostname, no_proxy): + return None, 0, None + + if proxy_host: + if not proxy_port: + raise WebSocketProxyException("Cannot use port 0 when proxy_host specified") + port = proxy_port + auth = proxy_auth + return proxy_host, port, auth + + env_key = "https_proxy" if is_secure else "http_proxy" + value = os.environ.get(env_key, os.environ.get(env_key.upper(), "")).replace( + " ", "" + ) + if value: + proxy = urlparse(value) + auth = ( + (unquote(proxy.username), unquote(proxy.password)) + if proxy.username + else None + ) + return proxy.hostname, proxy.port, auth + + return None, 0, None diff --git a/plugins/code/ui/lsp_manager/libs/websocket/_utils.py b/plugins/code/ui/lsp_manager/libs/websocket/_utils.py new file mode 100644 index 0000000..65f3c0d --- /dev/null +++ b/plugins/code/ui/lsp_manager/libs/websocket/_utils.py @@ -0,0 +1,459 @@ +from typing import Union + +""" +_url.py +websocket - WebSocket client library for Python + +Copyright 2024 engn33r + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" +__all__ = ["NoLock", "validate_utf8", "extract_err_message", "extract_error_code"] + + +class NoLock: + def __enter__(self) -> None: + pass + + def __exit__(self, exc_type, exc_value, traceback) -> None: + pass + + +try: + # If wsaccel is available we use compiled routines to validate UTF-8 + # strings. + from wsaccel.utf8validator import Utf8Validator + + def _validate_utf8(utfbytes: Union[str, bytes]) -> bool: + result: bool = Utf8Validator().validate(utfbytes)[0] + return result + +except ImportError: + # UTF-8 validator + # python implementation of http://bjoern.hoehrmann.de/utf-8/decoder/dfa/ + + _UTF8_ACCEPT = 0 + _UTF8_REJECT = 12 + + _UTF8D = [ + # The first part of the table maps bytes to character classes that + # to reduce the size of the transition table and create bitmasks. + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 9, + 9, + 9, + 9, + 9, + 9, + 9, + 9, + 9, + 9, + 9, + 9, + 9, + 9, + 9, + 9, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 8, + 8, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 10, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 4, + 3, + 3, + 11, + 6, + 6, + 6, + 5, + 8, + 8, + 8, + 8, + 8, + 8, + 8, + 8, + 8, + 8, + 8, + # The second part is a transition table that maps a combination + # of a state of the automaton and a character class to a state. + 0, + 12, + 24, + 36, + 60, + 96, + 84, + 12, + 12, + 12, + 48, + 72, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 0, + 12, + 12, + 12, + 12, + 12, + 0, + 12, + 0, + 12, + 12, + 12, + 24, + 12, + 12, + 12, + 12, + 12, + 24, + 12, + 24, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 24, + 12, + 12, + 12, + 12, + 12, + 24, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 24, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 36, + 12, + 36, + 12, + 12, + 12, + 36, + 12, + 12, + 12, + 12, + 12, + 36, + 12, + 36, + 12, + 12, + 12, + 36, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + ] + + def _decode(state: int, codep: int, ch: int) -> tuple: + tp = _UTF8D[ch] + + codep = ( + (ch & 0x3F) | (codep << 6) if (state != _UTF8_ACCEPT) else (0xFF >> tp) & ch + ) + state = _UTF8D[256 + state + tp] + + return state, codep + + def _validate_utf8(utfbytes: Union[str, bytes]) -> bool: + state = _UTF8_ACCEPT + codep = 0 + for i in utfbytes: + state, codep = _decode(state, codep, int(i)) + if state == _UTF8_REJECT: + return False + + return True + + +def validate_utf8(utfbytes: Union[str, bytes]) -> bool: + """ + validate utf8 byte string. + utfbytes: utf byte string to check. + return value: if valid utf8 string, return true. Otherwise, return false. + """ + return _validate_utf8(utfbytes) + + +def extract_err_message(exception: Exception) -> Union[str, None]: + if exception.args: + exception_message: str = exception.args[0] + return exception_message + else: + return None + + +def extract_error_code(exception: Exception) -> Union[int, None]: + if exception.args and len(exception.args) > 1: + return exception.args[0] if isinstance(exception.args[0], int) else None diff --git a/plugins/code/ui/lsp_manager/libs/websocket/_wsdump.py b/plugins/code/ui/lsp_manager/libs/websocket/_wsdump.py new file mode 100755 index 0000000..d4d76dc --- /dev/null +++ b/plugins/code/ui/lsp_manager/libs/websocket/_wsdump.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 + +""" +wsdump.py +websocket - WebSocket client library for Python + +Copyright 2024 engn33r + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import argparse +import code +import gzip +import ssl +import sys +import threading +import time +import zlib +from urllib.parse import urlparse + +import websocket + +try: + import readline +except ImportError: + pass + + +def get_encoding() -> str: + encoding = getattr(sys.stdin, "encoding", "") + if not encoding: + return "utf-8" + else: + return encoding.lower() + + +OPCODE_DATA = (websocket.ABNF.OPCODE_TEXT, websocket.ABNF.OPCODE_BINARY) +ENCODING = get_encoding() + + +class VAction(argparse.Action): + def __call__( + self, + parser: argparse.Namespace, + args: tuple, + values: str, + option_string: str = None, + ) -> None: + if values is None: + values = "1" + try: + values = int(values) + except ValueError: + values = values.count("v") + 1 + setattr(args, self.dest, values) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="WebSocket Simple Dump Tool") + parser.add_argument( + "url", metavar="ws_url", help="websocket url. ex. ws://echo.websocket.events/" + ) + parser.add_argument("-p", "--proxy", help="proxy url. ex. http://127.0.0.1:8080") + parser.add_argument( + "-v", + "--verbose", + default=0, + nargs="?", + action=VAction, + dest="verbose", + help="set verbose mode. If set to 1, show opcode. " + "If set to 2, enable to trace websocket module", + ) + parser.add_argument( + "-n", "--nocert", action="store_true", help="Ignore invalid SSL cert" + ) + parser.add_argument("-r", "--raw", action="store_true", help="raw output") + parser.add_argument("-s", "--subprotocols", nargs="*", help="Set subprotocols") + parser.add_argument("-o", "--origin", help="Set origin") + parser.add_argument( + "--eof-wait", + default=0, + type=int, + help="wait time(second) after 'EOF' received.", + ) + parser.add_argument("-t", "--text", help="Send initial text") + parser.add_argument( + "--timings", action="store_true", help="Print timings in seconds" + ) + parser.add_argument("--headers", help="Set custom headers. Use ',' as separator") + + return parser.parse_args() + + +class RawInput: + def raw_input(self, prompt: str = "") -> str: + line = input(prompt) + + if ENCODING and ENCODING != "utf-8" and not isinstance(line, str): + line = line.decode(ENCODING).encode("utf-8") + elif isinstance(line, str): + line = line.encode("utf-8") + + return line + + +class InteractiveConsole(RawInput, code.InteractiveConsole): + def write(self, data: str) -> None: + sys.stdout.write("\033[2K\033[E") + # sys.stdout.write("\n") + sys.stdout.write("\033[34m< " + data + "\033[39m") + sys.stdout.write("\n> ") + sys.stdout.flush() + + def read(self) -> str: + return self.raw_input("> ") + + +class NonInteractive(RawInput): + def write(self, data: str) -> None: + sys.stdout.write(data) + sys.stdout.write("\n") + sys.stdout.flush() + + def read(self) -> str: + return self.raw_input("") + + +def main() -> None: + start_time = time.time() + args = parse_args() + if args.verbose > 1: + websocket.enableTrace(True) + options = {} + if args.proxy: + p = urlparse(args.proxy) + options["http_proxy_host"] = p.hostname + options["http_proxy_port"] = p.port + if args.origin: + options["origin"] = args.origin + if args.subprotocols: + options["subprotocols"] = args.subprotocols + opts = {} + if args.nocert: + opts = {"cert_reqs": ssl.CERT_NONE, "check_hostname": False} + if args.headers: + options["header"] = list(map(str.strip, args.headers.split(","))) + ws = websocket.create_connection(args.url, sslopt=opts, **options) + if args.raw: + console = NonInteractive() + else: + console = InteractiveConsole() + print("Press Ctrl+C to quit") + + def recv() -> tuple: + try: + frame = ws.recv_frame() + except websocket.WebSocketException: + return websocket.ABNF.OPCODE_CLOSE, "" + if not frame: + raise websocket.WebSocketException(f"Not a valid frame {frame}") + elif frame.opcode in OPCODE_DATA: + return frame.opcode, frame.data + elif frame.opcode == websocket.ABNF.OPCODE_CLOSE: + ws.send_close() + return frame.opcode, "" + elif frame.opcode == websocket.ABNF.OPCODE_PING: + ws.pong(frame.data) + return frame.opcode, frame.data + + return frame.opcode, frame.data + + def recv_ws() -> None: + while True: + opcode, data = recv() + msg = None + if opcode == websocket.ABNF.OPCODE_TEXT and isinstance(data, bytes): + data = str(data, "utf-8") + if ( + isinstance(data, bytes) and len(data) > 2 and data[:2] == b"\037\213" + ): # gzip magick + try: + data = "[gzip] " + str(gzip.decompress(data), "utf-8") + except: + pass + elif isinstance(data, bytes): + try: + data = "[zlib] " + str( + zlib.decompress(data, -zlib.MAX_WBITS), "utf-8" + ) + except: + pass + + if isinstance(data, bytes): + data = repr(data) + + if args.verbose: + msg = f"{websocket.ABNF.OPCODE_MAP.get(opcode)}: {data}" + else: + msg = data + + if msg is not None: + if args.timings: + console.write(f"{time.time() - start_time}: {msg}") + else: + console.write(msg) + + if opcode == websocket.ABNF.OPCODE_CLOSE: + break + + thread = threading.Thread(target=recv_ws) + thread.daemon = True + thread.start() + + if args.text: + ws.send(args.text) + + while True: + try: + message = console.read() + ws.send(message) + except KeyboardInterrupt: + return + except EOFError: + time.sleep(args.eof_wait) + return + + +if __name__ == "__main__": + try: + main() + except Exception as e: + print(e) diff --git a/plugins/code/ui/lsp_manager/libs/websocket/py.typed b/plugins/code/ui/lsp_manager/libs/websocket/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/plugins/code/ui/lsp_manager/libs/websocket/tests/__init__.py b/plugins/code/ui/lsp_manager/libs/websocket/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/code/ui/lsp_manager/libs/websocket/tests/data/header01.txt b/plugins/code/ui/lsp_manager/libs/websocket/tests/data/header01.txt new file mode 100644 index 0000000..3142b43 --- /dev/null +++ b/plugins/code/ui/lsp_manager/libs/websocket/tests/data/header01.txt @@ -0,0 +1,6 @@ +HTTP/1.1 101 WebSocket Protocol Handshake +Connection: Upgrade +Upgrade: WebSocket +Sec-WebSocket-Accept: Kxep+hNu9n51529fGidYu7a3wO0= +some_header: something + diff --git a/plugins/code/ui/lsp_manager/libs/websocket/tests/data/header02.txt b/plugins/code/ui/lsp_manager/libs/websocket/tests/data/header02.txt new file mode 100644 index 0000000..a9dd2ce --- /dev/null +++ b/plugins/code/ui/lsp_manager/libs/websocket/tests/data/header02.txt @@ -0,0 +1,6 @@ +HTTP/1.1 101 WebSocket Protocol Handshake +Connection: Upgrade +Upgrade WebSocket +Sec-WebSocket-Accept: Kxep+hNu9n51529fGidYu7a3wO0= +some_header: something + diff --git a/plugins/code/ui/lsp_manager/libs/websocket/tests/data/header03.txt b/plugins/code/ui/lsp_manager/libs/websocket/tests/data/header03.txt new file mode 100644 index 0000000..1a81dc7 --- /dev/null +++ b/plugins/code/ui/lsp_manager/libs/websocket/tests/data/header03.txt @@ -0,0 +1,8 @@ +HTTP/1.1 101 WebSocket Protocol Handshake +Connection: Upgrade, Keep-Alive +Upgrade: WebSocket +Sec-WebSocket-Accept: Kxep+hNu9n51529fGidYu7a3wO0= +Set-Cookie: Token=ABCDE +Set-Cookie: Token=FGHIJ +some_header: something + diff --git a/plugins/code/ui/lsp_manager/libs/websocket/tests/echo-server.py b/plugins/code/ui/lsp_manager/libs/websocket/tests/echo-server.py new file mode 100644 index 0000000..5d1e870 --- /dev/null +++ b/plugins/code/ui/lsp_manager/libs/websocket/tests/echo-server.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python + +# From https://github.com/aaugustin/websockets/blob/main/example/echo.py + +import asyncio +import os + +import websockets + +LOCAL_WS_SERVER_PORT = int(os.environ.get("LOCAL_WS_SERVER_PORT", "8765")) + + +async def echo(websocket): + async for message in websocket: + await websocket.send(message) + + +async def main(): + async with websockets.serve(echo, "localhost", LOCAL_WS_SERVER_PORT): + await asyncio.Future() # run forever + + +asyncio.run(main()) diff --git a/plugins/code/ui/lsp_manager/libs/websocket/tests/test_abnf.py b/plugins/code/ui/lsp_manager/libs/websocket/tests/test_abnf.py new file mode 100644 index 0000000..a749f13 --- /dev/null +++ b/plugins/code/ui/lsp_manager/libs/websocket/tests/test_abnf.py @@ -0,0 +1,125 @@ +# -*- coding: utf-8 -*- +# +import unittest + +from websocket._abnf import ABNF, frame_buffer +from websocket._exceptions import WebSocketProtocolException + +""" +test_abnf.py +websocket - WebSocket client library for Python + +Copyright 2024 engn33r + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + + +class ABNFTest(unittest.TestCase): + def test_init(self): + a = ABNF(0, 0, 0, 0, opcode=ABNF.OPCODE_PING) + self.assertEqual(a.fin, 0) + self.assertEqual(a.rsv1, 0) + self.assertEqual(a.rsv2, 0) + self.assertEqual(a.rsv3, 0) + self.assertEqual(a.opcode, 9) + self.assertEqual(a.data, "") + a_bad = ABNF(0, 1, 0, 0, opcode=77) + self.assertEqual(a_bad.rsv1, 1) + self.assertEqual(a_bad.opcode, 77) + + def test_validate(self): + a_invalid_ping = ABNF(0, 0, 0, 0, opcode=ABNF.OPCODE_PING) + self.assertRaises( + WebSocketProtocolException, + a_invalid_ping.validate, + skip_utf8_validation=False, + ) + a_bad_rsv_value = ABNF(0, 1, 0, 0, opcode=ABNF.OPCODE_TEXT) + self.assertRaises( + WebSocketProtocolException, + a_bad_rsv_value.validate, + skip_utf8_validation=False, + ) + a_bad_opcode = ABNF(0, 0, 0, 0, opcode=77) + self.assertRaises( + WebSocketProtocolException, + a_bad_opcode.validate, + skip_utf8_validation=False, + ) + a_bad_close_frame = ABNF(0, 0, 0, 0, opcode=ABNF.OPCODE_CLOSE, data=b"\x01") + self.assertRaises( + WebSocketProtocolException, + a_bad_close_frame.validate, + skip_utf8_validation=False, + ) + a_bad_close_frame_2 = ABNF( + 0, 0, 0, 0, opcode=ABNF.OPCODE_CLOSE, data=b"\x01\x8a\xaa\xff\xdd" + ) + self.assertRaises( + WebSocketProtocolException, + a_bad_close_frame_2.validate, + skip_utf8_validation=False, + ) + a_bad_close_frame_3 = ABNF( + 0, 0, 0, 0, opcode=ABNF.OPCODE_CLOSE, data=b"\x03\xe7" + ) + self.assertRaises( + WebSocketProtocolException, + a_bad_close_frame_3.validate, + skip_utf8_validation=True, + ) + + def test_mask(self): + abnf_none_data = ABNF( + 0, 0, 0, 0, opcode=ABNF.OPCODE_PING, mask_value=1, data=None + ) + bytes_val = b"aaaa" + self.assertEqual(abnf_none_data._get_masked(bytes_val), bytes_val) + abnf_str_data = ABNF( + 0, 0, 0, 0, opcode=ABNF.OPCODE_PING, mask_value=1, data="a" + ) + self.assertEqual(abnf_str_data._get_masked(bytes_val), b"aaaa\x00") + + def test_format(self): + abnf_bad_rsv_bits = ABNF(2, 0, 0, 0, opcode=ABNF.OPCODE_TEXT) + self.assertRaises(ValueError, abnf_bad_rsv_bits.format) + abnf_bad_opcode = ABNF(0, 0, 0, 0, opcode=5) + self.assertRaises(ValueError, abnf_bad_opcode.format) + abnf_length_10 = ABNF(0, 0, 0, 0, opcode=ABNF.OPCODE_TEXT, data="abcdefghij") + self.assertEqual(b"\x01", abnf_length_10.format()[0].to_bytes(1, "big")) + self.assertEqual(b"\x8a", abnf_length_10.format()[1].to_bytes(1, "big")) + self.assertEqual("fin=0 opcode=1 data=abcdefghij", abnf_length_10.__str__()) + abnf_length_20 = ABNF( + 0, 0, 0, 0, opcode=ABNF.OPCODE_BINARY, data="abcdefghijabcdefghij" + ) + self.assertEqual(b"\x02", abnf_length_20.format()[0].to_bytes(1, "big")) + self.assertEqual(b"\x94", abnf_length_20.format()[1].to_bytes(1, "big")) + abnf_no_mask = ABNF( + 0, 0, 0, 0, opcode=ABNF.OPCODE_TEXT, mask_value=0, data=b"\x01\x8a\xcc" + ) + self.assertEqual(b"\x01\x03\x01\x8a\xcc", abnf_no_mask.format()) + + def test_frame_buffer(self): + fb = frame_buffer(0, True) + self.assertEqual(fb.recv, 0) + self.assertEqual(fb.skip_utf8_validation, True) + fb.clear + self.assertEqual(fb.header, None) + self.assertEqual(fb.length, None) + self.assertEqual(fb.mask_value, None) + self.assertEqual(fb.has_mask(), False) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/code/ui/lsp_manager/libs/websocket/tests/test_app.py b/plugins/code/ui/lsp_manager/libs/websocket/tests/test_app.py new file mode 100644 index 0000000..18eace5 --- /dev/null +++ b/plugins/code/ui/lsp_manager/libs/websocket/tests/test_app.py @@ -0,0 +1,352 @@ +# -*- coding: utf-8 -*- +# +import os +import os.path +import ssl +import threading +import unittest + +import websocket as ws + +""" +test_app.py +websocket - WebSocket client library for Python + +Copyright 2024 engn33r + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +# Skip test to access the internet unless TEST_WITH_INTERNET == 1 +TEST_WITH_INTERNET = os.environ.get("TEST_WITH_INTERNET", "0") == "1" +# Skip tests relying on local websockets server unless LOCAL_WS_SERVER_PORT != -1 +LOCAL_WS_SERVER_PORT = os.environ.get("LOCAL_WS_SERVER_PORT", "-1") +TEST_WITH_LOCAL_SERVER = LOCAL_WS_SERVER_PORT != "-1" +TRACEABLE = True + + +class WebSocketAppTest(unittest.TestCase): + class NotSetYet: + """A marker class for signalling that a value hasn't been set yet.""" + + def setUp(self): + ws.enableTrace(TRACEABLE) + + WebSocketAppTest.keep_running_open = WebSocketAppTest.NotSetYet() + WebSocketAppTest.keep_running_close = WebSocketAppTest.NotSetYet() + WebSocketAppTest.get_mask_key_id = WebSocketAppTest.NotSetYet() + WebSocketAppTest.on_error_data = WebSocketAppTest.NotSetYet() + + def tearDown(self): + WebSocketAppTest.keep_running_open = WebSocketAppTest.NotSetYet() + WebSocketAppTest.keep_running_close = WebSocketAppTest.NotSetYet() + WebSocketAppTest.get_mask_key_id = WebSocketAppTest.NotSetYet() + WebSocketAppTest.on_error_data = WebSocketAppTest.NotSetYet() + + def close(self): + pass + + @unittest.skipUnless( + TEST_WITH_LOCAL_SERVER, "Tests using local websocket server are disabled" + ) + def test_keep_running(self): + """A WebSocketApp should keep running as long as its self.keep_running + is not False (in the boolean context). + """ + + def on_open(self, *args, **kwargs): + """Set the keep_running flag for later inspection and immediately + close the connection. + """ + self.send("hello!") + WebSocketAppTest.keep_running_open = self.keep_running + self.keep_running = False + + def on_message(_, message): + print(message) + self.close() + + def on_close(self, *args, **kwargs): + """Set the keep_running flag for the test to use.""" + WebSocketAppTest.keep_running_close = self.keep_running + + app = ws.WebSocketApp( + f"ws://127.0.0.1:{LOCAL_WS_SERVER_PORT}", + on_open=on_open, + on_close=on_close, + on_message=on_message, + ) + app.run_forever() + + # @unittest.skipUnless(TEST_WITH_LOCAL_SERVER, "Tests using local websocket server are disabled") + @unittest.skipUnless(False, "Test disabled for now (requires rel)") + def test_run_forever_dispatcher(self): + """A WebSocketApp should keep running as long as its self.keep_running + is not False (in the boolean context). + """ + + def on_open(self, *args, **kwargs): + """Send a message, receive, and send one more""" + self.send("hello!") + self.recv() + self.send("goodbye!") + + def on_message(_, message): + print(message) + self.close() + + app = ws.WebSocketApp( + f"ws://127.0.0.1:{LOCAL_WS_SERVER_PORT}", + on_open=on_open, + on_message=on_message, + ) + app.run_forever(dispatcher="Dispatcher") # doesn't work + + # app.run_forever(dispatcher=rel) # would work + # rel.dispatch() + + @unittest.skipUnless( + TEST_WITH_LOCAL_SERVER, "Tests using local websocket server are disabled" + ) + def test_run_forever_teardown_clean_exit(self): + """The WebSocketApp.run_forever() method should return `False` when the application ends gracefully.""" + app = ws.WebSocketApp(f"ws://127.0.0.1:{LOCAL_WS_SERVER_PORT}") + threading.Timer(interval=0.2, function=app.close).start() + teardown = app.run_forever() + self.assertEqual(teardown, False) + + @unittest.skipUnless(TEST_WITH_INTERNET, "Internet-requiring tests are disabled") + def test_sock_mask_key(self): + """A WebSocketApp should forward the received mask_key function down + to the actual socket. + """ + + def my_mask_key_func(): + return "\x00\x00\x00\x00" + + app = ws.WebSocketApp( + "wss://api-pub.bitfinex.com/ws/1", get_mask_key=my_mask_key_func + ) + + # if numpy is installed, this assertion fail + # Note: We can't use 'is' for comparing the functions directly, need to use 'id'. + self.assertEqual(id(app.get_mask_key), id(my_mask_key_func)) + + @unittest.skipUnless(TEST_WITH_INTERNET, "Internet-requiring tests are disabled") + def test_invalid_ping_interval_ping_timeout(self): + """Test exception handling if ping_interval < ping_timeout""" + + def on_ping(app, _): + print("Got a ping!") + app.close() + + def on_pong(app, _): + print("Got a pong! No need to respond") + app.close() + + app = ws.WebSocketApp( + "wss://api-pub.bitfinex.com/ws/1", on_ping=on_ping, on_pong=on_pong + ) + self.assertRaises( + ws.WebSocketException, + app.run_forever, + ping_interval=1, + ping_timeout=2, + sslopt={"cert_reqs": ssl.CERT_NONE}, + ) + + @unittest.skipUnless(TEST_WITH_INTERNET, "Internet-requiring tests are disabled") + def test_ping_interval(self): + """Test WebSocketApp proper ping functionality""" + + def on_ping(app, _): + print("Got a ping!") + app.close() + + def on_pong(app, _): + print("Got a pong! No need to respond") + app.close() + + app = ws.WebSocketApp( + "wss://api-pub.bitfinex.com/ws/1", on_ping=on_ping, on_pong=on_pong + ) + app.run_forever( + ping_interval=2, ping_timeout=1, sslopt={"cert_reqs": ssl.CERT_NONE} + ) + + @unittest.skipUnless(TEST_WITH_INTERNET, "Internet-requiring tests are disabled") + def test_opcode_close(self): + """Test WebSocketApp close opcode""" + + app = ws.WebSocketApp("wss://tsock.us1.twilio.com/v3/wsconnect") + app.run_forever(ping_interval=2, ping_timeout=1, ping_payload="Ping payload") + + # This is commented out because the URL no longer responds in the expected way + # @unittest.skipUnless(TEST_WITH_INTERNET, "Internet-requiring tests are disabled") + # def testOpcodeBinary(self): + # """ Test WebSocketApp binary opcode + # """ + # app = ws.WebSocketApp('wss://streaming.vn.teslamotors.com/streaming/') + # app.run_forever(ping_interval=2, ping_timeout=1, ping_payload="Ping payload") + + @unittest.skipUnless(TEST_WITH_INTERNET, "Internet-requiring tests are disabled") + def test_bad_ping_interval(self): + """A WebSocketApp handling of negative ping_interval""" + app = ws.WebSocketApp("wss://api-pub.bitfinex.com/ws/1") + self.assertRaises( + ws.WebSocketException, + app.run_forever, + ping_interval=-5, + sslopt={"cert_reqs": ssl.CERT_NONE}, + ) + + @unittest.skipUnless(TEST_WITH_INTERNET, "Internet-requiring tests are disabled") + def test_bad_ping_timeout(self): + """A WebSocketApp handling of negative ping_timeout""" + app = ws.WebSocketApp("wss://api-pub.bitfinex.com/ws/1") + self.assertRaises( + ws.WebSocketException, + app.run_forever, + ping_timeout=-3, + sslopt={"cert_reqs": ssl.CERT_NONE}, + ) + + @unittest.skipUnless(TEST_WITH_INTERNET, "Internet-requiring tests are disabled") + def test_close_status_code(self): + """Test extraction of close frame status code and close reason in WebSocketApp""" + + def on_close(wsapp, close_status_code, close_msg): + print("on_close reached") + + app = ws.WebSocketApp( + "wss://tsock.us1.twilio.com/v3/wsconnect", on_close=on_close + ) + closeframe = ws.ABNF( + opcode=ws.ABNF.OPCODE_CLOSE, data=b"\x03\xe8no-init-from-client" + ) + self.assertEqual([1000, "no-init-from-client"], app._get_close_args(closeframe)) + + closeframe = ws.ABNF(opcode=ws.ABNF.OPCODE_CLOSE, data=b"") + self.assertEqual([None, None], app._get_close_args(closeframe)) + + app2 = ws.WebSocketApp("wss://tsock.us1.twilio.com/v3/wsconnect") + closeframe = ws.ABNF(opcode=ws.ABNF.OPCODE_CLOSE, data=b"") + self.assertEqual([None, None], app2._get_close_args(closeframe)) + + self.assertRaises( + ws.WebSocketConnectionClosedException, + app.send, + data="test if connection is closed", + ) + + @unittest.skipUnless( + TEST_WITH_LOCAL_SERVER, "Tests using local websocket server are disabled" + ) + def test_callback_function_exception(self): + """Test callback function exception handling""" + + exc = None + passed_app = None + + def on_open(app): + raise RuntimeError("Callback failed") + + def on_error(app, err): + nonlocal passed_app + passed_app = app + nonlocal exc + exc = err + + def on_pong(app, _): + app.close() + + app = ws.WebSocketApp( + f"ws://127.0.0.1:{LOCAL_WS_SERVER_PORT}", + on_open=on_open, + on_error=on_error, + on_pong=on_pong, + ) + app.run_forever(ping_interval=2, ping_timeout=1) + + self.assertEqual(passed_app, app) + self.assertIsInstance(exc, RuntimeError) + self.assertEqual(str(exc), "Callback failed") + + @unittest.skipUnless( + TEST_WITH_LOCAL_SERVER, "Tests using local websocket server are disabled" + ) + def test_callback_method_exception(self): + """Test callback method exception handling""" + + class Callbacks: + def __init__(self): + self.exc = None + self.passed_app = None + self.app = ws.WebSocketApp( + f"ws://127.0.0.1:{LOCAL_WS_SERVER_PORT}", + on_open=self.on_open, + on_error=self.on_error, + on_pong=self.on_pong, + ) + self.app.run_forever(ping_interval=2, ping_timeout=1) + + def on_open(self, _): + raise RuntimeError("Callback failed") + + def on_error(self, app, err): + self.passed_app = app + self.exc = err + + def on_pong(self, app, _): + app.close() + + callbacks = Callbacks() + + self.assertEqual(callbacks.passed_app, callbacks.app) + self.assertIsInstance(callbacks.exc, RuntimeError) + self.assertEqual(str(callbacks.exc), "Callback failed") + + @unittest.skipUnless( + TEST_WITH_LOCAL_SERVER, "Tests using local websocket server are disabled" + ) + def test_reconnect(self): + """Test reconnect""" + pong_count = 0 + exc = None + + def on_error(_, err): + nonlocal exc + exc = err + + def on_pong(app, _): + nonlocal pong_count + pong_count += 1 + if pong_count == 1: + # First pong, shutdown socket, enforce read error + app.sock.shutdown() + if pong_count >= 2: + # Got second pong after reconnect + app.close() + + app = ws.WebSocketApp( + f"ws://127.0.0.1:{LOCAL_WS_SERVER_PORT}", on_pong=on_pong, on_error=on_error + ) + app.run_forever(ping_interval=2, ping_timeout=1, reconnect=3) + + self.assertEqual(pong_count, 2) + self.assertIsInstance(exc, ws.WebSocketTimeoutException) + self.assertEqual(str(exc), "ping/pong timed out") + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/code/ui/lsp_manager/libs/websocket/tests/test_cookiejar.py b/plugins/code/ui/lsp_manager/libs/websocket/tests/test_cookiejar.py new file mode 100644 index 0000000..67eddb6 --- /dev/null +++ b/plugins/code/ui/lsp_manager/libs/websocket/tests/test_cookiejar.py @@ -0,0 +1,123 @@ +import unittest + +from websocket._cookiejar import SimpleCookieJar + +""" +test_cookiejar.py +websocket - WebSocket client library for Python + +Copyright 2024 engn33r + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + + +class CookieJarTest(unittest.TestCase): + def test_add(self): + cookie_jar = SimpleCookieJar() + cookie_jar.add("") + self.assertFalse( + cookie_jar.jar, "Cookie with no domain should not be added to the jar" + ) + + cookie_jar = SimpleCookieJar() + cookie_jar.add("a=b") + self.assertFalse( + cookie_jar.jar, "Cookie with no domain should not be added to the jar" + ) + + cookie_jar = SimpleCookieJar() + cookie_jar.add("a=b; domain=.abc") + self.assertTrue(".abc" in cookie_jar.jar) + + cookie_jar = SimpleCookieJar() + cookie_jar.add("a=b; domain=abc") + self.assertTrue(".abc" in cookie_jar.jar) + self.assertTrue("abc" not in cookie_jar.jar) + + cookie_jar = SimpleCookieJar() + cookie_jar.add("a=b; c=d; domain=abc") + self.assertEqual(cookie_jar.get("abc"), "a=b; c=d") + self.assertEqual(cookie_jar.get(None), "") + + cookie_jar = SimpleCookieJar() + cookie_jar.add("a=b; c=d; domain=abc") + cookie_jar.add("e=f; domain=abc") + self.assertEqual(cookie_jar.get("abc"), "a=b; c=d; e=f") + + cookie_jar = SimpleCookieJar() + cookie_jar.add("a=b; c=d; domain=abc") + cookie_jar.add("e=f; domain=.abc") + self.assertEqual(cookie_jar.get("abc"), "a=b; c=d; e=f") + + cookie_jar = SimpleCookieJar() + cookie_jar.add("a=b; c=d; domain=abc") + cookie_jar.add("e=f; domain=xyz") + self.assertEqual(cookie_jar.get("abc"), "a=b; c=d") + self.assertEqual(cookie_jar.get("xyz"), "e=f") + self.assertEqual(cookie_jar.get("something"), "") + + def test_set(self): + cookie_jar = SimpleCookieJar() + cookie_jar.set("a=b") + self.assertFalse( + cookie_jar.jar, "Cookie with no domain should not be added to the jar" + ) + + cookie_jar = SimpleCookieJar() + cookie_jar.set("a=b; domain=.abc") + self.assertTrue(".abc" in cookie_jar.jar) + + cookie_jar = SimpleCookieJar() + cookie_jar.set("a=b; domain=abc") + self.assertTrue(".abc" in cookie_jar.jar) + self.assertTrue("abc" not in cookie_jar.jar) + + cookie_jar = SimpleCookieJar() + cookie_jar.set("a=b; c=d; domain=abc") + self.assertEqual(cookie_jar.get("abc"), "a=b; c=d") + + cookie_jar = SimpleCookieJar() + cookie_jar.set("a=b; c=d; domain=abc") + cookie_jar.set("e=f; domain=abc") + self.assertEqual(cookie_jar.get("abc"), "e=f") + + cookie_jar = SimpleCookieJar() + cookie_jar.set("a=b; c=d; domain=abc") + cookie_jar.set("e=f; domain=.abc") + self.assertEqual(cookie_jar.get("abc"), "e=f") + + cookie_jar = SimpleCookieJar() + cookie_jar.set("a=b; c=d; domain=abc") + cookie_jar.set("e=f; domain=xyz") + self.assertEqual(cookie_jar.get("abc"), "a=b; c=d") + self.assertEqual(cookie_jar.get("xyz"), "e=f") + self.assertEqual(cookie_jar.get("something"), "") + + def test_get(self): + cookie_jar = SimpleCookieJar() + cookie_jar.set("a=b; c=d; domain=abc.com") + self.assertEqual(cookie_jar.get("abc.com"), "a=b; c=d") + self.assertEqual(cookie_jar.get("x.abc.com"), "a=b; c=d") + self.assertEqual(cookie_jar.get("abc.com.es"), "") + self.assertEqual(cookie_jar.get("xabc.com"), "") + + cookie_jar.set("a=b; c=d; domain=.abc.com") + self.assertEqual(cookie_jar.get("abc.com"), "a=b; c=d") + self.assertEqual(cookie_jar.get("x.abc.com"), "a=b; c=d") + self.assertEqual(cookie_jar.get("abc.com.es"), "") + self.assertEqual(cookie_jar.get("xabc.com"), "") + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/code/ui/lsp_manager/libs/websocket/tests/test_http.py b/plugins/code/ui/lsp_manager/libs/websocket/tests/test_http.py new file mode 100644 index 0000000..f495e63 --- /dev/null +++ b/plugins/code/ui/lsp_manager/libs/websocket/tests/test_http.py @@ -0,0 +1,370 @@ +# -*- coding: utf-8 -*- +# +import os +import os.path +import socket +import ssl +import unittest + +import websocket +from websocket._exceptions import WebSocketProxyException, WebSocketException +from websocket._http import ( + _get_addrinfo_list, + _start_proxied_socket, + _tunnel, + connect, + proxy_info, + read_headers, + HAVE_PYTHON_SOCKS, +) + +""" +test_http.py +websocket - WebSocket client library for Python + +Copyright 2024 engn33r + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +try: + from python_socks._errors import ProxyConnectionError, ProxyError, ProxyTimeoutError +except: + from websocket._http import ProxyConnectionError, ProxyError, ProxyTimeoutError + +# Skip test to access the internet unless TEST_WITH_INTERNET == 1 +TEST_WITH_INTERNET = os.environ.get("TEST_WITH_INTERNET", "0") == "1" +TEST_WITH_PROXY = os.environ.get("TEST_WITH_PROXY", "0") == "1" +# Skip tests relying on local websockets server unless LOCAL_WS_SERVER_PORT != -1 +LOCAL_WS_SERVER_PORT = os.environ.get("LOCAL_WS_SERVER_PORT", "-1") +TEST_WITH_LOCAL_SERVER = LOCAL_WS_SERVER_PORT != "-1" + + +class SockMock: + def __init__(self): + self.data = [] + self.sent = [] + + def add_packet(self, data): + self.data.append(data) + + def gettimeout(self): + return None + + def recv(self, bufsize): + if self.data: + e = self.data.pop(0) + if isinstance(e, Exception): + raise e + if len(e) > bufsize: + self.data.insert(0, e[bufsize:]) + return e[:bufsize] + + def send(self, data): + self.sent.append(data) + return len(data) + + def close(self): + pass + + +class HeaderSockMock(SockMock): + def __init__(self, fname): + SockMock.__init__(self) + path = os.path.join(os.path.dirname(__file__), fname) + with open(path, "rb") as f: + self.add_packet(f.read()) + + +class OptsList: + def __init__(self): + self.timeout = 1 + self.sockopt = [] + self.sslopt = {"cert_reqs": ssl.CERT_NONE} + + +class HttpTest(unittest.TestCase): + def test_read_header(self): + status, header, _ = read_headers(HeaderSockMock("data/header01.txt")) + self.assertEqual(status, 101) + self.assertEqual(header["connection"], "Upgrade") + # header02.txt is intentionally malformed + self.assertRaises( + WebSocketException, read_headers, HeaderSockMock("data/header02.txt") + ) + + def test_tunnel(self): + self.assertRaises( + WebSocketProxyException, + _tunnel, + HeaderSockMock("data/header01.txt"), + "example.com", + 80, + ("username", "password"), + ) + self.assertRaises( + WebSocketProxyException, + _tunnel, + HeaderSockMock("data/header02.txt"), + "example.com", + 80, + ("username", "password"), + ) + + @unittest.skipUnless(TEST_WITH_INTERNET, "Internet-requiring tests are disabled") + def test_connect(self): + # Not currently testing an actual proxy connection, so just check whether proxy errors are raised. This requires internet for a DNS lookup + if HAVE_PYTHON_SOCKS: + # Need this check, otherwise case where python_socks is not installed triggers + # websocket._exceptions.WebSocketException: Python Socks is needed for SOCKS proxying but is not available + self.assertRaises( + (ProxyTimeoutError, OSError), + _start_proxied_socket, + "wss://example.com", + OptsList(), + proxy_info( + http_proxy_host="example.com", + http_proxy_port="8080", + proxy_type="socks4", + http_proxy_timeout=1, + ), + ) + self.assertRaises( + (ProxyTimeoutError, OSError), + _start_proxied_socket, + "wss://example.com", + OptsList(), + proxy_info( + http_proxy_host="example.com", + http_proxy_port="8080", + proxy_type="socks4a", + http_proxy_timeout=1, + ), + ) + self.assertRaises( + (ProxyTimeoutError, OSError), + _start_proxied_socket, + "wss://example.com", + OptsList(), + proxy_info( + http_proxy_host="example.com", + http_proxy_port="8080", + proxy_type="socks5", + http_proxy_timeout=1, + ), + ) + self.assertRaises( + (ProxyTimeoutError, OSError), + _start_proxied_socket, + "wss://example.com", + OptsList(), + proxy_info( + http_proxy_host="example.com", + http_proxy_port="8080", + proxy_type="socks5h", + http_proxy_timeout=1, + ), + ) + self.assertRaises( + ProxyConnectionError, + connect, + "wss://example.com", + OptsList(), + proxy_info( + http_proxy_host="127.0.0.1", + http_proxy_port=9999, + proxy_type="socks4", + http_proxy_timeout=1, + ), + None, + ) + + self.assertRaises( + TypeError, + _get_addrinfo_list, + None, + 80, + True, + proxy_info( + http_proxy_host="127.0.0.1", http_proxy_port="9999", proxy_type="http" + ), + ) + self.assertRaises( + TypeError, + _get_addrinfo_list, + None, + 80, + True, + proxy_info( + http_proxy_host="127.0.0.1", http_proxy_port="9999", proxy_type="http" + ), + ) + self.assertRaises( + socket.timeout, + connect, + "wss://google.com", + OptsList(), + proxy_info( + http_proxy_host="8.8.8.8", + http_proxy_port=9999, + proxy_type="http", + http_proxy_timeout=1, + ), + None, + ) + self.assertEqual( + connect( + "wss://google.com", + OptsList(), + proxy_info( + http_proxy_host="8.8.8.8", http_proxy_port=8080, proxy_type="http" + ), + True, + ), + (True, ("google.com", 443, "/")), + ) + # The following test fails on Mac OS with a gaierror, not an OverflowError + # self.assertRaises(OverflowError, connect, "wss://example.com", OptsList(), proxy_info(http_proxy_host="127.0.0.1", http_proxy_port=99999, proxy_type="socks4", timeout=2), False) + + @unittest.skipUnless(TEST_WITH_INTERNET, "Internet-requiring tests are disabled") + @unittest.skipUnless( + TEST_WITH_PROXY, "This test requires a HTTP proxy to be running on port 8899" + ) + @unittest.skipUnless( + TEST_WITH_LOCAL_SERVER, "Tests using local websocket server are disabled" + ) + def test_proxy_connect(self): + ws = websocket.WebSocket() + ws.connect( + f"ws://127.0.0.1:{LOCAL_WS_SERVER_PORT}", + http_proxy_host="127.0.0.1", + http_proxy_port="8899", + proxy_type="http", + ) + ws.send("Hello, Server") + server_response = ws.recv() + self.assertEqual(server_response, "Hello, Server") + # self.assertEqual(_start_proxied_socket("wss://api.bitfinex.com/ws/2", OptsList(), proxy_info(http_proxy_host="127.0.0.1", http_proxy_port="8899", proxy_type="http"))[1], ("api.bitfinex.com", 443, '/ws/2')) + self.assertEqual( + _get_addrinfo_list( + "api.bitfinex.com", + 443, + True, + proxy_info( + http_proxy_host="127.0.0.1", + http_proxy_port="8899", + proxy_type="http", + ), + ), + ( + socket.getaddrinfo( + "127.0.0.1", 8899, 0, socket.SOCK_STREAM, socket.SOL_TCP + ), + True, + None, + ), + ) + self.assertEqual( + connect( + "wss://api.bitfinex.com/ws/2", + OptsList(), + proxy_info( + http_proxy_host="127.0.0.1", http_proxy_port=8899, proxy_type="http" + ), + None, + )[1], + ("api.bitfinex.com", 443, "/ws/2"), + ) + # TODO: Test SOCKS4 and SOCK5 proxies with unit tests + + @unittest.skipUnless(TEST_WITH_INTERNET, "Internet-requiring tests are disabled") + def test_sslopt(self): + ssloptions = { + "check_hostname": False, + "server_hostname": "ServerName", + "ssl_version": ssl.PROTOCOL_TLS_CLIENT, + "ciphers": "TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:\ + TLS_AES_128_GCM_SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:\ + ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384:\ + ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:\ + DHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:\ + ECDHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES128-GCM-SHA256:\ + ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:\ + DHE-RSA-AES256-SHA256:ECDHE-ECDSA-AES128-SHA256:\ + ECDHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA256:\ + ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA", + "ecdh_curve": "prime256v1", + } + ws_ssl1 = websocket.WebSocket(sslopt=ssloptions) + ws_ssl1.connect("wss://api.bitfinex.com/ws/2") + ws_ssl1.send("Hello") + ws_ssl1.close() + + ws_ssl2 = websocket.WebSocket(sslopt={"check_hostname": True}) + ws_ssl2.connect("wss://api.bitfinex.com/ws/2") + ws_ssl2.close + + def test_proxy_info(self): + self.assertEqual( + proxy_info( + http_proxy_host="127.0.0.1", http_proxy_port="8080", proxy_type="http" + ).proxy_protocol, + "http", + ) + self.assertRaises( + ProxyError, + proxy_info, + http_proxy_host="127.0.0.1", + http_proxy_port="8080", + proxy_type="badval", + ) + self.assertEqual( + proxy_info( + http_proxy_host="example.com", http_proxy_port="8080", proxy_type="http" + ).proxy_host, + "example.com", + ) + self.assertEqual( + proxy_info( + http_proxy_host="127.0.0.1", http_proxy_port="8080", proxy_type="http" + ).proxy_port, + "8080", + ) + self.assertEqual( + proxy_info( + http_proxy_host="127.0.0.1", http_proxy_port="8080", proxy_type="http" + ).auth, + None, + ) + self.assertEqual( + proxy_info( + http_proxy_host="127.0.0.1", + http_proxy_port="8080", + proxy_type="http", + http_proxy_auth=("my_username123", "my_pass321"), + ).auth[0], + "my_username123", + ) + self.assertEqual( + proxy_info( + http_proxy_host="127.0.0.1", + http_proxy_port="8080", + proxy_type="http", + http_proxy_auth=("my_username123", "my_pass321"), + ).auth[1], + "my_pass321", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/code/ui/lsp_manager/libs/websocket/tests/test_url.py b/plugins/code/ui/lsp_manager/libs/websocket/tests/test_url.py new file mode 100644 index 0000000..ae1a9cc --- /dev/null +++ b/plugins/code/ui/lsp_manager/libs/websocket/tests/test_url.py @@ -0,0 +1,464 @@ +# -*- coding: utf-8 -*- +# +import os +import unittest + +from websocket._url import ( + _is_address_in_network, + _is_no_proxy_host, + get_proxy_info, + parse_url, +) +from websocket._exceptions import WebSocketProxyException + +""" +test_url.py +websocket - WebSocket client library for Python + +Copyright 2024 engn33r + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + + +class UrlTest(unittest.TestCase): + def test_address_in_network(self): + self.assertTrue(_is_address_in_network("127.0.0.1", "127.0.0.0/8")) + self.assertTrue(_is_address_in_network("127.1.0.1", "127.0.0.0/8")) + self.assertFalse(_is_address_in_network("127.1.0.1", "127.0.0.0/24")) + + def test_parse_url(self): + p = parse_url("ws://www.example.com/r") + self.assertEqual(p[0], "www.example.com") + self.assertEqual(p[1], 80) + self.assertEqual(p[2], "/r") + self.assertEqual(p[3], False) + + p = parse_url("ws://www.example.com/r/") + self.assertEqual(p[0], "www.example.com") + self.assertEqual(p[1], 80) + self.assertEqual(p[2], "/r/") + self.assertEqual(p[3], False) + + p = parse_url("ws://www.example.com/") + self.assertEqual(p[0], "www.example.com") + self.assertEqual(p[1], 80) + self.assertEqual(p[2], "/") + self.assertEqual(p[3], False) + + p = parse_url("ws://www.example.com") + self.assertEqual(p[0], "www.example.com") + self.assertEqual(p[1], 80) + self.assertEqual(p[2], "/") + self.assertEqual(p[3], False) + + p = parse_url("ws://www.example.com:8080/r") + self.assertEqual(p[0], "www.example.com") + self.assertEqual(p[1], 8080) + self.assertEqual(p[2], "/r") + self.assertEqual(p[3], False) + + p = parse_url("ws://www.example.com:8080/") + self.assertEqual(p[0], "www.example.com") + self.assertEqual(p[1], 8080) + self.assertEqual(p[2], "/") + self.assertEqual(p[3], False) + + p = parse_url("ws://www.example.com:8080") + self.assertEqual(p[0], "www.example.com") + self.assertEqual(p[1], 8080) + self.assertEqual(p[2], "/") + self.assertEqual(p[3], False) + + p = parse_url("wss://www.example.com:8080/r") + self.assertEqual(p[0], "www.example.com") + self.assertEqual(p[1], 8080) + self.assertEqual(p[2], "/r") + self.assertEqual(p[3], True) + + p = parse_url("wss://www.example.com:8080/r?key=value") + self.assertEqual(p[0], "www.example.com") + self.assertEqual(p[1], 8080) + self.assertEqual(p[2], "/r?key=value") + self.assertEqual(p[3], True) + + self.assertRaises(ValueError, parse_url, "http://www.example.com/r") + + p = parse_url("ws://[2a03:4000:123:83::3]/r") + self.assertEqual(p[0], "2a03:4000:123:83::3") + self.assertEqual(p[1], 80) + self.assertEqual(p[2], "/r") + self.assertEqual(p[3], False) + + p = parse_url("ws://[2a03:4000:123:83::3]:8080/r") + self.assertEqual(p[0], "2a03:4000:123:83::3") + self.assertEqual(p[1], 8080) + self.assertEqual(p[2], "/r") + self.assertEqual(p[3], False) + + p = parse_url("wss://[2a03:4000:123:83::3]/r") + self.assertEqual(p[0], "2a03:4000:123:83::3") + self.assertEqual(p[1], 443) + self.assertEqual(p[2], "/r") + self.assertEqual(p[3], True) + + p = parse_url("wss://[2a03:4000:123:83::3]:8080/r") + self.assertEqual(p[0], "2a03:4000:123:83::3") + self.assertEqual(p[1], 8080) + self.assertEqual(p[2], "/r") + self.assertEqual(p[3], True) + + +class IsNoProxyHostTest(unittest.TestCase): + def setUp(self): + self.no_proxy = os.environ.get("no_proxy", None) + if "no_proxy" in os.environ: + del os.environ["no_proxy"] + + def tearDown(self): + if self.no_proxy: + os.environ["no_proxy"] = self.no_proxy + elif "no_proxy" in os.environ: + del os.environ["no_proxy"] + + def test_match_all(self): + self.assertTrue(_is_no_proxy_host("any.websocket.org", ["*"])) + self.assertTrue(_is_no_proxy_host("192.168.0.1", ["*"])) + self.assertFalse(_is_no_proxy_host("192.168.0.1", ["192.168.1.1"])) + self.assertFalse( + _is_no_proxy_host("any.websocket.org", ["other.websocket.org"]) + ) + self.assertTrue( + _is_no_proxy_host("any.websocket.org", ["other.websocket.org", "*"]) + ) + os.environ["no_proxy"] = "*" + self.assertTrue(_is_no_proxy_host("any.websocket.org", None)) + self.assertTrue(_is_no_proxy_host("192.168.0.1", None)) + os.environ["no_proxy"] = "other.websocket.org, *" + self.assertTrue(_is_no_proxy_host("any.websocket.org", None)) + + def test_ip_address(self): + self.assertTrue(_is_no_proxy_host("127.0.0.1", ["127.0.0.1"])) + self.assertFalse(_is_no_proxy_host("127.0.0.2", ["127.0.0.1"])) + self.assertTrue( + _is_no_proxy_host("127.0.0.1", ["other.websocket.org", "127.0.0.1"]) + ) + self.assertFalse( + _is_no_proxy_host("127.0.0.2", ["other.websocket.org", "127.0.0.1"]) + ) + os.environ["no_proxy"] = "127.0.0.1" + self.assertTrue(_is_no_proxy_host("127.0.0.1", None)) + self.assertFalse(_is_no_proxy_host("127.0.0.2", None)) + os.environ["no_proxy"] = "other.websocket.org, 127.0.0.1" + self.assertTrue(_is_no_proxy_host("127.0.0.1", None)) + self.assertFalse(_is_no_proxy_host("127.0.0.2", None)) + + def test_ip_address_in_range(self): + self.assertTrue(_is_no_proxy_host("127.0.0.1", ["127.0.0.0/8"])) + self.assertTrue(_is_no_proxy_host("127.0.0.2", ["127.0.0.0/8"])) + self.assertFalse(_is_no_proxy_host("127.1.0.1", ["127.0.0.0/24"])) + os.environ["no_proxy"] = "127.0.0.0/8" + self.assertTrue(_is_no_proxy_host("127.0.0.1", None)) + self.assertTrue(_is_no_proxy_host("127.0.0.2", None)) + os.environ["no_proxy"] = "127.0.0.0/24" + self.assertFalse(_is_no_proxy_host("127.1.0.1", None)) + + def test_hostname_match(self): + self.assertTrue(_is_no_proxy_host("my.websocket.org", ["my.websocket.org"])) + self.assertTrue( + _is_no_proxy_host( + "my.websocket.org", ["other.websocket.org", "my.websocket.org"] + ) + ) + self.assertFalse(_is_no_proxy_host("my.websocket.org", ["other.websocket.org"])) + os.environ["no_proxy"] = "my.websocket.org" + self.assertTrue(_is_no_proxy_host("my.websocket.org", None)) + self.assertFalse(_is_no_proxy_host("other.websocket.org", None)) + os.environ["no_proxy"] = "other.websocket.org, my.websocket.org" + self.assertTrue(_is_no_proxy_host("my.websocket.org", None)) + + def test_hostname_match_domain(self): + self.assertTrue(_is_no_proxy_host("any.websocket.org", [".websocket.org"])) + self.assertTrue(_is_no_proxy_host("my.other.websocket.org", [".websocket.org"])) + self.assertTrue( + _is_no_proxy_host( + "any.websocket.org", ["my.websocket.org", ".websocket.org"] + ) + ) + self.assertFalse(_is_no_proxy_host("any.websocket.com", [".websocket.org"])) + os.environ["no_proxy"] = ".websocket.org" + self.assertTrue(_is_no_proxy_host("any.websocket.org", None)) + self.assertTrue(_is_no_proxy_host("my.other.websocket.org", None)) + self.assertFalse(_is_no_proxy_host("any.websocket.com", None)) + os.environ["no_proxy"] = "my.websocket.org, .websocket.org" + self.assertTrue(_is_no_proxy_host("any.websocket.org", None)) + + +class ProxyInfoTest(unittest.TestCase): + def setUp(self): + self.http_proxy = os.environ.get("http_proxy", None) + self.https_proxy = os.environ.get("https_proxy", None) + self.no_proxy = os.environ.get("no_proxy", None) + if "http_proxy" in os.environ: + del os.environ["http_proxy"] + if "https_proxy" in os.environ: + del os.environ["https_proxy"] + if "no_proxy" in os.environ: + del os.environ["no_proxy"] + + def tearDown(self): + if self.http_proxy: + os.environ["http_proxy"] = self.http_proxy + elif "http_proxy" in os.environ: + del os.environ["http_proxy"] + + if self.https_proxy: + os.environ["https_proxy"] = self.https_proxy + elif "https_proxy" in os.environ: + del os.environ["https_proxy"] + + if self.no_proxy: + os.environ["no_proxy"] = self.no_proxy + elif "no_proxy" in os.environ: + del os.environ["no_proxy"] + + def test_proxy_from_args(self): + self.assertRaises( + WebSocketProxyException, + get_proxy_info, + "echo.websocket.events", + False, + proxy_host="localhost", + ) + self.assertEqual( + get_proxy_info( + "echo.websocket.events", False, proxy_host="localhost", proxy_port=3128 + ), + ("localhost", 3128, None), + ) + self.assertEqual( + get_proxy_info( + "echo.websocket.events", True, proxy_host="localhost", proxy_port=3128 + ), + ("localhost", 3128, None), + ) + + self.assertEqual( + get_proxy_info( + "echo.websocket.events", + False, + proxy_host="localhost", + proxy_port=9001, + proxy_auth=("a", "b"), + ), + ("localhost", 9001, ("a", "b")), + ) + self.assertEqual( + get_proxy_info( + "echo.websocket.events", + False, + proxy_host="localhost", + proxy_port=3128, + proxy_auth=("a", "b"), + ), + ("localhost", 3128, ("a", "b")), + ) + self.assertEqual( + get_proxy_info( + "echo.websocket.events", + True, + proxy_host="localhost", + proxy_port=8765, + proxy_auth=("a", "b"), + ), + ("localhost", 8765, ("a", "b")), + ) + self.assertEqual( + get_proxy_info( + "echo.websocket.events", + True, + proxy_host="localhost", + proxy_port=3128, + proxy_auth=("a", "b"), + ), + ("localhost", 3128, ("a", "b")), + ) + + self.assertEqual( + get_proxy_info( + "echo.websocket.events", + True, + proxy_host="localhost", + proxy_port=3128, + no_proxy=["example.com"], + proxy_auth=("a", "b"), + ), + ("localhost", 3128, ("a", "b")), + ) + self.assertEqual( + get_proxy_info( + "echo.websocket.events", + True, + proxy_host="localhost", + proxy_port=3128, + no_proxy=["echo.websocket.events"], + proxy_auth=("a", "b"), + ), + (None, 0, None), + ) + + self.assertEqual( + get_proxy_info( + "echo.websocket.events", + True, + proxy_host="localhost", + proxy_port=3128, + no_proxy=[".websocket.events"], + ), + (None, 0, None), + ) + + def test_proxy_from_env(self): + os.environ["http_proxy"] = "http://localhost/" + self.assertEqual( + get_proxy_info("echo.websocket.events", False), ("localhost", None, None) + ) + os.environ["http_proxy"] = "http://localhost:3128/" + self.assertEqual( + get_proxy_info("echo.websocket.events", False), ("localhost", 3128, None) + ) + + os.environ["http_proxy"] = "http://localhost/" + os.environ["https_proxy"] = "http://localhost2/" + self.assertEqual( + get_proxy_info("echo.websocket.events", False), ("localhost", None, None) + ) + os.environ["http_proxy"] = "http://localhost:3128/" + os.environ["https_proxy"] = "http://localhost2:3128/" + self.assertEqual( + get_proxy_info("echo.websocket.events", False), ("localhost", 3128, None) + ) + + os.environ["http_proxy"] = "http://localhost/" + os.environ["https_proxy"] = "http://localhost2/" + self.assertEqual( + get_proxy_info("echo.websocket.events", True), ("localhost2", None, None) + ) + os.environ["http_proxy"] = "http://localhost:3128/" + os.environ["https_proxy"] = "http://localhost2:3128/" + self.assertEqual( + get_proxy_info("echo.websocket.events", True), ("localhost2", 3128, None) + ) + + os.environ["http_proxy"] = "" + os.environ["https_proxy"] = "http://localhost2/" + self.assertEqual( + get_proxy_info("echo.websocket.events", True), ("localhost2", None, None) + ) + self.assertEqual( + get_proxy_info("echo.websocket.events", False), (None, 0, None) + ) + os.environ["http_proxy"] = "" + os.environ["https_proxy"] = "http://localhost2:3128/" + self.assertEqual( + get_proxy_info("echo.websocket.events", True), ("localhost2", 3128, None) + ) + self.assertEqual( + get_proxy_info("echo.websocket.events", False), (None, 0, None) + ) + + os.environ["http_proxy"] = "http://localhost/" + os.environ["https_proxy"] = "" + self.assertEqual(get_proxy_info("echo.websocket.events", True), (None, 0, None)) + self.assertEqual( + get_proxy_info("echo.websocket.events", False), ("localhost", None, None) + ) + os.environ["http_proxy"] = "http://localhost:3128/" + os.environ["https_proxy"] = "" + self.assertEqual(get_proxy_info("echo.websocket.events", True), (None, 0, None)) + self.assertEqual( + get_proxy_info("echo.websocket.events", False), ("localhost", 3128, None) + ) + + os.environ["http_proxy"] = "http://a:b@localhost/" + self.assertEqual( + get_proxy_info("echo.websocket.events", False), + ("localhost", None, ("a", "b")), + ) + os.environ["http_proxy"] = "http://a:b@localhost:3128/" + self.assertEqual( + get_proxy_info("echo.websocket.events", False), + ("localhost", 3128, ("a", "b")), + ) + + os.environ["http_proxy"] = "http://a:b@localhost/" + os.environ["https_proxy"] = "http://a:b@localhost2/" + self.assertEqual( + get_proxy_info("echo.websocket.events", False), + ("localhost", None, ("a", "b")), + ) + os.environ["http_proxy"] = "http://a:b@localhost:3128/" + os.environ["https_proxy"] = "http://a:b@localhost2:3128/" + self.assertEqual( + get_proxy_info("echo.websocket.events", False), + ("localhost", 3128, ("a", "b")), + ) + + os.environ["http_proxy"] = "http://a:b@localhost/" + os.environ["https_proxy"] = "http://a:b@localhost2/" + self.assertEqual( + get_proxy_info("echo.websocket.events", True), + ("localhost2", None, ("a", "b")), + ) + os.environ["http_proxy"] = "http://a:b@localhost:3128/" + os.environ["https_proxy"] = "http://a:b@localhost2:3128/" + self.assertEqual( + get_proxy_info("echo.websocket.events", True), + ("localhost2", 3128, ("a", "b")), + ) + + os.environ["http_proxy"] = ( + "http://john%40example.com:P%40SSWORD@localhost:3128/" + ) + os.environ["https_proxy"] = ( + "http://john%40example.com:P%40SSWORD@localhost2:3128/" + ) + self.assertEqual( + get_proxy_info("echo.websocket.events", True), + ("localhost2", 3128, ("john@example.com", "P@SSWORD")), + ) + + os.environ["http_proxy"] = "http://a:b@localhost/" + os.environ["https_proxy"] = "http://a:b@localhost2/" + os.environ["no_proxy"] = "example1.com,example2.com" + self.assertEqual( + get_proxy_info("example.1.com", True), ("localhost2", None, ("a", "b")) + ) + os.environ["http_proxy"] = "http://a:b@localhost:3128/" + os.environ["https_proxy"] = "http://a:b@localhost2:3128/" + os.environ["no_proxy"] = "example1.com,example2.com, echo.websocket.events" + self.assertEqual(get_proxy_info("echo.websocket.events", True), (None, 0, None)) + os.environ["http_proxy"] = "http://a:b@localhost:3128/" + os.environ["https_proxy"] = "http://a:b@localhost2:3128/" + os.environ["no_proxy"] = "example1.com,example2.com, .websocket.events" + self.assertEqual(get_proxy_info("echo.websocket.events", True), (None, 0, None)) + + os.environ["http_proxy"] = "http://a:b@localhost:3128/" + os.environ["https_proxy"] = "http://a:b@localhost2:3128/" + os.environ["no_proxy"] = "127.0.0.0/8, 192.168.0.0/16" + self.assertEqual(get_proxy_info("127.0.0.1", False), (None, 0, None)) + self.assertEqual(get_proxy_info("192.168.1.1", False), (None, 0, None)) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/code/ui/lsp_manager/libs/websocket/tests/test_websocket.py b/plugins/code/ui/lsp_manager/libs/websocket/tests/test_websocket.py new file mode 100644 index 0000000..a1d7ad5 --- /dev/null +++ b/plugins/code/ui/lsp_manager/libs/websocket/tests/test_websocket.py @@ -0,0 +1,497 @@ +# -*- coding: utf-8 -*- +# +import os +import os.path +import socket +import unittest +from base64 import decodebytes as base64decode + +import websocket as ws +from websocket._exceptions import WebSocketBadStatusException, WebSocketAddressException +from websocket._handshake import _create_sec_websocket_key +from websocket._handshake import _validate as _validate_header +from websocket._http import read_headers +from websocket._utils import validate_utf8 + +""" +test_websocket.py +websocket - WebSocket client library for Python + +Copyright 2024 engn33r + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +try: + import ssl +except ImportError: + # dummy class of SSLError for ssl none-support environment. + class SSLError(Exception): + pass + + +# Skip test to access the internet unless TEST_WITH_INTERNET == 1 +TEST_WITH_INTERNET = os.environ.get("TEST_WITH_INTERNET", "0") == "1" +# Skip tests relying on local websockets server unless LOCAL_WS_SERVER_PORT != -1 +LOCAL_WS_SERVER_PORT = os.environ.get("LOCAL_WS_SERVER_PORT", "-1") +TEST_WITH_LOCAL_SERVER = LOCAL_WS_SERVER_PORT != "-1" +TRACEABLE = True + + +def create_mask_key(_): + return "abcd" + + +class SockMock: + def __init__(self): + self.data = [] + self.sent = [] + + def add_packet(self, data): + self.data.append(data) + + def gettimeout(self): + return None + + def recv(self, bufsize): + if self.data: + e = self.data.pop(0) + if isinstance(e, Exception): + raise e + if len(e) > bufsize: + self.data.insert(0, e[bufsize:]) + return e[:bufsize] + + def send(self, data): + self.sent.append(data) + return len(data) + + def close(self): + pass + + +class HeaderSockMock(SockMock): + def __init__(self, fname): + SockMock.__init__(self) + path = os.path.join(os.path.dirname(__file__), fname) + with open(path, "rb") as f: + self.add_packet(f.read()) + + +class WebSocketTest(unittest.TestCase): + def setUp(self): + ws.enableTrace(TRACEABLE) + + def tearDown(self): + pass + + def test_default_timeout(self): + self.assertEqual(ws.getdefaulttimeout(), None) + ws.setdefaulttimeout(10) + self.assertEqual(ws.getdefaulttimeout(), 10) + ws.setdefaulttimeout(None) + + def test_ws_key(self): + key = _create_sec_websocket_key() + self.assertTrue(key != 24) + self.assertTrue("¥n" not in key) + + def test_nonce(self): + """WebSocket key should be a random 16-byte nonce.""" + key = _create_sec_websocket_key() + nonce = base64decode(key.encode("utf-8")) + self.assertEqual(16, len(nonce)) + + def test_ws_utils(self): + key = "c6b8hTg4EeGb2gQMztV1/g==" + required_header = { + "upgrade": "websocket", + "connection": "upgrade", + "sec-websocket-accept": "Kxep+hNu9n51529fGidYu7a3wO0=", + } + self.assertEqual(_validate_header(required_header, key, None), (True, None)) + + header = required_header.copy() + header["upgrade"] = "http" + self.assertEqual(_validate_header(header, key, None), (False, None)) + del header["upgrade"] + self.assertEqual(_validate_header(header, key, None), (False, None)) + + header = required_header.copy() + header["connection"] = "something" + self.assertEqual(_validate_header(header, key, None), (False, None)) + del header["connection"] + self.assertEqual(_validate_header(header, key, None), (False, None)) + + header = required_header.copy() + header["sec-websocket-accept"] = "something" + self.assertEqual(_validate_header(header, key, None), (False, None)) + del header["sec-websocket-accept"] + self.assertEqual(_validate_header(header, key, None), (False, None)) + + header = required_header.copy() + header["sec-websocket-protocol"] = "sub1" + self.assertEqual( + _validate_header(header, key, ["sub1", "sub2"]), (True, "sub1") + ) + # This case will print out a logging error using the error() function, but that is expected + self.assertEqual(_validate_header(header, key, ["sub2", "sub3"]), (False, None)) + + header = required_header.copy() + header["sec-websocket-protocol"] = "sUb1" + self.assertEqual( + _validate_header(header, key, ["Sub1", "suB2"]), (True, "sub1") + ) + + header = required_header.copy() + # This case will print out a logging error using the error() function, but that is expected + self.assertEqual(_validate_header(header, key, ["Sub1", "suB2"]), (False, None)) + + def test_read_header(self): + status, header, _ = read_headers(HeaderSockMock("data/header01.txt")) + self.assertEqual(status, 101) + self.assertEqual(header["connection"], "Upgrade") + + status, header, _ = read_headers(HeaderSockMock("data/header03.txt")) + self.assertEqual(status, 101) + self.assertEqual(header["connection"], "Upgrade, Keep-Alive") + + HeaderSockMock("data/header02.txt") + self.assertRaises( + ws.WebSocketException, read_headers, HeaderSockMock("data/header02.txt") + ) + + def test_send(self): + # TODO: add longer frame data + sock = ws.WebSocket() + sock.set_mask_key(create_mask_key) + s = sock.sock = HeaderSockMock("data/header01.txt") + sock.send("Hello") + self.assertEqual(s.sent[0], b"\x81\x85abcd)\x07\x0f\x08\x0e") + + sock.send("こんにちは") + self.assertEqual( + s.sent[1], + b"\x81\x8fabcd\x82\xe3\xf0\x87\xe3\xf1\x80\xe5\xca\x81\xe2\xc5\x82\xe3\xcc", + ) + + # sock.send("x" * 5000) + # self.assertEqual(s.sent[1], b'\x81\x8fabcd\x82\xe3\xf0\x87\xe3\xf1\x80\xe5\xca\x81\xe2\xc5\x82\xe3\xcc") + + self.assertEqual(sock.send_binary(b"1111111111101"), 19) + + def test_recv(self): + # TODO: add longer frame data + sock = ws.WebSocket() + s = sock.sock = SockMock() + something = ( + b"\x81\x8fabcd\x82\xe3\xf0\x87\xe3\xf1\x80\xe5\xca\x81\xe2\xc5\x82\xe3\xcc" + ) + s.add_packet(something) + data = sock.recv() + self.assertEqual(data, "こんにちは") + + s.add_packet(b"\x81\x85abcd)\x07\x0f\x08\x0e") + data = sock.recv() + self.assertEqual(data, "Hello") + + @unittest.skipUnless(TEST_WITH_INTERNET, "Internet-requiring tests are disabled") + def test_iter(self): + count = 2 + s = ws.create_connection("wss://api.bitfinex.com/ws/2") + s.send('{"event": "subscribe", "channel": "ticker"}') + for _ in s: + count -= 1 + if count == 0: + break + + @unittest.skipUnless(TEST_WITH_INTERNET, "Internet-requiring tests are disabled") + def test_next(self): + sock = ws.create_connection("wss://api.bitfinex.com/ws/2") + self.assertEqual(str, type(next(sock))) + + def test_internal_recv_strict(self): + sock = ws.WebSocket() + s = sock.sock = SockMock() + s.add_packet(b"foo") + s.add_packet(socket.timeout()) + s.add_packet(b"bar") + # s.add_packet(SSLError("The read operation timed out")) + s.add_packet(b"baz") + with self.assertRaises(ws.WebSocketTimeoutException): + sock.frame_buffer.recv_strict(9) + # with self.assertRaises(SSLError): + # data = sock._recv_strict(9) + data = sock.frame_buffer.recv_strict(9) + self.assertEqual(data, b"foobarbaz") + with self.assertRaises(ws.WebSocketConnectionClosedException): + sock.frame_buffer.recv_strict(1) + + def test_recv_timeout(self): + sock = ws.WebSocket() + s = sock.sock = SockMock() + s.add_packet(b"\x81") + s.add_packet(socket.timeout()) + s.add_packet(b"\x8dabcd\x29\x07\x0f\x08\x0e") + s.add_packet(socket.timeout()) + s.add_packet(b"\x4e\x43\x33\x0e\x10\x0f\x00\x40") + with self.assertRaises(ws.WebSocketTimeoutException): + sock.recv() + with self.assertRaises(ws.WebSocketTimeoutException): + sock.recv() + data = sock.recv() + self.assertEqual(data, "Hello, World!") + with self.assertRaises(ws.WebSocketConnectionClosedException): + sock.recv() + + def test_recv_with_simple_fragmentation(self): + sock = ws.WebSocket() + s = sock.sock = SockMock() + # OPCODE=TEXT, FIN=0, MSG="Brevity is " + s.add_packet(b"\x01\x8babcd#\x10\x06\x12\x08\x16\x1aD\x08\x11C") + # OPCODE=CONT, FIN=1, MSG="the soul of wit" + s.add_packet(b"\x80\x8fabcd\x15\n\x06D\x12\r\x16\x08A\r\x05D\x16\x0b\x17") + data = sock.recv() + self.assertEqual(data, "Brevity is the soul of wit") + with self.assertRaises(ws.WebSocketConnectionClosedException): + sock.recv() + + def test_recv_with_fire_event_of_fragmentation(self): + sock = ws.WebSocket(fire_cont_frame=True) + s = sock.sock = SockMock() + # OPCODE=TEXT, FIN=0, MSG="Brevity is " + s.add_packet(b"\x01\x8babcd#\x10\x06\x12\x08\x16\x1aD\x08\x11C") + # OPCODE=CONT, FIN=0, MSG="Brevity is " + s.add_packet(b"\x00\x8babcd#\x10\x06\x12\x08\x16\x1aD\x08\x11C") + # OPCODE=CONT, FIN=1, MSG="the soul of wit" + s.add_packet(b"\x80\x8fabcd\x15\n\x06D\x12\r\x16\x08A\r\x05D\x16\x0b\x17") + + _, data = sock.recv_data() + self.assertEqual(data, b"Brevity is ") + _, data = sock.recv_data() + self.assertEqual(data, b"Brevity is ") + _, data = sock.recv_data() + self.assertEqual(data, b"the soul of wit") + + # OPCODE=CONT, FIN=0, MSG="Brevity is " + s.add_packet(b"\x80\x8babcd#\x10\x06\x12\x08\x16\x1aD\x08\x11C") + + with self.assertRaises(ws.WebSocketException): + sock.recv_data() + + with self.assertRaises(ws.WebSocketConnectionClosedException): + sock.recv() + + def test_close(self): + sock = ws.WebSocket() + sock.connected = True + sock.close + + sock = ws.WebSocket() + s = sock.sock = SockMock() + sock.connected = True + s.add_packet(b"\x88\x80\x17\x98p\x84") + sock.recv() + self.assertEqual(sock.connected, False) + + def test_recv_cont_fragmentation(self): + sock = ws.WebSocket() + s = sock.sock = SockMock() + # OPCODE=CONT, FIN=1, MSG="the soul of wit" + s.add_packet(b"\x80\x8fabcd\x15\n\x06D\x12\r\x16\x08A\r\x05D\x16\x0b\x17") + self.assertRaises(ws.WebSocketException, sock.recv) + + def test_recv_with_prolonged_fragmentation(self): + sock = ws.WebSocket() + s = sock.sock = SockMock() + # OPCODE=TEXT, FIN=0, MSG="Once more unto the breach, " + s.add_packet( + b"\x01\x9babcd.\x0c\x00\x01A\x0f\x0c\x16\x04B\x16\n\x15\rC\x10\t\x07C\x06\x13\x07\x02\x07\tNC" + ) + # OPCODE=CONT, FIN=0, MSG="dear friends, " + s.add_packet(b"\x00\x8eabcd\x05\x07\x02\x16A\x04\x11\r\x04\x0c\x07\x17MB") + # OPCODE=CONT, FIN=1, MSG="once more" + s.add_packet(b"\x80\x89abcd\x0e\x0c\x00\x01A\x0f\x0c\x16\x04") + data = sock.recv() + self.assertEqual(data, "Once more unto the breach, dear friends, once more") + with self.assertRaises(ws.WebSocketConnectionClosedException): + sock.recv() + + def test_recv_with_fragmentation_and_control_frame(self): + sock = ws.WebSocket() + sock.set_mask_key(create_mask_key) + s = sock.sock = SockMock() + # OPCODE=TEXT, FIN=0, MSG="Too much " + s.add_packet(b"\x01\x89abcd5\r\x0cD\x0c\x17\x00\x0cA") + # OPCODE=PING, FIN=1, MSG="Please PONG this" + s.add_packet(b"\x89\x90abcd1\x0e\x06\x05\x12\x07C4.,$D\x15\n\n\x17") + # OPCODE=CONT, FIN=1, MSG="of a good thing" + s.add_packet(b"\x80\x8fabcd\x0e\x04C\x05A\x05\x0c\x0b\x05B\x17\x0c\x08\x0c\x04") + data = sock.recv() + self.assertEqual(data, "Too much of a good thing") + with self.assertRaises(ws.WebSocketConnectionClosedException): + sock.recv() + self.assertEqual( + s.sent[0], b"\x8a\x90abcd1\x0e\x06\x05\x12\x07C4.,$D\x15\n\n\x17" + ) + + @unittest.skipUnless( + TEST_WITH_LOCAL_SERVER, "Tests using local websocket server are disabled" + ) + def test_websocket(self): + s = ws.create_connection(f"ws://127.0.0.1:{LOCAL_WS_SERVER_PORT}") + self.assertNotEqual(s, None) + s.send("Hello, World") + result = s.next() + s.fileno() + self.assertEqual(result, "Hello, World") + + s.send("こにゃにゃちは、世界") + result = s.recv() + self.assertEqual(result, "こにゃにゃちは、世界") + self.assertRaises(ValueError, s.send_close, -1, "") + s.close() + + @unittest.skipUnless( + TEST_WITH_LOCAL_SERVER, "Tests using local websocket server are disabled" + ) + def test_ping_pong(self): + s = ws.create_connection(f"ws://127.0.0.1:{LOCAL_WS_SERVER_PORT}") + self.assertNotEqual(s, None) + s.ping("Hello") + s.pong("Hi") + s.close() + + @unittest.skipUnless(TEST_WITH_INTERNET, "Internet-requiring tests are disabled") + def test_support_redirect(self): + s = ws.WebSocket() + self.assertRaises(WebSocketBadStatusException, s.connect, "ws://google.com/") + # Need to find a URL that has a redirect code leading to a websocket + + @unittest.skipUnless(TEST_WITH_INTERNET, "Internet-requiring tests are disabled") + def test_secure_websocket(self): + s = ws.create_connection("wss://api.bitfinex.com/ws/2") + self.assertNotEqual(s, None) + self.assertTrue(isinstance(s.sock, ssl.SSLSocket)) + self.assertEqual(s.getstatus(), 101) + self.assertNotEqual(s.getheaders(), None) + s.settimeout(10) + self.assertEqual(s.gettimeout(), 10) + self.assertEqual(s.getsubprotocol(), None) + s.abort() + + @unittest.skipUnless( + TEST_WITH_LOCAL_SERVER, "Tests using local websocket server are disabled" + ) + def test_websocket_with_custom_header(self): + s = ws.create_connection( + f"ws://127.0.0.1:{LOCAL_WS_SERVER_PORT}", + headers={"User-Agent": "PythonWebsocketClient"}, + ) + self.assertNotEqual(s, None) + self.assertEqual(s.getsubprotocol(), None) + s.send("Hello, World") + result = s.recv() + self.assertEqual(result, "Hello, World") + self.assertRaises(ValueError, s.close, -1, "") + s.close() + + @unittest.skipUnless( + TEST_WITH_LOCAL_SERVER, "Tests using local websocket server are disabled" + ) + def test_after_close(self): + s = ws.create_connection(f"ws://127.0.0.1:{LOCAL_WS_SERVER_PORT}") + self.assertNotEqual(s, None) + s.close() + self.assertRaises(ws.WebSocketConnectionClosedException, s.send, "Hello") + self.assertRaises(ws.WebSocketConnectionClosedException, s.recv) + + +class SockOptTest(unittest.TestCase): + @unittest.skipUnless( + TEST_WITH_LOCAL_SERVER, "Tests using local websocket server are disabled" + ) + def test_sockopt(self): + sockopt = ((socket.IPPROTO_TCP, socket.TCP_NODELAY, 1),) + s = ws.create_connection( + f"ws://127.0.0.1:{LOCAL_WS_SERVER_PORT}", sockopt=sockopt + ) + self.assertNotEqual( + s.sock.getsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY), 0 + ) + s.close() + + +class UtilsTest(unittest.TestCase): + def test_utf8_validator(self): + state = validate_utf8(b"\xf0\x90\x80\x80") + self.assertEqual(state, True) + state = validate_utf8( + b"\xce\xba\xe1\xbd\xb9\xcf\x83\xce\xbc\xce\xb5\xed\xa0\x80edited" + ) + self.assertEqual(state, False) + state = validate_utf8(b"") + self.assertEqual(state, True) + + +class HandshakeTest(unittest.TestCase): + @unittest.skipUnless(TEST_WITH_INTERNET, "Internet-requiring tests are disabled") + def test_http_ssl(self): + websock1 = ws.WebSocket( + sslopt={"cert_chain": ssl.get_default_verify_paths().capath}, + enable_multithread=False, + ) + self.assertRaises(ValueError, websock1.connect, "wss://api.bitfinex.com/ws/2") + websock2 = ws.WebSocket(sslopt={"certfile": "myNonexistentCertFile"}) + self.assertRaises( + FileNotFoundError, websock2.connect, "wss://api.bitfinex.com/ws/2" + ) + + @unittest.skipUnless(TEST_WITH_INTERNET, "Internet-requiring tests are disabled") + def test_manual_headers(self): + websock3 = ws.WebSocket( + sslopt={ + "ca_certs": ssl.get_default_verify_paths().cafile, + "ca_cert_path": ssl.get_default_verify_paths().capath, + } + ) + self.assertRaises( + WebSocketBadStatusException, + websock3.connect, + "wss://api.bitfinex.com/ws/2", + cookie="chocolate", + origin="testing_websockets.com", + host="echo.websocket.events/websocket-client-test", + subprotocols=["testproto"], + connection="Upgrade", + header={ + "CustomHeader1": "123", + "Cookie": "TestValue", + "Sec-WebSocket-Key": "k9kFAUWNAMmf5OEMfTlOEA==", + "Sec-WebSocket-Protocol": "newprotocol", + }, + ) + + def test_ipv6(self): + websock2 = ws.WebSocket() + self.assertRaises(ValueError, websock2.connect, "2001:4860:4860::8888") + + def test_bad_urls(self): + websock3 = ws.WebSocket() + self.assertRaises(ValueError, websock3.connect, "ws//example.com") + self.assertRaises(WebSocketAddressException, websock3.connect, "ws://example") + self.assertRaises(ValueError, websock3.connect, "example.com") + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/code/ui/lsp_manager/lsp_manager.py b/plugins/code/ui/lsp_manager/lsp_manager.py new file mode 100644 index 0000000..8899be6 --- /dev/null +++ b/plugins/code/ui/lsp_manager/lsp_manager.py @@ -0,0 +1,199 @@ +# Python imports +from os import path +import json + +# Lib imports +import gi +gi.require_version('Gtk', '3.0') +gi.require_version('GtkSource', '4') + +from gi.repository import Gtk +from gi.repository import GLib +from gi.repository import GtkSource + +# Application imports +from .provider import Provider + + + +class LSPManager(Gtk.Dialog): + def __init__(self): + super(LSPManager, self).__init__() + + self._SCRIPT_PTH: str = path.dirname( path.realpath(__file__) ) + self._USER_HOME: str = path.expanduser('~') + self._LSP_SERVERS_CONFIG: str = "" + self.servers_config: dict = {} + + self.provider: Provider = Provider() + self.parent = None + self.source_view = None + + self._setup_styling() + self._setup_signals() + self._subscribe_to_events() + self._load_widgets() + + + def _setup_styling(self): + self.set_modal(True) + self.set_decorated(False) + self.set_vexpand(True) + self.set_hexpand(True) + + def _setup_signals(self): + self.connect("show", self._show) + + def _subscribe_to_events(self): + ... + + def _load_widgets(self): + content_area = self.get_content_area() + self.main_box = Gtk.Grid() + self.path_entry = Gtk.SearchEntry() + self.path_bttn = Gtk.FileChooserButton.new( + title = "Workspace Folder", + action = Gtk.FileChooserAction.SELECT_FOLDER + ) + self.combo_box = Gtk.ComboBoxText() + + self.hide_bttn = Gtk.Button(label = "X") + bttn_box = Gtk.Box() + create_client_bttn = Gtk.Button(label = "Create Language Client") + close_client_bttn = Gtk.Button(label = "Close Language Client") + + self.path_entry.set_can_focus(False) + self.path_entry.set_placeholder_text("Workspace Folder...") + self.path_entry.connect("changed", self._path_changed, bttn_box) + + self.path_bttn.connect("file-set", self._file_set) + self.path_bttn.set_halign(Gtk.Align.FILL) + self.hide_bttn.connect("clicked", lambda widget: self.hide()) + create_client_bttn.connect("clicked", self.create_client, close_client_bttn) + close_client_bttn.connect("clicked", self.close_client, create_client_bttn) + + self.main_box.set_column_spacing(15) + self.main_box.set_row_spacing(15) + + bttn_box.pack_start(create_client_bttn, False, False, 0) + bttn_box.pack_start(close_client_bttn, False, False, 0) + + self.main_box.attach(child = self.path_entry, left = 0, top = 0, width = 4, height = 1) + self.main_box.attach(child = self.path_bttn, left = 4, top = 0, width = 1, height = 1) + self.main_box.attach(child = self.combo_box, left = 5, top = 0, width = 1, height = 1) + self.main_box.attach(child = self.hide_bttn, left = 6, top = 0, width = 1, height = 1) + self.main_box.attach(child = bttn_box, left = 0, top = 1, width = 1, height = 1) + + content_area.set_vexpand(True) + content_area.set_hexpand(True) + + content_area.add(self.main_box) + content_area.show_all() + close_client_bttn.hide() + bttn_box.hide() + + def _show(self, widget): + GLib.idle_add(self.path_entry.grab_focus) + + def _map_resize(self, widget, parent): + parent_x, parent_y = parent.get_position() + parent_width, parent_height = parent.get_size() + if parent_width == 0 or parent_height == 0: return + + width = int(parent_width * 0.75) + height = int(parent_height * 0.75) + + widget.resize(width, height) + + x = parent_x + (parent_width - width) // 2 + y = parent_y + (parent_height - height) // 2 + widget.move(x, y) + + def _path_changed(self, widget, buttons_widget): + fpath = widget.get_text() + if not fpath: + buttons_widget.hide() + return + + buttons_widget.show() + + def _file_set(self, widget): + self.path_entry.set_text( + widget.get_filename() + ) + self.load_lsp_servers_config_placeholders() + + def map_parent_resize_event(self, parent): + parent.connect("size-allocate", lambda w, r: self._map_resize(self, parent)) + + def set_source_view(self, source_view): + scrolled_win = Gtk.ScrolledWindow() + lang_manager = GtkSource.LanguageManager() + buffer = source_view.get_buffer() + language = lang_manager.get_language("json") + self.source_view = source_view + + buffer.set_language(language) + buffer.set_style_scheme(self.source_view.syntax_theme) + + scrolled_win.set_hexpand(True) + scrolled_win.set_vexpand(True) + + scrolled_win.add(self.source_view) + self.main_box.attach(child = scrolled_win, left = 0, top = 2, width = 7, height = 1) + + scrolled_win.show_all() + + def load_lsp_servers_config(self): + with open(f"{self._SCRIPT_PTH}/configs/lsp-servers-config.json") as file: + self._LSP_SERVERS_CONFIG = file.read() + + def load_lsp_servers_config_placeholders(self): + data = self._LSP_SERVERS_CONFIG \ + .replace("{user.home}", self._USER_HOME) \ + .replace("{workspace.folder}", self.path_entry.get_text()) + + self.servers_config = json.loads(data) + + buffer = self.source_view.get_buffer() + start_itr, \ + end_itr = buffer.get_bounds() + + buffer.delete(start_itr, end_itr) + buffer.insert(start_itr, data, -1) + + self.set_language_combo_box( self.servers_config.keys() ) + + def set_language_combo_box(self, lang_ids: list[str]): + for lang_id in lang_ids: + self.combo_box.append_text(lang_id) + + def create_client(self, widget, sibling): + buffer = self.source_view.get_buffer() + lang_id = self.combo_box.get_active_text() + + if not lang_id: return + if not lang_id in self.servers_config: return + + self.servers_config = json.loads( buffer.get_text( *buffer.get_bounds() ) ) + init_opts = self.servers_config[lang_id]["initialization-options"] + workspace_dir = self.path_entry.get_text() + + result = self.provider.response_cache.create_client( + lang_id, workspace_dir, init_opts + ) + + if not result: return + + widget.hide() + sibling.show() + + def close_client(self, widget, sibling): + lang_id = self.combo_box.get_active_text() + + if not lang_id: return + result = self.provider.response_cache.close_client(lang_id) + if not result: return + + widget.hide() + sibling.show() diff --git a/plugins/code/completers/lsp_completer/manifest.json b/plugins/code/ui/lsp_manager/manifest.json similarity index 76% rename from plugins/code/completers/lsp_completer/manifest.json rename to plugins/code/ui/lsp_manager/manifest.json index 6557297..c0b82bc 100644 --- a/plugins/code/completers/lsp_completer/manifest.json +++ b/plugins/code/ui/lsp_manager/manifest.json @@ -1,5 +1,5 @@ { - "name": "LSP Completer", + "name": "LSP Manager", "author": "ITDominator", "version": "0.0.1", "support": "", diff --git a/plugins/code/ui/lsp_manager/mixins/__init__.py b/plugins/code/ui/lsp_manager/mixins/__init__.py new file mode 100644 index 0000000..da03c94 --- /dev/null +++ b/plugins/code/ui/lsp_manager/mixins/__init__.py @@ -0,0 +1,3 @@ +""" + Pligin Module Mixins +""" diff --git a/plugins/code/ui/lsp_manager/mixins/lsp_client_events_mixin.py b/plugins/code/ui/lsp_manager/mixins/lsp_client_events_mixin.py new file mode 100644 index 0000000..5cfdbf8 --- /dev/null +++ b/plugins/code/ui/lsp_manager/mixins/lsp_client_events_mixin.py @@ -0,0 +1,144 @@ +# Python imports + +# Lib imports +import gi + +from gi.repository import GLib + +# Application imports +from libs.event_factory import Code_Event_Types + + + +class LSPClientEventsMixin: + + def process_file_load(self, event: Code_Event_Types.AddedNewFileEvent): + lang_id = event.file.ftype + if lang_id not in self.clients: + logger.debug(f"No LSP client for '{lang_id}', skipping didOpen") + return + + controller = self.clients[lang_id] + fpath = event.file.fpath + uri = f"file://{fpath}" if not fpath.startswith("file://") else fpath + buffer = event.file.buffer + text = buffer.get_text(*buffer.get_bounds()) + self._last_active_language_id = lang_id + + controller._lsp_did_open({ + "uri": uri, + "language_id": lang_id, + "text": text + }) + + def process_file_close(self, event: Code_Event_Types.RemovedFileEvent): + lang_id = event.file.ftype + if lang_id not in self.clients: + logger.debug(f"No LSP client for '{lang_id}', skipping didClose") + return + + controller = self.clients[lang_id] + fpath = event.file.fpath + uri = f"file://{fpath}" if not fpath.startswith("file://") else fpath + + controller._lsp_did_close({"uri": uri}) + + def process_file_save(self, event: Code_Event_Types.SavedFileEvent): + lang_id = event.file.ftype + if lang_id not in self.clients: + logger.debug(f"No LSP client for '{lang_id}', skipping didSave") + return + + controller = self.clients[lang_id] + fpath = event.file.fpath + uri = f"file://{fpath}" if not fpath.startswith("file://") else fpath + buffer = event.file.buffer + text = buffer.get_text(*buffer.get_bounds()) + self._last_active_language_id = lang_id + + controller._lsp_did_save({"uri": uri, "text": text}) + + def process_file_change(self, event: Code_Event_Types.TextChangedEvent): + self._clear_delayed_cache_refresh_trigger() + + lang_id = event.file.ftype + if lang_id not in self.clients: + logger.debug(f"No LSP client for '{lang_id}', skipping didChange") + return + + controller = self.clients[lang_id] + fpath = event.file.fpath + uri = f"file://{fpath}" if not fpath.startswith("file://") else fpath + buffer = event.file.buffer + text = buffer.get_text(*buffer.get_bounds()) + self._last_active_language_id = lang_id + + controller._lsp_did_change({ + "uri": uri, + "language_id": lang_id, + "version": 1, + "text": text + }) + + iter = buffer.get_iter_at_mark( buffer.get_insert() ) + line = iter.get_line() + column = iter.get_line_offset() + self._set_cache_refresh_trigger( + lang_id, fpath, line, column + ) + + + def process_goto_definition( + self, lang_id: str, fpath: str, line: int, column: int + ): + if lang_id not in self.clients: + logger.debug(f"No LSP client for '{lang_id}', skipping goto definition") + return + + controller = self.clients[lang_id] + uri = f"file://{fpath}" if not fpath.startswith("file://") else fpath + self._last_active_language_id = lang_id + + controller._lsp_definition({ + "uri": uri, + "language_id": lang_id, + "version": 1, + "line": line, + "column": column + }) + + def process_completion_request( + self, lang_id: str, fpath: str, line: int, column: int + ): + if lang_id not in self.clients: + logger.debug(f"No LSP client for '{lang_id}', skipping completion") + return + + controller = self.clients[lang_id] + uri = f"file://{fpath}" if not fpath.startswith("file://") else fpath + self._last_active_language_id = lang_id + + controller._lsp_completion({ + "uri": uri, + "language_id": lang_id, + "version": 1, + "line": line, + "column": column + }) + + + def _clear_delayed_cache_refresh_trigger(self): + if self._cache_refresh_timeout_id: + GLib.source_remove(self._cache_refresh_timeout_id) + + def _set_cache_refresh_trigger( + self, lang_id: str, fpath: str, line: int, column: int + ): + def trigger_cache_refresh(lang_id, fpath, line, column): + self._cache_refresh_timeout_id = None + self.process_completion_request( + lang_id, fpath, line, column + ) + return False + + self._cache_refresh_timeout_id = GLib.timeout_add(1500, trigger_cache_refresh, lang_id, fpath, line, column) diff --git a/plugins/code/ui/lsp_manager/mixins/lsp_server_events_mixin.py b/plugins/code/ui/lsp_manager/mixins/lsp_server_events_mixin.py new file mode 100644 index 0000000..7b86b48 --- /dev/null +++ b/plugins/code/ui/lsp_manager/mixins/lsp_server_events_mixin.py @@ -0,0 +1,59 @@ +# Python imports + +# Lib imports +import gi + +from gi.repository import GLib + +# Application imports +from libs.event_factory import Code_Event_Types + + + +class LSPServerEventsMixin: + + def _handle_definition_response(self, result: dict or list): + if not result: return + self._prompt_goto_request(result[0]["uri"]) + + def _handle_completion_response(self, result: dict or list): + if not result: return + + items = [] + if isinstance(result, dict): + items = result.get("items", []) + elif isinstance(result, list): + items = result + + self.matchers.clear() + for item in items: + label = item.get("label", "") + if not label: continue + + text = item.get("insertText") + if not text and "textEdit" in item: + text = item["textEdit"].get("newText", "") + + info = "" + if "detail" in item: + info = item["detail"] + elif "documentation" in item: + doc = item["documentation"] + if isinstance(doc, dict): + info = doc.get("value", "") + else: + info = str(doc) + + self.matchers[label] = { + "label": label, + "text": text, + "info": info + } + + self._prompt_completion_request() + + def _prompt_completion_request(self): + raise NotImplementedError + + def _prompt_goto_request(self, uri: str): + raise NotImplementedError diff --git a/plugins/code/ui/lsp_manager/plugin.py b/plugins/code/ui/lsp_manager/plugin.py new file mode 100644 index 0000000..b5d7407 --- /dev/null +++ b/plugins/code/ui/lsp_manager/plugin.py @@ -0,0 +1,121 @@ +# Python imports + +# Lib imports + +# Application imports +from libs.event_factory import Event_Factory, Code_Event_Types +from libs.dto.states import SourceViewStates + +from plugins.plugin_types import PluginCode + +from .lsp_manager import LSPManager + + + +lsp_manager = LSPManager() + + + +class Plugin(PluginCode): + def __init__(self): + super(Plugin, self).__init__() + + + def _controller_message(self, event: Code_Event_Types.CodeEvent): + ... + + def load(self): + window = self.request_ui_element("main-window") + + lsp_manager.map_parent_resize_event(window) + + event = Event_Factory.create_event("register_command", + command_name = "LSP Manager", + command = Handler, + binding_mode = "released", + binding = ["l", "g", "i"] + ) + self.emit_to("source_views", event) + + event = Event_Factory.create_event( + "register_provider", + provider_name = "LSP Completer", + provider = lsp_manager.provider, + language_ids = [] + ) + self.emit_to("completion", event) + + event = Event_Factory.create_event( + "create_source_view", + state = SourceViewStates.INDEPENDENT + ) + self.emit_to("source_views", event) + + source_view = event.response + lsp_manager.load_lsp_servers_config() + lsp_manager.set_source_view(source_view) + lsp_manager.load_lsp_servers_config_placeholders() + lsp_manager.provider.response_cache._prompt_completion_request = \ + self._prompt_completion_request + + lsp_manager.provider.response_cache._prompt_goto_request = \ + self._prompt_goto_request + + def run(self): + ... + + def generate_plugin_element(self): + ... + + def _prompt_completion_request(self): + event = Event_Factory.create_event( + "get_active_view", + ) + self.emit_to("source_views", event) + view = event.response + + event = Event_Factory.create_event( + "request_completion", + view = view, + provider = lsp_manager.provider + ) + self.emit_to("completion", event) + + + def _prompt_goto_request(self, uri: str): + event = Event_Factory.create_event( + "get_active_view", + ) + self.emit_to("source_views", event) + view = event.response + view._on_uri_data_received( [uri] ) + + +class Handler: + @staticmethod + def execute( + view: any, + *args, + **kwargs + ): + logger.debug("Command: LSP Manager") + + char_str = args[0] + if char_str in ["g", "i"]: + file = view.command.exec("get_current_file") + buffer = view.get_buffer() + iter = buffer.get_iter_at_mark( buffer.get_insert() ) + line = iter.get_line() + column = iter.get_line_offset() + + if char_str == "g": + lsp_manager.provider.response_cache.process_goto_definition( + file.ftype, file.fpath, line, column + ) + + return + + if char_str == "i": + return + + lsp_manager.hide() if lsp_manager.is_visible() else lsp_manager.show() diff --git a/plugins/code/completers/lsp_completer/provider.py b/plugins/code/ui/lsp_manager/provider.py similarity index 81% rename from plugins/code/completers/lsp_completer/provider.py rename to plugins/code/ui/lsp_manager/provider.py index b6de14d..e1a8176 100644 --- a/plugins/code/completers/lsp_completer/provider.py +++ b/plugins/code/ui/lsp_manager/provider.py @@ -32,16 +32,13 @@ class Provider(GObject.GObject, GtkSource.CompletionProvider): return "LSP Code Completion" def do_match(self, context): - word = self.response_cache.get_word(context) - if not word or len(word) < 2: return False - iter = self.response_cache.get_iter_correctly(context) iter.backward_char() ch = iter.get_char() + # NOTE: Look to re-add or apply supprting logic to use spaces - # As is it slows down the editor in certain contexts... - # if not (ch in ('_', '.', ' ') or ch.isalnum()): - if not (ch in ('_', '.') or ch.isalnum()): + # As is it slows down the editor in certain contexts... + if not (ch in ('_', '.', ' ') or ch.isalnum()): return False buffer = iter.get_buffer() @@ -62,9 +59,10 @@ class Provider(GObject.GObject, GtkSource.CompletionProvider): def do_get_activation(self): """ The context for when a provider will show results """ - # return GtkSource.CompletionActivation.NONE - return GtkSource.CompletionActivation.USER_REQUESTED - # return GtkSource.CompletionActivation.INTERACTIVE +# return GtkSource.CompletionActivation.NONE +# return GtkSource.CompletionActivation.USER_REQUESTED +# return GtkSource.CompletionActivation.INTERACTIVE + return GtkSource.CompletionActivation.INTERACTIVE | GtkSource.CompletionActivation.USER_REQUESTED def do_populate(self, context): results = self.response_cache.filter_with_context(context) diff --git a/plugins/code/ui/lsp_manager/provider_response_cache.py b/plugins/code/ui/lsp_manager/provider_response_cache.py new file mode 100644 index 0000000..23eee95 --- /dev/null +++ b/plugins/code/ui/lsp_manager/provider_response_cache.py @@ -0,0 +1,114 @@ +# Python imports +from concurrent.futures import ThreadPoolExecutor +import asyncio +from asyncio import Queue + +# Lib imports +import gi +gi.require_version('GtkSource', '4') + +from gi.repository import GtkSource + +# Application imports +from libs.dto.code.lsp.lsp_message_structs import LSPResponseTypes, LSPResponseRequest, LSPResponseNotification + +from core.widgets.code.completion_providers.provider_response_cache_base import ProviderResponseCacheBase + +from .controllers.lsp_controller import LSPController +from .mixins.lsp_client_events_mixin import LSPClientEventsMixin +from .mixins.lsp_server_events_mixin import LSPServerEventsMixin + + + +class ProviderResponseCache(LSPClientEventsMixin, LSPServerEventsMixin, ProviderResponseCacheBase): + def __init__(self): + super(ProviderResponseCache, self).__init__() + + self.executor = ThreadPoolExecutor(max_workers = 1) + self.matchers: dict = {} + self.clients: dict = {} + self._cache_refresh_timeout_id: int = None + self._last_active_language_id: str = None + + + def create_client( + self, + lang_id: str = "python", + workspace_uri: str = "", + init_opts: dict = { + }) -> bool: + if lang_id in self.clients: return False + + address = "127.0.0.1" + port = 9999 + uri = f"ws://{address}:{port}/{lang_id}" + controller = LSPController() + controller.handle_lsp_response = self.server_response + + controller.set_language(lang_id) + controller.set_socket(uri) + controller.start_client() + + if not controller.ws_client.wait_for_connection(timeout = 5.0): + logger.error(f"Failed to connect to LSP server for {lang_id}") + return False + + self.clients[lang_id] = controller + controller.send_initialize_message(init_opts, "", f"file://{workspace_uri}") + + return True + + def close_client(self, lang_id: str) -> bool: + if lang_id not in self.clients: return False + + controller = self.clients.pop(lang_id) + controller.stop_client() + + return True + + def server_response(self, lsp_response: LSPResponseTypes): + logger.debug(f"LSP Response: { lsp_response }") + + if isinstance(lsp_response, LSPResponseRequest): + if not self._last_active_language_id in self.clients: + logger.debug(f"No LSP client for '{self._last_active_language_id}', skipping 'server_response'") + return + + controller = self.clients[self._last_active_language_id] + event = controller.get_event_by_id(lsp_response.id) + + match event: + case "textDocument/completion": + self._handle_completion_response(lsp_response.result) + case "textDocument/definition": + self._handle_definition_response(lsp_response.result) + case _: + ... + elif isinstance(lsp_response, LSPResponseNotification): + match lsp_response.method: + case "textDocument/publishDiagnostics": + ... + case _: + ... + + def filter(self, word: str) -> list[dict]: + return [] + + def filter_with_context(self, context: GtkSource.CompletionContext) -> list[dict]: + response = [] + iter = self.get_iter_correctly(context) + iter.backward_char() + char_str = iter.get_char() + + if char_str == "." or char_str == " ": + for label, item in self.matchers.items(): + response.append(item) + + return response + + word = self.get_word(context).rstrip() + for label, item in self.matchers.items(): + if label.startswith(word): + response.append(item) + + return response diff --git a/src/core/widgets/code/command_system/command_system.py b/src/core/widgets/code/command_system/command_system.py index e92a60e..7e5d8c4 100644 --- a/src/core/widgets/code/command_system/command_system.py +++ b/src/core/widgets/code/command_system/command_system.py @@ -47,6 +47,16 @@ class CommandSystem: ... + def filter_out_loaded_files(self, uris: list[str]): + event = Event_Factory.create_event( + "filter_out_loaded_files", + uris = uris + ) + + self.emit_to("files", event) + + return event.response + def set_info_labels(self, data: tuple[str]): event = Event_Factory.create_event( "set_info_labels", diff --git a/src/core/widgets/code/command_system/commands/open_files.py b/src/core/widgets/code/command_system/commands/open_files.py index e5fb83d..8e003f3 100644 --- a/src/core/widgets/code/command_system/commands/open_files.py +++ b/src/core/widgets/code/command_system/commands/open_files.py @@ -18,19 +18,13 @@ def execute( **kwargs ): logger.debug("Command: Open File(s)") - file = view.command.get_file(view) + start_path = None + file = view.command.get_file(view) if not file.ftype == "buffer": start_path = file.get_location() gfiles = event_system.emit_and_await("open-files", (None, None, start_path)) - if not gfiles: return - - if file.ftype == "buffer": - gfile = gfiles.pop() - view.command.exec_with_args("load_file", view, gfile, file) - view.set_buffer(file.buffer) - update_info_bar_if_focused(view.command, view) - - for i, gfile in enumerate(gfiles): - view.command.exec_with_args("load_file", view, gfile) + view._on_uri_data_received( + [ gfile.get_uri() for gfile in gfiles ] + ) diff --git a/src/core/widgets/code/controllers/completion_controller.py b/src/core/widgets/code/controllers/completion_controller.py index a9a3d1a..1f9db98 100644 --- a/src/core/widgets/code/controllers/completion_controller.py +++ b/src/core/widgets/code/controllers/completion_controller.py @@ -34,6 +34,8 @@ class CompletionController(ControllerBase): event.language_ids ) elif isinstance(event, Code_Event_Types.AddedNewFileEvent): + ... + elif isinstance(event, Code_Event_Types.LoadedNewFileEvent): self.provider_process_file_load(event) elif isinstance(event, Code_Event_Types.RemovedFileEvent): self.provider_process_file_close(event) diff --git a/src/core/widgets/code/controllers/files_controller.py b/src/core/widgets/code/controllers/files_controller.py index 18d66da..ba660b2 100644 --- a/src/core/widgets/code/controllers/files_controller.py +++ b/src/core/widgets/code/controllers/files_controller.py @@ -22,6 +22,8 @@ class FilesController(ControllerBase, list): self.new_file(event) elif isinstance(event, Code_Event_Types.PopFileEvent): self.pop_file(event) + elif isinstance(event, Code_Event_Types.FilterOutLoadedFilesEvent): + self.filter_loaded(event) elif isinstance(event, Code_Event_Types.RemoveFileEvent): self.remove_file(event) elif isinstance(event, Code_Event_Types.GetFileEvent): @@ -30,6 +32,13 @@ class FilesController(ControllerBase, list): self.get_swap_file(event) + def filter_loaded(self, event: Code_Event_Types.FilterOutLoadedFilesEvent): + loaded_paths = {file.fpath for file in self} + files = [ + uri for uri in event.uris if not any(path in uri for path in loaded_paths) + ] + event.response = files + def new_file(self, event: Code_Event_Types.AddNewFileEvent): file = SourceFile() file.emit = self.emit diff --git a/src/core/widgets/code/controllers/views/signal_mapper.py b/src/core/widgets/code/controllers/views/signal_mapper.py index 599c9a3..f73e53d 100644 --- a/src/core/widgets/code/controllers/views/signal_mapper.py +++ b/src/core/widgets/code/controllers/views/signal_mapper.py @@ -19,8 +19,8 @@ class SourceViewSignalMapper: self.state_manager = state_manager def set_buffer_to_active_view(self, buffer): - self.active_view.set_buffer(buffer) - self.active_view.command.exec("update_info_bar") + self.active_view.set_buffer(buffer) + self.active_view.command.exec("update_info_bar") def connect_signals(self, source_view: SourceView): signal_mappings = self._get_signal_mappings() diff --git a/src/core/widgets/code/controllers/views/source_views_controller.py b/src/core/widgets/code/controllers/views/source_views_controller.py index d097fdb..94edc48 100644 --- a/src/core/widgets/code/controllers/views/source_views_controller.py +++ b/src/core/widgets/code/controllers/views/source_views_controller.py @@ -73,6 +73,7 @@ class SourceViewsController(ControllerBase, list): for source_view in self: if not event.file.buffer == source_view.get_buffer(): continue if not event.next_file: + if source_view.state in [SourceViewStates.INDEPENDENT, SourceViewStates.READONLY]: continue source_view.command.exec("new_file") continue @@ -90,7 +91,7 @@ class SourceViewsController(ControllerBase, list): def first_map_load(self): for source_view in self: - if source_view.state == SourceViewStates.INDEPENDENT: continue + if source_view.state in [SourceViewStates.INDEPENDENT, SourceViewStates.READONLY]: continue source_view.command.exec("new_file") if not source_view.sibling_left: continue source_view.get_parent().hide() diff --git a/src/core/widgets/code/mixins/source_view_dnd_mixin.py b/src/core/widgets/code/mixins/source_view_dnd_mixin.py index b92e350..bee3467 100644 --- a/src/core/widgets/code/mixins/source_view_dnd_mixin.py +++ b/src/core/widgets/code/mixins/source_view_dnd_mixin.py @@ -31,7 +31,10 @@ class SourceViewDnDMixin: self._on_uri_data_received(uris) - def _on_uri_data_received(self, uris: []): + def _on_uri_data_received(self, uris: list[str]): + uris = self.command.filter_out_loaded_files(uris) + if not uris: return + uri = uris.pop(0) self.command.exec_with_args("dnd_load_file_to_buffer", self, uri) diff --git a/src/core/widgets/code/source_file.py b/src/core/widgets/code/source_file.py index 430163c..c21b8cf 100644 --- a/src/core/widgets/code/source_file.py +++ b/src/core/widgets/code/source_file.py @@ -47,8 +47,6 @@ class SourceFile(GtkSource.File): ... def _after_changed(self, buffer: SourceBuffer): - self.check_file_on_disk() - event = Event_Factory.create_event( "text_changed", file = self, @@ -56,25 +54,6 @@ class SourceFile(GtkSource.File): ) self.emit(event) - if self.is_deleted(): - self.was_deleted = True - event = Event_Factory.create_event( - "file_externally_deleted", - file = self, - buffer = buffer - ) - self.emit(event) - return - - if self.is_externally_modified(): -# event = Event_Factory.create_event( -# "file_externally_modified", -# file = self, -# buffer = buffer -# ) -# self.emit(event) - return - def _insert_text( self, buffer: SourceBuffer, @@ -145,19 +124,26 @@ class SourceFile(GtkSource.File): def load_path(self, gfile: Gio.File): if not gfile: return - self.set_path(gfile) text = gfile.load_bytes()[0].get_data().decode("UTF-8") info = gfile.query_info('standard::content-type', Gio.FileQueryInfoFlags.NONE, None) content_type = info.get_content_type() - self.ftype = Gio.content_type_get_mime_type(content_type) + self.ftype = Gio.content_type_get_mime_type(content_type) \ + .replace("application/", "") \ + .replace("text/", "") \ + .replace("x-", "") + + self.set_path(gfile) logger.debug(f"File content type: {self.ftype}") undo_manager = self.buffer.get_undo_manager() + self.buffer.block_changed_signal() + self.buffer.block_changed_after_signal() + self.buffer.block_modified_changed_signal() + def move_insert_to_start(): start_itr = self.buffer.get_start_iter() self.buffer.place_cursor(start_itr) - undo_manager.begin_not_undoable_action() with self.buffer.freeze_notify(): @@ -170,6 +156,17 @@ class SourceFile(GtkSource.File): undo_manager.end_not_undoable_action() self.buffer.set_modified(False) + eve = Event_Factory.create_event( + "loaded_new_file", + file = self + ) + self.emit(eve) + + self.buffer.unblock_changed_signal() + self.buffer.unblock_changed_after_signal() + self.buffer.unblock_modified_changed_signal() + + def set_path(self, gfile: Gio.File): if not gfile: return self.set_location(gfile) diff --git a/src/libs/dto/code/__init__.py b/src/libs/dto/code/events/__init__.py similarity index 90% rename from src/libs/dto/code/__init__.py rename to src/libs/dto/code/events/__init__.py index db58d5c..96b0d7f 100644 --- a/src/libs/dto/code/__init__.py +++ b/src/libs/dto/code/events/__init__.py @@ -14,6 +14,8 @@ from .file_externally_modified_event import FileExternallyModifiedEvent from .file_externally_deleted_event import FileExternallyDeletedEvent from .set_info_labels_event import SetInfoLabelsEvent from .populate_source_view_popup_event import PopulateSourceViewPopupEvent +from .filter_out_loaded_files_event import FilterOutLoadedFilesEvent +from .get_active_view_event import GetActiveViewEvent from .get_new_command_system_event import GetNewCommandSystemEvent from .request_completion_event import RequestCompletionEvent @@ -26,6 +28,7 @@ from .set_active_file_event import SetActiveFileEvent from .file_path_set_event import FilePathSetEvent from .added_new_file_event import AddedNewFileEvent +from .loaded_new_file_event import LoadedNewFileEvent from .popped_file_event import PoppedFileEvent from .removed_file_event import RemovedFileEvent from .saved_file_event import SavedFileEvent diff --git a/src/libs/dto/code/add_new_file_event.py b/src/libs/dto/code/events/add_new_file_event.py similarity index 100% rename from src/libs/dto/code/add_new_file_event.py rename to src/libs/dto/code/events/add_new_file_event.py diff --git a/src/libs/dto/code/added_new_file_event.py b/src/libs/dto/code/events/added_new_file_event.py similarity index 100% rename from src/libs/dto/code/added_new_file_event.py rename to src/libs/dto/code/events/added_new_file_event.py diff --git a/src/libs/dto/code/code_event.py b/src/libs/dto/code/events/code_event.py similarity index 89% rename from src/libs/dto/code/code_event.py rename to src/libs/dto/code/events/code_event.py index 0f8dbb3..6e5f822 100644 --- a/src/libs/dto/code/code_event.py +++ b/src/libs/dto/code/events/code_event.py @@ -4,7 +4,7 @@ from dataclasses import dataclass, field # Lib imports # Application imports -from ..base_event import BaseEvent +from ...base_event import BaseEvent diff --git a/src/libs/dto/code/create_source_view_event.py b/src/libs/dto/code/events/create_source_view_event.py similarity index 100% rename from src/libs/dto/code/create_source_view_event.py rename to src/libs/dto/code/events/create_source_view_event.py diff --git a/src/libs/dto/code/cursor_moved_event.py b/src/libs/dto/code/events/cursor_moved_event.py similarity index 100% rename from src/libs/dto/code/cursor_moved_event.py rename to src/libs/dto/code/events/cursor_moved_event.py diff --git a/src/libs/dto/code/file_externally_deleted_event.py b/src/libs/dto/code/events/file_externally_deleted_event.py similarity index 100% rename from src/libs/dto/code/file_externally_deleted_event.py rename to src/libs/dto/code/events/file_externally_deleted_event.py diff --git a/src/libs/dto/code/file_externally_modified_event.py b/src/libs/dto/code/events/file_externally_modified_event.py similarity index 100% rename from src/libs/dto/code/file_externally_modified_event.py rename to src/libs/dto/code/events/file_externally_modified_event.py diff --git a/src/libs/dto/code/file_path_set_event.py b/src/libs/dto/code/events/file_path_set_event.py similarity index 100% rename from src/libs/dto/code/file_path_set_event.py rename to src/libs/dto/code/events/file_path_set_event.py diff --git a/src/libs/dto/code/events/filter_out_loaded_files_event.py b/src/libs/dto/code/events/filter_out_loaded_files_event.py new file mode 100644 index 0000000..796bfaf --- /dev/null +++ b/src/libs/dto/code/events/filter_out_loaded_files_event.py @@ -0,0 +1,13 @@ +# Python imports +from dataclasses import dataclass, field + +# Lib imports + +# Application imports +from .code_event import CodeEvent + + + +@dataclass +class FilterOutLoadedFilesEvent(CodeEvent): + uris: list[str] = None diff --git a/src/libs/dto/code/focused_view_event.py b/src/libs/dto/code/events/focused_view_event.py similarity index 100% rename from src/libs/dto/code/focused_view_event.py rename to src/libs/dto/code/events/focused_view_event.py diff --git a/src/libs/dto/code/request_completion_event.py b/src/libs/dto/code/events/get_active_view_event.py similarity index 78% rename from src/libs/dto/code/request_completion_event.py rename to src/libs/dto/code/events/get_active_view_event.py index ba9b01c..4bd096c 100644 --- a/src/libs/dto/code/request_completion_event.py +++ b/src/libs/dto/code/events/get_active_view_event.py @@ -9,5 +9,5 @@ from .code_event import CodeEvent @dataclass -class RequestCompletionEvent(CodeEvent): +class GetActiveViewEvent(CodeEvent): ... diff --git a/src/libs/dto/code/get_file_event.py b/src/libs/dto/code/events/get_file_event.py similarity index 100% rename from src/libs/dto/code/get_file_event.py rename to src/libs/dto/code/events/get_file_event.py diff --git a/src/libs/dto/code/get_new_command_system_event.py b/src/libs/dto/code/events/get_new_command_system_event.py similarity index 100% rename from src/libs/dto/code/get_new_command_system_event.py rename to src/libs/dto/code/events/get_new_command_system_event.py diff --git a/src/libs/dto/code/get_swap_file_event.py b/src/libs/dto/code/events/get_swap_file_event.py similarity index 100% rename from src/libs/dto/code/get_swap_file_event.py rename to src/libs/dto/code/events/get_swap_file_event.py diff --git a/src/libs/dto/code/events/loaded_new_file_event.py b/src/libs/dto/code/events/loaded_new_file_event.py new file mode 100644 index 0000000..d7d42f8 --- /dev/null +++ b/src/libs/dto/code/events/loaded_new_file_event.py @@ -0,0 +1,13 @@ +# Python imports +from dataclasses import dataclass, field + +# Lib imports + +# Application imports +from .code_event import CodeEvent + + + +@dataclass +class LoadedNewFileEvent(CodeEvent): + ... diff --git a/src/libs/dto/code/modified_changed_event.py b/src/libs/dto/code/events/modified_changed_event.py similarity index 100% rename from src/libs/dto/code/modified_changed_event.py rename to src/libs/dto/code/events/modified_changed_event.py diff --git a/src/libs/dto/code/pop_file_event.py b/src/libs/dto/code/events/pop_file_event.py similarity index 100% rename from src/libs/dto/code/pop_file_event.py rename to src/libs/dto/code/events/pop_file_event.py diff --git a/src/libs/dto/code/popped_file_event.py b/src/libs/dto/code/events/popped_file_event.py similarity index 100% rename from src/libs/dto/code/popped_file_event.py rename to src/libs/dto/code/events/popped_file_event.py diff --git a/src/libs/dto/code/populate_source_view_popup_event.py b/src/libs/dto/code/events/populate_source_view_popup_event.py similarity index 100% rename from src/libs/dto/code/populate_source_view_popup_event.py rename to src/libs/dto/code/events/populate_source_view_popup_event.py diff --git a/src/libs/dto/code/register_command_event.py b/src/libs/dto/code/events/register_command_event.py similarity index 81% rename from src/libs/dto/code/register_command_event.py rename to src/libs/dto/code/events/register_command_event.py index 09a477c..d56524e 100644 --- a/src/libs/dto/code/register_command_event.py +++ b/src/libs/dto/code/events/register_command_event.py @@ -8,12 +8,12 @@ gi.require_version('GtkSource', '4') from gi.repository import GtkSource # Application imports -from ..base_event import BaseEvent +from .code_event import CodeEvent @dataclass -class RegisterCommandEvent(BaseEvent): +class RegisterCommandEvent(CodeEvent): command_name: str = "" command: callable = None binding_mode: str = "" diff --git a/src/libs/dto/code/register_completer_event.py b/src/libs/dto/code/events/register_completer_event.py similarity index 100% rename from src/libs/dto/code/register_completer_event.py rename to src/libs/dto/code/events/register_completer_event.py diff --git a/src/libs/dto/code/register_provider_event.py b/src/libs/dto/code/events/register_provider_event.py similarity index 83% rename from src/libs/dto/code/register_provider_event.py rename to src/libs/dto/code/events/register_provider_event.py index b0a0b38..3c14a3f 100644 --- a/src/libs/dto/code/register_provider_event.py +++ b/src/libs/dto/code/events/register_provider_event.py @@ -8,12 +8,12 @@ gi.require_version('GtkSource', '4') from gi.repository import GtkSource # Application imports -from ..base_event import BaseEvent +from .code_event import CodeEvent @dataclass -class RegisterProviderEvent(BaseEvent): +class RegisterProviderEvent(CodeEvent): provider_name: str = "" provider: GtkSource.CompletionProvider = None language_ids: list = field(default_factory=lambda: []) diff --git a/src/libs/dto/code/remove_file_event.py b/src/libs/dto/code/events/remove_file_event.py similarity index 100% rename from src/libs/dto/code/remove_file_event.py rename to src/libs/dto/code/events/remove_file_event.py diff --git a/src/libs/dto/code/removed_file_event.py b/src/libs/dto/code/events/removed_file_event.py similarity index 100% rename from src/libs/dto/code/removed_file_event.py rename to src/libs/dto/code/events/removed_file_event.py diff --git a/src/libs/dto/code/events/request_completion_event.py b/src/libs/dto/code/events/request_completion_event.py new file mode 100644 index 0000000..e6075eb --- /dev/null +++ b/src/libs/dto/code/events/request_completion_event.py @@ -0,0 +1,17 @@ +# Python imports +from dataclasses import dataclass, field + +# Lib imports +import gi +gi.require_version('GtkSource', '4') + +from gi.repository import GtkSource + +# Application imports +from .code_event import CodeEvent + + + +@dataclass +class RequestCompletionEvent(CodeEvent): + provider: GtkSource.CompletionProvider or list[GtkSource.CompletionProvider] = None diff --git a/src/libs/dto/code/saved_file_event.py b/src/libs/dto/code/events/saved_file_event.py similarity index 100% rename from src/libs/dto/code/saved_file_event.py rename to src/libs/dto/code/events/saved_file_event.py diff --git a/src/libs/dto/code/set_active_file_event.py b/src/libs/dto/code/events/set_active_file_event.py similarity index 100% rename from src/libs/dto/code/set_active_file_event.py rename to src/libs/dto/code/events/set_active_file_event.py diff --git a/src/libs/dto/code/set_info_labels_event.py b/src/libs/dto/code/events/set_info_labels_event.py similarity index 100% rename from src/libs/dto/code/set_info_labels_event.py rename to src/libs/dto/code/events/set_info_labels_event.py diff --git a/src/libs/dto/code/text_changed_event.py b/src/libs/dto/code/events/text_changed_event.py similarity index 100% rename from src/libs/dto/code/text_changed_event.py rename to src/libs/dto/code/events/text_changed_event.py diff --git a/src/libs/dto/code/text_inserted_event.py b/src/libs/dto/code/events/text_inserted_event.py similarity index 100% rename from src/libs/dto/code/text_inserted_event.py rename to src/libs/dto/code/events/text_inserted_event.py diff --git a/src/libs/dto/code/unregister_completer_event.py b/src/libs/dto/code/events/unregister_completer_event.py similarity index 100% rename from src/libs/dto/code/unregister_completer_event.py rename to src/libs/dto/code/events/unregister_completer_event.py diff --git a/src/libs/dto/code/unregister_provider_event.py b/src/libs/dto/code/events/unregister_provider_event.py similarity index 100% rename from src/libs/dto/code/unregister_provider_event.py rename to src/libs/dto/code/events/unregister_provider_event.py diff --git a/src/libs/dto/code/lsp/lsp_message_structs.py b/src/libs/dto/code/lsp/lsp_message_structs.py new file mode 100644 index 0000000..edc74d5 --- /dev/null +++ b/src/libs/dto/code/lsp/lsp_message_structs.py @@ -0,0 +1,95 @@ +# Python imports +import json +import enum +from dataclasses import dataclass + +# Lib imports + +# Application imports +from .lsp_structs import TextDocumentItem + + + +class MessageEncoder(json.JSONEncoder): + """ + Encodes an object in JSON + """ + + def default(self, o): # pylint: disable=E0202 + return o.__dict__ + + +@dataclass +class ClientRequest(object): + def __init__(self, id: int, method: str, params: dict): + """ + Constructs a new Client Request instance. + + :param int id: Message id to track instance. + :param str method: The type of lsp request being made. + :param dict params: The arguments of the given method. + """ + self.jsonrpc = "2.0" + self.id = id + self.method = method + self.params = params + +@dataclass +class ClientNotification(object): + def __init__(self, method: str, params: dict): + """ + Constructs a new Client Notification instance. + + :param str method: The type of lsp notification being made. + :param dict params: The arguments of the given method. + """ + self.jsonrpc = "2.0" + self.method = method + self.params = params + +@dataclass +class LSPResponseRequest(object): + """ + Constructs a new LSP Response Request instance. + + :param id result: The id of the given message. + :param dict result: The arguments of the given method. + """ + jsonrpc: str + id: int + result: dict + +@dataclass +class LSPResponseNotification(object): + """ + Constructs a new LSP Response Notification instance. + + :param str method: The type of lsp notification being made. + :params dict result: The arguments of the given method. + """ + jsonrpc: str + method: str + params: dict + +@dataclass +class LSPIDResponseNotification(object): + """ + Constructs a new LSP Response Notification instance. + + :param str method: The type of lsp notification being made. + :params dict result: The arguments of the given method. + """ + jsonrpc: str + id: int + method: str + params: dict + + +class MessageTypes(ClientRequest, ClientNotification, LSPResponseRequest, LSPResponseNotification, LSPIDResponseNotification): + ... + +class ClientMessageTypes(ClientRequest, ClientNotification): + ... + +class LSPResponseTypes(LSPResponseRequest, LSPResponseNotification): + ... diff --git a/src/libs/dto/code/lsp/lsp_messages.py b/src/libs/dto/code/lsp/lsp_messages.py new file mode 100644 index 0000000..81117a4 --- /dev/null +++ b/src/libs/dto/code/lsp/lsp_messages.py @@ -0,0 +1,193 @@ +# Python imports +import json + +# Lib imports + +# Application imports +from .lsp_message_structs import MessageEncoder + + + +LEN_HEADER = "Content-Length: " +TYPE_HEADER = "Content-Type: " + + + +def get_message_str(data: dict) -> str: + return json.dumps(data, separators = (',', ':'), indent = 4, cls = MessageEncoder) + +def get_message_obj(data: str): + return json.loads(data) + + + +# Request type formatting +# https://github.com/microsoft/multilspy/blob/main/src/multilspy/language_server.py#L417 +content_part = { + "method": "textDocument/definition", + "params": { + "textDocument": { + "uri": "file://", + "languageId": "python", + "version": 1, + "text": "" + }, + "position": { + "line": 5, + "character": 12, + "offset": 0 + } + } +} + + +didopen_notification = { + "method": "textDocument/didOpen", + "params": { + "textDocument": { + "uri": "file://", + "languageId": "python", + "version": 1, + "text": "" + } + } +} + +didsave_notification = { + "method": "textDocument/didSave", + "params": { + "textDocument": { + "uri": "file://" + }, + "text": "" + } +} + +didclose_notification = { + "method": "textDocument/didClose", + "params": { + "textDocument": { + "uri": "file://" + } + } +} + + +didchange_notification = { + "method": "textDocument/didChange", + "params": { + "textDocument": { + "uri": "file://", + "languageId": "python", + "version": 1, + }, + "contentChanges": [ + { + "text": "" + } + ] + } +} + +didchange_notification_range = { + "method": "textDocument/didChange", + "params": { + "textDocument": { + "uri": "file://", + "languageId": "python", + "version": 1, + "text": "" + }, + "contentChanges": [ + { + "range": { + "start": { + "line": 1, + "character": 1, + }, + "end": { + "line": 1, + "character": 1, + }, + "rangeLength": 0 + } + } + ] + } +} + + +# CompletionTriggerKind = 1 | 2 | 3; +# export const Invoked: 1 = 1; +# export const TriggerCharacter: 2 = 2; +# export const TriggerForIncompleteCompletions: 3 = 3; +completion_request = { + "method": "textDocument/completion", + "params": { + "textDocument": { + "uri": "file://", + "languageId": "python", + "version": 1, + "text": "" + }, + "position": { + "line": 5, + "character": 12, + "offset": 0 + }, + "contet": { + "triggerKind": 3, + "triggerCharacter": "" + } + } +} + +definition_request = { + "method": "textDocument/definition", + "params": { + "textDocument": { + "uri": "file://", + "languageId": "python", + "version": 1, + "text": "" + }, + "position": { + "line": 5, + "character": 12, + "offset": 0 + } + } +} + +references_request = { + "method": "textDocument/references", + "params": { + "context": { + "includeDeclaration": False + }, + "textDocument": { + "uri": "file://", + "languageId": "python", + "version": 1, + "text": "" + }, + "position": { + "line": 30, + "character": 13, + "offset": 0 + } + } +} + + +symbols_request = { + "method": "textDocument/documentSymbol", + "params": { + "textDocument": { + "uri": "file://", + "languageId": "python", + "version": 1, + "text": "" + } + } +} \ No newline at end of file diff --git a/src/libs/dto/code/lsp/lsp_structs.py b/src/libs/dto/code/lsp/lsp_structs.py new file mode 100644 index 0000000..7ec4449 --- /dev/null +++ b/src/libs/dto/code/lsp/lsp_structs.py @@ -0,0 +1,571 @@ +# Python imports +import enum + +# Lib imports + +# Application imports + + + +def to_type(o, new_type): + ''' + Helper funciton that receives an object or a dict and convert it to a new + given type. + + :param object|dict o: The object to convert + :param Type new_type: The type to convert to. + ''' + + return o if new_type == type(o) else new_type(**o) + + +class Position(object): + def __init__(self, line, character): + """ + Constructs a new Position instance. + + :param int line: Line position in a document (zero-based). + :param int character: Character offset on a line in a document + (zero-based). + """ + self.line = line + self.character = character + + +class Range(object): + def __init__(self, start, end): + """ + Constructs a new Range instance. + + :param Position start: The range's start position. + :param Position end: The range's end position. + """ + self.start = to_type(start, Position) + self.end = to_type(end, Position) + + +class Location(object): + """ + Represents a location inside a resource, such as a line inside a text file. + """ + + def __init__(self, uri, range): + """ + Constructs a new Location instance. + + :param str uri: Resource file. + :param Range range: The range inside the file + """ + self.uri = uri + self.range = to_type(range, Range) + + +class LocationLink(object): + """ + Represents a link between a source and a target location. + """ + + def __init__(self, originSelectionRange, targetUri, targetRange, targetSelectionRange): + """ + Constructs a new LocationLink instance. + + :param Range originSelectionRange: Span of the origin of this link. + Used as the underlined span for mouse interaction. Defaults to the word range at the mouse position. + :param str targetUri: The target resource identifier of this link. + :param Range targetRange: The full target range of this link. If the target for example is a symbol then target + range is the range enclosing this symbol not including leading/trailing whitespace but everything else + like comments. This information is typically used to highlight the range in the editor. + :param Range targetSelectionRange: The range that should be selected and revealed when this link is being followed, + e.g the name of a function. Must be contained by the the `targetRange`. See also `DocumentSymbol#range` + """ + self.originSelectionRange = to_type(originSelectionRange, Range) + self.targetUri = targetUri + self.targetRange = to_type(targetRange, Range) + self.targetSelectionRange = to_type(targetSelectionRange, Range) + + +class Diagnostic(object): + def __init__(self, range, severity, code, source, message, relatedInformation): + """ + Constructs a new Diagnostic instance. + :param Range range: The range at which the message applies.Resource file. + :param int severity: The diagnostic's severity. Can be omitted. If omitted it is up to the + client to interpret diagnostics as error, warning, info or hint. + :param str code: The diagnostic's code, which might appear in the user interface. + :param str source: A human-readable string describing the source of this + diagnostic, e.g. 'typescript' or 'super lint'. + :param str message: The diagnostic's message. + :param list relatedInformation: An array of related diagnostic information, e.g. when symbol-names within + a scope collide all definitions can be marked via this property. + """ + self.range = range + self.severity = severity + self.code = code + self.source = source + self.message = message + self.relatedInformation = relatedInformation + + +class DiagnosticSeverity(object): + Error = 1 + Warning = 2 # TODO: warning is known in python + Information = 3 + Hint = 4 + + +class DiagnosticRelatedInformation(object): + def __init__(self, location, message): + """ + Constructs a new Diagnostic instance. + :param Location location: The location of this related diagnostic information. + :param str message: The message of this related diagnostic information. + """ + self.location = location + self.message = message + + +class Command(object): + def __init__(self, title, command, arguments): + """ + Constructs a new Diagnostic instance. + :param str title: Title of the command, like `save`. + :param str command: The identifier of the actual command handler. + :param list argusments: Arguments that the command handler should be invoked with. + """ + self.title = title + self.command = command + self.arguments = arguments + + +class TextDocumentItem(object): + """ + An item to transfer a text document from the client to the server. + """ + def __init__(self, uri, languageId, version, text): + """ + Constructs a new Diagnostic instance. + + :param DocumentUri uri: uri file path. + :param str languageId: The identifier of the actual command handler. + :param int version: Arguments that the command handler should be invoked with. + :param str text: Arguments that the command handler should be invoked with. + """ + self.uri = uri + self.languageId = languageId + self.version = version + self.text = text + + +class TextDocumentIdentifier(object): + """ + Text documents are identified using a URI. On the protocol level, URIs are passed as strings. + """ + def __init__(self, uri): + """ + Constructs a new TextDocumentIdentifier instance. + + :param DocumentUri uri: The text document's URI. + """ + self.uri = uri + + +class VersionedTextDocumentIdentifier(TextDocumentIdentifier): + """ + An identifier to denote a specific version of a text document. + """ + def __init__(self, uri, version): + """ + Constructs a new TextDocumentIdentifier instance. + + :param DocumentUri uri: The text document's URI. + :param int version: The version number of this document. If a versioned + text document identifier is sent from the server to the client and + the file is not open in the editor (the server has not received an + open notification before) the server can send `null` to indicate + that the version is known and the content on disk is the truth (as + speced with document content ownership). + The version number of a document will increase after each change, including + undo/redo. The number doesn't need to be consecutive. + """ + super(VersionedTextDocumentIdentifier, self).__init__(uri) + self.version = version + + +class TextDocumentContentChangeEvent(object): + """ + An event describing a change to a text document. If range and rangeLength are omitted + the new text is considered to be the full content of the document. + """ + def __init__(self, range, rangeLength, text): + """ + Constructs a new TextDocumentContentChangeEvent instance. + + :param Range range: The range of the document that changed. + :param int rangeLength: The length of the range that got replaced. + :param str text: The new text of the range/document. + """ + self.range = range + self.rangeLength = rangeLength + self.text = text + + +class TextDocumentPositionParams(object): + """ + A parameter literal used in requests to pass a text document and a position inside that document. + """ + def __init__(self, textDocument, position): + """ + Constructs a new TextDocumentPositionParams instance. + + :param TextDocumentIdentifier textDocument: The text document. + :param Position position: The position inside the text document. + """ + self.textDocument = textDocument + self.position = position + + +class LANGUAGE_IDENTIFIER(object): + BAT = "bat" + BIBTEX = "bibtex" + CLOJURE = "clojure" + COFFESCRIPT = "coffeescript" + C = "c" + CPP = "cpp" + CSHARP = "csharp" + CSS = "css" + DIFF = "diff" + DOCKERFILE = "dockerfile" + FSHARP = "fsharp" + GIT_COMMIT = "git-commit" + GIT_REBASE = "git-rebase" + GO = "go" + GROOVY = "groovy" + HANDLEBARS = "handlebars" + HTML = "html" + INI = "ini" + JAVA = "java" + JAVASCRIPT = "javascript" + JSON = "json" + LATEX = "latex" + LESS = "less" + LUA = "lua" + MAKEFILE = "makefile" + MARKDOWN = "markdown" + OBJECTIVE_C = "objective-c" + OBJECTIVE_CPP = "objective-cpp" + Perl = "perl" + PHP = "php" + POWERSHELL = "powershell" + PUG = "jade" + PYTHON = "python" + R = "r" + RAZOR = "razor" + RUBY = "ruby" + RUST = "rust" + SASS = "sass" + SCSS = "scss" + ShaderLab = "shaderlab" + SHELL_SCRIPT = "shellscript" + SQL = "sql" + SWIFT = "swift" + TYPE_SCRIPT = "typescript" + TEX = "tex" + VB = "vb" + XML = "xml" + XSL = "xsl" + YAML = "yaml" + + +class SymbolKind(enum.Enum): + File = 1 + Module = 2 + Namespace = 3 + Package = 4 + Class = 5 + Method = 6 + Property = 7 + Field = 8 + Constructor = 9 + Enum = 10 + Interface = 11 + Function = 12 + Variable = 13 + Constant = 14 + String = 15 + Number = 16 + Boolean = 17 + Array = 18 + Object = 19 + Key = 20 + Null = 21 + EnumMember = 22 + Struct = 23 + Event = 24 + Operator = 25 + TypeParameter = 26 + + +class SymbolInformation(object): + """ + Represents information about programming constructs like variables, classes, interfaces etc. + """ + def __init__(self, name, kind, location, containerName = None, deprecated = False): + """ + Constructs a new SymbolInformation instance. + + :param str name: The name of this symbol. + :param int kind: The kind of this symbol. + :param bool Location: The location of this symbol. The location's range is used by a tool + to reveal the location in the editor. If the symbol is selected in the + tool the range's start information is used to position the cursor. So + the range usually spans more then the actual symbol's name and does + normally include things like visibility modifiers. + + The range doesn't have to denote a node range in the sense of a abstract + syntax tree. It can therefore not be used to re-construct a hierarchy of + the symbols. + :param str containerName: The name of the symbol containing this symbol. This information is for + user interface purposes (e.g. to render a qualifier in the user interface + if necessary). It can't be used to re-infer a hierarchy for the document + symbols. + :param bool deprecated: Indicates if this symbol is deprecated. + """ + self.name = name + self.kind = SymbolKind(kind) + self.deprecated = deprecated + self.location = to_type(location, Location) + self.containerName = containerName + + +class ParameterInformation(object): + """ + Represents a parameter of a callable-signature. A parameter can + have a label and a doc-comment. + """ + def __init__(self, label, documentation = ""): + """ + Constructs a new ParameterInformation instance. + + :param str label: The label of this parameter. Will be shown in the UI. + :param str documentation: The human-readable doc-comment of this parameter. Will be shown in the UI but can be omitted. + """ + self.label = label + self.documentation = documentation + + +class SignatureInformation(object): + """ + Represents the signature of something callable. A signature + can have a label, like a function-name, a doc-comment, and + a set of parameters. + """ + def __init__(self, label, documentation = "", parameters = []): + """ + Constructs a new SignatureInformation instance. + + :param str label: The label of this signature. Will be shown in the UI. + :param str documentation: The human-readable doc-comment of this signature. Will be shown in the UI but can be omitted. + :param ParameterInformation[] parameters: The parameters of this signature. + """ + self.label = label + self.documentation = documentation + self.parameters = [to_type(parameter, ParameterInformation) for parameter in parameters] + + +class SignatureHelp(object): + """ + Signature help represents the signature of something + callable. There can be multiple signature but only one + active and only one active parameter. + """ + def __init__(self, signatures, activeSignature = 0, activeParameter = 0): + """ + Constructs a new SignatureHelp instance. + + :param SignatureInformation[] signatures: One or more signatures. + :param int activeSignature: + :param int activeParameter: + """ + self.signatures = [to_type(signature, SignatureInformation) for signature in signatures] + self.activeSignature = activeSignature + self.activeParameter = activeParameter + + +class CompletionTriggerKind(object): + Invoked = 1 + TriggerCharacter = 2 + TriggerForIncompleteCompletions = 3 + + +class CompletionContext(object): + """ + Contains additional information about the context in which a completion request is triggered. + """ + def __init__(self, triggerKind, triggerCharacter = None): + """ + Constructs a new CompletionContext instance. + + :param CompletionTriggerKind triggerKind: How the completion was triggered. + :param str triggerCharacter: The trigger character (a single character) that has trigger code complete. + Is undefined if `triggerKind !== CompletionTriggerKind.TriggerCharacter` + """ + self.triggerKind = triggerKind + if triggerCharacter: + self.triggerCharacter = triggerCharacter + + +class TextEdit(object): + """ + A textual edit applicable to a text document. + """ + def __init__(self, range, newText): + """ + :param Range range: The range of the text document to be manipulated. To insert + text into a document create a range where start === end. + :param str newText: The string to be inserted. For delete operations use an empty string. + """ + self.range = range + self.newText = newText + + +class InsertTextFormat(object): + PlainText = 1 + Snippet = 2 + + +class CompletionItem(object): + """ + """ + def __init__(self, label, \ + kind = None, \ + detail = None, \ + documentation = None, \ + deprecated = None, \ + preselect = None, \ + sortText = None, \ + filterText = None, \ + insertText = None, \ + insertTextFormat = None, \ + textEdit = None, \ + additionalTextEdits = None, \ + commitCharacters = None, \ + command = None, \ + data = None, \ + score = 0.0 + ): + """ + :param str label: The label of this completion item. By default also the text that is inserted when selecting + this completion. + :param int kind: The kind of this completion item. Based of the kind an icon is chosen by the editor. + :param str detail: A human-readable string with additional information about this item, like type or symbol information. + :param tr ocumentation: A human-readable string that represents a doc-comment. + :param bool deprecated: Indicates if this item is deprecated. + :param bool preselect: Select this item when showing. Note: that only one completion item can be selected and that the + tool / client decides which item that is. The rule is that the first item of those that match best is selected. + :param str sortText: A string that should be used when comparing this item with other items. When `falsy` the label is used. + :param str filterText: A string that should be used when filtering a set of completion items. When `falsy` the label is used. + :param str insertText: A string that should be inserted into a document when selecting this completion. When `falsy` the label is used. + The `insertText` is subject to interpretation by the client side. Some tools might not take the string literally. For example + VS Code when code complete is requested in this example `con` and a completion item with an `insertText` of `console` is provided it + will only insert `sole`. Therefore it is recommended to use `textEdit` instead since it avoids additional client side interpretation. + @deprecated Use textEdit instead. + :param InsertTextFormat insertTextFormat: The format of the insert text. The format applies to both the `insertText` property + and the `newText` property of a provided `textEdit`. + :param TextEdit textEdit: An edit which is applied to a document when selecting this completion. When an edit is provided the value of `insertText` is ignored. + Note:* The range of the edit must be a single line range and it must contain the position at which completion + has been requested. + :param TextEdit additionalTextEdits: An optional array of additional text edits that are applied when selecting this completion. + Edits must not overlap (including the same insert position) with the main edit nor with themselves. + Additional text edits should be used to change text unrelated to the current cursor position + (for example adding an import statement at the top of the file if the completion item will + insert an unqualified type). + :param str commitCharacters: An optional set of characters that when pressed while this completion is active will accept it first and + then type that character. *Note* that all commit characters should have `length=1` and that superfluous + characters will be ignored. + :param Command command: An optional command that is executed *after* inserting this completion. Note: that + additional modifications to the current document should be described with the additionalTextEdits-property. + :param data: An data entry field that is preserved on a completion item between a completion and a completion resolve request. + :param float score: Score of the code completion item. + """ + self.label = label + self.kind = kind + self.detail = detail + self.documentation = documentation + self.deprecated = deprecated + self.preselect = preselect + self.sortText = sortText + self.filterText = filterText + self.insertText = insertText + self.insertTextFormat = insertTextFormat + self.textEdit = textEdit + self.additionalTextEdits = additionalTextEdits + self.commitCharacters = commitCharacters + self.command = command + self.data = data + self.score = score + + +class CompletionItemKind(enum.Enum): + Text = 1 + Method = 2 + Function = 3 + Constructor = 4 + Field = 5 + Variable = 6 + Class = 7 + Interface = 8 + Module = 9 + Property = 10 + Unit = 11 + Value = 12 + Enum = 13 + Keyword = 14 + Snippet = 15 + Color = 16 + File = 17 + Reference = 18 + Folder = 19 + EnumMember = 20 + Constant = 21 + Struct = 22 + Event = 23 + Operator = 24 + TypeParameter = 25 + + +class CompletionList(object): + """ + Represents a collection of [completion items](#CompletionItem) to be preselect in the editor. + """ + def __init__(self, isIncomplete, items): + """ + Constructs a new CompletionContext instance. + + :param bool isIncomplete: This list it not complete. Further typing should result in recomputing this list. + :param CompletionItem items: The completion items. + """ + self.isIncomplete = isIncomplete + self.items = [to_type(i, CompletionItem) for i in items] + +class ErrorCodes(enum.Enum): + # Defined by JSON RPC + ParseError = -32700 + InvalidRequest = -32600 + MethodNotFound = -32601 + InvalidParams = -32602 + InternalError = -32603 + serverErrorStart = -32099 + serverErrorEnd = -32000 + ServerNotInitialized = -32002 + UnknownErrorCode = -32001 + + # Defined by the protocol. + RequestCancelled = -32800 + ContentModified = -32801 + +class ResponseError(Exception): + def __init__(self, code, message, data = None): + self.code = code + self.message = message + if data: + self.data = data \ No newline at end of file diff --git a/src/libs/event_factory.py b/src/libs/event_factory.py index 7354eb4..b9d42bf 100644 --- a/src/libs/event_factory.py +++ b/src/libs/event_factory.py @@ -9,17 +9,17 @@ import re from .singleton import Singleton from .dto.base_event import BaseEvent -from .dto import code +from .dto.code import events as code class EventFactory(Singleton): def __init__(self): + self._event_classes: Dict[str, Type[BaseEvent]] = {} self._auto_register_events( code.__dict__.items() ) - def register_event(self, event_type: str, event_class: Type[BaseEvent]): self._event_classes[event_type] = event_class