ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

基于Roslyn的实时C#脚本编辑器架构与实践

基于Roslyn的实时C#脚本编辑器架构与实践

简介:这是一套基于 .NET Core 的轻量级代码编辑器开发示例,面向 C# 桌面应用开发者、IDE 插件学习者及 Roslyn 动态编译实践者,解决传统 CSharpCodeProvider 在 .NET Core 环境下不可用、代码提示缺失、编辑器功能简陋等实际问题。资源共 620 个文件,涵盖 271 个核心 C# 源码(实现 AvalonEdit 扩展、NRefactory 集成与 Roslyn 编译引擎)、89 个引用 DLL(含 ICSharpCode.AvalonEdit、NRefactory 及 Roslyn 相关组件)、44 张 UI 图标 PNG 与 22 个语法高亮定义文件(.xshd),整体压缩包仅 13.69MB,结构紧凑、模块边界清晰。已有 750 人学习下载,适合希望快速掌握现代 .NET 编辑器底层集成方案的中高级开发者。读者可直接运行完整可交互编辑器,获取带语法高亮、智能提示、实时编译反馈的端到端实现逻辑,并深入理解 AvalonEdit 渲染机制、NRefactory AST 解析流程与 Roslyn C# 脚本化编译的工程化落地方式。

1. 这不是又一个“语法高亮编辑器”:它能边写C#代码、边实时编译、边弹出智能提示,连var x = new List<int>(); x.后面按点,立刻补全Add、Count、ForEach——而且所有这些能力都跑在 .NET Core 进程里,不依赖 Visual Studio,也不走任何外部服务

你有没有试过在 WinForm 或 WPF 里嵌一个“像模像样”的代码编辑器?加个 AvalonEdit,语法高亮有了;再接个 NRefactory,基础补全勉强能用;但只要用户敲错一个括号、少写一个分号,或者想调用自己刚写的类方法,整个提示就崩了——因为 NRefactory 的语义分析是静态的、离线的、不感知当前项目引用和编译状态的。而这个项目把三者真正拧成一股绳:AvalonEdit 负责渲染与交互,NRefactory 提供 AST 解析与符号索引,Roslyn 动态编译则作为“活的编译上下文”实时喂给 NRefactory。它不是一个玩具 Demo,而是一个可嵌入、可调试、可热更新的轻量级 C# 脚本执行环境。适合做配置脚本编辑器、规则引擎控制台、教学实验沙箱、自动化任务编排界面——尤其当你需要让用户写 C# 逻辑,又不能把 VS 安装包塞进安装包时,它就是那个被反复验证过的落地解。技术栈干净利落:.NET Core 3.1+(兼容 6/7/8)、WPF 客户端、纯内存编译、零磁盘临时文件。


2. 架构总览:三层协同模型与核心数据流

这个项目不是简单地把三个 NuGet 包堆在一起,而是构建了一个明确职责边界、低耦合、可替换的三层协同模型。我拆开源码第一眼就确认:它没走“用 Roslyn 编译完扔掉,再让 NRefactory 自己重新解析”的老路,而是让 NRefactory 复用 Roslyn 的Compilation实例,共享同一套符号表(ISymbol)和语义模型(SemanticModel)。这才是补全不翻车、跳转不跳空、错误提示准到行号的根本原因。

