简介:面向C#开发者的智能脚本编辑器源码包,基于.NET 4.0环境,专为需要为上位机、工控或自动化平台增加脚本扩展能力的工程师设计。它具备类似Visual Studio的智能提示与代码着色,针对自定义类以及自建库加载后的成员字段、属性同样能够联想,核心思路是采用脚本引擎加脚本编译器,使平台底层无需频繁改动,应用层通过脚本即可满足多变需求。压缩包仅1.89MB,共76个文件,以18个cs源码文件为核心,辅以17个dll依赖库、4个exe示例程序、6个pdb调试符号及config与resx配置资源文件,结构紧凑。目前已有1090人学习或下载,内含完整VS2015解决方案、可直接运行的Demo与核心脚本提供器实现,并附带自定义测试类用于验证联想效果。读者既可快速套用脚本引擎,也可深入研究自定义类加载、成员提取与智能提示实现流程,适合希望提升平台灵活性与交付效率的中高级C#开发人员借鉴。
1. 智能C#脚本编辑器源码:三层拆解,先搞清楚补全是怎样工作的
智能C#脚本编辑器源码这个方向,真正难的不是“让C#跑成脚本”,而是补全提示能不能在几百毫秒内响应,并把 .NET 4.0 这种老目标处理好。做过上位机或工控项目的读者体会最深:工控机里留一个可改脚本的宿主,最怕的就是每次写代码都要切出去查方法签名,引用和 using 拼不对。这个主题下最常见的翻车现场是:编辑器能编译、能运行,可输一个“.”要等半秒;或是开发机上好好的,部署到老环境里一跑就报 MissingMethodException。把这些事理清楚,我习惯把这类源码拆成三层看:第一层是文本编辑外壳,第二层是编译执行引擎,第三层是智能提示服务。三层边界画清楚,后面所有配置、报错、卡顿都能顺着边界定位。
2. 编译执行引擎:先把“能跑”这件事做扎实
2.1 引擎选型:为什么弃用 CodeDOM 和 CS-Script
“能跑”这个起点上有两条老路常被拿出来对比:CodeDOM 和 CS-Script。CodeDOM 从 .NET 1.0 就有,能把字符串编译成程序集,但不会给你语法树和语义模型,做一个纯“编译执行器”还可以,要做“智能提示”则完全没有抓手。CS-Script 本身是相当完整的脚本宿主,只是它的重心在运行机制、插件挂载和批处理上,补全这一层仍然要自己找解析器。所以一旦标题里出现“智能提示”,Roslyn 基本是唯一选项:只有它把解析源码、绑定类型、给出补全候选这三件事当成标准能力开放给第三方。
用 Roslyn 的代价是概念比 CodeDOM 多一层。你至少要理解 SyntaxTree、SemanticModel、Workspace 三个对象,而不是像以前那样只管“编译、反射调用”。下面是个选型对照,方便接到老代码里时判断值不值得换引擎。
| 方案 | 补全能力 | 调整脚本目标 .NET 版本 | 学习成本 | 适合场景 |
|---|---|---|---|---|
| CodeDOM | 基本没有 | 很难 | 低 | 只做一次性字符串编译 |
| CS-Script | 不强 | 可 | 中 | 已有脚本宿主、优先稳定运行 |
| Roslyn | 完整补全服务 | 可 | 中高 | 新编辑器、智能提示刚需 |
2.2 最小编译宿主:一段能直接复用的代码
先看最少的可用实现。脚本输入之后,我要做三件事:把用户写的代码包成一个带 Main 的类、用 Roslyn 编译成程序集、再把结果载入内存并反射调用。
using System; using System.IO; using System.Linq; using System.Reflection; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; public class ScriptEngine { private readonly IList<string> _referencePaths; public ScriptEngine(IList<string> referencePaths) { _referencePaths = referencePaths; } public object Run(string code) { // 把用户脚本包进一个带 Main 的类,返回值统一走 object,上层只需处理一个入口 string wrappedCode = @" using System; using System.Collections.Generic; using System.Linq; public static class ScriptProgram { public static object Main() { " + code + @" } }"; SyntaxTree tree = CSharpSyntaxTree.ParseText(wrappedCode); List<MetadataReference> refs = _referencePaths .Select(path => MetadataReference.CreateFromFile(path)) .ToList(); CSharpCompilation compilation = CSharpCompilation.Create( "Script_Assembly_" + Guid.NewGuid().ToString("N"), new[] { tree }, refs, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); using (MemoryStream ms = new MemoryStream()) { EmitResult result = compilation.Emit(ms); if (!result.Success) { throw new Exception(string.Join("\r\n", result.Diagnostics .Where(d => d.Severity == DiagnosticSeverity.Error))); } byte[] assemblyBytes = ms.ToArray(); Assembly asm = Assembly.Load(assemblyBytes); Type type = asm.GetType("ScriptProgram"); return type.GetMethod("Main").Invoke(null, null); } } }这段代码有三个地方不建议按自己的习惯乱改。第一是包装方式,不要用CSharpScript.EvalAsync替代,那个接口对“脚本要反复编辑、要拿返回值、要自定义引用”都不友好,拼一个Main方法反而是最可控的做法。第二是OutputKind.DynamicallyLinkedLibrary,它决定程序集的入口形态,用这个值才能保证后面反射拿到ScriptProgram.Main。第三是ms.ToArray()后再Assembly.Load,这会把字节复制一份再交给运行时,MemoryStream释放后程序集仍然可用,不用担心中间资源被回收。
引用路径_referencePaths由外部白名单传入,脚本能用的类型面完全由这个白名单决定。脚本里写上using System.Data但白名单没有 System.Data.dll,编译阶段就会报 CS0234,这种错误看脚本本体是看不出原因的,后面避坑清单会专门说。
2.3 引用注入:脚本能用哪些类型,由白名单宽度决定
引用注入最容易犯的错是两个引擎用两份引用清单。补全引擎和编译引擎如果各自维护一套,最常见的结果就是:智能提示里明明能弹出 DataTable,点编译却说找不到类型,或者反过来编译能过、补全永远少一截。我现在的做法是把引用路径收敛成一个静态清单,补全和编译都读它。
public static class ReferenceProvider { public static List<string> Net40 = new List<string> { @"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.0\mscorlib.dll", @"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.0\System.dll", @"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.0\System.Core.dll", @"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.0\System.Data.dll" }; }注意这里指向的是 Reference Assemblies 目录,不是C:\Windows\Microsoft.NET\Framework\v4.0.30319下的运行时程序集。脚本要部署到老机器时,这个区别是命门:引用运行时程序集容易把开发机环境的依赖带进去,引用程序集则能让编译器按 .NET 4.0 的契约来校验 API。开发机上没有v4.0目录的话,通常是没装对应 Targeting Pack,装完后路径就稳定了。
3. 智能提示的实现:把 CompletionService 接入编辑器的输入流
3.1 补全请求从按下按键到弹出列表,内部走了什么路
智能提示和“关键字高亮”不是一回事。关键字高亮只需要正则匹配,而补全需要知道光标当前位置能出现哪些符号。Roslyn 的做法是把编辑器文本当成一个 Document,扔进 Workspace,Workspace 里挂着 Project 和 MetadataReference;每次用户修改文本,都要基于最新文本拿到 SyntaxTree,再由 CompletionService 在光标位置做一次补全查询。
这里有个必须接受的现实:AdhocWorkspace 和它里面的 Project、Document 都是不可变对象。你用AddProject、AddDocument拿到的不是“修改后的同一个实例”,而是新实例。新手写的代码常常是调完方法不接返回值,结果文档内容永远停在第一版,补全自然跟着翻车。任何一次文本更新都必须重新走一遍“建 Project、挂引用、建 Document”的流程,或者用Workspace.TryApplyChanges去同步。
3.2 最小补全:先不管美观,把候选列表捞出来
下面这段代码是补全的最小闭环,目标是确认 CompletionService 能出结果,再谈过滤和排序。
using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Completion; using Microsoft.CodeAnalysis.Text; using System.Threading.Tasks; public class CompletionEngine { private AdhocWorkspace _workspace = new AdhocWorkspace(); private Document _document; public void AttachToDocument(string scriptText, List<string> referencePaths) { var project = _workspace.CurrentSolution.AddProject( "Script", "Script", LanguageNames.CSharp); foreach (string path in referencePaths) { project = project.AddMetadataReference( MetadataReference.CreateFromFile(path)); } _document = project.AddDocument("script.cs", SourceText.From(scriptText)); } public async Task<List<string>> GetCandidates(int caretPosition) { var service = CompletionService.GetService(_document); if (service == null) return new List<string>(); var result = await service.GetCompletionsAsync(_document, caretPosition); if (result == null) return new List<string>(); return result.ItemsList.Select(i => i.DisplayText).ToList(); } }这段代码最关键的是AttachToDocument里的循环。很多人只建了一个空 Project 就去取补全,结果文档里明明using System,System 里的类型一个都补不出来,原因就是 Project 上没挂任何 MetadataReference。补全依赖语义模型,语义模型能看到的类型范围,与编译引擎的引用清单完全一致。
GetCompletionsAsync返回的DisplayText是给用户看的名字,比如Console的补全项里有WriteLine、ReadLine、Beep。现在这一步不做过筛,一整个列表丢给 ListBox,实际使用会很卡手,所以下一节要加触发和过滤。
3.3 触发时机与过滤排序:决定“像不像 IDE”的开关
触发没有统一答案,我按三层来判断。第一层是按键层:字母、数字、下划线、点号触发;回车、退格不触发;光标不在标识符中间时不触发。第二层是位置层:光标在字符串里、注释里、或者行首空白处不触发,这一层最好用 SyntaxTree 去判断,而不是拿文本正则猜。第三层是频率层:如果输入速度很快,前一次请求还没回来,直接用 CancellationToken 取消,避免弹窗反复跳。
过滤排序则集中在输入前缀上。用户停在Console.Wri时,候选应该先精确匹配Wri,再考虑忽略大小写匹配,最后再按字母序排。
string prefix = GetCurrentWord(textBox.Text, caret); var filtered = result.ItemsList .Where(item => item.DisplayText.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) .OrderByDescending(item => item.DisplayText.StartsWith(prefix, StringComparison.Ordinal)) .ThenBy(item => item.DisplayText) .Take(30) .ToList();这个.OrderByDescending的小技巧是让“大小写完全匹配”的排前面。比如输入console,Console也会被忽略大小写捞进来,但精确匹配条件让Console优先出现在顶部。你的输入法、命名习惯固然不同,但在脚本编辑器里尽量保持 IDE 习惯:精确前缀优先于模糊匹配,候选数量控制在 30 个以内,超过 30 个用户基本不会逐个翻。
注意:退格删字符时也会触发文本变化,这时候要重新取光标位置。事件回调里拿到的
SelectionStart很可能还没更新到退格后的位置,最好通过拦截文本变化事件后延迟取一次。
4. 支持 .NET 4.0:把编译目标钉到引用程序集,而不是开发机
4.1 标题里的“支持 .NET 4.0”其实是两个边界,别混着谈
第一种边界是编辑器宿主自身必须运行在 .NET 4.0 上。这种情况下 Roslyn 的新包很难直接落地,因为较新的 Roslyn 宿主要求至少 .NET Framework 4.6 起步,老版本 Roslyn 的功能和 API 又不如现在全。说实话,编辑器本身要跑在纯 .NET 4.0 环境里还想要完整语义补全,难度很大,我一般会劝退:要么把宿主升级到 .NET 4.5 以上,要么接受一个“半智能补全”,自己反射已加载程序集做前缀匹配。
第二种边界更常见,也是多数“智能C#脚本编辑器源码 支持.NET 4.0”想表达的事:脚本编译出来的结果要能被 .NET 4.0 的老机器直接运行。开发机可以用新 Roslyn,但编译时引用的程序集必须是 .NET 4.0 Reference Assemblies。只有把这个边界定清楚,后面才不会指着运行时报错骂编译器。
4.2 引用清单:编译时指到哪里,运行时就长什么样
脚本要跑在 .NET 4.0 环境,编译时不能拿开发机的 mscorlib 当基准。.NET Framework 4.0 安装后,引用程序集默认在C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.0\下。只要编译引用指向这里,Roslyn 就会按这个版本的程序集元数据做 API 校验。
| 引用程序集 | 主要用途 | 备注 |
|---|---|---|
| mscorlib.dll | 基础类型、Console、String | 必引用,运行时用的 GAC 版本不能代替 |
| System.dll | 集合、组件模型、IO | 必引用 |
| System.Core.dll | LINQ、扩展方法 | 必引用,脚本里using System.Linq就靠它 |
| System.Data.dll | DataTable、SqlClient | 上位机脚本处理数据库时常用 |
| System.Windows.Forms.dll | MessageBox 等 UI | 按需,脚本宿主要求严格就别给 |
构建引擎时不要把这些路径写死在代码里,更不要把运行时目录和引用程序集目录混在一起。运行时程序集带的是开发机的版本特征,编译进脚本后会引入不该出现的 API 面;引用程序集才是“目标契约”。
如果你拿到一台连 .NET Framework 3.5 或 4.0 运行库都装不完整的旧机器,先解决运行库安装问题,再谈脚本环境。很多时候部署机的报错根本不是脚本问题,而是系统组件缺失,这两类问题要分开排查,别混成同一个锅。
4.3 异步化:把补全耗时从 UI 线程上挪走
补全请求在语法树重建、语义模型计算、候选列表生成三个阶段都会有开销。首轮甚至可能到几百毫秒。这个值放在 UI 线程上,用户会明显感到打字被卡住,放后台线程则只是弹窗晚一点出现。
private CancellationTokenSource _cts = new CancellationTokenSource(); private async void OnEditorTextChanged(object sender, EventArgs e) { if (_isProgrammaticChange) return; _cts.Cancel(); _cts = new CancellationTokenSource(); int caret = editor.SelectionStart; if (ShouldSkipCompletion(editor, caret)) return; try { var result = await _service.GetCompletionsAsync( _document, caret, CompletionTrigger.Invoke, _cts.Token) .ConfigureAwait(false); if (result == null) return; var filtered = FilterItems(result.ItemsList, GetPrefix(editor.Text, caret)); BeginInvoke(new Action(() => { ShowCompletionList(filtered, caret); })); } catch (OperationCanceledException) { // 用户继续输入,旧结果直接丢弃 } }CancellationTokenSource.Cancel会取消上一轮还没跑完的补全请求,这是“连续打字不卡顿”的关键。捕获OperationCanceledException时什么都不用做,因为新的一轮已经在队列里。ConfigureAwait(false)让后台线程拿结果,再通过BeginInvoke回到 UI 线程刷新控件,避免跨线程抛异常。
参数上不要太迷信CompletionTrigger.Invoke。这个触发参数更多是描述“手动调起”的场景,连续输入时真正的驱动时机是自己维护的文本变化事件。触发参数给错不会让补全崩溃,但会让候选偶尔不完整,调试时多留意这里。
5. 智能提示脚本编辑器的避坑清单:5 条踩出来的经验
5.1 补全弹窗没跟着输入光标走
现象:代码输入正常,候选列表也能出来,但弹窗固定显示在编辑器左上角,或者停在第一次出现的位置,输入到文件末尾时弹窗和光标之间隔了十万八千里。
原因:弹窗用的坐标是从文本控件拿到的字符索引,没有换算成屏幕坐标就直接送给Show方法。带滚动条的编辑器还要额外考虑横向偏移,GetPositionFromCharIndex返回的是文本框可视区域坐标,不是屏幕坐标。
解决:取光标位置后,先转成屏幕坐标再定位,滚动线框带来的 X 偏移也要加上。
Point pos = editor.GetPositionFromCharIndex(editor.SelectionStart); pos.Offset(-editor.HorizontalScrollPosition, editor.LineHeight); popup.Show(editor, editor.PointToScreen(pos));LineHeight补偿的是光标底部到候选框顶部的视觉间距,具体值跟随控件字体走,不要写死 20。
5.2 智能提示能弹出 DataTable,点编译却报 CS0234
现象:补全列表里 DataTable、DataSet 都有,运行时也用了它们,结果编译引擎报“The type or namespace name 'DataTable' could not be found”。
原因:补全和编译各自用了一套引用清单。补全引擎的 AdhocWorkspace 挂了 System.Data.dll,编译引擎的白名单里没挂,两个引擎各说各话。
解决:把引用清单收敛成单一来源,两个引擎都从同一个ReferenceProvider取。不要相信“补全里能出现就一定能编译”的直觉,补全服务只对它自己加载的 MetadataReference 负责。
5.3 第一次输入提示响应接近一秒,第二次就正常
现象:编辑器打开后第一次按键等很久,后续按键明显变快,重启编辑器后又回到一秒延迟。
原因:AdhocWorkspace 是第一次使用时才开始解析引用程序集。引用清单比较大时,首轮要完成元数据读取、语法树建立、语义模型预热,一次性开销就落在第一次补全上。
解决:做一个静态预热方法,在编辑器启动时提前把常用引用放进一个长期复用的 AdhocWorkspace,让语义模型先计算一次。
public static void WarmUp() { var workspace = new AdhocWorkspace(); var project = workspace.CurrentSolution.AddProject( "Warm", "Warm", LanguageNames.CSharp); foreach (string path in ReferenceProvider.Net40) { project = project.AddMetadataReference( MetadataReference.CreateFromFile(path)); } project.AddDocument("warm.cs", "using System;\nclass C { }"); }预热不是做做样子。它把引用程序集的元数据加载进进程缓存,后续无论建多少新 Document,只要 MetadataReference 来自相同程序集文件,Roslyn 都会复用已解析的结果。预热代码放在编辑器Main方法里、UI 显示之前执行即可。
5.4 编译通过,拷到 .NET 4.0 老机器上 MissingMethodException
现象:开发机上脚本运行正常,部署到工控机后,运行时抛MissingMethodException,或者更晦气地抛TypeLoadException。
原因:编译时引用的 mscorlib 是开发机上的新版,脚本里调用了 .NET 4.0 以后才加入的 API。最典型的是Task.Run,它从 .NET 4.5 开始提供,编译器按 4.5 的 mscorlib 校验当然放行,目标机上 4.0 没有这个方法。
解决:检查 ReferenceProvider 是否指向Reference Assemblies\Microsoft\Framework\.NETFramework\v4.0,而不是 GAC 或开发机运行目录。.NET 4.0 的引用程序集没安装时,先在开发机装对应 Targeting Pack,不要自己去 GAC 里拖一个 mscorlib 出来用。
5.5 打开老项目的脚本文件中文乱码,补全位置跟着全偏
现象:从老设备项目里拷来的 .cs 脚本,打开后中文注释变成黑点,光标停在中文后面时,补全弹窗位置明显错位,甚至补全内容都不对。
原因:文件不是 UTF-8 无 BOM 编码,编辑器默认用 UTF-8 解码,按字节换算的字符索引全部偏移。字符串内容错乱还会把后续标识符都带偏。
解决:读文件时不信任默认编码,先探测 BOM,再做回退兼容。Windows 客户端产生的老脚本常见 GB2312/GBK,没有 BOM 时优先尝试按该编码解码,而不是直接当乱码给用户看。
public static string ReadWithEncodingProbe(string path) { var bytes = File.ReadAllBytes(path); if (bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF) { return Encoding.UTF8.GetString(bytes, 3, bytes.Length - 3); } try { // 严格 UTF-8 解码,非法字节会抛异常 return new UTF8Encoding(false, true).GetString(bytes); } catch (DecoderFallbackException) { return Encoding.GetEncoding("GB2312").GetString(bytes); } }顺带一个细节:字符串长度被错误解码后,补全坐标是按字符数算的,不是按字节算的,所以乱码文件里光标在第 80 个字符,语义模型认为它在第 71 个字符,弹窗定在错的位置。把编码问题修掉后,这个连带症状会自动消失。
6. 进阶:把补全和编译封成 ScriptPad 控件,用压测数据说话
6.1 面向宿主程序的封装接口
编辑器做到能跑、有补全、扛得住 .NET 4.0 环境,下一步通常是被集成进上位机主界面。我不建议把补全和编译逻辑裸在窗体里,最好封成一个ScriptPad控件,外部只暴露LoadScriptFile、Run、CompletionEnabled三个面。
public partial class ScriptPad : UserControl { private readonly ScriptEngine _engine; private readonly CompletionEngine _completion; private TextBox _editor; public ScriptPad() { InitializeComponent(); _engine = new ScriptEngine(ReferenceProvider.Net40); _completion = new CompletionEngine(); } public void LoadScriptFile(string path) { string content = ScriptFile.ReadWithEncodingProbe(path); _editor.Text = content; _completion.AttachToDocument(content, ReferenceProvider.Net40); } public object Run() { try { return _engine.Run(_editor.Text); } catch (Exception ex) { statusLabel.Text = ex.Message; return null; } } }Run()里吞异常是给宿主看的,真正排错时把ex完整记录到日志。脚本编辑器这种组件,错误越早暴露给使用者越好,别在控件层做太多“友好包装”。
6.2 压测补全延迟:先定门槛再谈优化
补全能不能用,我给自己定的门槛是:在普通 x86 工控机上,首次补全不超过 300ms,热补全稳定在 50ms 以内。超过这个值,用户会明显感觉“弹窗跟不上手”。
var sw = Stopwatch.StartNew(); var result = await _service.GetCompletionsAsync(_document, caret); sw.Stop(); logger.Debug($"补全 {sw.ElapsedMilliseconds}ms, {result.ItemsList.Count} 项");压测时不要只测一次。连续输入 20 个字符,记录每次耗时,关注中间有没有超过 200ms 的尖峰。尖峰一般来自语义模型首次计算某个类型,或者某个引用程序集首次被解析。出现尖峰时优先做预热,而不是先换排序算法,这块玄学很少,主要时间都耗在懒加载上。
6.3 我的一个习惯:永远让引用清单成为唯一真相来源
补全引擎、编译引擎、初始化预热三处代码都只从同一个ReferenceProvider取路径,不单独在任意一个类里硬编码引用。这个习惯救过我一次:上线前发现脚本里明明能调用System.Data,编译也没问题,部署后老机器上却报找不到程序集,最后定位到是某次“顺手”修改只改在了编译引擎的白名单里,补全引擎的缓存没同步。后来我把引用清单收敛成一个静态类,代码里再也不出现第二份路径列表。
脚本编辑器这类源码,技术上不难懂,难在让三个模块始终对同一套环境认知。补全能弹、编译能过、运行能稳,这三件事分别代表的其实是语法树、语义模型、引用程序集三个层次的协同。把这个想通,任何开源源码拿回来都能快速定位问题出在哪一层。这份笔记里写的坑,我带过的人基本全都踩过至少一遍,希望帮到你。
本文还有配套的精品资源,点击获取