好玩系列:脚本和插件使我快乐

前言

好玩系列,好久不见。

致敬王小波。王小波,中国当代学者、作家,代表作品有《黄金时代》《白银时代》《青铜时代》《黑铁时代》等。因90年代软件匮乏,在微机上自行编写了输入法和中文编辑软件,用于小说创作。

笔者最近在尝试新的研发领域,在"学习"过程中,使用到了QT-IDE 和 CLion。

笔者按: 因为学习还不足够深入,掌握的知识还不足够广泛和透彻,因此,文章提到的解决方案未必是最合适的方案。

在这个过程中,我遇到了不少"低效率"的手动工作,使我感到不适。为了改变这些令人烦躁的状况,我使用以往的知识构建了脚本和IDE插件,使我脱离这些低效的工作事项。

聪明人应当有效的分配自己的时间,并不断收获愉悦感,避免在烦人的琐事中不断消耗自身精力。

插件工程仓库 脚本文件托管于插件工程仓库 script_demo 目录下。

琐事

  • 维护QT的pro文件,尤其是声明头文件、源文件、form
  • 维护CMakeLists文件,尤其是QT模块、头文件、源文件等清单
  • 构建过程
  • 建立桩代码

很显然,这些都是极其机械化的工作,非常适合使用脚本、IDE插件进行工作简化。

思路及方案

关于:pro和CMakeLists

笔者按: 因为尚未彻底吃透C++编译和构建的所有细节,我暂时不打算全部迁移到使用CMakeLists指导构建,先保留QT的构建

因此,我需要保持正确的pro文件,以支持QT构建,并且让CMakeLists文件的内容,达到支持CLion正确链接文件、跳转、提示的程度。

核心点:

  • 递归扫描,找出头文件、源文件、Form文件
  • 借助.gitignore,忽略掉不受版本控制的文件
  • 参考gitignore机制,额外忽略掉需要被版本控制但不参与编译的文件
  • 定位pro和CMakeLists文件,识别内容段,模板生成和内容替换
  • 解析pro中使用的QT模块等,自动维护CMakeLists内容段

关于构建过程:

因为过程中涉及到临时指定环境变量、拷贝文件、调用工具链等工作,胶水语言是不二选择

关于桩代码:

本质上,它是需要按照用户指定的意图,选择模板创建文件的过程。虽然胶水语言脚本也能完成,但需要通过命令行进行交互,但不如使用IDE插件。

考虑到我对groovy/kts/python都比较熟悉,而JetBrain系列IDE几乎都支持gradle插件,既往开发Android和Java后端时已经具备完善的gradle环境,因此,胶水语言选择groovy+gradle环境;

在开发Pandora项目时,我已经掌握了JetBrain系列IDE plugin 开发的基本技能,因此选择开发CLion Plugin实现桩代码生成。

实践

实践1-gradle脚本维护清单文件

寻找文件

和CMakeLists不同,pro文件没有固定的文件名,因此需要先定位pro文件。

groovy 复制代码
File findProFile(File rootDir) {
    // 如果 gradle.properties 有配置,就用配置的
    if (project.hasProperty("QT_PRO_FILE")) {
        return file(project.property("QT_PRO_FILE"))
    }
    // 否则自动查找
    def list = rootDir.listFiles().findAll { it.name.endsWith(".pro") }
    if (list.isEmpty()) {
        throw new GradleException("未找到 .pro 文件,请配置 QT_PRO_FILE")
    }
    if (list.size() > 1) {
        println "发现多个 .pro 文件,默认使用第一个: ${list[0]}"
    }
    return list[0]
}

加载忽略规则&寻找有效文件

在递归扫描目录时,搜集整个树分支中的 .gitignore 与 .proignore内容,并进行文件过滤。