2.1 三层职责划分:编辑层 → 分析层 → 编译层

  • 编辑层(AvalonEdit):只负责文本输入、高亮渲染、光标定位、选区管理。它不关心“这是不是合法 C#”,也不管“DateTime.Now是哪个命名空间”。它暴露TextDocument和TextArea事件(如TextChanged、KeyDown),是整个流程的触发源。
  • 分析层(NRefactory):接收编辑层传来的原始文本,用CSharpParser.Parse()构建 AST,再通过CSharpAstResolver将 AST 节点绑定到符号。但它不自己加载程序集或解析引用——它的IProjectContent实际上是 Roslyn 编译层提供的Compilation的包装体。
  • 编译层(Roslyn):这是真正的“大脑”。它监听编辑层的变更,在后台线程中持续调用CSharpCompilation.Create()构建新编译对象,并注入所有引用(System.dll、System.Core.dll、用户自定义 DLL、甚至当前项目生成的bin/Debug/*.dll)。编译成功后,将Compilation封装为RoslynProjectContent,交给 NRefactory 使用。

提示:NRefactory 的IProjectContent接口是抽象的,项目中实现了RoslynProjectContent : IProjectContent,它内部持有一个Lazy<Compilation>,只有在首次请求语义分析时才真正触发编译——这是性能关键点。

2.2 核心数据流:从按键到补全的完整链路

我们以用户在编辑器中输入Console.后按下Ctrl+Space触发补全为例,追踪一次完整链路:

  1. AvalonEdit 捕获KeyDown事件,检测到Ctrl+Space组合键;
  2. 调用CompletionWindow.Show(textArea),窗口初始化时调用GetCompletionData();
  3. GetCompletionData()调用NRefactoryHelper.GetCompletionsAtOffset(document, offset);
  4. NRefactoryHelper内部构造CSharpAstResolver,传入当前RoslynProjectContent;
  5. CSharpAstResolver.Resolve(node)时,实际调用compilation.GetSemanticModel(tree).GetSymbolInfo(node);
  6. Roslyn 返回ISymbol列表(如Console.WriteLine、Console.ReadKey);
  7. NRefactory 将ISymbol映射为ICompletionData(含显示文本、描述、图标、插入文本);
  8. 补全窗口渲染并响应用户选择。

整个过程没有一次磁盘 IO,没有进程间通信,所有对象都在同一 AppDomain 内存中流转。这也是它比“启动 csc.exe 子进程 + 读取 stderr”方案快一个数量级的原因。

2.3 关键接口契约:IRoslynHost与生命周期管理

项目定义了IRoslynHost接口,作为编译层对外的唯一门面:

public interface IRoslynHost { // 主动触发编译(如用户点击“运行”) Task<CompilationResult> CompileAsync(string code, CancellationToken ct = default); // 获取当前有效 Compilation(供 NRefactory 使用) Compilation GetCurrentCompilation(); // 注册/移除程序集引用(支持运行时热加载) void AddReference(Assembly assembly); void AddReference(string assemblyPath); // 清理缓存(如切换脚本文件时) void Reset(); }

实现类DefaultRoslynHost内部维护:

  • ConcurrentDictionary<string, Lazy<Compilation>> _compilations:按源码哈希缓存 Compilation,避免重复编译相同内容;
  • List<MetadataReference>:所有已注册的引用,包括MetadataReference.CreateFromFile(typeof(object).Assembly.Location)等基础框架引用;
  • SemaphoreSlim _compileLock:防止高频编辑时并发编译导致 CPU 爆满。

这个设计让上层完全不用操心 Roslyn 的CSharpCompilation创建细节、SyntaxTree构建方式、Emit输出路径——它只认IRoslynHost,换实现(比如换成基于Microsoft.CodeAnalysis.Scripting的脚本模式)只需重写一个类。


3. 运行实操:从零搭建可运行环境(含 WPF 主窗体与最小依赖)

别被“Roslyn”“NRefactory”吓住——这个项目对新手极其友好,它不依赖任何 IDE 插件、不生成临时 csproj、不修改全局 SDK 配置。你只需要一个干净的 .NET SDK 环境,5 分钟就能跑起来。我用的是 .NET 6 SDK(dotnet --version输出6.0.402),但源码已适配 .NET Core 3.1 至 .NET 8。

3.1 初始化解决方案与项目结构

项目采用标准多项目结构,共 3 个工程:

AvalonEdit.NRefactory.Roslyn.sln ├── EditorHost.Wpf/ # WPF 主宿主程序(含 MainWindow.xaml) ├── Core/ # 核心库(含 IRoslynHost、RoslynProjectContent、NRefactoryHelper) └── TestScripts/ # 示例脚本目录(.cs 文件,用于快速验证)

注意:EditorHost.Wpf是启动项目,它引用Core;Core项目引用以下 NuGet 包(版本已锁死,无需手动升级):

  • AvalonEditv6.10.0
  • ICSharpCode.NRefactoryv5.7.0
  • Microsoft.CodeAnalysis.CSharpv4.7.0
  • Microsoft.CodeAnalysis.Workspaces.Commonv4.7.0

所有包均来自 nuget.org 官方源,无私有 feed 依赖。

3.2 WPF 主窗体关键代码:如何把 AvalonEdit 和补全系统粘起来

MainWindow.xaml中仅声明一个TextEditor控件(AvalonEdit 的核心控件):

<avalonEdit:TextEditor x:Name="editor" FontFamily="Consolas" FontSize="14" ShowLineNumbers="True" SyntaxHighlighting="{x:Static avalonEdit:HighlightingManager.Instance.CSharp}" DocumentChanged="OnDocumentChanged"/>

后台代码MainWindow.xaml.cs做三件事:

  1. 初始化 RoslynHost(在Loaded事件中):
private IRoslynHost _roslynHost; private void Window_Loaded(object sender, RoutedEventArgs e) { _roslynHost = new DefaultRoslynHost(); // 注册基础引用(必须!否则 Console 类型找不到) _roslynHost.AddReference(typeof(object).Assembly); _roslynHost.AddReference(typeof(Console).Assembly); _roslynHost.AddReference(typeof(List<>).Assembly); // 可选:加载当前解决方案的输出目录(便于调用自己写的类) var binDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, @"..\..\..\TestScripts\bin\Debug\net6.0"); if (Directory.Exists(binDir)) { foreach (var dll in Directory.GetFiles(binDir, "*.dll")) { try { _roslynHost.AddReference(dll); } catch { /* 忽略无法加载的 DLL */ } } } }
  1. 绑定编辑器事件,驱动实时分析:
