最近我花了大约两周的业余时间,把标题里这口“大话”真给落地了:用 Roslyn 和 AvalonEdit 这两大引擎,从 0 到 1 搭了一个跨平台 C# 编辑器。它不是 Visual Studio,也不是 VS Code 里的某个插件,而是一个自己掌控所有细节的轻量级编辑器壳子。写 C# 代码、开工程、看实时报错、用补全,这一整套高频操作下来,流畅程度说实话超过了我一开始的预期。
这篇不是项目 Showcase,也不是 IDE 评测。我会把整个项目的来龙去脉、技术选型、关键代码、踩坑记录全部摊开。如果你也想自研编辑器,或者想在 .NET 生态里接入 Roslyn 做一些“半个 IDE”的工具,那这篇应该能帮你省下不少弯路。
1. 项目发起的真实动机:为什么放着 VS 不用,偏要自研编辑器
1.1 从“不满”到“设计”:产品定位
说句公道话,Visual Studio 是目前 C# 开发最强的 IDE,没有之一。但它的问题也很明显:启动和加载工程的速度、内存占用、后台进程的复杂性、界面里层层叠叠的工具栏和面板,对于只写脚本、刷算法题、或者维护一个中大型项目的某一块模块来说,严重超配。
我日常的需求其实很朴素:打开一个目录,能看到里面的 .cs 文件,编辑时语法高亮要准,输入时能给我补全,写错了能立刻看到波浪线。我不想为此等 VS 加载 30 秒,也不想被各种解决方案、项目文件、NuGet 还原的流程牵着走。
所以这个编辑器的定位从一开始就很明确,做“轻量级 C# 代码工作站”,而不是“IDE 全家桶”。它只解决三件事:写代码、读代码、查错误。重构、调试、测试、版本控制那些,留给专业工具去做。换句话讲,我做的是“编辑体验的最后一公里”,不是“软件开发生命周期的全覆盖”。
这个定位直接决定了后续所有的技术选型:不做插件系统,不做项目级编译,不做复杂的代码索引,所有的复杂逻辑都围绕“当前打开的这份源码”展开。这听起来好像“不够硬核”,但恰恰是这个限制,让我能在两周内完成一个可用的版本,而不是陷入 IDE 基础设施的无底洞。
1.2 技术选型的核心博弈:AvalonEdit、Scintilla 还是自己写
编辑器最核心的控件就是文本编辑框。这一层我从一开始就没打算手写。市面上能直接嵌入 C# 应用的文本编辑器有三大流派:WinForms 体系的 Scintilla、WPF 体系的 AvalonEdit、以及 WebView 内核的 Monaco/CodeMirror。
我先排除了 WebView 方案。原因很简单:我不想为了一个编辑器框引入一整个浏览器进程,也不想去维护 JS 和 C# 之间的通信桥,更不想背“WebView 2 Runtime 没装”这种运行环境包袱。Monaco 本身的体验确实顶级,但那是给 Web 应用用的,嵌入原生 .NET 应用会带来一堆工程复杂度。
Scintilla 也很强,性能好、功能多,但它本身是 C++ 写的,接口带着强烈的 Win32 风格,而且它的语法高亮体系是词法级的,对 Roslyn 这种语法树级的分析器支持并不自然。我要做的是“让 Roslyn 的分析结果直接映射到编辑器 UI”,Scintilla 的 marker、indicator 机制虽然能做,但代码写起来会非常“底层”。
最后剩 AvalonEdit。它是 SharpDevelop 编辑器团队沉淀多年的 WPF 组件,文本模型、折叠、高亮框架、智能提示的 UI 框架都是现成的。最关键的是,AvalonEdit 是纯 C# 实现,代码风格和 Roslyn 一样,是托管生态自己人。我做多行折行、代码折叠、URL 点击等能力时,完全不用跨语言调 API。
跨平台是香饵也是个坑。AvalonEdit 原版基于 WPF,WPF 在 Linux/macOS 上没法跑。好在 Avalonia Edit 这个移植分支很早就有人做了,它把 AvalonEdit 的 API 平移到了 Avalonia 之上,而 Avalonia 本身是跨平台 UI 框架。所以我最终的技术栈很统一:Avalonia 做窗口、布局、菜单,AvaloniaEdit 做编辑器核心,Roslyn 做所有“懂 C#”的工作。
方案对比我整理了一个表格,你可以直观感受一下:
| 方案 | 性能 | 跨平台 | 与 Roslyn 集成的自然度 | 工程复杂度 |
|---|---|---|---|---|
| Scintilla | 极高 | 弱(原生依赖) | 需要自己封装语义映射 | 高 |
| WebView + Monaco | 高 | 强 | 需要桥接双进程 | 很高 |
| AvalonEdit + Avalonia | 中高 | 强 | 高(纯托管生态) | 低 |
| 纯手写文本框 | 取决于实现 | 取决于实现 | 极高难度 | 极高 |
从表格能看出来,AvalonEdit 方案不是每一项都最强,但它每项都是“足够好且不折腾”,这就是自研项目最需要的平衡点。
这个选型过程我最大的体会是:不要在编辑器控件上追求“最强”,要追求“最快能跑通闭环”。Roslyn 才是这个项目的大脑,编辑器控件只是手和脚。手再快,脑子跟不上也没用;反过来,脑子很聪明,但手连一支笔都拿不稳,项目照样胎死腹中。
2. 架构设计与核心部件拆解:Roslyn 和 AvalonEdit 怎么配合
2.1 AvalonEdit 的能力边界:它到底帮你做了什么
很多人把 AvalonEdit 当成一个“带高亮的 TextBox”,这是对它的低估。它其实自带了一套完整的文本编辑器基础设施:Document 模型、行管理、可视化区域渲染、折叠管理器、文本段标记、智能提示的宿主层。
我实际用到的核心能力有这几个:
第一,Document 与行对象体系。AvalonEdit 的 Document 是线段树结构,文本变化会以 TextChange 事件发出。每一行是一个 DocumentLine 对象,有行号、长度、偏移量,遍历行、定位行号、计算坐标都非常高效。第二,TextSegmentMarkerCollection,也就是“文本语义标记层”。它可以把任意一段文本标记成波浪线、下划线、高亮背景。这简直是为 Roslyn 诊断量身定做的:拿到 Diagnostic 的行号和列号,就能转成文本偏移量,直接挂 marker。第三,CompletionWindow 的宿主,AvalonEdit 的智能提示窗口控件支持可定制的内容列表和事件回调,我只需要把 Roslyn 返回的补全项塞进去就行。
AvalonEdit 不做什么?它不做任何和“编程语言知识”相关的事。它不知道class是什么,不知道await后面应该跟什么,不知道List<T>是一个泛型集合。所有的“语言智力”都必须从 Roslyn 那边供给。
2.2 Roslyn 的核心机制:从 SyntaxTree 到 SemanticModel
Roslyn 对 C# 源码的处理分几个层次,每一个层次都是我在编辑器里要直接交互的对象。
最底层是 SyntaxTree,即语法树。它把源码拆成 token(关键字、标识符、标点)和节点(表达式、语句、声明)。语法树的生成不依赖任何外部信息,只要字符串是合法的 C# 文本,它就能建出树来。语法树最神奇的一点是,即使源码存在语法错误,它也能继续生成树,错误的位置会放一个“错误节点”或“缺失 token 修补”。这意味着我不用先“判断代码能不能编译”,就能做高亮、做折叠、做结构化导航。
SyntaxTree 之上是 SemanticModel,即语义模型。语义模型需要一组“前几层的知识”:当前项目的引用、NuGet 包、同项目其他文件的类型定义。它会做名字绑定、类型解析、成员查找。比如Foo.Bar()这行代码,SyntaxTree 只知道 Foo 是名字、Bar 是名字、括号是调用;SemanticModel 才能告诉你 Foo 指向哪个类、Bar 是方法还是属性、这个调用有没有参数错误。补全和诊断这两个核心功能,全都依赖 SemanticModel。
再往上就是 Workspace 和 Document。Workspace 是 Roslyn 里的“解决方案级”工作区模型,Document 则代表一个源文件(和磁盘上的文件一一对应)。我最开始尝试直接只给 Roslyn 传“一行字符串”让它分析,后来很快意识到不对:字符串分析虽然能出语法树,但 SemanticModel 需要一个“项目环境”,否则List<T>这种来自 BCL 的类型根本解析不出来,补全和诊断都会变成残疾行为。所以我改用 AdhocWorkspace,把当前编辑的文件加入一个临时项目,并给它配上基本的框架引用。
整个架构的分层关系可以用一句简单的话概括:AvalonEdit 负责把文本变成可交互的 UI,Roslyn 负责把文本变成可理解的知识,而我要做的是在两者之间建立一个低延迟、高同步的转换通道。
2.3 桥梁:文档同步与增量更新的关键
这个连接通道的设计,是整个项目最核心的技术决策。它解决的是一个几乎每个编辑器都要面对的问题:用户在文本框中敲了一个字符,Roslyn 侧的分析结果应该如何更新?
最粗暴的方案是在每次按键后,把整个文本重新解析一遍,然后刷新所有标记。这个方案在小文件下问题不大,但文件一旦超过几千行,或者 SemanticModel 需要重新绑定程序集引用,性能就完全不可接受了。用户打个字要等 200 毫秒才看到波浪线消失,那是灾难级的体验。
增量更新的核心是利用 Roslyn 的 TextChange 事件机制。用户在 AvalonEdit 里输入字符,编辑器的 Document 会产生一个 TextChange 对象,带有变化区间和新文本。我把它映射成 Roslyn 的 SourceText 变化,然后调用document.WithText(newText)生成一个新的 Document 实例,再通过workspace.TryApplyChanges应用解决。
这个映射并不是简单的“把字符串塞进去”。SourceText 在 Roslyn 内部是以不可变状态存在的,每一次改动都会创建一个新版本。而 Roslyn 的语法树和语义模型带有版本号机制,能够识别哪个部分被修改过,只对受影响区域做增量重解析。所以在“修改字符 → 拿到新文本 → 应用变更”这条链路里,真正耗时的部分被 Roslyn 增量解析本身吸收掉了,我只需要保证这条链路够快、够及时。
文档同步的触发时机我选择的是 TextChange 事件而非键盘事件,因为键盘输入可能被输入法组合、快捷键粘贴批量处理,而 TextChange 反映的是最终文本变化,更准确。
这个架构有个意外的收获:因为 Roslyn 侧维护了一个和编辑器 UI 几乎同步的 Document 对象,我后面做代码折叠、括号匹配、区域命名时,都直接在 Roslyn 的语法树出发,而不是在 UI 控件上猜。语法树能告诉我哪个{对应哪个},哪个#region包裹了哪些行,这些结构信息是任何普通文本标记方案都给不了我的。
3. 从零搭建:核心功能的实操实现
3.1 项目骨架与依赖配置
动手前先把依赖确认清楚。我创建了一个 Avalonia MVVM 风格的项目,目标框架是 .NET 8。NuGet 包方面,除了 Avalonia 本体,还需要 AvaloniaEdit(Avalonia 版的编辑器控件),以及 Roslyn 的几个核心包。
下面是项目文件里的关键引用片段:
<ItemGroup> <PackageReference Include="Avalonia" Version="11.2.0" /> <PackageReference Include="Avalonia.Desktop" Version="11.2.0" /> <PackageReference Include="AvaloniaEdit" Version="0.11.0" /> <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.11.0" /> <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Features" Version="4.11.0" /> <PackageReference Include="Microsoft.CodeAnalysis.Workspaces.Common" Version="4.11.0" /> </ItemGroup>注意Microsoft.CodeAnalysis.CSharp.Features这个包,它是补全服务和特定诊断 provider 所在的包。很多人搭 Roslyn 工具时只引了分析器包,结果一调用补全服务就找不到类型,就是因为缺了这个 Features 包。
依赖装好之后,第一个耗费时间的环节是把 AvaloniaEdit 控件加载到窗口里。AvaloniaEdit 的基本接入方式非常简单,只需要在 XAML 里声明一个 Editor 控件,然后初始化时绑定 Document 实例。
3.2 让代码亮起来:语法高亮
高亮看起来是最简单的功能,但它直接决定了一个编辑器的“第一眼质感”。我用的是 Roslyn 语法树驱动的动态高亮方案,而不是 AvalonEdit 自带的正则高亮。
思路是这样:拿到当前 Document 的 SyntaxTree 根节点,递归遍历所有 token 和 trivia(注释、空白、预处理指令都算 trivia),根据 token 的Kind()来决定高亮分类,然后映射到 AvalonEdit 的颜色方案。比如Kind()是SyntaxKind.ClassKeyword的 token 归为关键字,SyntaxKind.StringLiteralToken归为字符串,SyntaxKind.SingleLineCommentTrivia归为注释。
核心代码大致长这样:
public class SyntaxHighlightingTransformer : ILineTransformer { private readonly SyntaxTree _tree; private readonly HighlightingColorizer _colorizer; public void TransformLine(ITextRunConstructionContext context, ITextRunConstructionContext lineContext) { // 从语法树中获取当前行的 node 和 token var text = context.DocumentText; var root = _tree.GetRoot(); var range = new TextSegment { StartOffset = context.Offset, Length = context.Length }; var tokens = root.DescendantTokens() .Where(t => t.SpanStart >= range.StartOffset && t.Span.End <= range.EndOffset); foreach (var token in tokens) { var color = token.Kind() switch { SyntaxKind.ClassKeyword or SyntaxKind.PublicKeyword => "keyword", SyntaxKind.StringLiteralToken => "string", SyntaxKind.IdentifierToken => "identifier", _ => "default" }; lineContext.SetTextRunProperties(new TextRunProperties(color)); } } }上面这段代码为了便于阅读做了很大简化,真正的实现细节更繁琐,因为它要处理 ILineTransformer 的接口规范、文本偏移量映射、以及缓存 token 列表避免每次全量扫描。但核心思想就是“让 Roslyn 定义什么是关键字,而不是让正则猜测”。
这个方案比正则高亮强在一点:它能识别上下文。dynamic是关键字吗?在var x = dynamic.Method()里它可能是类型名,要结合语义模型才能判断。正则只能做到词法层面的粗暴匹配,而语法树能区分“真正关键字”还是“作为标识符使用”的上下文。
高亮还有一个隐藏性能问题:文件很长的时候,全量遍历 SyntaxTree 很慢。所以我只在行可见、或者行有变化时触发 Transformer。AvalonEdit 的 ILineTransformer 机制本身就有可视区域优化,它只对当前渲染到屏幕上的行调用,这等于天然帮我过滤掉了不可见区域的计算。
3.3 红色波浪线:实时诊断
诊断是 C# 编辑器的灵魂。当用户输入Console.WriteLine却忘了分号,或者把未定义的变量传给方法时,编辑器应该立刻在对应位置画一条红色波浪线。
Roslyn 提供了两套诊断获取方式:一是syntaxTree.GetDiagnostics(),只做语法级诊断,比如缺失分号、非法的表达式;二是semanticModel.GetDiagnostics(),更进一步,包括未定义名字、类型不匹配、访问权限错误等。实际项目里,我要把两者合并使用。
我先在后台异步取回诊断列表,然后转成 AvalonEdit 的标记。诊断对象里包含的行号列号是 0 基准,而 AvalonEdit 的 DocumentLine 是基于 1 基准的,这个映射关系一定要处理对,否则波浪线会画到错误的行上。转换后的代码大致是这个模式:
private void ApplyDiagnostics(IReadOnlyList<Diagnostic> diagnostics) { _textSegmentCollection.Clear(); foreach (var diag in diagnostics) { var line = _doc.GetLineByNumber(diag.Location.GetLineSpan().StartLinePosition.Line + 1); var offset = line.Offset + diag.Location.GetLineSpan().StartLinePosition.Character; var length = Math.Min(diag.Location.SourceSpan.Length, line.Length); var segment = new TextSegment { StartOffset = offset, Length = length }; var marker = new TextSegmentMarker(segment) { MarkerType = TextMarkerType.SquigglyUnderline }; marker.MarkerColor = Colors.Red; _doc.TextSegmentMarkerCollection.Add(marker); } }这里的细节是:SourceSpan.Length可能超出当前行长度(比如缺失 token 时 Roslyn 会给一个空的 span),所以我要用Math.Min把它截到行尾,否则波浪线会画到下一行去,造成视觉混乱。
实时代码分析必须在后台线程跑。Roslyn 的 API 大多是异步的,GetDiagnosticsAsync、GetSemanticModelAsync都返回 Task。我一开始直接在 UI 线程上等待这些异步方法,结果输入一个字符后 UI 卡顿非常明显。后来改成在后台 Task 里分析,然后用Dispatcher.UIThread.Post把结果回传给 UI 标记层,卡顿基本消失。
3.4 智能补全:最体现“编辑器灵魂”的功能
补全功能是整个项目里最复杂也最有成就感的模块。用户输入Console.的时候,编辑器要弹出WriteLine、ReadLine、Write等成员列表;输入str.的时侯要弹出 string 的成员方法;输入ne的时侯要给出new、nameof等关键字建议。
Roslyn 的补全服务封装了一个非常完整的 API。核心入口是CompletionService.GetService(document),它返回一个能针对代码位置生成补全列表的服务对象。
var completionService = CompletionService.GetService(document); var completionList = await completionService.GetCompletionsAsync(document, caretPosition);拿到CompletionList之后,里面的Items就是所有候选补全。每个补全项带有很多元数据:DisplayText、FilterText、Description、Glyph、Tags。把这些数据绑到 AvalonEdit 的智能提示窗口的 ListBox 上即可。
补全项的图案显示是另一个细节。AvalonEdit 的智能提示控件支持每个 item 的自定义模板。我给每个补全项绑定了一个小图标,图标的来源是Glyph属性。根据补全项的类型(类、方法、属性、字段、关键字),显示不同的图标样式,这样用户在列表里能迅速分辨成员类型,认知负担会小很多。
关键是插入逻辑。当用户选中一个补全项并按回车或 Tab 时,我需要计算正确的替换文本。这个计算不能是“把补全年字符串塞到光标位置”,因为补全项可能带有智能修改逻辑。比如用户输入Console.W,选中WriteLine后,应该只替换W之后的部分,而不是把整个Console.W都覆盖掉。
Roslyn 提供了一个CompletionChange对象来封装插入行为:
var change = completionList.CompletionList.GetChange(selectedItem, commitCharacter: null); var textChange = change.TextChange; var resultText = textChange.NewText ?? string.Empty; // 将光标定位到补全插入点之后 editor.Document.Replace(textChange.Span.Start, textChange.Span.Length, resultText); editor.CaretOffset = textChange.Span.Start + resultText.Length;这段逻辑正确处理了过滤器文本和补全文本不同的情况,避免补全后出现重复字符。
补全触发时机也很讲究。不能每次击键都弹补全框,那样会很烦。我的策略是:当用户输入了标识符字符(字母或下划线)且前面有一个触发句点.,或者输入了两个以上字母的组合,才触发查询。Roslyn 的ShouldTriggerCompletion静态方法就是用来判断的,它可以识别出.、<、:. 等上下文触发点。
3.5 多窗口与原文件管理:从“编辑单文件”到“打开目录”
做到这一步,编辑器已经能很好地编辑单个文件了。可现实场景是:真实的 C# 工程有几十个文件,用户总是在成组的文件之间跳转。所以我把功能扩展成了“目录模式”。
目录模式下,左侧是一个文件树,右侧打开对应文件。但这不只是多了个文件列表这么简单,它深刻地影响 Roslyn 侧的文档模型。之前是单文档的 AdhocWorkspace,现在每个打开的文件都要加入同一个 Project,这样它们之间才能互相引用,语义模型才能跨文件解析类型。
文件树实现我用的是 Avalonia 的 TreeView,但核心逻辑是我的文件系统遍历服务。在这个服务里,我做了两个决策:默认忽略bin和obj目录;只显示.cs、.xaml等源码文件。这样文件树不会变成整个垃圾堆。
当用户在文件树里点击一个文件,我会先检查这个文件是否已经作为一个 Document 存在于 Workspace 里,如果不存在,就调用AddDocument;如果已经存在,直接切换编辑器内容。文件的变化则通过文件系统监听(FileSystemWatcher)进行检测,一旦外部修改了文件,编辑器可以提示用户“文件已在磁盘上改变,是否重新加载”。
4. 踩坑实录与性能调优
4.1 异步竞态条件:输入太快结果错乱
这个坑我在做实时诊断时踩得最深。现象是:用户快速输入一段代码后,波浪线的位置常常跑到旧代码的位置上去了,过一会儿才跳回正确位置。甚至有时候编辑器显示的诊断和当前文本完全对不上。
原因很直白:用户在 UI 线程敲字符,后台线程在跑语义分析,后台线程回来的时候,编辑器文本已经变了。旧文本的分析结果当然不能应用到新文本上,但如果 UI 线程没有校验版本号,它就会稀里糊涂地把旧结果画上去。
解决方案是给每次分析打一个递增的版本号。UI 层触发分析时记录当前版本,后台分析完成回传时,UI 层先检查版本号是否仍然匹配,不匹配就丢弃,等待下一轮分析的结果。
private int _analysisVersion; private void OnTextChanged(object sender, EventArgs e) { var version = ++_analysisVersion; _ = AnalyzeAsync(editor.Text, version); } private async Task AnalyzeAsync(string text, int version) { var diagnostics = await Task.Run(async () => { var doc = GetRoslynDocument(text); var syntaxDiagnostics = await doc.GetSyntaxTreeAsync(); return await syntaxDiagnostics.GetDiagnosticsAsync(); }); if (version != _analysisVersion) return; // 过期结果,丢弃 ApplyDiagnostics(diagnostics); }除了版本号,还有一个不起眼但很致命的问题:后台任务并发执行会浪费 CPU。如果用户连续输入了 20 个字符,就会有 20 个分析任务同时在跑,大部分都会因为版本过期被丢弃,但计算已经浪费了。我加了一个简单的互斥机制,只要有一个分析任务在跑,新任务就先排队等待,而不是无脑并发。这样轮次更少,性能更稳定。
4.2 大文件渲染卡顿:不可见区域的计算最终都会找上门
AvalonEdit 自带可视区域优化,但它的高亮 Transformer 接口虽然只处理可见行,我却做了一个错误的优化——我把整棵语法树缓存在内存里,每次文本变化都重新解析。小文件没事,一旦打开一个几万行代码的生成文件,第一次解析就要几百毫秒,期间 UI 卡死。
最终的解药是分而治之:不要让整个文件进语法树分析,而是让 Roslyn 只分析“从当前位置向上向下各扩展 100 行”的局部区域。虽然这会损失全局语义,但语法高亮和简单诊断根本不依赖全局信息,语义诊断则可以按需从全局模型查询。也就是说:高亮用局部树,诊断用全局模型,分层处理、互不拖累。
这一套性能分层思路,我总结成了一张操作清单:
- 高频操作(输入、高亮、光标移动)只动局部 SyntaxTree,不碰全局
- 中频操作(补全、成员列表)使用当前 Document 的语义模型,但只算一次
- 低频操作(打开文件、全局诊断刷新)可以忍受 100 到 300 毫秒的延迟,但绝不能阻塞 UI
4.3 跨平台差异:换行符、字体与输入法
说好的是“跨平台编辑器”,就一定会暴露平台差异问题。第一个麻烦是换行符。在 Windows 上,C# 源码一般用 CRLF;在 Linux 和 macOS 上则是 LF。如果一上来就用错误的换行符保存,git diff 会直接碎掉。处理很简单:打开文件时先探测换行符类型,保存时保持原样。AvalonEdit 的 Document 提供了LineTerminator设置,但我最终选择了“原样往返”策略,保留文件本身的末尾换行符格式。
第二个问题在字体。WPF 环境下 Cascadia Code 的等宽表现非常细腻,到了 Avalonia 上,不同平台的字体渲染引擎差异会直接导致字符宽度偏差,进而引起光标位置偏移。解决方式是做一次字体回退检测:优先使用用户配置的字体,如果该系统没有该字体,则回退到系统等宽字体,并在配置界面里提供字体预览。
第三个痛点是输入法。在 Windows 上键入中文注释时,输入法组合窗口有时会被 AvalonEdit 的键盘处理逻辑打断。Avalonia 的输入法支持虽然已在逐步完善,但组合状态的兼容性仍然不如原生控件。我的处理是:在控件样式里显式声明UseLayoutRounding="True",并对输入法组合字符串使用物理光标定位,而不是逻辑文本偏移,这能减少大部分中文输入时“光标飘走”的现象。
跨平台调试时的体验非常折磨:本地修好一个输入法问题,到另一平台重新出现。我的建议是:如果你做跨平台编辑器,从一开始就在三个系统上同时跑细粒度测试,不要留到最后统一“修 bug”。逐平台修正的成本比统一重构要高得多。
5. 这个项目能走多远:从编辑器到 IDE 的最后一公里
5.1 目前已经达成的能力
现阶段这个编辑器的功能清单大概是这样:
| 功能模块 | 当前能力 | 实现方式 |
|---|---|---|
| 语法高亮 | 关键字、字符串、注释、类名、类型名区分 | Roslyn 语法树 + 语义模型分类 |
| 实时诊断 | 语法错误、未定义变量、类型不匹配 | Roslyn Diagnostic + AvalonEdit 波浪线 |
| 智能补全 | 类型成员、关键字、方法签名 | Roslyn CompletionService + AvalonEdit 弹出窗 |
| 代码折叠 | 支持#region、方法体、类声明折叠 | Roslyn 节点范围 + AvalonEdit 折叠管理 |
| 目录浏览 | 打开文件夹、切换文件、外部变更监听 | 文件系统遍历 + AdhocWorkspace |
| 多平台 | Windows / Linux / macOS 均可用 | Avalonia 渲染 |
这套组合已经能支撑我日常的 LeetCode 刷题、小工具开发、以及阅读中等规模源码的场景。相比 Visual Studio 的启动时间和内存占用,这个编辑器完全不构成负担。
5.2 还差的“最后一公里”
公平地说,它离 VS 还有明显差距,主要集中在下面几个方向:
调试器完全没做。C# 调试需要和 CLR 调试 API 打交道,工作量大到一个独立项目都未必做得好。如果需要断点调试,当前还得把 IDE 拉回来干活。重构支持也是零。Roslyn 提供了一套CodeAction抽象,可以实现“提取方法”、“重命名符号”这类操作,但接入到 UI 层需要处理文档多个位置的同步修改,这项工作比补全要复杂不少。
另外,缺少对 csproj 文件的完整解析和 NuGet 依赖恢复。目前我是手工给 AdhocWorkspace 加引用,还没做到读取项目文件自动构建依赖语义模型。这会导致跨文件的类型补全和诊断不太准确。这一块如果要做好,相当于做一个迷你版的“项目系统”,是整个项目里最重的一个部分。
但我会继续做下去。下一步计划是先接 Roslyn 的代码操作(Quick Actions),把“添加 using”“生成类”“重构变量名”这些高频代码操作点出来。第二个方向是研究DocumentOutline控件,实现类似 VS 的类视图(成员导航面板)——根据 SemanticModel 列出当前文件里的类、方法、属性列表,点击即可跳转。这个功能对阅读大文件极其有用。
最后说一点个人体会:自研编辑器最大的收益不是“功能上追平 VS ”,而是让我真正理解了 IDE 背后的核心机制。以前我用 VS 的时候,以为高亮是正则模板、补全是字符串匹配,做完整个项目才发现,Roslyn 构建了一张完整的语义网,IDE 的每个“智能”功能都是在网里做查询。理解了这一点之后,你再看任何现有的 C# 工具,都会有一种“原来如此”的豁然感。
如果你也想做自己的工具,我的建议是:不要苛求一步到位。先做一个只能高亮的编辑器,再逐步加诊断、补全、折叠,每次只走一小步,让每一步都能跑起来、能验证、能被你日常使用。工具是慢慢长出来的,不是设计出来的。我自己就是从“半夜里看到一个红色波浪线亮起来”的那一刻开始,真正爱上了这个项目的。