改造EmmyLua(LuaLS)的UnityAPI注解生成器

在 Unity 的 Lua 业务中调用 C#,经常会遇到一种割裂感:代码运行没有问题,编辑器却不知道对象是什么类型,也无法继续补全后面的字段。尤其经过单例、委托和 CS 表之后,一条本来有完整类型信息的调用链,很容易变成一串未知字段。

我在开源生成器上做了些修改,把这些类型提示补齐。

Lua 类型注解生成器的项目介绍与 Unity 类型补全效果拼图

这批改动从 2025 年 1 月持续到 3 月,最终通过 PR 合并回上游。本文记录其中几个最有价值的处理。

EmmyLua-Snippet-Generator 的 GitHub README,介绍工具用途、语言服务支持与原项目来源

工具的 GitHub README,点击图片可打开仓库。

生成注解,并不改变运行时

工具在 Unity 编辑器中通过反射读取 C# 程序集,再生成 Lua 语言服务能够理解的类型声明。注解文件提供给编辑器分析,不应该作为业务脚本被加载执行。

1
2
3
4
5
C# 程序集
↓ 反射读取类型、成员和继承关系
Lua 注解文件
↓ 语言服务建立索引
字段补全、参数提示与静态诊断

例如一个普通类型可以生成这样的声明:

1
2
3
4
5
6
7
---@class Demo.InventoryService
---@field Capacity integer
local InventoryService = {}

---@param itemId integer
---@return boolean
function InventoryService:Contains(itemId) end

这里的方法体是空的,因为它的作用是告诉语言服务“存在这个方法”,不是重新实现 C# 逻辑。真正的跨语言调用仍然由 xLua 等运行时桥接完成。

生成注解后,Lua 编辑器能够列出 Unity Transform 类型及成员

当时的补全效果,裁去业务代码,仅保留类型与成员提示。

泛型继承会把类型信息丢在哪

最常见的情况是泛型单例:

1
2
3
4
5
6
7
8
9
public class Singleton<T>
{
public static T Instance { get; }
}

public sealed class InventoryService : Singleton<InventoryService>
{
public int Capacity { get; }
}

如果生成器只看泛型定义,Instance 的类型只能写成 T。但对 InventoryService 来说,继承关系已经明确提供了实参,编辑器本来可以知道 Instance 就是 InventoryService。

我补充的处理是:在生成具体派生类时检查父类的泛型信息,把能够确定的字段与属性类型落实到派生类的声明中。于是调用 InventoryService.Instance.Capacity 时,类型链可以继续下去。

1
2
3
4
---@class Demo.InventoryService : Demo.Singleton
---@field Instance Demo.InventoryService
---@field Capacity integer
local InventoryService = {}

这不是一套完整的 C# 泛型推导系统。这个阶段的实现主要覆盖非泛型派生类、直接父类只有一个泛型参数、继承字段与属性的常见场景;多参数映射、复杂嵌套以及方法的泛型参数和返回值仍需另外处理。

把边界说明清楚很有必要:类型提示在大部分调用链上改善,并不意味着所有泛型组合都能正确生成。

类型名与 CS 表的访问路径

xLua 中常通过 CS.UnityEngine.Vector3 访问 C# 类型,但已有注解可能写成 UnityEngine.Vector3。如果把两种写法当成互不相干的类型,赋值与字段访问就会出现无意义的提示。

工具默认生成正常的类型名,再提供可选的 CS. 前缀兼容声明。目标是让这两种注解写法描述同一个类型,同时让访问 CS 表时能够获得成员提示。

1
2
3
4
5
---@type UnityEngine.Vector3
local a = CS.UnityEngine.Vector3.zero

---@type CS.UnityEngine.Vector3
local b = CS.UnityEngine.Vector3.zero

这里要同时关心类型别名和表的字段声明。仅仅建立一个名称别名,并不自动保证语言服务在 CS.UnityEngine 这条访问路径上能够继续识别成员;这也是改造中实际修过的问题之一。

项目需要保留哪种写法,最好在接入时明确下来,避免生成文件与手写注解形成两套相互不兼容的命名习惯。

需要区分的是,类型别名只改变编辑器怎样理解注解,不会创建新的运行时全局变量。即使启用了兼容别名,xLua 代码真正访问类型时仍然需要 CS.UnityEngine.Vector3;直接写 UnityEngine.Vector3.zero 不会因为生成了注解就自动可用。

C# 委托与 Lua function 的兼容

运行时桥接通常允许把 Lua 函数传给某些 C# 委托,但语言服务未必知道这条转换规则。例如:

1
2
3
4
5
local onDone = function() end

