zudo-codemirror-wisdom
GitHub リポジトリ

検索したい単語を入力

いつでも検索バーを開ける

カスタム CompletionSource

CodeMirror 6 でオートコンプリートを自作する: コンテキスト判定によるトリガー制御、validFor、section/boost によるグルーピング、2 種類の接続方法 (override と languageData)。

@codemirror/autocomplete

@codemirror/autocomplete パッケージは CodeMirror 6 のオートコンプリート基盤を提供します。autocompletion() を拡張として追加し、いつ何を提案するかを決める CompletionSource 関数を渡すことで有効になります。

import { autocompletion } from "@codemirror/autocomplete";

const extensions = [autocompletion()];

CompletionSource は次のシグネチャを持つ関数です。

type CompletionSource = (
  context: CompletionContext,
) => CompletionResult | null | Promise<CompletionResult | null>;

null を返せば「ここでは何も提案しない」という意味になります。CompletionResult を返すとオプション一覧が表示されます。

コンテキスト判定: いつ発火するかを決める

よくある失敗は、意味のない位置でもコンプリーションソースが発火してしまうことです。よく設計されたソースは最初に位置を確認し、コンテキスト外であればすぐ null を返します。これを コンテキスト判定 (context gating) と呼びます。

CompletionContext が提供する主なヘルパーは次のとおりです。

  • context.pos — カーソル位置 (数値)

  • context.state.doc.lineAt(context.pos) — 現在行の Line オブジェクト

  • context.matchBefore(regex) — カーソル直前で正規表現をマッチさせ、{ from, to, text } または null を返す

例 1 — Tailwind クラス補完

このソースは class または className 属性値の内側にカーソルがある場合にのみ発火します。行の先頭からカーソルまでのテキストを検査します。

import { autocompletion } from "@codemirror/autocomplete";
import type { CompletionContext, CompletionResult } from "@codemirror/autocomplete";
import { EditorState } from "@codemirror/state";

function twCompletionSource(context: CompletionContext): CompletionResult | null {
  const line = context.state.doc.lineAt(context.pos);
  const textBefore = line.text.slice(0, context.pos - line.from);

  // Gate: only suggest inside class/className attribute values
  const classAttrMatch = textBefore.match(
    /(?:class|className)\s*=\s*(?:"[^"]*$|'[^']*$|{`[^`]*$|{'[^']*$)/,
  );
  if (!classAttrMatch) return null;

  // Determine the replace range: the partial class name before the cursor
  const wordMatch = context.matchBefore(/[\w\-/]*/);
  if (!wordMatch) return null;

  return {
    from: wordMatch.from,   // replace starts at the beginning of the partial token
    options: completionOptions,
    validFor: /^[\w\-/]*$/, // keep filtering while the typed text still matches
  };
}

置換範囲は context.matchBefore(/[\w\-/]*/) で決まるため、from は部分クラス名の先頭に設定されます。to は省略するとカーソル位置になります。

例 2 — スキルコマンド補完

このソースは現在行が特定のコマンドプレフィックスパターン (@@ /slug) に一致する場合にのみ発火します。置換範囲の計算方法が異なり、matchBefore ではなく / 文字を直接 lastIndexOf で探し、fromto の両方を明示的に設定します。

import type { CompletionContext, CompletionResult, CompletionSource } from "@codemirror/autocomplete";

// The trigger pattern must mirror the submit-side resolver exactly.
// Previously `(^|\s)@@\s+/…` fired mid-line, where the action handler
// would never activate — picking a skill then silently did nothing.
const PREFIX_PATTERN = /^[ \t]*@@ \/[a-z0-9-]*$/;

export function skillAutocomplete(skills: SkillEntry[]): CompletionSource {
  return (context: CompletionContext): CompletionResult | null => {
    const line = context.state.doc.lineAt(context.pos);
    const before = line.text.slice(0, context.pos - line.from);

    // Gate: line must match the command prefix up to cursor
    if (!PREFIX_PATTERN.test(before)) return null;

    // Replace range: from the leading `/` to the current cursor
    const slashOffsetInLine = before.lastIndexOf("/");
    if (slashOffsetInLine < 0) return null;
    const from = line.from + slashOffsetInLine;

    return {
      from,
      to: context.pos,  // explicit `to` since we computed `from` manually
      options: skills.map((skill) => ({
        label: "/" + skill.name,
        detail: skill.mode,
        info: skill.description,
        type: "function",
      })),
      validFor: /^\/[a-z0-9-]*$/,
    };
  };
}

2 つの置換範囲の計算方法はそれぞれ異なる意図を持っています。

  • matchBefore パターン (Tailwind): 単語境界を CM に自動検出させる。単語に似たトークンに便利。

  • lastIndexOf パターン (スキル): 既知の区切り文字 (/) を起点に正確に制御する。

validFor: ソースを再実行せずにクライアント側でフィルタリング

CompletionResultvalidFor フィールドは、ユーザーが追加文字を入力したとき、既存の結果が引き続き有効かどうかをソース関数を再呼び出しせずに判断するための情報です。

return {
  from: wordMatch.from,
  options: completionOptions,
  validFor: /^[\w\-/]*$/, // while typed text matches this, reuse the result
};

ユーザーが文字を入力するたびに CM は次の処理を行います。

  1. from から新しいカーソル位置までのテキストが validFor にマッチするか確認する。

  2. マッチする場合 — 既存のオプション一覧をクライアント側で再フィルタリングする。ソース関数は呼び出されない。

  3. マッチしない場合 — ソース関数を再度呼び出して新しい結果を取得する。

