二次元绘画创作
56.21M · 2026-02-04
如果你想为 JetBrains IDE(IntelliJ IDEA、WebStorm、Rider 等)写一个插件,最省心的起点就是官方模板 intellij-platform-plugin-template。本文以开源插件 I18n Toolkit 为例,结合真实代码,讲清楚从克隆模板、跑起来,到实现核心功能的完整路径。
在插件开发中,IDE 版本、JDK 版本和 Gradle 版本强相关。I18n Toolkit 的约束来自项目配置:
./gradlew)建议:始终使用 Gradle Wrapper,避免本机 Gradle 版本与项目不一致。
git clone
cd intellij-platform-plugin-template
你需要修改几个关键文件:
settings.gradle.kts:项目名称gradle.properties:插件名、版本、ID、平台版本等src/main/resources/META-INF/plugin.xml:插件 ID、名称、描述、扩展点README.md:保留 <!-- Plugin description --> 标记区域模板自带脚本也能一键替换变量,但手工改更直观。
./gradlew runIde
此命令会启动一个 IDE 沙箱环境,插件会自动加载在这个测试 IDE 中。
插件主要结构是固定的:
src/
├── main/
│ ├── kotlin/… # Kotlin 源码
│ └── resources/
│ └── META-INF/plugin.xml # 插件描述文件
└── test/ # 测试
I18n Toolkit 的代码划分更细,按功能分包:
service/:缓存与核心数据scanner/:翻译文件扫描parser/:多格式解析completion/ / annotator/ / reference/:补全、诊断、导航searcheverywhere/ / statusbar/:搜索与状态栏这样的结构非常适合插件类项目:入口清晰、扩展点映射明确。
下面从核心功能视角,拆解这款插件的实现方式,并补充关键类与关键流程的实现细节,方便你对 IntelliJ 平台 API 的落点有更清晰的映射。
插件运行期的“数据生命周期”由两条链路保证稳定:
I18nProjectActivity 实现 ProjectActivity,在项目打开时初始化缓存;同时替换 QuickJavaDoc Action,确保 i18n key 悬停/快捷文档体验一致。I18nDynamicPluginListener 支持插件动态加载/卸载,无需重启 IDE;加载时对所有已打开项目初始化缓存并刷新 UI。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)
} }
}
}
}
}
}
插件需要频繁获取翻译数据,缓存服务是第一优先级:
这让补全、导航、搜索等功能都能基于同一份数据源。
实现细节补充:
initialize() 用 initialized 标记避免重复初始化,实际核心逻辑在 refresh()。refresh() 包裹在 ReadAction.compute 中,确保 PSI 读取安全;同时维护 keyToFiles 方便后续 quick fix 快速定位。TranslationData 内部结构是 key -> locale -> TranslationEntry,并提供 getTranslation() 默认回退策略(zh_CN → zh → en → 首个可用值)。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
}
扫描逻辑有两个重点:
locales / i18n / messages / lang 等node_modules、dist、build、隐藏目录这样可以有效避免无关文件的解析成本。
实现细节补充:
I18nDirectories.STANDARD_DIRS 维护标准目录白名单;扫描使用 VfsUtil.iterateChildrenRecursively。. 开头)以及 node_modules、dist、build。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
}
}
插件支持:
解析方式是“尽可能利用 PSI”:
这解释了为什么 YAML / TOML 的定位可能稍有偏差,但实际效果可接受。
实现细节补充:
JsonFile / JsonObject 递归解析,TranslationEntry.offset 精确定位到 key 的 textOffset。export default、变量声明与表达式语句,提取对象字面量中的字符串值。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) }
}
}
}
i18n 目录结构通常体现模块或业务层级,例如:
src/views/mes/locales/lang/zh_CN/order.ts
插件通过路径自动推导:
zh_CNmes.order.这样在代码里写 t('create'),也能正确定位到 mes.order.create。
实现细节补充:
parseFilePath() 区分 views 模式 与 标准模式:views 模式会把业务单元与模块组合为前缀。message/messages 目录进行特殊处理:避免把它作为 module 前缀(常见于 Spring Message)。示例代码(路径解析与前缀生成):
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 = "")
}
}
插件会自动识别 i18n 框架:
pom.xml 或 build.gradle)如果识别成功,可自动决定语义规则和函数习惯,减少配置。
实现细节补充:
pom.xml / build.gradle(.kts) 文本,判断关键依赖字符串。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
}
这类体验是插件“可感知度”最高的部分:
t('key') 后面显示翻译内容这套逻辑结合了 PSI + Inlay API + InjectedLanguageManager。
实现细节补充:
globalProcessedHints 以 filePath:modStamp:offset 为 key 去重,避免多语言实例重复插入提示。{{ t('key') }} 使用 InjectedLanguageManager 处理 injected PSI。I18nNamespaceResolver.getFullKey() 拼接命名空间,再做翻译匹配。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)
缺失 key 会被标红,并提供一键创建:
I18nKeyAnnotator 负责提示错误CreateI18nKeyQuickFix 根据 key 自动选择目标文件尤其是“根据 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()
插件在 Search Everywhere 中新增 I18n 标签:
这让翻译搜索真正成为“IDE 级别”的能力。
实现细节补充:
calculateMatchScore() 中实现:前缀匹配最高,其次是包含匹配与 value 匹配。示例代码(搜索评分片段):
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
}
这些 UI 功能用到 StatusBarWidget 和 JBPopupFactory,是典型插件 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 注册:
projectService:缓存服务inlayProvider:内联提示annotator:错误提示completion.contributor:补全psi.referenceContributor:导航searchEverywhereContributor:搜索statusBarWidgetFactory:状态栏这里是 IntelliJ 平台插件开发的核心:
常用命令:
./gradlew runIde # 本地运行
./gradlew buildPlugin # 构建分发包
./gradlew test # 运行测试
./gradlew check # 测试 + 覆盖率
./gradlew verifyPlugin # 插件兼容性验证
如果要发布到 JetBrains Marketplace,补齐签名配置即可。
模板给你骨架,真正的价值来自你的“功能设计”与“用户体验”。
I18n Toolkit 的实现告诉我们:
如果你也想写一个生产力插件,不妨从这个开源项目入手,读一遍核心类,跑一遍 runIde,就能快速进入实战状态。
如果你对该插件感兴趣或想参与贡献:
祝你玩得开心,写出属于自己的 JetBrains 插件!