💡

LSPと静的解析による構造的コード理解

に公開

はじめに

三菱UFJインフォメーションテクノロジーの田中です。

最近「Serena」というコーディングエージェントツールが話題になっていたので触ってみました。AIエージェントとSerenaをMCPで連携して使ってみましたが、コードの修正が、構造を理解しているように感じました。

https://github.com/oraios/serena

Serenaは、Language Servers(LS) を活用して、セマンティックコード検索・編集ツールを提供しています。

普段、統合開発環境(IDE)で当たり前のように使っている「定義へジャンプ」や「未使用変数の検出」、「変数名の変更」などもこの技術が支えています。正直これまで深く考えずに「いやー、便利だなー」とIDEで使っていました。

人間のエンジニアのために作られた技術を、AIエージェントが活用することで、コード理解が向上するのは理にかなっているなと感じました。昔からある技術ですが、良い機会だと思い仕組みを調べてみました。

技術要素の役割分担

IDEが提供する機能は、以下の技術要素の組み合わせで実現されています。

技術要素:

  • Language Server Protocol(LSP):プログラミング言語と開発ツールの間の通信を標準化するプロトコル(2016年にMicrosoftが提案)
  • Language Servers(LS):静的解析機能をLSPで提供する実装(Pyright、eclipse.jdt.lsなど)
  • 静的解析エンジン:コードの構造解析、型チェック、エラー検出などの実際の解析処理(LSに含まれる場合もある)

イメージ:
技術要素の役割分担
※図は当社にて作成

これらを連携することで、「定義へジャンプ」や「未使用変数の検出」、「変数名の変更」などのIDEから提供される機能を実現しています。
Pyrightのような一つのツールがLSとして静的解析機能を提供することも多いです。各IDE/エディタ側が独自にプログラム言語の解析機能を実装する必要がなくなり、言語ごとに一つのLSを作れば、すべてのIDE/エディタで同じ品質の支援を受けられるようになりました。

IDEの高度な機能を支える技術

IDEで当たり前のように使っている機能は、以下のような技術的要素の組み合わせで実現されています。

1. 構文解析技術

抽象構文木(AST: Abstract Syntax Tree)によるコード構造の把握

コードというテキストデータを、コンピュータが理解しやすいツリー構造に変換します。これが 抽象構文木(AST) と呼ばれるものです。ASTは、コードの各要素(関数、変数、演算子など)をノードとして、その論理的な構造(HTMLのDOMのようなもの)を表現します。

実際に以下のコードがどのようにASTに変換されるか見てみます。今回は、Pythonのastモジュールを使用してASTを抽出してみます。

def calculate_area(width, height):
    return width * height

radius = 5
area = calculate_area(10, 20)
print("Area: {}".format(area))
AST変換サンプルコード
import ast

code = """
def calculate_area(width, height):
    return width * height

radius = 5
area = calculate_area(10, 20)
print("Area: {}".format(area))
"""

tree = ast.parse(code)
print(ast.dump(tree, indent=2))

コードとASTの対応関係
コードとASTのマッピング図
※図は当社にて作成

上記のコードは以下のようなAST構造に変換されます。

  • 1-2行目def calculate_area(width, height): return width * height
    FunctionDefノード(関数定義)として構造化
  • 4行目radius = 5
    Assignノード(代入)で代入操作を表現
  • 5行目area = calculate_area(10, 20)
    Assignノード(代入)とCallノード(関数呼び出し)で呼び出しと代入操作を表現
  • 6行目print("Area: {}".format(area))
    Exprノード(式)内のCallノード(関数呼び出し)とAttributeノード(オブジェクトの属性)で表現

この構造化により、「どこに関数定義があるか」「変数がどこで使われているか」を正確に把握できます。

未使用変数の検出例

気づかれた方もいるかもしれませんが、上記のコード例でradius = 5と変数を定義していますが、どこからも参照されていません。IDEによっては、未使用変数として黄色の下波線が表示されるものです。

