Chapter 06

エラー検知 Diagnostic

kitta65
kitta65
2023.06.25に更新

いよいよ最後の章です。この章ではエラー検知[1]を扱います。これまでと違い、常に最新のコードの状態を確認する必要があります。ただ、LSPの公式ページを確認する → 型を定義する → 具体的な実装をする、という流れは変わりません。この章の最終状態がこのcommitです。

前提

今回はclientからコード変更をnotificationで受け取る → 検知したエラーをnotificationで返すという段取りになります。公式ページの確認するべき範囲も広いです。

コード変更関連は Document Synchronization > Overview - Text Document 辺りの内容です。検知したエラーを通知することについては、 Language Features > Publish Diagnostics と Basic JSON Structures > Diagnostic を見ておくとよいです。

型定義

ここでも最低限の型定義をやっていきます。コード変更関連はこんな感じです。

types.go
// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_didClose
type didCloseNotification struct {
	notification
	Params struct {
		TextDocument struct {
			Uri string `json:"uri"`
		} `json:"textDocument"`
	} `json:"params"`
}

// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_didChange
type didChangeNotification struct {
	notification
	Params struct {
		TextDocument struct {
			Version int    `json:"version"`
			Uri     string `json:"uri"`
		} `json:"textDocument"`
		ContentChanges []struct {
			Text string `json:"text"`
		} `json:"contentChanges"`
	} `json:"params"`
}

// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_didOpen
type didOpenNotification struct {
	notification
	Params struct {
		TextDocument struct {
			Uri        string `json:"uri"`
			LanguageId string `json:"languageId"`
			Version    int    `json:"version"`
			Text       string `json:"text"`
		} `json:"textDocument"`
	} `json:"params"`
}

エラー検知関連は以下のようになります。

types.go
// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_publishDiagnostics
type publishDiagnosticsNotification struct {
	notification
	Params publishDiagnosticsParams `json:"params"`
}

type publishDiagnosticsParams struct {
	Uri         string       `json:"uri"`
	Diagnostics []diagnostic `json:"diagnostics"`
}

// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#diagnostic
type diagnostic struct {
	Range   range_ `json:"range"`
	Message string `json:"message"`
}

type range_ struct {
	// positionは補完の章で定義しました	
	Start position `json:"start"`
	End   position `json:"end"`
}

実装

コード変更に追従

上記の定義も使いつつ、実装をやっていきます。まずは常にコードの最新状態をserver側で把握したいですね。(お行儀がよいかは置いておいて)map[string]string型のグローバル変数を定義してしまいます。コードが開かれたらuri(PATHだと思えばよさそう)をkeyに変数に格納し、コードが編集される度に更新します。

main.go
var texts = map[string]string{} // これも追記
func main() {
	// ... 省略 ...
	for {
		// ... 省略 ...

		// check method
		method := reqOrNotif.Method
		switch method {
		// ... 省略 ...

		// ... 追記ここから ...
		case "textDocument/didChange":
			var notif didChangeNotification
			if err := json.Unmarshal(b, &notif); err != nil {
				log.Fatal(err)
			}
			handleDidChange(notif)
		case "textDocument/didOpen":
			var notif didOpenNotification
			if err := json.Unmarshal(b, &notif); err != nil {
				log.Fatal(err)
			}
			handleDidOpen(notif)
		// ... 追記ここまで ...
		}
		// ... 省略 ...
	}
}
handler.go
func handleDidChange(notif didChangeNotification) {
	uri := notif.Params.TextDocument.Uri
	texts[uri] = notif.Params.ContentChanges[0].Text
	// TODO エラー検知
}

func handleDidOpen(notif didOpenNotification) {
	uri := notif.Params.TextDocument.Uri
	texts[uri] = notif.Params.TextDocument.Text
	// TODO エラー検知
}

エラー検知

ここまででコードの最新状態を常にserverが把握できるようになりました。残るはエラー検知です。ここでは単純にコード中のvoldemortという文字列を見つけます[2]。やや面倒なのが、探した文字列の位置はposition型でm行目のn列目と指定する必要がある点です。m行目とか関係なく先頭からの文字数で指定できたら楽なのですが...なんて言ってもしょうがないので、実装していきます。

