Zed (clangd LSP) で PlatformIO プロジェクトを構成する

Rust と C/C++ プロジェクトで使う IDE を Zed に更新してみました。超高速で大変良い感じです…!

本記事では PlatformIO プロジェクトを Zed で扱う方法を記載します。

https://zed.dev

Love your editor again
Zed is a minimal code editor crafted for speed and collaboration with humans and AI.

Zed での C/C++ の扱いについてですが LSP として clangd が使われています。このことから cmake などが作り出す compile_commands.json を読ませることで多くの C/C++ プロジェクトを Zed で編集することができます。

2025-12 現在、PlatformIO の生成する compile_commands.json にいろいろ混乱があるようで、この記事の手順にはかなりのハックを含んでいです。PlatformIO が将来改善されている可能性が高いのでその部分はご参考程度です。

Linux で動作確認していますが、他の OS でも同様に構成できるはずです。(Windows が手元になくてそらで書いてますので適宜修正していただければ…)

ここでは Fujix さんの ESP32S3 を使ったスーパーな FM 音源プレイヤーである、NanoDrive7 の PlatformIO プロジェクトを Zed で扱えるようにする例で解説します。

まずは NanoDrive7 の PlatformIO プロジェクトを取得しましょう。もしくは既存の PlatformIO にカレントディレクトリを移してください。

$ git clone https://github.com/Fujix1/NanoDrive7.git
$ cd NanoDrive7

clangd の導入

最初はプロジェクトごとの設定に先行して、C/C++ LSP である clangdclangd のドキュメントを参考にしながら導入し、 clangd コマンドで起動するように構成します。

Ubuntu の場合:

$ sudo apt install clangd-21
$ sudo update-alternatives --install /usr/bin/clangd clangd /usr/bin/clangd-21 100
$ clangd --version
Ubuntu clangd version 21.1.8 (++20251212093602+51e5074676d4-1~exp1~20251212093735.68)
Features: linux
Platform: x86_64-pc-linux-gnu

Windows の場合:

おそらく winget で入れるのがお手軽です。以前の記憶では PATH は自動で通らなかったと思うので clangd を探し起動できるようにしてください。

> winget install --id=LLVM.clangd -e
# パスを通して起動できれば OK
> clangd --version

PlatformIO の導入

既に Python に PlatformIO の最新版が導入されていればこの項の手順は不要です。

ここではプロジェクトローカルに最新版の PlatformIO を導入する手順を記載します。Python の venv を使っていますが、最近だと uv のほうがいいのかもしれません。

Linux / macOS の場合:

$ pwd # プロジェクトにカレントディレクトリがあること
/home/hiromasa/devel/esp32/NanoDrive7
$ zed . # Zed を起動する(以下 Zed のターミナルにて)
$ python -m venv .venv
$ source .venv/bin/activate # [[ もしくは Zed ターミナルを再起動 ]]
$ python3 -m pip install -U platformio
$ pio --version
PlatformIO Core, version 6.1.18

Windows の場合:

> pwd # プロジェクトにカレントディレクトリがあること
> zed . # Zed を起動する(以下 Zed のターミナルにて)
> python -m venv .venv
> .venv\Scripts\activate.ps1 # [[ もしくは Zed ターミナルを再起動 ]]
> python3 -m pip install -U platformio
> pio --version
PlatformIO Core, version 6.1.18

Zed は次のデフォルトセッティングにより .venv を認識して自動アクティベートしてくれますので次回 Zed 起動時は source .venv/bin/activate の操作不要でターミナルでの pio コマンドがプロジェクトローカル(.venv 内)を指すようになります。

{
  "terminal": {
    "detect_venv": {
      "on": {
        "directories": [".env", "env", ".venv", "venv"],
        "activate_script": "default"
      }
    },
  }
}

最後に .gitignore.venv/ を追加しておきます。

clangd の構成

