Представьте: у вас есть N редакторов или IDE и M языков программирования. Получается, что для их корректной работы вам необходимо поддерживать N*M плагинов. Но что, если таких редакторов и языков много?.. Решением может стать LSP — единый интерфейс взаимодействия языкового сервера и редакторов, который помогает сузить проблему до N+М.

Меня зовут Денис Маматин, я работаю в отделе R&D СберТеха. Наша команда занимается разработкой и тестированием новых технологий. В этой статье я расскажу, что такое LSP‑протокол, как он поможет упростить разработку, и рассмотрю небольшой пример LSP‑сервера.

Что такое LSP

LSP (Language Server Protocol) — это протокол между редактором или IDE и языковым сервером, который расширяет работу с текстовым документом. Протокол поддерживает ряд функций для удобного редактирования текста. Например, автодополнение (auto complete), переход к объявлению (go to), найти все вхождения (find all) и другие.

Основная идея LSP — стандартизировать взаимодействие между языковым сервером и инструментом разработки (IDE). Один LSP‑сервер используется для разных редакторов (VScode, IntelliJ, vim).

Проблема N*M

До создания LSP редактору приходилось поддерживать каждый из языков программирования. Допустим, если у вас N редакторов и M языков программирования, вам требуется N*M плагинов. На рисунке ниже можно увидеть пример того, как это выглядит.

Эта проблема касается не только разработчиков этих плагинов, но и рядовых программистов. К примеру, вышла новая версия Go, в которой добавили дженерики (они уже есть, но допустим). Я как разработчик хочу от IDE базовую функцию — подсказку ошибок компиляции. Помимо того, что у меня этого нет, IDE указывает, что мой код с дженериками — ошибка. А всё потому, что IDE не поддерживает новую версию Go. Хотя в той же Java дженерики существуют много лет, и IDE умеет с ними работать.

Теперь всё, что я могу сделать, — ждать и верить, что разработчики IDE добавят поддержку. И это ещё было бы полбеды, если бы не одно «но»: у нас N редакторов. Петя ждёт новую версию для редактора-1, и спустя день её получает, а Вася ждёт месяц, потому что редактор-2 поддерживается одним контрибьютором. Ещё раз напомню, что у нас M языков программирования. Как разработчик IDE, я бы ушёл плакать.

И тут на помощь приходит LSP: с ним проблема сужается до N+M. Получаем ситуацию, как на рисунке ниже.

Подробнее о том, чем полезен LSP, можно почитать тут.

Спецификация

Протокол состоит из двух частей: HTTP‑заголовок и контент. Требуется только один заголовок — Content‑Length. Контент — это сообщение в формате JsonRPC. Пример запроса:

Content-Length: ...\r\n
\r\n
{
	"jsonrpc": "2.0",
	"id": 1,
	"method": "textDocument/completion",
	"params": {
		...
	}
}
Пример ответа:
Content-Length: ...\r\n
\r\n
{
	"jsonrpc": "2.0",
	"id": 1,
	"result": [
		...
	]
}

Помимо обычного «запрос‑ответ» клиент и сервер могут отправлять уведомления (notification). Уведомление — это сообщение, которое не требует ответа. Например, открытие документа.

В документации сообщения определены как TypeScript-интерфейсы.

interface Message {
	jsonrpc: string;
}

interface RequestMessage extends Message {
	id: integer | string;
	method: string;
	params?: array | object;
}

interface NotificationMessage extends Message {
	method: string;
	params?: array | object;
}

interface ResponseMessage extends Message {
	id: integer | string | null;
	result?: string | number | boolean | array | object | null;
	error?: ResponseError;
}

interface ResponseError {
	code: integer;
	message: string;
	data?: string | number | boolean | array | object | null;
}

Допустим, хотим найти объявление структуры. Тогда сообщение от клиента выглядело бы так:

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "textDocument/definition",
  "params": {
    "textDocument": {
      "uri": "file:///someFolder/main.go"
    },
    "position": {. // позиция, где находился курсор
      "line": 1,
      "character": 13
    }
  }
}

Ответ сервера:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "uri": "file:///someFolder/exampleStructure.go",
    "range": {
      "start": {
        "line": 0,
        "character": 6
      },
      "end": {
        "line": 0,
        "character": 12
      }
    }
  }
}

