EmacsのLSPクライアントを書く

Emacsにはよく使われるLSPクライアントとしてEglotとlsp-modeの2つがある。この2つの実装を僕はあまり上手く使えた試しがない。設定をこねくり回したり、昔ながらのTAGSファイルを作るという手段でこれまでなんとか取り繕ってきたが、そろそろ限界だ。そこで小さなLSPクライアントをEmacs Lispで書いた。この記事は、その時の記録だ。

LSPサーバーはコンテナ内部で動かしたい

最近ではサプライチェーン攻撃をよく聞くため、パッケージのインストールが怖い。まあ以前から同様のリスクはあったのだろうけれど、最近は特に多く感じる。

そのためフロントエンドの開発、JavaScriptやTypeScriptを使う開発は、ほとんどコンテナ内部で行っている。当然、LSPサーバーもコンテナ内部で動かしたい。

期待する構成はこんな感じだ。

+---------------------------+             stdio              +----------------------------------+
| macOS host                | <---------------------------> | docker run --rm -i               |
|                           |                               |                                  |
|  +---------------------+  |                               |  +----------------------------+  |
|  | Emacs               |  |                               |  | typescript-language-server |  |
|  |                     |  |                               |  | --stdio                    |  |
|  |  app/page.tsx       |  |                               |  +-------------+--------------+  |
|  |  M-. / M-?          |  |                               |                |                 |
|  |  xref               |  |                               |                |                 |
|  |  my-lsp.el          |  |                               |                v                 |
|  +----------+----------+  |                               |      +-----------------------+   |
|             |             |                               |      | tsserver / TypeScript |   |
|             | file://     |                               |      +-----------+-----------+   |
|             v             |                               |                  |               |
|  /Users/.../example       | -- same absolute path mount ->|  /Users/.../example              |
+-------------+-------------+                               |                  |               |
              |                                             |                  v               |
              | browser                                     |      +-----------------------+   |
              v                                             |      | Next.js (npm run dev) |   |
    http://localhost:3000                                   |      +-----------------------+   |
                                                            +----------------------------------+
期待する構成

Eglotとlsp-mode

EmacsでLSPを使う場合、Eglotかlsp-modeのどちらかを使うことが多い。Eglotは実装が小さく、lsp-modeは厚めの実装となっている。

期待する構成、つまりdockerを使ってコンテナ内部でLSPサーバーを起動する構成を、Eglotやlsp-modeで行おうとすると沼にはまる。で、できるはず、できるはずなんだけれど、できない。LSPサーバーが起動しない、起動してもすぐ死ぬ、5秒に1回ぐらいEmacsが固まる、勝手にホスト側にLSPサーバーをインストールするなど、いろいろな問題が出て上手く扱えない。もしかしたらこの構成じゃなくても、5秒に1回ぐらいEmacsが固まることは発生するかもしれない。

いずれにしても、このままでは使えないし、このようなことをトラブルシューティングするのも嫌だ。Eglotもlsp-modeも少しだけコードも眺めた。コードの状態としては問題箇所の特定も難しそうだったし、すぐに修正できそうにないということは分かった。

ただDockerでLSPサーバーを起動しstdio経由でやりとりしたかっただけ

本来やりたいことは、それほど難しいことじゃない。ただDockerでLSPサーバーを起動しstdio経由でやりとりしたかっただけだ。

Emacsが固まる問題も明らかにLSPクライアントの実装に問題があるという予想は付いた。LSPサーバーを make-process で子プロセスとして非同期に起動し、その子プロセスの標準入出力とのやりとりをシンプルにするのであれば、そうそう固まらないはずだ。

LSPサーバーがやりとりとりするのは JSON-RPC 2.0 、実際に使う機能はタグジャンプや程度だ。

そんなことを考えていると徐々に、思うように動かないEglotやlsp-modeを使い続けるよりも、自分自身で必要最低限のLSPクライアントを実装することもできそうな気がしてきた。

作業対象のディレクトリの開発コンテナへのボリュームマウント

コンテナ内部のコードをホスト側からは見れないのではという話もある。ただ僕は開発時に使用するコンテナは、作業対象のホスト側ディレクトリを、コンテナ側にも同じパスでアクセスできるようにボリュームマウントしていた。例えばこんな感じだ。