両方のソースで validFor を設定しています。スキルソースの場合は /^\/[a-z0-9-]*$/ で、/ の後に小文字スラッグを入力し続けている間は再フィルタリングが行われます。スペースなどパターンを壊す文字を入力すると既存の結果は破棄されます。

section と boost: オプションのグルーピングとランキング

個々の Completion オブジェクトの sectionboost フィールドにより、ポップアップを整理しランキングに影響を与えられます。

section

同じ section 文字列 (または CompletionSection オブジェクト) を持つオプションは、オートコンプリートのポップアップ内で見出し付きのグループとしてまとめられます。section が未設定のオプションはそれ自体のグループに入ります。

boost

正の boost 値はオプションをそのグループの上位に移動させます。デフォルト値は 0 です。Tailwind ソースではレイアウト関連のクラスに最も高い boost が設定されています。

import type { Completion } from "@codemirror/autocomplete";

const completionOptions: Completion[] = twClasses.map((entry) => ({
  label: entry.label,
  detail: entry.detail,
  type: "class",
  section: entry.section,           // groups under popup headings: "layout", "spacing", …
  boost: entry.section === "layout"
    ? 2
    : entry.section === "spacing"
      ? 1
      : 0,                          // layout floats first, spacing second, rest follow
}));

オプション配列はモジュールロード時に一度だけ構築します (ソース関数の外側で)。これにより、キーストロークごとに構築コストを払わずに済みます。

ロックステップルール: トリガー検出とアクションハンドラーを同期させる

Warning

コンプリーションソースが位置ベースのトリガー (textBefore への正規表現、行番号チェック、特定の区切り文字) でゲーティングしている場合、そのトリガーパターンはアクションハンドラー — ユーザーが実際に補完を確定したときに実行されるコード — が使うパターンと一致していなければなりません。

スキルコマンドソースでは、かつてトリガーに (^|\s)@@\s+\/[a-z0-9-]*$ を使っていました。このパターンは行の途中にある @@ /slug にもマッチしてしまいます。一方、送信側リゾルバーは @@ が行の先頭にある場合にしか処理しません。結果として、ポップアップは表示されてスキルを選択できるのに、何も起きないというバグが発生しました。

トリガーを ^[ \t]*@@ \/[a-z0-9-]*$ に絞り込むことで問題が解消されました。ポップアップはリゾルバーが処理できる位置にのみ表示されるようになりました。

CompletionSource のトリガー正規表現を変更する場合は、対応するアクションハンドラー (キーバインド、送信コールバック、ステートフィールドエフェクト) も必ず確認して一致させてください。

接続方法: 2 つのスタイル

override — グローバルソース、デフォルトをすべて置き換える

autocompletion({ override: [...] }) にソースを渡します。これはリストに指定したソースだけを使用し、言語が提供する補完を含む他のすべてのソースを置き換えます。

import { autocompletion } from "@codemirror/autocomplete";

const extensions = [
  autocompletion({
    override: [skillAutocomplete(mySkillRegistry)],
    activateOnTyping: true,
  }),
];

override は完全なコントロールが必要な場合に使います。たとえば、スラッシュコマンドだけを補完し言語キーワードを表示しないエディタなどに適しています。

languageData — 言語ごとのソース、他のソースと合成される

EditorState.languageDataautocomplete キーを通じてソースを登録します。CodeMirror はアクティブな言語に登録されたすべてのソースを収集して他のソースとマージします。

import { autocompletion } from "@codemirror/autocomplete";
import { EditorState } from "@codemirror/state";

export function tailwindCompletion() {
  return [
    autocompletion({
      activateOnTyping: true,
      maxRenderedOptions: 50,
    }),
    EditorState.languageData.of(() => [
      { autocomplete: twCompletionSource },
    ]),
  ];
}

languageData は、既存の言語補完に追加する形で機能させたい場合に使います。たとえば、すでに有効な言語補完の上に Tailwind クラス補完を重ねるケースがこれにあたります。languageData ファセットの詳細については Language Support を参照してください。

完全な例

これらをまとめると、autocompletion 拡張と languageData 登録の両方を返す関数ができます。EditorState.create({ extensions: [...] }) にそのまま渡せます。

import { autocompletion } from "@codemirror/autocomplete";
import type { CompletionContext, CompletionResult, Completion } from "@codemirror/autocomplete";
import { EditorState } from "@codemirror/state";

// --- Options built once at module load ---
const fruitOptions: Completion[] = [
  { label: "apple",      section: "fruit", boost: 1, type: "keyword" },
  { label: "apricot",    section: "fruit", boost: 0, type: "keyword" },
  { label: "banana",     section: "fruit", boost: 0, type: "keyword" },
  { label: "blueberry",  section: "fruit", boost: 0, type: "keyword" },
];

// --- CompletionSource with context gating ---
function fruitSource(context: CompletionContext): CompletionResult | null {
  // Only fire when the line starts with "fruit:"
  const line = context.state.doc.lineAt(context.pos);
  const textBefore = line.text.slice(0, context.pos - line.from);
  if (!textBefore.startsWith("fruit:")) return null;

  const wordMatch = context.matchBefore(/\w*/);
  if (!wordMatch) return null;

  return {
    from: wordMatch.from,
    options: fruitOptions,
    validFor: /^\w*$/,
  };
}

// --- Extension factory ---
export function fruitCompletion() {
  return [
    autocompletion({ activateOnTyping: true }),
    EditorState.languageData.of(() => [{ autocomplete: fruitSource }]),
  ];
}

Revision History

Takeshi Takatsudo作成: 2026-05-29T05:54:36+09:00更新: 2026-05-29T06:25:22+09:00