
简介这套基于AvalonEdit、NRefactory与Roslyn构建的代码编辑器完整方案面向需要开发自定义文本编辑器或代码智能提示功能的.NET开发者解决了从基础编辑操作到跨框架动态编译的完整链路问题尤其适配同时兼容.NET Framework与.NET Core的项目场景。压缩包共620个文件、约13.69MB以cs源代码271个为主配套dll运行库、xshd高亮定义、xaml界面布局、json配置及editorconfig规范文件AvalonEdit负责编写、高亮、复制粘贴与撤回等基础功能NRefactory提供代码提示Roslyn替代仅支持Framework的CSharpCodeProvider实现双平台动态编译。已有745人学习下载。资源目录结构完整清晰含完整示例项目与配置文件适合中高级.NET开发者参考可直接在此基础上扩展定制编辑器功能。1. 一套老库新用的组合在 WPF 里做出带提示、能编译的编辑器多少人打开 VSCode 写 C 没有代码提示就放弃了转过头来想自己能不能做个轻量 IDE。可一旦真动手就会发现编辑器控件好找代码提示是个无底洞动态编译又是一道坎。AvalonEdit 文本器 NRefactory 代码提示 Roslyn 动态编译这个组合恰好把三条需求都接住了。AvalonEdit 负责文本编辑和语法高亮NRefactory 从源码里解析出补全数据Roslyn 把写好的 C# 代码直接编译成程序集并载入执行。适合的读者是那些想给内部工具做脚本面板、想给产品加一个“可编程宏”入口的 WPF 从业者。这套方案不需要你去啃 IDE 插件体系三个开源库叠加就能搭出一个能提示、能运行的最小闭环。2. AvalonEdit 搭编辑器底座最小可运行工程与语法高亮2.1 为什么选 AvalonEdit 而不是 RichTextBox 或 FastColoredTextBoxWPF 里做代码编辑器最容易想到的是 RichTextBox但真正写起来就发现它做文字排版可以做代码编辑器完全不是那块料。光标行列定位要自己算、行号要自己画、语法高亮要自己在 TextChanged 里跑正则性能还没法保证。AvalonEdit 本身就是为代码编辑设计的内置虚拟化的文本渲染几十万行的文件也能滚动不卡TextDocument 模型对批量修改和撤销栈的处理比 RichTextBox 的 FlowDocument 直观得多。FastColoredTextBox 虽然是老牌控件也支持高亮和自动补全但它在 WinForms 时代更顺手放到 WPF 里用总有点别扭而且它内置的补全引擎和 C# 的语法解析深度不够。AvalonEdit 的优势在于它和 NRefactory 本来就是同一套生态互相之间留了对接的口子后续做补全和解析时省去很多适配功夫。2.2 搭建最小编辑器从 NuGet 到可编辑窗口创建 WPF 工程后第一步是装包。打开 NuGet 包管理器搜索 AvalonEdit装最新稳定版即可。注意别和 ICSharpCode.AvalonEdit 的预发布版本搞混。装完在 XAML 里引命名空间然后放控件Window x:ClassScriptPad.MainWindow xmlnshttp://schemas.microsoft.com/winfx/2006/xaml/presentation xmlns:xhttp://schemas.microsoft.com/winfx/2006/xaml xmlns:avalonEdithttp://icsharpcode.net/sharpdevelop/avalonedit TitleScriptPad Height600 Width900 Grid avalonEdit:TextEditor x:Nameeditor FontFamilyConsolas FontSize14 ShowLineNumbersTrue HorizontalScrollBarVisibilityAuto VerticalScrollBarVisibilityAuto / /Grid /WindowTextEditor 控件一放上去就有基本的文本编辑能力光标移动、选区、剪切粘贴、CtrlZ 撤销这些都是内置的。ShowLineNumbers 打开后左边自动出现行号栏不需要额外画。这一步的核心是把 TextEditor 的 Document 属性理解清楚。它默认内置了一个 Document但你最好主动挂一个新的 TextDocument 实例这样后续无论做高亮还是做解析操作的都是同一个文档对象public partial class MainWindow : Window { private readonly TextDocument _document new TextDocument(); public MainWindow() { InitializeComponent(); editor.Document _document; editor.Text using System;\n\nclass Program\n{\n static void Main()\n {\n Console.WriteLine(\Hello\);\n }\n}\n; } }这里 _document 是整个编辑器的数据核心。AvalonEdit 的架构是 Document 持有文本内容并触发变更事件View 层只负责渲染。后面 NRefactory 做解析时我们直接从这个 _document 里取 Text源数据是一致的。2.3 语法高亮用 xshd 文件把关键字和字符串标出来AvalonEdit 的语法高亮走的是规则引擎常见做法是定义一个 .xshd 文件XML 格式里面描述高亮规则然后通过 HighlightingLoader 加载。下面是一个能覆盖 C# 主要语法的最小 xshd?xml version1.0? SyntaxDefinition nameCSharp extensions.cs xmlnshttp://icsharpcode.net/sharpdevelop/syntaxdefinition/2008 Color nameComment foreground#57A64A / Color nameString foreground#D69D85 / Color nameKeyword foreground#569CD6 / Color nameType foreground#4EC9B0 / Color nameNumber foreground#B5CEA0 / RuleSet Span colorComment multilinetrue Begin/*/Begin End*//End /Span Span colorComment Begin///Begin End$(LineEnd)/End /Span Span colorString Begin/Begin End/End /Span Keywords colorKeyword Wordusing/Word Wordclass/Word Wordstatic/Word Wordvoid/Word Wordint/Word Wordstring/Word Wordreturn/Word Wordif/Word Wordelse/Word Wordfor/Word Wordforeach/Word Wordnew/Word Wordnamespace/Word /Keywords /RuleSet /SyntaxDefinition加载方式如下using System.Xml; using ICSharpCode.AvalonEdit.Highlighting; using ICSharpCode.AvalonEdit.Highlighting.Xshd; // 从嵌入资源或文件读取 xshd using (var stream File.OpenRead(CSharp.xshd)) using (var reader XmlReader.Create(stream)) { var xshd XshdSyntaxDefinition.ReadXml(reader); editor.SyntaxHighlighting HighlightingLoader.Load(xshd, HighlightingManager.Instance); }这段逻辑的关键在于 HighlightingManager.Instance。它是全局高亮定义的管理器你加载的自定义高亮放进它里面之后可以在多个编辑器实例间复用。xshd 里的 Span 是区域规则比如 /* */ 和 // 注释Keywords 是精确单词匹配。花括号缩进、括号匹配这些不需要在这里写AvalonEdit 内置的 IndentationStrategy 和括号高亮是分开的后面可以单独开。2.4 防抖处理文本变化不要立刻去解析这里有一个第一次做容易翻车的细节。TextEditor 的 TextChanged 事件在用户每敲一个字符都会触发。如果每次触发都去跑语法树解析和补全计算输入稍微一快就卡顿。常见做法是引入防抖一般延迟 300 到 500 毫秒用户停顿下来才开始解析private CancellationTokenSource _parseCts; private void OnTextChanged(object sender, EventArgs e) { _parseCts?.Cancel(); _parseCts new CancellationTokenSource(); var token _parseCts.Token; var text editor.Text; _ Task.Run(async () { await Task.Delay(300, token); if (token.IsCancellationRequested) return; // 这里做 NRefactory 解析或 Roslyn 编译准备 }, token); }_token 在整个链路里有两个作用一是防抖周期内用户继续输入就取消上一次解析避免堆积二是解析任务本身如果是 CPU 密集型的也要支持中途取消。Task.Run 里不要直接访问 editor 控件——WPF 的 UI 元素只能在 UI 线程访问取 text 要提前取好。3. NRefactory 接代码提示补全数据从哪来、怎么送到编辑器3.1 NRefactory 在补全链路里的位置很多人以为代码提示是“编辑器把已输入的单词拿去匹配一个关键词表”。真实工程里的补全要复杂得多你输入string.提示里要出Length、Substring、ToUpper、静态方法IsNullOrEmpty你输入new Li提示要出ListT、LinkedListT并带入泛型参数。这些补全数据来自对代码语义的理解而不是字符串匹配。NRefactory 在组合里扮演的就是语义提供者。它把 C# 源码解析成语法树和类型解析上下文然后对光标位置查询“这里可以补什么”。和 Roslyn 比NRefactory 的解析速度更快内存占用更低对做“轻量提示”这种场景更友好。Roslyn 也可以做补全但它的语义模型体量更大后台编译一次的开销在低配机器上能感知到。这里我们是把两套库都要用上的NRefactory 做交互层的提示Roslyn 去做编译执行各管一段。3.2 把编辑器文本同步给解析器NRefactory 的解析入口是 CSharpParser。它会吞掉一段源码字符串生成 SyntaxTree。注意这里有个版本细节NRefactory 5.x 只支持 C# 5 时代的语法最新的 record、init、文件级 namespace 它是不认识的。解析遇到不认识的关键字会报错或丢节点但不会崩补全结果会退回比较基础的水平。所以给用户用的代码不建议用太新的语法特性。解析的典型实现如下using ICSharpCode.NRefactory.CSharp; var parser new CSharpParser(); var syntaxTree parser.Parse(text, script.cs);syntaxTree 拿到以后你可以遍历它的节点做很多事。比如收集所有 using 指令、收集类型定义、收集方法签名。这是补全引擎的数据基础。这里有一个容易踩的坑NRefactory 的解析不是增量式的。每敲一个字符都重新 Parse 整个文件性能瓶颈会在文件变大后出现。之前说的防抖在这里就很重要了。另外Parse 方法本身是同步的要在后台线程跑防止 UI 卡顿。解析完成后回调 UI 线程更新提示列表。3.3 补全数据的核心获取逻辑NRefactory 提供了一个 CSharpCompletionEngine这是做补全的主入口。它的构造参数比较多常见配置方式是这样的using ICSharpCode.NRefactory.CSharp; using ICSharpCode.NRefactory.CSharp.CodeCompletion; using ICSharpCode.NRefactory.Editor; // 构建编辑器文档接口NRefactory 需要 IDocument 类型 var document new StringTextSource(text).CreateDocument(); document.FileName script.cs; // 类型解析上下文需要告诉引擎引用了哪些程序集 var context new CSharpTypeResolveContext(assemblyReference);这一段代码在工程里需要包装成一次补全会话。CSharpCompletionEngine 拿到 document 和上下文后会基于语法树计算光标位置处的补全数据。核心方法调用是 GetCompletionData它的返回值是包含补全项列表和触发字长度的结构。这里的参数稍微敏感triggerWordLength 表示要把光标往前的多少个字符替换掉比如你输入了Conso触发字长度就是 6替换后变成Console加后续字段。把这个引擎封装成后台服务在防抖回调里调用然后拿返回结果刷新 UI 层弹出的补全列表。注意 GetCompletionData 的调用也必须在非 UI 线程否则输入时提示框会卡顿。3.4 补全弹出 UI 的挂接方式拿到补全项列表后WPF 里展示的常见做法是用 Popup 或 ListBox锚定在编辑器当前光标位置。AvalonEdit 有个 TextView 坐标系统可以拿到光标字符位置的屏幕坐标。这里给个简化实现private void ShowCompletion(IEnumerableICompletionData data) { var position editor.TextArea.Caret.Position; var visualPoint editor.TextArea.TextView.GetVisualPosition( position, VisualYPosition.LineBottom); var screenPoint editor.TextArea.TextView.PointToScreen(visualPoint); completionPopup.IsOpen true; completionPopup.PlacementTarget editor; completionPopup.PlacementRectangle new Rect(screenPoint, new Size(0, 0)); completionList.ItemsSource data; completionList.SelectedIndex 0; }GetVisualPosition 拿到的是文本视图里的坐标PointToScreen 转成屏幕坐标。这里最容易出问题的是缩放因子在 125% 或 150% 的显示缩放下坐标会有偏移。要处理 DPI 缩放需要拿到 VisualTreeHelper.GetDpi(editor) 来做换算。补全列表显示出来后用户按 Tab 或回车时要把选中项文本插入编辑器同时按 triggerWordLength 删掉已经输入的触发词。插入时用 Document.Replace 而不是直接改 Text因为 Text 属性每次赋值会丢失撤销栈和光标位置editor.Document.Replace(offset - triggerWordLength, triggerWordLength, selectedText);为什么要用 Replace因为 Text 的整体替换会让 AvalonEdit 把整个文档标记为变更虚线边框和撤销栈都会被破坏。按字符操作的 Replace 则能保住文本编辑器的“原生感”。4. Roslyn 动态编译把编辑器里的代码变成能跑的程序集4.1 为什么动态编译这条路要选 Roslyn很多人在这一步会尝试 CSharpCodeProvider老式 .NET Framework 里的编译器封装。它的问题是不能跨平台且在 .NET Core/5 环境下已经被标记为过时。Roslyn 把 C# 编译器做成了可调用的服务你可以在进程里创建编译对象、引用程序集、生成程序集文件或内存字节甚至直接把编译后的程序集加载执行。Roslyn 编译的核心概念是 CSharpCompilation。它由三部分组成语法树SyntaxTree、引用MetadataReference、编译选项CSharpCompilationOptions。一次编译就是把这三维度组装好然后调用 Emit 输出结果。4.2 编译器的组装与调用先看代码。这段是动态编译的标准姿势using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.Emit; var tree CSharpSyntaxTree.ParseText( sourceCode, new CSharpParseOptions(LanguageVersion.CSharp10)); var references new ListMetadataReference(); foreach (var path in GetTrustedAssemblies()) { references.Add(MetadataReference.CreateFromFile(path)); } var compilation CSharpCompilation.Create( DynamicScript, new[] { tree }, references, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); using (var ms new MemoryStream()) { var result compilation.Emit(ms); if (!result.Success) { var errors string.Join(\n, result.Diagnostics.Where(d d.Severity DiagnosticSeverity.Error)); // 把 errors 显示到界面上 return; } ms.Seek(0, SeekOrigin.Begin); var assembly Assembly.Load(ms.ToArray()); // 用反射调用目标类型和方法 }这里 GetTrustedAssemblies 是取运行时自带的程序集列表。.NET Core/5 里有一个 API 能直接拿到它private static IEnumerablestring GetTrustedAssemblies() { var runtimeDir Path.GetDirectoryName(typeof(object).Assembly.Location); return Directory.EnumerateFiles(runtimeDir, *.dll); }这个目录里包括 System.Private.CoreLib、System.Console、System.Runtime 等所有基础程序集。引用它们之后编译出来的程序集才能调用 Console.WriteLine 这些基础 API。CSharpParseOptions 里的 LanguageVersion 参数值得留意。如果你设成 Latest那用户写的代码只要编译器支持都能通过如果你希望限制用户的代码风格可以显式指定版本。比如 CSharp10 就不会允许 file 关键字因为那是 C# 11 的语法。4.3 Emit 之后怎么执行Emit 成功后拿到的 MemoryStream 里是一份完整的 .NET 程序集字节。Assembly.Load 之后你不能直接“调用它”因为编译出的 DLL 没有固定的入口点。常见做法是约定一个接口或类名用反射去实例化并调用。var type assembly.GetType(ScriptProgram); var method type.GetMethod(Main, BindingFlags.Public | BindingFlags.Static); var result method.Invoke(null, new object[] { args });这里隐含一个关键约定用户写的代码必须有 ScriptProgram 类和 Main 方法。为了让用户不用每次手写这些样板代码我一般会在编译前做一层代码包装——用户只写方法体编辑器自动补全类定义和方法签名。这样既简化了用户输入也让编译后的调用逻辑变得可控。如果想让用户能返回数据并显示在界面里可以让 Main 方法返回 stringInvoke 后拿返回值显示在编辑器下方的输出面板。如果希望用户能直接在代码里调用你程序里的 API那就要在编译引用里加入自己程序集的引用并且用 extern alias 或者命名约定来保证用户代码能访问到你的内部对象。这个属于进阶话题最后一章专门说。4.4 内存泄漏隐患Assembly.Load 和 AssemblyLoadContext前面代码里用的 Assembly.Load 有一个严重问题加载进来的程序集会一直占据内存无法卸载。如果你的脚本面板允许用户反复修改、反复编译运行每跑一次就泄漏一份。解决方案是使用 AssemblyLoadContext 来做可卸载上下文。.NET Core 3.0 以后支持 collectible ALC。using System.Runtime.Loader; public class ScriptLoadContext : AssemblyLoadContext { public ScriptLoadContext() : base(isCollectible: true) { } protected override Assembly Load(AssemblyName assemblyName) null; } // 编译时 var alc new ScriptLoadContext(); using (var ms new MemoryStream()) { compilation.Emit(ms); ms.Seek(0, SeekOrigin.Begin); var assembly alc.LoadFromStream(ms); // 调用... // 执行完后卸载 alc.Unload(); }注意 LoadFromStream 需要的是编译产物的完整字节所以你 Emit 时不能用默认的 EmitToFile而是 Emit 到 MemoryStream 再 LoadFromStream。Unload 之后如果没有任何对象还持有程序集里的类型引用它才会真正被 GC 回收。为了验证是否成功释放可以监听 ALC 的 Unloading 事件并输出一条日志这是排查泄漏最直接的手段。5. 三库联调的常见坑提示失联、程序集锁死与线程卡顿5.1 补全弹出后又立刻关掉现象用户在编辑器里打字补全窗口闪了一下马上消失根本来不及选中。原因TextEditor 的光标位置在输入过程中会连续变化。补全 Popup 显示的时候如果编辑器触发了一次 SelectionChanged 或 CaretChanged 事件而代码里写了“非补全模式”就关闭 Popup就会把它立即关掉。解决给补全窗口加一个“锁定”状态。显示补全后置一个标志位当用户输入的字符属于补全词的组成部分字母、数字、下划线时不自动关闭只有遇到空格、分号、括号这些结束符时才关闭。另外插入补全项时不要通过替换 editor.Text 的粗暴方式那样会触发全文刷新事件导致 Popup 重定位抖动。5.2 编译后的程序集文件被占用第二次编译报错现象第一次编译运行成功用户改了代码再点运行报“文件正在被另一进程使用”或“无法写入 DLL 文件”。原因上一轮的 Assembly.Load 把编译输出的 DLL 文件锁住了或者 Emit 到文件后没有释放 FileStream。操作系统的文件锁不释放第二次 Emit 当然写不进去。解决不要 Emit 到硬盘文件全部走 MemoryStream。如果确实需要落盘调试Emit 完之后立即关闭流。同时用 AssemblyLoadContext 来管理程序集的迭代生命周期每次运行用新 ALC运行结束执行 Unload。这是我在这个项目里觉得价值最大的一次改写修完这个问题后连续编译几十次都不再报错。5.3 解析线程抢占了 UI 线程导致打字卡顿现象输入飞快时明显感觉编辑器跟手延迟严重甚至输入法都跟不上。原因TextChanged 事件里直接调用了 NRefactory 的 Parse。Parse 是吃 CPU 的操作在 UI 线程上跑每敲一个键就卡一次。解决把所有解析动作移出 UI 线程防抖窗口加长到 400 毫秒以上并且用 CancellationToken 保证新的输入可以取消旧的任务。另一个隐藏问题NRefactory 的解析器不是线程安全的。多个解析任务在后台线程并发时同一个 CSharpParser 实例会被多个线程同时调用解决办法是每次解析都新建一个 parser 实例或者用对象池加锁。5.4 用户代码里用了较新的 C# 语法NRefactory 解析失败现象补全列表突然只剩关键字和基础类型用户写record、init、file等新语法时提示完全“失聪”。原因NRefactory 5.x 的语法解析器停留在 C# 5 时代对新语法不认识。解析失败后语义树不完整补全引擎退化为最基础的关键词匹配。解决在界面里写明支持的语法范围或者在代码输入框旁提示“建议使用 C# 5 语法”。如果你确实需要新语法补全办法是换用 Roslyn 的完成服务CompletionService但代价是引入更多依赖和内存开销。权衡下来对内部工具来说限制语法版本比换引擎更划算。5.5 Roslyn 编译引用了错误的程序集导致运行时报 MissingMethodException现象编译成功Assembly.Load 和反射调用都成功但运行到某个方法时抛 MissingMethodException。原因用户代码引用的某个 API 在编译时引用的程序集版本和运行时加载到的版本不一致。最常见的是因为 MetadataReference.CreateFromFile 用文件路径加载了重复的同名程序集运行时解析时选错了版本。解决引用基础程序集时用 AppDomain.CurrentDomain.GetAssemblies() 去重过滤优先使用运行时已经加载的版本。不要盲目把 runtime 目录下所有 DLL 都塞进 references只引用 System.Private.CoreLib、System.Runtime、System.Console、System.Linq 等用户代码真正会用到的程序集其余的用到了再按需补充。这既能减少版本冲突也能降低编译内存开销。6. 串成完整方案验证管道跑通的三条用例与后续改进方向三库合体之后第一件事不是写更多功能是用三个最小用例把管道验证完整。第一个用例是“提示准”编辑器输入Console.补全列表里出现 WriteLine且敲 Tab 能插入完整调用。这个用例验证 NRefactory 的解析、补全引擎和 UI 挂接全链路是否通。注意不要在刚启动时就试因为首次解析需要加载程序集元数据会有几百毫秒延迟第二次输入应该明显变快。第二个用例是“编译通”写一段完整的 ScriptProgram 类包含一个返回字符串的 Main 方法点运行后在输出区看到返回值。这个用例验证 Roslyn 编译、AssemblyLoadContext 加载、反射调用三层是否正常。如果 Main 方法里用了 LINQ 的 Where 和 Select还能顺带验证 Linq 程序集的引用是否配齐。第三个用例是“循环稳”连续修改代码并运行十次观察内存占用是否持续增长。这个用例验证 ALC 卸载是否真的把上一轮程序集释放干净。在调试器里用dotnet-counters monitor --process-id pid --counters System.Runtime观察 GC 堆大小如果每次都上涨几百 KB 不回落说明 ALC 里还有东西没释放干净。管道跑通后值得投入的改进方向有两个。一个是把编译错误信息格式化后定位到编辑器行号让用户点错误列表直接跳转到出错代码行。做法是从 Roslyn 的 Diagnostic 里取 LineSpan换算成 TextDocument 的行号然后用 editor.ScrollToLine 跳转。另一个是把可调 API 暴露给用户脚本把宿主程序里的对象用 RegisterType 的方式注册进编译引用用户在脚本里直接调用你公开的服务方法这个方案能极大扩展脚本面板的实用性。这些年我做过几次类似的编辑器工具最大的教训是把“补全”和“编译”当成两个独立问题去设计结果两边都做过头或做不够。NRefactory 够轻就让它专心做补全Roslyn 够重就让它只负责编译和语义诊断。两边通过文档文本解耦不要共享内部对象。这样才能在给用户提供流畅编辑体验的同时保留足够的编译能力。希望这个组合方案能帮你少走几条弯路。本文还有配套的精品资源点击获取