未使用変数の検出は、AST解析と使用箇所の追跡を組み合わせて行われます。

  1. AST解析Name(id='radius', ctx=Store())から、radiusという変数に値が保存されていることを検出
  2. 使用箇所の追跡:AST全体を走査して、radius変数が一度もctx=Load()(値の読み込み)されていないことを確認
  3. 警告の生成:定義されているが使用されていない変数として警告

2. セマンティック解析(意味解析)

構文解析だけでは、コードの「構造」は理解できますが、「意味」までは理解できません。セマンティック解析 は、コードの意味的な関係性を理解する技術です。
セマンティック解析により、以下のようなIDEなどで提供される機能を実現できます。

  • 同名でも異なるスコープの変数を正確に区別
  • プロジェクト全体での依存関係の把握
  • 変数名の一括変換(変更時の影響範囲の特定)
  • クラスや関数の定義へジャンプ

シンボル解決とスコープ管理

AST情報を基に、同じ名前でもスコープが違えば別の変数として扱います。これが定義へジャンプやリファクタリングの正確性を支えています。

スコープの情報をプロジェクト全体で管理するのが シンボルテーブル と呼ばれるものです。これは、プロジェクト内の全ての名前(変数名、関数名など)について、その型、定義場所、そして属するスコープといった属性情報をマッピングします。
このシンボルテーブルを用いて、ある名前がどの定義を指しているかを正確に特定します。このプロセスが シンボル解決 です。

例えば、以下のコードのような合計計算の処理のシンボルテーブルを覗いてみようと思います。
今回は、Pythonのsymtableモジュールを使用して抽出してみます。

class Order:
    def calculate_total(self) -> float:
        # 注文合計の計算ロジック
        return 100.0

class Inventory:
    def calculate_total(self) -> int:
        # 在庫合計の計算ロジック
        return 500
シンボルテーブル出力サンプルコード
import symtable
import pandas as pd

code = """
class Order:
    def calculate_total(self) -> float:
        # 注文合計の計算ロジック
        return 100.0

class Inventory:
    def calculate_total(self) -> int:
        # 在庫合計の計算ロジック
        return 500
"""

# シンボルテーブルを解析
table = symtable.symtable(code, "<string>", "exec")

# データを格納するための空のリスト
data = []

print("=== トップレベル ===")
for symbol in table.get_symbols():
    print(f"名前: {symbol.get_name()}, 種類: {symbol.__class__.__name__}")

print("\n=== クラス詳細 ===")
for class_table in table.get_children():
    if class_table.get_type() == 'class':
        class_name = class_table.get_name()
        for symbol in class_table.get_symbols():
            symbol_info = {
                "クラス名": class_name,
                "シンボル名": symbol.get_name(),
                "パラメータ": symbol.is_parameter(),
                "グローバル": symbol.is_global(),
                "ローカル": symbol.is_local(),
                # シンボルがメソッドか変数を判定
                "種類": "メソッド" if symbol.is_namespace() else "変数"
            }
            # リストに追加
            data.append(symbol_info)

# DataFrameの表示
df = pd.DataFrame(data)
print(df)

シンボルテーブルの出力例

=== トップレベル ===
名前: Order, 種類: Symbol
名前: Inventory, 種類: Symbol

=== クラス詳細 ===
#  クラス名    シンボル名       パラメータ   グローバル   ローカル   種類
0      Order  calculate_total  False       False       True      メソッド
1      Order            float  False       True        False     変数
2  Inventory  calculate_total  False       False       True      メソッド
3  Inventory              int  False       True        False     変数

上記のように、同名のcalculate_totalが存在しても、OrderクラスとInventoryクラスに定義されていると、シンボルテーブル上で明確に区別 されます。
また、メソッドは各クラス内でローカル、型ヒントはグローバルシンボルとして参照されるなど、スコープも管理されます。

依存関係グラフによるプロジェクト全体の構造把握