clangd を構成するためプロジェクトのルートに .clangd ドットファイルを新規作成して次のようにします。Compiler は構成するプロジェクトに合わせてください(識別なのでここは厳密に設定しなくても大丈夫なのかもです)

CompileFlags:
  Compiler: xtensa-esp32s3-elf-gcc
  Add: [-DEXAMPLE_DEFINE1=1, -DEXAMPLE_DEFINE2]
  Remove:
    - -m*
    - -fstrict-volatile-bitfields
    - -fno-tree-switch-conversion
  CompilationDatabase: .pio/build/esp32-s3-devkitc-1

Remove はコンパイルコマンドから clang(d) にとって不要なオプションを削除する定義です。ESP32 系は gcc でコンパイルされますので clang で使えないオプションを削除するなどしています。お好みで調整してください。

CompilationDatabase でこの後生成する compile_commands.json が配置されるディレクトリを設定しています。

compile_commands.json の生成

.clangdCompilationDatabase で指定したディレクトリに、PlatformIO (pio) で compile_commands.json を生成させます。 compiledb というタスクが標準で定義されています。

Zed のターミナルから次のように入力します。

pio run -t compiledb

この操作で .pio/build/esp32-s3-devkitc-1/compile_commands.json が生成されたのを確認して、Zed から src/main.cpp などを開くと clangd LSP が自動起動して僅かに待つと補完が効くようになります。

以後、ライブラリやソースコードファイルの追加が合った場合は、pio run -t compiledb するイテレーションになります。

基本的にはここまでの手順となります。想定通り動作しているようであれば以下の手順は不要です。


compile_commands.json のパッチ