docker run --volume ${PWD}:${PWD} image bash

だから、同じパスでファイルへアクセスはできるということを前提にできた。

やりたいことを少し整理する

さきほどよりも少しだけ詳しく、やりたい事を整理してみよう。

  • LSPサーバーは Emacsから子プロセスとして docker run で起動する。
  • 子プロセスの作成は、シンプルさを重視し make-process で作成する。
  • Emacsは、子プロセスで起動したLSPサーバーと標準入出力でやりとりする。
  • 入力や出力はxrefを経由して処理することでタグジャンプなどを実現する。

これぐらいだろうか。案外シンプルなものだ。

なぜ make-process なのか

Eglotもlsp-modeも、本来はとても便利なものなのだろう。簡単にLSPの環境を整備し使えるようにしていたはずだ。しかし僕の環境では、それらのLSPクライアントがサーバーの起動を便利にしようとして複雑化し、トラブルシューティングを難しい状態にしていた。

簡単(easy)とシンプル(simple)は異なる。その"簡単"の不都合が表面化したような状態だった。僕はこれに苛立ちを感じた。

もし make-process を直接使い、それだけなのであれば、本当にシンプルだ。指定した argv で子プロセスを起動し、標準入出力へバイト列を流すだけである。余計なことはほとんど何もしない。

bashで成功しているコマンドがあるなら、同じargvを make-process に渡せばいいだけだ。

実装

それでは実装していこう。とりあえずこのライブラリを my-lsp.el として実装する事にした。このファイルは大きく 6 つの層に分かれている。

1. プロジェクトと URI の扱い

my-ts-lsp--project-roottsconfig.jsonpackage.json を遡って project root を見つける。 my-ts-lsp--file-urimy-ts-lsp--uri-path は、ローカルパスと file:// URI の相互変換を担当する。

今回のように example/app/page.tsx から @/components/counter へ飛びたい場合、project root 判定はとても重要である。ここがずれると TypeScript 側の module resolution 自体が崩れる。

2. プロセス起動

my-ts-lsp--start-servermake-process を呼び出す。

