
简介一款面向C#开发者的智能脚本编辑器源码基于VS2015编写支持.NET 4.0。核心亮点在于代码颜色显示与智能提示不仅内置标准库联想还能在加载自定义类及自定义库后对字段、属性实现类似VS编译器的自动补全适合需要为自动化设备或平台增加脚本能力的工程师使用。资源共76个文件大小1.89MB以cs源码、dll库、exe可执行程序为主还包括config配置、resx资源及项目工程文件便于直接编译学习和二次开发。压缩包内含完整Demo包含ScriptProvider、Form1等模块并附有自定义测试类MyTest及TestDll可快速验证自定义对象提示效果。已有1090人学习下载。对于希望搭建灵活脚本系统、减少平台频繁改动的团队该源码提供了一套经过项目实践验证的参考实现。1. 智能C#脚本编辑器为什么上位机调试总差这一步做C#上位机的朋友应该都有过这种经历现场设备跑着逻辑有点问题你改了算法想验证按F5重新编译整个程序退出再起来连接重新握手变量重新初始化一折腾就是几分钟。如果现场还不能停就更折腾。后来我接触到这种带智能提示的C#脚本编辑器源码思路一下子打开了——把C#当脚本语言用运行时改代码、运行时执行不用重启主程序。这套资源的核心是把C#脚本编辑器、智能提示和.NET 4.0环境绑在一起不依赖重型的IDE就能在软件内部写C#代码并直接执行。它适合三类人一是工控和上位机开发者想把调试逻辑做成可插拔二是做工具类软件、想给用户开放一点定制能力的人三是想研究编译器前端和代码补全实现原理的C#学习者。这篇笔记就把它的实现思路、跑通步骤和真实踩过的坑摊开讲你拿到的源码包怎么盘活它看完心里有数。2. 动态编译不是魔法从CSharpCodeProvider到运行时加载2.1 先把最小可运行链路跑起来我拿到这类源码的第一件事不是看智能提示那部分而是先找动态编译入口。绝大多数C#脚本编辑器源码的核心不是Roslyn而是System.CodeDom.Compiler命名空间下的CSharpCodeProvider说通俗点就是字符串编译。源码里一般会有一个类似ScriptEngine的类把脚本代码、引用的程序集、编译参数打包进CompilerParameters调用CompileAssemblyFromSource最后从生成的程序集里反射出入口方法执行。using System; using System.CodeDom.Compiler; using System.Reflection; using Microsoft.CSharp; public class ScriptEngine { public object Run(string scriptCode, string[] assemblyPaths, string entryMethod Main) { var provider new CSharpCodeProvider(); var parameters new CompilerParameters { GenerateInMemory true, // 编译结果只驻内存不落盘 GenerateExecutable false, // 编译成DLL而非EXE TreatWarningsAsErrors false // 警告不拦截新手期别给自己找麻烦 }; foreach (var path in assemblyPaths) parameters.ReferencedAssemblies.Add(path); var results provider.CompileAssemblyFromSource(parameters, scriptCode); if (results.Errors.HasErrors) throw new Exception(BuildErrorText(results.Errors)); var assembly results.CompiledAssembly; var type assembly.GetType(DynamicScript.Program); var method type.GetMethod(entryMethod, BindingFlags.Static | BindingFlags.Public); return method.Invoke(null, null); } private string BuildErrorText(CompilerErrorCollection errors) { var sb new System.Text.StringBuilder(); foreach (CompilerError error in errors) sb.AppendLine($行号: {error.Line}, 错误: {error.ErrorNumber}, {error.ErrorText}); return sb.ToString(); } }这段代码的逻辑是把一段C#源码字符串直接编译成内存中的程序集然后通过反射找到入口方法执行。CompilerParameters几个参数要理解GenerateInMemory为true时编译产物不写磁盘适合编辑器场景GenerateExecutable设为false是编译成类库ReferencedAssemblies是引用列表不加依赖DLL的话脚本里就算写了using也照样报“找不到类型”。另外要注意CompileAssemblyFromSource是同步阻塞的脚本长或引用多时UI会卡顿很多源码没处理这一点自己用的时候可以放到后台线程。2.2 引用与程序集解析没引用就没一切源码包里引用的DLL名单是调试脚本编辑器时最容易翻车的地方。新手往往只在ReferencedAssemblies里加了System.dll和System.Core.dll结果脚本里一句“System.Windows.Forms.MessageBox.Show”直接编译失败。实际需要哪些引用取决于脚本里用到了哪个命名空间我一般会把主程序所在的目录扫一遍把用到的业务程序集全加进去。private Liststring CollectReferences() { var baseDir AppDomain.CurrentDomain.BaseDirectory; var names new[] { System.dll, System.Core.dll, System.Windows.Forms.dll, System.Drawing.dll, YourBusiness.dll, YourControls.dll }; return names.Select(n Path.Combine(baseDir, n)).ToList(); }这里有个小坑某些程序集是间接依赖的比如你的界面库依赖Newtonsoft.Json脚本里没直接用Json但程序集加载时会去找。我的做法是在AppDomain.CurrentDomain.AssemblyResolve事件里做兜底找不到程序集时按根目录和子目录回退搜索。很多脚本编辑器源码不处理这个事件首次集成到大型项目时就会冒出一堆“无法加载文件或程序集”的异常。2.3 选型理由为什么.NET 4.0的源码方案绕不开CodeDom可能有人会问现在写C#为什么不直接用Roslyn这个源码包的定位就是支持.NET 4.0而Roslyn的官方NuGet包在.NET 4.0项目上集成成本高、体积大很多工控电脑还处于离线环境装个包都费劲。CodeDom的优势是从.NET Framework 2.0就有了老环境直接可用智能提示和高亮可以自己写。代价就是编译速度比Roslyn慢而且错误信息是纯文本的不像Roslyn能给出结构化的语法树。所以拿到这套源码先看清它的编译层用的是CodeDom还是Roslyn再决定怎么改。如果是CodeDom版本重点优化Cache——同一个脚本文本缓存编译结果只有内容变化时才重新编译这条必须写进自己的改造清单。3. 智能提示把编辑器变成半个Visual Studio3.1 数据收集文本扫描与作用域识别源码里最值得研究的是智能提示这块。它的基本流程是编辑器失焦或按下触发键之后先扫描光标左侧的文本提取出当前正在输入的标识符前缀。比如光标前是“textBox1.Tex”那就需要把“textBox1”当成目标对象识别出成员名以“Tex”开头的都给出来。很多实现用的是简单的字符串截取和正则匹配不会去构建完整语法树这在.NET 4.0场景下反而是合理取舍。private string ExtractCurrentIdentifier(string line, int caretPosition) { var beforeCaret line.Substring(0, caretPosition); var match Regex.Match(beforeCaret, ([A-Za-z_][A-Za-z0-9_.]*)$); return match.Success ? match.Value : string.Empty; }这个正则表达式匹配的是光标前最近的“字母开头、可带点和下划线”的连续串。提取出来后要判断最后一段是类名、变量名还是命名空间这决定补全列表从哪来。如果前缀最后一个点是“.”说明用户在访问成员这时要找到点前面的对象引用类型如果前缀是普通标识符那就从已输入的变量名集合和using命名空间里找。这一步是智能提示的灵魂源码里如果只做了简单的文本匹配补全准确率会很难看。3.2 候选词过滤与渲染补全列表的体验全在这拿到了前缀和类型下一步就是拿候选词。常规做法是用反射获取类型的公共属性和方法properties、fields、events、methods。这里必须做两件事过滤和缓存。过滤是根据前缀做不区分大小写的StartsWith匹配缓存是把反射结果按Type存进字典因为反射很慢每次按键都反射一次必然会卡。private DictionaryType, ListMemberInfo _memberCache new DictionaryType, ListMemberInfo(); private Liststring GetCompletionItems(Type targetType, string prefix) { if (!_memberCache.TryGetValue(targetType, out var members)) { members targetType.GetMembers(BindingFlags.Public | BindingFlags.Instance) .ToList(); _memberCache[targetType] members; } return members .Select(m m.Name) .Where(n n.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) .Distinct() .ToList(); }注意这里只取了公共实例成员静态成员和扩展方法要不要纳入看你的场景。如果你的脚本里大量使用Enumerable扩展方法获取成员时还要额外抓using程序集里的静态扩展类这是很多初学者没考虑到的地方。渲染侧补全列表用悬浮ListBox或ListView按上下列表移动、回车或Tab确认这些交互逻辑在源码里通常是一套单独的EditorControl类跑起来能否顺畅取决于候选词集合够不够小所以能过滤就先过滤。3.3 常见误用反射缓存没建、弹窗线程不对很多人在第一步就翻车是因为每次击键都实时反射结果就是敲一个字卡一下。另一个高频问题是在非UI线程更新补全列表控件WinForm的控件不允许跨线程访问源码里通常会做Invoke封装但二次开发时容易丢掉。还有一种情况是把全部成员一股脑塞进列表几百个候选项目用户根本看不过来按前缀过滤是必须的这一点源码如果没体现你可以自己补上这个逻辑。4. 把编辑器嵌进你的WinForm/WPF项目集成路径与参数配置4.1 控件选型RichTextBox自绘和整装控件的取舍源码包里大概率带了一个编辑器界面但对接你自己的WinForm项目时界面控件往往需要二次改造。常见做法有两种基于RichTextBox自己做行号和悬浮补全窗口或者直接内嵌一个现成的轻量编辑器控件。RichTextBox方案轻量、可控性强但括号匹配、高亮、行号这些全得手写。我拿到这种源码后通常会把编辑器的核心逻辑分成两层一层是文本缓冲与词法分析另一层是UI渲染这两层在对接时不能耦合太死否则换控件等于重写。public class EditorHost : UserControl { private RichTextBox _txtEditor; private ListBox _suggestBox; public void ShowSuggestions(Liststring items, int x, int y) { _suggestBox.Items.Clear(); _suggestBox.Items.AddRange(items.ToArray()); _suggestBox.Location new Point(x, y); _suggestBox.Visible items.Count 0; _suggestBox.BringToFront(); _suggestBox.TopIndex 0; } public string GetSelectedSuggestion() { return _suggestBox.SelectedItem?.ToString() ?? string.Empty; } }这个代码块展示了悬浮建议框的核心交互建议框是一个ListBox控件跟着光标位置弹出选中项可以被外部获取。参数Position是光标绝对坐标注意要把RichTextBox的GetPositionFromCharIndex换算成控件的屏幕坐标再设置。选完建议词后要把当前标识符替换成完整单词这个替换逻辑必须同时更新文本和光标位置两件事是分开处理的源码里容易漏掉光标更新导致用户一确认补全光标就跳错位置。4.2 快捷键、Tab补全与括号自动成对真正好用的脚本编辑器不能只依赖鼠标点选。源码里常见的交互绑定是CtrlSpace弹出补全、Tab或回车确认补全、括号自动成对插入。我的习惯是回车确认为主、Tab为次因为用户在写多行代码时回车还要换行如果回车一律确认补全写一行代码要按两次回车。这个逻辑上的微妙差别用起来体验差异很大。括号成对有个细节要注意自动插入右括号后光标落在括号中间但用户直接输入右括号时不应该再插入一个右括号否则会出现“))”。常见的做法是重写OnKeyDown当光标右侧已经是右括号且用户输入右括号时只移动光标不插入字符。这类交互细节在源码里一般会分散在各事件处理方法中二次开发时先找KeyDown事件处理方法的完整性。4.3 配置参数怎么调命名空间、引用程序集与超时脚本编辑器的能力边界全在配置里。这个源码包如果做得完整应该会有一个配置类集中管理三块内容配置项作用建议初始值AssemblyPaths脚本可引用的程序集路径集合主程序目录下全部业务DLLDefaultUsings脚本编译时自动追加的using语句System、System.Linq、System.Collections.GenericScriptTimeout单次脚本执行超时限制防止死循环10秒左右MaxOutputLength脚本输出信息的最大长度视场景而定通常1MB足够超时这块我多说一句。脚本是运行时编译执行的使用者写了个while(true)空转你的主程序直接卡死。很多源码没有超时机制执行操作丢在ThreadPool里就算完了。正确的做法是给执行任务包一层Task用WaitAsync或者轮询方式判断是否超时超时后至少要把运行标记置为中断再进一步就得做进程隔离——这个我在后面单独讲。5. 避坑/排查脚本编辑器集成常见问题记录5.1 现象第一次编译运行正常第二次执行抛FileLoadException原因两次编译生成的程序集虽然GenerateInMemory设置为true但程序集标识可能撞了。如果脚本内容一致编译器可能返回缓存的编译结果但第二次如果改了脚本文本新程序集加载时与旧程序集在同一个AppDomain里并存如果某个类型名和命名空间完全一致会触发程序集加载冲突。解决方法是每次编译前把上一次动态生成的程序集引用卸掉。代码上就是在重新编译之前调用AppDomain.CurrentDomain卸载不现实的最常见的出路是把动态执行放在一个独立子AppDomain里子域用完就卸掉。var setup new AppDomainSetup { ApplicationBase AppDomain.CurrentDomain.BaseDirectory }; var sandbox AppDomain.CreateDomain(ScriptDomain, null, setup); try { sandbox.DoCallBack(() { var engine new ScriptEngine(); engine.Run(scriptCode, assemblyPaths); }); } finally { AppDomain.Unload(sandbox); }AppDomain隔离是.NET 4.0下最现实的后悔药机制。脚本执行的所有类型加载都发生在sandbox里执行完卸掉整个域第二次编译不会再撞程序集。代价是跨域调用要用MarshalByRefObject或者序列化传参稍微复杂一点但比FileLoadException强多了。5.2 现象脚本执行死循环编辑器界面假死原因执行逻辑直接跑在UI线程同步调用或者放在ThreadPool里但没有取消机制。解决把脚本执行封装成独立后台线程并加入超时管理。真实血泪经验是超时就算是到了杀线程也是一个伪命题——Thread.Abort在.NET 4.0里能调但不可靠卡在非托管代码里照样杀不掉。所以设计上要前置防护在脚本里注入执行计数器或者限制循环次数更彻底的是做进程隔离。5.3 现象输入“textBox”时智能提示完全不弹原因编辑器识别当前上下文时认为“textBox”是一个标识符同时弹窗触发条件是字符变化或按下CtrlSpace如果你配置的TriggerCharacters没包含字母字母输入时不会触发补全。解决检查触发条件通常要包含字母数字和下划线。另外如果编辑器的变量表没做全局扫描它根本不知道当前作用域里存在一个叫textBox的东西补全列表当然为空。5.4 现象64位机器上引用32位DLL编译报错或运行时Bitness不匹配原因脚本编辑器所在的进程是AnyCPU或者x86而引用的底层通讯DLL是x64加载程序集时报BadImageFormatException。这个在工控领域非常常见西门子通讯库、CAN卡驱动都有位数要求。解决主程序的目标平台直接锁定和依赖DLL一致的位数别用AnyCPU。打开项目属性→生成→平台目标改成x64或x86同时确认CSharpCodeProvider本身没被NuGet版Roslyn影响——老的CodeDom不受位数影响但引用的参考程序集路径一定要指向正确的位数版本。5.5 现象路径里有空格编译总报“找不到源文件”原因脚本编辑器如果允许从文件加载脚本内容但拼接命令行参数时没加引号路径“D:\My Tools\script.cs”会被拆成两段。这种问题在源码里藏得很隐蔽因为你自己机器上的路径可能没空格。解决所有涉及文件路径的地方统一用Path.Combine拼接传参时给路径加双引号核心原则是不要手写路径字符串更不要用字符串相加去拼路径。6. 进程隔离与热更把编辑器的“后悔药”做成标配脚本编辑器做到能跑、能提示、能不崩其实只完成了一半。我更建议把动态执行这一步放到子进程里去主程序通过System.Diagnostics.Process拉起一个独立的小宿主进程脚本在子进程里编译执行结果通过标准输出回传。这样脚本再烂最多挂掉一个子进程主程序连眼皮都不眨。var process new Process { StartInfo new ProcessStartInfo { FileName ScriptHost.exe, Arguments $-code \{base64Code}\ -assembly \{string.Join(;, assemblyPaths)}\, UseShellExecute false, RedirectStandardOutput true, RedirectStandardError true, CreateNoWindow true } }; process.Start(); var output process.StandardOutput.ReadToEnd(); process.WaitForExit(5000); if (!process.HasExited) { process.Kill(); throw new Exception(脚本执行超时已强制终止宿主进程。); }这段做的是把脚本代码Base64编码后作为参数传给ScriptHost.exe在子进程里编译执行。Base64编码是为了避免命令行转义问题脚本里要是有引号、斜杠、$符直接拼命令行参数会乱套。RedirectStandardOutput和RedirectStandardError分别接输出和错误这样脚本的日志、异常信息都能回传给主程序。超时控制也简单粗暴了——WaitForExit(5000)没等到退出就Kill。从那以后我每次做编辑器类工具都会强制走一遍这条流程先确认动态程序集能被正确定位再验证子进程超时能被Kill最后检查宿主进程退出时不会残留半开的日志文件。这套习惯帮我挡住了很几次现场翻车。如果你正为脚本卡死或者程序集冲突头疼不妨先把资源下载下来按这个思路调一遍希望帮到你。本文还有配套的精品资源点击获取