groovy 复制代码
void scanDir(File rootDir, File dir, List<Map> parentPatterns,
             List<String> headers, List<String> sources, List<String> forms) {

    List<Map> patterns = new ArrayList<>(parentPatterns)

    [".gitignore", ".proignore"].each { ignoreFile ->
        File file = new File(dir, ignoreFile)
        if (file.exists()) {
            file.eachLine { line ->
                line = line.trim()
                if (!line || line.startsWith("#")) return
                boolean negate = line.startsWith("!")
                if (negate) line = line.substring(1)
                patterns << [pattern: line, negate: negate]
            }
        }
    }

    dir.eachFile { f ->
        if (f.name == ".git") return

        String relPath = rootDir.toPath().relativize(f.toPath()).toString().replace("\", "/")

        if (isIgnored(relPath, f.isDirectory(), patterns)) {
            if (f.isDirectory()) return
            else return
        }

        if (f.isDirectory()) {
            scanDir(rootDir, f, patterns, headers, sources, forms)
        } else {
            def ext = relPath.tokenize('.').last().toLowerCase()
            switch (ext) {
                case "h": headers << relPath; break
                case "cpp": sources << relPath; break
                case "ui": forms << relPath; break
            }
        }
    }
}

按模板生成内容并按照插桩位置替换

至此,已收集到足够的信息,按照模板凭借后即可按照插桩TAG识别文件进行替换;

groovy 复制代码
// 生成 .pro 内容
String buildProContent(List<String> headers, List<String> sources, List<String> forms) { /* ...逐项拼接,行尾续行... */ }

// 生成 CMakeLists 内容(文件清单)
String buildCMakeContent(List<String> headers, List<String> sources, List<String> forms) { /* set(SOURCES/HEADERS/FORMS) */ }

// 通用替换(支持自动新增标识区)
void replaceSection(File file, String newContent, String startMark, String endMark) { /* 基于正则替换 */ }

// 汇总任务:扫描 -> 解析模块 -> 生成文本 -> 替换三个区块
task generateQtProFiles {
    doLast {
        File proFile = findProFile(project.projectDir)
        File cmakeFile = file("CMakeLists.txt")
        List<String> headers = [], sources = [], forms = []
        scanDir(project.projectDir, project.projectDir, [], headers, sources, forms)
        List<String> qtModules = parseQtModules(proFile)
        replaceSection(proFile, buildProContent(headers, sources, forms),
                       "# auto generate start", "# auto generate end")
        replaceSection(cmakeFile, buildCMakeContent(headers, sources, forms),
                       "# auto generate start", "# auto generate end")
        replaceSection(cmakeFile, buildCMakeFindPackage(qtModules),
                       "# auto find package start", "# auto find package end")
    }
}

注意:CMakeLists中仍然需要加入QT模块、QT生成文件(如ui文件转换的头文件)等,项目仓库中提供了参考,但仍有不严谨之处。

实践2-gradle脚本组装编译过程

读者诸君,如果你已经为自动化构建平台创建了构建脚本,尤其是构建过程中需要链接文件、移动资源时,这一实践会具有更高价值。

以下是一份参考脚本,可以构建/构建并运行Qt项目,如果你需要在构建过程中链接文件、移动资源,可以用gradle任务串接,比单bat脚本要方便不少。

batch 复制代码
#!/usr/bin/env bash
set -e

# ==============================
# 配置 使用环境变量更佳
# ==============================
QT_SPEC=win32-msvc
QT_BIN="D:/ide/Qt/6.4.0/msvc2019_64/bin"
QT_EXE="XXX.exe"
VCVARS="D:/ide/VS/VC/Auxiliary/Build/vcvars64.bat"
ROOTDIR="$(cd "$(dirname "$0")"; pwd)"
PROJECT="$ROOTDIR/XXX.pro"

# 参数检查
if [ $# -eq 0 ]; then
  echo "用法: $0 [Debug|Release|RunDebug|RunRelease]"
  exit 1
fi

MODE="$1"
BUILDDIR="$ROOTDIR/build/Desktop_Qt_6_4_0_MSVC2019_64bit-$MODE"

# 调 MSVC 环境变量(抓 PATH)
VCENV=$(cmd.exe /c "call "${VCVARS}" x64 && set")
export PATH="$QT_BIN:$(echo "$VCENV" | grep '^PATH=' | cut -d= -f2- | tr -d '\r')"

case "$MODE" in
  Debug|Release)
    mkdir -p "$BUILDDIR/release"
    cd "$BUILDDIR"
    echo "====== qmake + nmake ======"
    qmake "$PROJECT" -r -spec $QT_SPEC CONFIG+=$MODE
    nmake -nologo -f Makefile.$MODE
    ;;
  RunDebug)
    MODE=Debug
    EXE_PATH="$BUILDDIR/$MODE/$QT_EXE"
    if [ ! -f "$EXE_PATH" ]; then
      echo "错误: 未找到 $EXE_PATH"
      exit 1
    fi
    echo "====== 运行 $EXE_PATH ======"
    "$EXE_PATH"
    ;;
  RunRelease)
    MODE=Release
    EXE_PATH="$BUILDDIR/$MODE/$QT_EXE"
    if [ ! -f "$EXE_PATH" ]; then
      echo "错误: 未找到 $EXE_PATH"
      exit 1
    fi
    echo "====== 运行 $EXE_PATH ======"
    "$EXE_PATH"
    ;;
  *)
    echo "参数错误: $MODE"
    exit 1
    ;;