(make-process
 :name "ts-lsp"
 :buffer (get-buffer-create my-ts-lsp--stdout-buffer)
 :stderr (get-buffer-create my-ts-lsp--stderr-buffer)
 :command (my-ts-lsp--docker-command my-ts-lsp--root-dir)
 :connection-type 'pipe
 :coding 'utf-8-unix
 :noquery t
 :filter #'my-ts-lsp--process-filter
 :sentinel #'my-ts-lsp--process-sentinel)

connection-typepipe にしているのは、相手が --stdio サーバだからである。PTY を挟む理由はない。

3. LSP メッセージの読み書き

標準出力の受信は my-ts-lsp--process-filtermy-ts-lsp--drain-read-buffer で行っている。LSP は HTTP 風のヘッダを持つので、=Content-Length= を読んで body を切り出す必要がある。

送信側は my-ts-lsp--send が担当する。

(process-send-string
 my-ts-lsp--process
 (format "Content-Length: %d\r\n\r\n%s" (string-bytes body) body))

やっていることはそれだけだ。LSP は難しそうに見えるが、stdio 上では「長さ付き JSON を投げ合う」だけでもある。

4. initialize とバッファ同期

LSP サーバは起動しただけでは使えない。=initialize= と initialized が必要になる。

その後、定義ジャンプの前に現在バッファの内容をサーバへ知らせる必要がある。そのために my-ts-lsp--sync-buffer

  • didOpen
  • didChange
  • didClose

を最小限だけ実装してある。

ここを入れておかないと、Emacs 上では編集済みなのに、LSP サーバは古いファイル内容のまま解決してしまう。タグジャンプ用途でも、この同期は省略できない。

5. textDocument/definitiontextDocument/references

LSP 側で実際に使っているメソッドは今のところ 2 つだけだ。

  • textDocument/definition
  • textDocument/references

my-ts-lsp--definition-xrefs が前者を投げ、 my-ts-lsp--reference-xrefs が後者を投げる。

返ってきた location はそのままでは Emacs は使えないので、 my-ts-lsp--location-markermy-ts-lsp--location-xrefxref 用オブジェクトへ変換している。

6. xref バックエンド

最後に Emacs 側と繋ぐのがこの部分である。

(cl-defmethod xref-backend-definitions ((_backend (eql my-ts-lsp)) _identifier)
  (my-ts-lsp--definition-xrefs))

(cl-defmethod xref-backend-references ((_backend (eql my-ts-lsp)) _identifier)
  (my-ts-lsp--reference-xrefs))

これにより、

  • M-.xref-find-definitions
  • M-?xref-find-references

を経由して、最終的に TypeScript LSP へ届く。

Emacs 的には「LSP クライアントを作った」というより、「 xref バックエンドの中身を LSP にした」と言った方が正確かもしれない。

実際にどう使うか

使い方はかなり単純である。

(load-file "./my-lsp.el")

その上で example/app/page.tsx を開き、

  1. M-x my-ts-lsp-start
  2. CounterlistArticles の上で M-.
  3. 参照一覧を見たければ M-?
  4. 戻る時は M-,

とすればよい。

実際に Counter に対しては、参照検索で次のような一覧が出る。

app/page.tsx
 1:import { Counter } from "@/components/counter";
54:<Counter initialValue={2} step={2} />
components/counter.tsx
5:export function Counter({ initialValue, step = 1 }: CounterProps) {

これで最低限の「読むための道具」は揃った。

途中でハマったこと

最終形に辿り着くまでに、いくつか典型的な罠があった。

my-lsp.el 自体の構文エラー

最初の版では閉じ括弧が 1 個足りず、=load-file= で end-of-file が出た。 LSP 以前に Elisp が読めない、というかなり素朴なミスである。

file:// URI の作り方

Emacs 31 で url-hexify-string の使い方を雑にやると壊れた。 最終的には url-encode-urlfile://... 全体を作る形に落ち着いた。

xref の要求メソッド

definitionreferences だけ実装すれば済むと思っていたが、実際には xref-backend-identifier-completion-table も必要だった。 この手の「Emacs 側の最小要件」は、自前実装をすると素直に露出する。

とはいえ、これは悪いことではない。 何が本当に必要で、何が不要なのかが見えるからだ。

この実装の気に入っているところ

この方法の良いところは、責務の境界が極めて明確な点にある。

  • Docker は LSP サーバの実行環境
  • TypeScript はモジュール解決と型解決を行う
  • Emacs は xref の UI を担当する
  • my-lsp.el はその間の JSON-RPC を繋ぐだけ

余計な自動判定が少ないので、壊れた時も見れば分かる。

  • 起動に失敗したなら *ts-lsp-stderr*
  • 応答が壊れているなら JSON-RPC
  • ジャンプ先が変なら TypeScript 側の解決

切り分けの軸が明快だ。大きなEmacs LSPクライアントの上で設定を積むより、個人的にはかなり好みである。

ここから先

現状の my-lsp.el は「タグジャンプのための最小 LSP クライアント」である。まだ入っていないものは多い。

  • hover
  • diagnostics
  • completion
  • rename
  • formatting

ただ、全部を一気に入れる必要はない。必要になった機能だけを 1 個ずつ増やせばよい。

むしろ今回の収穫は、LSP を「統合パッケージ」としてではなく、「必要な JSON-RPC メソッドだけを使うプロトコル」として見直せたことだった。

まとめ

Docker for Mac 上で Next.js を動かしながら、ホスト macOS の Emacs で TypeScript を編集する時、最初から eglotlsp-mode に全てを委ねる必要はない。

今回の最終形は次の通りになった。

  • ファイルはホストの Emacs で直接開く
  • typescript-language-server は Docker コンテナ内で起動する
  • Emacs は make-process で stdio を握る
  • xref バックエンドを自前実装して M-.M-? を通す

見た目は原始的だが、構造はむしろこちらの方が素直だった。少なくとも「なぜ動くのか」と「なぜ壊れるのか」が理解しやすい。

使用者の力が及ぶ範囲で、使用者の歩調に合わせて、エディタが進化する。これがEmacsの素敵な所だと、僕は思う。

コード全体

コード全体を貼っておく。

;;; my-lsp.el --- Minimal TypeScript LSP client over stdio  -*- lexical-binding: t; -*-

;; Copyright (C) 2026 TakesxiSximada

;; Author: TakesxiSximada
;; Keywords: languages, tools
;; License: GNU Affero General Public License version 3 or later

;; This program is free software: you can redistribute it and/or modify
;; it under the terms of the GNU Affero General Public License as
;; published by the Free Software Foundation, either version 3 of the
;; License, or (at your option) any later version.
;;
;; This program is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
;; GNU Affero General Public License for more details.
;;
;; You should have received a copy of the GNU Affero General Public License
;; along with this program.  If not, see <https://www.gnu.org/licenses/>.

;;; Commentary:

;; Minimal TypeScript LSP client for Emacs.
;; It starts `typescript-language-server` in Docker over stdio and connects
;; it to `xref` for definitions and references.

;;; Code:

(require 'cl-lib)
(require 'json)
(require 'subr-x)
(require 'url-util)
(require 'xref)

(defgroup my-ts-lsp nil
  "Minimal TypeScript LSP client over stdio."
  :group 'tools)

(defconst my-ts-lsp-docker-image
  "sximada/agentic/all:202605.dev1"
  "Docker image used to start the TypeScript language server.")

(defvar my-ts-lsp--process nil
  "Process object for the TypeScript language server.")

(defvar my-ts-lsp--stderr-buffer "*ts-lsp-stderr*"
  "Buffer name used for the TypeScript language server stderr.")

(defvar my-ts-lsp--stdout-buffer "*ts-lsp*"
  "Buffer name used for the TypeScript language server stdout.")

(defvar my-ts-lsp--response-table (make-hash-table :test #'eql)
  "Pending synchronous responses keyed by request id.")

(defvar my-ts-lsp--notification-handlers (make-hash-table :test #'equal)
  "Registered notification handlers keyed by method name.")

(defvar my-ts-lsp--next-id 0
  "Next JSON-RPC request id.")

(defvar my-ts-lsp--read-buffer ""
  "Accumulated unread stdout bytes from the language server.")

(defvar my-ts-lsp--initialized nil
  "Non-nil when initialize/initialized has completed.")

(defvar my-ts-lsp--root-dir nil
  "Project root used for the current TypeScript language server.")

(defvar my-ts-lsp--opened-files (make-hash-table :test #'equal)
  "Version table keyed by absolute file name.")

(defvar-local my-ts-lsp--managed-buffer nil
  "Non-nil when the current buffer is tracked by the TypeScript LSP.")

(defun my-ts-lsp--project-root ()
  "Return the TypeScript project root for the current buffer."
  (or (locate-dominating-file default-directory "tsconfig.json")
      (locate-dominating-file default-directory "package.json")
      (error "No TypeScript project root found from %s" default-directory)))

(defun my-ts-lsp--file-uri (path)
  "Convert PATH to a file URI."
  (url-encode-url (concat "file://" (expand-file-name path))))

(defun my-ts-lsp--uri-path (uri)
  "Convert file URI to a local path."
  (url-unhex-string (string-remove-prefix "file://" uri)))

(defun my-ts-lsp--docker-command (project-dir)
  "Build the Docker command used to start the TypeScript language server."
  (let ((root (directory-file-name (expand-file-name project-dir))))
    (list "docker" "run" "--rm" "-i"
          "--volume" (format "%s:%s" root root)
          "--workdir" root
          my-ts-lsp-docker-image
          "npx" "typescript-language-server" "--stdio")))

(defun my-ts-lsp--live-p ()
  "Return non-nil when the language server process is running."
  (process-live-p my-ts-lsp--process))

(defun my-ts-lsp--cleanup-state ()
  "Reset all transient client state."
  (setq my-ts-lsp--initialized nil
        my-ts-lsp--read-buffer ""
        my-ts-lsp--root-dir nil
        my-ts-lsp--next-id 0
        my-ts-lsp--response-table (make-hash-table :test #'eql)
        my-ts-lsp--opened-files (make-hash-table :test #'equal)))

(defun my-ts-lsp--process-filter (_proc chunk)
  "Decode LSP messages from CHUNK."
  (setq my-ts-lsp--read-buffer (concat my-ts-lsp--read-buffer chunk))
  (my-ts-lsp--drain-read-buffer))

(defun my-ts-lsp--process-sentinel (_proc event)
  "Handle server lifecycle EVENT."
  (unless (my-ts-lsp--live-p)
    (message "ts-lsp exited: %s" (string-trim event))
    (my-ts-lsp--cleanup-state)))

(defun my-ts-lsp--drain-read-buffer ()
  "Parse as many complete LSP messages as possible."
  (let (done)
    (while (not done)
      (let ((header-end (string-match "\r\n\r\n" my-ts-lsp--read-buffer)))
        (if (not header-end)
            (setq done t)
          (let* ((header (substring my-ts-lsp--read-buffer 0 header-end))
                 (content-length (my-ts-lsp--content-length header))
                 (body-start (+ header-end 4))
                 (body-end (+ body-start content-length)))
            (if (> body-end (length my-ts-lsp--read-buffer))
                (setq done t)
              (let ((body (substring my-ts-lsp--read-buffer body-start body-end)))
                (setq my-ts-lsp--read-buffer
                      (substring my-ts-lsp--read-buffer body-end))
                (my-ts-lsp--handle-message body)))))))))

(defun my-ts-lsp--content-length (header)
  "Extract Content-Length from HEADER."
  (let ((case-fold-search t))
    (or (cl-loop for line in (split-string header "\r\n" t)
                 when (string-match "^Content-Length: \\([0-9]+\\)$" line)
                 return (string-to-number (match-string 1 line)))
        (error "Missing Content-Length in header: %s" header))))

(defun my-ts-lsp--handle-message (body)
  "Handle one JSON-RPC BODY."
  (let* ((json-object-type 'alist)
         (json-array-type 'list)
         (json-key-type 'symbol)
         (message (json-read-from-string body))
         (id (alist-get 'id message))
         (method (alist-get 'method message)))
    (cond
     (id
      (puthash id message my-ts-lsp--response-table))
     (method
      (let ((handler (gethash method my-ts-lsp--notification-handlers)))
        (when handler
          (funcall handler message)))))))

(defun my-ts-lsp--send (payload)
  "Send one LSP PAYLOAD."
  (let* ((json-encoding-pretty-print nil)
         (body (json-encode payload)))
    (process-send-string
     my-ts-lsp--process
     (format "Content-Length: %d\r\n\r\n%s" (string-bytes body) body))))

(defun my-ts-lsp--notify (method &optional params)
  "Send a JSON-RPC notification METHOD with PARAMS."
  (my-ts-lsp--send
   `((jsonrpc . "2.0")
     (method . ,method)
     (params . ,(or params (make-hash-table))))))

(defun my-ts-lsp--request (method &optional params timeout)
  "Send METHOD with PARAMS and wait up to TIMEOUT seconds."
  (let* ((id (cl-incf my-ts-lsp--next-id))
         (deadline (+ (float-time) (or timeout 5.0))))
    (puthash id nil my-ts-lsp--response-table)
    (my-ts-lsp--send
     `((jsonrpc . "2.0")
       (id . ,id)
       (method . ,method)
       (params . ,(or params (make-hash-table)))))
    (while (and (null (gethash id my-ts-lsp--response-table))
                (my-ts-lsp--live-p)
                (< (float-time) deadline))
      (accept-process-output my-ts-lsp--process 0.1))
    (let ((response (gethash id my-ts-lsp--response-table)))
      (remhash id my-ts-lsp--response-table)
      (cond
       ((null response)
        (error "Timed out waiting for %s" method))
       ((alist-get 'error response)
        (error "LSP %s failed: %S" method (alist-get 'error response)))
       (t
        (alist-get 'result response))))))

(defun my-ts-lsp--start-server (project-dir)
  "Start a language server rooted at PROJECT-DIR."
  (my-ts-lsp--cleanup-state)
  (setq my-ts-lsp--root-dir (directory-file-name (expand-file-name project-dir))
        my-ts-lsp--process
        (make-process
         :name "ts-lsp"
         :buffer (get-buffer-create my-ts-lsp--stdout-buffer)
         :stderr (get-buffer-create my-ts-lsp--stderr-buffer)
         :command (my-ts-lsp--docker-command my-ts-lsp--root-dir)
         :connection-type 'pipe
         :coding 'utf-8-unix
         :noquery t
         :filter #'my-ts-lsp--process-filter
         :sentinel #'my-ts-lsp--process-sentinel)))

(defun my-ts-lsp--ensure-server ()
  "Ensure a matching language server is running for the current buffer."
  (let ((project-dir (directory-file-name (expand-file-name (my-ts-lsp--project-root)))))
    (unless (and (my-ts-lsp--live-p)
                 my-ts-lsp--initialized
                 (equal project-dir my-ts-lsp--root-dir))
      (when (my-ts-lsp--live-p)
        (delete-process my-ts-lsp--process))
      (my-ts-lsp--start-server project-dir)
      (my-ts-lsp--initialize project-dir))))

(defun my-ts-lsp--initialize (project-dir)
  "Run initialize/initialized for PROJECT-DIR."
  (let ((root-uri (my-ts-lsp--file-uri project-dir))
        (root-name (file-name-nondirectory
                    (directory-file-name project-dir))))
    (my-ts-lsp--request
     "initialize"
     `((processId . nil)
       (clientInfo . ((name . "my-ts-lsp")
                      (version . "0.1")))
       (rootUri . ,root-uri)
       (capabilities
        . ((textDocument
            . ((definition . ((linkSupport . t)))
               (synchronization
                . ((didSave . t)
                   (dynamicRegistration . :json-false)))))
           (workspace . ((workspaceFolders . t)))))
       (workspaceFolders . [((uri . ,root-uri)
                             (name . ,root-name))]))))
  (my-ts-lsp--notify "initialized" (make-hash-table))
  (setq my-ts-lsp--initialized t))

(defun my-ts-lsp--buffer-language-id ()
  "Return the LSP language id for the current buffer."
  (cond
   ((derived-mode-p 'tsx-ts-mode) "typescriptreact")
   ((derived-mode-p 'typescript-ts-mode 'typescript-mode) "typescript")
   ((derived-mode-p 'js-ts-mode 'js-mode) "javascript")
   ((derived-mode-p 'jsx-ts-mode) "javascriptreact")
   (t "typescript")))

(defun my-ts-lsp--current-position ()
  "Return the current point as an LSP position alist."
  (save-restriction
    (widen)
    (let ((line (1- (line-number-at-pos)))
          (character (current-column)))
      `((line . ,line)
        (character . ,character)))))

(defun my-ts-lsp--sync-buffer ()
  "Synchronize the current buffer contents with the server."
  (my-ts-lsp--ensure-server)
  (let* ((file-name (buffer-file-name))
         (uri (my-ts-lsp--file-uri file-name))
         (version (1+ (gethash file-name my-ts-lsp--opened-files 0)))
         (text (buffer-substring-no-properties (point-min) (point-max))))
    (puthash file-name version my-ts-lsp--opened-files)
    (if (= version 1)
        (my-ts-lsp--notify
         "textDocument/didOpen"
         `((textDocument . ((uri . ,uri)
                            (languageId . ,(my-ts-lsp--buffer-language-id))
                            (version . ,version)
                            (text . ,text)))))
      (my-ts-lsp--notify
       "textDocument/didChange"
       `((textDocument . ((uri . ,uri)
                          (version . ,version)))
         (contentChanges . [((text . ,text))]))))
    (setq my-ts-lsp--managed-buffer t)))

(defun my-ts-lsp--did-close ()
  "Notify the server that the current buffer has closed."
  (when (and my-ts-lsp--managed-buffer
             (buffer-file-name)
             my-ts-lsp--initialized)
    (let ((file-name (buffer-file-name)))
      (remhash file-name my-ts-lsp--opened-files)
      (my-ts-lsp--notify
       "textDocument/didClose"
       `((textDocument . ((uri . ,(my-ts-lsp--file-uri file-name))))))
      (setq my-ts-lsp--managed-buffer nil))))

(defun my-ts-lsp--location-marker (location)
  "Convert one LSP LOCATION to an xref marker."
  (let* ((uri (or (alist-get 'targetUri location)
                  (alist-get 'uri location)))
         (range (or (alist-get 'targetSelectionRange location)
                    (alist-get 'targetRange location)
                    (alist-get 'range location)))
         (start (alist-get 'start range))
         (file (my-ts-lsp--uri-path uri))
         (line (1+ (alist-get 'line start)))
         (character (alist-get 'character start))
         (buffer (find-file-noselect file)))
    (with-current-buffer buffer
      (save-excursion
        (goto-char (point-min))
        (forward-line (1- line))
        (move-to-column character)
        (point-marker)))))

(defun my-ts-lsp--location-xref (location)
  "Convert one LSP LOCATION to an xref item."
  (let* ((marker (my-ts-lsp--location-marker location))
         (buffer (marker-buffer marker))
         (line-text (with-current-buffer buffer
                      (save-excursion
                        (goto-char marker)
                        (buffer-substring-no-properties
                         (line-beginning-position)
                         (line-end-position))))))
    (xref-make (string-trim line-text)
               (xref-make-file-location
                (buffer-file-name buffer)
                (line-number-at-pos marker)
                (save-excursion
                  (goto-char marker)
                  (current-column))))))

(defun my-ts-lsp--normalize-locations (result)
  "Normalize RESULT from textDocument/definition into a list."
  (cond
   ((null result) nil)
   ((and (listp result) (alist-get 'uri result)) (list result))
   ((and (listp result) (alist-get 'targetUri result)) (list result))
   ((listp result) result)
   (t nil)))

(defun my-ts-lsp--definition-xrefs ()
  "Return xrefs for the symbol at point."
  (my-ts-lsp--sync-buffer)
  (let* ((result
          (my-ts-lsp--request
           "textDocument/definition"
           `((textDocument . ((uri . ,(my-ts-lsp--file-uri (buffer-file-name)))))
             (position . ,(my-ts-lsp--current-position)))))
         (locations (my-ts-lsp--normalize-locations result)))
    (mapcar #'my-ts-lsp--location-xref locations)))

(defun my-ts-lsp--reference-xrefs ()
  "Return xrefs for references to the symbol at point."
  (my-ts-lsp--sync-buffer)
  (let* ((result
          (my-ts-lsp--request
           "textDocument/references"
           `((textDocument . ((uri . ,(my-ts-lsp--file-uri (buffer-file-name)))))
             (position . ,(my-ts-lsp--current-position))
             (context . ((includeDeclaration . t))))))
         (locations (my-ts-lsp--normalize-locations result)))
    (mapcar #'my-ts-lsp--location-xref locations)))

(cl-defmethod xref-backend-identifier-at-point ((_backend (eql my-ts-lsp)))
  (thing-at-point 'symbol t))

(cl-defmethod xref-backend-identifier-completion-table ((_backend (eql my-ts-lsp)))
  nil)

(cl-defmethod xref-backend-definitions ((_backend (eql my-ts-lsp)) _identifier)
  (my-ts-lsp--definition-xrefs))

(cl-defmethod xref-backend-references ((_backend (eql my-ts-lsp)) _identifier)
  (my-ts-lsp--reference-xrefs))

(defun my-ts-lsp-xref-backend ()
  "Return the xref backend for the current buffer."
  (when (and buffer-file-name
             (derived-mode-p 'typescript-ts-mode
                             'tsx-ts-mode
                             'typescript-mode
                             'js-ts-mode
                             'js-mode
                             'jsx-ts-mode)
             (ignore-errors (my-ts-lsp--project-root)))
    'my-ts-lsp))

(define-minor-mode my-ts-lsp-mode
  "Use a minimal TypeScript LSP xref backend over stdio."
  :lighter " my-ts-lsp"
  (if my-ts-lsp-mode
      (progn
        (add-hook 'xref-backend-functions #'my-ts-lsp-xref-backend nil t)
        (add-hook 'kill-buffer-hook #'my-ts-lsp--did-close nil t))
    (remove-hook 'xref-backend-functions #'my-ts-lsp-xref-backend t)
    (remove-hook 'kill-buffer-hook #'my-ts-lsp--did-close t)
    (my-ts-lsp--did-close)))

(defun my-ts-lsp-start ()
  "Enable `my-ts-lsp-mode' in the current buffer."
  (interactive)
  (my-ts-lsp-mode 1))

(defun my-ts-lsp-stop ()
  "Disable `my-ts-lsp-mode' and stop the shared server."
  (interactive)
  (my-ts-lsp-mode -1)
  (when (my-ts-lsp--live-p)
    (delete-process my-ts-lsp--process))
  (my-ts-lsp--cleanup-state))

(provide 'my-lsp)

;;; my-lsp.el ends here
my-lsp.el
作成日
最終更新日