Несколько примеров других функций:

  • textDocument/hover— наведение курсора на позицию в тексте.

    JSON request:

    {
        "jsonrpc": "2.0",
        "id": 1,
        "method": "textDocument/hover",
        "params": {
            "textDocument": {
                "uri": "file:///path/to/document.txt"
            },
            "position": {
                "line": 10,
                "character": 3
            }
        }
    }

    JSON response:

    {
      "contents": [
        {
          "value": "Some value",
          "kind": "markdown"
        }
      ],
      "range": {
        "start": {
          "line": 0,
          "character": 0
        },
        "end": {
          "line": 0,
          "character": 6
        }
      }
    }
  • textDocument/completion — автодополнение

    JSON request:

    {
        "jsonrpc": "2.0",
        "id": 2,
        "method": "textDocument/completion",
        "params": {
            "textDocument": {
                "uri": "file:///path/to/document.txt"
            },
            "position": {
                "line": 5,
                "character": 10
            }
        }
    }

    JSON response:

    "isIncomplete": false,
      "items": [
        {
          "label": "Some label",
          "kind": 1,
          "documentation": "Some doc",
          "detail": "console",
          "textEdit": {
            "newText": "Some label",
            "range": {
              "start": {
                "line": 3,
                "character": 1
              },
              "end": {
                "line": 3,
                "character": 1
              }
            }
          }
        }
      ]
    }
  • textDocument/signatureHelp — получение справки/помощи по текущей позиции курсора

    JSON request:

    {
        "jsonrpc": "2.0",
        "id": 3,
        "method": "textDocument/signatureHelp",
        "params": {
            "textDocument": {
                "uri": "file:///path/to/document.txt"
            },
            "position": {
                "line": 8,
                "character": 5
            }
        }
    }

    JSON response:

    {
      "signatures": [
        {
          "label": "print()",
          "documentation": "Prints a message to the console.",
          "parameters": []
        }
      ]
    }
  • references - найти ссылки

    JSON request:

    {
        "jsonrpc": "2.0",
        "id": 4,
        "method": "textDocument/references",
        "params": {
            "textDocument": {
                "uri": "file:///path/to/document.txt"
            },
            "position": {
                "line": 12,
                "character": 8
            },
            "context": {
                "includeDeclaration": true
            }
        }
    }

    JSON response:

    [
      {
        "uri": "file:///path/to/document.txt",
        "range": {
          "start": {
            "line": 10,
            "character": 5
          },
          "end": {
            "line": 10,
            "character": 10
          }
        }
      }
    ]
  • documentHighlight — выделение всех вхождений символа в области видимости. Например, в файле выделяются все места, где «упоминается» название функции.

    JSON request:

    {
        "jsonrpc": "2.0",
        "id": 5,
        "method": "textDocument/documentHighlight",
        "params": {
            "textDocument": {
                "uri": "file:///path/to/document.txt"
            },
            "position": {
                "line": 3,
                "character": 7
            }
        }
    }

    JSON response:

    [
      {
        "range": {
          "start": {
            "line": 10,
            "character": 5
          },
          "end": {
            "line": 10,
            "character": 10
          }
        },
        "kind": "write"
      }
    ]
  • textDocument/formatting — форматировать весь документ

    JSON request:

    {
        "jsonrpc": "2.0",
        "id": 7,
        "method": "textDocument/formatting",
        "params": {
            "textDocument": {
                "uri": "file:///path/to/document.txt"
            },
            "options": {
                "tabSize": 4,
                "insertSpaces": true
            }
        }
    }

    JSON response:

    [
    	{
          "range": {
            "start": {
              "line": 0,
              "character": 0
            },
            "end": {
              "line": 10,
              "character": 0
            }
          },
          "newText": "some text"
        }
    ]
  • textDocument/declaration — перейти к объявлению

    JSON request:

    {
        "jsonrpc": "2.0",
        "id": 8,
        "method": "textDocument/declaration",
        "params": {
            "textDocument": {
                "uri": "file:///path/to/document.txt"
            },
            "position": {
                "line": 10,
                "character": 5
            }
        }
    }

    JSON response:

    {
      "uri": "file:///path/to/document.txt",
      "range": {
        "start": {
          "line": 10,
          "character": 5
        },
        "end": {
          "line": 10,
          "character": 10
        }
      }
    }
  • textDocument/definition — перейти к определению

    JSON request:

    {
        "jsonrpc": "2.0",
        "id": 9,
        "method": "textDocument/definition",
        "params": {
            "textDocument": {
                "uri": "file:///path/to/document.txt"
            },
            "position": {
                "line": 8,
                "character": 3
            }
        }
    }

    JSON response:

    {
      "uri": "file:///path/to/document.txt",
      "range": {
        "start": {
          "line": 10,
          "character": 5
        },
        "end": {
          "line": 10,
          "character": 10
        }
      }
    }
  • textDocument/rename — переименовать

    JSON request:

    {
        "jsonrpc": "2.0",
        "id": 11,
        "method": "textDocument/rename",
        "params": {
            "textDocument": {
                "uri": "file:///path/to/document.txt"
            },
            "position": {
                "line": 3,
                "character": 7
            },
            "newName": "newIdentifier"
        }
    }

    JSON response:

    {
      "changes": {
        "file:///path/to/document.txt": [
          {
            "range": {
              "start": {
                "line": 0,
                "character": 10
              },
              "end": {
                "line": 0,
                "character": 15
              }
            },
            "newText": "newName"
          }
        ]
      "documentChanges": [
        {
          "textDocument": {
            "uri": "file:///path/to/document.txt",
            "version": 1
          },
          "edits": [
            {
              "range": {
                "start": {
                  "line": 0,
                  "character": 10
                },
                "end": {
                  "line": 0,
                  "character": 15
                }
              },
              "newText": "newName"
            }
          ]
        }
      ]
    }

Инициализация

В спецификации описан большой набор функций (в документации — capabilities), которые могут поддерживать клиент и сервер. Однако было бы странно требовать от клиента или сервера поддержку всех доступных функций. Поэтому клиент и сервер вначале обмениваются initialize‑сообщениями, в которых указывают, что они «умеют».


Само сообщение выглядело бы как‑то так:

{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "initialize",
    "params": {
        "processId": null,
        "rootUri": "file:///path/to/root",
        "capabilities": {
            "textDocument": {
                "synchronization": {
                    "willSave": true,
                    "willSaveWaitUntil": true,
                    "didSave": true
                },
                "hover": {
                    "dynamicRegistration": true
                },
                "completion": {
                    "dynamicRegistration": true,
                    "completionItem": {
                        "snippetSupport": true
                    }
                },
                "signatureHelp": {
                    "dynamicRegistration": true
                }
            },
            "workspace": {
                "symbol": {
                    "dynamicRegistration": true
                }
            }
        }
    }
}

Ещё один сценарий

  1. Когда пользователь открывает файл в редакторе, клиент сообщает языковому серверу о факте открытия документа посредством сообщения «textDocument/didOpen». Содержимое документа перестает храниться в файловой системе и переносится в оперативную память инструмента.

  2. При внесении изменений в документ клиент сообщает серверу о модификациях с помощью сообщения «textDocument/didChange». В ответ на это языковой сервер обновляет семантические данные программы, анализирует их и отправляет инструменту сведения об ошибках и предупреждениях через сообщение «textDocument/publishDiagnostics».

  3. Когда пользователь выбирает команду «Перейти к определению» для символа в редакторе, клиент отправляет на сервер запрос «textDocument/definition». Запрос содержит два параметра: URI документа и позицию в тексте, откуда был инициирован запрос. В ответ сервер передает URI документа и позицию определения символа в этом документе.

  4. При закрытии документа (файла) пользователь инициирует отправку клиентом уведомления «textDocument/didClose». Это уведомление информирует языковой сервер о том, что документ больше не загружен в оперативную память и содержимое файла в файловой системе обновлено.

Резюмируя

  • Сообщения состоят из заголовка и тела jsonRPC.

  • Сообщения могут быть запросами/ответами или уведомлениями, которые не требуют ответа.

  • Текстовому редактору не нужно ждать ответа на запрос для отправки дополнительных запросов.

  • Клиент может отправлять уведомления, такие как textDocument/didOpen или textDocument/publishDiagnostics.

  • Клиент и сервер обмениваются initialize‑сообщениями с объявлением поддерживаемых функций.

LSP-сервер

Давайте напишем свой небольшой LSP‑сервер. В качестве основного инструмента возьмем golang, можно и любой другой язык программирования.

Клиент

Но для начала нужен клиент, чтобы запустить сервер. У VSCode и JetBrains IDE есть шаблон расширения для клиента, в который нужно внести незначительные правки, и всё будет работать. Рассмотрим пример для VScode из Language Server Extension Guide — для детального погружения советую его изучить. Склонируем репозиторий и соберём проект.

git clone https://github.com/microsoft/vscode-extension-samples.git
cd vscode-extension-samples/lsp-sample
npm install
npm run compile
code .

Получим следующую структуру проекта:

├── client
│   ├── src
│   │   ├── test
│   │   └── extension.ts // тут будет лежать основной код для расширения
├── package.json
└── server // Сервер у нас свой, это мы удаляем
    └── src
        └── server.ts

VSCode поддерживает общение с сервером через IPC, сокет и поток вывода. В нашем примере рассмотрим работу через сокет. Также добавим в настройки расширения порт, который нужно слушать, и удалим из tsconfig.json ссылку на пакет с сервером.

В package.json добавим порт и удалим всё, что связано с сервером:

{
	"name": "lsp-sample",
	"description": "A language server example",
	"author": "Microsoft Corporation",
	"license": "MIT",
	"version": "1.0.0",
	"repository": {
		"type": "git",
		"url": "https://github.com/Microsoft/vscode-extension-samples"
	},
	"publisher": "vscode-samples",
	"categories": [],
	"keywords": [
		"multi-root ready"
	],
	"engines": {
		"vscode": "^1.75.0"
	},
	"activationEvents": [
		"onLanguage:plaintext"
	],
	"main": "./client/out/extension",
	"contributes": {
		"configuration": {
			"type": "object",
			"title": "Configuration",
			"properties": {
				"serverPort": {
					"type": "number",
					"default": 9091,
					"description": "Port for lsp server"
				}
			}
		}
	},
	"scripts": {
		"vscode:prepublish": "npm run compile",
		"compile": "tsc -b",
		"watch": "tsc -b -w",
		"lint": "eslint ./client/src  --ext .ts,.tsx",
		"postinstall": "cd client && npm install && cd ..",
		"test": "sh ./scripts/e2e.sh"
	},
	"devDependencies": {
		"@types/mocha": "^10.0.6",
		"@types/node": "^18.14.6",
		"@typescript-eslint/eslint-plugin": "^7.1.0",
		"@typescript-eslint/parser": "^7.1.0",
		"eslint": "^8.57.0",
		"mocha": "^10.3.0",
		"typescript": "^5.3.3"
	}
}

Код расширения:

import * as vscode from "vscode";
import { workspace, ExtensionContext } from 'vscode';
import * as net from 'net';
import {
	LanguageClient,
	LanguageClientOptions,
	StreamInfo
} from 'vscode-languageclient/node';

let client: LanguageClient;

export function activate(context: ExtensionContext) {
	const config = vscode.workspace.getConfiguration();
	const serverPort: string = config.get("serverPort"); // Получаем порт из настроек окружения vscode
	vscode.window.showInformationMessage(`Starting LSP client on port: ` + serverPort);  // Отправим пользователю информацию о запуске расширения


	const connectionInfo = {
        port: Number(serverPort),
		host: "localhost"
    };
    const serverOptions = () => {
        // Подключение по сокету
        const socket = net.connect(connectionInfo);
        const result: StreamInfo = {
            writer: socket,
            reader: socket
        };
        return Promise.resolve(result);
    };

	const clientOptions: LanguageClientOptions = {
		documentSelector: [{ scheme: 'file', language: 'yaml' }], // Указываем расширение файлов, с которыми поддерживаем работу
		synchronize: {
			fileEvents: workspace.createFileSystemWatcher('**/.clientrc')
		}
	};

	client = new LanguageClient(
		'languageServerExample',
		'Language Server Example',
		serverOptions,
		clientOptions
	);

	client.start();
}

export function deactivate(): Thenable<void> | undefined {
	if (!client) {
		return undefined;
	}
	return client.stop();
}

Сервер

Рассмотрим пример сервера для YAML‑файлов. Сервер будет поддерживать функции hover, completion, initialize, didChange, didOpen, diagnostic. Основной упор будет на поддержке LSP‑протокола, а логика обработки этих функций будет на заглушках.

Для обмена сообщениями потребуются модели, описанные в протоколе, но переносить их самостоятельно — затратное и неинтересное занятие. Поэтому возьмём уже сгенерированные из gopls — LSP‑сервер для Go (лицензия позволяет). Нужны файлы tsprotocol.go, uril.go, tsdocument_changes.go, pathutil/utils.go.

Создадим маршрутизатор, который будет обрабатывать вызванный метод, читать из сокета и писать в него ответ.

type Mux struct {
	concurrencyLimit     int64
	requestHandlers      map[string]handlers.Request
	notificationHandlers map[string]handlers.Notification
	writeLock            *sync.Mutex
	conn                 *net.Conn
}

Есть два вида обработчиков: запросы (initialize, hover, completion) и уведомления (initialized, didOpen, didChange). Отличие в том, что уведомление не ожидает ответа.

type Request interface {
	Call(params json.RawMessage) (any, error)
}
type Notification interface {
	Call(params json.RawMessage) error
}

Создадим сервер, который создает соединение, добавляет обработчики и запускает диагностику кода. Для диагностики создадим канал, в который будут приходить изменения в коде — в нашем случае всё содержимое. Сам процесс диагностики — это отдельная горутина, которая читает канал с изменениями в коде и отправляет какие‑либо уведомления на клиент.

func (s *Server) runDiagnostic(documentUpdates chan protocol.TextDocumentItem) {
	for doc := range documentUpdates {
		diagnostics := s.createDiagnostics(doc)

		err := s.mux.Notify(
			handlers.PublishDiagnosticsMethod,
			protocol.PublishDiagnosticsParams{
				URI:         doc.URI,
				Version:     doc.Version,
				Diagnostics: diagnostics,
			})
		if err != nil {
			slog.Error("error to send diagnostic notify", slog.Any("err", err))
			return
		}
	}
}

Функции сервера

Первым делом надо сообщить редактору, какие функции поддерживает сервер. Для этого надо отправить ответ клиенту на событие initialize. Указываем поддержку hover и completion и тип синхронизации. Есть несколько видов синхронизации: отсутствие, полная (на сервер приходит весь файл при изменении), последовательная («посимвольная»). Мы рассмотрим полную синхронизацию.

const (
	TextDocumentSyncKindNone protocol.TextDocumentSyncKind = iota
	TextDocumentSyncKindFull
	TextDocumentSyncKindIncremental
)

func (i Initialize) Call(params json.RawMessage) (any, error) {
	slog.Info("received initialize method")
	iParams, err := i.parseParams(params)
	if err != nil {
		slog.Error("Error to parse initialized params")
		return nil, err
	}
	slog.Debug("initialized params", slog.Any("params", *iParams))

	result := protocol.InitializeResult{
		Capabilities: protocol.ServerCapabilities{
			TextDocumentSync:   TextDocumentSyncKindFull,
			HoverProvider:      &protocol.Or_ServerCapabilities_hoverProvider{},
			CompletionProvider: &protocol.CompletionOptions{},
		},
		ServerInfo: &protocol.ServerInfo{
			Name:    meta.Name,
			Version: meta.Version,
		},
	}

	return result, nil
}

func (i Initialize) parseParams(params json.RawMessage) (*protocol.InitializedParams, error) {
	var initializeParams protocol.InitializedParams
	if err := json.Unmarshal(params, &initializeParams); err != nil {
		return nil, err
	}

	return &initializeParams, nil
}

Теперь надо определить два уведомления: didOpen и didChange, чтобы получать изменения в коде от клиента. В этих запросах передаем полученный код в канал изменений. Этот канал читает горутина с диагностикой.

type DidChange struct {
	documentUpdates chan protocol.TextDocumentItem
}

func NewDidChange(documentUpdates chan protocol.TextDocumentItem) *DidChange {
	return &DidChange{documentUpdates: documentUpdates}
}

func (d DidChange) Call(params json.RawMessage) error {
	slog.Info("received didChange notification")
	changeParams, err := d.parseParams(params)
	if err != nil {
		slog.Error("Error to parse didChange params")
		return err
	}
	slog.Debug("didChange params", slog.Any("params", *changeParams))

	d.documentUpdates <- protocol.TextDocumentItem{
		URI:     changeParams.TextDocument.URI,
		Version: changeParams.TextDocument.Version,
		Text:    changeParams.ContentChanges[0].Text,
	}

	return nil
}

Сообщения в этом канале выглядят следующим образом:

type TextDocumentItem struct {
	// путь до файла
	URI DocumentURI `json:"uri"`
	// Номер версии документа, увеличивается после change, including undo/redo
	Version int32 `json:"version"`
	// Содержимое документа
	Text string `json:"text"`
}

Для запросов hover и completion понадобится мапа, в которой будет храниться актуальное состояние файлов по их пути. Это состояние обновляем при чтении из канала изменений в документе. Hover во входных аргументах принимает:

type HoverParams struct {
	TextDocumentPositionParams // позиция курсора в документе (строка и номер символа)
	WorkDoneProgressParams // необязательный параметр о состоянии прогресса 
}

В ответ ожидается:

type Hover struct {
	// Отображаемый ответ. Может быть обычным текстом или markdown
	Contents MarkupContent `json:"contents"`
	// Можно выделить часть текста, для который выполняется hover
	Range Range `json:"range,omitempty"`
}

Обработка запроса:

func NewHover(fileURIToContents *map[string][]string) *Hover {
	return &Hover{fileURIToContents: fileURIToContents}
}

func (h Hover) Call(params json.RawMessage) (any, error) {
	slog.Info("received hover method")
	hParams, err := h.parseParams(params)
	if err != nil {
		slog.Error("Error to parse hover params")
		return nil, err
	}
	slog.Debug("hover params", slog.Any("params", *hParams))

	hoverItem := h.createHoverItem(hParams)

	return hoverItem, nil
}

func (h Hover) createHoverItem(hParams *protocol.HoverParams) *protocol.Hover {
	hoverItem := &protocol.Hover{
		Contents: protocol.MarkupContent{
			Kind:  protocol.Markdown,
			Value: "some hover",
		},
		Range: protocol.Range{
			Start: protocol.Position{
				Line:      hParams.Position.Line,
				Character: hParams.Position.Character,
			},
			End: protocol.Position{
				Line:      hParams.Position.Line,
				Character: hParams.Position.Character,
			},
		},
	}
	return hoverItem
}

Параметры запроса для completion:

type CompletionParams struct {
	// Опциональный параметр, контекст вызова. Например вызов по символу, как пример "." у инстанса класса
	Context CompletionContext `json:"context,omitempty"`
	TextDocumentPositionParams // позиция в документе, аналогично hover
	WorkDoneProgressParams // аналогично hover
	PartialResultParams // опционально, передача ответа в виде стрима
}

В ответ ожидается список с CompletionItem, с обязательным параметром «значение для вставки». 

func (c Completion) Call(params json.RawMessage) (any, error) {
	slog.Info("received completion method")
	completionParams, err := c.parseParams(params)
	if err != nil {
		slog.Error("Error to parse completion params")
		return nil, err
	}
	slog.Debug("completion params", slog.Any("params", *completionParams))

	suggestions := []protocol.CompletionItem{
		{
			Label: "some completion",
			Kind:  protocol.ValueCompletion,
		},
	}

	return suggestions, err
}

Как видно выше, все запросы и уведомления обрабатываются похожим образом. Наибольшую сложность представляет именно логика обработки документа — построение AST и работа с ним. Этот вопрос рассмотрим в другой статье. В самой имплементации протокола сложностей немного. Оптимальным подходом будет смотреть документацию и для примера заглядывать в существующие LSP‑серверы (например, gopls).

Подводя итоги

LSP‑протокол оказал большое влияние на разработку IDE. Поддерживать большой зоопарк языков для разных редакторов стало легче. Помимо этого, необязательно ограничиваться языком программирования. Можно использовать LSP‑протокол и для других документов, например OpenAPI, k8s‑файлы и др. В данной статье хочется привлечь внимание сообщества к этой технологии, чтобы появлялось больше решений для удобства разработки. Тем более, что в наше время всё больше набирают популярность такие подходы, как IaC и AaC.


Исходный код сервера: https://gitverse.ru/Asako/LSP-server-example

Исходный код клиента: https://gitverse.ru/Asako/LSP-client-example

Источники

Комментарии (0)