2025-03-16

F# で command-line predictor を書いてる Part 4

krymtkts/SnippetPredictor の開発をした。

新しい機能として、 tooltip での絞り込みと、 snippet に付与した group での絞り込みを追加した。 #22 #23

これによって以下の絞り込みのバリエーションが増えた。

  • :snp で snippet の絞り込み

  • :tip で tooltip の絞り込み

  • :{group} で指定した識別子の group で snippet の絞り込み

{
"Snippets": [
{
"Snippet": "dotnet new classlib --name project --language F# --output ./src/project",
"Tooltip": "Create a new F# class library project",
"Group": "fsharp"
},
{
"Snippet": "dotnet tool install fantomas --prerelease",
"Tooltip": "Install or update Fantomas",
"Group": "fsharp"
},
{
"Snippet": "dotnet new sln",
"Tooltip": "Create a new solution file",
"Group": "project"
}
]
}

みたいなんがあったとして、 :fsharp で絞れる。 この例のように、 F# 関連でまとめておきたい snippet があって snippet や tooltip に F# を含まないような場合に "Group": "fsharp" でまとめておけるというメリットがあるはず。 group のこの用途だと実は tooltip の記述を工夫すれば代用できるが、 Get-Snippet で取得した snippet を Where-Object で楽に絞り込むには、やはり別の field を持っているのが最適だと考えた。

今のところ、この group の絞り込みを使うことで絞り込み対象の母数が減って速くなるということはない。愚直に snippet の sequence を絞り込むのに group の条件がついただけになってる。 snippet の登録件数がそんなに多くならないだろうし、空間計算量に寄せるほどでもないかなという直感だ。 もし今後件数が増えまくったりして、 command-line predictor の 20ms の制限に触れてくるようなら再検討する。

Group field に設定する値に長さの制限はないが、文字種は [a-zA-Z0-9] しか使えなくしている。 Add-Snippet でも制限されているし、 .snippet-predictor.json 読み込み時にも制限されてる。 また Group field は省略可能なので、従来(というほど古くもないけど)の .snippet-predictor の記述通り Group なしの snippet も読める。

Group field の制限は System.Text.Json Namespace を使ってこんなコードでできた。

type GroupJsonConverter() =
inherit JsonConverter<string>()

[<Literal>]
static let pattern = "^[A-Za-z0-9]+$"

static let regex = Regex(pattern)

override _.Read(reader: byref<Utf8JsonReader>, _typeToConvert: Type, options: JsonSerializerOptions) =
reader.GetString()
|> function
| null as value -> value // NOTE: unreachable when JsonIgnoreCondition.WhenWritingNull is used.
| value when regex.IsMatch(value) -> value
| value -> JsonException(sprintf "Invalid characters in group: %s" value) |> raise

override _.Write(writer: Utf8JsonWriter, value: string, options: JsonSerializerOptions) =
value |> writer.WriteStringValue

type SnippetEntry =
{ Snippet: string
Tooltip: string
[<JsonConverter(typeof<GroupJsonConverter>)>]
[<JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)>]
Group: string | null }

F# 的には group には option を使うべきだろうけど、 .NET と直にやり取りしてるし変換コストを考えたらそのままがいいかなと。 F# 9 で Nullable Reference Type ができたことで、 null を取り扱わないといけないケースも option と遜色なく書けるようになった気がする。 代わりに到達不能な branch ができたり coverage 的には頭を悩ますことが多いが。

group には :snp:tip との一体感やタイプ回数を考えたら小文字英数 3 文字くらいの識別子が良かろうが、分かりやすさ的に英単語を使うのもありかな。 いま個人的には英単語の方を、個別の識別子考えて覚える必要ないから使ってる

いま if 連発で愚直に書いただけのロジックになってるのをリファクタリングしたら、 v0.2.0 としてリリースしたい。