☰
.NET 4.0下的C#智能脚本编辑器:基于Roslyn的运行时编译与补全实现
2026/9/25 2:09:30 网站建设 项目流程

简介:一套支持.NET 4.0的智能C#脚本编辑器源码,面向自动化软件工程师和需在平台内嵌入脚本能力的开发者,用于在系统架构完成后仍能通过脚本快速调整业务逻辑。编辑器使用VS2015开发,可实现代码颜色高亮、自定义类及自定义库中的对象字段属性智能提示,输入体验接近VS编译器,能够显著降低主程序频繁改版带来的维护成本。整个压缩包仅1.89MB,共76个文件,核心为18个cs源码文件、17个dll类库、6个pdb调试文件,以及resources资源、config配置和可执行示例;解决方案中包含演示窗体、脚本提供器与自定义测试类,便于直接定位关键实现。作者作为自动化软件控制工程师,已在实际项目中应用验证;目前已有1090人学习使用,适合用来研究脚本引擎交互、智能联想触发机制及自定义程序集加载,也可作为C#脚本编辑器二次开发的起点。

1. 为什么在.NET 4.0上还要自己做智能提示脚本编辑器

做过C#上位机或者自动化测试框架的人,多半遇到过这种尴尬:业务逻辑经常变,不能每次改动都重新编译整个WinForms程序,于是想塞一个脚本引擎进去,让用户在界面上写几行C#代码就能改行为。市面上现成的C#脚本编辑器插件不少,但一提到要跑在.NET 4.0的旧项目里,还要带智能提示,大部分方案直接哑火——要么依赖.NET Framework 4.7.2以上的新API,要么编辑器控件体积大得离谱。这套智能C#脚本编辑器源码的价值就在这:它把“运行时编译C#代码”和“代码补全提示”两件事都压在了.NET 4.0这个老框架上,让老项目也能获得接近Visual Studio的编辑体验。适合的人群很具体:还在维护.NET 4.0时代WinForms/WPF项目的工程师、做C#上位机需要脚本化二次开发的团队,以及想给自家自动化工具嵌一个脚本面板的开发者。

2. 运行时编译与代码补全:先搞懂这两条技术路线

2.1 为什么选Roslyn而不是CodeDOM

在.NET 4.0上做运行时编译,常见路线有两条:CodeDOM和Roslyn。CodeDOM是微软老牌的动态编译方案,通过CSharpCodeProvider把源码字符串编译成程序集,然后反射调用。它的优点是框架自带、部署简单,缺点是编译速度慢、错误信息难读,而且完全没有任何代码分析能力——想做智能提示就得自己去解析源码,工作量直接起飞。

Roslyn是编译器即服务(Compiler as a Service),把编译过程拆成了词法分析、语法分析、符号绑定、编译成IL等公开的API,开发者可以直接拿到语法树。智能提示的本质就是基于语法树做语义分析,然后从符号表里捞可用的成员。Roslyn的Microsoft.CodeAnalysis.CSharp包虽然官方建议新项目用,但它有一部分版本仍然保留了net40目标框架支持,这正是这套源码能在.NET 4.0上落地的前提。

我一般会先看项目的运行时约束再选:如果脚本只是简单计算,用CodeDOM省心;一旦要做成员补全、参数列表提示、错误波浪线,Roslyn是唯一靠谱选择。这套编辑器源码显然走的是后者,而且它在引用版本上做了固定处理,避免新版本Roslyn默认不支持net40的坑。

2.2 智能提示的两个层次:字符串扫描和语义补全

智能提示不是只有一种实现深度。最粗糙的做法是正则扫描用户输入的文本,把类名、方法名做成静态列表,然后做前缀匹配。这种方案在简单场景下能跑,但一旦用户输出了多行代码、改变了变量的类型,提示列表就完全失去上下文关联,属于典型的“黑匣子式”实现——看着能用,一深入就翻车。

真正可用的是基于语义模型的补全。Roslyn拿到用户输入文本后,先解析成SyntaxTree,再通过Compilation对象获取SemanticModel,最后调用GetCompletionItemsAsync拿到当前光标位置的候选成员。这套链路能做到随输入实时刷新,且严格基于当前代码上下文。最直接的差异:同样是输入myList.,静态扫描只能列出泛型List的常见方法,语义补全能根据myList的实际声明类型,列出Add、Remove、Where等精确成员,连扩展方法都能带出来。