esac

Gradle 任务编排

笔者在实践过程中,面临以下需求:

  • 拷贝SQL文件

  • 拷贝三方库dll等

  • build.gradle 文件中,我定义了一系列 Gradle 任务来调用上述的批处理脚本,并增加了依赖拷贝等额外步骤。

读者诸君可关注以下gradle task:

  • createQtTasks(String mode) :动态创建 DebugRelease 两种模式下的所有相关任务。
  • qtBuild<Mode> :执行编译任务。
  • copyDependencies<Mode> :一个 Copy 类型的任务,负责将运行时需要的动态链接库(如 VLC、LSL 的 .dll)和其他资源文件(如 .sql)拷贝到输出目录。
  • qtBuildRun<Mode> :一键完成"编译 + 运行"的操作。
  • qtRun<Mode> :运行已经编译好的程序。
groovy 复制代码
def createQtTasks(String mode) {
    // ...

    // 定义编译任务,类型为 Exec,用于执行外部命令
    task("qtBuild${mode}", type: Exec) {
        group = 'qt'
        description = "构建 Qt ${mode} 项目"
        dependsOn "qtSetupBuildDir${mode}"

        // 调用批处理脚本
        commandLine 'cmd', '/c', "build-qt.bat", mode

        // 编译完成后自动执行依赖拷贝
        doLast {
            tasks.named("copyDependencies${mode}").get().execute()
        }
    }

    // 定义依赖拷贝任务
    task("copyDependencies${mode}", type: Copy) {
        // ...
        from(vlcSourceDir) { include "*.dll" }
        into destDir
    }

    // 定义"构建并运行"任务
    task("qtBuildRun${mode}", type: Exec) {
        dependsOn "qtBuild${mode}"
        commandLine 'cmd', '/c', "build-qt.bat", "Run${mode}"
    }
}

// 创建 Debug 和 Release 两套任务
createQtTasks("Debug")
createQtTasks('Release')

实践3-CLion插件

从功能上看,这是一个极其简单的插件,因为它只需要:

  • 获知用户行为所对应的目录
  • 获知用户输入的"关键词"
  • 按照模板,替换关键词,在对应目录生成文件、并输入文件内容,及桩代码

并不需要分析当前代码文件的AST、理解上下文、插入对应代码段,更不涉及复杂的代码AST索引、Hint、Navigation等。

创建V2版本插件工程

笔者编写Pandora项目插件已较为久远,当时插件是V1代,如今,已进入V2时代,在工程配置方面需要注意:

  • 依赖V2版本plugin
  • 测试环境配置。
kotlin 复制代码
plugins {
    id("org.jetbrains.intellij.platform") version "2.7.2"
    kotlin("jvm") version "1.9.24"
}

//...

dependencies {
    intellijPlatform {
        intellijIdeaCommunity("2024.1")
//        clion("2025.1")
    }
}

笔者按:记忆中最早期需要下载IDEA源码和可运行的测试版软件,后来jetbrains对Idea的license控制更加严格,放宽开发限制,可直接将安装好的 Idea CE 软件作为测试环境。目前虽然配置方便,但又回到了下载单独测试软件的方案上,这一步需要耐心!

本插件项目本身即为一个基于 Gradle 的 IntelliJ Platform Plugin V2 模板工程。其核心配置文件包括 build.gradle.ktssettings.gradle.kts,用于管理依赖和构建设置。项目结构遵循了官方推荐的标准布局。

完整工程配置请参见 仓库

定义Action并注册

插件的功能入口点是一个 Action。定义 GenerateMvpAction 类以实现功能逻辑。

Action需要注册,src/main/resources/META-INF/plugin.xml ,将其添加到 NewGroup,及 "File" 的 "New" 菜单中,符合习惯。

