导出 EXE:实验页工具栏"导出 EXE"把学生的程序编译成一个 exe,拷到机房电脑上双击就能控制真的实训台。编译服务照教材工程编译学生写的原始代码(.NET Framework 4.5 引用程序集,构建时只复制教材工程引用的 13 个到 netfx/;真机版设备类库),不做平台的仿真改写和 API 限制,和 VS 里一样。教材 dll(DeviceCtrlLibrary 及 API 下 7 个、WinFormsDeviceControlLibrary、媒体播放器的两个 Interop)作为资源嵌进 exe,模块初始化器注册 AssemblyResolve 从 exe 里加载,Program.cs 不用改;Properties.Resources 的图片、声音嵌进 exe,代码里按文件名用到的素材(项目 7 的 ring.mp3)启动时放到 exe 旁边的 Resources;媒体播放器的 OcxState 用教材 resx 的默认状态;书上的 ledHelper.Close() 在教材类库里没有,补一个空的扩展方法。23 个实验都能导出(约 0.9 MB),mono + Xvfb 里实测能启动、控件和图片正常。接口 POST /api/export-exe,代码有错时 422,实验页弹框列出报错、点击跳到代码。教材 dll 有版权、GitHub 仓库公开,不进仓库:本机放 textbook-libs/,服务器放 ~/IOT_SIM/textbook-libs/ 由 compose 只读挂载(deploy/README.md),没放时提示原因。使用说明、设计方案、README 同步
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
46e0f40cce
commit
5e5d29b2aa
19 files changed
+601
-10
No files matched your search
@@ -11,12 +11,14 @@ namespace Sim.Runner;
|
||||
/// <summary>
|
||||
/// 常驻编译服务:从标准输入读取 {"id":…,"dir":…},编译该目录下的学生工程,
|
||||
/// 成功时把 StudentApp.dll / StudentApp.pdb 写到同一目录,回复 {"id":…,"type":"compiled",…}。
|
||||
/// 请求带 "export":{"libs":…,"name":…} 时导出 EXE(见 ExeExporter),写到同一目录的 export.exe。
|
||||
/// 编译器常驻,第一次之后的编译不再有加载和预热开销。
|
||||
/// </summary>
|
||||
internal static class CompileServer
|
||||
{
|
||||
public const string AssemblyFile = "StudentApp.dll";
|
||||
public const string PdbFile = "StudentApp.pdb";
|
||||
public const string ExportFile = "export.exe";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
|
||||
|
||||
@@ -36,7 +38,10 @@ internal static class CompileServer
|
||||
{
|
||||
var request = JsonNode.Parse(line)!.AsObject();
|
||||
id = request["id"]?.DeepClone();
|
||||
reply = Compile((string?)request["dir"] ?? throw new ArgumentException("缺少 dir"));
|
||||
var dir = (string?)request["dir"] ?? throw new ArgumentException("缺少 dir");
|
||||
reply = request["export"] is JsonObject export
|
||||
? Export(dir, (string?)export["libs"] ?? "", (string?)export["name"] ?? "")
|
||||
: Compile(dir);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -66,6 +71,24 @@ internal static class CompileServer
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导出 EXE:照教材工程编译成 .NET Framework 程序,嵌入 libs 目录下的教材 dll,成功时写到工程目录的 ExportFile。
|
||||
/// name 是程序集名(exe 的名字),由服务器按工程取好。
|
||||
/// </summary>
|
||||
private static JsonObject Export(string dir, string libs, string name)
|
||||
{
|
||||
var watch = Stopwatch.StartNew();
|
||||
var result = ExeExporter.Export(dir, libs, Sim.Host.ProjectResources.Directory, string.IsNullOrWhiteSpace(name) ? "上位机程序" : name);
|
||||
if (result.Success) File.WriteAllBytes(Path.Combine(dir, ExportFile), result.Exe);
|
||||
return new JsonObject
|
||||
{
|
||||
["type"] = "compiled",
|
||||
["ok"] = result.Success,
|
||||
["diagnostics"] = result.Diagnostics,
|
||||
["ms"] = watch.ElapsedMilliseconds,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>启动时编译一个小程序,让 Roslyn 完成加载和 JIT,学生第一次点"运行"时就不用等</summary>
|
||||
private static void WarmUp()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
using Microsoft.CodeAnalysis.Emit;
|
||||
using Microsoft.CodeAnalysis.Text;
|
||||
|
||||
namespace Sim.Runner;
|
||||
|
||||
/// <summary>
|
||||
/// 导出 EXE:把学生工程照教材工程的样子(.NET Framework 4.5 窗体应用,引用真机版设备类库)编译成一个 exe,
|
||||
/// 在机房的 Windows 上双击就能运行,控制真的实训台。
|
||||
/// <list type="bullet">
|
||||
/// <item>编译的是学生写的原始代码:平台为仿真做的改写(虚拟时钟、旧线程 API、Windows 路径)和 API 限制都不用,和在 VS 里编译一样。</item>
|
||||
/// <item>教材 dll(设备类库、设备控件库、媒体播放器的 Interop)嵌进 exe,启动时由模块初始化器从 exe 里加载,学生的 Program.cs 不用改。</item>
|
||||
/// <item>Properties.Resources 的图片、声音嵌进 exe;代码里按文件名用到的素材(如 Resources\ring.mp3)启动时放到 exe 旁边的 Resources 文件夹,和 VS 输出目录一样。</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
internal static class ExeExporter
|
||||
{
|
||||
public sealed record Result(bool Success, byte[] Exe, JsonArray Diagnostics);
|
||||
|
||||
private const string LibPrefix = "IotSimExport.lib.";
|
||||
private const string FilePrefix = "IotSimExport.file.";
|
||||
private const string MediaPrefix = "IotSimExport.media.";
|
||||
|
||||
/// <summary>
|
||||
/// VS 设计器给媒体播放器存的 OcxState(AxHost.State 的 BinaryFormatter 序列化数据,取自教材项目 7 的 Form1.resx,是控件的默认状态)。
|
||||
/// 平台没有 .resx,导出时给每个 resources.GetObject("…OcxState") 都放这一份。
|
||||
/// </summary>
|
||||
private const string MediaPlayerOcxState =
|
||||
"AAEAAAD/////AQAAAAAAAAAMAgAAAFdTeXN0ZW0uV2luZG93cy5Gb3JtcywgVmVyc2lvbj00LjAuMC4w" +
|
||||
"LCBDdWx0dXJlPW5ldXRyYWwsIFB1YmxpY0tleVRva2VuPWI3N2E1YzU2MTkzNGUwODkFAQAAACFTeXN0" +
|
||||
"ZW0uV2luZG93cy5Gb3Jtcy5BeEhvc3QrU3RhdGUBAAAABERhdGEHAgIAAAAJAwAAAA8DAAAAtwAAAAIB" +
|
||||
"AAAAAQAAAAAAAAAAAAAAAKIAAAAAAwAACAAAAAAABQAAAAAAAADwPwMAAAAAAAUAAAAAAAAAAAAIAAIA" +
|
||||
"AAAAAAMAAQAAAAsA//8DAAAAAAALAP//CAACAAAAAAADADIAAAALAAAACAAKAAAAZgB1AGwAbAAAAAsA" +
|
||||
"AAALAAAACwD//wsA//8LAAAACAACAAAAAAAIAAIAAAAAAAgAAgAAAAAACAACAAAAAAALAAAANAYAAOcB" +
|
||||
"AAAL";
|
||||
|
||||
private const string AxHostStateType =
|
||||
"System.Windows.Forms.AxHost+State, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089";
|
||||
|
||||
/// <summary>平台生成的 Properties/Resources.Designer.cs 从素材库取图片,导出时改成从 exe 里取</summary>
|
||||
private const string PlatformResources = "global::Sim.Host.ProjectResources.";
|
||||
private const string EmbeddedResources = "global::IotSimExport.EmbeddedFiles.";
|
||||
|
||||
private static readonly Regex ResourceFile = new(@"ProjectResources\.(?:Image|Bytes)\(""([^""]+)""\)");
|
||||
private static readonly Regex OcxStateKey = new(@"GetObject\(""([^""]+\.OcxState)""\)");
|
||||
|
||||
private static readonly CSharpCompilationOptions Options = new CSharpCompilationOptions(OutputKind.WindowsApplication)
|
||||
.WithPlatform(Platform.AnyCpu32BitPreferred)
|
||||
.WithOptimizationLevel(OptimizationLevel.Release)
|
||||
.WithNullableContextOptions(NullableContextOptions.Disable)
|
||||
.WithAllowUnsafe(false)
|
||||
.WithConcurrentBuild(true);
|
||||
|
||||
/// <summary>.NET Framework 4.5 的引用程序集(构建时复制到 netfx/,见 Sim.Runner.csproj)</summary>
|
||||
private static readonly Lazy<IReadOnlyList<MetadataReference>> Framework = new(() =>
|
||||
Directory.EnumerateFiles(Path.Combine(AppContext.BaseDirectory, "netfx"), "*.dll")
|
||||
.Select(path => (MetadataReference)MetadataReference.CreateFromFile(path))
|
||||
.ToList());
|
||||
|
||||
/// <summary>libsDir:教材 dll 所在目录;galleryDir:平台素材库</summary>
|
||||
public static Result Export(string projectDir, string libsDir, string galleryDir, string assemblyName)
|
||||
{
|
||||
var libs = Directory.Exists(libsDir) ? Directory.GetFiles(libsDir, "*.dll").OrderBy(p => p, StringComparer.Ordinal).ToArray() : Array.Empty<string>();
|
||||
if (!libs.Any(p => Path.GetFileName(p) == "DeviceCtrlLibrary.dll"))
|
||||
throw new InvalidOperationException("服务器上没有放教材的设备类库(真机版 DeviceCtrlLibrary.dll 等),不能导出 EXE,请联系老师");
|
||||
|
||||
var sources = StudentCompiler.SourceFiles(projectDir)
|
||||
.Select(path => (Path: Path.GetRelativePath(projectDir, path), Text: File.ReadAllText(path)))
|
||||
.ToList();
|
||||
|
||||
// 平台生成的资源类改成从 exe 里取,用到的素材文件嵌进去
|
||||
var files = new SortedSet<string>(StringComparer.Ordinal);
|
||||
for (var i = 0; i < sources.Count; i++)
|
||||
{
|
||||
if (!sources[i].Text.Contains(PlatformResources)) continue;
|
||||
foreach (Match m in ResourceFile.Matches(sources[i].Text)) files.Add(Path.GetFileName(m.Groups[1].Value));
|
||||
sources[i] = (sources[i].Path, sources[i].Text.Replace(PlatformResources, EmbeddedResources));
|
||||
}
|
||||
// 代码里按文件名用到的素材(如 BaseDirectory + "//Resources//ring.mp3"),在 VS 里是复制到输出目录的 Resources 文件夹
|
||||
var media = Directory.Exists(galleryDir)
|
||||
? Directory.GetFiles(galleryDir)
|
||||
.Select(Path.GetFileName)
|
||||
.OfType<string>()
|
||||
.Where(name => !name.Contains(".2x.") && name != "index.json")
|
||||
.Where(name => sources.Any(s => s.Text.Contains(name + "\"")))
|
||||
.OrderBy(name => name, StringComparer.Ordinal)
|
||||
.ToList()
|
||||
: new List<string>();
|
||||
|
||||
var trees = sources
|
||||
.Select(s => CSharpSyntaxTree.ParseText(SourceText.From(s.Text, Encoding.UTF8), StudentCompiler.ParseOptions, path: s.Path))
|
||||
.Append(CSharpSyntaxTree.ParseText(SourceText.From(Bootstrap(media), Encoding.UTF8), StudentCompiler.ParseOptions, path: "IotSimExport.cs"))
|
||||
.ToList();
|
||||
var references = Framework.Value.Concat(libs.Select(p => (MetadataReference)MetadataReference.CreateFromFile(p))).ToList();
|
||||
var compilation = CSharpCompilation.Create(assemblyName, trees, references, Options);
|
||||
|
||||
var resources = new List<ResourceDescription>();
|
||||
foreach (var lib in libs)
|
||||
{
|
||||
var path = lib;
|
||||
resources.Add(new ResourceDescription(LibPrefix + Path.GetFileName(path), () => File.OpenRead(path), isPublic: false));
|
||||
}
|
||||
foreach (var file in files)
|
||||
{
|
||||
var path = Path.Combine(galleryDir, file);
|
||||
if (File.Exists(path)) resources.Add(new ResourceDescription(FilePrefix + file, () => File.OpenRead(path), isPublic: false));
|
||||
}
|
||||
foreach (var file in media)
|
||||
{
|
||||
var path = Path.Combine(galleryDir, file);
|
||||
resources.Add(new ResourceDescription(MediaPrefix + file, () => File.OpenRead(path), isPublic: false));
|
||||
}
|
||||
resources.AddRange(ControlResources(compilation, sources.SelectMany(s => OcxStateKey.Matches(s.Text).Select(m => m.Groups[1].Value)).ToHashSet()));
|
||||
|
||||
using var pe = new MemoryStream();
|
||||
using var win32 = compilation.CreateDefaultWin32Resources(versionResource: true, noManifest: false, manifestContents: null, iconInIcoFormat: null);
|
||||
var emit = compilation.Emit(pe, win32Resources: win32, manifestResources: resources,
|
||||
options: new EmitOptions(debugInformationFormat: DebugInformationFormat.Embedded));
|
||||
|
||||
var diagnostics = new JsonArray();
|
||||
// 和平台运行时一样按编译器给出的顺序:语法错误在前,真正的出错原因一般是第一条
|
||||
foreach (var diagnostic in emit.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error))
|
||||
{
|
||||
diagnostics.Add(StudentCompiler.ToJson(diagnostic));
|
||||
}
|
||||
return new Result(emit.Success, pe.ToArray(), diagnostics);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 每个窗体、控件类的 .resources:VS 生成的 Designer.cs 用 ComponentResourceManager 读窗体的 .resx,
|
||||
/// 平台没有 .resx。媒体播放器的 OcxState 放默认状态,其他取到 null(和运行时一样)。
|
||||
/// </summary>
|
||||
private static List<ResourceDescription> ControlResources(CSharpCompilation compilation, HashSet<string> ocxKeys)
|
||||
{
|
||||
var stream = new MemoryStream();
|
||||
using (var writer = new System.Resources.ResourceWriter(stream))
|
||||
{
|
||||
var state = Convert.FromBase64String(MediaPlayerOcxState);
|
||||
foreach (var key in ocxKeys.OrderBy(k => k, StringComparer.Ordinal)) writer.AddResourceData(key, AxHostStateType, state);
|
||||
writer.Generate();
|
||||
}
|
||||
var data = stream.ToArray();
|
||||
|
||||
var control = compilation.GetTypeByMetadataName("System.Windows.Forms.Control");
|
||||
var resources = new List<ResourceDescription>();
|
||||
if (control == null) return resources;
|
||||
var names = new HashSet<string>();
|
||||
foreach (var tree in compilation.SyntaxTrees)
|
||||
{
|
||||
var model = compilation.GetSemanticModel(tree);
|
||||
foreach (var declaration in tree.GetRoot().DescendantNodes().OfType<ClassDeclarationSyntax>())
|
||||
{
|
||||
if (model.GetDeclaredSymbol(declaration) is not { } type) continue;
|
||||
var derives = false;
|
||||
for (var t = type.BaseType; t != null && !derives; t = t.BaseType) derives = SymbolEqualityComparer.Default.Equals(t, control);
|
||||
var name = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat).Replace("global::", "") + ".resources";
|
||||
if (derives && names.Add(name)) resources.Add(new ResourceDescription(name, () => new MemoryStream(data), isPublic: true));
|
||||
}
|
||||
}
|
||||
return resources;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 加进 exe 的启动代码:模块初始化器在 Main 之前运行(.NET Framework 也支持),
|
||||
/// 注册 AssemblyResolve,用到教材 dll 时从 exe 里加载;把代码里用到的素材文件放到 exe 旁边。
|
||||
/// </summary>
|
||||
private static string Bootstrap(IReadOnlyList<string> media) => $$"""
|
||||
[assembly: global::System.Runtime.Versioning.TargetFramework(".NETFramework,Version=v4.5", FrameworkDisplayName = ".NET Framework 4.5")]
|
||||
|
||||
namespace System.Runtime.CompilerServices
|
||||
{
|
||||
[global::System.AttributeUsage(global::System.AttributeTargets.Method, Inherited = false)]
|
||||
internal sealed class ModuleInitializerAttribute : global::System.Attribute { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 书上项目 2 任务 3 调用 DeviceCtrlHelper.ledHelper.Close(),教材的类库里没有这个方法(LED 屏每次发送才用串口,不占着),
|
||||
/// 平台补上了它;导出时也补一个,书上的代码照样能编译。放在全局命名空间,不用 using 就能找到;类库自己有 Close 时用类库的。
|
||||
/// </summary>
|
||||
internal static class IotSimExportLedCompat
|
||||
{
|
||||
internal static void Close(this global::DeviceCtrlLibrary.LEDHelper helper) { }
|
||||
}
|
||||
|
||||
namespace IotSimExport
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
|
||||
internal static class Bootstrap
|
||||
{
|
||||
private static readonly Dictionary<string, Assembly> Loaded = new Dictionary<string, Assembly>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
[global::System.Runtime.CompilerServices.ModuleInitializer]
|
||||
internal static void Init()
|
||||
{
|
||||
AppDomain.CurrentDomain.AssemblyResolve += Resolve;
|
||||
ExtractMedia();
|
||||
}
|
||||
|
||||
private static Assembly Resolve(object sender, ResolveEventArgs args)
|
||||
{
|
||||
var name = new AssemblyName(args.Name).Name;
|
||||
lock (Loaded)
|
||||
{
|
||||
Assembly assembly;
|
||||
if (Loaded.TryGetValue(name, out assembly)) return assembly;
|
||||
var bytes = EmbeddedFiles.Read("{{LibPrefix}}" + name + ".dll");
|
||||
if (bytes == null) return null;
|
||||
assembly = Assembly.Load(bytes);
|
||||
Loaded[name] = assembly;
|
||||
return assembly;
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly string[] Media = { {{string.Join(", ", media.Select(m => SymbolDisplay.FormatLiteral(m, true)))}} };
|
||||
|
||||
private static void ExtractMedia()
|
||||
{
|
||||
foreach (var file in Media)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Resources");
|
||||
var path = Path.Combine(dir, file);
|
||||
if (File.Exists(path)) continue;
|
||||
Directory.CreateDirectory(dir);
|
||||
File.WriteAllBytes(path, EmbeddedFiles.Read("{{MediaPrefix}}" + file));
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// 文件夹不能写(如放在只读的 U 盘上)时不影响程序运行,只是用不到这个文件
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static class EmbeddedFiles
|
||||
{
|
||||
internal static byte[] Read(string name)
|
||||
{
|
||||
using (var stream = typeof(EmbeddedFiles).Assembly.GetManifestResourceStream(name))
|
||||
{
|
||||
if (stream == null) return null;
|
||||
var bytes = new byte[stream.Length];
|
||||
var read = 0;
|
||||
while (read < bytes.Length) read += stream.Read(bytes, read, bytes.Length - read);
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
|
||||
internal static global::System.Drawing.Bitmap Image(string file)
|
||||
{
|
||||
return new global::System.Drawing.Bitmap(new MemoryStream(Bytes(file)));
|
||||
}
|
||||
|
||||
internal static byte[] Bytes(string file)
|
||||
{
|
||||
return Read("{{FilePrefix}}" + Path.GetFileName(file));
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
}
|
||||
@@ -14,6 +14,20 @@
|
||||
<!-- 语言服务(代码补全、悬停提示):和 VS 的 IntelliSense 用同一套引擎;只有 language-server 模式会加载 -->
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Features" Version="4.14.0" />
|
||||
</ItemGroup>
|
||||
<!-- 导出 EXE:照教材工程(.NET Framework 4.5)编译学生代码,要用 4.5 的引用程序集(只供编译,不含实现)。
|
||||
只复制教材工程引用的那些(VS 窗体应用模板默认的引用加图表控件),放到输出目录的 netfx/ -->
|
||||
<ItemGroup>
|
||||
<PackageDownload Include="Microsoft.NETFramework.ReferenceAssemblies.net45" Version="[1.0.3]" />
|
||||
</ItemGroup>
|
||||
<Target Name="CopyNetFxReferences" AfterTargets="Build">
|
||||
<PropertyGroup>
|
||||
<NetFxDir>$(NuGetPackageRoot)microsoft.netframework.referenceassemblies.net45/1.0.3/build/.NETFramework/v4.5/</NetFxDir>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<NetFxReference Include="$(NetFxDir)mscorlib.dll;$(NetFxDir)System.dll;$(NetFxDir)System.Core.dll;$(NetFxDir)System.Data.dll;$(NetFxDir)System.Data.DataSetExtensions.dll;$(NetFxDir)System.Deployment.dll;$(NetFxDir)System.Drawing.dll;$(NetFxDir)System.Net.Http.dll;$(NetFxDir)System.Windows.Forms.dll;$(NetFxDir)System.Windows.Forms.DataVisualization.dll;$(NetFxDir)System.Xml.dll;$(NetFxDir)System.Xml.Linq.dll;$(NetFxDir)Microsoft.CSharp.dll" />
|
||||
</ItemGroup>
|
||||
<Copy SourceFiles="@(NetFxReference)" DestinationFiles="@(NetFxReference->'$(OutDir)netfx/%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../Sim.WinForms/Sim.WinForms.csproj" />
|
||||
<ProjectReference Include="../Sim.Devices/Sim.Devices.csproj" />
|
||||
|
||||
@@ -43,9 +43,7 @@ internal static class StudentCompiler
|
||||
public static Result Compile(string projectDir)
|
||||
{
|
||||
var parseOptions = ParseOptions;
|
||||
var trees = Directory.EnumerateFiles(projectDir, "*.cs", SearchOption.AllDirectories)
|
||||
.Where(path => !IsBuildOutput(projectDir, path))
|
||||
.OrderBy(path => path, StringComparer.Ordinal)
|
||||
var trees = SourceFiles(projectDir)
|
||||
.Select(path => CSharpSyntaxTree.ParseText(
|
||||
SourceText.From(File.ReadAllText(path), Encoding.UTF8),
|
||||
parseOptions,
|
||||
@@ -123,6 +121,12 @@ internal static class StudentCompiler
|
||||
return json;
|
||||
}
|
||||
|
||||
/// <summary>学生工程里的源文件(不含 bin、obj 下的),按路径排序</summary>
|
||||
internal static IEnumerable<string> SourceFiles(string projectDir) =>
|
||||
Directory.EnumerateFiles(projectDir, "*.cs", SearchOption.AllDirectories)
|
||||
.Where(path => !IsBuildOutput(projectDir, path))
|
||||
.OrderBy(path => path, StringComparer.Ordinal);
|
||||
|
||||
private static bool IsBuildOutput(string projectDir, string path)
|
||||
{
|
||||
var relative = Path.GetRelativePath(projectDir, path).Replace('\\', '/');
|
||||
|
||||
Reference in new issue
Block a user