简介:这是一份面向C#初学者与中级开发者的学习型源码资源,聚焦于可编译、可扩展的智能脚本编辑器实现,解决日常轻量级C#代码快速编写、语法高亮、智能提示及一键生成exe/dll等实际开发痛点。资源共174个文件,包含54个核心C#源码文件(如SharpEdit.csproj、FastColoredTextBox相关类)、20个本地化resources资源、17个UI图标与界面素材png、10个调试用pdb文件、10个运行依赖dll,以及sln解决方案、csproj项目文件和配置类xml等,完整呈现VS工程结构与编译链路,压缩包仅1.74MB,轻量易读。已有2365人学习下载,适合通过源码逆向理解C#编译原理(如CSharpCodeProvider调用)、文本编辑器架构(词法分析+符号表+事件驱动插件机制)及WinForm高级控件(FastColoredTextBox)集成实践。
1. 为什么一个“智能的C#脚本编辑器”比你想象中更难做,也更值得做?
不是所有带“智能”二字的工具都只是加了点语法高亮和自动补全——真正的智能C#脚本编辑器,是能让非资深开发者在不编译、不部署、不配环境的前提下,安全地写、即时地跑、可控地调试一段具备真实业务逻辑的C#代码。它不是Visual Studio的简化版,而是面向自动化任务、规则引擎插件、低代码后端胶水层、教学沙箱或运维脚本场景的专用执行环境。这类编辑器的核心矛盾在于:既要保留C#语言的强类型、LINQ、async/await等生产力特性,又要绕过传统.NET项目构建链(csproj、nuget restore、dotnet build),实现“粘贴即运行”。网上能搜到的多数所谓“源码”,要么是WinForms里套个RichTextBox加简单关键字着色,要么依赖完整SDK路径硬编码,一换机器就报错;真正可落地的方案,必须解决动态编译隔离、Assembly加载沙箱、上下文生命周期管理、错误定位映射这四大黑匣子。本文带你从零手撸一个最小可行版本,不依赖任何IDE插件或商业框架,所有代码可直接编译运行,重点讲清每个CSharpScript调用背后的真实行为边界和血泪踩坑点。
2. 用Microsoft.CodeAnalysis.Scripting在本地跑通C#脚本:最小命令与执行上下文初始化
2.1 为什么选Scripting而非Roslyn Workspaces或dotnet-script?
很多开发者第一反应是“用Roslyn不就行了?”,但这里必须划清三条线:
Microsoft.CodeAnalysis.Workspaces是为设计时分析(如VS里的实时诊断、重构)服务的,它重度依赖MSBuild工程模型,无法脱离.csproj存在;dotnet-script是一个封装良好的CLI工具,但它本质是启动新进程执行dotnet exec,无法嵌入宿主程序做细粒度控制(比如限制CPU时间、捕获变量快照、注入自定义类型);- 而
Microsoft.CodeAnalysis.Scripting(即CSharpScript)是微软官方提供的轻量级、内存内、可编程式脚本引擎,它不生成磁盘DLL,所有编译、解析、执行都在内存完成,且支持自定义ScriptOptions、GlobalVariables和AssemblyLoadContext,这才是嵌入式脚本编辑器的唯一正解。
提示:NuGet包名是
Microsoft.CodeAnalysis.Scripting,注意不要装错成Microsoft.CodeAnalysis(那是编译器API,不含Scripting层)或Microsoft.CodeAnalysis.CSharp.Scripting(这是旧版命名空间,已弃用)。
2.2 创建可运行的最小脚本宿主:三行核心代码与参数含义
新建一个 .NET 6+ 控制台项目(推荐.NET 8,兼容性更好),执行以下命令安装依赖:
dotnet add package Microsoft.CodeAnalysis.Scripting --version 4.10.0然后在Program.cs中写入最简可执行脚本宿主:
using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.Scripting; // 1. 定义全局变量容器(可传入用户代码能访问的对象) var globals = new { Name = "World", Count = 42 }; // 2. 构建脚本选项:指定语言版本、引用程序集、导入命名空间 var options = ScriptOptions.Default .WithLanguageVersion(LanguageVersion.CSharp12) // 显式指定C#版本,避免默认值漂移 .AddReferences(typeof(Console).Assembly) // 必须显式添加System.Console所在程序集 .AddReferences(typeof(System.Linq.Enumerable).Assembly) // LINQ支持 .AddImports("System", "System.Linq", "System.Collections.Generic"); // 常用命名空间 // 3. 编译并执行脚本(返回Task<object>,需await) var script = CSharpScript.Create<int>("return Count * 2 + Name.Length;", options, globals); var result = await script.RunAsync(); Console.WriteLine($"Result: {result}"); // 输出:Result: 95关键参数说明:
globals:必须是匿名类型或具名类实例,不能是dynamic或object,否则运行时报ScriptCompilationException;它的属性会成为脚本中的全局变量(如Count、Name)。ScriptOptions.Default:提供基础配置,但必须调用.AddReferences()显式添加所有依赖程序集——这是新手翻车第一高发区。typeof(Console).Assembly获取的是System.Console.dll,不是mscorlib.dll或System.Runtime.dll,后者会被自动推导,前者必须手动加。WithLanguageVersion():强烈建议显式设置。若不设,不同.NET SDK版本下默认值可能不同(如.NET 6默认C#10,.NET 8默认C#12),导致同一段脚本在CI和本地行为不一致。
2.3 支持async/await的脚本:如何正确返回Task 并等待结果?
C#脚本天然支持async,但宿主必须用Script<object>并调用GetCompletionResultAsync(),不能直接await RunAsync()——后者只适用于同步返回值。看这个典型场景:调用HTTP API并返回JSON长度。
// 注意:此处必须用 Script<object>,因为async lambda返回的是Task<object> var asyncScript = CSharpScript.Create<object>(@" using System.Net.Http; var client = new HttpClient(); var response = await client.GetAsync(""https://httpbin.org/get""); return response.Content.ReadAsStringAsync().Result.Length; // 简化演示,实际应await ", options.WithReferences(typeof(HttpClient).Assembly)); var state = await asyncScript.RunAsync(); // 此时state.ReturnValue是Task<object>,需进一步await才能拿到结果 var finalResult = await ((Task<object>)state.ReturnValue); Console.WriteLine($"HTTP response length: {finalResult}");避坑点:
HttpClient所在的程序集是System.Net.Http.dll,必须通过typeof(HttpClient).Assembly显式添加,不能靠AddImports自动引入;- 若脚本中用了
await,RunAsync()返回的是ScriptState<object>,其ReturnValue字段是Task<object>,必须二次await,否则你会得到一个未完成的Task对象,打印出来是System.Threading.Tasks.Task'1[System.Object]; - 不要在脚本里直接
await Task.Delay(1000)做延时测试——它会阻塞整个UI线程(如果嵌入WinForms/WPF),应改用Task.Run(() => Thread.Sleep(1000))模拟CPU密集型操作。
3. 实现语法高亮与智能提示:基于SyntaxTree的实时解析与符号提取
3.1 用CSharpSyntaxTree解析代码结构,不依赖VS SDK
语法高亮不是简单正则匹配关键字——那会把字符串里的"class"也标红。真·高亮必须走Roslyn语法树。我们不用Workspace,只用轻量级CSharpSyntaxTree:
using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.Text; string code = @"var x = 10; Console.WriteLine(x);"; // 1. 将字符串转为SourceText(Roslyn内部文本表示) SourceText sourceText = SourceText.From(code); // 2. 解析为语法树(不编译,极快) SyntaxTree tree = CSharpSyntaxTree.ParseText(sourceText); // 3. 遍历所有Token,按Kind分类着色 foreach (var token in tree.GetRoot().DescendantTokens()) { switch (token.Kind()) { case SyntaxKind.StringLiteralToken: Console.WriteLine($"[STRING] {token}"); break; case SyntaxKind.IdentifierToken: // 进一步判断是否为关键字、类型名或变量名 if (SyntaxFacts.GetText(token.Kind()).Contains("class")) Console.WriteLine($"[KEYWORD] {token}"); else Console.WriteLine($"[IDENTIFIER] {token}"); break; case SyntaxKind.NumericLiteralToken: Console.WriteLine($"[NUMBER] {token}"); break; } }为什么不用正则?
- 正则无法区分
var在var x = 1;(上下文关键字)和var在string var = "test";(变量名); - 正则无法识别泛型
List<int>中的<和>是类型分隔符而非比较运算符; - 正则对多行字符串字面量(
@"line1\r\nline2")和逐字字符串($"Hello {name}")完全无能为力。
3.2 实现基础智能提示:从SyntaxTree中提取当前光标位置的可用符号
智能提示(IntelliSense)的核心是:给定代码字符串和光标位置(字符索引),返回该位置前缀可补全的所有标识符。我们用SemanticModel获取语义信息:
// 继续上例,tree已存在 SemanticModel model = await CSharpCompilation.Create("Script") .AddSyntaxTrees(tree) .AddReferences(MetadataReference.CreateFromFile(typeof(Console).Assembly.Location)) .GetSemanticModelAsync(); // 假设光标在第15个字符(即"Console."后的空位) int cursorPosition = 15; var position = tree.GetRoot().FindToken(cursorPosition); // 获取该Token所在语句的语义信息 var symbolInfo = model.GetSymbolInfo(position.Parent); if (symbolInfo.Symbol is INamedTypeSymbol typeSymbol) { // 列出该类型的公共成员(方法、属性) foreach (var member in typeSymbol.GetMembers().Where(m => m.DeclaredAccessibility == Accessibility.Public)) { Console.WriteLine($"Suggestion: {member.Name} ({member.Kind})"); } }关键约束:
SemanticModel必须由CSharpCompilation构建,而CSharpCompilation需要MetadataReference——这就是为什么前面强调必须传入typeof(Console).Assembly.Location;FindToken(cursorPosition)返回的是最内层Token,但智能提示通常需要向上找MemberAccessExpressionSyntax节点(即Console.这种结构),所以实际工程中要position.Parent?.Parent向上遍历;- 此方案仅支持静态分析,不支持运行时动态类型(如
dynamic变量的成员),这是设计使然——脚本编辑器的提示必须在执行前给出,不能等RunAsync()之后再查。
3.3 高亮错误:将编译异常映射回编辑器行号列号
当用户敲错代码,CSharpScript.Create()会抛CompilationErrorException,但异常信息里的行号是Script内部生成的虚拟文件名(如<script>),无法直接对应编辑器真实行号。必须用Diagnostic对象提取位置:
try { var script = CSharpScript.Create("var x = ;", options); } catch (CompilationErrorException ex) { foreach (var diag in ex.Diagnostics) { // diag.Location.GetLineSpan() 可获取真实行列 var lineSpan = diag.Location.GetLineSpan(); int line = lineSpan.StartLinePosition.Line + 1; // Roslyn行号从0开始,编辑器通常从1 int column = lineSpan.StartLinePosition.Character + 1; Console.WriteLine($"Error at L{line}, C{column}: {diag.GetMessage()}"); // 输出:Error at L1, C11: Invalid expression term ';' } }注意:diag.Location可能为NoLocation(如语法错误在文件开头),此时需降级处理为“第1行”。
4. 避坑:C#脚本编辑器开发中5个高频翻车点与解决方案
4.1 现象:脚本中调用DateTime.Now正常,但DateTimeOffset.Now报“找不到类型”
原因:DateTimeOffset定义在System.Runtime.dll,而typeof(Console).Assembly只提供System.Console.dll,System.Runtime.dll虽是.NET Core共享框架一部分,但ScriptOptions.AddReferences()不会自动包含它。
解决:显式添加System.Runtime.dll引用:
var runtimePath = Path.Combine(Path.GetDirectoryName(typeof(object).Assembly.Location), "System.Runtime.dll"); options.AddReferences(MetadataReference.CreateFromFile(runtimePath));4.2 现象:脚本里new List<string>()能运行,但list.Add("a")提示“未找到Add方法”
原因:List<T>的Add方法是泛型方法,SemanticModel在无上下文时无法推断T,导致符号解析失败。这不是Bug,是Roslyn设计限制。
解决:在脚本中显式指定泛型参数,或改用var list = new List<string>(); list.Add("a");——让编译器从new List<string>推断list类型。
4.3 现象:多次执行同一段脚本后,内存占用持续上涨,GC无法回收
原因:CSharpScript.Create()每次都会生成新的Assembly并加载到默认AssemblyLoadContext,而默认上下文中的Assembly永不卸载(.NET Core 3+才支持自定义可卸载上下文)。
解决:使用CollectibleAssemblyLoadContext(仅.NET Core 3.0+):
var context = new AssemblyLoadContext(isCollectible: true); var script = CSharpScript.Create("...", options.WithAssemblyLoadContext(context)); // 执行完后调用 context.Unload() 即可释放内存4.4 现象:脚本中#r "Newtonsoft.Json.dll"能加载,但using Newtonsoft.Json;仍报错
原因:#r指令只添加引用,不自动导入命名空间;#load用于加载其他脚本文件,与引用无关。
解决:在ScriptOptions中同时调用:
options.AddReferences(assembly) // 加载DLL .AddImports("Newtonsoft.Json"); // 导入命名空间4.5 现象:WPF应用中嵌入脚本编辑器,首次执行正常,第二次执行卡死UI
原因:CSharpScript.RunAsync()返回的Task默认在TaskScheduler.FromCurrentSynchronizationContext()上调度,而WPF的DispatcherSynchronizationContext会将延续任务封送到UI线程。若脚本本身含await Task.Delay(),延续任务会排队等待UI空闲,形成死锁。
解决:强制在ThreadPool上执行:
var task = script.RunAsync(); await task.ConfigureAwait(false); // 关键!避免捕获SynchronizationContext5. 支持用户自定义类型与扩展方法:让脚本真正“接入”你的系统
5.1 向脚本注入宿主程序中的类实例:不只是简单变量
globals只能传入只读数据,但业务常需传入可调用的服务对象(如日志记录器、数据库连接器)。这时要用ScriptOptions.WithGlobalsType()配合自定义类:
public class ScriptHostServices { public ILogger Logger { get; set; } public IDbConnection Db { get; set; } public void Log(string msg) => Logger?.LogInformation(msg); public async Task<int> ExecuteSql(string sql) => await Db.QuerySingleAsync<int>(sql); } // 创建实例并传入 var services = new ScriptHostServices { Logger = new ConsoleLogger(), Db = new SqlConnection("...") }; var options = ScriptOptions.Default .WithGlobalsType(typeof(ScriptHostServices)) .AddReferences(typeof(ScriptHostServices).Assembly); var script = CSharpScript.Create<int>( "Log(\"Starting script\"); return await ExecuteSql(\"SELECT COUNT(*) FROM Users\");", options, services // 注意:这里传入的是实例,不是类型 );关键点:
WithGlobalsType()指定的是全局变量的类型,不是实例类型;RunAsync(globals)的globals参数必须是该类型的实例;ScriptHostServices中的方法必须是public,且参数类型必须是脚本能理解的(如string、int、Task<T>),不能是IQueryable<T>等EF Core特有类型;- 若方法返回
Task<T>,脚本中必须await,否则会得到未完成Task对象。
5.2 在脚本中使用扩展方法:必须满足三个硬性条件
扩展方法不是魔法,它依赖编译器在编译期插入静态调用。要在脚本中用"hello".ToTitleCase(),必须同时满足:
| 条件 | 说明 | 示例 |
|---|---|---|
| 1. 扩展方法类必须是public static | 非static类或internal类会被忽略 | public static class StringExtensions |
| 2. 扩展方法本身必须是public static | private static或internal static不可见 | public static string ToTitleCase(this string s) |
| 3. 扩展方法所在程序集必须被AddReferences,且命名空间被AddImports | 缺一不可 | .AddReferences(typeof(StringExtensions).Assembly).AddImports("MyApp.Extensions") |
验证方式:在脚本中写typeof(StringExtensions).GetMethod("ToTitleCase"),若返回null,则说明未正确加载。
5.3 安全沙箱:限制脚本只能访问白名单程序集
生产环境绝不能允许脚本随意File.ReadAllText(@"C:\passwords.txt")。ScriptOptions本身不提供沙箱,需结合AssemblyLoadContext和AssemblyResolve事件:
public class RestrictedLoadContext : AssemblyLoadContext { private readonly AssemblyDependencyResolver _resolver; private readonly HashSet<string> _allowedAssemblies = new() { "System.Runtime", "System.Console", "System.Linq" }; public RestrictedLoadContext() : base(isCollectible: true) { _resolver = new AssemblyDependencyResolver(typeof(Program).Assembly.Location); } protected override Assembly Load(AssemblyName assemblyName) { if (_allowedAssemblies.Contains(assemblyName.Name)) { var path = _resolver.ResolveAssemblyToPath(assemblyName); return path != null ? LoadFromAssemblyPath(path) : null; } return null; // 拒绝加载黑名单程序集 } } // 使用时 var context = new RestrictedLoadContext(); var options = ScriptOptions.Default.WithAssemblyLoadContext(context);效果:当脚本中写using System.IO; File.ReadAllText(...)时,System.IO.dll不在白名单中,Load()返回null,编译直接失败,报错CS0234: The type or namespace name 'IO' does not exist in the namespace 'System'。
6. 调试与热重载:让脚本编辑器像IDE一样可断点、可重跑、可查变量
6.1 实现“单步执行”假象:用ExpressionVisitor重写脚本为带日志的版本
Roslyn不支持脚本断点,但我们可以通过AST重写,在关键节点插入日志输出,模拟单步效果:
public class LoggingRewriter : CSharpSyntaxRewriter { private int _stepId = 0; public override SyntaxNode VisitExpressionStatement(ExpressionStatementSyntax node) { var expr = node.Expression; var logCall = SyntaxFactory.ParseStatement( $"Console.WriteLine($\"STEP {_stepId++}: {expr.ToString()} = {{{expr.ToString()}}}\");" ); return SyntaxFactory.Block(logCall, node); } } // 使用 var tree = CSharpSyntaxTree.ParseText("var x = 1; x++; Console.WriteLine(x);"); var rewriter = new LoggingRewriter(); var newTree = rewriter.Visit(tree.GetRoot()); Console.WriteLine(newTree.ToFullString()); // 输出:Console.WriteLine($"STEP 0: var x = 1 = {var x = 1}"); // var x = 1; // Console.WriteLine($"STEP 1: x++ = {x++}"); // x++; // ...局限性:这只是日志注入,不是真断点,但对教学、调试逻辑流极其有效,且无需调试器支持。
6.2 热重载:监听.cs文件变化,自动重新编译脚本
利用FileSystemWatcher监听脚本文件,配合CSharpScript.Create()缓存编译结果:
var watcher = new FileSystemWatcher(".", "*.cs"); watcher.Changed += async (s, e) => { try { string code = File.ReadAllText(e.FullPath); // 重新创建脚本(注意:此处应复用options,避免重复AddReferences) _currentScript = CSharpScript.Create(code, _options, _globals); Console.WriteLine($"Reloaded {e.Name}"); } catch (Exception ex) { Console.WriteLine($"Reload failed: {ex.Message}"); } }; watcher.EnableRaisingEvents = true;注意:_currentScript应是Script<T>类型变量,每次重载后旧脚本对象可被GC回收(前提是没用CollectibleAssemblyLoadContext,否则需手动Unload())。
6.3 查看运行时变量:从ScriptState中提取所有局部变量值
ScriptState<T>的Variables属性是ImmutableArray<Variable>,但它是内部类型,需用反射读取:
var state = await script.RunAsync(); var variablesField = state.GetType().GetField("_variables", BindingFlags.NonPublic | BindingFlags.Instance); var variables = (ImmutableArray<Variable>)variablesField.GetValue(state); foreach (var v in variables) { Console.WriteLine($"{v.Name} = {v.Value} (type: {v.Type})"); }风险提示:_variables是私有字段,未来Roslyn版本可能改名。生产环境建议用ScriptState.GetVariable<T>(name)按名称取值,更稳定。
我做这个方向三年,踩过最多的是Assembly加载顺序和CollectibleAssemblyLoadContext的卸载时机——有次在客户现场,脚本每执行一次内存涨2MB,连续跑两小时后服务OOM,最后发现是忘了调context.Unload()。现在我的习惯是:所有Create()都配对using var context = new CollectibleAssemblyLoadContext();,哪怕多写两行,也比半夜被电话叫醒强。希望帮到你。
本文还有配套的精品资源,点击获取