---@type System.Action
local callback
callback = onDone

没有额外信息时,这可能被诊断为 function 不能赋给 System.Action。

我增加了可配置的兼容类型列表,在需要的声明位置将指定委托表达为联合类型:

1
2
---@param callback System.Action | function
function RunWhenReady(callback) end

这样保留了原 C# 类型,同时允许 Lua 函数传入。这个处理也要覆盖方法重载,不能只修普通声明,否则同一套 API 会因为是否进入重载分支而产生不同提示。

代价是 function 本身比较宽泛,它不能替代精确的参数与返回值检查。所以兼容列表只应包含运行时确实支持转换的类型。若某个回调需要严格签名,更合适的办法是进一步生成明确的 fun(...) 声明。

静态方法与实例方法不能混淆

Lua 的点调用与冒号调用在参数上有区别,冒号会隐式传入 self。如果把 C# 静态方法生成为实例方法,编辑器看到的参数列表就会错位。

1
2
3
4
5
-- 静态方法
function Demo.Utility.Create() end

-- 实例方法
function Demo.Service:Refresh() end

生成器需要根据反射结果中的 IsStatic 决定声明方式。我也修复了静态函数仍然携带实例参数的情况,并整理了泛型名称后缀、匿名类型等不适合直接暴露的类型名称。

这些问题看起来零碎,但它们决定了使用者能否信任提示。如果日常面对很多错误警告,人反而会习惯性忽略真正的问题。

让生成过程适合反复使用

除了类型映射,我还补了几项使用上的功能:

  • 按命名空间选择要生成的程序集内容,避免把无关类型全部塞进索引。
  • 为 C# 注入的全局变量和 typeof 等入口生成声明。
  • 按配置行数拆分注解文件,便于管理较大的输出。
  • 用编辑器窗口保存生成选项,减少每次修改源码配置的操作。

文件拆分是否改善分析速度,与语言服务和项目规模有关,我没有做跨编辑器的统一性能基准。更确定的收益是生成范围和输出组织都可以控制。

使用 Lua Language Server 时,可以把生成目录加入 workspace.library。无论文件放在哪里,都应让语言服务只索引需要的声明,避免旧输出和新输出同时存在、重复定义同一类型。

生成目录也应专用。这个工具会清理目标目录中的旧注解,因此不能把手写业务脚本和生成文件混放。

在项目中接入

将仓库克隆到 Unity 项目中,等待编辑器编译后,从顶部的 LuaType 菜单先打开设置,再执行生成。README 中的几项配置可以按下面的顺序准备:

配置 设置方法
配置文件目录 指向一个可写目录;默认值来自 AppDomain.CurrentDomain.BaseDirectory,在 Unity 中可能落到引擎安装目录
注解输出目录 使用专用目录,按工具要求以路径分隔符结尾;不要存放业务文件
C# 命名空间 根据项目引用补充,用空格分隔,例如 DG FairyGUI
全局变量 使用 名称:类型,例如 UNITY_EDITOR:boolean DEBUG_LEVEL:number
Lua 函数兼容类型 填写完整类型名,例如 System.Action FairyGUI.EventCallback0

使用 LuaLS 时,如果输出在工作区之外,把它加入语言服务的库目录即可。下面是 .luarc.json 的一个简化示例,路径需要替换成自己的注解目录:

1
2
3
4
5
{
"workspace.library": [
"../LuaTypeHints"
]
}

生成后先找一条真实调用链验证:能否识别 CS 下的类型、泛型单例后的成员,以及委托参数接受 Lua 函数时的提示。新增程序集、调整生成选项或 C# API 变化后,再重新生成对应的声明。

不同语言服务对注解的兼容细节可能不同,README 所说的 EmmyLua 与 SumnekoLua / LuaLS 支持,并不意味着任意版本组合都完全一致。接入时用项目实际使用的编辑器验证,比只确认生成了文件更可靠。

从项目改动到上游合并

这次改造建立在原作者的生成逻辑上,我主要补充了项目实际需要的能力与问题修复。提交上游时,把新增选项、兼容范围和使用说明一起整理,比只提交一份能在自己工程里运行的脚本更容易让其他人理解。

PR #2 于 2025 年 3 月 27 日合并。工具仓库:EmmyLua-Snippet-Generator;原项目:EmmyLuaXLuaSnippetGenerator。

跨语言类型提示的价值,首先体现在日常写代码时少一点猜测。把运行时已经掌握的类型信息传给编辑器,就能让调用链、参数和字段更早暴露问题;但最终执行是否合法,仍然要由真实的运行时规则与测试确认。