セマンティック解析のもう一つの重要な要素が、依存関係グラフです。これは、プロジェクト内のファイル、モジュール、そしてシンボル(関数、クラス、変数など)間の参照関係を管理するデータ構造です。

  • ノード:ファイル、クラス、関数、変数などの個々の要素
  • エッジ:ノード間の依存関係(import、関数呼び出し、継承、変数参照など)

LSは静的解析エンジンやインデックス機構が提供する情報をプロトコル経由でやり取りし、依存関係を解析・問い合わせ可能な形で提供します。
静的解析エンジンは、単にファイル間のimportを追跡するだけでなく、より詳細なレベルで情報を管理しています。

1. シンボルレベルの依存関係

  • 関数呼び出し:どの関数がどこで呼び出されているかを追跡
  • クラスの継承class Employee(Person):のような継承関係を把握
  • 変数参照:変数がどこで定義され、どこで使用されているかを管理

2. 型情報の依存関係

  • 関数の引数や戻り値の型が変更された場合の影響範囲を把握
  • 異なるファイルで定義されたクラスのインスタンス生成時の型依存を追跡

実際の例で見る依存関係の管理

ファイル構成とコード

project/
├─ utils.py
└─ main.py
utils.py
def calculate_tax(amount: float, rate: float = 0.1) -> float:
    return amount * rate

class Calculator:
    def add(self, a: int, b: int) -> int:
        return a + b
main.py
from utils import calculate_tax, Calculator

price = 1000
tax = calculate_tax(price)  # ---> 関数呼び出しの依存:utils.pyの calculate_tax を参照
calc = Calculator()         # ---> クラスインスタンス化の依存:utils.pyの Calculator を参照
result = calc.add(10, 20)   # ---> メソッド呼び出しの依存:utils.pyの Calculator.add を参照

LSが管理している依存関係

※フローは当社にて作成

  • ファイルレベルmain.pyutils.py
  • シンボルレベルmain.py:calculate_tax()utils.py:calculate_tax
  • 型レベルmain.py:calcutils.py:Calculator

これにより、calculate_tax関数の引数を変更した場合、LSは依存関係グラフをたどってmain.pyの呼び出し箇所を特定し、型エラーをリアルタイムで検出できます。関数名を変更する場合も、影響を受ける全ての箇所を正確にリストアップして、参照されるものすべてを一括で変更できます。

まとめ

IDEの機能は、コードを構造的に理解する技術の組み合わせです。

  • 主要な技術要素
    • AST(抽象構文木):コードの構造を正確に把握
    • シンボルテーブル:スコープを管理し、同名でも異なる実体を区別
    • 型システム連携:型チェッカーと協力して型安全性を確保
    • 依存関係グラフ:プロジェクト全体の構造を把握

これらの技術により、コードは「テキスト」ではなく「構造化されたデータ」として扱えます。人間エンジニアのために発展してきたものですが、AIエージェントにとっても価値があると思います。

  • 構造化された理解を活用できる:コードを単なるテキストではなく構造化データとして扱うことで、コードを正確に把握できる
  • 人の指示を置き換えられる:人(有識者)がAIエージェントに「どのファイルのどの関数を修正するか」を指示していましたが、LSの解析結果を渡すことで、AI自身が修正範囲や依存関係を判断可能になる
  • コンテキストエンジニアリングに有効:依存関係グラフを使って、修正対象の関数や参照先を抽出することで、ファイル全体を読み込ませるよりも少ないトークン数で、正確なコンテキストをLLMに提供できる

普段何気なく使っているIDEの機能を支える技術を知ることで、効果的なAIへの指示を考えられるようになりそうです。それにより、より賢くAIツールを活用できるようになると思いました。

注意点

ツールや本記事の内容は私が調査した情報に基づいており、正確性を保証するものではありません。
また、掲載したソースコードはサンプルになります。
本記事の内容を利用することで発生するいかなる損害や不利益について、当社は一切の責任を負いませんので自己の責任においてご利用ください。

参考

Discussion