utils.go
func validate(text string) []diagnostic {
	var diagnostics = []diagnostic{}
	ranges := search(`(?i)voldemort`, text)
	for _, r := range ranges {
		diagnostics = append(diagnostics, diagnostic{
			Range:   r,
			Message: "Do not call his name!",
		})
	}
	return diagnostics
}

func search(exp string, text string) []range_ {
	re := regexp.MustCompile(exp)
	matches := re.FindAllIndex([]byte(text), -1)
	var ranges []range_
	for _, m := range matches {
		start, err := idx2pos(m[0], text)
		if err != nil {
			log.Fatal(err)
		}
		end, err := idx2pos(m[1], text)
		if err != nil {
			log.Fatal(err)
		}
		ranges = append(ranges, range_{start, end})
	}
	return ranges
}

func idx2pos(idx int, text string) (position, error) {
	lines := strings.Split(text, "\n")
	curr := 0
	for i, line := range lines {
		if len(line)+curr < idx {
			curr += len(line) + 1
			continue
		}
		return position{i, idx - curr}, nil
	}
	return position{}, fmt.Errorf(
		"cannot convert index into position. idx: %v, text length: %v",
		idx,
		len(text),
	)
}

validate()を使えば簡単にvoldemortの位置を取得できます。これを使ってhandler.goを更新しましょう。

handler.go
func handleDidChange(notif didChangeNotification) {
	uri := notif.Params.TextDocument.Uri
	texts[uri] = notif.Params.ContentChanges[0].Text
	// ... 追記ここから ...
	params := publishDiagnosticsParams{Uri: uri, Diagnostics: validate(texts[uri])}
	resp := publishDiagnosticsNotification{
		notification: notification{Method: "textDocument/publishDiagnostics"},
		Params:       params,
	}
	b, err := json.Marshal(&resp)
	if err != nil {
		log.Fatal(err)
	}
	respond(b)
}

func handleDidOpen(notif didOpenNotification) {
	uri := notif.Params.TextDocument.Uri
	texts[uri] = notif.Params.TextDocument.Text
	// ... 追記ここから ...
	params := publishDiagnosticsParams{Uri: uri, Diagnostics: validate(texts[uri])}
	resp := publishDiagnosticsNotification{
		notification: notification{Method: "textDocument/publishDiagnostics"},
		Params:       params,
	}
	b, err := json.Marshal(&resp)
	if err != nil {
		log.Fatal(err)
	}
	respond(b)
}

ところで一度検知したエラーが全て編集されて直った場合はどうしたらよいでしょうか?答えは空の配列をclientに送信する、です。上記の実装は自然とそうなるようにできています。

If the computed set is empty it has to push the empty array to clear former diagnostics.

さて、もう一つだけやり忘れた処理があります。今回のエラー検知はファイル単体についての処理だったので、ファイルを閉じたタイミングでも空の配列を送信するべきでしょう。

if a language is single file only (for example HTML) then diagnostics are cleared by the server when the file is closed.

main.goとhandler.goに以下を追記します。

main.go
func main() {
	// ... 省略 ...
	for {
		// ... 省略 ...

		// check method
		method := reqOrNotif.Method
		switch method {
		// ... 省略 ...

		// ... 追記ここから ...
		case "textDocument/didClose":
			var notif didCloseNotification
			if err := json.Unmarshal(b, &notif); err != nil {
				log.Fatal(err)
			}
			handleDidClose(notif)
		// ... 追記ここまで ...
		}
		// ... 省略 ...
	}
}
handler.go
func handleDidClose(notif didCloseNotification) {
	uri := notif.Params.TextDocument.Uri

	// empty diagnostics
	params := publishDiagnosticsParams{Uri: uri, Diagnostics: []diagnostic{}}
	resp := publishDiagnosticsNotification{
		notification: notification{Method: "textDocument/publishDiagnostics"},
		Params:       params,
	}
	b, err := json.Marshal(&resp)
	if err != nil {
		log.Fatal(err)
	}
	respond(b)
}

さあgo buildして動かしてみましょう。

neovim

脚注
  1. diagnosticに対応する適当な日本語が思い浮かびませんでした。「エラー検知」で語弊があったらすみません。 ↩︎

  2. うっかり名前を呼んではいけないですからね。 ↩︎