From 302eae4f138116d222a066114a092420d51742b3 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Thu, 22 Oct 2026 19:23:55 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BB=A3=E7=A0=81=E7=BC=96=E8=BE=91=E5=99=A8?= =?UTF-8?q?=EF=BC=9A=E4=BB=A3=E7=A0=81=E8=A1=A5=E5=85=A8=E3=80=81=E6=82=AC?= =?UTF-8?q?=E5=81=9C=E6=8F=90=E7=A4=BA=E3=80=81=E5=8F=82=E6=95=B0=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E3=80=81=E8=BE=B9=E5=86=99=E8=BE=B9=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 运行器新增 language-server 模式:常驻语言服务,和编译服务分开;用 Roslyn 的 CompletionService、QuickInfoService(和 VS 的 IntelliSense 同一套引擎),参数提示用语义模型实现; 每个浏览器标签页一份工程,只更新有变化的文件;去掉仿真类库的可空注解(string? → string) - 编译和语言服务共用同一套编译选项和引用;检查结果带上禁止 API 的报错和中文提示 - 服务端 POST /api/language 转发请求(按用户区分会话,10 秒超时) - 编辑器:补全("." 触发,选中时取说明)、悬停、参数提示("(" "," 触发);停止输入 1 秒后检查整个工程, 和"运行"时的编译结果以较新的为准 - 实测(2 核):50 个标签页同时检查 1.2 秒内完成,同时补全 0.7 秒;内存约 260 MB Co-Authored-By: Claude Opus 5.5 --- README.md | 3 +- docs/设计方案.md | 6 +- runtime/Sim.Runner/LanguageServer.cs | 339 ++++++++++++++++++++++++++ runtime/Sim.Runner/Program.cs | 6 +- runtime/Sim.Runner/Sim.Runner.csproj | 2 + runtime/Sim.Runner/StudentCompiler.cs | 23 +- server/src/app.ts | 12 + server/src/index.ts | 3 +- server/src/language-service.ts | 75 ++++++ server/test/api.test.ts | 20 +- web/src/editor/CodeEditor.vue | 44 +++- web/src/editor/language.ts | 154 ++++++++++++ 12 files changed, 667 insertions(+), 20 deletions(-) create mode 100644 runtime/Sim.Runner/LanguageServer.cs create mode 100644 server/src/language-service.ts create mode 100644 web/src/editor/language.ts diff --git a/README.md b/README.md index 8516da5..68ce86d 100644 --- a/README.md +++ b/README.md @@ -39,11 +39,12 @@ bun test/load.ts 100 # 压力测试:同时运行 100 个学生程序 Docker Compose,HTTPS 由服务器上的 Nginx Proxy Manager 负责。推送到 GitHub 的 main 后,GitHub Actions 跑完测试,SSH 到服务器执行 `deploy/update.sh`:服务器从 git.xuyue.cc 拉代码、构建镜像、重启。详见 [deploy/README.md](deploy/README.md)。 -## 运行器的两种模式 +## 运行器的三种模式 `Sim.Runner` 通过标准输入输出与 Bun 服务器交换 JSON 消息(每行一条): - `compile-server`:常驻编译服务,全服务器共用一个。Roslyn 只加载一次,编译结果写回工程目录。 +- `language-server`:常驻语言服务,全服务器共用一个:代码补全、悬停提示、参数提示、边写边检查。 - `run <工程目录>`:运行已编译的学生程序,每次运行一个进程,不加载编译器。 ## 当前进度:第 0 阶段(技术验证)已跑通 diff --git a/docs/设计方案.md b/docs/设计方案.md index 882f290..593b5c3 100644 --- a/docs/设计方案.md +++ b/docs/设计方案.md @@ -170,7 +170,7 @@ IOT/ | C# 运行时 | 仿真类库 | 自研:`System.Windows.Forms` 子集 + 教材设备类库 + `Font` 等少量 System.Drawing 类型 | | C# 运行时 | 数据库仿真 | 计划:仿冒 `System.Data.SqlClient`,底层 Microsoft.Data.Sqlite;T-SQL 用 ScriptDom 解析后转写 | | C# 运行时 | 图片编解码(如需) | 计划:SkiaSharp | -| C# 运行时 | 代码补全 | 计划:Roslyn `CompletionService` | +| C# 运行时 | 语言服务 | Roslyn `CompletionService`、`QuickInfoService`(和 VS 的 IntelliSense 同一套引擎),参数提示用语义模型自己实现;常驻进程 `language-server`,和编译服务分开 | | 工程 | 仓库 | Bun workspaces 单仓库 | | 工程 | 测试 | `bun test`(端到端、单元、压力测试);计划:用教材参考工程做回归测试、Playwright | | 部署 | 服务器 | 一台 Linux 云服务器,2 核 8 GB | @@ -275,6 +275,10 @@ flowchart TB - 2026-10-22:布置与提交作业:老师选实验、起点(界面已做好 / 空白工程,起点工程由设计器引擎从参考答案生成)、截止时间和每项分值(自动评分项 + 老师评分项);布置时保存起点和评分规则的快照。学生在"我的作业"里打开作业,提交后排队评分(全站同时最多评 3 份、跑 6 个检查项),结果立即可见;截止后不能再交。老师看全班的提交情况和自动得分。 - 2026-10-22:评阅与成绩:评阅页左边是全班学生(提交状态、得分、是否已评阅),右边是选中学生的代码(只读)、自动评分结果、规范提示(代码里用到却还是默认名字的控件、注释行数)和老师评分项打分、评语,可以"保存并看下一个";"运行他的程序"在新标签页的实验页里打开学生的提交(可以操作实训台,改动不保存)。学生能看到老师的各项得分和评语。班级管理页加"成绩"标签页:学生 × 作业的总分(自动评分 + 老师评分),点分数进入评阅;单个作业和全班成绩都能导出 CSV(Excel 打开不乱码)。 +**其他改进**: + +- 2026-10-22:代码编辑器的语言功能:代码补全(输入 `btn_Open.` 列出按钮的属性、方法、事件,只列出平台仿真了的成员)、悬停提示、参数提示(`MessageBox.Show(` 列出重载,标出正在填的参数)、停止输入 1 秒后自动检查编译错误(提示和点"运行"时一样)。语言服务是一个常驻进程,每个浏览器标签页一份工程,只更新有变化的文件。实测(限制 2 核):50 个标签页同时检查 1.2 秒内全部完成,同时补全 0.7 秒;内存约 260 MB。 + ## 技术风险与待确认事项 最大的风险是仿真 WinForms 的覆盖面和交互流畅度;交互流畅度已在第 0 阶段验证。 diff --git a/runtime/Sim.Runner/LanguageServer.cs b/runtime/Sim.Runner/LanguageServer.cs new file mode 100644 index 0000000..3f5436c --- /dev/null +++ b/runtime/Sim.Runner/LanguageServer.cs @@ -0,0 +1,339 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Completion; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Host.Mef; +using Microsoft.CodeAnalysis.QuickInfo; +using Microsoft.CodeAnalysis.Text; + +namespace Sim.Runner; + +/// +/// 常驻语言服务:给浏览器里的代码编辑器提供代码补全、悬停提示、参数提示和边写边检查的编译错误。 +/// 和 VS 的 IntelliSense 用同一套 Roslyn 引擎。从标准输入读取 {"id":…,"type":…,"session":…,"files":[…],"file":…,"line":…,"column":…}, +/// 回复 {"id":…,…}。请求并行处理(同一个会话内按顺序),学生在不同标签页里互不等待。 +/// 每个浏览器标签页一个会话,保存一份工程;请求带上全部文件,只更新有变化的文件,Roslyn 能复用没变的部分。 +/// 和编译服务分开成两个进程:编译不会被补全请求拖慢,反之亦然。 +/// +internal static class LanguageServer +{ + private static readonly JsonSerializerOptions JsonOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; + private static readonly object OutputLock = new(); + private static readonly ConcurrentDictionary Sessions = new(); + private const int MaxSessions = 300; + private const int MaxCompletionItems = 2000; + + public static int Run() + { + using var input = new StreamReader(Console.OpenStandardInput(), new UTF8Encoding(false)); + var output = new StreamWriter(Console.OpenStandardOutput(), new UTF8Encoding(false)) { AutoFlush = true }; + + WarmUp(); + Write(output, new JsonObject { ["type"] = "ready" }); + + while (input.ReadLine() is { } line) + { + _ = Task.Run(() => HandleAsync(line, output)); + } + return 0; + } + + private static void Write(StreamWriter output, JsonObject message) + { + var text = message.ToJsonString(JsonOptions); + lock (OutputLock) output.WriteLine(text); + } + + private static async Task HandleAsync(string line, StreamWriter output) + { + JsonNode? id = null; + JsonObject reply; + try + { + var request = JsonNode.Parse(line)!.AsObject(); + id = request["id"]?.DeepClone(); + reply = await DispatchAsync(request); + } + catch (Exception ex) + { + reply = new JsonObject { ["type"] = "error", ["message"] = ex.Message }; + } + reply["id"] = id; + Write(output, reply); + } + + private static async Task DispatchAsync(JsonObject request) + { + var session = GetSession((string?)request["session"] ?? "default"); + await session.Lock.WaitAsync(); + try + { + session.LastUsed = DateTime.UtcNow; + if (request["files"] is JsonArray files) session.Update(files); + var type = (string?)request["type"]; + if (type == "diagnostics") return await DiagnosticsAsync(session); + if (type == "completionDetail") return await CompletionDetailAsync(session, (int?)request["index"] ?? -1); + + var document = session.Document((string?)request["file"] ?? ""); + if (document == null) return new JsonObject { ["type"] = "empty" }; + var text = await document.GetTextAsync(); + var line = Math.Clamp(((int?)request["line"] ?? 1) - 1, 0, text.Lines.Count - 1); + var column = Math.Clamp(((int?)request["column"] ?? 1) - 1, 0, text.Lines[line].End - text.Lines[line].Start); + var position = text.Lines.GetPosition(new LinePosition(line, column)); + return type switch + { + "complete" => await CompleteAsync(session, document, text, position), + "hover" => await HoverAsync(document, text, position), + "signature" => await SignatureAsync(document, position), + _ => new JsonObject { ["type"] = "error", ["message"] = $"不认识的请求 {type}" }, + }; + } + finally + { + session.Lock.Release(); + } + } + + private static Session GetSession(string id) + { + if (Sessions.TryGetValue(id, out var existing)) return existing; + // 会话太多时丢掉最久没用的(浏览器关了也不会通知这里) + if (Sessions.Count >= MaxSessions) + { + foreach (var old in Sessions.OrderBy(s => s.Value.LastUsed).Take(Sessions.Count - MaxSessions + 1)) Sessions.TryRemove(old.Key, out _); + } + return Sessions.GetOrAdd(id, _ => new Session()); + } + + // ---------- 代码补全 ---------- + + private static async Task CompleteAsync(Session session, Document document, SourceText text, int position) + { + var service = CompletionService.GetService(document)!; + var list = await service.GetCompletionsAsync(document, position); + session.LastCompletion = (document, list); + var items = new JsonArray(); + foreach (var item in list.ItemsList.Take(MaxCompletionItems)) + { + items.Add(new JsonObject + { + ["label"] = item.DisplayTextPrefix + item.DisplayText + item.DisplayTextSuffix, + ["insert"] = item.DisplayText, + ["filter"] = item.FilterText, + ["sort"] = item.SortText, + ["kind"] = item.Tags.FirstOrDefault() ?? "", + ["detail"] = item.InlineDescription, + }); + } + return new JsonObject { ["type"] = "completions", ["range"] = Range(text, list.Span), ["items"] = items }; + } + + /// 补全列表里选中某一项时,取它的说明(签名和文档) + private static async Task CompletionDetailAsync(Session session, int index) + { + if (session.LastCompletion is not var (document, list) || index < 0 || index >= list.ItemsList.Count) + { + return new JsonObject { ["type"] = "detail", ["text"] = null }; + } + var description = await CompletionService.GetService(document)!.GetDescriptionAsync(document, list.ItemsList[index]); + return new JsonObject { ["type"] = "detail", ["text"] = WithoutNullable(description?.Text) }; + } + + // ---------- 悬停提示 ---------- + + private static async Task HoverAsync(Document document, SourceText text, int position) + { + var info = await QuickInfoService.GetService(document)!.GetQuickInfoAsync(document, position); + if (info == null) return new JsonObject { ["type"] = "hover", ["text"] = null }; + var sections = info.Sections.Select(s => s.Text).Where(t => !string.IsNullOrWhiteSpace(t)); + return new JsonObject { ["type"] = "hover", ["text"] = WithoutNullable(string.Join("\n\n", sections)), ["range"] = Range(text, info.Span) }; + } + + // ---------- 参数提示 ---------- + + /// 不显示可空注解:仿真类库开启了可空检查,学生代码没有开,VS 里看到的也是 string 而不是 string? + private static readonly SymbolDisplayFormat TypeFormat = SymbolDisplayFormat.MinimallyQualifiedFormat + .RemoveMiscellaneousOptions(SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier); + + /// Roslyn 生成的说明文字里去掉可空注解(如 string? → string) + private static string? WithoutNullable(string? text) => + text == null ? null : Regex.Replace(text, @"(?<=[\w>\]])\?(?=[\s,)\]>]|$)", ""); + + /// 光标在方法调用或 new 的括号里时,列出各个重载,标出正在填的是第几个参数 + private static async Task SignatureAsync(Document document, int position) + { + var empty = new JsonObject { ["type"] = "signature", ["signatures"] = new JsonArray() }; + var root = await document.GetSyntaxRootAsync(); + var model = await document.GetSemanticModelAsync(); + if (root == null || model == null) return empty; + + var argumentList = root.FindToken(Math.Max(position - 1, 0)).Parent? + .AncestorsAndSelf() + .OfType() + .FirstOrDefault(a => position > a.OpenParenToken.SpanStart && (a.CloseParenToken.IsMissing || position <= a.CloseParenToken.SpanStart)); + if (argumentList == null) return empty; + + IEnumerable methods; + ISymbol? chosen; + switch (argumentList.Parent) + { + case InvocationExpressionSyntax invocation: + methods = model.GetMemberGroup(invocation.Expression).OfType(); + chosen = model.GetSymbolInfo(invocation).Symbol; + break; + case BaseObjectCreationExpressionSyntax creation when model.GetTypeInfo(creation).Type is INamedTypeSymbol type: + methods = type.InstanceConstructors.Where(c => c.DeclaredAccessibility == Accessibility.Public); + chosen = model.GetSymbolInfo(creation).Symbol; + break; + default: + return empty; + } + + var activeParameter = argumentList.Arguments.GetSeparators().Count(comma => comma.SpanStart < position); + var signatures = new JsonArray(); + var list = methods.ToList(); + foreach (var method in list) + { + var label = new StringBuilder(method.MethodKind == MethodKind.Constructor ? method.ContainingType.Name : method.Name).Append('('); + var parameters = new JsonArray(); + foreach (var p in method.Parameters) + { + if (p.Ordinal > 0) label.Append(", "); + var start = label.Length; + label.Append(p.Type.ToDisplayString(TypeFormat)).Append(' ').Append(p.Name); + parameters.Add(new JsonArray(start, label.Length)); + } + label.Append(')'); + if (method.MethodKind != MethodKind.Constructor) label.Insert(0, method.ReturnType.ToDisplayString(TypeFormat) + " "); + // 返回类型插在前面,参数位置要跟着后移 + var offset = method.MethodKind != MethodKind.Constructor ? method.ReturnType.ToDisplayString(TypeFormat).Length + 1 : 0; + foreach (var range in parameters.OfType()) + { + range[0] = (int)range[0]! + offset; + range[1] = (int)range[1]! + offset; + } + signatures.Add(new JsonObject { ["label"] = label.ToString(), ["parameters"] = parameters }); + } + // 已经能确定是哪个重载时选它,否则选参数够多的第一个 + var active = list.FindIndex(m => SymbolEqualityComparer.Default.Equals(m, chosen)); + if (active < 0) active = Math.Max(list.FindIndex(m => m.Parameters.Length > activeParameter), 0); + return new JsonObject { ["type"] = "signature", ["signatures"] = signatures, ["activeSignature"] = active, ["activeParameter"] = activeParameter }; + } + + // ---------- 边写边检查 ---------- + + /// 整个工程的编译错误和警告(不生成程序集),提示和真正编译时一样 + private static async Task DiagnosticsAsync(Session session) + { + var project = session.Workspace.CurrentSolution.GetProject(session.ProjectId)!; + var compilation = (CSharpCompilation)(await project.GetCompilationAsync())!; + var diagnostics = new JsonArray(); + foreach (var diagnostic in compilation.GetDiagnostics() + .Concat(ApiPolicy.Check(compilation)) + .Where(d => d.Severity is DiagnosticSeverity.Error or DiagnosticSeverity.Warning && d.Location.IsInSource) + .OrderBy(d => d.Severity == DiagnosticSeverity.Error ? 0 : 1)) + { + diagnostics.Add(StudentCompiler.ToJson(diagnostic)); + } + return new JsonObject { ["type"] = "diagnostics", ["diagnostics"] = diagnostics }; + } + + private static JsonObject Range(SourceText text, TextSpan span) + { + var lines = text.Lines.GetLinePositionSpan(span); + return new JsonObject + { + ["startLine"] = lines.Start.Line + 1, + ["startColumn"] = lines.Start.Character + 1, + ["endLine"] = lines.End.Line + 1, + ["endColumn"] = lines.End.Character + 1, + }; + } + + /// 启动时做一遍补全和检查,让 MEF 组合和 JIT 提前完成,学生第一次按"."时不用等几秒 + private static void WarmUp() + { + var session = new Session(); + session.Update(new JsonArray(new JsonObject + { + ["path"] = "Program.cs", + ["content"] = "using System.Windows.Forms; static class P { static void Main() { var f = new Form(); f.Text = \"预热\"; MessageBox.Show(\"\"); } }", + })); + var document = session.Document("Program.cs")!; + var text = document.GetTextAsync().Result; + var position = text.ToString().IndexOf("f.Text", StringComparison.Ordinal) + 2; + CompleteAsync(session, document, text, position).Wait(); + HoverAsync(document, text, position).Wait(); + SignatureAsync(document, text.ToString().IndexOf("Show(", StringComparison.Ordinal) + 5).Wait(); + DiagnosticsAsync(session).Wait(); + } + + /// 一个浏览器标签页的工程 + private sealed class Session + { + public readonly AdhocWorkspace Workspace = new(MefHostServices.DefaultHost); + public readonly ProjectId ProjectId = ProjectId.CreateNewId(); + public readonly SemaphoreSlim Lock = new(1, 1); + public DateTime LastUsed = DateTime.UtcNow; + /// 最近一次补全的结果,选中某项时据此取说明 + public (Document, CompletionList)? LastCompletion; + private readonly Dictionary documents = new(); + + public Session() + { + Workspace.AddProject(ProjectInfo.Create( + ProjectId, VersionStamp.Create(), "StudentApp", "StudentApp", LanguageNames.CSharp, + compilationOptions: StudentCompiler.CompilationOptions, + parseOptions: StudentCompiler.ParseOptions, + metadataReferences: StudentCompiler.References.Value)); + } + + /// 同步浏览器里的文件:只更新有变化的,删掉已经没有的 + public void Update(JsonArray files) + { + var solution = Workspace.CurrentSolution; + var seen = new HashSet(); + foreach (var file in files.OfType()) + { + var path = (string?)file["path"]; + var content = (string?)file["content"] ?? ""; + if (path == null || !path.EndsWith(".cs", StringComparison.Ordinal)) continue; + seen.Add(path); + if (documents.TryGetValue(path, out var doc)) + { + if (doc.Content == content) continue; + solution = solution.WithDocumentText(doc.Id, SourceText.From(content)); + documents[path] = (doc.Id, content); + } + else + { + var id = DocumentId.CreateNewId(ProjectId); + solution = solution.AddDocument(id, path, SourceText.From(content), filePath: path); + documents[path] = (id, content); + } + } + foreach (var path in documents.Keys.Where(p => !seen.Contains(p)).ToList()) + { + solution = solution.RemoveDocument(documents[path].Id); + documents.Remove(path); + } + Workspace.TryApplyChanges(solution); + } + + public Document? Document(string path) => + documents.TryGetValue(path, out var doc) ? Workspace.CurrentSolution.GetDocument(doc.Id) : null; + } +} diff --git a/runtime/Sim.Runner/Program.cs b/runtime/Sim.Runner/Program.cs index 94a8c43..bad4c9f 100644 --- a/runtime/Sim.Runner/Program.cs +++ b/runtime/Sim.Runner/Program.cs @@ -2,20 +2,22 @@ using System; using System.IO; using Sim.Runner; -// 两种模式,都通过标准输入输出与 Bun 服务器交换 JSON 消息(每行一条): +// 三种模式,都通过标准输入输出与 Bun 服务器交换 JSON 消息(每行一条): // dotnet Sim.Runner.dll compile-server 常驻编译服务,全服务器共用一个,编译学生工程并把程序集写回工程目录 +// dotnet Sim.Runner.dll language-server 常驻语言服务,全服务器共用一个:代码补全、悬停提示、参数提示、边写边检查 // dotnet Sim.Runner.dll run <工程目录> 运行已编译的学生程序,每次运行一个进程 // 运行模式不加载 Roslyn,单个学生程序的内存占用因此小得多。 return args switch { ["compile-server"] => CompileServer.Run(), + ["language-server"] => LanguageServer.Run(), ["run", var dir] when Directory.Exists(dir) => StudentProgramHost.Run(dir), _ => Usage(), }; static int Usage() { - Console.Error.WriteLine("用法:Sim.Runner compile-server | Sim.Runner run <工程目录>"); + Console.Error.WriteLine("用法:Sim.Runner compile-server | Sim.Runner language-server | Sim.Runner run <工程目录>"); return 2; } diff --git a/runtime/Sim.Runner/Sim.Runner.csproj b/runtime/Sim.Runner/Sim.Runner.csproj index a073753..b34b7a3 100644 --- a/runtime/Sim.Runner/Sim.Runner.csproj +++ b/runtime/Sim.Runner/Sim.Runner.csproj @@ -11,6 +11,8 @@ + + diff --git a/runtime/Sim.Runner/StudentCompiler.cs b/runtime/Sim.Runner/StudentCompiler.cs index 6fe930e..5b596f8 100644 --- a/runtime/Sim.Runner/StudentCompiler.cs +++ b/runtime/Sim.Runner/StudentCompiler.cs @@ -31,9 +31,18 @@ internal static class StudentCompiler public sealed record Result(bool Success, byte[] Assembly, byte[] Pdb, JsonArray Diagnostics); + /// 编译和语言服务(代码补全等)共用同一套选项 + internal static readonly CSharpParseOptions ParseOptions = new(LanguageVersion.Latest); + + internal static readonly CSharpCompilationOptions CompilationOptions = new CSharpCompilationOptions(OutputKind.WindowsApplication) + .WithOptimizationLevel(OptimizationLevel.Debug) + .WithNullableContextOptions(NullableContextOptions.Disable) + .WithAllowUnsafe(false) + .WithConcurrentBuild(true); + public static Result Compile(string projectDir) { - var parseOptions = new CSharpParseOptions(LanguageVersion.Latest); + var parseOptions = ParseOptions; var trees = Directory.EnumerateFiles(projectDir, "*.cs", SearchOption.AllDirectories) .Where(path => !IsBuildOutput(projectDir, path)) .OrderBy(path => path, StringComparer.Ordinal) @@ -43,13 +52,7 @@ internal static class StudentCompiler path: Path.GetRelativePath(projectDir, path))) .ToList(); - var options = new CSharpCompilationOptions(OutputKind.WindowsApplication) - .WithOptimizationLevel(OptimizationLevel.Debug) - .WithNullableContextOptions(NullableContextOptions.Disable) - .WithAllowUnsafe(false) - .WithConcurrentBuild(true); - - var compilation = CSharpCompilation.Create("StudentApp", trees, References.Value, options); + var compilation = CSharpCompilation.Create("StudentApp", trees, References.Value, CompilationOptions); using var peStream = new MemoryStream(); using var pdbStream = new MemoryStream(); @@ -67,7 +70,7 @@ internal static class StudentCompiler return new Result(emit.Success && denied.Count == 0, peStream.ToArray(), pdbStream.ToArray(), diagnostics); } - private static JsonObject ToJson(Diagnostic diagnostic) + internal static JsonObject ToJson(Diagnostic diagnostic) { var span = diagnostic.Location.GetMappedLineSpan(); var message = diagnostic.GetMessage(Chinese); @@ -92,7 +95,7 @@ internal static class StudentCompiler } /// 学生代码可引用的程序集:.NET 框架(去掉冲突的门面)+ 仿真库 - private static readonly Lazy> References = new(() => + internal static readonly Lazy> References = new(() => { var frameworkDir = Path.GetDirectoryName(typeof(object).Assembly.Location)!; var framework = Directory.EnumerateFiles(frameworkDir, "*.dll") diff --git a/server/src/app.ts b/server/src/app.ts index d8e8cd1..d968f00 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -16,6 +16,7 @@ import { updateAssignment, } from './assignments' import { parseChecks } from './grading/checks' +import { languageService } from './language-service' import { getReview, gradebook, manualScore, saveReview, studentDetail, submissionFiles } from './reviews' import type { GradingQueue } from './grading/queue' import { listSamples, loadChecks, loadSample } from './samples' @@ -83,6 +84,17 @@ export function createApp(db: Database, gradingQueue: GradingQueue) { return files ? c.json(files) : c.notFound() }) + // 代码编辑器的语言服务:补全、悬停提示、参数提示、边写边检查 + const LANGUAGE_REQUESTS = new Set(['complete', 'completionDetail', 'hover', 'signature', 'diagnostics']) + app.post('/api/language', async (c) => { + const r = await body<{ type: string; session: string; files: unknown; file: string; line: number; column: number; index: number }>(c) + if (!LANGUAGE_REQUESTS.has(String(r.type)) || typeof r.session !== 'string') return c.json({ message: '请求格式不对' }, 400) + if (r.files !== undefined && !validFiles(r.files)) return c.json({ message: '作品格式不对或太大' }, 400) + // 会话按用户区分,不同学生的工程不会混在一起 + const reply = await languageService.request({ ...r, session: `${c.get('user').id}:${r.session.slice(0, 64)}` }) + return c.json(reply ?? { type: 'empty' }) + }) + // 自己的作品:key 如 "sample:p2-t1-LED显示屏" app.get('/api/works/:key', (c) => c.json(getWork(db, c.get('user').id, c.req.param('key')))) diff --git a/server/src/index.ts b/server/src/index.ts index c69b0c7..49ee1d1 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -2,6 +2,7 @@ import type { ClientMessage, ServerMessage } from '@iot/shared' import { createApp } from './app' import { ensureTeacher, tokenUser } from './auth' import { compileService } from './compile-service' +import { languageService } from './language-service' import { config } from './config' import { openDatabase } from './db' import { createGradingQueue } from './grading/queue' @@ -69,5 +70,5 @@ const server = Bun.serve({ }) console.log(`服务器已启动:http://localhost:${server.port}`) -await compileService.start() +await Promise.all([compileService.start(), languageService.start()]) console.log('C# 编译服务已就绪') diff --git a/server/src/language-service.ts b/server/src/language-service.ts new file mode 100644 index 0000000..268d9d1 --- /dev/null +++ b/server/src/language-service.ts @@ -0,0 +1,75 @@ +import type { Subprocess } from 'bun' +import { config } from './config' +import { readLines } from './runner-process' + +/** 一个请求最多等多久;语言服务忙或卡住时,编辑器不弹补全也不影响写代码 */ +const TIMEOUT_MS = 10_000 + +/** + * 常驻语言服务:代码补全、悬停提示、参数提示、边写边检查。全服务器共用一个进程(和编译服务分开), + * 请求按编号对应回复。进程意外退出时,挂起的请求失败,下一次请求自动重启。 + */ +class LanguageService { + private proc?: Subprocess<'pipe', 'pipe', 'inherit'> + private ready?: Promise + private nextId = 1 + private readonly pending = new Map | null) => void>() + + /** 发一个请求(type、session、files、file、line、column 等原样转给语言服务);超时或出错返回 null */ + async request(body: Record): Promise | null> { + await this.start() + const id = this.nextId++ + const reply = new Promise | null>((resolve) => { + const timer = setTimeout(() => { + this.pending.delete(id) + resolve(null) + }, TIMEOUT_MS) + this.pending.set(id, (r) => { + clearTimeout(timer) + resolve(r) + }) + }) + this.proc!.stdin.write(`${JSON.stringify({ ...body, id })}\n`) + this.proc!.stdin.flush() + return reply + } + + stop() { + this.proc?.kill() + } + + /** 服务器启动时调用,提前完成加载和预热 */ + start() { + if (this.ready) return this.ready + const proc = Bun.spawn(['dotnet', config.runnerDll, 'language-server'], { + stdin: 'pipe', + stdout: 'pipe', + stderr: 'inherit', + env: { PATH: process.env.PATH ?? '/usr/bin:/bin', HOME: process.env.HOME ?? '/tmp', DOTNET_CLI_TELEMETRY_OPTOUT: '1', DOTNET_NOLOGO: '1' }, + }) + this.proc = proc + this.ready = new Promise((resolveReady) => { + void readLines(proc.stdout, (line) => { + const message = JSON.parse(line) as { type: string; id?: number } + if (message.type === 'ready') { + resolveReady() + return + } + const resolve = this.pending.get(message.id!) + if (!resolve) return + this.pending.delete(message.id!) + resolve(message.type === 'error' ? null : message) + }) + }) + proc.exited.then(() => { + if (this.proc !== proc) return + this.proc = undefined + this.ready = undefined + for (const resolve of this.pending.values()) resolve(null) + this.pending.clear() + }) + return this.ready + } +} + +export const languageService = new LanguageService() diff --git a/server/test/api.test.ts b/server/test/api.test.ts index 3c4d15e..0694a23 100644 --- a/server/test/api.test.ts +++ b/server/test/api.test.ts @@ -1,10 +1,11 @@ // HTTP 接口测试:内存数据库,直接调用 app.fetch。 -import { beforeEach, describe, expect, test } from 'bun:test' +import { afterAll, beforeEach, describe, expect, test } from 'bun:test' import { createApp } from '../src/app' import { ensureTeacher } from '../src/auth' import { config } from '../src/config' import { openDatabase } from '../src/db' import { createGradingQueue, type GradingQueue } from '../src/grading/queue' +import { languageService } from '../src/language-service' import { loadSample } from '../src/samples' let app: ReturnType @@ -405,3 +406,20 @@ describe('评阅与成绩', () => { expect((await student.get(`/api/teacher/submissions/${submission.id}`)).status).toBe(403) }, 60000) }) + +describe('语言服务', () => { + afterAll(() => languageService.stop()) + + test('代码补全、边写边检查(中文提示);请求格式不对拒绝;没登录不能用', async () => { + const t = await teacher() + const files = [{ path: 'Program.cs', content: 'using System.Windows.Forms;\nstatic class P\n{\n static void Main()\n {\n var b = new Button();\n b.\n }\n}\n' }] + const completions = await t.post('/api/language', { type: 'complete', session: 'tab1', files, file: 'Program.cs', line: 7, column: 11 }) + expect(completions.body.items.map((i: any) => i.label)).toEqual(expect.arrayContaining(['Text', 'Click', 'Enabled'])) + + const check = await t.post('/api/language', { type: 'diagnostics', session: 'tab1', files }) + expect(check.body.diagnostics).toEqual(expect.arrayContaining([expect.objectContaining({ code: 'CS1002', hint: 'C# 每条语句末尾要有英文分号 ;' })])) + + expect((await t.post('/api/language', { type: 'run', session: 'tab1' })).status).toBe(400) + expect((await new Browser().post('/api/language', { type: 'complete', session: 'tab1', files })).status).toBe(401) + }, 60000) +}) diff --git a/web/src/editor/CodeEditor.vue b/web/src/editor/CodeEditor.vue index 403ea75..5593428 100644 --- a/web/src/editor/CodeEditor.vue +++ b/web/src/editor/CodeEditor.vue @@ -1,8 +1,10 @@