从模板到实战:写一个 IntelliJ 平台插件(以 I18n Toolkit 为例)

从模板到实战:写一个 IntelliJ 平台插件(以 I18n Toolkit 为例)

如果你想为 JetBrains IDE(IntelliJ IDEA、WebStorm、Rider 等)写一个插件,最省心的起点就是官方模板 intellij-platform-plugin-template。本文以开源插件 I18n Toolkit 为例,结合真实代码,讲清楚从克隆模板、跑起来,到实现核心功能的完整路径。

示例仓库:I18n Toolkit(开源)
https://github.com/yelog/i18n-toolkit

模板仓库:IntelliJ Platform Plugin Template
https://github.com/JetBrains/intellij-platform-plugin-template


一、准备环境

在插件开发中,IDE 版本、JDK 版本和 Gradle 版本强相关。I18n Toolkit 的约束来自项目配置:

  • JDK:21
  • Gradle:9.2.1(必须使用 ./gradlew
  • IntelliJ Platform:2025.2.5

建议:始终使用 Gradle Wrapper,避免本机 Gradle 版本与项目不一致。


二、从模板开始:克隆 + 初始化

1. 克隆模板

git clone https://github.com/JetBrains/intellij-platform-plugin-template
cd intellij-platform-plugin-template

2. 初始化项目信息

你需要修改几个关键文件:

  • settings.gradle.kts:项目名称
  • gradle.properties:插件名、版本、ID、平台版本等
  • src/main/resources/META-INF/plugin.xml:插件 ID、名称、描述、扩展点
  • README.md:保留 <!-- Plugin description --> 标记区域

模板自带脚本也能一键替换变量,但手工改更直观。

3. 启动开发 IDE

./gradlew runIde

此命令会启动一个 IDE 沙箱环境,插件会自动加载在这个测试 IDE 中。


三、项目结构速览

插件主要结构是固定的:

src/
├── main/
│   ├── kotlin/…                 # Kotlin 源码
│   └── resources/
│       └── META-INF/plugin.xml  # 插件描述文件
└── test/                         # 测试

I18n Toolkit 的代码划分更细,按功能分包:

  • service/:缓存与核心数据
  • scanner/:翻译文件扫描
  • parser/:多格式解析
  • completion/ / annotator/ / reference/:补全、诊断、导航
  • searcheverywhere/ / statusbar/:搜索与状态栏

这样的结构非常适合插件类项目:入口清晰、扩展点映射明确。


四、核心功能如何实现:I18n Toolkit 代码拆解

下面从核心功能视角,拆解这款插件的实现方式,并补充关键类与关键流程的实现细节,方便你对 IntelliJ 平台 API 的落点有更清晰的映射。

0. 启动与更新链路:ProjectActivity + VFS 监听

插件运行期的“数据生命周期”由两条链路保证稳定:

  • 启动初始化I18nProjectActivity 实现 ProjectActivity,在项目打开时初始化缓存;同时替换 QuickJavaDoc Action,确保 i18n key 悬停/快捷文档体验一致。
  • 动态加载I18nDynamicPluginListener 支持插件动态加载/卸载,无需重启 IDE;加载时对所有已打开项目初始化缓存并刷新 UI。
  • VFS 变更监听I18nFileListener 基于 AsyncFileListener 监听创建/修改/删除/移动/复制事件,仅处理 i18n 翻译文件,触发 I18nCacheService.invalidateFile()refresh()I18nUiRefresher.refresh() 的刷新链路。

示例代码(启动与监听):

class I18nProjectActivity : ProjectActivity {
    override suspend fun execute(project: Project) {
        I18nCacheService.getInstance(project).initialize()
        installQuickDocOverride()
    }
}

class I18nFileListener : AsyncFileListener {
    override fun prepareChange(events: MutableList<out VFileEvent>): AsyncFileListener.ChangeApplier? {
        val relevantEvents = events.filter { e ->
            val file = e.file ?: return@filter false
            e is VFileContentChangeEvent || e is VFileCreateEvent ||
                e is VFileDeleteEvent || e is VFileMoveEvent || e is VFileCopyEvent
        }.filter { e -> e.file?.let(I18nDirectoryScanner::isTranslationFile) == true }
        if (relevantEvents.isEmpty()) return null
        return object : AsyncFileListener.ChangeApplier {
            override fun afterVfsChange() {
                ProjectManager.getInstance().openProjects.forEach { project ->
                    relevantEvents.forEach { it.file?.let { f ->
                        I18nCacheService.getInstance(project).invalidateFile(f)
                    } }
                }
            }
        }
    }
}

1. 统一缓存中心:I18nCacheService

插件需要频繁获取翻译数据,缓存服务是第一优先级:

  • 项目启动时初始化
  • 扫描目录 → 解析文件 → 生成 TranslationData
  • 提供 API:按 key 查找、按语言过滤、查找所有翻译
  • 文件变更时刷新缓存并刷新 UI

这让补全、导航、搜索等功能都能基于同一份数据源。

实现细节补充:

  • initialize()initialized 标记避免重复初始化,实际核心逻辑在 refresh()
  • refresh() 包裹在 ReadAction.compute 中,确保 PSI 读取安全;同时维护 keyToFiles 方便后续 quick fix 快速定位。
  • TranslationData 内部结构是 key -> locale -> TranslationEntry,并提供 getTranslation() 默认回退策略(zh_CNzhen → 首个可用值)。
  • getTranslationStrict() 基于 I18nLocaleUtils.buildLocaleCandidates() 进行严格 locale 匹配,不做全局回退。

示例代码(缓存刷新核心流程):

fun refresh() {
    val translationFiles = I18nDirectoryScanner.scanForTranslationFiles(project)
    keyToFiles.clear()
    val data = ReadAction.compute<TranslationData, RuntimeException> {
        val result = TranslationData(I18nFrameworkDetector.detect(project))
        translationFiles.forEach { file ->
            val pathInfo = I18nKeyGenerator.parseFilePath(file, project.basePath ?: "")
            val entries = TranslationFileParser.parse(project, file, pathInfo.keyPrefix, pathInfo.locale)
            entries.forEach { (key, entry) ->
                result.addEntry(entry)
                keyToFiles.getOrPut(key) { mutableSetOf() }.add(entry)
            }
        }
        result
    }
    translationData = data
}

2. 目录扫描:I18nDirectoryScanner

扫描逻辑有两个重点:

  • 只扫描标准 i18n 目录:locales / i18n / messages / lang
  • 排除目录:node_modulesdistbuild、隐藏目录

这样可以有效避免无关文件的解析成本。

实现细节补充:

  • I18nDirectories.STANDARD_DIRS 维护标准目录白名单;扫描使用 VfsUtil.iterateChildrenRecursively
  • 目录过滤逻辑同时跳过隐藏目录(以 . 开头)以及 node_modulesdistbuild
  • 识别文件类型来自 TranslationFileType,支持 json/yaml/yml/toml/js/mjs/cjs/ts/mts/cts/properties

示例代码(扫描与类型识别):

object I18nDirectoryScanner {
    private val excludedDirNames = setOf("node_modules", "dist", "build")
    fun scanForTranslationFiles(project: Project): List<VirtualFile> {
        val baseDir = project.guessProjectDir() ?: return emptyList()
        val translationFiles = mutableListOf<VirtualFile>()
        findI18nDirectories(baseDir).forEach { dir ->
            VfsUtil.iterateChildrenRecursively(dir, ::shouldTraverse) { file ->
                val ext = file.extension?.lowercase()
                if (!file.isDirectory && ext in TranslationFileType.allExtensions()) {
                    translationFiles.add(file)
                }
                true
            }
        }
        return translationFiles
    }
}

3. 多格式解析:TranslationFileParser

插件支持:

  • JSON / YAML / TOML
  • Properties
  • JS / TS(对象字面量)

解析方式是“尽可能利用 PSI”:

  • JSON / JS / TS:走 PSI 结构,能拿到精确 offset
  • YAML / TOML:用第三方 parser,offset 为估算值

这解释了为什么 YAML / TOML 的定位可能稍有偏差,但实际效果可接受。

实现细节补充:

  • JSON:用 JsonFile / JsonObject 递归解析,TranslationEntry.offset 精确定位到 key 的 textOffset
  • JS/TS:解析 export default、变量声明与表达式语句,提取对象字面量中的字符串值。
  • YAML/TOML:使用 SnakeYAML / toml4j 解析结构,offset 通过累加长度估算。
  • Properties:按行扫描 key=value,过滤注释行并记录行内 offset。

示例代码(JSON / JS/TS 解析片段):

private fun parseJsonObject(obj: JsonObject, prefix: String, locale: String, file: VirtualFile, out: MutableMap<String, TranslationEntry>) {
    obj.propertyList.forEach { prop ->
        val key = prop.name
        val fullKey = if (prefix.isEmpty()) key else "$prefix$key"
        when (val value = prop.value) {
            is JsonStringLiteral -> out[fullKey] = TranslationEntry(fullKey, value.value, locale, file, prop.nameElement.textOffset, prop.nameElement.textLength)
            is JsonObject -> parseJsonObject(value, "$fullKey.", locale, file, out)
        }
    }
}

private fun parseJsExpression(expr: JSExpression?, prefix: String, locale: String, file: VirtualFile, out: MutableMap<String, TranslationEntry>) {
    if (expr is JSObjectLiteralExpression) {
        expr.properties.forEach { prop ->
            val key = prop.name ?: return@forEach
            val fullKey = if (prefix.isEmpty()) key else "$prefix$key"
            val value = prop.value as? JSLiteralExpression
            value?.stringValue?.let { out[fullKey] = TranslationEntry(fullKey, it, locale, file, prop.textOffset, key.length) }
        }
    }
}

4. Key 前缀生成:I18nKeyGenerator

i18n 目录结构通常体现模块或业务层级,例如:

src/views/mes/locales/lang/zh_CN/order.ts

插件通过路径自动推导:

  • locale:zh_CN
  • keyPrefix:mes.order.

这样在代码里写 t('create'),也能正确定位到 mes.order.create

实现细节补充:

  • parseFilePath() 区分 views 模式标准模式:views 模式会把业务单元与模块组合为前缀。
  • message/messages 目录进行特殊处理:避免把它作为 module 前缀(常见于 Spring Message)。
  • 当路径里找不到 locale 时,会回退到文件名判断 locale。

示例代码(路径解析与前缀生成):

fun parseFilePath(file: VirtualFile, projectBasePath: String): PathInfo {
    val relativePath = file.path.removePrefix(projectBasePath).removePrefix("/")
    val parts = relativePath.split("/")
    val fileName = file.nameWithoutExtension
    return when {
        isViewsLocalePattern(parts) -> parseViewsLocalePattern(parts, fileName)
        isStandardLocalePattern(parts) -> parseStandardLocalePattern(parts, fileName)
        else -> PathInfo(locale = extractLocale(parts, fileName), module = null, businessUnit = null, keyPrefix = "")
    }
}

5. 框架检测:I18nFrameworkDetector

插件会自动识别 i18n 框架:

  • vue-i18n
  • react-i18next
  • next-intl
  • @nuxtjs/i18n
  • react-intl
  • Spring Message(检测 pom.xmlbuild.gradle

如果识别成功,可自动决定语义规则和函数习惯,减少配置。

实现细节补充:

  • Spring 检测:直接读取 pom.xml / build.gradle(.kts) 文本,判断关键依赖字符串。
  • JS/TS 检测:通过 PSI 解析 package.json,遍历 dependencies / devDependencies / peerDependencies

示例代码(依赖检测):

private fun parsePackageJson(project: Project, file: VirtualFile): I18nFramework {
    val psiFile = PsiManager.getInstance(project).findFile(file) as? JsonFile ?: return I18nFramework.UNKNOWN
    val rootObject = psiFile.topLevelValue as? JsonObject ?: return I18nFramework.UNKNOWN
    val deps = mutableSetOf<String>()
    listOf("dependencies", "devDependencies", "peerDependencies").forEach { depType ->
        (rootObject.findProperty(depType)?.value as? JsonObject)?.propertyList?.forEach { deps.add(it.name) }
    }
    return I18N_PACKAGES.firstOrNull { deps.contains(it) }?.let(I18nFramework::fromPackageName) ?: I18nFramework.UNKNOWN
}

6. Inlay 提示:I18nInlayHintsProvider

这类体验是插件“可感知度”最高的部分:

  • t('key') 后面显示翻译内容
  • 支持 Vue template 的注入代码
  • 缓存已处理位置,避免重复插入
  • 可设置为“仅显示翻译”或“key + 翻译”模式

这套逻辑结合了 PSI + Inlay API + InjectedLanguageManager。

实现细节补充:

  • globalProcessedHintsfilePath:modStamp:offset 为 key 去重,避免多语言实例重复插入提示。
  • Vue 模板插值中的 {{ t('key') }} 使用 InjectedLanguageManager 处理 injected PSI。
  • 先用 I18nNamespaceResolver.getFullKey() 拼接命名空间,再做翻译匹配。
  • 若显示语言存在但缺失该 key,会显示 Missing translation for 'locale' 的提示文案。

示例代码(Inlay 去重与渲染):

val hintKey = "$filePath:$modStamp:$offset"
if (globalProcessedHints.putIfAbsent(hintKey, true) != null) return

val presentation = factory.roundWithBackground(
    factory.smallText(" → $translationText")
)
sink.addInlineElement(offset, true, presentation, false)

7. 缺失 Key 诊断 + 快速修复

缺失 key 会被标红,并提供一键创建:

  • I18nKeyAnnotator 负责提示错误
  • CreateI18nKeyQuickFix 根据 key 自动选择目标文件
  • 支持 JSON / JS / TS / Properties 直接写入

尤其是“根据 key 前缀和兄弟 key 自动选择文件”的逻辑,极大提升了体验。

实现细节补充:

  • I18nKeyAnnotator 使用 I18nFunctionResolver 获取可配置的 i18n 函数名(默认 t/$t/i18n/translate/...)。
  • 只高亮字符串内容本身(排除引号),并挂载 CreateI18nKeyQuickFix
  • CreateI18nKeyQuickFix 先尝试 最长前缀匹配,失败后再用 兄弟 key 反推文件。
  • 实际写入通过 WriteCommandAction + PSI 操作完成,写入后用 OpenFileDescriptor 定位并把光标放在引号之间。

示例代码(缺失 Key 高亮范围):

val elementRange = literalExpr.textRange
val keyStartOffset = elementRange.startOffset + 1
val keyEndOffset = keyStartOffset + partialKey.length
val highlightRange = TextRange(keyStartOffset, keyEndOffset)
holder.newAnnotation(HighlightSeverity.ERROR, "Unresolved i18n key: '$fullKey'")
    .range(highlightRange)
    .textAttributes(DefaultLanguageHighlighterColors.INVALID_STRING_ESCAPE)
    .withFix(CreateI18nKeyQuickFix(fullKey))
    .create()

8. Search Everywhere 集成

插件在 Search Everywhere 中新增 I18n 标签:

  • key 和翻译都支持模糊搜索
  • Enter:复制 key
  • Ctrl+Enter:跳转到翻译文件
  • 结果排序有评分策略(前缀匹配 > 包含匹配)

这让翻译搜索真正成为“IDE 级别”的能力。

实现细节补充:

  • 搜索结果以 “key + 多语言翻译” 合并为一个条目。
  • 评分策略在 calculateMatchScore() 中实现:前缀匹配最高,其次是包含匹配与 value 匹配。
  • Enter 复制 key,Ctrl+Enter 直接打开翻译文件,行为明确且稳定。

示例代码(搜索评分片段):

private fun calculateMatchScore(key: String, entries: Collection<TranslationEntry>, tokens: List<String>, compactQuery: String): Int {
    val keyLower = key.lowercase()
    val keyMatchesAll = tokens.isNotEmpty() && tokens.all { keyLower.contains(it) }
    var score = 0
    if (keyMatchesAll) score += 100
    if (tokens.isNotEmpty() && keyLower.startsWith(tokens.first())) score += 1000
    return score
}

9. 状态栏语言切换 + 翻译编辑

  • 状态栏小部件支持显示与切换当前语言
  • 翻译弹窗支持多语言编辑,并可实时写入文件

这些 UI 功能用到 StatusBarWidgetJBPopupFactory,是典型插件 UI 技术。

实现细节补充:

  • I18nStatusBarWidget 通过 ListPopup 提供语言列表与“Go to Settings”入口,切换语言后触发 UI refresh。
  • I18nTranslationEditPopup 使用 Alarm 做 300ms 防抖,编辑后实时写回文件。
  • I18nTranslationWriter 根据文件类型分别替换 JSON/JS/Properties 内容,并处理引号与转义。

示例代码(状态栏切换与写回):

val step = object : BaseListPopupStep<PopupItem>("I18n Toolkit", allItems) {
    override fun onChosen(selectedValue: PopupItem?, finalChoice: Boolean): PopupStep<*>? {
        if (selectedValue is PopupItem.LocaleItem) {
            settings.state.displayLocale = selectedValue.locale
            I18nUiRefresher.refresh(project)
        }
        return super.onChosen(selectedValue, finalChoice)
    }
}

WriteCommandAction.runWriteCommandAction(project, "Update i18n Translation", null, Runnable {
    val document = FileDocumentManager.getInstance().getDocument(entry.file) ?: return@Runnable
    document.replaceString(valueStart, lineEnd, newValue)
})

五、插件功能如何挂载:plugin.xml 扩展点

所有功能都需要通过 plugin.xml 注册:

  • projectService:缓存服务
  • inlayProvider:内联提示
  • annotator:错误提示
  • completion.contributor:补全
  • psi.referenceContributor:导航
  • searchEverywhereContributor:搜索
  • statusBarWidgetFactory:状态栏

这里是 IntelliJ 平台插件开发的核心:

一切功能都是“扩展点 + 实现类”的组合。


六、构建、测试与发布

常用命令:

./gradlew runIde          # 本地运行
./gradlew buildPlugin     # 构建分发包
./gradlew test            # 运行测试
./gradlew check           # 测试 + 覆盖率
./gradlew verifyPlugin    # 插件兼容性验证

如果要发布到 JetBrains Marketplace,补齐签名配置即可。


七、总结:从模板到可用插件的关键路径

模板给你骨架,真正的价值来自你的“功能设计”与“用户体验”。

I18n Toolkit 的实现告诉我们:

  • 缓存与解析是插件性能的核心
  • IntelliJ 扩展点体系决定功能边界
  • 体验要足够“IDE 级”,才能真正提升开发效率

如果你也想写一个生产力插件,不妨从这个开源项目入手,读一遍核心类,跑一遍 runIde,就能快速进入实战状态。


如果你对该插件感兴趣或想参与贡献:

祝你玩得开心,写出属于自己的 JetBrains 插件!


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 [email protected]

×

喜欢就点赞,疼爱就打赏