xml 复制代码
<actions>
    <action id="osp.leobert.plugins.qt.mvpgenerator.GenerateMvpAction"
            class="osp.leobert.plugins.qt.mvpgenerator.GenerateMvpAction"
            text="Generate MVP Stubs"
            description="Generate MVP stub code">
        <!-- 移动到 Project → New 菜单下 -->
        <add-to-group group-id="NewGroup" anchor="last"/>
    </action>
</actions>

定义默认模板

模板文件放置于 src/main/resources/templates/ 目录下,使用 Name 作为占位符,后续将被用户输入的关键词替换。

我使用的模板文件如下,读者诸君可按照自身需求处理:

  • INamePresenter.h
  • INameView.h
  • NamePresenter.cpp
  • NamePresenter.h
  • NameWindow.cpp
  • NameWindow.h
  • NameWindow.ui

例如, INamePresenter.h 的内容如下,其中 ${NAME} 将被动态替换:

cpp 复制代码
// I${NAME}Presenter.h
#ifndef I_PRESENTER_H
#define I_PRESENTER_H

class I${NAME}Presenter {
public:
    virtual ~I${NAME}Presenter() {}
    // ... other methods
};

#endif // I_PRESENTER_H

实现Action

GenerateMvpAction 的核心逻辑在 actionPerformed 方法中实现。

主要步骤如下:

  1. 获取上下文 :通过 AnActionEvent 获取当前项目 project 和用户操作的目录 baseDir
  2. 获取用户输入 :弹出一个 InputDialog 对话框,让用户输入模块名(例如 StimulationLibrary)作为关键词 keyword
  3. 定义文件映射 :创建一个从目标文件名到模板文件路径的映射。目标文件名使用 ${keyword} 占位符。
  4. 执行写操作 :在 ApplicationManager.getApplication().runWriteAction 中执行文件创建操作,这是与IDE文件系统交互的标准做法。
    • 遍历模板映射。
    • 加载模板文件内容。
    • 使用用户输入的 keyword 替换模板中的 ${NAME} 占位符。
    • 在目标目录创建新文件,并写入处理后的内容。
  5. 反馈结果:操作完成后,显示一个信息对话框,告知用户代码已成功生成。

核心代码片段如下:

kotlin 复制代码
// GenerateMvpAction.kt

override fun actionPerformed(e: AnActionEvent) {
    val project = e.project ?: return
    val baseDir = e.getData(CommonDataKeys.VIRTUAL_FILE) ?: return

    // 2. 获取用户输入
    val dialog = InputDialog(project, "Enter keyword (e.g. StimulationLibrary):", "MVP Generator")
    if (!dialog.showAndGet()) return
    val keyword = dialog.inputText.trim()
    if (keyword.isEmpty()) return

    // 3. 定义文件映射
    val templates = mapOf(
        "I${keyword}Presenter.h" to "/templates/INamePresenter.h",
        // ... 其他文件
    )

    // 4. 执行写操作
    ApplicationManager.getApplication().runWriteAction {
        templates.forEach { (fileName, templatePath) ->
            val content = loadTemplate(templatePath).replace("${NAME}", keyword)
            val fName = fileName.replace("${keyword}", keyword)
            val newFile = baseDir.findOrCreateChildData(this, fName)
            newFile.setBinaryContent(content.toByteArray())
        }
    }

    // 5. 反馈结果
    Messages.showInfoMessage(project, "MVP stubs generated for $keyword", "Success")
}

总结

受限于知识,这一轮实践不是最佳实践;

我依然坚持以下观点:我们不能仅用编程能力糊口,更应当让自己感到快乐

相关推荐
穿花云烛展2 小时前
实习日记6(select选择的超出问题)
前端
前端搞毛开发工程师2 小时前
Ubuntu 系统 Docker 安装避坑指南
前端·后端
猪哥帅过吴彦祖2 小时前
Flutter 系列教程:布局基础 (下) - Stack 绝对定位和 Expanded 弹性布局
前端·flutter·ios
伊织code2 小时前
Uvicorn - Python ASGI Web 服务器
服务器·前端·python·uvicorn·asgi
Cache技术分享2 小时前
201. Java 异常 - 如何抛出异常
前端·javascript·后端
京东云开发者2 小时前
代码之美-代码整洁之道
程序员
京东云开发者3 小时前
RAG实践:一文掌握大模型RAG过程
程序员
SimonKing3 小时前
弃用html2canvas!新一代截图神器snapdom要快800倍
java·后端·程序员