0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

独自スクリプト言語をVS Code拡張でサポート

0
Last updated at Posted at 2026-07-26

はじめに

独自スクリプト言語を開発運用を楽にする方法として記事を作成しました。

今回対象にしたのは、次のようなBASIC風の言語です。

  • IFELSEWHILE程度の制御構文
  • 関数のように呼び出す製品固有コマンドが多数存在
  • 変数宣言は不要
  • $Nameは文字列型、!Countは整数型
  • ファイルの拡張子は専用拡張子ではなく.txt
  • 製品付属の言語エンジンがパースして実行する

言語エンジンを使えば実行や構文チェックはできます。しかし、単なるテキストエディターでは、コマンド名を思い出す、引数順を調べる、同じ変数を探す、といった入力作業そのものが楽になりません。

そこでVS Code拡張を作り、次の編集支援を追加します。

  • 構文の色分け
  • コメント、括弧、インデント
  • IFWHILEのスニペット
  • 大量のコマンドの入力補完
  • コマンドのホバー説明
  • 引数ヒント
  • ファイル内変数の補完
  • 変数の型をホバー表示
  • 同じ変数のハイライト
  • パネルからのコマンドとスニペットの選択入力

まず全体像

VS Codeの言語支援は、大きく3層に分けると理解しやすくなります。

主な担当 今回の例
宣言的な言語機能 JSONで設定 色分け、括弧、コメント、スニペット
VS Code APIを使う機能 TypeScript/JavaScript 補完、ホバー、変数ハイライト、コマンド検索
言語仕様の正解 既存の言語エンジン 構文チェック、型検査、実行

VS Code公式も、色分けやスニペットなどを「宣言的な言語機能」、補完やエラー検出などを「プログラムによる言語機能」と分けています。

今回のような比較的小さな言語なら、最初からLanguage Serverを作る必要はありません。まずは通常のVS Code拡張と既存エンジンの組み合わせで、十分な効果を得られます。

最初に配置方法を決める:VSIXか.vscode/extensions

VS Code拡張の中身を作り始める前に、完成した拡張をどこへ置くか決めておきます。

主な選択肢は次の2つです。

方式 配置・導入方法 向いているケース
VSIX .vsixファイルを作り、各PCのVS Codeへインストール 複数製品で同じ言語仕様を共有し、拡張を一括配布したい
Local Workspace Extension 展開済み拡張を製品リポジトリの.vscode/extensionsへ置く 製品ごとにコマンドや言語仕様が異なり、スクリプトと拡張の版をそろえたい

この記事では、.vscode/extensions方式を書いていきます。

なお、VS Code 1.89で正式提供されたLocal Workspace Extensionsでは、展開済み拡張をワークスペースの.vscode/extensionsへ配置できます。利用時にはワークスペースの信頼が必要です。

この記事の第1段階から第11段階までは、次のフォルダーを直接編集します。

ProductA/
├─ .vscode/
│  ├─ settings.json
│  └─ extensions/
│     └─ product-a-script-support/  ← ここを直接編集する
└─ Scripts/

初回だけ、拡張機能ビューのWorkspace Recommendationsからこのローカル拡張をインストールします。ワークスペースを信頼してインストールした後は、JSONやJavaScriptを保存してVS Codeのウィンドウを再読み込みすれば、変更が反映されます。JavaScriptはVS CodeのExtension Hostが実行するため、読者側でNode.jsをインストールしたり、TypeScriptをコンパイルしたりする必要はありません。

最後の第12段階だけを発展編とし、直接編集してきた拡張をtools/extension-srcのTypeScriptソースへ移し、コンパイル結果を.vscode/extensionsへ生成する構成を紹介します。小規模な運用なら、第11段階のJavaScript版で止めても良いです。

第1段階:.vscodeに最小の拡張を置く

拡張機能のルートはpackage.json

VS Codeは、拡張機能フォルダー直下のpackage.jsonをマニフェストとして読みます。今回の拡張ルートはProductA/.vscode/extensions/product-a-script-supportです。

最小構成は次のようになります。

ProductA/
└─ .vscode/
   └─ extensions/
      └─ product-a-script-support/  ← 拡張ルート
         ├─ package.json            ← VS Codeが読むマニフェスト
         ├─ language-configuration.json
         └─ syntaxes/
            └─ legacybasic.tmLanguage.json

package.jsonの最小例です。

{
  "name": "product-a-script-support",
  "displayName": "Product A Script Support",
  "version": "0.0.1",
  "publisher": "sample",
  "engines": {
    "vscode": "^1.96.0"
  },
  "categories": [
    "Programming Languages"
  ],
  "contributes": {
    "languages": [
      {
        "id": "legacybasic",
        "aliases": [
          "Legacy BASIC"
        ],
        "configuration": "./language-configuration.json"
      }
    ],
    "grammars": [
      {
        "language": "legacybasic",
        "scopeName": "source.legacybasic",
        "path": "./syntaxes/legacybasic.tmLanguage.json"
      }
    ]
  }
}

重要なのは次の対応です。

.vscode/extensions/product-a-script-support/package.json
  contributes.languages.id = legacybasic
            │
            ├─ grammarのlanguage
            ├─ snippetsのlanguage
            ├─ VS Code APIへ渡す言語ID
            └─ files.associationsの値

package.jsonは単なるnpm設定ではなく、「このフォルダーがどのようなVS Code拡張なのか」を示すルートマニフェストです。

公式の項目一覧は次のページにあります。

この段階での追加先とファイル全体

この段階では、ProductA/.vscode/extensions/product-a-script-supportフォルダーを作り、その直下へpackage.jsonを新規作成します。上のJSONブロックは部分追記ではなく、現時点のpackage.json全文です。

configurationpathが参照するファイルも必要なので、最初は最小の空定義を置きます。

第1段階終了時のファイル全体を表示

ProductA/.vscode/extensions/product-a-script-support/package.json

{
  "name": "product-a-script-support",
  "displayName": "Product A Script Support",
  "version": "0.0.1",
  "publisher": "sample",
  "engines": {
    "vscode": "^1.96.0"
  },
  "categories": [
    "Programming Languages"
  ],
  "contributes": {
    "languages": [
      {
        "id": "legacybasic",
        "aliases": [
          "Legacy BASIC"
        ],
        "configuration": "./language-configuration.json"
      }
    ],
    "grammars": [
      {
        "language": "legacybasic",
        "scopeName": "source.legacybasic",
        "path": "./syntaxes/legacybasic.tmLanguage.json"
      }
    ]
  }
}

ProductA/.vscode/extensions/product-a-script-support/language-configuration.json

{}

ProductA/.vscode/extensions/product-a-script-support/syntaxes/legacybasic.tmLanguage.json

{
  "scopeName": "source.legacybasic",
  "patterns": []
}

この段階の確認

  1. ワークスペースを信頼します

  2. 拡張機能ビューを開き、推奨から Product A Script Support をインストールします
    image.png

  3. Scripts/以下の任意の.txtを開き、右下の言語モードからLegacy BASICを手動選択します
    image.png

image.png

Legacy BASICを選択できれば、ローカル拡張は正しく認識されています。第2段階以降は、ファイルを変更して
Ctrl + Shift + P でコマンドパレットを表示して、Developer: Reload Windowを実行するだけで更新が反映されます。

第2段階:.txt全部を奪わず、対象フォルダーだけ判定する

今回のスクリプトは.txtです。

拡張本体で次のように登録してはいけません。

{
  "extensions": [
    ".txt"
  ]
}

これをすると、通常のメモや説明文までLegacy BASICとして認識される可能性があります。

そこで拡張本体では拡張子を登録せず、製品ワークスペース側の.vscode/settings.jsonで対象フォルダーを限定します。

{
  "files.associations": {
    "**/Scripts/**/*.txt": "legacybasic"
  }
}

これにより、

ProductA/
├─ Scripts/
│  ├─ Main.txt       ← Legacy BASIC
│  └─ Sub/Test.txt   ← Legacy BASIC
└─ Documents/
   └─ Memo.txt       ← Plain Text

となります。

製品ごとに言語仕様や使用可能コマンドが異なる場合も、このワークスペース設定と拡張一式を製品リポジトリへ置けば、製品単位で管理できます。

この段階での追加先とファイル全体

この設定は拡張側のpackage.jsonへ追加するものではありません。スクリプトを格納する製品リポジトリ側のProductA/.vscode/settings.jsonへ追加します。既にsettings.jsonがある場合は、ルートオブジェクトの中へfiles.associationsプロパティを追加してください。

第2段階終了時の変更ファイル全文を表示

ProductA/.vscode/settings.json

{
  "files.associations": {
    "**/Scripts/**/*.txt": "legacybasic"
  }
}

この段階の確認

ProductA/Scripts/Demo.txtを開き、右下の言語モードが自動的にLegacy BASICになれば成功です。Documents/Memo.txtなど、Scripts以外の.txtPlain Textのままです。

第3段階:まずはJSONだけで色分けする

構文色分けにはTextMate Grammarを使用します。TextMate Grammarは正規表現を使って、コメント、文字列、キーワード、数値などを分類します。

例えば制御構文は次のように定義できます。

{
  "name": "keyword.control.legacybasic",
  "match": "(?i:\\b(IF|THEN|ELSE|ENDIF|WHILE|ENDWHILE|AND|OR|NOT)\\b)"
}

文字列変数と整数変数も分類できます。

[
  {
    "name": "variable.other.string.legacybasic",
    "match": "\\$[A-Za-z_][A-Za-z0-9_]*"
  },
  {
    "name": "variable.other.integer.legacybasic",
    "match": "![A-Za-z_][A-Za-z0-9_]*"
  }
]

ここでは型検査をしているわけではありません。$または!から始まる字句を、テーマが色付けできる種類へ分類しているだけです。

