简介面向.NET开发者的文本编辑器完整实现整合AvalonEdit、NRefactory与Roslyn三大组件解决跨Framework与.NET Core环境下的代码编辑、智能提示及动态编译需求。包内共620个文件其中271个C#源码承载主要逻辑89个程序集为依赖项22个xshd文件用于语法高亮规则定义XAML界面布局与JSON配置配合完成交互呈现压缩包13.69MB目录按功能模块划分可快速定位编辑器核心、代码解析与编译相关代码。已有747人学习/下载。整套工程覆盖编辑器基础交互选中、复制粘贴、撤销重做与语法高亮NRefactory模块负责解析上下文并提供代码完成提示Roslyn模块则弥补CSharpCodeProvider仅支持Framework的不足给出可同时运行于Framework与.NET Core的动态编译实现。适合正在构建代码编辑器、IDE插件或需要动态编译能力的.NET工程师通过阅读源码理解三大组件如何协同工作也可直接改造现有功能或集成到自有工具链中省去从零搭建底层框架的繁琐工作获得完整可运行的参考范本。1. 三个库拼一个内嵌C#脚本编辑器Roslyn动态编译踩过的坑某个内部工具要加“用户自定义过滤规则”功能运营同事在界面上配置一段过滤逻辑用 C# 表达式写完点一下运行就要出结果。先入为主的思路是自己实现一个带代码高亮的编辑器再挂一套动态编译——真动手才发现方向反了。AvalonEdit 文本编辑器组件管界面NRefactory 负责代码提示Roslyn 动态编译负责把用户写的代码编译成内存程序集并执行三个库各干各的活拼起来就是一个内嵌的 C# 脚本编辑器。项目跑在 NetCore 上用 AssemblyLoadContext 对每次运行做隔离避免脚本拖垮宿主进程。这篇文章从技术选型、工程结构、逐段代码讲到避坑记录适合正在做代码生成器、规则引擎配置器或报表计算器这类工具的开发者。照完这篇文章你也能在自己的界面里嵌入同样一套编辑器用户写完就能跑不用求着开发改规则发版。2. 技术选型与工程结构三层组件分工目录先拆到文件2.1 技术选型AvalonEdit做界面、NRefactory做补全、Roslyn做编译整个项目把职责拆成三层而不是找一个大而全的控件硬塞。AvalonEdit 负责编辑器外观和交互NRefactory 负责在当前文档上算补全列表Roslyn 只做最后一公里的编译执行。这样每一层都能单独替换也方便定位问题。组件负责的事关键点AvalonEdit编辑器外观、行号、高亮、折叠、光标、CompletionWindow大文本渲染性能好自带补全弹窗组件NRefactory解析当前文档计算补全列表和替换范围进程内完成不起子进程、不依赖语言服务器Roslyn把源代码编译为内存程序集反射调用入口方法官方编译器平台直接跨 .NET CoreNetCore宿主运行时提供 AssemblyLoadContext 隔离配合 isCollectible 才能卸载脚本程序集常见做法是用 Roslyn 的 CompletionService 把补全和编译都接管但那样要维护 Workspace 的文档同步编辑器里每次文本变化都要更新文档状态小工具里偏沉。NRefactory 的组合轻很多它直接把当前文本当成一个 CSharpFile 喂进去算完就丢。Roslyn 在这里只负责“把字符串变成能跑的代码”分工非常干净。2.2 目录结构一条条链路能单独替换工程按“编辑器、补全、执行”三条链路拆目录而不是按页面拆。这样改高亮不会碰到补全换编译器也不动 UI。文件职责Editor/EditorView.xamlAvalonEdit 文本编辑器控件外观、按键绑定Editor/EditorViewModel.cs文本内容、光标偏移、折叠状态Completion/CompletionProvider.cs对 NRefactory 的补全数据源封装Completion/CompletionTrigger.cs监听输入和光标弹 CompletionWindowExecution/ScriptCompiler.csRoslyn 编译入口产出内存程序集Execution/ScriptRunner.cs加载程序集、反射调用入口方法Execution/OutputSink.csConsole 输出重定向到输出区Models/BuildResult.cs编译诊断、耗时、运行结果的统一模型Services/ScriptAssemblyLoadContext.csAssemblyLoadContext 隔离容器这个结构里最值得抄的是 Execution/OutputSink.cs 独立出来。脚本里的 Console.WriteLine 默认写到哪里是不确定的重定向逻辑如果散落在窗体代码里第二次做类似功能又要重新翻一遍。独立成类之后任何一个 TextWriter 场景都能复用。2.3 初始化顺序先编辑器、再补全、后执行器public MainWindow() { InitializeComponent(); // 1. 初始化AvalonEdit行号、高亮、折叠 editorView.Initialize(); // 2. NRefactory补全挂到编辑器文档上 completionProvider.Attach(editorView.CodeEditor); // 3. 创建编译与执行对象按钮事件绑定到命令 scriptCompiler new ScriptCompiler(); scriptRunner new ScriptRunner(OutputSink.Instance); }顺序有讲究。CompletionProvider.Attach 内部会注册 TextChanged、Caret.PositionChanged 这些事件所以要访问编辑器 DocumentAvalonEdit 在 Initialize 完成之前 Document 是 null先挂事件会丢事件甚至直接空引用。常见做法是把编辑器的外观初始化全部收敛到 EditorView.Initialize 里MainWindow 只关心三个步骤的先后不关心控件细节。运行按钮的链路同样清晰按钮点击触发 ScriptCompiler.Compile(editorView.Text)拿到 BuildResult 后交给 ScriptRunner.RunAsync最终由 OutputSink 刷新输出区。编译和运行完全分离出问题时先看是编译阶段还是运行阶段黑匣子效应小很多。3. AvalonEdit编辑器搭建行号、高亮、折叠与文本事件3.1 NuGet与XAML基础配置引入 AvalonEdit 包之后编辑器本体就是一个控件的事省去和原生文本框较劲的时间。我的 XAML 配置长这样avalonedit:TextEditor x:NamecodeEditor FontFamilyConsolas FontSize14 ShowLineNumbersTrue HorizontalScrollBarVisibilityAuto VerticalScrollBarVisibilityAuto Padding8 SyntaxHighlightingC# WordWrapFalse /参数说明ShowLineNumbers 是行号显示报错时用户能对着行号找代码SyntaxHighlighting 直接指定 C# 高亮AvalonEdit 内置了一套 C# 规则不需要自己写正则高亮。WordWrap 设成 False 是刻意的脚本代码通常讲究格式对齐换行会把缩进视觉搞乱。HorizontalScrollBarVisibility.Auto 保证长表达式不会挤成一团。血泪经验是容器约束AvalonEdit 不能用 ScrollViewer 直接包。它内部自己处理滚动放到 ScrollViewer 里反而会无限撑高文本一多直接卡成幻灯片。给它一个确定高度的 Grid 行或 DockPanel 区域即可几乎所有“编辑器消失了”的诡异问题都出在这个容器结构上。3.2 折叠默认花括号折叠与自定义#region折叠AvalonEdit 自带 BraceFoldingStrategy 处理花括号折叠但内部工具的脚本模板经常用 #region 分组默认策略不认识。我一般会继承 AbstractFoldingStrategy 自己实现一版public sealed class RegionFoldingStrategy : AbstractFoldingStrategy { public override void UpdateFoldings(FoldingManager manager, TextDocument document) { var foldings new ListNewFolding(); foreach (var line in document.Lines) { var content document.GetText(line.StartOffset, line.Length); int regionIndex content.IndexOf(#region, StringComparison.Ordinal); if (regionIndex 0) continue; var startOffset line.StartOffset regionIndex; var endOffset FindRegionEndOffset(document, line); foldings.Add(new NewFolding(startOffset, endOffset, true)); } manager.UpdateFoldings(foldings, -1); } }逻辑说明逐行扫文档找到含 #region 的行把折叠起点放在 #region 文本的起始偏移再向后找 #endregion 作为终点没找到就一路折到文档末尾。NewFolding 构造函数里最后的 true 表示默认折叠好模板文件的头部说明区域一打开就是收拢的。关键参数是 manager.UpdateFoldings(foldings, -1) 的 -1。它表示强制从第 0 行更新到文档末尾确保删除或插入行后折叠标记跟着重新计算。不传这个值的话折叠标记可能滞留在旧位置出现“折起来却露出一块空白的怪东西”这种玄学现象。另外千万不要在整篇文本字符串上找括号偏移必须按 document.Lines 循环否则文本一变折叠就错位。3.3 文本变更与光标事件驱动补全的源头编辑器的文本变化和光标移动是后续补全模块的输入源这一步先把事件喂出去codeEditor.Document.TextChanged (s, e) { completionTrigger.OnTextChanged( codeEditor.Document.Text, codeEditor.CaretOffset); }; codeEditor.TextArea.Caret.PositionChanged (s, e) { completionTrigger.OnCaretMoved(codeEditor.TextArea.Caret.Offset); };逻辑说明TextChanged 里把最新全文和光标偏移传给补全触发器补全模块需要知道“当前打了什么前缀、光标落在哪个位置”才能算候选。Caret.PositionChanged 是光标移动时触发用户用方向键在成员名后面移动时提示窗口的位置要跟着刷新。注意 e 参数里拿不到新文本AvalonEdit 的 TextChanged 事件参数只告诉你“文本变了”这个事实具体内容必须读 codeEditor.Document.Text。常见误用是试图从 e.NewValue 或类似属性取增量内容实际上那个属性根本不存在编译直接报错。文本全部重传有种笨重感但内部工具的脚本体量很小性能完全不是问题。4. NRefactory代码提示补全数据源、触发时机与插入规则4.1 把当前编辑器文本变成补全数据源NRefactory 的补全引擎需要一个“工程”上下文工程里要有引用的程序集和当前源码文件。我封装了一个创建方法private CSharpCompletionEngine CreateCompletionEngine() { var refs MetadataReferenceService.GetSharedReferences(); var projectContent new CSharpProjectContent() .SetAssemblyReferences(refs) .AddOrUpdateFiles(new CSharpFile(Script.cs, new StringSource(codeEditor.Text))); var contextProvider new SimpleCompletionContextProvider(); return new CSharpCompletionEngine( codeEditor.Document, projectContent, contextProvider); }逻辑说明CSharpProjectContent 代表一个虚拟工程SetAssemblyReferences 传入程序集引用集合AddOrUpdateFiles 把编辑器当前文本注册成一个名为 Script.cs 的源文件。CSharpCompletionEngine 拿到这份工程后就能在任意文档偏移上计算补全候选。参数说明refs 是 MetadataReference 的列表来源是扫描 AppContext.BaseDirectory 下的 System.*.dll 和项目自身引用抽成 GetSharedReferences() 是为了让补全和 Roslyn 编译共用同一套引用这条后面避坑章节还会重点讲。SimpleCompletionContextProvider 是小项目里最省事的实现它实现 ICompletionContextProvider返回当前 UsingScope 和类型解析结果NRefactory 官方示例里有现成模板可以直接抄。补全数据源必须跟着文本实时更新每次文本变化后重新 AddOrUpdateFiles。否则用户已经打了三百行代码补全引擎还在用第一次打开时的空文档计算候选列表永远是空的。我一般把 CreateCompletionEngine 放到一个 UpdateProjectContent 方法里TextChanged 时一起调用。4.2 弹窗的触发与替换范围当用户在符号中间或成员访问符后输入时触发补全弹窗private void ShowCompletion(int offset) { var completions engine.GetCompletionList(offset, out var suggestedWord); if (completions null || !completions.Any()) return; if (completionWindow ! null) completionWindow.Close(); completionWindow new CompletionWindow(codeEditor.TextArea); completionWindow.CompletionList.CompletionData.AddRange( completions.Select(ToCompletionData)); completionWindow.StartOffset suggestedWord.StartOffset; completionWindow.EndOffset suggestedWord.EndOffset; completionWindow.Show(); }逻辑说明GetCompletionList 在指定偏移处算出候选列表同时返回一个 suggestedWord它标记了“当前正在输入、要被替换的单词范围”。比如用户打了 Con这个范围就是光标往前 3 个字符的位置选中补全项后编辑器只替换这个范围不会把前面已经存在的标识符吞掉。参数说明StartOffset 和 EndOffset 是文档绝对偏移不是行号和列号。最容易翻车的是这两处不设置CompletionWindow 默认按“光标前一个单词”替换。对单单词补全没问题但对“命名空间.类.成员”这种多点表达式默认替换范围常常把前面已经写好的完整类名截断一选中补全项半个表达式就被吃掉了。4.3 选中即插入自定义补全数据NRefactory 返回的补全条目要适配成 AvalonEdit 能消费的 ICompletionDatapublic sealed class ScriptCompletionData : ICompletionData { public string Text { get; set; } public object Content Text; public object Description { get; set; } public double Priority { get; set; } public void Complete(TextArea textArea, ISegment completionSegment, EventArgs insertionRequestEventArgs) { textArea.Document.Replace(completionSegment, Text); } }逻辑说明Complete 是补全项被选中时的落地方法默认行为是用 Text 原样替换补全段。Content 是弹窗里显示的文本Description 是鼠标悬停时的说明文字可以在里放方法签名。想升级成代码片段补全的话在这里做手脚就行。比如输入 if 回车后想要直接补成 if (条件) { }Complete 里就不能简单地 Replace而是先替换模板文本再把光标塞到括号中间最后通过 textArea.Caret 设置新位置。NRefactory 对关键字补全给的是原始关键字换不了模板所以模板逻辑必须在这一层补。4.4 弹窗体验的参数调优弹窗出来不等于体验好还有几个参数值得调。触发长度方面我一般限制在 1 个字符或点号后触发避免用户刚敲一个字母就弹窗干扰阅读。大小写匹配上C# 本身区分大小写但补全列表可以做成前缀不敏感用户打小写开头能搜到大写开头的成员体验更顺手。团队里如果有新人建议把关键字补全关掉只保留类型和成员补全否则一敲 keyword 就弹一堆候选列表长到没耐心看。最常见的体验问题还是替换范围。命名空间补全和成员补全的 suggestedWord 长度不一样命名空间会重写整段点号分隔的标识成员补全只需替换最后一个单词。每次改完补全逻辑先把这段点号表达式的场景测一遍多数翻车都发生在这里。5. Roslyn动态编译与运行沙箱避坑实录与排查思路5.1 编译执行链路从源代码到内存程序集动态编译的关键一步是把用户代码包装成合法的编译单元。我不会让用户写完整的类声明而是预埋一个入口壳子用户只写方法体里的内容public BuildResult Compile(string userCode) { string wrapped $ using System; using System.Collections.Generic; using System.Linq; public static class ScriptEntry {{ public static object Run(ObjectContext ctx) {{ #line 1 UserScript {userCode} #line default }} }}; var tree CSharpSyntaxTree.ParseText(wrapped, new CSharpParseOptions(LanguageVersion.CSharp9)); using var ms new MemoryStream(); var emit CSharpCompilation.Create( UserScript_ Guid.NewGuid().ToString(N), new[] { tree }, MetadataReferenceService.GetSharedReferences(), new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, optimizationLevel: OptimizationLevel.Debug) ).Emit(ms); if (!emit.Success) { return BuildResult.FromDiagnostics(emit.Diagnostics .Where(d d.Severity DiagnosticSeverity.Error).ToList()); } return BuildResult.FromSuccess(ms.ToArray(), emit.Diagnostics); }逻辑说明wrapped 是拼出来的完整源码用户写“2 3”或一段循环都能直接塞进 Run 方法体。#line 1 UserScript指令把编译错误的行号映射回用户代码的原始行报错时用户看到的是自己在编辑器里写的行号而不是壳子的行号——这个细节不做的话所有报错行号都偏到十几行以后用户完全对不上位置。参数说明LanguageVersion.CSharp9 决定用户能用哪些新语法switch 表达式、record 都取决于这一项OutputKind.DynamicallyLinkedLibrary 表示产出 dll 而不是 exe只有 dll 才能直接加载进内存。OptimizationLevel.Debug 会让编译产物携带调试信息报错堆栈更完整脚本场景完全不差这点性能我一般不开 Release。编译成功后的加载与调用public async TaskRunResult RunAsync(BuildResult build) { if (build.HasError) return RunResult.FromError(build.Diagnostics); await Task.Run(() { var context new ScriptAssemblyLoadContext(); using var ms new MemoryStream(build.ILBytes); var assembly context.LoadFromStream(ms); var type assembly.GetType(ScriptEntry); var method type.GetMethod(Run, BindingFlags.Public | BindingFlags.Static); try { object result method.Invoke(null, new object[] { ctx }); _sink.WriteLine(result?.ToString() ?? null); } catch (TargetInvocationException ex) { _sink.WriteLine(异常堆栈: ex.InnerException?.StackTrace); } finally { context.Unload(); } }); }逻辑说明整个加载与调用的过程塞进 Task.Run避免编译和反射调用阻塞 UI 线程脚本里写个死循环也不会把界面卡死。context.LoadFromStream 从内存加载程序集然后通过反射找到 ScriptEntry.Run 并调用。参数说明TargetInvocationException 必须拆一层。反射调用时用户代码抛出的异常会被包在 TargetInvocationException 里面直接输出 ex.ToString() 只能看到反射调用失败真实堆栈在 InnerException 里。finally 里调用 context.Unload() 是卸载程序集的关键配合下一节的隔离容器脚本跑完内存能收回来。5.2 避坑一Console.WriteLine 不显示现象脚本里写了 Console.WriteLine(hello)程序正常结束界面输出区一片空白。原因控制台输出默认走进程的控制台句柄WPF 应用本来就没有控制台。就算重定向了 Console.Out如果 OutputSink 只在 WriteLine 结束时才刷界面或者多个脚本共用同一个 sink 时上一个脚本的残留被清掉都会看到“跑了但没输出”的假象。解决自定义一个 TextWriter缓冲到换行再批量刷新 UI并保证每次运行前重置缓冲public sealed class OutputSink : TextWriter { private readonly StringBuilder _buffer new(); public override void Write(char value) { _buffer.Append(value); if (value \n) FlushToUI(); } public override void Write(string value) { _buffer.Append(value); if (value.EndsWith(\n) || value.Contains(\n)) FlushToUI(); } public override Encoding Encoding Encoding.UTF8; private void FlushToUI() { // 用Dispatcher抛给界面线程追加到输出区 Dispatcher.CurrentDispatcher.Invoke(() { OutputText _buffer.ToString(); _buffer.Clear(); }); } }逻辑说明TextWriter 必须实现 Encoding 属性和 Write 方法。Write(string) 是 Write(char) 的批量入口两个重载都要处理。每次运行前调用 _buffer.Clear() 和 Console.SetOut(OutputSink.Instance)确保上一个脚本的输出不会串到这次。参数说明Encoding 是抽象属性返回 UTF8 就行影响中文注释和输出内容的编码。FlushToUI 里用 Dispatcher 抛到界面线程因为脚本可能在后台线程执行直接改 UI 集合会抛跨线程异常。5.3 避坑二内存只涨不跌程序集卸载不掉现象连续运行几十个脚本后进程内存翻了快一倍。同一个脚本反复运行也会涨因为每次编译都会产生一份新的元数据。原因普通 Assembly.Load 加载的程序集只能随进程退出卸载。脚本程序集只要加载一次就占住元数据内存加载一千次就是一千份GC 也收不走。解决用可收集的 AssemblyLoadContext 做隔离容器public sealed class ScriptAssemblyLoadContext : AssemblyLoadContext { public ScriptAssemblyLoadContext() : base(isCollectible: true) { } protected override Assembly Load(AssemblyName assemblyName) null; }逻辑说明isCollectible: true 是核心参数它让这个加载上下文支持卸载。每次运行都 new 一个 ScriptAssemblyLoadContext程序集加载进这个私有上下文运行完调用 Unload()GC 就能回收整份元数据。Load 方法返回 null 表示不拦截依赖解析脚本程序集引用的 System.* 直接走默认探测路径和宿主共享这样才不会被重复加载。注意 Unload() 不是立刻生效要等没人引用这个 context 里的对象才能回收。运行完马上看内存发现还在涨是正常现象等 GC 跑一两轮再量看到回落就说明卸载机制生效了。这点不注意容易误判成“内存泄漏”然后写出更复杂的清理代码。5.4 避坑三提示都有编译报“当前上下文中不存在名称”现象编辑器里输入 List、Console、Math 都有智能提示一点运行编译报一串“当前上下文中不存在名称 List / Console / Math”。原因补全数据源和编译引用集合是两套代码。补全扫了 AppContext.BaseDirectory 下的 System.*.dll编译却只手工引用了两三个程序集两边不一致。项目小的时候不觉得一旦加了第三方库漏引用就成了编译错误的头号来源。解决把 MetadataReference 集合抽成单例服务补全和编译共用一个 GetSharedReferences()。加新引用只改这一处补全和编译永远保持一致。我一般在这个方法里扫描 BaseDirectory 下的 System.*.dll再附加当前项目已经加载的程序集列表两边任意一边提出需求就补在同一个列表里。5.5 排查思路从报错现象反推问题环节动态编译最大的问题是“黑匣子”报错来源有好几个环节我一般按现象反推。编译错误集中在行号映射和引用集合检查#line是否生效、GetSharedReferences 是否覆盖了新增程序集运行错误看 TargetInvocationException 的 InnerException那里才是用户代码的真实堆栈界面卡顿十有八九是编译执行没进 Task.Run。断点不生效不用纠结脚本程序集是从内存加载的没带 PDB 符号调试器本来就钩不上项目里靠 OutputSink 打日志定位脚本逻辑比挂调试器靠谱。6. 二次开发与改造技巧把脚本编辑器改成自己的工具6.1 三个可以立刻动手的改造方向第一个方向是加“表达式模式”。现在用户写的是方法体写“2 3”没问题但想要“直接拿返回值做后续流程”的场景就不够用。改造方式是在壳子里多加一个入口解析用户代码里是否包含分号没有分号就包一层return (用户代码);有分号就原样放进 Run。这样一套编辑器同时支持“计算式”和“指令式”两种写法规则配置工具特别需要这个。第二个方向是做编译缓存。脚本工具最明显的卡顿来自重复编译同样的代码。我一般会对源码文本做哈希存一个 Dictionary哈希, BuildResult每次点运行先比对哈希再决定要不要重新编译。缓存命中时要同时比对源码长度防止哈希碰撞带来的错误复用这个双条件校验几乎不花成本。第三个方向是把三层服务接口化。把编辑器、补全、执行分别抽象成 IEditorHost、ICompletionService、IScriptExecutorNRefactory 补全就可以换成 Roslyn Workspace 补全AvalonEdit 也可以换成任何控件。前期不用做等出现“我要给编辑器加另一个语言”的需求时再动收益最大。6.2 一个验证方法与一个习惯验证这套编辑器我一般只跑五组脚本纯表达式、多行语句循环、带中文注释、引用外部 Dll 里的 API、故意写错的语法。五组一起过编译链路、运行链路、输出重定向、错误映射这四个环节就都覆盖了。从那以后我每次给编辑器加新能力都强制先画一遍输入和输出的数据流图谁产生文本、谁消费文本、谁吃结果。这个小习惯省掉的返工比我多写的代码值钱多了希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?