2025-12 現在、PlatformIO が標準で生成する compile_commands.json は次のような問題があり Zed 上のソースコードに多数のエラーや警告が出力されます。

  • プロジェクトの lib/ 配下のソースコードに適切な CC や include パスが設定されない。
  • いくつか pio で改善する環境変数(env.Replace(COMPILATIONDB_INCLUDE_TOOLCHAIN=True)はあるが、PlatformIO に複数のツールチェインを入れている場合、全ての include パスに入ってしまう。
$ ls -laF ~/.platformio/packages | grep toolchain
$ drwx------  6 hiromasa hiromasa 4096 10月 13  2024 toolchain-esp32ulp/
$ drwx------  7 hiromasa hiromasa 4096  9月 16  2024 toolchain-riscv32-esp/
$ drwx------  7 hiromasa hiromasa 4096  9月 16  2024 toolchain-xtensa-esp32s3/

うちの環境では PlatformIO に 3 つツールチェインの登録があるため、 include パスが混乱して困ったことになっていました。 (platformio/builder/tools/piobuild.py#L131-L149

そこでこれ以降はいささかハックではありますが、生成された compile_commands.json にパッチをあてる PlatformIO タスクを定義して解決します。(通常のビルドには影響しないようにしています)

platform.ini に次の行を追加:

[env:esp32-s3-devkitc-1]
# ..snip..
extra_scripts = pre:pio_fix_compiledb.py

プロジェクトルートに pio_fix_compiledb.py ファイルを新規作成して次のようにします。

# SPDX-License-Identifier: CC0-1.0
# pyright: reportMissingImports=false, reportUndefinedVariable=false
# flake8: noqa
"""
Utility build hook to generate and fix a compile_commands.json suitable for
clangd. Prepares the compilation database and propagates -I flags to library
entries under `lib/`.
Additionally, in environments with multiple toolchains installed, this script restricts
include paths to a single specified toolchain to ensure deterministic include
resolution.

Usage (must be run as a pair):
    pio run -t compiledb && pio run -t fix_compiledb

Run `compiledb` first, then `fix_compiledb`.

Version Info (pio --version)
    PlatformIO Core, version 6.1.18
"""

import glob
import json
import os
import sys
import shlex

Import("env")  # type: ignore


def pre_compiledb_actions(toolchain, framework_libs=None):
    """
    Prepare compile_commands.json for clangd:
      - restrict CPPPATH to the specified toolchain and Arduino framework libs
      - set COMPILATIONDB_PATH to the build directory

    framework_libs_subdirs: optional list of sub-path patterns under PROJECT_PACKAGES_DIR
      e.g. [os.path.join("arduinoespressif32", "libraries")]
    """
    print("start pre_compiledb_actions")
    # turn off default toolchain includes
    env.Replace(COMPILATIONDB_INCLUDE_TOOLCHAIN=False)
    # restrict CPPPATH to xtensa-esp32s3-elf include paths to avoid mixing toolchains.
    toolchain_paths = [
        p for p in env.DumpIntegrationIncludes().get("toolchain", []) if toolchain in p
    ]
    # add path(s) to Arduino framework libraries (support multiple framework dirs)
    packages_dir = env.get("PROJECT_PACKAGES_DIR")
    framework_libs = framework_libs or []
    framework_libs_dirs = []
    for sub in framework_libs:
        # join packages_dir with the provided sub-path pattern and glob
        pattern = os.path.join(packages_dir, sub)
        framework_libs_dirs.extend(glob.glob(pattern))

    src_paths = []
    for libraries_dir in framework_libs_dirs:
        src_paths.extend(glob.glob(os.path.join(libraries_dir, "*", "src")))
    libs_path = list(set(src_paths))
    # replace CPPPATH for compiledb only
    env.Replace(CPPPATH=toolchain_paths + libs_path)
    # create compile_commands.json
    env.Replace(COMPILATIONDB_PATH=os.path.join("$BUILD_DIR", "compile_commands.json"))

    print("completed pre_compiledb_actions")


def fix_compiledb_action(*args, **kwargs):
    """
    Post-process compile_commands.json:
      - find the entry for the given project-relative source (args[1])
      - extract its -I include flags and append missing ones to entries under 'lib/'
      - write the file back only if modifications were made
    """
    print("start fix_compiledb_action")

    compile_commands_dir = os.path.normpath(args[0])
    src_main = os.path.normpath(args[1])

    compile_commands_path = os.path.join(compile_commands_dir, "compile_commands.json")
    if not os.path.isfile(compile_commands_path):
        print(
            f"fix_compiledb_action: compile_commands.json not found at {compile_commands_path}",
            file=sys.stderr,
        )
        return

    compile_commands = []
    with open(compile_commands_path, "r", encoding="utf-8") as fp:
        compile_commands = json.load(fp)

    match = next(
        (
            entry
            for entry in compile_commands
            if os.path.normpath(entry.get("file") or "") == src_main
        ),
        None,
    )
    if not match:
        print(f"fix_compiledb_action: no match {src_main}", file=sys.stderr)
        return
    cmd = match.get("command") or " ".join(match.get("arguments", []))
    tokens = shlex.split(cmd)
    includes = [t for t in tokens if t.startswith("-I")]

    # propagate -I flags to entries under 'lib/'
    lib_count = 0
    if includes:
        for entry in compile_commands:
            rel = os.path.normpath(entry.get("file")).replace(os.sep, "/")
            if rel.startswith("lib/"):
                original_cmd = entry.get("command") or ""
                existing_tokens = shlex.split(original_cmd)
                # only append tokens that are not already present to avoid duplication
                to_append = [inc for inc in includes if inc not in existing_tokens]
                if to_append:
                    entry["command"] = original_cmd + (" " + " ".join(to_append))
                    lib_count += 1
    if lib_count:
        with open(compile_commands_path, "w", encoding="utf-8") as fp:
            json.dump(compile_commands, fp, indent=2)
        print(
            f"Appended includes to {lib_count} lib entries in {compile_commands_path}"
        )

        print("completed fix_compiledb_action")


# pio run -t compiledb
if "compiledb" in COMMAND_LINE_TARGETS:
    # Running `pio run -t compiledb` alone can aggregate include paths from all
    # installed toolchains; to avoid that we explicitly restrict to a single
    # toolchain here and collect includes only for it.
    pre_compiledb_actions(
        # ls -laF ~/.platformio/packages | grep toolchain
        # drwx------  6 hiromasa hiromasa 4096 10月 13  2024 toolchain-esp32ulp/
        # drwx------  7 hiromasa hiromasa 4096  9月 16  2024 toolchain-riscv32-esp/
        # drwx------  7 hiromasa hiromasa 4096  9月 16  2024 toolchain-xtensa-esp32s3/
        # specify exactly one toolchain identifier, e.g. "xtensa-esp32s3-elf"
        "xtensa-esp32s3-elf",
        # ls -laF ~/.platformio/packages | grep frame
        # drwx------  6 hiromasa hiromasa 4096 10月 14  2023 framework-arduino-gd32v/
        # drwx------  6 hiromasa hiromasa 4096  9月 16  2024 framework-arduinoespressif32/
        # drwx------  8 hiromasa hiromasa 4096 10月 13  2024 framework-espidf/
        # framework library subdirs under PROJECT_PACKAGES_DIR (joined with packages_dir inside function)
        [
            os.path.join("framework-arduinoespressif32", "libraries"),
        ],
    )


# pio run -t fix_compiledb
env.AddCustomTarget(
    "fix_compiledb",
    None,
    fix_compiledb_action(
        # build directory where compile_commands.json is written (COMPILATIONDB_PATH)
        # .pio/build/esp32-s3-devkitc-1
        os.path.join(env.get("PROJECT_BUILD_DIR"), env.get("PIOENV")),
        # source file whose compile_commands entry is used to extract -I include flags
        # src/main.cpp
        os.path.join("src", "main.cpp"),
    ),
)

Gist : https://gist.github.com/h1romas4/7399dd3985bd4dae42e8c01ee343b233

ハックなのにスクリプトが大きくて見苦しいので動作確認できたら platform.ini のパスを変更して隠してしまっていいと思います…

pio_fix_compiledb.py ファイルを追加後に次のように pio コマンドで fix_compiledb タスクを起動して、compile_commands.json にパッチをあてます。

pio run -t fix_compiledb

タスク終了後、Zed 上で開いている *.cpp*.c を一度すべて閉じてから、main.cpp などを開き直すと、LSP が再起動 & compile_commands.json が再読込にて晴れてクリーンな補完が効くようになるはずです。

重要)ソースコードに新規追加があった場合は次のように compile_commands.json を生成・パッチしなおして同様にソースコードを開き直せばうまくいきます。

pio -t compiledb
pio -t fix_compiledb
# 一行でかく版 (Linux)
pio -t compiledb && pio -t fix_compiledb

PlatformIO タスク dependencies を使えば fix_compiledb を自動起動できそうな気がしましたが、、うまくいかなかったので 2 コマンドにて…

パッチのスクリプトは Windows のパス区切りの違いなども気をつけてみましたが、期待通り動作しない場合は .pio/build/esp32-s3-devkitc-1/compile_commands.json を確認してスクリプトを調整してください。

また、${HOME}/.platformio 以下にある依存を変更・追加する場合はスクリプトの次の定義を修正してください。

        [
            os.path.join("framework-arduinoespressif32", "libraries"),
        ],

また、esp32-s3-devkitc-1 値が異なる場合は .clangd のパスを修正お願いします。

CompileFlags:
  # ...snip...
  CompilationDatabase: .pio/build/esp32-s3-devkitc-1

というわけで、pio ハックぎみですが Zed で PlatformIO プロジェクトが扱えるようになって気分が高まりました。

Zed 良い…!




Rust の Relm/GTK4 で GUI アプリハローワールドメモ

クロスプラットフォーム GUI フレームワークな Relm4 と GTK4 を使って GUI アプリをつくるハローワールドのメモ。GTK4 だと macOS で動作させてもあまり GTK ぽさがないかも。良い。

https://relm4.org

Relm4 makes developing beautiful cross-platform applications idiomatic, simple and fast and enables you to become productive in just a few hours.

GTK4 の devel パッケージ準備

Linux (Debian):

$ sudo apt install libgtk-4-dev build-essential

macOS: – pkg-config 使うので入れること

$ brew install gtk4 pkg-config 

Windows: – たぶんこれを(未検証)

Windows – GUI development with Rust and GTK 4

When preparing your Windows machine, you have to decide between either using the MSVC toolchain or the GNU toolchain. If in doubt, go for MSVC since that is the default on Windows. You will want to go for the GNU toolchain if you depend on libraries that can only be compiled with the GNU toolchain.

とりあえず定番の ToDo リストハローワールド

Cargo.toml

[package]
name = "relm-test"
version = "0.1.0"
edition = "2024"

[dependencies]
relm4 = "0.9.1"
relm4-components = "0.9.1"

main.rs

use gtk::glib::clone;
use gtk::prelude::{BoxExt, ButtonExt, GtkWindowExt, WidgetExt, EntryExt, EditableExt};
use relm4::{gtk, ComponentParts, ComponentSender, RelmApp, RelmWidgetExt, SimpleComponent};

struct AppModel {
    tasks: Vec<String>,
}

#[derive(Debug)]
enum AppMsg {
    AddTask(String),
    RemoveTask(usize),
}

struct AppWidgets {
    task_list: gtk::ListBox,
}

impl SimpleComponent for AppModel {
    type Input = AppMsg;
    type Output = ();
    type Init = ();
    type Root = gtk::Window;
    type Widgets = AppWidgets;

    fn init_root() -> Self::Root {
        gtk::Window::builder()
            .title("ToDo List")
            .default_width(400)
            .default_height(300)
            .resizable(false)
            .build()
    }

    fn init(
        _init: Self::Init,
        window: Self::Root,
        sender: ComponentSender<Self>,
    ) -> relm4::ComponentParts<Self> {
        let model = AppModel { tasks: Vec::new() };

        let vbox = gtk::Box::builder()
            .orientation(gtk::Orientation::Vertical)
            .spacing(5)
            .build();

        let task_entry = gtk::Entry::builder()
            .placeholder_text("Enter a new task")
            .build();

        let add_button = gtk::Button::with_label("Add Task");

        let task_list = gtk::ListBox::new();

        let scrolled_window = gtk::ScrolledWindow::builder()
            .vexpand(true)
            .child(&task_list)
            .build();

        window.set_child(Some(&vbox));
        vbox.set_margin_all(5);
        vbox.append(&task_entry);
        vbox.append(&add_button);
        vbox.append(&scrolled_window);

        let add_task = {
            let sender = sender.clone();
            let task_entry = task_entry.clone();
            move || {
                if let Some(text) = task_entry.text().as_str().to_owned().into() {
                    if !text.is_empty() {
                        sender.input(AppMsg::AddTask(text));
                        task_entry.set_text("");
                    }
                }
            }
        };

        task_entry.connect_activate({
            let add_task = add_task.clone();
            move |_| {
                add_task();
            }
        });

        add_button.connect_clicked({
            let add_task = add_task.clone();
            move |_| {
                add_task();
            }
        });

        let widgets = AppWidgets { task_list };

        ComponentParts { model, widgets }
    }

    fn update(&mut self, message: Self::Input, _sender: ComponentSender<Self>) {
        match message {
            AppMsg::AddTask(task) => {
                self.tasks.push(task);
            }
            AppMsg::RemoveTask(index) => {
                if index < self.tasks.len() {
                    self.tasks.remove(index);
                }
            }
        }
    }

    fn update_view(&self, widgets: &mut Self::Widgets, sender: ComponentSender<Self>) {
        while let Some(child) = widgets.task_list.first_child() {
            widgets.task_list.remove(&child);
        }
        for (index, task) in self.tasks.iter().enumerate() {
            let row = gtk::Box::new(gtk::Orientation::Horizontal, 5);

            let label = gtk::Label::new(Some(task));
            label.set_halign(gtk::Align::Start);
            label.set_wrap(true);
            label.set_wrap_mode(gtk::pango::WrapMode::Char);
            label.set_xalign(0.0);
            label.set_size_request(300, -1);

            let remove_button = gtk::Button::with_label("Remove");
            remove_button.set_halign(gtk::Align::End);

            remove_button.connect_clicked(clone!(
                #[strong]
                sender,
                move |_| {
                    sender.input(AppMsg::RemoveTask(index));
                }
            ));

            row.append(&label);
            row.append(&remove_button);
            widgets.task_list.append(&row);
        }
        widgets.task_list.show();
    }
}

fn main() {
    let app = RelmApp::new("relm4.test.todo_list");
    app.run::<AppModel>(());
}

ToDo:

  • WIndows でも試すこと。もう少しです。こんにちは。
  • Ubuntu で動作させるとテキストボックスに日本語が入らない確認。

ダークモードメモ。

fn main() {
    // Initialize GTK
    gtk::init().expect("Failed to initialize GTK");

    // Enable dark mode
    if let Some(settings) = gtk::Settings::default() {
        settings.set_gtk_application_prefer_dark_theme(true);
    }

    let app = RelmApp::new("relm4.test.todo_list");
    app.run::<AppModel>(());
}

以上

Rust の Servo html5ever ライブラリで HTML をパース

Rust の html5ever ライブラリで HTML をパースする。Servo で使われているものと思われる。メモ。

https://github.com/servo/html5ever

High-performance browser-grade HTML5 parser

Cargo.toml

[package]
name = "htmlpaser-test"
version = "0.1.0"
edition = "2024"

[dependencies]
html5ever = "0.27.0"
markup5ever_rcdom = "0.3.0"

src/main.rs

use html5ever::driver::parse_document;
use html5ever::tendril::TendrilSink;
use markup5ever_rcdom::{RcDom, Handle};
use std::default::Default;

// HTML ドキュメントをパースする関数
fn parse_html(html: &str) -> RcDom {
    parse_document(RcDom::default(), Default::default()).one(html)
}

// DOM を再帰的に表示する関数
fn print_dom(handle: &Handle, depth: usize) {
    let indent = "  ".repeat(depth);
    let node = handle;

    match node.data {
        markup5ever_rcdom::NodeData::Document => {
            println!("{}Document", indent);
        }
        markup5ever_rcdom::NodeData::Element { ref name, ref attrs, .. } => {
            println!("{}Element: {}", indent, name.local);
            for attr in attrs.borrow().iter() {
                println!("{}  Attribute: {}=\"{}\"", indent, attr.name.local, attr.value);
            }
        }
        markup5ever_rcdom::NodeData::Text { ref contents } => {
            println!("{}Text: {}", indent, contents.borrow());
        }
        _ => {}
    }

    for child in node.children.borrow().iter() {
        print_dom(child, depth + 1);
    }
}

fn main() {
    let html = "<!DOCTYPE html><html><head><title>Test</title></head><body><h1 th:loop='test'>Hello, world!</h1></body></html>";
    let dom = parse_html(html);

    // DOM を再帰的に表示
    print_dom(&dom.document, 0);
}

結果:

$ cargo run
   Compiling htmlpaser-test v0.1.0 (/Users/hk2a/devel/rust/htmlpaser-test)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.98s
     Running `target/debug/htmlpaser-test`
Document
  Element: html
    Element: head
      Element: title
        Text: Test
    Element: body
      Element: h1
        Attribute: th:loop="test"
        Text: Hello, world!