private void OnDocumentChanged(object sender, EventArgs e) { // 防抖:150ms 内只触发一次编译(避免每敲一个字都编译) _debounceTimer?.Stop(); _debounceTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(150) }; _debounceTimer.Tick += (s, args) => { _debounceTimer.Stop(); _ = TryCompileCurrentCode(); // 异步编译,不阻塞 UI }; _debounceTimer.Start(); } private async Task TryCompileCurrentCode() { var code = editor.Text; var result = await _roslynHost.CompileAsync(code); if (!result.Success) { // 显示错误到状态栏(非弹窗,避免打断) statusText.Text = $"编译失败: {result.Diagnostics.First().GetMessage()}"; } else { statusText.Text = $"OK ({result.Compilation.Assembly.GetName().Version})"; } }
  1. 接入补全逻辑(Ctrl+Space):
private void editor_KeyDown(object sender, KeyEventArgs e) { if (e.Key == Key.Space && Keyboard.Modifiers == ModifierKeys.Control) { e.Handled = true; ShowCompletion(); } } private void ShowCompletion() { var textView = editor.TextArea.TextView; var pos = editor.TextArea.Caret.Offset; var document = editor.Document; var completionData = NRefactoryHelper.GetCompletionsAtOffset(document, pos, _roslynHost.GetCurrentCompilation()); if (completionData.Any()) { var completionWindow = new CompletionWindow(editor.TextArea); completionWindow.CompletionList.CompletionData.AddRange(completionData); completionWindow.Show(); } }

这段代码就是全部——没有反射、没有动态代理、没有魔法字符串。NRefactoryHelper.GetCompletionsAtOffset是项目封装的核心方法,它内部完成 AST 解析、节点定位、语义绑定、符号筛选全流程。

3.3 运行与验证:第一个“Hello World”脚本

  1. 打开TestScripts/HelloWorld.cs,内容如下:
using System; public class Script { public static void Main() { Console.WriteLine("Hello from Roslyn-powered editor!"); var list = new List<int> { 1, 2, 3 }; Console.WriteLine($"Count: {list.Count}"); } }
  1. 在EditorHost.Wpf中按F5启动;
  2. 粘贴上述代码到编辑器;
  3. 按Ctrl+Space:光标处应弹出Console成员列表(WriteLine,ReadKey,BackgroundColor...);
  4. 输入list.再按Ctrl+Space:应出现Count,Add,RemoveAt等List<int>方法;
  5. 点击工具栏“运行”按钮(或按F5):底部状态栏显示OK (1.0.0.0),且控制台输出Hello from Roslyn-powered editor!。

如果第 4 步补全为空,请检查IRoslynHost.AddReference()是否漏掉了typeof(List<>)所在程序集;如果第 6 步报错The name 'Console' does not exist...,说明基础框架引用未正确加载——这是新手最常卡住的点,下一节专门讲怎么排查。


4. 避坑指南:5 条血泪经验总结(附现象、根因与修复命令)

这个项目看似结构清晰,但在真实集成场景中,有 5 个高频翻车点,几乎每个二次开发者都至少踩过其中 2 个。我把它们按“现象 → 原因 → 解决”列出来,全是实测复现过的案例。

4.1 现象:补全窗口弹出但内容为空,或只显示var、if等关键字,不显示Console、List等类型成员

原因:IRoslynHost未正确注入基础框架程序集引用,或引用路径指向了错误版本(如 .NET Framework 的mscorlib.dll)。NRefactory 解析 AST 时能识别语法,但Resolve()阶段找不到符号定义。
解决:强制使用typeof(T).Assembly加载引用,而非硬编码路径:

// ✅ 正确:自动适配当前运行时 _roslynHost.AddReference(typeof(Console).Assembly); _roslynHost.AddReference(typeof(List<>).Assembly); // ❌ 错误:路径可能不存在或版本不匹配 // _roslynHost.AddReference(@"C:\Program Files\dotnet\shared\Microsoft.NETCore.App\6.0.20\System.Console.dll");

4.2 现象:编辑器输入中文字符后,光标位置错乱,补全弹出位置偏移

原因:AvalonEdit 默认使用TextDocument的Text属性获取内容,但该属性返回的是 UTF-16 字符串,而 Roslyn 的SyntaxTree解析基于 UTF-8 字节偏移。当存在中文(UTF-16 中占 2 个 char,UTF-8 中占 3 字节)时,offset计算失准。
解决:统一使用TextDocument.GetText()并显式指定编码,或改用TextDocument.GetCharOffset():

// 在 GetCompletionsAtOffset 中,不要直接用 editor.Text.Length 计算 var offset = editor.TextArea.Caret.Offset; // ✅ AvalonEdit 自带的逻辑已处理好 Unicode var text = editor.Document.Text; // ✅ TextDocument.Text 是 Unicode 安全的

4.3 现象:连续快速输入时 UI 卡死,CPU 占用 100%,日志显示大量Compilation创建日志

原因:未启用防抖(debounce),每次TextChanged都触发CompileAsync,而 Roslyn 编译是 CPU 密集型操作。默认DefaultRoslynHost不自带防抖,需上层控制。
解决:在OnDocumentChanged中加入DispatcherTimer防抖,如 3.2 节所示;或更激进地,只在用户停顿 500ms 后才编译:

private void OnDocumentChanged(object sender, EventArgs e) { _debounceTimer?.Stop(); _debounceTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) }; _debounceTimer.Tick += async (s, args) => { _debounceTimer.Stop(); await TryCompileCurrentCode(); // 注意:这里必须 await,否则 Timer 不会等编译完成 }; _debounceTimer.Start(); }

4.4 现象:调用自定义类库中的方法时,补全能显示,但运行时报System.MissingMethodException

原因:AddReference(string path)加载了 DLL,但该 DLL 依赖的其他程序集(如Newtonsoft.Json.dll)未一并加载,导致运行时解析失败。Roslyn 编译阶段不检查运行时依赖,只校验编译期符号。
解决:递归加载依赖项。在AddReference(string path)实现中加入:

public void AddReference(string path) { var assembly = AssemblyLoadContext.Default.LoadFromAssemblyPath(path); _references.Add(MetadataReference.CreateFromFile(path)); // 递归加载此程序集的所有依赖(排除 .NET 基础库) foreach (var dep in assembly.GetReferencedAssemblies()) { if (dep.FullName.StartsWith("System.") || dep.FullName.StartsWith("Microsoft.")) continue; var depPath = Path.Combine(Path.GetDirectoryName(path), dep.Name + ".dll"); if (File.Exists(depPath)) AddReference(depPath); } }

4.5 现象:WPF 窗体关闭后,后台编译线程仍在运行,dotnet进程不退出

原因:DefaultRoslynHost内部使用Task.Run启动编译,但未对CancellationToken做传播,也未监听AppDomain.CurrentDomain.ProcessExit。编译任务成为孤儿线程。
解决:在MainWindow.Closed事件中显式清理:

private void MainWindow_Closed(object sender, EventArgs e) { // 取消所有待处理编译任务 _roslynHost.CancelAllPendingCompilations(); // 清理 AvalonEdit 缓存 editor.Document.UndoStack.ClearAll(); // 强制 GC(可选,但推荐) GC.Collect(); GC.WaitForPendingFinalizers(); }

并在DefaultRoslynHost中添加:

private readonly CancellationTokenSource _cts = new(); public void CancelAllPendingCompilations() => _cts.Cancel();

然后所有CompileAsync调用都传入_cts.Token。


5. 二次开发实战:如何接入自己的 DSL 解析器与运行时沙箱

现在你已经能跑通 C# 脚本,但现实项目往往不止于此。比如某高校实验室需要让学生编写“机器人运动指令”,语法类似move forward 100; turn left 90;,背后要映射到 C# 类库的Robot.Move(100)调用。这时候,你不需要重写整个编辑器,只需替换“分析层”和“编译层”的部分能力,保留 AvalonEdit 的渲染与交互。下面是我实际落地过的改造路径。

5.1 替换语法分析器:从 C# 到自定义 DSL

NRefactory 的CSharpParser是封闭的,但它的 AST 设计是开放的。项目中Core/Parser/目录下已预留IDslParser<TAstNode>接口:

public interface IDslParser<TAstNode> { TAstNode Parse(string code); IEnumerable<Diagnostic> GetDiagnostics(string code); }

我们实现一个RobotCommandParser:

public class RobotCommandParser : IDslParser<RobotCommandAst> { public RobotCommandAst Parse(string code) { var tokens = Tokenize(code); // 简单空格分割,实际可用 ANTLR4 var commands = new List<RobotCommand>(); for (int i = 0; i < tokens.Length; i++) { if (tokens[i] == "move" && i + 2 < tokens.Length) { commands.Add(new MoveCommand { Direction = tokens[i + 1], Distance = int.Parse(tokens[i + 2]) }); i += 2; } else if (tokens[i] == "turn" && i + 2 < tokens.Length) { commands.Add(new TurnCommand { Direction = tokens[i + 1], Angle = int.Parse(tokens[i + 2]) }); i += 2; } } return new RobotCommandAst { Commands = commands }; } private string[] Tokenize(string code) => code.Replace(";", " ").Split(new[] { ' ', '\t', '\n' }, StringSplitOptions.RemoveEmptyEntries); }

接着,修改NRefactoryHelper.GetCompletionsAtOffset,让它根据当前光标位置判断语言模式:

public static IList<ICompletionData> GetCompletionsAtOffset( TextDocument document, int offset, Compilation compilation) { var line = document.GetLineByOffset(offset); var text = line.Text.Trim(); if (text.StartsWith("move") || text.StartsWith("turn")) { // DSL 模式:只返回预设指令 return new[] { new SimpleCompletionData("move forward <dist>", "向前移动指定距离"), new SimpleCompletionData("turn left <angle>", "向左转指定角度"), }.ToList(); } // 默认 C# 模式 return GetCSharpCompletions(document, offset, compilation); }

这样,用户输入move就触发 DSL 补全,输入Console.就走 Roslyn 补全——无需切换编辑器模式。

5.2 构建运行时沙箱:拦截Console.WriteLine并重定向到 UI 文本框

很多教学场景要求“运行结果不打印到黑窗口,而显示在编辑器下方”。Roslyn 允许我们重写Compilation.Emit()的输出,但更轻量的做法是:在编译前,用CSharpSyntaxRewriter把所有Console.WriteLine(...)替换为ScriptHost.Log(...):

public class ConsoleRewriter : CSharpSyntaxRewriter { public override SyntaxNode VisitInvocationExpression(InvocationExpressionSyntax node) { var memberAccess = node.Expression as MemberAccessExpressionSyntax; if (memberAccess?.Name.ToString() == "WriteLine" && memberAccess.Expression.ToString() == "Console") { var newArgs = SyntaxFactory.ArgumentList( SyntaxFactory.SeparatedList(new[] { SyntaxFactory.Argument( SyntaxFactory.InvocationExpression( SyntaxFactory.MemberAccessExpression( SyntaxKind.SimpleMemberAccessExpression, SyntaxFactory.IdentifierName("ScriptHost"), SyntaxFactory.IdentifierName("Log"))) .WithArgumentList(node.ArgumentList)) }) ); return SyntaxFactory.InvocationExpression( SyntaxFactory.MemberAccessExpression( SyntaxKind.SimpleMemberAccessExpression, SyntaxFactory.IdentifierName("ScriptHost"), SyntaxFactory.IdentifierName("Log"))) .WithArgumentList(newArgs); } return base.VisitInvocationExpression(node); } }

然后在DefaultRoslynHost.CompileAsync中插入:

var tree = CSharpSyntaxTree.ParseText(code); var rewrittenTree = new ConsoleRewriter().Visit(tree.GetRoot()); var newCompilation = compilation.ReplaceSyntaxTree(tree, rewrittenTree);

最后,在ScriptHost.Log中把字符串追加到 UI 的TextBox:

public static class ScriptHost { public static Action<string> OnLog; public static void Log(string message) { OnLog?.Invoke(message); } } // 在 MainWindow 中注册 ScriptHost.OnLog = msg => outputTextBox.AppendText($"[LOG] {msg}\r\n");

这样,学生写的Console.WriteLine("Hello");运行时实际调用ScriptHost.Log("Hello"),结果直接出现在界面上——整个过程对用户完全透明。

5.3 扩展调试能力:添加断点与变量监视(最小可行版)

AvalonEdit 支持行号旁绘制断点标记(editor.TextArea.TextView.LineTransformers),而 Roslyn 的Compilation可生成 PDB 调试信息。我们不需要完整调试器,只需做到:

  • 用户点击行号左侧,添加/删除断点(存储在Dictionary<int, bool>);
  • 运行时,用Emit()生成带 PDB 的 DLL;
  • 在ScriptHost中注入Debugger.Break()调用(需System.Diagnostics.Debugger);
  • 捕获AppDomain.CurrentDomain.UnhandledException显示变量快照。

关键代码片段:

// 断点管理 private readonly Dictionary<int, bool> _breakpoints = new(); private void LineNumbers_MouseDown(object sender, MouseButtonEventArgs e) { var point = e.GetPosition(lineNumbers); var line = lineNumbers.GetLineIndexFromPoint(point); _breakpoints[line] = !_breakpoints.GetValueOrDefault(line); lineNumbers.InvalidateVisual(); // 重绘断点图标 } // 编译时注入断点 public async Task<CompilationResult> CompileAsync(string code, CancellationToken ct) { var tree = CSharpSyntaxTree.ParseText(code); var root = tree.GetRoot(); // 在每个断点行插入 Debugger.Break() foreach (var kvp in _breakpoints.Where(x => x.Value)) { var lineSpan = root.FindToken(kvp.Key).Parent.FullSpan; var breakStmt = SyntaxFactory.ExpressionStatement( SyntaxFactory.InvocationExpression( SyntaxFactory.MemberAccessExpression( SyntaxKind.SimpleMemberAccessExpression, SyntaxFactory.IdentifierName("Debugger"), SyntaxFactory.IdentifierName("Break")))); root = root.InsertNodesBefore(root.FindNode(lineSpan), new[] { breakStmt }); } var newTree = root.SyntaxTree; // ... 后续编译逻辑 }

虽然简陋,但它让“教学脚本调试”从“猜错在哪”变成“点哪停哪”,学生反馈提升显著。

从那以后我每次接到“需要嵌入式脚本编辑器”的需求,都先拉这个项目骨架,30 分钟内完成 DSL 接入和沙箱重定向。它教会我的不是 Roslyn API,而是“把编译器当服务用”的思维——编译不再是构建阶段的黑匣子,而是运行时可编程的组件。希望帮到你。

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

返回列表