commit a5f124a9db3d6629e339c206c885d078d3ffb725 Author: Timerix Date: Fri Jun 12 01:49:40 2026 +0500 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e334c97 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Build results +[Bb]in/ +.bin/ +[Dd]ebug/ +[Rr]elease/ +[Rr]eleases/ +[Oo]bj/ +[Oo]ut/ +[Ll]og/ +[Ll]ogs/ +[Pp]ublish/ +*.log + +# IDE files +.vs/ +.vscode/ +.vshistory/ +.idea/ +.editorconfig +*.user +*.DotSettings + +# temp files +.old*/ +old/ +tmp/ +temp/ +*.tmp +*.temp diff --git a/CSharpScript.sln b/CSharpScript.sln new file mode 100644 index 0000000..dd42a02 --- /dev/null +++ b/CSharpScript.sln @@ -0,0 +1,16 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CSharpScript", "CSharpScript\CSharpScript.csproj", "{4A25F563-E6D4-4A4D-82B4-21A1E7FADC8A}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {4A25F563-E6D4-4A4D-82B4-21A1E7FADC8A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4A25F563-E6D4-4A4D-82B4-21A1E7FADC8A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4A25F563-E6D4-4A4D-82B4-21A1E7FADC8A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4A25F563-E6D4-4A4D-82B4-21A1E7FADC8A}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/CSharpScript/CSharpScript.csproj b/CSharpScript/CSharpScript.csproj new file mode 100644 index 0000000..5ea7494 --- /dev/null +++ b/CSharpScript/CSharpScript.csproj @@ -0,0 +1,23 @@ + + + Exe + net8.0 + latest + disable + enable + true + none + + + + + + + + + + + PreserveNewest + + + diff --git a/CSharpScript/CSharpScript.toml.default b/CSharpScript/CSharpScript.toml.default new file mode 100644 index 0000000..ff426fa --- /dev/null +++ b/CSharpScript/CSharpScript.toml.default @@ -0,0 +1,18 @@ +# where to search for .cs files +src_dir = "src" + +# namespace and class with method Main +main_class = "Script.Program" + +# where compiled files will be cached +cache_dir = "cache/CSharpScript" + +# comppiled file name +assembly_file_name = "Program.dll" + +# paths of dll files that are referenced in your source code +# Placeholders: +# %RUNTIME% - directory where System.* libraries are stored +references = [ + "%RUNTIME%/netstandard.dll" +] diff --git a/CSharpScript/Program.cs b/CSharpScript/Program.cs new file mode 100644 index 0000000..e949053 --- /dev/null +++ b/CSharpScript/Program.cs @@ -0,0 +1,191 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection; +using Microsoft.CodeAnalysis; +using Tomlyn; + +namespace CSharpScript; + +public class Config +{ + public string src_dir { get; set; } + public string main_class { get; set; } + public string cache_dir { get; set; } = "cache/CSharpScript"; + public string assembly_file_name { get; set; } + + public List references { get; set; } = ["%RUNTIME%/netstandard.dll"]; +} + +public static class Program +{ + private const string CONFIG_PATH = "CSharpScript.toml"; + + private static readonly TomlSerializerOptions TomlSerializerOptions = new() + { + WriteIndented = true, + IndentSize = 4, + MaxDepth = 64, + DefaultIgnoreCondition = TomlIgnoreCondition.WhenWritingNull, + }; + + static string PathFixSeparators(string path) + { + char sep = Path.DirectorySeparatorChar; + char notSep = sep == '\\' ? '/' : '\\'; + return path.Replace(notSep, sep); + } + + static int Main(string[] args) + { + if (!File.Exists(CONFIG_PATH)) + { + Console.WriteLine($"Error: Config '{CONFIG_PATH}' not found"); + return 1; + } + + var conf = TomlSerializer.Deserialize(File.ReadAllText(CONFIG_PATH), TomlSerializerOptions) + ?? throw new Exception("can't deserialize config " + CONFIG_PATH); + + // find sources + var csFiles = Directory.GetFiles(PathFixSeparators(conf.src_dir), "*.cs").ToList(); + Console.WriteLine("Source files:"); + csFiles.ForEach(Console.WriteLine); + Console.WriteLine(""); + + // try load cached assembly + string cachedAssemblyPath = Path.GetFullPath( + Path.Combine( + PathFixSeparators(conf.cache_dir), + conf.assembly_file_name)); + if (File.Exists(cachedAssemblyPath)) + { + var sourceFileUpdateTime = csFiles.Select(File.GetLastWriteTime).Max(); + var cachedAssemblyUpdateTime = File.GetLastWriteTime(cachedAssemblyPath); + Console.WriteLine($"Sources in '{conf.src_dir}' were updated at {sourceFileUpdateTime:yyyy.MM.dd-HH:mm:ss}"); + Console.WriteLine($"Assembly '{cachedAssemblyPath}' was cached at {cachedAssemblyUpdateTime:yyyy.MM.dd-HH:mm:ss}"); + // sources weren't changed + if (sourceFileUpdateTime <= cachedAssemblyUpdateTime) + { + Console.WriteLine($"Loading cached assembly '{cachedAssemblyPath}'"); + var assembly = Assembly.LoadFile(cachedAssemblyPath); + return ExecAssemblyMain(assembly, conf, args); + } + Console.WriteLine("Cached assembly is obsolete"); + } + + // compile sources + var sw = Stopwatch.StartNew(); + var compiler = InitCompiler(conf); + Console.WriteLine($"Initialized in {sw.ElapsedMilliseconds}ms"); + sw.Restart(); + Console.WriteLine("Compiling..."); + var compResult = compiler.Compile(csFiles); + Console.WriteLine($"Compiled in {sw.ElapsedMilliseconds}s"); + sw.Stop(); + return compResult.Match( + noChanges => throw new NotImplementedException(noChanges.ToString()), + success => + { + PrintDiagnostics(success.Diagnostics); + Console.WriteLine(); + var assembly = SaveAndLoadCompiledAssembly(success, cachedAssemblyPath); + return ExecAssemblyMain(assembly, conf, args); + }, + error => + { + PrintDiagnostics(error.Diagnostics); + Console.WriteLine("Compilation failed."); + return 2; + }); + } + + + private static RoslynWrapper InitCompiler(Config conf) + { + Console.WriteLine("\nInitializing compiler..."); + + Console.WriteLine("Referenced assemblies:"); + List referenceAssemblyFilePaths = AppDomain.CurrentDomain + .GetAssemblies() + .Where(a => !a.IsDynamic && !string.IsNullOrEmpty(a.Location)) + .Select(a => a.Location) + .ToList(); + referenceAssemblyFilePaths.ForEach(Console.WriteLine); + + var systemDllPath = referenceAssemblyFilePaths + .Find(p => Path.GetFileName(p).StartsWith("System.Private.")) + ?? throw new Exception("can't find location of System.dll in loaded assenmblies"); + string runtimeAssembliesDir = Path.GetDirectoryName(systemDllPath) ?? "."; + foreach (var path_ in conf.references) + { + string path = Path.GetFullPath( + path_.Replace("%RUNTIME%", runtimeAssembliesDir)); + referenceAssemblyFilePaths.Add(path); + Console.WriteLine(path); + } + + var referencedAssemblies = referenceAssemblyFilePaths + .Select(p => (MetadataReference)MetadataReference.CreateFromFile(p)) + .ToList(); + Console.WriteLine(); + + var compiler = new RoslynWrapper(conf.assembly_file_name, referencedAssemblies); + compiler.CompilationOptions = compiler.CompilationOptions + .WithOutputKind(OutputKind.ConsoleApplication) + .WithMainTypeName(conf.main_class); + return compiler; + } + + private static void PrintDiagnostics(IEnumerable diagnostics) + { + Console.WriteLine("Diagnostic messages:"); + foreach (var d in diagnostics) + { + var span = d.Location.GetLineSpan(); + var start = span.StartLinePosition; + string? locationString = null; + if (!string.IsNullOrEmpty(span.Path)) + locationString = $"{span.Path}:{start.Line + 1}:{start.Character + 1} "; + Console.WriteLine($"{locationString}{d.Severity} {d.Id}: {d.GetMessage()}"); + } + } + + private static Assembly SaveAndLoadCompiledAssembly(CompilationSuccess success, string filePath) + { + Console.WriteLine($"Saving compiled assembly to '{filePath}'"); + Directory.CreateDirectory(Path.GetDirectoryName(filePath) ?? "."); + File.WriteAllBytes(filePath, success.AssemblyBytes); + Console.WriteLine("Loading compiled assembly from memory"); + var assembly = Assembly.Load(success.AssemblyBytes); + return assembly; + } + + private static int ExecAssemblyMain(Assembly assembly, Config conf, string[] args) + { + MethodInfo? entryPoint = assembly.EntryPoint; + if (entryPoint == null) + throw new Exception($"No entry point found in assembly {assembly} "); + + // Main() + object?[]? parameters = null; + // Main(string[] args) + if (entryPoint.GetParameters().Length == 1) + { + parameters = [ args ]; + } + + string? argsStr = null; + if (args.Length != 0) + { + argsStr = "'" + string.Join("', '", args) + "'"; + } + Console.WriteLine($"Executing {conf.assembly_file_name} {conf.main_class}.Main({argsStr})\n"); + object? result = entryPoint.Invoke(null, parameters); + if (result is int retcode) + return retcode; + return 0; + } +} \ No newline at end of file diff --git a/CSharpScript/RoslynWrapper.cs b/CSharpScript/RoslynWrapper.cs new file mode 100644 index 0000000..4a3158b --- /dev/null +++ b/CSharpScript/RoslynWrapper.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Text; +using OneOf; + +namespace CSharpScript; + +// Based on https://github.com/tugberkugurlu/DotNetSamples/blob/master/csharp/RoslynCompileSample/RoslynCompileSample/Program.cs + +public enum CompilationStatus +{ + Compiled, NoChanges, Error +} + +public record CompilationNoChanges; +public record CompilationSuccess(IList Diagnostics, byte[] AssemblyBytes); +public record CompilationError(IList Diagnostics); + + +public class RoslynWrapper +{ + public readonly string AssemblyName; + + public List References; + + public CSharpParseOptions ParseOptions = new( + languageVersion: LanguageVersion.Latest); + + public CSharpCompilationOptions CompilationOptions = new( + outputKind: OutputKind.DynamicallyLinkedLibrary); + + + private record CachedSourceFile(DateTime EditTime, CSharpSyntaxTree SyntaxTree); + + private Dictionary _cachedSourceFiles = new(); + + private CSharpCompilation? _cachedCompilation; + + + public RoslynWrapper(string assemblyName, List references) + { + AssemblyName = assemblyName; + References = references; + } + + + public OneOf Compile(IEnumerable filePaths) + { + if(_cachedCompilation is null) + CreateCompilation(filePaths); + else if (UpdateCompilation(filePaths) == false) + return new CompilationNoChanges(); + + using var ms = new MemoryStream(); + var result = _cachedCompilation!.Emit(ms); + + if (result.Success) + { + ms.Seek(0, SeekOrigin.Begin); + return new CompilationSuccess(result.Diagnostics, ms.ToArray()); + } + + var errors = result.Diagnostics + .Where(d => + d.IsWarningAsError || + d.Severity == DiagnosticSeverity.Error) + .ToList(); + return new CompilationError(errors); + } + + + private static SourceText ReadSourceText(string filePath) + { + using var fileStream = File.OpenRead(filePath); + return SourceText.From(fileStream); + } + + private void CreateCompilation(IEnumerable filePaths) + { + Dictionary sourceFiles = new(); + List syntaxTrees = new(); + foreach (var filePath in filePaths) + { + var editTime = File.GetLastWriteTime(filePath); + var syntaxTree = CSharpSyntaxTree.ParseText(ReadSourceText(filePath), ParseOptions, filePath); + syntaxTrees.Add(syntaxTree); + sourceFiles.Add(filePath, new CachedSourceFile(editTime, (CSharpSyntaxTree)syntaxTree)); + } + + var compilation = CSharpCompilation.Create( + AssemblyName, + syntaxTrees: syntaxTrees, + references: References, + options: CompilationOptions); + + // assign fields only when compilation is initialized successfully + _cachedCompilation = compilation; + _cachedSourceFiles = sourceFiles; + } + + /// false if source code wasn't changed since last cached compilation + private bool UpdateCompilation(IEnumerable filePaths) + { + if (_cachedCompilation is null) + throw new Exception("can't update compilation because it was not cached"); + + Dictionary cachedSourceFilesTemp = new(_cachedSourceFiles); + List<(CachedSourceFile old, CachedSourceFile upd)> sourceFilesChanged = new(); + List sourceFilesNotChanged = new(); + List sourceFilesAdded = new(); + List sourceFilesRemoved = new(); + + // populate sourceFilesChanged, sourceFilesNotChanged, sourceFilesAdded + foreach (var filePath in filePaths) + { + var editTime = File.GetLastWriteTime(filePath); + // source file is cached + if(_cachedSourceFiles.TryGetValue(filePath, out var cachedFile)) + { + cachedSourceFilesTemp.Remove(filePath); + + // file wasn't edited + if (editTime == cachedFile.EditTime) + { + sourceFilesNotChanged.Add(cachedFile); + continue; + } + + // file was edited + var updatedSyntaxTree = cachedFile.SyntaxTree.WithChangedText(ReadSourceText(filePath)); + var updatedFile = new CachedSourceFile(editTime, (CSharpSyntaxTree)updatedSyntaxTree); + sourceFilesChanged.Add(new(cachedFile, updatedFile)); + } + + // source file is new + var syntaxTree = CSharpSyntaxTree.ParseText(ReadSourceText(filePath), ParseOptions, filePath); + sourceFilesAdded.Add(new CachedSourceFile(editTime, (CSharpSyntaxTree)syntaxTree)); + } + + // at this point all wiles that are left in cachedSourceFilesTemp are not present in filePaths + sourceFilesRemoved.AddRange(cachedSourceFilesTemp.Values); + + // source files are the same as in cached compilation, nothing to update + int changedFilesCount = sourceFilesChanged.Count + sourceFilesAdded.Count + sourceFilesRemoved.Count; + if (changedFilesCount == 0) + return false; + + Dictionary updatedCachedSourceFiles = new(); + foreach (var csf in sourceFilesNotChanged) + updatedCachedSourceFiles.Add(csf.SyntaxTree.FilePath, csf); + foreach (var csf in sourceFilesAdded) + updatedCachedSourceFiles.Add(csf.SyntaxTree.FilePath, csf); + foreach (var tupl in sourceFilesChanged) + updatedCachedSourceFiles.Add(tupl.upd.SyntaxTree.FilePath, tupl.upd); + + var updatedCompilation = _cachedCompilation + .RemoveSyntaxTrees(sourceFilesRemoved.Select(csf => csf.SyntaxTree)) + .AddSyntaxTrees(sourceFilesAdded.Select(csf => csf.SyntaxTree)); + foreach (var (old, upd) in sourceFilesChanged) + { + updatedCompilation = updatedCompilation.ReplaceSyntaxTree(old.SyntaxTree, upd.SyntaxTree); + } + + // assign fields only when compilation is modified successfully + _cachedCompilation = updatedCompilation; + _cachedSourceFiles = updatedCachedSourceFiles; + return true; + } +} \ No newline at end of file