コマンドは、SEND_DATAなどの一覧を正規表現へ列挙する方法もあります。しかし、コマンド定義と色分け定義を二重管理したくないため、サンプルでは「大文字名の後ろに(があるもの」を関数形式コマンドとして色分けします。

{
  "name": "support.function.legacybasic",
  "match": "(?i:\\b[A-Z_][A-Z0-9_]*(?=\\s*\\())"
}

正確なコマンドかどうかは、後で読み込むコマンドカタログや言語エンジンが判断します。

この段階での追加先とファイル全体

3つのnamematchブロックは、.vscode/extensions/product-a-script-support/syntaxes/legacybasic.tmLanguage.jsonpatterns配列へ要素として追加します。JSON配列なので、各要素の間にはカンマが必要です。

第3段階終了時の変更ファイル全文を表示

ProductA/.vscode/extensions/product-a-script-support/syntaxes/legacybasic.tmLanguage.json

{
  "name": "Legacy BASIC",
  "scopeName": "source.legacybasic",
  "patterns": [
    {
      "name": "comment.line.apostrophe.legacybasic",
      "match": "'.*$"
    },
    {
      "name": "string.quoted.double.legacybasic",
      "begin": "\"",
      "end": "\""
    },
    {
      "name": "keyword.control.legacybasic",
      "match": "(?i:\\b(IF|THEN|ELSE|ENDIF|WHILE|ENDWHILE|AND|OR|NOT)\\b)"
    },
    {
      "name": "variable.other.string.legacybasic",
      "match": "\\$[A-Za-z_][A-Za-z0-9_]*"
    },
    {
      "name": "variable.other.integer.legacybasic",
      "match": "![A-Za-z_][A-Za-z0-9_]*"
    },
    {
      "name": "constant.numeric.legacybasic",
      "match": "\\b\\d+\\b"
    },
    {
      "name": "support.function.legacybasic",
      "match": "(?i:\\b[A-Z_][A-Z0-9_]*(?=\\s*\\())"
    }
  ]
}

この段階の確認

ウィンドウを再読み込みし、Scripts/Main.txtIF$Message!Count、文字列、コメントを書きます。それぞれ異なる種類として色分けされれば成功です。

image.png

第4段階:コメント、括弧、インデントを設定する

.vscode/extensions/product-a-script-support/language-configuration.jsonは次のような基本編集機能を担当します。

  • コメント切り替え
  • 括弧の自動補完
  • 選択範囲を括弧や引用符で囲む
  • インデント
  • 折りたたみ

公式ガイドはこちらです。

例えば、コメントが'、ブロック終端がENDIFENDWHILEなら次のようにします。

{
  "comments": {
    "lineComment": "'"
  },
  "brackets": [
    ["(", ")"]
  ],
  "indentationRules": {
    "increaseIndentPattern": "^\\s*(IF\\b.*|ELSE\\b.*|WHILE\\b.*)$",
    "decreaseIndentPattern": "^\\s*(ELSE\\b.*|ENDIF\\b.*|ENDWHILE\\b.*)$"
  }
}

この段階まではTypeScriptもNode.jsも不要です。JSONファイルだけで実装できます。

この段階での追加先とファイル全体

このブロックは.vscode/extensions/product-a-script-support/language-configuration.jsonのルートオブジェクトへ入れます。このファイルは第1段階の.vscode/extensions/product-a-script-support/package.jsonにあるcontributes.languages[].configurationから参照されています。

第4段階終了時の変更ファイル全文を表示

ProductA/.vscode/extensions/product-a-script-support/language-configuration.json

{
  "comments": {
    "lineComment": "'"
  },
  "brackets": [
    [
      "(",
      ")"
    ]
  ],
  "autoClosingPairs": [
    {
      "open": "(",
      "close": ")"
    },
    {
      "open": "\"",
      "close": "\""
    }
  ],
  "surroundingPairs": [
    [
      "(",
      ")"
    ],
    [
      "\"",
      "\""
    ]
  ],
  "indentationRules": {
    "increaseIndentPattern": "^\\s*(IF\\b.*|ELSE\\b.*|WHILE\\b.*)$",
    "decreaseIndentPattern": "^\\s*(ELSE\\b.*|ENDIF\\b.*|ENDWHILE\\b.*)$"
  },
  "folding": {
    "markers": {
      "start": "^\\s*(IF|WHILE)\\b",
      "end": "^\\s*(ENDIF|ENDWHILE)\\b"
    }
  }
}

この段階の確認

Ctrl+/で行コメントを切り替え、("が自動的に閉じることを確認します。IFWHILEの次の行でインデントが増えれば、この段階は成功です。

image.png

第5段階:スニペットを追加する

.vscode/extensions/product-a-script-support/snippets/legacybasic.jsonへ定型構文を登録します。

{
  "IF-ELSE文": {
    "prefix": "ifelse",
    "body": [
      "IF ${1:condition}",
      "\t${2:' 真の場合}",
      "ELSE",
      "\t${3:' 偽の場合}",
      "ENDIF"
    ],
    "description": "IF~ELSE~ENDIFを挿入します"
  }
}

${1:condition}は、挿入後に選択される入力位置です。Tabキーを押すと${2:...}${3:...}へ移動します。

.vscode/extensions/product-a-script-support/package.jsonからスニペットを登録します。

{
  "contributes": {
    "snippets": [
      {
        "language": "legacybasic",
        "path": "./snippets/legacybasic.json"
      }
    ]
  }
}

この段階での追加先とファイル全体

最初のブロックは.vscode/extensions/product-a-script-support/snippets/legacybasic.jsonとして新規作成します。次のcontributes.snippetsは、.vscode/extensions/product-a-script-support/package.jsonの既存contributesオブジェクト内で、languagesgrammarsに並ぶプロパティとして追加します。

第5段階終了時の変更ファイル全文を表示

ProductA/.vscode/extensions/product-a-script-support/snippets/legacybasic.json

{
  "IF-ELSE文": {
    "prefix": "ifelse",
    "body": [
      "IF ${1:condition}",
      "\t${2:' 真の場合}",
      "ELSE",
      "\t${3:' 偽の場合}",
      "ENDIF"
    ],
    "description": "IF~ELSE~ENDIFを挿入します"
  },
  "WHILE文": {
    "prefix": "while",
    "body": [
      "WHILE ${1:condition}",
      "\t${2:' 繰り返す処理}",
      "ENDWHILE"
    ],
    "description": "WHILE~ENDWHILEを挿入します"
  }
}

ProductA/.vscode/extensions/product-a-script-support/package.json

{
  "name": "product-a-script-support",
  "displayName": "Product A Script Support",
  "version": "0.0.1",
  "publisher": "sample",
  "engines": {
    "vscode": "^1.96.0"
  },
  "categories": [
    "Programming Languages"
  ],
  "contributes": {
    "languages": [
      {
        "id": "legacybasic",
        "aliases": [
          "Legacy BASIC"
        ],
        "configuration": "./language-configuration.json"
      }
    ],
    "grammars": [
      {
        "language": "legacybasic",
        "scopeName": "source.legacybasic",
        "path": "./syntaxes/legacybasic.tmLanguage.json"
      }
    ],
    "snippets": [
      {
        "language": "legacybasic",
        "path": "./snippets/legacybasic.json"
      }
    ]
  }
}

この段階の確認

ウィンドウを再読み込みし、スクリプト内でifelseまたはwhileを入力して補完候補を選びます。Tabキーで入力位置を順番に移動できれば成功です。

image.png

image.png

第6段階:extension.jsで動的な編集支援を作る

ここからVS Code APIを使う機能を追加します。

この段階ではTypeScriptを使わず、VS Codeがそのまま実行できるJavaScriptを.vscode/extensionsへ置きます。読者はコンパイルせずに試せます。

VS Code拡張のエントリーポイントは、一般にactivatedeactivateです。CommonJS形式で公開します。

function activate(context) {
  // 拡張が有効になったときの登録処理
}

function deactivate() {
  // 必要なら終了処理
}

module.exports = {
  activate,
  deactivate
};
.vscode/extensions/product-a-script-support/out/extension.js
                         ↓
             VS CodeのExtension Hostで実行

VS Code本体がExtension Hostを持っているため、このJavaScriptを動かすだけならNode.jsの追加インストールは不要です。

この段階での追加先とファイル全体

activatedeactivate.vscode/extensions/product-a-script-support/out/extension.jsへ記述します。VS Codeがこのファイルを読めるよう、拡張ルートのpackage.jsonmainactivationEventsを追加します。

追加位置は次の対応です。

.vscode/extensions/product-a-script-support/package.json
├─ main                  ← ルートへ追加
└─ activationEvents      ← ルートへ追加

.vscode/extensions/product-a-script-support/out/extension.js
                              ← 新規作成
第6段階終了時の変更ファイル全文を表示

ProductA/.vscode/extensions/product-a-script-support/package.json

{
  "name": "product-a-script-support",
  "displayName": "Product A Script Support",
  "version": "0.0.1",
  "publisher": "sample",
  "engines": {
    "vscode": "^1.96.0"
  },
  "categories": [
    "Programming Languages"
  ],
  "main": "./out/extension.js",
  "activationEvents": [
    "onLanguage:legacybasic"
  ],
  "contributes": {
    "languages": [
      {
        "id": "legacybasic",
        "aliases": [
          "Legacy BASIC"
        ],
        "configuration": "./language-configuration.json"
      }
    ],
    "grammars": [
      {
        "language": "legacybasic",
        "scopeName": "source.legacybasic",
        "path": "./syntaxes/legacybasic.tmLanguage.json"
      }
    ],
    "snippets": [
      {
        "language": "legacybasic",
        "path": "./snippets/legacybasic.json"
      }
    ]
  }
}

ProductA/.vscode/extensions/product-a-script-support/out/extension.js

const vscode = require("vscode");

function activate(context) {
  const output = vscode.window.createOutputChannel(
    "Legacy BASIC"
  );

  output.appendLine("Legacy BASIC拡張を有効化しました。");
  output.show(true);

  void vscode.window.showInformationMessage(
    "Legacy BASIC拡張を有効化しました。"
  );

  context.subscriptions.push(output);
}

function deactivate() {
  // 明示的な終了処理はありません。
}

module.exports = {
  activate,
  deactivate
};

保存後、コマンドパレットからDeveloper: Reload Windowを実行します。出力パネルのLegacy BASICへ有効化メッセージが出れば、この段階は成功です。

image.png

第7段階:大量のコマンドをカタログ化する

コマンドごとにJavaScriptを書くと保守できません。

コマンド名、分類、説明、検索語、引数、戻り値、使用例をデータ化し、1件の定義を複数機能で共有します。

{
  "name": "SEND_DATA",
  "category": "通信",
  "summary": "指定した送信先へ文字列データを送信します。",
  "keywords": [
    "送信",
    "通信",
    "データ"
  ],
  "parameters": [
    {
      "name": "destination",
      "type": "integer",
      "description": "送信先番号"
    },
    {
      "name": "data",
      "type": "string",
      "description": "送信する文字列"
    }
  ],
  "returnType": "void",
  "snippet": "SEND_DATA(${1:destination}, \"${2:data}\")",
  "example": "SEND_DATA(1, \"ABC\")"
}

この1件から次を生成します。

  • 通常の入力補完
  • 日本語コマンド検索
  • ホバー説明
  • 引数ヒント
  • サイドバーのカテゴリー別一覧

特に、コマンド名の1文字目すら思い出せない問題には、日本語の検索語が効きます。

item.filterText = [
  definition.name,
  definition.category,
  definition.summary,
  ...definition.keywords
].join(" ");

送信と入力すれば、コマンド名がSEND_DATAであることを覚えていなくても候補に出せます。

この段階での追加先とファイル全体

コマンド定義は.vscode/extensions/product-a-script-support/data/commands.jsonとして新規作成します。filterTextを設定するブロックは、同じ拡張フォルダーのout/extension.js内でCompletionItemを組み立てるcreateCompletionItem関数へ入れます。

日本語検索用のコマンドをVS Codeへ公開するため、.vscode/extensions/product-a-script-support/package.jsonでは次の2か所を追加します。

activationEvents
└─ onCommand:legacyBasic.searchCommands

contributes
└─ commands
   └─ command: legacyBasic.searchCommands
第7段階終了時の変更ファイル全文を表示

ProductA/.vscode/extensions/product-a-script-support/data/commands.json

[
  {
    "name": "SEND_DATA",
    "category": "通信",
    "summary": "指定した送信先へ文字列データを送信します。",
    "keywords": [
      "送信",
      "通信",
      "データ"
    ],
    "parameters": [
      {
        "name": "destination",
        "type": "integer",
        "description": "送信先番号"
      },
      {
        "name": "data",
        "type": "string",
        "description": "送信する文字列"
      }
    ],
    "returnType": "void",
    "snippet": "SEND_DATA(${1:destination}, \"${2:data}\")",
    "example": "SEND_DATA(1, \"ABC\")"
  },
  {
    "name": "GET_TIME",
    "category": "時間",
    "summary": "現在時刻を文字列で取得します。",
    "keywords": [
      "時刻",
      "現在"
    ],
    "parameters": [],
    "returnType": "string",
    "snippet": "GET_TIME()",
    "example": "$Now = GET_TIME()"
  }
]

ProductA/.vscode/extensions/product-a-script-support/out/extension.js

const fs = require("node:fs");
const path = require("node:path");
const vscode = require("vscode");

const LANGUAGE_ID = "legacybasic";
const SEARCH_COMMAND_ID = "legacyBasic.searchCommands";

function activate(context) {
  const definitions = loadDefinitions(context);
  const selector = {
    language: LANGUAGE_ID,
    scheme: "file"
  };

  context.subscriptions.push(
    vscode.languages.registerCompletionItemProvider(
      selector,
      {
        provideCompletionItems() {
          return definitions.map(createCompletionItem);
        }
      }
    ),
    vscode.commands.registerCommand(
      SEARCH_COMMAND_ID,
      async () => {
        await searchCommands(definitions);
      }
    )
  );
}

function deactivate() {
  // 明示的な終了処理はありません。
}

function loadDefinitions(context) {
  const filePath = path.join(
    context.extensionPath,
    "data",
    "commands.json"
  );
  return JSON.parse(fs.readFileSync(filePath, "utf8"));
}

function createCompletionItem(definition) {
  const item = new vscode.CompletionItem(
    definition.name,
    vscode.CompletionItemKind.Function
  );
  item.detail =
    `${definition.category} | 戻り値: ${definition.returnType}`;
  item.documentation = new vscode.MarkdownString(
    `${definition.summary}\n\n` +
    `例: \`${definition.example}\``
  );
  item.insertText = new vscode.SnippetString(
    definition.snippet
  );
  item.filterText = [
    definition.name,
    definition.category,
    definition.summary,
    ...definition.keywords
  ].join(" ");
  return item;
}

async function searchCommands(definitions) {
  const selected = await vscode.window.showQuickPick(
    definitions.map((definition) => ({
      label: definition.name,
      description: definition.category,
      detail: [
        definition.summary,
        ...definition.keywords
      ].join(" ")
    })),
    {
      placeHolder: "目的や日本語のキーワードで検索"
    }
  );

  if (!selected) {
    return;
  }

  const editor = vscode.window.activeTextEditor;
  if (!editor || editor.document.languageId !== LANGUAGE_ID) {
    return;
  }

  const definition = definitions.find(
    (item) => item.name === selected.label
  );
  if (definition) {
    await editor.insertSnippet(
      new vscode.SnippetString(definition.snippet)
    );
  }
}

module.exports = {
  activate,
  deactivate
};

ProductA/.vscode/extensions/product-a-script-support/package.json

{
  "name": "product-a-script-support",
  "displayName": "Product A Script Support",
  "version": "0.0.1",
  "publisher": "sample",
  "engines": {
    "vscode": "^1.96.0"
  },
  "categories": [
    "Programming Languages"
  ],
  "main": "./out/extension.js",
  "activationEvents": [
    "onLanguage:legacybasic",
    "onCommand:legacyBasic.searchCommands"
  ],
  "contributes": {
    "languages": [
      {
        "id": "legacybasic",
        "aliases": [
          "Legacy BASIC"
        ],
        "configuration": "./language-configuration.json"
      }
    ],
    "grammars": [
      {
        "language": "legacybasic",
        "scopeName": "source.legacybasic",
        "path": "./syntaxes/legacybasic.tmLanguage.json"
      }
    ],
    "snippets": [
      {
        "language": "legacybasic",
        "path": "./snippets/legacybasic.json"
      }
    ],
    "commands": [
      {
        "command": "legacyBasic.searchCommands",
        "title": "Legacy BASIC: コマンドを検索"
      }
    ]
  }
}

ウィンドウを再読み込みしたあと、Ctrl + Shift + P の「コマンドの表示と実行」から、Legacy BASIC: コマンドを検索を実行します。

image.png

送信SEND_DATAを絞り込み、エディターへ挿入できれば成功です。

image.png

image.png

第8段階:JSONを手編集せず、CSVから生成する

コマンド数が増えると、commands.jsonの手編集はつらくなります。

  • カンマの不足
  • 余計なカンマ
  • "の閉じ忘れ
  • []の対応
  • コマンド名や引数順の重複

そこで、JSONは実行用の生成物とし、人間はCSVを編集します。

1コマンド1行ではなく、1引数1行

引数は可変長なので、横方向に引数1引数2引数3と増やしません。1引数につき1行を使用します。

さらに、2行目以降のコマンド共通項目は空欄にできます。

CommandName,Category,Summary,ReturnType,Keywords,Example,ParameterOrder,ParameterName,ParameterType,ParameterDescription
SEND_DATA,通信,データを送信します,void,送信|通信,"SEND_DATA(1, ""ABC"")",1,destination,integer,送信先番号
,,,,,,2,data,string,送信する文字列
GET_TIME,時間,現在時刻を取得します,string,時刻|現在,GET_TIME(),,,,

読み取り規則は次のとおりです。

  • CommandNameあり:新しいコマンド
  • CommandNameなし:直前コマンドの引数継続行
  • 引数欄も空:引数なしコマンド

この形式はデータベースとしては非正規化されていますが、目的はトランザクション処理ではなく、人間がExcelなどで編集しやすいコマンド定義表です。

生成スクリプトでは次を検査します。

  • コマンド名の形式と重複
  • 継続行より前にコマンド行があるか
  • 継続行の共通項目が空か
  • 引数順の形式と重複
  • 引数名の形式と重複
  • 引数型と戻り値型
  • 検索キーワード

JSONのカンマや引用符はConvertTo-Jsonへ任せます。

この段階での追加先とファイル全体

人が編集するのは.vscode/extensions/product-a-script-support/definitions/commands.csvです。変換処理は製品リポジトリ直下のGenerate-Commands.ps1へ置き、生成先を.vscode/extensions/product-a-script-support/data/commands.jsonとします。

.vscode/extensions/product-a-script-support/definitions/commands.csv
                              │
                              │ Generate-Commands.ps1
                              ▼
.vscode/extensions/product-a-script-support/data/commands.json

CSVの2行目以降で省略するのは、CommandNameからExampleまでのコマンド共通列です。ParameterOrder以降は引数ごとに記述します。

第8段階終了時の変更ファイル全文を表示

ProductA/.vscode/extensions/product-a-script-support/definitions/commands.csv

CommandName,Category,Summary,ReturnType,Keywords,Example,ParameterOrder,ParameterName,ParameterType,ParameterDescription
SEND_DATA,通信,指定した送信先へ文字列データを送信します,void,送信|通信|データ,"SEND_DATA(1, ""ABC"")",1,destination,integer,送信先番号
,,,,,,2,data,string,送信する文字列
WRITE_LOG,ログ,ログへメッセージを出力します,void,ログ|記録|出力,"WRITE_LOG(""処理を開始します"")",1,message,string,出力するメッセージ
WAIT,時間,指定した時間だけ処理を待機します,void,待つ|待機|時間,WAIT(1000),1,milliseconds,integer,待機時間(ミリ秒)
GET_TIME,時間,現在時刻を文字列で取得します,string,時刻|現在|時計,GET_TIME(),,,,
FILE_EXISTS,ファイル,指定したファイルが存在するか確認します,boolean,ファイル|存在|確認,"FILE_EXISTS(""C:\Data\input.txt"")",1,path,string,確認するファイルのパス

ProductA/Generate-Commands.ps1
UTF-8 BOM付で保存します。

param(
    [string]$CsvPath = (
        Join-Path $PSScriptRoot ".vscode\extensions\product-a-script-support\definitions\commands.csv"
    ),
    [string]$OutputPath = (
        Join-Path $PSScriptRoot ".vscode\extensions\product-a-script-support\data\commands.json"
    )
)

Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"

$allowedReturnTypes = @("void", "string", "integer", "boolean")
$allowedParameterTypes = @("string", "integer", "boolean")
$commonColumnNames = @(
    "Category",
    "Summary",
    "ReturnType",
    "Keywords",
    "Example"
)

$rows = @(Import-Csv -LiteralPath $CsvPath -Encoding UTF8)
if ($rows.Count -eq 0) {
    throw "コマンド定義CSVが空です: $CsvPath"
}

$states = [System.Collections.Generic.List[object]]::new()
$commandNames = [System.Collections.Generic.HashSet[string]]::new(
    [System.StringComparer]::OrdinalIgnoreCase
)
$current = $null
$rowNumber = 1

foreach ($row in $rows) {
    $rowNumber++
    $commandName = $row.CommandName.Trim()

    if ($commandName.Length -gt 0) {
        if ($commandName -notmatch "^[A-Za-z_][A-Za-z0-9_]*$") {
            throw "${rowNumber}行目: コマンド名が不正です: $commandName"
        }
        if (-not $commandNames.Add($commandName)) {
            throw "${rowNumber}行目: コマンド名が重複しています: $commandName"
        }
        if ([string]::IsNullOrWhiteSpace($row.Category) -or
            [string]::IsNullOrWhiteSpace($row.Summary) -or
            [string]::IsNullOrWhiteSpace($row.ReturnType)) {
            throw "${rowNumber}行目: コマンドの共通項目が不足しています。"
        }

        $returnType = $row.ReturnType.Trim().ToLowerInvariant()
        if ($allowedReturnTypes -notcontains $returnType) {
            throw "${rowNumber}行目: 戻り値型が不正です: $returnType"
        }

        $keywords = @(
            $row.Keywords -split "\|" |
                ForEach-Object { $_.Trim() } |
                Where-Object { $_.Length -gt 0 }
        )
        if ($keywords.Count -eq 0) {
            throw "${rowNumber}行目: 検索キーワードがありません。"
        }

        $current = [pscustomobject]@{
            Name = $commandName
            Category = $row.Category.Trim()
            Summary = $row.Summary.Trim()
            ReturnType = $returnType
            Keywords = $keywords
            Example = $row.Example.Trim()
            Parameters = [System.Collections.Generic.List[object]]::new()
            ParameterOrders = [System.Collections.Generic.HashSet[int]]::new()
            ParameterNames = [System.Collections.Generic.HashSet[string]]::new(
                [System.StringComparer]::OrdinalIgnoreCase
            )
        }
        $states.Add($current)
    }
    else {
        if ($null -eq $current) {
            throw "${rowNumber}行目: 継続行より前にコマンド行がありません。"
        }

        foreach ($columnName in $commonColumnNames) {
            if (-not [string]::IsNullOrWhiteSpace($row.$columnName)) {
                throw "${rowNumber}行目: 継続行の${columnName}は空欄にしてください。"
            }
        }
    }

    $parameterValues = @(
        $row.ParameterOrder,
        $row.ParameterName,
        $row.ParameterType,
        $row.ParameterDescription
    )
    $hasParameterValue = @(
        $parameterValues |
            Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
    ).Count -gt 0

    if (-not $hasParameterValue) {
        continue
    }
    $missingParameterValueCount = @(
        $parameterValues |
            Where-Object { [string]::IsNullOrWhiteSpace($_) }
    ).Count
    if ($missingParameterValueCount -gt 0) {
        throw "${rowNumber}行目: 引数項目は4項目すべて入力してください。"
    }

    $order = 0
    if (-not [int]::TryParse($row.ParameterOrder, [ref]$order) -or
        $order -le 0) {
        throw "${rowNumber}行目: 引数順は1以上の整数で入力してください。"
    }
    if (-not $current.ParameterOrders.Add($order)) {
        throw "${rowNumber}行目: 引数順が重複しています: $order"
    }

    $parameterName = $row.ParameterName.Trim()
    if ($parameterName -notmatch "^[A-Za-z_][A-Za-z0-9_]*$") {
        throw "${rowNumber}行目: 引数名が不正です: $parameterName"
    }
    if (-not $current.ParameterNames.Add($parameterName)) {
        throw "${rowNumber}行目: 引数名が重複しています: $parameterName"
    }

    $parameterType = $row.ParameterType.Trim().ToLowerInvariant()
    if ($allowedParameterTypes -notcontains $parameterType) {
        throw "${rowNumber}行目: 引数型が不正です: $parameterType"
    }

    $current.Parameters.Add([pscustomobject]@{
        Order = $order
        Name = $parameterName
        Type = $parameterType
        Description = $row.ParameterDescription.Trim()
    })
}

$definitions = @(
    foreach ($state in $states) {
        $parameters = @(
            $state.Parameters |
                Sort-Object Order |
                ForEach-Object {
                    [ordered]@{
                        name = $_.Name
                        type = $_.Type
                        description = $_.Description
                    }
                }
        )

        $snippetArguments = @(
            for ($index = 0; $index -lt $parameters.Count; $index++) {
                $parameter = $parameters[$index]
                $placeholder = '$' + '{' + ($index + 1) +
                    ':' + $parameter.name + '}'

                if ($parameter.type -eq "string") {
                    '"' + $placeholder + '"'
                }
                else {
                    $placeholder
                }
            }
        )

        [ordered]@{
            name = $state.Name
            category = $state.Category
            summary = $state.Summary
            keywords = @($state.Keywords)
            parameters = $parameters
            returnType = $state.ReturnType
            snippet = $state.Name + '(' + ($snippetArguments -join ", ") + ')'
            example = $state.Example
        }
    }
)

$json = $definitions | ConvertTo-Json -Depth 10
$outputDirectory = Split-Path -Parent $OutputPath
[System.IO.Directory]::CreateDirectory($outputDirectory) | Out-Null
$utf8WithoutBom = [System.Text.UTF8Encoding]::new($false)
[System.IO.File]::WriteAllText($OutputPath, $json + [Environment]::NewLine, $utf8WithoutBom)

Write-Host "生成完了: $OutputPath"
Write-Host "コマンド数: $($definitions.Count)"

ProductA/.vscode/extensions/product-a-script-support/data/commands.json

このファイルは生成物です。手作業では変更しません。

[
  {
    "name": "SEND_DATA",
    "category": "通信",
    "summary": "指定した送信先へ文字列データを送信します。",
    "keywords": [
      "送信",
      "通信",
      "データ",
      "メッセージ",
      "転送"
    ],
    "parameters": [
      {
        "name": "destination",
        "type": "integer",
        "description": "送信先番号"
      },
      {
        "name": "data",
        "type": "string",
        "description": "送信する文字列"
      }
    ],
    "returnType": "void",
    "snippet": "SEND_DATA(${1:destination}, \"${2:data}\")",
    "example": "SEND_DATA(1, \"ABC\")"
  },
  {
    "name": "RECEIVE_DATA",
    "category": "通信",
    "summary": "受信済みのデータを取得します。",
    "keywords": [
      "受信",
      "通信",
      "データ",
      "受け取る"
    ],
    "parameters": [
      {
        "name": "source",
        "type": "integer",
        "description": "受信元番号"
      }
    ],
    "returnType": "string",
    "snippet": "RECEIVE_DATA(${1:source})",
    "example": "Message = RECEIVE_DATA(1)"
  },
  {
    "name": "CONNECT",
    "category": "通信",
    "summary": "指定した接続先へ接続します。",
    "keywords": [
      "接続",
      "通信",
      "開始",
      "つなぐ"
    ],
    "parameters": [
      {
        "name": "destination",
        "type": "integer",
        "description": "接続先番号"
      },
      {
        "name": "timeout",
        "type": "integer",
        "description": "タイムアウト時間(ミリ秒)"
      }
    ],
    "returnType": "boolean",
    "snippet": "CONNECT(${1:destination}, ${2:timeout})",
    "example": "Result = CONNECT(1, 5000)"
  },
  {
    "name": "DISCONNECT",
    "category": "通信",
    "summary": "指定した接続先との通信を切断します。",
    "keywords": [
      "切断",
      "通信",
      "接続終了",
      "閉じる"
    ],
    "parameters": [
      {
        "name": "destination",
        "type": "integer",
        "description": "接続先番号"
      }
    ],
    "returnType": "void",
    "snippet": "DISCONNECT(${1:destination})",
    "example": "DISCONNECT(1)"
  },
  {
    "name": "IS_CONNECTED",
    "category": "通信",
    "summary": "指定した接続先が接続中か確認します。",
    "keywords": [
      "接続",
      "確認",
      "状態",
      "通信中"
    ],
    "parameters": [
      {
        "name": "destination",
        "type": "integer",
        "description": "接続先番号"
      }
    ],
    "returnType": "boolean",
    "snippet": "IS_CONNECTED(${1:destination})",
    "example": "IF IS_CONNECTED(1)"
  },
  {
    "name": "READ_FILE",
    "category": "ファイル",
    "summary": "ファイルの内容を文字列として読み込みます。",
    "keywords": [
      "ファイル",
      "読む",
      "読込",
      "読み込み",
      "テキスト"
    ],
    "parameters": [
      {
        "name": "path",
        "type": "string",
        "description": "読み込むファイルのパス"
      }
    ],
    "returnType": "string",
    "snippet": "READ_FILE(\"${1:path}\")",
    "example": "Text = READ_FILE(\"C:\\\\Data\\\\input.txt\")"
  },
  {
    "name": "WRITE_FILE",
    "category": "ファイル",
    "summary": "文字列をファイルへ書き込みます。",
    "keywords": [
      "ファイル",
      "書く",
      "書込",
      "保存",
      "出力"
    ],
    "parameters": [
      {
        "name": "path",
        "type": "string",
        "description": "書き込み先ファイルのパス"
      },
      {
        "name": "text",
        "type": "string",
        "description": "書き込む文字列"
      }
    ],
    "returnType": "void",
    "snippet": "WRITE_FILE(\"${1:path}\", \"${2:text}\")",
    "example": "WRITE_FILE(\"C:\\\\Data\\\\result.txt\", \"OK\")"
  },
  {
    "name": "FILE_EXISTS",
    "category": "ファイル",
    "summary": "指定したファイルが存在するか確認します。",
    "keywords": [
      "ファイル",
      "存在",
      "確認",
      "ある"
    ],
    "parameters": [
      {
        "name": "path",
        "type": "string",
        "description": "確認するファイルのパス"
      }
    ],
    "returnType": "boolean",
    "snippet": "FILE_EXISTS(\"${1:path}\")",
    "example": "IF FILE_EXISTS(\"C:\\\\Data\\\\input.txt\")"
  },
  {
    "name": "WRITE_LOG",
    "category": "ログ",
    "summary": "ログへメッセージを出力します。",
    "keywords": [
      "ログ",
      "記録",
      "出力",
      "メッセージ"
    ],
    "parameters": [
      {
        "name": "message",
        "type": "string",
        "description": "出力するメッセージ"
      }
    ],
    "returnType": "void",
    "snippet": "WRITE_LOG(\"${1:message}\")",
    "example": "WRITE_LOG(\"処理を開始します\")"
  },
  {
    "name": "CLEAR_LOG",
    "category": "ログ",
    "summary": "現在のログを消去します。",
    "keywords": [
      "ログ",
      "消す",
      "消去",
      "クリア",
      "初期化"
    ],
    "parameters": [],
    "returnType": "void",
    "snippet": "CLEAR_LOG()",
    "example": "CLEAR_LOG()"
  },
  {
    "name": "WAIT",
    "category": "時間",
    "summary": "指定した時間だけ処理を待機します。",
    "keywords": [
      "待つ",
      "待機",
      "時間",
      "遅延",
      "スリープ"
    ],
    "parameters": [
      {
        "name": "milliseconds",
        "type": "integer",
        "description": "待機時間(ミリ秒)"
      }
    ],
    "returnType": "void",
    "snippet": "WAIT(${1:milliseconds})",
    "example": "WAIT(1000)"
  },
  {
    "name": "GET_TIME",
    "category": "時間",
    "summary": "現在時刻を文字列で取得します。",
    "keywords": [
      "時刻",
      "時間",
      "現在",
      "時計"
    ],
    "parameters": [],
    "returnType": "string",
    "snippet": "GET_TIME()",
    "example": "Now = GET_TIME()"
  },
  {
    "name": "SHOW_MESSAGE",
    "category": "画面",
    "summary": "画面にメッセージを表示します。",
    "keywords": [
      "画面",
      "表示",
      "メッセージ",
      "通知",
      "ダイアログ"
    ],
    "parameters": [
      {
        "name": "message",
        "type": "string",
        "description": "表示するメッセージ"
      }
    ],
    "returnType": "void",
    "snippet": "SHOW_MESSAGE(\"${1:message}\")",
    "example": "SHOW_MESSAGE(\"完了しました\")"
  },
  {
    "name": "START_DEVICE",
    "category": "装置制御",
    "summary": "指定した装置の運転を開始します。",
    "keywords": [
      "装置",
      "開始",
      "運転",
      "起動",
      "動かす"
    ],
    "parameters": [
      {
        "name": "deviceId",
        "type": "integer",
        "description": "装置番号"
      }
    ],
    "returnType": "boolean",
    "snippet": "START_DEVICE(${1:deviceId})",
    "example": "Result = START_DEVICE(1)"
  },
  {
    "name": "STOP_DEVICE",
    "category": "装置制御",
    "summary": "指定した装置の運転を停止します。",
    "keywords": [
      "装置",
      "停止",
      "止める",
      "終了"
    ],
    "parameters": [
      {
        "name": "deviceId",
        "type": "integer",
        "description": "装置番号"
      }
    ],
    "returnType": "boolean",
    "snippet": "STOP_DEVICE(${1:deviceId})",
    "example": "Result = STOP_DEVICE(1)"
  }
]

この段階の確認

製品リポジトリのルートで次を実行します。

.\Generate-Commands.ps1

.vscode/extensions/product-a-script-support/data/commands.jsonが生成されることを確認します。VS Codeを再読み込みし、追加したCSV定義がコマンド検索へ現れれば成功です。

image.png

第9段階:変数補完と型ホバー

今回の変数は、名前自体に型識別子があります。

変数
$Name string
!Value integer

そのため、代入式から型推論する必要はありません。

function getVariableType(name) {
  return name.startsWith("$")
    ? "string"
    : "integer";
}

ファイル内を走査して変数を集めれば、補完候補へ追加できます。

$Message       変数 | string
!Count         変数 | integer
!Destination   変数 | integer

ホバーでは次のように表示します。

変数: $Message
型: string

サンプルでは単純な正規表現だけでなく、文字列リテラルと'コメントを避けて変数を走査します。これにより、

WRITE_LOG("$Messageは説明用の文字列")
' !OldCountは廃止済み

を変数候補に含めにくくしています。

この段階での追加位置とファイル全体イメージ

変数支援も.vscode/extensions/product-a-script-support/out/extension.jsへ追加します。既存のコマンド補完を消すのではなく、同じprovideCompletionItemsの戻り値へ変数候補を先頭追加します。ホバーも同様に、最初に変数かどうかを調べ、変数でなければ既存のコマンドホバー処理へ進めます。

.vscode/extensions/product-a-script-support/out/extension.js
├─ activate
│  ├─ registerCompletionItemProvider
│  │  └─ 変数候補 + 既存のコマンド候補   ← 変更
│  └─ registerHoverProvider
│     ├─ 変数なら名前と型を返す           ← 追加
│     └─ それ以外は既存のコマンド説明
├─ createVariableCompletionItems         ← 追加
├─ getVariableType                       ← 追加
├─ findVariableAtPosition                ← 追加
└─ findVariablesInLine                   ← 追加

以下は、activate内へ追加・変更するブロックの全体です。

const definitionMap = new Map(
  definitions.map((definition) => [
    definition.name.toUpperCase(),
    definition
  ])
);

context.subscriptions.push(
  vscode.languages.registerCompletionItemProvider(
    LANGUAGE_ID,
    {
      provideCompletionItems(document, position) {
        return [
          ...createVariableCompletionItems(document),
          ...definitions.map(createCompletionItem)
        ];
      }
    }
  )
);

context.subscriptions.push(
  vscode.languages.registerHoverProvider(LANGUAGE_ID, {
    provideHover(document, position) {
      const variable = findVariableAtPosition(
        document,
        position
      );
      if (variable !== undefined) {
        return new vscode.Hover(
          new vscode.MarkdownString(
            `**変数:** \`${variable.name}\`\n\n` +
            `**型:** \`${getVariableType(variable.name)}\``
          ),
          new vscode.Range(
            position.line,
            variable.start,
            position.line,
            variable.end
          )
        );
      }

      const range = document.getWordRangeAtPosition(
        position,
        /[A-Za-z_][A-Za-z0-9_]*/
      );
      if (!range) {
        return undefined;
      }

      const definition = definitionMap.get(
        document.getText(range).toUpperCase()
      );
      if (!definition) {
        return undefined;
      }

      return new vscode.Hover(
        new vscode.MarkdownString(
          `${definition.summary}\n\n` +
          `戻り値: \`${definition.returnType}\`\n\n` +
          `例: \`${definition.example}\``
        ),
        range
      );
    }
  })
);

次の補助関数は、activate関数の閉じ括弧より後ろへ追加します。これが変数支援部分の全文です。

第9段階で追加する変数支援関数の全文を表示
const fs = require("node:fs");
const path = require("node:path");
const vscode = require("vscode");

const LANGUAGE_ID = "legacybasic";
const SEARCH_COMMAND_ID = "legacyBasic.searchCommands";

function activate(context) {
  const definitions = loadDefinitions(context);

  const definitionMap = new Map(
    definitions.map((definition) => [
      definition.name.toUpperCase(),
      definition
    ])
  );

  const selector = {
    language: LANGUAGE_ID,
    scheme: "file"
  };

  context.subscriptions.push(
    vscode.languages.registerCompletionItemProvider(
      selector,
      {
        provideCompletionItems(document) {
          return [
            ...createVariableCompletionItems(document),
            ...definitions.map(createCompletionItem)
          ];
        }
      }
    )
  );

  context.subscriptions.push(
    vscode.languages.registerHoverProvider(
      selector,
      {
        provideHover(document, position) {
          const variable = findVariableAtPosition(
            document,
            position
          );

          if (variable !== undefined) {
            return new vscode.Hover(
              new vscode.MarkdownString(
                `**変数:** \`${variable.name}\`\n\n` +
                `**型:** \`${getVariableType(variable.name)}\``
              ),
              new vscode.Range(
                position.line,
                variable.start,
                position.line,
                variable.end
              )
            );
          }

          const range = document.getWordRangeAtPosition(
            position,
            /[A-Za-z_][A-Za-z0-9_]*/
          );

          if (range === undefined) {
            return undefined;
          }

          const commandName = document
            .getText(range)
            .toUpperCase();

          const definition = definitionMap.get(commandName);

          if (definition === undefined) {
            return undefined;
          }

          return new vscode.Hover(
            new vscode.MarkdownString(
              `${definition.summary}\n\n` +
              `**分類:** ${definition.category}\n\n` +
              `**戻り値:** \`${definition.returnType}\`\n\n` +
              `**使用例:** \`${definition.example}\``
            ),
            range
          );
        }
      }
    )
  );

  context.subscriptions.push(
    vscode.commands.registerCommand(
      SEARCH_COMMAND_ID,
      async () => {
        await searchCommands(definitions);
      }
    )
  );
}

function deactivate() {
  // 明示的な終了処理はありません。
}

function loadDefinitions(context) {
  const filePath = path.join(
    context.extensionPath,
    "data",
    "commands.json"
  );

  return JSON.parse(
    fs.readFileSync(filePath, "utf8")
  );
}

function createCompletionItem(definition) {
  const item = new vscode.CompletionItem(
    definition.name,
    vscode.CompletionItemKind.Function
  );

  item.detail =
    `${definition.category} | ` +
    `戻り値: ${definition.returnType}`;

  item.documentation = new vscode.MarkdownString(
    `${definition.summary}\n\n` +
    `例: \`${definition.example}\``
  );

  item.insertText = new vscode.SnippetString(
    definition.snippet
  );

  item.filterText = [
    definition.name,
    definition.category,
    definition.summary,
    ...definition.keywords
  ].join(" ");

  item.sortText = `1_${definition.name}`;

  return item;
}

function createVariableCompletionItems(document) {
  const names = new Map();

  for (
    let lineNumber = 0;
    lineNumber < document.lineCount;
    lineNumber++
  ) {
    const lineText = document.lineAt(lineNumber).text;
    const occurrences = findVariablesInLine(lineText);

    for (const occurrence of occurrences) {
      names.set(
        occurrence.name.toUpperCase(),
        occurrence.name
      );
    }
  }

  return [...names.values()]
    .sort((left, right) =>
      left.localeCompare(right)
    )
    .map((name) => {
      const item = new vscode.CompletionItem(
        name,
        vscode.CompletionItemKind.Variable
      );

      item.detail =
        `変数 | ${getVariableType(name)}`;

      // コマンド候補より上へ表示します。
      item.sortText = `0_${name}`;

      return item;
    });
}

function getVariableType(name) {
  if (name.startsWith("$")) {
    return "string";
  }

  return "integer";
}

function findVariableAtPosition(
  document,
  position
) {
  const lineText =
    document.lineAt(position.line).text;

  return findVariablesInLine(lineText).find(
    (occurrence) =>
      occurrence.start <= position.character &&
      position.character < occurrence.end
  );
}

function findVariablesInLine(lineText) {
  const result = [];
  let inString = false;

  for (
    let index = 0;
    index < lineText.length;
    index++
  ) {
    const character = lineText[index];

    // 文字列リテラルの開始・終了を判定します。
    if (character === "\"") {
      // BASIC風の "" エスケープを読み飛ばします。
      if (
        inString &&
        lineText[index + 1] === "\""
      ) {
        index++;
        continue;
      }

      inString = !inString;
      continue;
    }

    // 文字列外の ' 以降はコメントです。
    if (
      !inString &&
      character === "'"
    ) {
      break;
    }

    if (
      inString ||
      (
        character !== "$" &&
        character !== "!"
      )
    ) {
      continue;
    }

    const firstNameCharacter =
      lineText[index + 1];

    if (
      firstNameCharacter === undefined ||
      !/[A-Za-z_]/.test(firstNameCharacter)
    ) {
      continue;
    }

    let end = index + 2;

    while (
      end < lineText.length &&
      /[A-Za-z0-9_]/.test(lineText[end])
    ) {
      end++;
    }

    result.push({
      name: lineText.slice(index, end),
      start: index,
      end
    });

    index = end - 1;
  }

  return result;
}

async function searchCommands(definitions) {
  const selected = await vscode.window.showQuickPick(
    definitions.map((definition) => ({
      label: definition.name,
      description: definition.category,
      detail: [
        definition.summary,
        ...definition.keywords
      ].join(" ")
    })),
    {
      placeHolder:
        "目的や日本語のキーワードで検索"
    }
  );

  if (selected === undefined) {
    return;
  }

  const editor = vscode.window.activeTextEditor;

  if (
    editor === undefined ||
    editor.document.languageId !== LANGUAGE_ID
  ) {
    return;
  }

  const definition = definitions.find(
    (item) => item.name === selected.label
  );

  if (definition === undefined) {
    return;
  }

  await editor.insertSnippet(
    new vscode.SnippetString(
      definition.snippet
    )
  );
}

module.exports = {
  activate,
  deactivate
};

この段階の.vscode/extensions/product-a-script-support/out/extension.jsは「第7段階の全文」に、上の登録ブロックと補助関数を組み込んだ形です。保存してウィンドウを再読み込みすると、ファイル内変数の補完と型ホバーをすぐに確認できます。

第10段階:同じ変数をハイライトする

VS Code APIのDocumentHighlightProviderを使います。

vscode.languages.registerDocumentHighlightProvider(
  LANGUAGE_ID,
  {
    provideDocumentHighlights(document, position) {
      // カーソル位置の変数を調べ、
      // 同名の変数範囲をすべて返す
    }
  }
);

例えば、

$Message = "ABC"
WRITE_LOG($Message)
SEND_DATA(!Destination, $Message)

$Messageへカーソルを置くと、同じファイル内の3か所をハイライトします。

読み取りと代入の色分けまでは行わず、すべて同じ変数参照として扱います。今回の目的なら、その方が単純で十分です。

この段階での追加位置とファイル全体イメージ

ハイライト登録はactivate内で、補完・ホバーと同じ並びへ追加します。変数の走査には第9段階のfindVariableAtPositionfindVariablesInLineを再利用するため、新しい正規表現や別の変数一覧は作りません。

.vscode/extensions/product-a-script-support/out/extension.js
├─ activate
│  ├─ registerCompletionItemProvider
│  ├─ registerHoverProvider
│  └─ registerDocumentHighlightProvider  ← 今回追加
├─ createVariableCompletionItems
├─ getVariableType
├─ findVariableAtPosition
└─ findVariablesInLine

activate関数内へ追加するブロックの全文は次のとおりです。

context.subscriptions.push(
  vscode.languages.registerDocumentHighlightProvider(
    LANGUAGE_ID,
    {
      provideDocumentHighlights(document, position) {
        const selected = findVariableAtPosition(
          document,
          position
        );
        if (selected === undefined) {
          return undefined;
        }

        const selectedName = selected.name.toUpperCase();
        const highlights = [];

        for (let lineNumber = 0;
          lineNumber < document.lineCount;
          lineNumber++) {
          const lineText = document.lineAt(lineNumber).text;

          for (const occurrence of
            findVariablesInLine(lineText)) {
            if (
              occurrence.name.toUpperCase() !== selectedName
            ) {
              continue;
            }

            highlights.push(
              new vscode.DocumentHighlight(
                new vscode.Range(
                  lineNumber,
                  occurrence.start,
                  lineNumber,
                  occurrence.end
                ),
                vscode.DocumentHighlightKind.Text
              )
            );
          }
        }

        return highlights;
      }
    }
  )
);

この段階では補助関数側の変更はありません。したがって、ファイル全体は第9段階の構造にregisterDocumentHighlightProviderが1ブロック加わった形です。再読み込み後、変数へカーソルを置いて同名変数が強調されれば成功です。

第10段階で追加する変数支援関数の全文を表示
const fs = require("node:fs");
const path = require("node:path");
const vscode = require("vscode");

const LANGUAGE_ID = "legacybasic";
const SEARCH_COMMAND_ID = "legacyBasic.searchCommands";

function activate(context) {
  const definitions = loadDefinitions(context);

  const definitionMap = new Map(
    definitions.map((definition) => [
      definition.name.toUpperCase(),
      definition
    ])
  );

  const selector = {
    language: LANGUAGE_ID,
    scheme: "file"
  };

  /*
   * コマンドと変数の入力補完
   */
  context.subscriptions.push(
    vscode.languages.registerCompletionItemProvider(
      selector,
      {
        provideCompletionItems(document) {
          return [
            ...createVariableCompletionItems(document),
            ...definitions.map(createCompletionItem)
          ];
        }
      }
    )
  );

  /*
   * 変数の型とコマンド説明のホバー表示
   */
  context.subscriptions.push(
    vscode.languages.registerHoverProvider(
      selector,
      {
        provideHover(document, position) {
          const variable = findVariableAtPosition(
            document,
            position
          );

          if (variable !== undefined) {
            return new vscode.Hover(
              new vscode.MarkdownString(
                `**変数:** \`${variable.name}\`\n\n` +
                `**型:** \`${getVariableType(variable.name)}\``
              ),
              new vscode.Range(
                position.line,
                variable.start,
                position.line,
                variable.end
              )
            );
          }

          const range = document.getWordRangeAtPosition(
            position,
            /[A-Za-z_][A-Za-z0-9_]*/
          );

          if (range === undefined) {
            return undefined;
          }

          const commandName = document
            .getText(range)
            .toUpperCase();

          const definition = definitionMap.get(commandName);

          if (definition === undefined) {
            return undefined;
          }

          return new vscode.Hover(
            new vscode.MarkdownString(
              `${definition.summary}\n\n` +
              `**分類:** ${definition.category}\n\n` +
              `**戻り値:** \`${definition.returnType}\`\n\n` +
              `**使用例:** \`${definition.example}\``
            ),
            range
          );
        }
      }
    )
  );

  /*
   * カーソル位置と同じ変数をハイライト
   */
  context.subscriptions.push(
    vscode.languages.registerDocumentHighlightProvider(
      selector,
      {
        provideDocumentHighlights(
          document,
          position
        ) {
          const selected = findVariableAtPosition(
            document,
            position
          );

          if (selected === undefined) {
            return undefined;
          }

          const selectedName =
            selected.name.toUpperCase();

          const highlights = [];

          for (
            let lineNumber = 0;
            lineNumber < document.lineCount;
            lineNumber++
          ) {
            const lineText =
              document.lineAt(lineNumber).text;

            const occurrences =
              findVariablesInLine(lineText);

            for (const occurrence of occurrences) {
              if (
                occurrence.name.toUpperCase() !==
                selectedName
              ) {
                continue;
              }

              const range = new vscode.Range(
                lineNumber,
                occurrence.start,
                lineNumber,
                occurrence.end
              );

              highlights.push(
                new vscode.DocumentHighlight(
                  range,
                  vscode.DocumentHighlightKind.Text
                )
              );
            }
          }

          return highlights;
        }
      }
    )
  );

  /*
   * 日本語キーワードからコマンドを検索して挿入
   */
  context.subscriptions.push(
    vscode.commands.registerCommand(
      SEARCH_COMMAND_ID,
      async () => {
        await searchCommands(definitions);
      }
    )
  );
}

function deactivate() {
  // 明示的な終了処理はありません。
}

function loadDefinitions(context) {
  const filePath = path.join(
    context.extensionPath,
    "data",
    "commands.json"
  );

  return JSON.parse(
    fs.readFileSync(filePath, "utf8")
  );
}

function createCompletionItem(definition) {
  const item = new vscode.CompletionItem(
    definition.name,
    vscode.CompletionItemKind.Function
  );

  item.detail =
    `${definition.category} | ` +
    `戻り値: ${definition.returnType}`;

  item.documentation = new vscode.MarkdownString(
    `${definition.summary}\n\n` +
    `例: \`${definition.example}\``
  );

  item.insertText = new vscode.SnippetString(
    definition.snippet
  );

  item.filterText = [
    definition.name,
    definition.category,
    definition.summary,
    ...definition.keywords
  ].join(" ");

  // 変数候補より後ろへ表示します。
  item.sortText = `1_${definition.name}`;

  return item;
}

function createVariableCompletionItems(document) {
  const names = new Map();

  for (
    let lineNumber = 0;
    lineNumber < document.lineCount;
    lineNumber++
  ) {
    const lineText =
      document.lineAt(lineNumber).text;

    const occurrences =
      findVariablesInLine(lineText);

    for (const occurrence of occurrences) {
      names.set(
        occurrence.name.toUpperCase(),
        occurrence.name
      );
    }
  }

  return [...names.values()]
    .sort((left, right) =>
      left.localeCompare(right)
    )
    .map((name) => {
      const item = new vscode.CompletionItem(
        name,
        vscode.CompletionItemKind.Variable
      );

      item.detail =
        `変数 | ${getVariableType(name)}`;

      // コマンド候補より上へ表示します。
      item.sortText = `0_${name}`;

      return item;
    });
}

function getVariableType(name) {
  if (name.startsWith("$")) {
    return "string";
  }

  return "integer";
}

function findVariableAtPosition(
  document,
  position
) {
  const lineText =
    document.lineAt(position.line).text;

  return findVariablesInLine(lineText).find(
    (occurrence) =>
      occurrence.start <= position.character &&
      position.character < occurrence.end
  );
}

function findVariablesInLine(lineText) {
  const result = [];
  let inString = false;

  for (
    let index = 0;
    index < lineText.length;
    index++
  ) {
    const character = lineText[index];

    /*
     * 文字列リテラルの開始・終了を判定します。
     */
    if (character === "\"") {
      /*
       * BASIC風の "" エスケープは、
       * 文字列の終了として扱いません。
       */
      if (
        inString &&
        lineText[index + 1] === "\""
      ) {
        index++;
        continue;
      }

      inString = !inString;
      continue;
    }

    /*
     * 文字列外の ' 以降はコメントです。
     */
    if (
      !inString &&
      character === "'"
    ) {
      break;
    }

    /*
     * 文字列内、または型識別子以外は
     * 読み飛ばします。
     */
    if (
      inString ||
      (
        character !== "$" &&
        character !== "!"
      )
    ) {
      continue;
    }

    const firstNameCharacter =
      lineText[index + 1];

    /*
     * $ または ! の直後は、
     * 英字またはアンダースコアとします。
     */
    if (
      firstNameCharacter === undefined ||
      !/[A-Za-z_]/.test(firstNameCharacter)
    ) {
      continue;
    }

    let end = index + 2;

    while (
      end < lineText.length &&
      /[A-Za-z0-9_]/.test(lineText[end])
    ) {
      end++;
    }

    result.push({
      name: lineText.slice(index, end),
      start: index,
      end
    });

    index = end - 1;
  }

  return result;
}

async function searchCommands(definitions) {
  const selected = await vscode.window.showQuickPick(
    definitions.map((definition) => ({
      label: definition.name,
      description: definition.category,
      detail: [
        definition.summary,
        ...definition.keywords
      ].join(" ")
    })),
    {
      placeHolder:
        "目的や日本語のキーワードで検索"
    }
  );

  if (selected === undefined) {
    return;
  }

  const editor = vscode.window.activeTextEditor;

  if (
    editor === undefined ||
    editor.document.languageId !== LANGUAGE_ID
  ) {
    return;
  }

  const definition = definitions.find(
    (item) => item.name === selected.label
  );

  if (definition === undefined) {
    return;
  }

  await editor.insertSnippet(
    new vscode.SnippetString(
      definition.snippet
    )
  );
}

module.exports = {
  activate,
  deactivate
};

この段階の確認

image.png

第11段階:パネルからのコマンドとスニペットの選択入力

コマンドやスニペットを先頭数文字入力で候補を出す機能はありますが、大量にある場合は適切なコマンドを思い出す必要があります。
拡張機能のサイドパネル表示して、カテゴリ分類されたツリーからコマンドやスニペットを選択可能にします。

以下ファイルを差し替えます。

packages.json
{
  "name": "product-a-script-support",
  "displayName": "Product A Script Support",
  "description": "Product Aに同梱するBASIC風独自スクリプト言語の編集支援",
  "version": "0.2.0",
  "publisher": "sample-company",
  "license": "MIT",
  "engines": {
    "vscode": "^1.96.0"
  },
  "categories": [
    "Programming Languages",
    "Other"
  ],
  "activationEvents": [
    "onLanguage:legacybasic",
    "onView:legacyBasic.catalog",
    "onCommand:legacyBasic.searchCommands"
  ],
  "main": "./out/extension.js",
  "contributes": {
    "languages": [
      {
        "id": "legacybasic",
        "aliases": [
          "Legacy BASIC",
          "legacybasic"
        ],
        "configuration": "./language-configuration.json"
      }
    ],
    "grammars": [
      {
        "language": "legacybasic",
        "scopeName": "source.legacybasic",
        "path": "./syntaxes/legacybasic.tmLanguage.json"
      }
    ],
    "snippets": [
      {
        "language": "legacybasic",
        "path": "./snippets/legacybasic.json"
      }
    ],
    "commands": [
      {
        "command": "legacyBasic.searchCommands",
        "title": "Legacy BASIC: コマンドを検索",
        "category": "Legacy BASIC"
      }
    ],
    "keybindings": [
      {
        "command": "legacyBasic.searchCommands",
        "key": "ctrl+alt+space",
        "mac": "cmd+alt+space",
        "when": "editorTextFocus && editorLangId == legacybasic"
      }
    ],
    "menus": {
      "editor/context": [
        {
          "command": "legacyBasic.searchCommands",
          "group": "legacyBasic@1",
          "when": "editorLangId == legacybasic"
        }
      ]
    },
    "viewsContainers": {
      "activitybar": [
        {
          "id": "legacyBasic",
          "title": "Legacy BASIC",
          "icon": "resources/legacybasic.svg"
        }
      ]
    },
    "views": {
      "legacyBasic": [
        {
          "id": "legacyBasic.catalog",
          "name": "コマンドとスニペット"
        }
      ]
    }
  }
}
extension.js
const fs = require("node:fs");
const path = require("node:path");
const vscode = require("vscode");

const LANGUAGE_ID = "legacybasic";
const SEARCH_COMMAND_ID = "legacyBasic.searchCommands";
const PANEL_VIEW_ID = "legacyBasic.catalog";
const INSERT_CATALOG_ITEM_COMMAND_ID =
  "legacyBasic.insertCatalogItem";

class CatalogTreeDataProvider {
  constructor(definitions, snippets) {
    this.definitions = definitions;
    this.snippets = snippets;
    this.categories = [
      ...new Set(
        definitions.map(
          (definition) => definition.category
        )
      )
    ].sort((left, right) =>
      left.localeCompare(right, "ja")
    );
  }

  getTreeItem(element) {
    if (element.kind === "group") {
      const item = new vscode.TreeItem(
        element.label,
        vscode.TreeItemCollapsibleState.Expanded
      );
      item.iconPath = new vscode.ThemeIcon(
        element.groupType === "commands"
          ? "symbol-function"
          : "symbol-snippet"
      );
      return item;
    }

    if (element.kind === "category") {
      const item = new vscode.TreeItem(
        element.name,
        vscode.TreeItemCollapsibleState.Collapsed
      );
      item.iconPath = new vscode.ThemeIcon("folder");
      return item;
    }

    if (element.kind === "command") {
      const definition = element.definition;
      const item = new vscode.TreeItem(
        definition.name,
        vscode.TreeItemCollapsibleState.None
      );
      item.description = definition.returnType;
      item.tooltip =
        createCommandDocumentation(definition);
      item.iconPath =
        new vscode.ThemeIcon("symbol-function");
      item.command = {
        command: INSERT_CATALOG_ITEM_COMMAND_ID,
        title: "コマンドを挿入",
        arguments: [
          {
            label: definition.name,
            insertText: definition.snippet
          }
        ]
      };
      return item;
    }

    const snippet = element.snippet;
    const item = new vscode.TreeItem(
      snippet.name,
      vscode.TreeItemCollapsibleState.None
    );
    item.description = snippet.prefix;
    item.tooltip = new vscode.MarkdownString(
      `${snippet.description}\n\n` +
      `入力候補: \`${snippet.prefix}\``
    );
    item.iconPath =
      new vscode.ThemeIcon("symbol-snippet");
    item.command = {
      command: INSERT_CATALOG_ITEM_COMMAND_ID,
      title: "スニペットを挿入",
      arguments: [
        {
          label: snippet.name,
          insertText: snippet.body
        }
      ]
    };
    return item;
  }

  getChildren(element) {
    if (element === undefined) {
      return [
        {
          kind: "group",
          groupType: "commands",
          label: "コマンド"
        },
        {
          kind: "group",
          groupType: "snippets",
          label: "構文スニペット"
        }
      ];
    }

    if (
      element.kind === "group" &&
      element.groupType === "commands"
    ) {
      return this.categories.map((name) => ({
        kind: "category",
        name
      }));
    }

    if (
      element.kind === "group" &&
      element.groupType === "snippets"
    ) {
      return this.snippets.map((snippet) => ({
        kind: "snippet",
        snippet
      }));
    }

    if (element.kind === "category") {
      return this.definitions
        .filter(
          (definition) =>
            definition.category === element.name
        )
        .map((definition) => ({
          kind: "command",
          definition
        }));
    }

    return [];
  }
}

function activate(context) {
  const output =
    vscode.window.createOutputChannel(
      "Legacy BASIC"
    );

  output.appendLine(
    "Legacy BASIC拡張を有効化しました。"
  );
  output.show(true);

  void vscode.window.showInformationMessage(
    "Legacy BASIC拡張を有効化しました。"
  );

  context.subscriptions.push(output);

  const definitions = loadDefinitions(context);
  const snippets = loadSnippets(context);

  const definitionMap = new Map(
    definitions.map((definition) => [
      definition.name.toUpperCase(),
      definition
    ])
  );

  const selector = {
    language: LANGUAGE_ID,
    scheme: "file"
  };

  context.subscriptions.push(
    vscode.window.registerTreeDataProvider(
      PANEL_VIEW_ID,
      new CatalogTreeDataProvider(
        definitions,
        snippets
      )
    )
  );

  context.subscriptions.push(
    vscode.commands.registerCommand(
      INSERT_CATALOG_ITEM_COMMAND_ID,
      async (catalogItem) => {
        await insertCatalogItem(catalogItem);
      }
    )
  );

  context.subscriptions.push(
    vscode.languages.registerCompletionItemProvider(
      selector,
      {
        provideCompletionItems(document) {
          return [
            ...createVariableCompletionItems(document),
            ...definitions.map(createCompletionItem)
          ];
        }
      }
    )
  );

  context.subscriptions.push(
    vscode.languages.registerHoverProvider(
      selector,
      {
        provideHover(document, position) {
          const variable =
            findVariableAtPosition(
              document,
              position
            );

          if (variable !== undefined) {
            return new vscode.Hover(
              new vscode.MarkdownString(
                `**変数:** \`${variable.name}\`\n\n` +
                `**型:** \`${getVariableType(variable.name)}\``
              ),
              new vscode.Range(
                position.line,
                variable.start,
                position.line,
                variable.end
              )
            );
          }

          const range =
            document.getWordRangeAtPosition(
              position,
              /[A-Za-z_][A-Za-z0-9_]*/
            );

          if (range === undefined) {
            return undefined;
          }

          const definition = definitionMap.get(
            document
              .getText(range)
              .toUpperCase()
          );

          if (definition === undefined) {
            return undefined;
          }

          return new vscode.Hover(
            createCommandDocumentation(
              definition
            ),
            range
          );
        }
      }
    )
  );

  context.subscriptions.push(
    vscode.languages
      .registerDocumentHighlightProvider(
        selector,
        {
          provideDocumentHighlights(
            document,
            position
          ) {
            return createVariableHighlights(
              document,
              position
            );
          }
        }
      )
  );

  context.subscriptions.push(
    vscode.commands.registerCommand(
      SEARCH_COMMAND_ID,
      async () => {
        await searchCommands(definitions);
      }
    )
  );
}

function deactivate() {
  // context.subscriptionsの内容は
  // VS Codeが破棄します。
}

function loadDefinitions(context) {
  const filePath = path.join(
    context.extensionPath,
    "data",
    "commands.json"
  );

  const definitions = JSON.parse(
    fs.readFileSync(filePath, "utf8")
  );

  return definitions.sort((left, right) =>
    left.name.localeCompare(right.name)
  );
}

function loadSnippets(context) {
  const filePath = path.join(
    context.extensionPath,
    "snippets",
    "legacybasic.json"
  );

  const snippetObject = JSON.parse(
    fs.readFileSync(filePath, "utf8")
  );

  return Object.entries(snippetObject)
    .map(([name, definition]) => ({
      name,
      prefix: Array.isArray(definition.prefix)
        ? definition.prefix.join(", ")
        : String(definition.prefix ?? ""),
      body: Array.isArray(definition.body)
        ? definition.body.join("\n")
        : String(definition.body ?? ""),
      description:
        String(definition.description ?? "")
    }))
    .sort((left, right) =>
      left.name.localeCompare(right.name, "ja")
    );
}

function createCompletionItem(definition) {
  const item = new vscode.CompletionItem(
    definition.name,
    vscode.CompletionItemKind.Function
  );

  item.detail =
    `${definition.category} | ` +
    `戻り値: ${definition.returnType}`;

  item.documentation =
    createCommandDocumentation(definition);

  item.insertText =
    new vscode.SnippetString(
      definition.snippet
    );

  item.filterText = [
    definition.name,
    definition.category,
    definition.summary,
    ...definition.keywords
  ].join(" ");

  item.sortText = `1_${definition.name}`;

  return item;
}

function createCommandDocumentation(
  definition
) {
  const markdown =
    new vscode.MarkdownString();

  markdown.appendMarkdown(
    `${definition.summary}\n\n`
  );

  markdown.appendMarkdown(
    `**分類:** ${definition.category}\n\n`
  );

  markdown.appendMarkdown(
    `**戻り値:** ` +
    `\`${definition.returnType}\`\n\n`
  );

  if (
    Array.isArray(definition.parameters) &&
    definition.parameters.length > 0
  ) {
    markdown.appendMarkdown(
      "**引数:**\n\n"
    );

    for (
      const parameter of
      definition.parameters
    ) {
      markdown.appendMarkdown(
        `- \`${parameter.name}\` ` +
        `(\`${parameter.type}\`): ` +
        `${parameter.description}\n`
      );
    }

    markdown.appendMarkdown("\n");
  }

  markdown.appendMarkdown(
    `**使用例:** ` +
    `\`${definition.example}\``
  );

  return markdown;
}

function createVariableCompletionItems(
  document
) {
  const names = new Map();

  for (
    let lineNumber = 0;
    lineNumber < document.lineCount;
    lineNumber++
  ) {
    const lineText =
      document.lineAt(lineNumber).text;

    for (
      const occurrence of
      findVariablesInLine(lineText)
    ) {
      names.set(
        occurrence.name.toUpperCase(),
        occurrence.name
      );
    }
  }

  return [...names.values()]
    .sort((left, right) =>
      left.localeCompare(right)
    )
    .map((name) => {
      const item =
        new vscode.CompletionItem(
          name,
          vscode.CompletionItemKind.Variable
        );

      item.detail =
        `変数 | ${getVariableType(name)}`;

      item.sortText = `0_${name}`;

      return item;
    });
}

function getVariableType(name) {
  return name.startsWith("$")
    ? "string"
    : "integer";
}

function findVariableAtPosition(
  document,
  position
) {
  const lineText =
    document.lineAt(position.line).text;

  return findVariablesInLine(lineText).find(
    (occurrence) =>
      occurrence.start <=
        position.character &&
      position.character <
        occurrence.end
  );
}

function findVariablesInLine(lineText) {
  const result = [];
  let inString = false;

  for (
    let index = 0;
    index < lineText.length;
    index++
  ) {
    const character = lineText[index];

    if (character === "\"") {
      if (
        inString &&
        lineText[index + 1] === "\""
      ) {
        index++;
        continue;
      }

      inString = !inString;
      continue;
    }

    if (
      !inString &&
      character === "'"
    ) {
      break;
    }

    if (
      inString ||
      (
        character !== "$" &&
        character !== "!"
      )
    ) {
      continue;
    }

    const firstNameCharacter =
      lineText[index + 1];

    if (
      firstNameCharacter === undefined ||
      !/[A-Za-z_]/.test(
        firstNameCharacter
      )
    ) {
      continue;
    }

    let end = index + 2;

    while (
      end < lineText.length &&
      /[A-Za-z0-9_]/.test(
        lineText[end]
      )
    ) {
      end++;
    }

    result.push({
      name: lineText.slice(index, end),
      start: index,
      end
    });

    index = end - 1;
  }

  return result;
}

function createVariableHighlights(
  document,
  position
) {
  const selected =
    findVariableAtPosition(
      document,
      position
    );

  if (selected === undefined) {
    return undefined;
  }

  const selectedName =
    selected.name.toUpperCase();

  const highlights = [];

  for (
    let lineNumber = 0;
    lineNumber < document.lineCount;
    lineNumber++
  ) {
    const lineText =
      document.lineAt(lineNumber).text;

    for (
      const occurrence of
      findVariablesInLine(lineText)
    ) {
      if (
        occurrence.name.toUpperCase() !==
        selectedName
      ) {
        continue;
      }

      highlights.push(
        new vscode.DocumentHighlight(
          new vscode.Range(
            lineNumber,
            occurrence.start,
            lineNumber,
            occurrence.end
          ),
          vscode.DocumentHighlightKind.Text
        )
      );
    }
  }

  return highlights;
}

async function searchCommands(definitions) {
  const items = definitions.map(
    (definition) => ({
      label: definition.name,
      description: definition.category,
      detail: [
        definition.summary,
        ...definition.keywords
      ].join(" "),
      definition
    })
  );

  const selected =
    await vscode.window.showQuickPick(
      items,
      {
        placeHolder:
          "目的や日本語のキーワードで検索",
        matchOnDescription: true,
        matchOnDetail: true
      }
    );

  if (selected === undefined) {
    return;
  }

  await insertCatalogItem({
    label: selected.definition.name,
    insertText: selected.definition.snippet
  });
}

async function insertCatalogItem(
  catalogItem
) {
  const editor =
    vscode.window.activeTextEditor;

  if (editor === undefined) {
    void vscode.window.showWarningMessage(
      "挿入先のエディターを開いてください。"
    );
    return;
  }

  if (
    editor.document.languageId !==
    LANGUAGE_ID
  ) {
    void vscode.window.showWarningMessage(
      "Legacy BASICファイルを開いてください。"
    );
    return;
  }

  if (
    catalogItem === undefined ||
    typeof catalogItem.insertText !== "string"
  ) {
    void vscode.window.showErrorMessage(
      "挿入する項目を取得できませんでした。"
    );
    return;
  }

  await editor.insertSnippet(
    new vscode.SnippetString(
      catalogItem.insertText
    ),
    editor.selection
  );
}

module.exports = {
  activate,
  deactivate
};

以下新規ファイルを追加します。

.vscode/extensions/product-a-script-support/resources/legacybasic.svg
<svg xmlns="http://www.w3.org/2000/svg"
     width="24"
     height="24"
     viewBox="0 0 24 24">
  <text x="12"
        y="12.5"
        fill="currentColor"
        font-family="Yu Gothic UI, Meiryo, sans-serif"
        font-size="18"
        font-weight="700"
        text-anchor="middle"
        dominant-baseline="central">LB</text>
</svg>

この段階の確認

サイドのツールバーに[LB]のアイコンが表示されます。
image.png

これを開くとカテゴリからコマンドやスニペットが選択可能となります。
マウスを重ねるとコマンドの仕様も表示されます。
image.png

最終ファイル構成

ProductA/
├─ .vscode/
│  ├─ settings.json
│  └─ extensions/
│     └─ product-a-script-support/
│        ├─ package.json
│        ├─ language-configuration.json
│        ├─ definitions/
│        │  └─ commands.csv
│        ├─ data/
│        │  ├─ commands.json  ← Generate-Commands.ps1で生成する
│        │  └─ variable-types.json
│        ├─ snippets/
│        │  └─ legacybasic.json
│        ├─ syntaxes/
│        │  └─ legacybasic.tmLanguage.json
│        └─ out/
│        │  └─ extension.js
│        └─ resources/
│           └─ legacybasic.svg
├─ Scripts/
│  └─ Main.txt
└─ Generate-Commands.ps1
.vscode/settings.json
{
  "files.associations": {
    "**/Scripts/**/*.txt": "legacybasic"
  }
}
.vscode/extensions/product-a-script-support/package.json
{
  "name": "product-a-script-support",
  "displayName": "Product A Script Support",
  "description": "Product Aに同梱するBASIC風独自スクリプト言語の編集支援",
  "version": "0.2.0",
  "publisher": "sample-company",
  "license": "MIT",
  "engines": {
    "vscode": "^1.96.0"
  },
  "categories": [
    "Programming Languages",
    "Other"
  ],
  "activationEvents": [
    "onLanguage:legacybasic",
    "onView:legacyBasic.catalog",
    "onCommand:legacyBasic.searchCommands"
  ],
  "main": "./out/extension.js",
  "contributes": {
    "languages": [
      {
        "id": "legacybasic",
        "aliases": [
          "Legacy BASIC",
          "legacybasic"
        ],
        "configuration": "./language-configuration.json"
      }
    ],
    "grammars": [
      {
        "language": "legacybasic",
        "scopeName": "source.legacybasic",
        "path": "./syntaxes/legacybasic.tmLanguage.json"
      }
    ],
    "snippets": [
      {
        "language": "legacybasic",
        "path": "./snippets/legacybasic.json"
      }
    ],
    "commands": [
      {
        "command": "legacyBasic.searchCommands",
        "title": "Legacy BASIC: コマンドを検索",
        "category": "Legacy BASIC"
      }
    ],
    "keybindings": [
      {
        "command": "legacyBasic.searchCommands",
        "key": "ctrl+alt+space",
        "mac": "cmd+alt+space",
        "when": "editorTextFocus && editorLangId == legacybasic"
      }
    ],
    "menus": {
      "editor/context": [
        {
          "command": "legacyBasic.searchCommands",
          "group": "legacyBasic@1",
          "when": "editorLangId == legacybasic"
        }
      ]
    },
    "viewsContainers": {
      "activitybar": [
        {
          "id": "legacyBasic",
          "title": "Legacy BASIC",
          "icon": "resources/legacybasic.svg"
        }
      ]
    },
    "views": {
      "legacyBasic": [
        {
          "id": "legacyBasic.catalog",
          "name": "コマンドとスニペット"
        }
      ]
    }
  }
}
.vscode/extensions/product-a-script-support/language-configuration.json
{
  "comments": {
    "lineComment": "'"
  },
  "brackets": [
    [
      "(",
      ")"
    ]
  ],
  "autoClosingPairs": [
    {
      "open": "(",
      "close": ")"
    },
    {
      "open": "\"",
      "close": "\""
    }
  ],
  "surroundingPairs": [
    [
      "(",
      ")"
    ],
    [
      "\"",
      "\""
    ]
  ],
  "indentationRules": {
    "increaseIndentPattern": "^\\s*(IF\\b.*|ELSE\\b.*|WHILE\\b.*)$",
    "decreaseIndentPattern": "^\\s*(ELSE\\b.*|ENDIF\\b.*|ENDWHILE\\b.*)$"
  },
  "folding": {
    "markers": {
      "start": "^\\s*(IF|WHILE)\\b",
      "end": "^\\s*(ENDIF|ENDWHILE)\\b"
    }
  }
}
.vscode/extensions/product-a-script-support/definitions/commands.csv
CommandName,Category,Summary,ReturnType,Keywords,Example,ParameterOrder,ParameterName,ParameterType,ParameterDescription
SEND_DATA,通信,指定した送信先へ文字列データを送信します,void,送信|通信|データ,"SEND_DATA(1, ""ABC"")",1,destination,integer,送信先番号
,,,,,,2,data,string,送信する文字列
WRITE_LOG,ログ,ログへメッセージを出力します,void,ログ|記録|出力,"WRITE_LOG(""処理を開始します"")",1,message,string,出力するメッセージ
WAIT,時間,指定した時間だけ処理を待機します,void,待つ|待機|時間,WAIT(1000),1,milliseconds,integer,待機時間(ミリ秒)
GET_TIME,時間,現在時刻を文字列で取得します,string,時刻|現在|時計,GET_TIME(),,,,
FILE_EXISTS,ファイル,指定したファイルが存在するか確認します,boolean,ファイル|存在|確認,"FILE_EXISTS(""C:\Data\input.txt"")",1,path,string,確認するファイルのパス
.vscode/extensions/product-a-script-support/data/commands.json
[
  {
    "name": "SEND_DATA",
    "category": "通信",
    "summary": "指定した送信先へ文字列データを送信します。",
    "keywords": [
      "送信",
      "通信",
      "データ",
      "メッセージ",
      "転送"
    ],
    "parameters": [
      {
        "name": "destination",
        "type": "integer",
        "description": "送信先番号"
      },
      {
        "name": "data",
        "type": "string",
        "description": "送信する文字列"
      }
    ],
    "returnType": "void",
    "snippet": "SEND_DATA(${1:destination}, \"${2:data}\")",
    "example": "SEND_DATA(1, \"ABC\")"
  },
  {
    "name": "RECEIVE_DATA",
    "category": "通信",
    "summary": "受信済みのデータを取得します。",
    "keywords": [
      "受信",
      "通信",
      "データ",
      "受け取る"
    ],
    "parameters": [
      {
        "name": "source",
        "type": "integer",
        "description": "受信元番号"
      }
    ],
    "returnType": "string",
    "snippet": "RECEIVE_DATA(${1:source})",
    "example": "Message = RECEIVE_DATA(1)"
  },
  {
    "name": "CONNECT",
    "category": "通信",
    "summary": "指定した接続先へ接続します。",
    "keywords": [
      "接続",
      "通信",
      "開始",
      "つなぐ"
    ],
    "parameters": [
      {
        "name": "destination",
        "type": "integer",
        "description": "接続先番号"
      },
      {
        "name": "timeout",
        "type": "integer",
        "description": "タイムアウト時間(ミリ秒)"
      }
    ],
    "returnType": "boolean",
    "snippet": "CONNECT(${1:destination}, ${2:timeout})",
    "example": "Result = CONNECT(1, 5000)"
  },
  {
    "name": "DISCONNECT",
    "category": "通信",
    "summary": "指定した接続先との通信を切断します。",
    "keywords": [
      "切断",
      "通信",
      "接続終了",
      "閉じる"
    ],
    "parameters": [
      {
        "name": "destination",
        "type": "integer",
        "description": "接続先番号"
      }
    ],
    "returnType": "void",
    "snippet": "DISCONNECT(${1:destination})",
    "example": "DISCONNECT(1)"
  },
  {
    "name": "IS_CONNECTED",
    "category": "通信",
    "summary": "指定した接続先が接続中か確認します。",
    "keywords": [
      "接続",
      "確認",
      "状態",
      "通信中"
    ],
    "parameters": [
      {
        "name": "destination",
        "type": "integer",
        "description": "接続先番号"
      }
    ],
    "returnType": "boolean",
    "snippet": "IS_CONNECTED(${1:destination})",
    "example": "IF IS_CONNECTED(1)"
  },
  {
    "name": "READ_FILE",
    "category": "ファイル",
    "summary": "ファイルの内容を文字列として読み込みます。",
    "keywords": [
      "ファイル",
      "読む",
      "読込",
      "読み込み",
      "テキスト"
    ],
    "parameters": [
      {
        "name": "path",
        "type": "string",
        "description": "読み込むファイルのパス"
      }
    ],
    "returnType": "string",
    "snippet": "READ_FILE(\"${1:path}\")",
    "example": "Text = READ_FILE(\"C:\\\\Data\\\\input.txt\")"
  },
  {
    "name": "WRITE_FILE",
    "category": "ファイル",
    "summary": "文字列をファイルへ書き込みます。",
    "keywords": [
      "ファイル",
      "書く",
      "書込",
      "保存",
      "出力"
    ],
    "parameters": [
      {
        "name": "path",
        "type": "string",
        "description": "書き込み先ファイルのパス"
      },
      {
        "name": "text",
        "type": "string",
        "description": "書き込む文字列"
      }
    ],
    "returnType": "void",
    "snippet": "WRITE_FILE(\"${1:path}\", \"${2:text}\")",
    "example": "WRITE_FILE(\"C:\\\\Data\\\\result.txt\", \"OK\")"
  },
  {
    "name": "FILE_EXISTS",
    "category": "ファイル",
    "summary": "指定したファイルが存在するか確認します。",
    "keywords": [
      "ファイル",
      "存在",
      "確認",
      "ある"
    ],
    "parameters": [
      {
        "name": "path",
        "type": "string",
        "description": "確認するファイルのパス"
      }
    ],
    "returnType": "boolean",
    "snippet": "FILE_EXISTS(\"${1:path}\")",
    "example": "IF FILE_EXISTS(\"C:\\\\Data\\\\input.txt\")"
  },
  {
    "name": "WRITE_LOG",
    "category": "ログ",
    "summary": "ログへメッセージを出力します。",
    "keywords": [
      "ログ",
      "記録",
      "出力",
      "メッセージ"
    ],
    "parameters": [
      {
        "name": "message",
        "type": "string",
        "description": "出力するメッセージ"
      }
    ],
    "returnType": "void",
    "snippet": "WRITE_LOG(\"${1:message}\")",
    "example": "WRITE_LOG(\"処理を開始します\")"
  },
  {
    "name": "CLEAR_LOG",
    "category": "ログ",
    "summary": "現在のログを消去します。",
    "keywords": [
      "ログ",
      "消す",
      "消去",
      "クリア",
      "初期化"
    ],
    "parameters": [],
    "returnType": "void",
    "snippet": "CLEAR_LOG()",
    "example": "CLEAR_LOG()"
  },
  {
    "name": "WAIT",
    "category": "時間",
    "summary": "指定した時間だけ処理を待機します。",
    "keywords": [
      "待つ",
      "待機",
      "時間",
      "遅延",
      "スリープ"
    ],
    "parameters": [
      {
        "name": "milliseconds",
        "type": "integer",
        "description": "待機時間(ミリ秒)"
      }
    ],
    "returnType": "void",
    "snippet": "WAIT(${1:milliseconds})",
    "example": "WAIT(1000)"
  },
  {
    "name": "GET_TIME",
    "category": "時間",
    "summary": "現在時刻を文字列で取得します。",
    "keywords": [
      "時刻",
      "時間",
      "現在",
      "時計"
    ],
    "parameters": [],
    "returnType": "string",
    "snippet": "GET_TIME()",
    "example": "Now = GET_TIME()"
  },
  {
    "name": "SHOW_MESSAGE",
    "category": "画面",
    "summary": "画面にメッセージを表示します。",
    "keywords": [
      "画面",
      "表示",
      "メッセージ",
      "通知",
      "ダイアログ"
    ],
    "parameters": [
      {
        "name": "message",
        "type": "string",
        "description": "表示するメッセージ"
      }
    ],
    "returnType": "void",
    "snippet": "SHOW_MESSAGE(\"${1:message}\")",
    "example": "SHOW_MESSAGE(\"完了しました\")"
  },
  {
    "name": "START_DEVICE",
    "category": "装置制御",
    "summary": "指定した装置の運転を開始します。",
    "keywords": [
      "装置",
      "開始",
      "運転",
      "起動",
      "動かす"
    ],
    "parameters": [
      {
        "name": "deviceId",
        "type": "integer",
        "description": "装置番号"
      }
    ],
    "returnType": "boolean",
    "snippet": "START_DEVICE(${1:deviceId})",
    "example": "Result = START_DEVICE(1)"
  },
  {
    "name": "STOP_DEVICE",
    "category": "装置制御",
    "summary": "指定した装置の運転を停止します。",
    "keywords": [
      "装置",
      "停止",
      "止める",
      "終了"
    ],
    "parameters": [
      {
        "name": "deviceId",
        "type": "integer",
        "description": "装置番号"
      }
    ],
    "returnType": "boolean",
    "snippet": "STOP_DEVICE(${1:deviceId})",
    "example": "Result = STOP_DEVICE(1)"
  }
]
.vscode/extensions/product-a-script-support/data/variable-types.json
{
  "$": {
    "type": "string",
    "description": "文字列型"
  },
  "!": {
    "type": "integer",
    "description": "整数型"
  }
}
.vscode/extensions/product-a-script-support/snippets/legacybasic.json
{
  "IF-ELSE文": {
    "prefix": "ifelse",
    "body": [
      "IF ${1:condition}",
      "\t${2:' 真の場合}",
      "ELSE",
      "\t${3:' 偽の場合}",
      "ENDIF"
    ],
    "description": "IF~ELSE~ENDIFを挿入します"
  },
  "WHILE文": {
    "prefix": "while",
    "body": [
      "WHILE ${1:condition}",
      "\t${2:' 繰り返す処理}",
      "ENDWHILE"
    ],
    "description": "WHILE~ENDWHILEを挿入します"
  }
}
.vscode/extensions/product-a-script-support/syntaxes/legacybasic.tmLanguage.json
{
  "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json",
  "name": "Legacy BASIC",
  "scopeName": "source.legacybasic",
  "patterns": [
    {
      "include": "#comments"
    },
    {
      "include": "#strings"
    },
    {
      "include": "#keywords"
    },
    {
      "include": "#commands"
    },
    {
      "include": "#variables"
    },
    {
      "include": "#constants"
    },
    {
      "include": "#numbers"
    },
    {
      "include": "#operators"
    }
  ],
  "repository": {
    "comments": {
      "patterns": [
        {
          "name": "comment.line.apostrophe.legacybasic",
          "match": "'.*$"
        },
        {
          "name": "comment.line.rem.legacybasic",
          "match": "(?i:^\\s*REM\\b.*$)"
        }
      ]
    },
    "strings": {
      "patterns": [
        {
          "name": "string.quoted.double.legacybasic",
          "begin": "\"",
          "end": "\"",
          "patterns": [
            {
              "name": "constant.character.escape.legacybasic",
              "match": "\"\"|\\\\."
            }
          ]
        }
      ]
    },
    "keywords": {
      "patterns": [
        {
          "name": "keyword.control.legacybasic",
          "match": "(?i:\\b(IF|THEN|ELSE|ENDIF|WHILE|ENDWHILE|AND|OR|NOT)\\b)"
        }
      ]
    },
    "commands": {
      "patterns": [
        {
          "name": "support.function.legacybasic",
          "match": "(?i:\\b[A-Z_][A-Z0-9_]*(?=\\s*\\())"
        }
      ]
    },
    "variables": {
      "patterns": [
        {
          "name": "variable.other.legacybasic",
          "match": "[$!#%&?@][A-Za-z_][A-Za-z0-9_]*"
        }
      ]
    },
    "constants": {
      "patterns": [
        {
          "name": "constant.language.legacybasic",
          "match": "(?i:\\b(TRUE|FALSE|NULL)\\b)"
        }
      ]
    },
    "numbers": {
      "patterns": [
        {
          "name": "constant.numeric.hex.legacybasic",
          "match": "(?i:\\b0x[0-9a-f]+\\b)"
        },
        {
          "name": "constant.numeric.decimal.legacybasic",
          "match": "\\b\\d+(?:\\.\\d+)?\\b"
        }
      ]
    },
    "operators": {
      "patterns": [
        {
          "name": "keyword.operator.legacybasic",
          "match": "<=|>=|<>|!=|==|=|<|>|\\+|-|\\*|/"
        }
      ]
    }
  }
}
.vscode/extensions/product-a-script-support/out/extension.js
const fs = require("node:fs");
const path = require("node:path");
const vscode = require("vscode");

const LANGUAGE_ID = "legacybasic";
const SEARCH_COMMAND_ID = "legacyBasic.searchCommands";
const PANEL_VIEW_ID = "legacyBasic.catalog";
const INSERT_CATALOG_ITEM_COMMAND_ID =
  "legacyBasic.insertCatalogItem";

let variableTypeMap = new Map();

class CatalogTreeDataProvider {
  constructor(definitions, snippets) {
    this.definitions = definitions;
    this.snippets = snippets;
    this.categories = [
      ...new Set(
        definitions.map(
          (definition) => definition.category
        )
      )
    ].sort((left, right) =>
      left.localeCompare(right, "ja")
    );
  }

  getTreeItem(element) {
    if (element.kind === "group") {
      const item = new vscode.TreeItem(
        element.label,
        vscode.TreeItemCollapsibleState.Expanded
      );
      item.iconPath = new vscode.ThemeIcon(
        element.groupType === "commands"
          ? "symbol-function"
          : "symbol-snippet"
      );
      return item;
    }

    if (element.kind === "category") {
      const item = new vscode.TreeItem(
        element.name,
        vscode.TreeItemCollapsibleState.Collapsed
      );
      item.iconPath = new vscode.ThemeIcon("folder");
      return item;
    }

    if (element.kind === "command") {
      const definition = element.definition;
      const item = new vscode.TreeItem(
        definition.name,
        vscode.TreeItemCollapsibleState.None
      );
      item.description = definition.returnType;
      item.tooltip =
        createCommandDocumentation(definition);
      item.iconPath =
        new vscode.ThemeIcon("symbol-function");
      item.command = {
        command: INSERT_CATALOG_ITEM_COMMAND_ID,
        title: "コマンドを挿入",
        arguments: [
          {
            label: definition.name,
            insertText: definition.snippet
          }
        ]
      };
      return item;
    }

    const snippet = element.snippet;
    const item = new vscode.TreeItem(
      snippet.name,
      vscode.TreeItemCollapsibleState.None
    );
    item.description = snippet.prefix;
    item.tooltip = new vscode.MarkdownString(
      `${snippet.description}\n\n` +
      `入力候補: \`${snippet.prefix}\``
    );
    item.iconPath =
      new vscode.ThemeIcon("symbol-snippet");
    item.command = {
      command: INSERT_CATALOG_ITEM_COMMAND_ID,
      title: "スニペットを挿入",
      arguments: [
        {
          label: snippet.name,
          insertText: snippet.body
        }
      ]
    };
    return item;
  }

  getChildren(element) {
    if (element === undefined) {
      return [
        {
          kind: "group",
          groupType: "commands",
          label: "コマンド"
        },
        {
          kind: "group",
          groupType: "snippets",
          label: "構文スニペット"
        }
      ];
    }

    if (
      element.kind === "group" &&
      element.groupType === "commands"
    ) {
      return this.categories.map((name) => ({
        kind: "category",
        name
      }));
    }

    if (
      element.kind === "group" &&
      element.groupType === "snippets"
    ) {
      return this.snippets.map((snippet) => ({
        kind: "snippet",
        snippet
      }));
    }

    if (element.kind === "category") {
      return this.definitions
        .filter(
          (definition) =>
            definition.category === element.name
        )
        .map((definition) => ({
          kind: "command",
          definition
        }));
    }

    return [];
  }
}

function activate(context) {
  variableTypeMap =
    loadVariableTypes(context);

  const output =
    vscode.window.createOutputChannel(
      "Legacy BASIC"
    );

  output.appendLine(
    "Legacy BASIC拡張を有効化しました。"
  );
  output.show(true);

  void vscode.window.showInformationMessage(
    "Legacy BASIC拡張を有効化しました。"
  );

  context.subscriptions.push(output);

  const definitions = loadDefinitions(context);
  const snippets = loadSnippets(context);

  const definitionMap = new Map(
    definitions.map((definition) => [
      definition.name.toUpperCase(),
      definition
    ])
  );

  const selector = {
    language: LANGUAGE_ID,
    scheme: "file"
  };

  context.subscriptions.push(
    vscode.window.registerTreeDataProvider(
      PANEL_VIEW_ID,
      new CatalogTreeDataProvider(
        definitions,
        snippets
      )
    )
  );

  context.subscriptions.push(
    vscode.commands.registerCommand(
      INSERT_CATALOG_ITEM_COMMAND_ID,
      async (catalogItem) => {
        await insertCatalogItem(catalogItem);
      }
    )
  );

  context.subscriptions.push(
    vscode.languages.registerCompletionItemProvider(
      selector,
      {
        provideCompletionItems(document) {
          return [
            ...createVariableCompletionItems(document),
            ...definitions.map(createCompletionItem)
          ];
        }
      }
    )
  );

  context.subscriptions.push(
    vscode.languages.registerHoverProvider(
      selector,
      {
        provideHover(document, position) {
          const variable =
            findVariableAtPosition(
              document,
              position
            );

          if (variable !== undefined) {
            const typeDefinition =
              getVariableTypeDefinition(
                variable.name
              );

            return new vscode.Hover(
              new vscode.MarkdownString(
                `**変数:** \`${variable.name}\`\n\n` +
                `**型:** \`${getVariableType(variable.name)}\`` +
                (
                  typeDefinition?.description
                    ? `\n\n${typeDefinition.description}`
                    : ""
                )
              ),
              new vscode.Range(
                position.line,
                variable.start,
                position.line,
                variable.end
              )
            );
          }

          const range =
            document.getWordRangeAtPosition(
              position,
              /[A-Za-z_][A-Za-z0-9_]*/
            );

          if (range === undefined) {
            return undefined;
          }

          const definition = definitionMap.get(
            document
              .getText(range)
              .toUpperCase()
          );

          if (definition === undefined) {
            return undefined;
          }

          return new vscode.Hover(
            createCommandDocumentation(
              definition
            ),
            range
          );
        }
      }
    )
  );

  context.subscriptions.push(
    vscode.languages
      .registerDocumentHighlightProvider(
        selector,
        {
          provideDocumentHighlights(
            document,
            position
          ) {
            return createVariableHighlights(
              document,
              position
            );
          }
        }
      )
  );

  context.subscriptions.push(
    vscode.commands.registerCommand(
      SEARCH_COMMAND_ID,
      async () => {
        await searchCommands(definitions);
      }
    )
  );
}

function deactivate() {
  // context.subscriptionsの内容は
  // VS Codeが破棄します。
}

function loadDefinitions(context) {
  const filePath = path.join(
    context.extensionPath,
    "data",
    "commands.json"
  );

  const definitions = JSON.parse(
    fs.readFileSync(filePath, "utf8")
  );

  return definitions.sort((left, right) =>
    left.name.localeCompare(right.name)
  );
}

function loadVariableTypes(context) {
  const filePath = path.join(
    context.extensionPath,
    "data",
    "variable-types.json"
  );

  const definitions = JSON.parse(
    fs.readFileSync(filePath, "utf8")
  );

  const entries = Object.entries(definitions);

  for (const [prefix, definition] of entries) {
    if (prefix.length !== 1) {
      throw new Error(
        `変数の型識別子は1文字で指定してください: ${prefix}`
      );
    }

    if (
      definition === null ||
      typeof definition !== "object" ||
      typeof definition.type !== "string" ||
      definition.type.length === 0
    ) {
      throw new Error(
        `変数型の定義が不正です: ${prefix}`
      );
    }
  }

  return new Map(entries);
}

function loadSnippets(context) {
  const filePath = path.join(
    context.extensionPath,
    "snippets",
    "legacybasic.json"
  );

  const snippetObject = JSON.parse(
    fs.readFileSync(filePath, "utf8")
  );

  return Object.entries(snippetObject)
    .map(([name, definition]) => ({
      name,
      prefix: Array.isArray(definition.prefix)
        ? definition.prefix.join(", ")
        : String(definition.prefix ?? ""),
      body: Array.isArray(definition.body)
        ? definition.body.join("\n")
        : String(definition.body ?? ""),
      description:
        String(definition.description ?? "")
    }))
    .sort((left, right) =>
      left.name.localeCompare(right.name, "ja")
    );
}

function createCompletionItem(definition) {
  const item = new vscode.CompletionItem(
    definition.name,
    vscode.CompletionItemKind.Function
  );

  item.detail =
    `${definition.category} | ` +
    `戻り値: ${definition.returnType}`;

  item.documentation =
    createCommandDocumentation(definition);

  item.insertText =
    new vscode.SnippetString(
      definition.snippet
    );

  item.filterText = [
    definition.name,
    definition.category,
    definition.summary,
    ...definition.keywords
  ].join(" ");

  item.sortText = `1_${definition.name}`;

  return item;
}

function createCommandDocumentation(
  definition
) {
  const markdown =
    new vscode.MarkdownString();

  markdown.appendMarkdown(
    `${definition.summary}\n\n`
  );

  markdown.appendMarkdown(
    `**分類:** ${definition.category}\n\n`
  );

  markdown.appendMarkdown(
    `**戻り値:** ` +
    `\`${definition.returnType}\`\n\n`
  );

  if (
    Array.isArray(definition.parameters) &&
    definition.parameters.length > 0
  ) {
    markdown.appendMarkdown(
      "**引数:**\n\n"
    );

    for (
      const parameter of
      definition.parameters
    ) {
      markdown.appendMarkdown(
        `- \`${parameter.name}\` ` +
        `(\`${parameter.type}\`): ` +
        `${parameter.description}\n`
      );
    }

    markdown.appendMarkdown("\n");
  }

  markdown.appendMarkdown(
    `**使用例:** ` +
    `\`${definition.example}\``
  );

  return markdown;
}

function createVariableCompletionItems(
  document
) {
  const names = new Map();

  for (
    let lineNumber = 0;
    lineNumber < document.lineCount;
    lineNumber++
  ) {
    const lineText =
      document.lineAt(lineNumber).text;

    for (
      const occurrence of
      findVariablesInLine(lineText)
    ) {
      names.set(
        occurrence.name.toUpperCase(),
        occurrence.name
      );
    }
  }

  return [...names.values()]
    .sort((left, right) =>
      left.localeCompare(right)
    )
    .map((name) => {
      const item =
        new vscode.CompletionItem(
          name,
          vscode.CompletionItemKind.Variable
        );

      item.detail =
        `変数 | ${getVariableType(name)}`;

      item.sortText = `0_${name}`;

      return item;
    });
}

function getVariableType(name) {
  return getVariableTypeDefinition(name)?.type ??
    "unknown";
}

function getVariableTypeDefinition(name) {
  return variableTypeMap.get(name[0]);
}

function findVariableAtPosition(
  document,
  position
) {
  const lineText =
    document.lineAt(position.line).text;

  return findVariablesInLine(lineText).find(
    (occurrence) =>
      occurrence.start <=
        position.character &&
      position.character <
        occurrence.end
  );
}

function findVariablesInLine(lineText) {
  const result = [];
  let inString = false;

  for (
    let index = 0;
    index < lineText.length;
    index++
  ) {
    const character = lineText[index];

    if (character === "\"") {
      if (
        inString &&
        lineText[index + 1] === "\""
      ) {
        index++;
        continue;
      }

      inString = !inString;
      continue;
    }

    if (
      !inString &&
      character === "'"
    ) {
      break;
    }

    if (
      inString ||
      !variableTypeMap.has(character)
    ) {
      continue;
    }

    const firstNameCharacter =
      lineText[index + 1];

    if (
      firstNameCharacter === undefined ||
      !/[A-Za-z_]/.test(
        firstNameCharacter
      )
    ) {
      continue;
    }

    let end = index + 2;

    while (
      end < lineText.length &&
      /[A-Za-z0-9_]/.test(
        lineText[end]
      )
    ) {
      end++;
    }

    result.push({
      name: lineText.slice(index, end),
      start: index,
      end
    });

    index = end - 1;
  }

  return result;
}

function createVariableHighlights(
  document,
  position
) {
  const selected =
    findVariableAtPosition(
      document,
      position
    );

  if (selected === undefined) {
    return undefined;
  }

  const selectedName =
    selected.name.toUpperCase();

  const highlights = [];

  for (
    let lineNumber = 0;
    lineNumber < document.lineCount;
    lineNumber++
  ) {
    const lineText =
      document.lineAt(lineNumber).text;

    for (
      const occurrence of
      findVariablesInLine(lineText)
    ) {
      if (
        occurrence.name.toUpperCase() !==
        selectedName
      ) {
        continue;
      }

      highlights.push(
        new vscode.DocumentHighlight(
          new vscode.Range(
            lineNumber,
            occurrence.start,
            lineNumber,
            occurrence.end
          ),
          vscode.DocumentHighlightKind.Text
        )
      );
    }
  }

  return highlights;
}

async function searchCommands(definitions) {
  const items = definitions.map(
    (definition) => ({
      label: definition.name,
      description: definition.category,
      detail: [
        definition.summary,
        ...definition.keywords
      ].join(" "),
      definition
    })
  );

  const selected =
    await vscode.window.showQuickPick(
      items,
      {
        placeHolder:
          "目的や日本語のキーワードで検索",
        matchOnDescription: true,
        matchOnDetail: true
      }
    );

  if (selected === undefined) {
    return;
  }

  await insertCatalogItem({
    label: selected.definition.name,
    insertText: selected.definition.snippet
  });
}

async function insertCatalogItem(
  catalogItem
) {
  const editor =
    vscode.window.activeTextEditor;

  if (editor === undefined) {
    void vscode.window.showWarningMessage(
      "挿入先のエディターを開いてください。"
    );
    return;
  }

  if (
    editor.document.languageId !==
    LANGUAGE_ID
  ) {
    void vscode.window.showWarningMessage(
      "Legacy BASICファイルを開いてください。"
    );
    return;
  }

  if (
    catalogItem === undefined ||
    typeof catalogItem.insertText !== "string"
  ) {
    void vscode.window.showErrorMessage(
      "挿入する項目を取得できませんでした。"
    );
    return;
  }

  await editor.insertSnippet(
    new vscode.SnippetString(
      catalogItem.insertText
    ),
    editor.selection
  );
}

module.exports = {
  activate,
  deactivate
};
.vscode/extensions/product-a-script-support/resources/legacybasic.svg
<svg xmlns="http://www.w3.org/2000/svg"
     width="24"
     height="24"
     viewBox="0 0 24 24">
  <text x="12"
        y="12.5"
        fill="currentColor"
        font-family="Yu Gothic UI, Meiryo, sans-serif"
        font-size="18"
        font-weight="700"
        text-anchor="middle"
        dominant-baseline="central">LB</text>
</svg>
Generate-Commands.ps1 (UTF-8 BOM保存)
param(
    [string]$CsvPath = (
        Join-Path $PSScriptRoot ".vscode\extensions\product-a-script-support\definitions\commands.csv"
    ),
    [string]$OutputPath = (
        Join-Path $PSScriptRoot ".vscode\extensions\product-a-script-support\data\commands.json"
    )
)

Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"

$allowedReturnTypes = @("void", "string", "integer", "boolean")
$allowedParameterTypes = @("string", "integer", "boolean")
$commonColumnNames = @(
    "Category",
    "Summary",
    "ReturnType",
    "Keywords",
    "Example"
)

$rows = @(Import-Csv -LiteralPath $CsvPath -Encoding UTF8)
if ($rows.Count -eq 0) {
    throw "コマンド定義CSVが空です: $CsvPath"
}

$states = [System.Collections.Generic.List[object]]::new()
$commandNames = [System.Collections.Generic.HashSet[string]]::new(
    [System.StringComparer]::OrdinalIgnoreCase
)
$current = $null
$rowNumber = 1

foreach ($row in $rows) {
    $rowNumber++
    $commandName = $row.CommandName.Trim()

    if ($commandName.Length -gt 0) {
        if ($commandName -notmatch "^[A-Za-z_][A-Za-z0-9_]*$") {
            throw "${rowNumber}行目: コマンド名が不正です: $commandName"
        }
        if (-not $commandNames.Add($commandName)) {
            throw "${rowNumber}行目: コマンド名が重複しています: $commandName"
        }
        if ([string]::IsNullOrWhiteSpace($row.Category) -or
            [string]::IsNullOrWhiteSpace($row.Summary) -or
            [string]::IsNullOrWhiteSpace($row.ReturnType)) {
            throw "${rowNumber}行目: コマンドの共通項目が不足しています。"
        }

        $returnType = $row.ReturnType.Trim().ToLowerInvariant()
        if ($allowedReturnTypes -notcontains $returnType) {
            throw "${rowNumber}行目: 戻り値型が不正です: $returnType"
        }

        $keywords = @(
            $row.Keywords -split "\|" |
                ForEach-Object { $_.Trim() } |
                Where-Object { $_.Length -gt 0 }
        )
        if ($keywords.Count -eq 0) {
            throw "${rowNumber}行目: 検索キーワードがありません。"
        }

        $current = [pscustomobject]@{
            Name = $commandName
            Category = $row.Category.Trim()
            Summary = $row.Summary.Trim()
            ReturnType = $returnType
            Keywords = $keywords
            Example = $row.Example.Trim()
            Parameters = [System.Collections.Generic.List[object]]::new()
            ParameterOrders = [System.Collections.Generic.HashSet[int]]::new()
            ParameterNames = [System.Collections.Generic.HashSet[string]]::new(
                [System.StringComparer]::OrdinalIgnoreCase
            )
        }
        $states.Add($current)
    }
    else {
        if ($null -eq $current) {
            throw "${rowNumber}行目: 継続行より前にコマンド行がありません。"
        }

        foreach ($columnName in $commonColumnNames) {
            if (-not [string]::IsNullOrWhiteSpace($row.$columnName)) {
                throw "${rowNumber}行目: 継続行の${columnName}は空欄にしてください。"
            }
        }
    }

    $parameterValues = @(
        $row.ParameterOrder,
        $row.ParameterName,
        $row.ParameterType,
        $row.ParameterDescription
    )
    $hasParameterValue = @(
        $parameterValues |
            Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
    ).Count -gt 0

    if (-not $hasParameterValue) {
        continue
    }
    $missingParameterValueCount = @(
        $parameterValues |
            Where-Object { [string]::IsNullOrWhiteSpace($_) }
    ).Count
    if ($missingParameterValueCount -gt 0) {
        throw "${rowNumber}行目: 引数項目は4項目すべて入力してください。"
    }

    $order = 0
    if (-not [int]::TryParse($row.ParameterOrder, [ref]$order) -or
        $order -le 0) {
        throw "${rowNumber}行目: 引数順は1以上の整数で入力してください。"
    }
    if (-not $current.ParameterOrders.Add($order)) {
        throw "${rowNumber}行目: 引数順が重複しています: $order"
    }

    $parameterName = $row.ParameterName.Trim()
    if ($parameterName -notmatch "^[A-Za-z_][A-Za-z0-9_]*$") {
        throw "${rowNumber}行目: 引数名が不正です: $parameterName"
    }
    if (-not $current.ParameterNames.Add($parameterName)) {
        throw "${rowNumber}行目: 引数名が重複しています: $parameterName"
    }

    $parameterType = $row.ParameterType.Trim().ToLowerInvariant()
    if ($allowedParameterTypes -notcontains $parameterType) {
        throw "${rowNumber}行目: 引数型が不正です: $parameterType"
    }

    $current.Parameters.Add([pscustomobject]@{
        Order = $order
        Name = $parameterName
        Type = $parameterType
        Description = $row.ParameterDescription.Trim()
    })
}

$definitions = @(
    foreach ($state in $states) {
        $parameters = @(
            $state.Parameters |
                Sort-Object Order |
                ForEach-Object {
                    [ordered]@{
                        name = $_.Name
                        type = $_.Type
                        description = $_.Description
                    }
                }
        )

        $snippetArguments = @(
            for ($index = 0; $index -lt $parameters.Count; $index++) {
                $parameter = $parameters[$index]
                $placeholder = '$' + '{' + ($index + 1) +
                    ':' + $parameter.name + '}'

                if ($parameter.type -eq "string") {
                    '"' + $placeholder + '"'
                }
                else {
                    $placeholder
                }
            }
        )

        [ordered]@{
            name = $state.Name
            category = $state.Category
            summary = $state.Summary
            keywords = @($state.Keywords)
            parameters = $parameters
            returnType = $state.ReturnType
            snippet = $state.Name + '(' + ($snippetArguments -join ", ") + ')'
            example = $state.Example
        }
    }
)

$json = $definitions | ConvertTo-Json -Depth 10
$outputDirectory = Split-Path -Parent $OutputPath
[System.IO.Directory]::CreateDirectory($outputDirectory) | Out-Null
$utf8WithoutBom = [System.Text.UTF8Encoding]::new($false)
[System.IO.File]::WriteAllText($OutputPath, $json + [Environment]::NewLine, $utf8WithoutBom)

Write-Host "生成完了: $OutputPath"
Write-Host "コマンド数: $($definitions.Count)"
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?