2.3 框架约束:.NET 4.0到底卡住了什么

.NET 4.0的约束要掰开看。首先,它默认没有Microsoft.CodeAnalysis程序集,需要手动引用NuGet包;其次,很多Roslyn新版本在启动时会校验运行框架版本,低于4.6.1的直接拒绝加载。常见的报错是“此实现不是Windows平台FIPS验证的加密算法的一部分”这类系统性异常,或者直接抛BadImageFormatException。这套源码的做法是把Roslyn包锁在支持net40的旧版本(比如1.3.x系列),同时加上app.config里的bindingRedirect,强制运行时加载指定版本。如果你拿到源码后直接升级了Roslyn包而没注意net40支持,大概率第一步就启动失败。

3. 用Roslyn在.NET 4.0里搭最小编辑器:源码结构与关键类

3.1 最小编译执行链路:从字符串到程序集

先写一个最小可用的运行时编译执行代码,这是整个脚本编辑器的地基。以下是我常用的模板,直接基于Roslyn的CSharpCompilation:

using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.Emit; using System; using System.Collections.Generic; using System.IO; using System.Reflection; public class ScriptRunner { private readonly List<MetadataReference> _references = new List<MetadataReference>(); public ScriptRunner() { // 必须引用当前运行时所在的程序集,否则脚本里用不了System命名空间 _references.Add(MetadataReference.CreateFromFile(typeof(object).Assembly.Location)); _references.Add(MetadataReference.CreateFromFile(typeof(Enumerable).Assembly.Location)); // 如果你的宿主程序集有公开类型要暴露给脚本,把宿主自身也加上 _references.Add(MetadataReference.CreateFromFile(Assembly.GetExecutingAssembly().Location)); } public object Run(string code, string entryMethodName = "Main") { var syntaxTree = CSharpSyntaxTree.ParseText(code); var compilation = CSharpCompilation.Create( "DynamicScript" + Guid.NewGuid().ToString("N"), new[] { syntaxTree }, _references, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); using (var ms = new MemoryStream()) { EmitResult result = compilation.Emit(ms); if (!result.Success) { // 把诊断信息拼成可读文本,抛给上层显示 throw new InvalidOperationException(string.Join(Environment.NewLine, result.Diagnostics)); } ms.Seek(0, SeekOrigin.Begin); var assembly = Assembly.Load(ms.ToArray()); var type = assembly.GetType("ScriptProgram"); var method = type.GetMethod(entryMethodName, BindingFlags.Public | BindingFlags.Static); return method?.Invoke(null, null); } } }

逻辑说明:这段代码做的事情是把一段C#源码解析成语法树,然后CSharpCompilation.Create创建一个编译单元。注意参数里的OutputKind.DynamicallyLinkedLibrary,它表示产物是DLL而不是可执行文件,这样即使脚本里没有Main方法也能编译成功。Emit结果被写进内存流,再用Assembly.Load加载到当前AppDomain,最后通过反射找到入口方法并调用。

参数说明里最需要注意的是_references列表:脚本能用哪些类型,完全由它决定。如果你漏了System.Core.dll(即typeof(Enumerable).Assembly.Location),那句using System.Linq在编译期就会报错。建议是把宿主程序集、核心运行时程序集、脚本可能用到的第三方程序集全部提前加进来。如果你的宿主是WinForms项目,还需要拿typeof(Form).Assembly.Location引进来,否则脚本里处理UI会直接编译失败。

3.2 编辑器界面的核心类划分

这套源码的编辑器部分不是用RichTextBox硬写的,而是基于一个轻量级语法高亮控件(常见做法是封装TextBoxBase或Scintilla的.NET包装)承载编辑区,再叠加一个列表控件展示补全项。源码中的核心类通常有这么几个:

  • ScriptEditorControl:负责文本输入、光标位置跟踪、快捷键处理(比如Ctrl+Space触发补全)。
  • CompletionManager:调用Roslyn的语义分析,返回补全列表,并管理补全窗口的显示位置。
  • CompileService:把编辑器里的全文喂给ScriptRunner,返回编译结果或错误信息。
  • ErrorMarker:把编译诊断映射到编辑器的行号上,画红色波浪线。

我一般会把CompletionManager设计成事件驱动:当文本内容变化且光标停留超过200毫秒,就异步触发补全请求。避免每次击键都同步分析,否则用户打字会出现明显的卡顿感。补全窗口的坐标计算要基于GetPositionFromCharIndex,否则在滚动状态下会错位,这个小细节经常被忽略。

3.3 配置中心:.NET 4.0下的依赖注入与初始化顺序

初始化顺序在这套源码里是能跑和不能跑的分水岭。必须先初始化AdhocWorkspace和MetadataReference集合,再创建Document,最后建立语义模型。常见错误是把Document的创建放在引用之前,导致后续GetSemanticModelAsync永远返回空。建议在Form_Load里按这样的顺序执行:

private AdhocWorkspace _workspace; private Document _document; private void InitScriptEngine() { // 1. 先建workspace,它是Roslyn管理文档列表的容器 _workspace = new AdhocWorkspace(); // 2. 准备引用,包含脚本需要的基础程序集 var references = new List<MetadataReference> { MetadataReference.CreateFromFile(typeof(object).Assembly.Location), MetadataReference.CreateFromFile(typeof(Enumerable).Assembly.Location), MetadataReference.CreateFromFile(typeof(Form).Assembly.Location) }; // 3. 创建Project和Document,指定语言为C#,并带上引用 var projectInfo = ProjectInfo.Create( ProjectId.CreateNewId(), VersionStamp.Default, "ScriptProject", "ScriptProject", LanguageNames.CSharp, compilationOptions: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary), metadataReferences: references); var project = _workspace.AddProject(projectInfo); _document = _workspace.AddDocument(project.Id, "Script.cs", SourceText.From("")); }

这段初始化代码的一个小技巧是先用空字符串建立Document,后续每次文本变化时再不重建文档,而是用_document.WithText(SourceText.From(newText))替换文本内容。这样能保持DocumentId稳定,CompletionManager里持有的语义模型缓存不会失效。

4. 智能提示的核心:从语法树到补全列表的完整链路

4.1 获取补全项的API调用:GetCompletionsAsync的完整用法

补全列表的生成走的是Roslyn的CompletionService。核心过程分三步:拿到当前Document,计算光标位置对应的Document位置(注意文本变化后位置要同步更新),然后调用GetCompletionsAsync获取候选。完整代码我拆解如下:

using Microsoft.CodeAnalysis.Completion; using System.Linq; using System.Threading.Tasks; public async Task<List<string>> GetCompletingItems(int cursorPosition) { // 确保Document是最新文本,这一步不能省 var latestDoc = _document.WithText(SourceText.From(_editorControl.Text)); // 获取CompletionService实例,Roslyn的入口点 var completionService = CompletionService.GetService(latestDoc); // 计算补全触发条件:如果光标前面紧跟字母或点号,才显示列表 if (!completionService.ShouldTriggerCompletion(SourceText.From(_editorControl.Text), cursorPosition)) { return new List<string>(); } var completionList = await completionService.GetCompletionsAsync(latestDoc, cursorPosition); // 过滤掉不显示的项,比如仅编译器使用的符号 var items = completionList.Items .Where(i => i.DisplayText != "await" || i.DisplayText != "var") .Select(i => i.DisplayText) .ToList(); return items; }

这个方法的执行时机是文本变化事件和Ctrl+Space快捷键。ShouldTriggerCompletion这一步是一个容易被忽略的细节:如果用户输入的是字母,系统会持续触发补全;如果输入的是数字或括号结尾,通常不该弹补全窗口,这个判断能减少大量无效计算。

一个实际体验优化:如果补全列表有多页或者列表较长,建议在返回列表前先按DisplayText去重,因为Roslyn有时会把同一个符号以不同kind返回两次,直接展示会让用户觉得列出了重复项。

4.2 补全列表的数据绑定与高亮匹配逻辑

拿到列表还不够,要把列表显示在编辑器旁边并做关键词高亮。这块源码里常见的是重写了一个ListBox的DrawItem事件:

private void completionListBox_DrawItem(object sender, DrawItemEventArgs e) { if (e.Index < 0) return; string displayText = completionListBox.Items[e.Index].ToString(); string userInput = _currentPrefix; // 当前用户已输入的前缀 e.DrawBackground(); if (userInput.Length > 0 && displayText.StartsWith(userInput, StringComparison.OrdinalIgnoreCase)) { // 把前缀部分画成蓝色加粗 using (var boldFont = new Font(e.Font, FontStyle.Bold)) using (var blueBrush = new SolidBrush(Color.FromArgb(0, 120, 215))) { e.Graphics.DrawString(displayText, boldFont, blueBrush, e.Bounds.Left, e.Bounds.Top); } } else { e.Graphics.DrawString(displayText, e.Font, new SolidBrush(SystemColors.WindowText), e.Bounds.Left, e.Bounds.Top); } e.DrawFocusRectangle(); }

这段代码实现的是“前缀匹配高亮”,即用户输入的字符在补全项里以蓝色粗体标出。这里有个坑:如果你用StartsWith做过滤,那么像“GetData”这样的项,在用户输入“Data”时永远不会出现。更好的做法是同时支持Contains匹配(子串匹配),或者干脆交给Roslyn的CompletionFilterReason来判断。在源码里,过滤规则通常是CompletionFilterReason.Type、CompletionFilterReason.Member这些枚举的组合,只过滤掉不合理项,让匹配交给显示层。

4.3 参数列表的显示:两个边界的取舍

除了成员名补全,智能提示还包括参数列表提示(用户输入方法名后显示形参类型和名称)。Roslyn的GetSymbolsAsync接口可以拿到当前方法重载列表:

var symbol = await completionService.GetSymbolAsync(latestDoc, cursorPosition, item); if (symbol is IMethodSymbol methodSymbol) { foreach (var parameter in methodSymbol.Parameters) { string paramText = $"{parameter.Type.ToDisplayString()} {parameter.Name}"; // 展示到参数提示框中 } }

这个方法看起来很顺,但有一个实际体验问题:参数提示框的显隐时机很难拿捏。显示太早会遮挡代码,显示太晚用户已经输入了一半参数。我自己的做法是只监控逗号和左括号两个字符,当用户输入左括号时弹出提示,输入对应右括号时隐藏。不要尝试实时更新参数高亮和“当前参数”位置,因为光标在参数内的移动判定逻辑复杂且容易出Bug。参数提示做到“显示形参列表”这个粒度就足够了。

5. 避坑:.NET 4.0下的Roslyn版本、缓存和内存泄漏排错

5.1 现象:启动报错BadImageFormatException或直接崩溃

原因:引用了新版Roslyn的NuGet包,但目标框架是.NET 4.0,运行时无法加载编译目标为.NET 4.7.2的程序集。解决方法是把包版本降级到1.3.1或更高但仍在net40支持范围内的版本,并在web.config或app.config里加好绑定重定向。具体操作为项目配置文件里添加:

<dependentAssembly> <assemblyIdentity name="Microsoft.CodeAnalysis.CSharp" publicKeyToken="31bf3856ad364e35" culture="neutral" /> <bindingRedirect oldVersion="0.0.0.0-1.3.1.0" newVersion="1.3.1.0" /> </dependentAssembly>

5.2 现象:补全列表一直空白,GetCompletionsAsync返回空集

原因:常见有两种,一是Document的文本不是最新内容,光标位置和文本行数对应不上,因为用户删除了某行导致位置偏移;二是MetadataReference缺少关键程序集,比如没加System.Runtime。解决方法是每次触发补全前先检查_document.GetTextAsync().Result.ToString()与编辑框当前文本是否一致,不一致就先用WithText覆盖,再计算位置。另一个排查点:如果项目里同时存在多个版本的Roslyn,CompletionService.GetService拿到的实例可能是旧版的,需要检查是否有程序集冲突。

5.3 现象:编译成功后脚本执行却找不到类型

原因:脚本源码里的类名和ScriptRunner里的固定类名不匹配。比如Run方法里查找“ScriptProgram”,但用户写的类叫“MyProgram”。解决方式是把入口方法名和类名做成参数传入,或者在编译前,用SyntaxTree.GetRoot()的DescendantNodes()分析出所有的类声明,让运行器自动选择包含入口方法的类型:

var root = syntaxTree.GetRoot(); var classDecl = root.DescendantNodes() .OfType<ClassDeclarationSyntax>() .FirstOrDefault(c => c.Identifier.ValueText.Contains("Script"));

这里取FirstOrDefault的自适应逻辑是一个稳妥兜底方案:只要类名里包含“Script”字样就能命中,完全避免用户改类名导致宿主反射失败。

5.4 现象:编辑器使用久了内存暴涨

原因:每次文本变化都新建Document和Compilation,旧对象没有释放;尤其是GetCompletionsAsync每次返回后,CompletionList里的符号列表会持有大量ITypeSymbol引用。解决方式是全局只保留一个AdhocWorkspace,文本变化时复用同一个DocumentId;补全完成后,立即将completionList置空,并调用GC.Collect(在空闲时)。另一个常用的优化是只在光标停留超过300毫秒才触发语义分析,避免连续击键时产生大量垃圾。

6. 把编辑器接到你的业务里:宿主调用、权限沙箱与性能调优

6.1 脚本与宿主的类型交互:暴露对象给脚本调用

编辑器本身只是输入工具,真正的价值是让脚本调用宿主业务逻辑。为了实现“脚本里能操作主程序的对象”,需要把宿主实例作为全局变量传给脚本。常见做法是创建一个ScriptGlobals类,通过修改CSharpCompilationOptions的ScriptClassName特性实现,或者更直接地把脚本源码做字符串拼接:在用户代码前面插入一行dynamic host = null;,然后运行时给脚本注入宿主引用。但Runtime中更推荐用“静态实例”方案:在ScriptRunner中定义一个静态字段存放宿主上下文,脚本通过HostContext.Instance访问。这个方案避免了在脚本顶部拼接代码带来的语法压力,也方便后续加权限控制。

6.2 安全边界:不允许脚本碰文件系统和网络

脚本编辑器一旦暴露给用户,就相当于在程序里开了后门。即使是在公司内部的上位机工具里,也要做沙箱。最简单的分层隔离是限制引用的程序集——_references里不包含System.IO.FileSystem和System.Net.Http,脚本自然用不了文件读写和网络请求。如果你的脚本确实需要访问某些文件,不要直接给File类,而是通过宿主提供白名单方法,比如HostContext.Instance.LoadConfig(string filePath),在方法内部校验路径是否处于允许目录。另一个思路是用AppDomain隔离,把脚本跑在单独的AppDomain里并配合PermissionSet,避免脚本恶意调用Environment.Exit把宿主进程一起结束。

6.3 性能调优:异步编译与缓存解析结果

脚本编译是一个耗时操作,如果用户在编辑器里敲击频繁,不能每次都重新编译整个程序集。常见做法是“编辑时只做语法检查和语义高亮”(这部分Roslyn有增量缓存),只有点击“运行”按钮时才走完整编译链路。我在实际项目里会再加一层“代码片段模板”机制:如果编辑器里只有一行表达式,比如DateTime.Now.ToString("yyyy-MM-dd"),则走轻量级表达式编译,不生成完整程序集。做法是判断当前文本是否包含类声明或方法声明,如果没有,就用CSharpScript.EvaluateAsync(Roslyn的脚本API,注意它同样有net40版本兼容问题)直接执行表达式并返回结果。

6.4 验证补全效果的几条基准测试

做完了脚本编辑器,怎么证明智能提示是“真智能”而不是“碰巧能用”?我一般跑四组验证用例。第一组:声明一个List<int>,输入list.后确认补全项包含Add、Where、Select,且不包含Split这类字符串方法。第二组:在类里写一个私有方法,输入方法名前两个字母,确认私有成员能出现在补全列表。第三组:输入System.确认命名空间下的类型出现在提示中。第四组:故意输入一个错误类型名,确认补全结果为空且编译错误能正确标记。这四组覆盖了成员补全、私有成员可见性、命名空间递归和错误处理四个维度,过不了任何一组,说明语义模型链路还有断点。

最后说一句自己的习惯:我每次拿到别人的脚本编辑器源码,第一件事不是打开界面看效果,而是先查它的Roslyn引用版本和初始化顺序,因为大部分翻车都出在这两处。这个项目能做到在.NET 4.0上带智能提示地把脚本运行起来,整个方案的工程取舍是经得起推敲的,值得你花一个下午把源码跑通并接到自己的上位机或自动化工具里。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询