/* Copyright (C) 2014-2019 de4dot@gmail.com This file is part of dnSpy dnSpy is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. dnSpy is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with dnSpy. If not, see . */ using System; using System.Collections.Generic; using System.Collections.Immutable; using System.ComponentModel; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using System.Windows.Input; using System.Windows.Threading; using dnSpy.Contracts.App; using dnSpy.Contracts.MVVM; using dnSpy.Contracts.Scripting; using dnSpy.Contracts.Scripting.Roslyn; using dnSpy.Contracts.Text; using dnSpy.Contracts.Text.Classification; using dnSpy.Contracts.Text.Editor; using dnSpy.Contracts.Utilities; using dnSpy.Roslyn.Text; using dnSpy.Roslyn.Text.Classification; using dnSpy.Scripting.Roslyn.Properties; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Scripting; using Microsoft.CodeAnalysis.Scripting.Hosting; using Microsoft.CodeAnalysis.Text; using Microsoft.VisualStudio.Text.Classification; using Microsoft.VisualStudio.Text.Editor; using Microsoft.VisualStudio.Text.Editor.OptionsExtensionMethods; namespace dnSpy.Scripting.Roslyn.Common { sealed class UserScriptOptions { public readonly List References = new List(); public readonly List Imports = new List(); public readonly List LibPaths = new List(); public readonly List LoadPaths = new List(); } abstract class ScriptControlVM : ViewModelBase, IReplCommandHandler, IScriptGlobalsHelper { internal const string CMD_PREFIX = "#"; static readonly string TEXTFILES_FILTER = $"{dnSpy_Scripting_Roslyn_Resources.TextFiles} (*.txt)|*.txt|{dnSpy_Scripting_Roslyn_Resources.AllFiles} (*.*)|*.*"; protected abstract string TextFilenameNoExtension { get; } protected abstract string CodeFilenameNoExtension { get; } protected abstract string CodeFileExtension { get; } protected abstract string CodeFilterText { get; } public string ResetToolTip => ToolTipHelper.AddKeyboardShortcut(dnSpy_Scripting_Roslyn_Resources.Script_ToolTip_Reset, null); public string ClearScreenToolTip => ToolTipHelper.AddKeyboardShortcut(dnSpy_Scripting_Roslyn_Resources.Script_ToolTip_ClearScreen, dnSpy_Scripting_Roslyn_Resources.ShortCutKeyCtrlL); public string HistoryPreviousToolTip => ToolTipHelper.AddKeyboardShortcut(dnSpy_Scripting_Roslyn_Resources.Script_ToolTip_HistoryPrevious, dnSpy_Scripting_Roslyn_Resources.ShortCutKeyAltUp); public string HistoryNextToolTip => ToolTipHelper.AddKeyboardShortcut(dnSpy_Scripting_Roslyn_Resources.Script_ToolTip_HistoryNext, dnSpy_Scripting_Roslyn_Resources.ShortCutKeyAltDown); public string SaveToolTip => ToolTipHelper.AddKeyboardShortcut(dnSpy_Scripting_Roslyn_Resources.Repl_Save_ToolTip, dnSpy_Scripting_Roslyn_Resources.ShortCutKeyCtrlS); public string WordWrapToolTip => ToolTipHelper.AddKeyboardShortcut(dnSpy_Scripting_Roslyn_Resources.Repl_WordWrap_ToolTip, dnSpy_Scripting_Roslyn_Resources.ShortCutKeyCtrlECtrlW); public ICommand ResetCommand => new RelayCommand(a => Reset(), a => CanReset); public ICommand ClearCommand => new RelayCommand(a => ReplEditor.ClearScreen(), a => ReplEditor.CanClearScreen); public ICommand SaveCommand => new RelayCommand(a => SaveText(), a => CanSaveText); public ICommand HistoryPreviousCommand => new RelayCommand(a => ReplEditor.SelectPreviousCommand(), a => ReplEditor.CanSelectPreviousCommand); public ICommand HistoryNextCommand => new RelayCommand(a => ReplEditor.SelectNextCommand(), a => ReplEditor.CanSelectNextCommand); public bool CanReset => hasInitialized && (execState is null || !execState.IsInitializing); public bool CanSaveText => ReplEditor.CanSaveText; public void SaveText() => ReplEditor.SaveText(TextFilenameNoExtension, "txt", TEXTFILES_FILTER); public bool CanSaveCode => ReplEditor.CanSaveCode; public void SaveCode() => ReplEditor.SaveCode(CodeFilenameNoExtension, CodeFileExtension, CodeFilterText); public void Reset(bool loadConfig = true) { if (!CanReset) return; if (execState is not null) { execState.CancellationTokenSource.Cancel(); try { execState.Globals.RaiseScriptResetting(); } catch { // Ignore buggy script exceptions } execState.CancellationTokenSource.Dispose(); } isResetting = true; execState = null; ReplEditor.Reset(); isResetting = false; ReplEditor.OutputPrintLine(dnSpy_Scripting_Roslyn_Resources.ResettingExecutionEngine, BoxedTextColor.ReplOutputText); InitializeExecutionEngine(loadConfig, false); } bool isResetting; public bool WordWrap { get => (ReplEditor.TextView.Options.WordWrapStyle() & WordWrapStyles.WordWrap) != 0; set { if (value) WordWrapStyle |= WordWrapStyles.WordWrap; else WordWrapStyle &= ~WordWrapStyles.WordWrap; } } WordWrapStyles WordWrapStyle { get => ReplEditor.TextView.Options.WordWrapStyle(); set { var oldWordWrapStyle = WordWrapStyle; if (value == oldWordWrapStyle) return; ReplEditor.TextView.Options.SetOptionValue(DefaultTextViewOptions.WordWrapStyleId, value); OnPropertyChanged(nameof(WordWrapStyle)); if (((oldWordWrapStyle ^ value) & WordWrapStyles.WordWrap) != 0) OnPropertyChanged(nameof(WordWrap)); replSettings.WordWrapStyle = value; } } bool ShowLineNumbers { get => ReplEditor.TextView.Options.IsLineNumberMarginEnabled(); set { if (ShowLineNumbers == value) return; ReplEditor.TextView.Options.SetOptionValue(DefaultTextViewHostOptions.LineNumberMarginId, value); replSettings.ShowLineNumbers = value; } } public IReplEditor ReplEditor { get; } public IEnumerable ScriptCommands => toScriptCommand.Values; readonly Dictionary toScriptCommand; IEnumerable CreateScriptCommands() { yield return new ClearCommand(); yield return new HelpCommand(); yield return new ResetCommand(); } readonly Dispatcher dispatcher; readonly RoslynClassificationTypes roslynClassificationTypes; readonly IClassificationType defaultClassificationType; readonly ReplSettings replSettings; protected ScriptControlVM(IReplEditor replEditor, ReplSettings replSettings, IServiceLocator serviceLocator) { dispatcher = Dispatcher.CurrentDispatcher; this.replSettings = replSettings; this.replSettings.PropertyChanged += ReplSettings_PropertyChanged; ReplEditor = replEditor; ReplEditor.CommandHandler = this; this.serviceLocator = serviceLocator; ReplEditor.TextView.Options.OptionChanged += Options_OptionChanged; var themeClassificationTypeService = serviceLocator.Resolve(); roslynClassificationTypes = RoslynClassificationTypes.GetClassificationTypeInstance(themeClassificationTypeService); defaultClassificationType = themeClassificationTypeService.GetClassificationType(TextColor.Error); toScriptCommand = new Dictionary(StringComparer.Ordinal); foreach (var sc in CreateScriptCommands()) { foreach (var name in sc.Names) toScriptCommand.Add(name, sc); } WordWrapStyle = replSettings.WordWrapStyle; ShowLineNumbers = replSettings.ShowLineNumbers; } void ReplSettings_PropertyChanged(object? sender, PropertyChangedEventArgs e) { if (e.PropertyName == nameof(replSettings.WordWrapStyle)) WordWrapStyle = replSettings.WordWrapStyle; else if (e.PropertyName == nameof(replSettings.ShowLineNumbers)) ShowLineNumbers = replSettings.ShowLineNumbers; } protected abstract string Logo { get; } protected abstract string Help { get; } protected abstract Script Create(string code, ScriptOptions options, Type globalsType, InteractiveAssemblyLoader? assemblyLoader); public void OnVisible() { if (hasInitialized) return; hasInitialized = true; ReplEditor.OutputPrintLine(Logo, BoxedTextColor.ReplOutputText); InitializeExecutionEngine(true, true); } bool hasInitialized; void Options_OptionChanged(object? sender, EditorOptionChangedEventArgs e) { if (e.OptionId == DefaultTextViewOptions.WordWrapStyleName) { OnPropertyChanged(nameof(WordWrap)); replSettings.WordWrapStyle = WordWrapStyle; } else if (e.OptionId == DefaultTextViewHostOptions.LineNumberMarginName) replSettings.ShowLineNumbers = ShowLineNumbers; } public bool IsCommand(string text) { if (ParseScriptCommand(text) is not null) return true; return IsCompleteSubmission(text); } protected abstract bool IsCompleteSubmission(string text); sealed class ExecState { public ScriptOptions? ScriptOptions; public readonly CancellationTokenSource CancellationTokenSource; public readonly CancellationToken CancellationToken; public readonly ScriptGlobals Globals; public ScriptState? ScriptState; public Task>? ExecTask; public bool Executing; public bool IsInitializing; public ExecState(ScriptControlVM vm, Dispatcher dispatcher, CancellationTokenSource cts) { CancellationTokenSource = cts; CancellationToken = cts.Token; Globals = new ScriptGlobals(vm, dispatcher, CancellationToken); IsInitializing = true; } } ExecState? execState; readonly object lockObj = new object(); IEnumerable GetDefaultScriptFilePaths() { const string SCRIPTS_DIR = "scripts"; var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); Debug.Assert(Directory.Exists(userProfile)); if (Directory.Exists(userProfile)) { yield return Path.Combine(userProfile, SCRIPTS_DIR); yield return userProfile; } yield return Path.Combine(AppDirectories.DataDirectory, SCRIPTS_DIR); yield return Path.Combine(AppDirectories.BinDirectory, SCRIPTS_DIR); } IEnumerable GetDefaultLibPaths() => GetDefaultScriptFilePaths(); IEnumerable GetDefaultLoadPaths() => GetDefaultScriptFilePaths(); void InitializeExecutionEngine(bool loadConfig, bool showHelp) { Debug2.Assert(execState is null); if (execState is not null) throw new InvalidOperationException(); execState = new ExecState(this, dispatcher, new CancellationTokenSource()); var execStateCache = execState; Task.Run(() => { execStateCache.CancellationToken.ThrowIfCancellationRequested(); var userOpts = new UserScriptOptions(); if (loadConfig) { userOpts.LibPaths.AddRange(GetDefaultLibPaths()); userOpts.LoadPaths.AddRange(GetDefaultLoadPaths()); InitializeUserScriptOptions(userOpts); } var opts = ScriptOptions.Default; opts = opts.WithMetadataResolver(ScriptMetadataResolver.Default .WithBaseDirectory(AppDirectories.BinDirectory) .WithSearchPaths(userOpts.LibPaths.Distinct(StringComparer.OrdinalIgnoreCase))); opts = opts.WithSourceResolver(ScriptSourceResolver.Default .WithBaseDirectory(AppDirectories.BinDirectory) .WithSearchPaths(userOpts.LoadPaths.Distinct(StringComparer.OrdinalIgnoreCase))); opts = opts.WithImports(userOpts.Imports); opts = opts.WithReferences(userOpts.References); execStateCache.ScriptOptions = opts; var script = Create(string.Empty, execStateCache.ScriptOptions, typeof(IScriptGlobals), null); execStateCache.CancellationToken.ThrowIfCancellationRequested(); execStateCache.ScriptState = script.RunAsync(execStateCache.Globals, execStateCache.CancellationToken).Result; if (showHelp) ReplEditor.OutputPrintLine(Help, BoxedTextColor.ReplOutputText); }, execStateCache.CancellationToken) .ContinueWith(t => { execStateCache.IsInitializing = false; var ex = t.Exception; if (!t.IsCanceled && !t.IsFaulted) CommandExecuted(); else ReplEditor.OutputPrintLine($"Could not create the script:\n\n{ex}", BoxedTextColor.Error, true); }, CancellationToken.None, TaskContinuationOptions.None, TaskScheduler.FromCurrentSynchronizationContext()); } protected abstract void InitializeUserScriptOptions(UserScriptOptions options); public void ExecuteCommand(string input) { try { if (!ExecuteCommandInternal(input)) CommandExecuted(); } catch (Exception ex) { ReplEditor.OutputPrint(ex.ToString(), BoxedTextColor.Error, true); CommandExecuted(); } } public void OnNewCommand() { } public Task OnCommandUpdatedAsync(IReplCommandInput command, CancellationToken cancellationToken) { if (isResetting) return Task.CompletedTask; Debug2.Assert(execState is not null); if (execState is null) throw new InvalidOperationException(); string code = command.Input; const string assemblyName = "myasm"; var previousScriptCompilation = execState.ScriptState!.Script.GetCompilation(); if (cancellationToken.IsCancellationRequested) return Task.CompletedTask; var options = previousScriptCompilation.Options; if (cancellationToken.IsCancellationRequested) return Task.CompletedTask; var syntaxTree = CreateSyntaxTree(code, cancellationToken); if (cancellationToken.IsCancellationRequested) return Task.CompletedTask; var sc = CreateScriptCompilation(assemblyName, syntaxTree, null, options, previousScriptCompilation, execState.ScriptState.Script.ReturnType, execState.ScriptState.Script.GlobalsType); if (cancellationToken.IsCancellationRequested) return Task.CompletedTask; var sem = sc.GetSemanticModel(syntaxTree); if (cancellationToken.IsCancellationRequested) return Task.CompletedTask; using (var workspace = new AdhocWorkspace(RoslynMefHostServices.DefaultServices)) { var classifier = new RoslynClassifier(sem.SyntaxTree.GetRoot(), sem, workspace, roslynClassificationTypes, defaultClassificationType, cancellationToken); foreach (var info in classifier.GetColors(new TextSpan(0, command.Input.Length))) command.AddClassification(info.Span.Start, info.Span.Length, (IClassificationType)info.Color); } return Task.CompletedTask; } protected abstract SyntaxTree CreateSyntaxTree(string code, CancellationToken cancellationToken); protected abstract Compilation CreateScriptCompilation(string assemblyName, SyntaxTree syntaxTree, IEnumerable? references, CompilationOptions options, Compilation previousScriptCompilation, Type returnType, Type globalsType); bool ExecuteCommandInternal(string input) { Debug2.Assert(execState is not null && !execState.IsInitializing); if (execState is null || execState.IsInitializing) return true; lock (lockObj) { Debug2.Assert(execState.ExecTask is null && !execState.Executing); if (execState.ExecTask is not null || execState.Executing) return true; execState.Executing = true; } try { var scState = ParseScriptCommand(input); if (scState is not null) { if (execState is not null) { lock (lockObj) execState.Executing = false; } scState.Command.Execute(this, scState.Arguments); bool isReset = scState.Command is ResetCommand; if (!isReset) CommandExecuted(); return true; } var oldState = execState; var taskSched = TaskScheduler.FromCurrentSynchronizationContext(); Task.Run(() => { oldState.CancellationToken.ThrowIfCancellationRequested(); var opts = oldState.ScriptOptions!.WithReferences(Array.Empty()).WithImports(Array.Empty()); var execTask = oldState.ScriptState!.ContinueWithAsync(input, opts, oldState.CancellationToken); oldState.CancellationToken.ThrowIfCancellationRequested(); lock (lockObj) { if (oldState == execState) oldState.ExecTask = execTask; } execTask.ContinueWith(t => { var ex = t.Exception; bool isActive; lock (lockObj) { isActive = oldState == execState; if (isActive) oldState.ExecTask = null; } if (isActive) { try { if (ex is not null) ReplEditor.OutputPrint(Format(ex.InnerException!), BoxedTextColor.Error, true); if (!t.IsCanceled && !t.IsFaulted) { oldState.ScriptState = t.Result; var val = t.Result.ReturnValue; if (val is not null) ObjectOutputLine(BoxedTextColor.ReplOutputText, oldState.Globals.PrintOptionsImpl, val, true); } } finally { CommandExecuted(); } } }, CancellationToken.None, TaskContinuationOptions.None, taskSched); }) .ContinueWith(t => { if (execState is not null) { lock (lockObj) execState.Executing = false; } var innerEx = t.Exception?.InnerException; if (innerEx is CompilationErrorException cee) { PrintDiagnostics(cee.Diagnostics); CommandExecuted(); } else if (innerEx is OperationCanceledException) CommandExecuted(); else { var ex = t.Exception; if (ex is not null) { ReplEditor.OutputPrint(ex.ToString(), BoxedTextColor.Error, true); CommandExecuted(); } } }, CancellationToken.None, TaskContinuationOptions.None, taskSched); return true; } catch (Exception ex) { if (execState is not null) { lock (lockObj) execState.Executing = false; } ReplEditor.OutputPrintLine($"Error executing script:\n\n{ex}", BoxedTextColor.Error, true); return false; } } bool UnpackScriptCommand(string input, [NotNullWhen(true)] out string? name, [NotNullWhen(true)] out string[]? args) { name = null; args = null; var s = input.TrimStart(); if (!s.StartsWith(CMD_PREFIX)) return false; s = s.Substring(CMD_PREFIX.Length).TrimStart(); var parts = s.Split(argSeps, StringSplitOptions.RemoveEmptyEntries); args = parts.Skip(1).ToArray(); name = parts[0]; return true; } static readonly char[] argSeps = new char[] { ' ', '\t', '\r', '\n', '\u0085', '\u2028', '\u2029' }; sealed class ExecScriptCommandState { public readonly IScriptCommand Command; public readonly string[] Arguments; public ExecScriptCommandState(IScriptCommand sc, string[] args) { Command = sc; Arguments = args; } } ExecScriptCommandState? ParseScriptCommand(string input) { if (!UnpackScriptCommand(input, out var name, out var args)) return null; if (!toScriptCommand.TryGetValue(name, out var sc)) return null; return new ExecScriptCommandState(sc, args); } void PrintDiagnostics(ImmutableArray diagnostics) { const int MAX_DIAGS = 5; for (int i = 0; i < diagnostics.Length && i < MAX_DIAGS; i++) ReplEditor.OutputPrintLine(DiagnosticFormatter.Format(diagnostics[i], Thread.CurrentThread.CurrentUICulture), BoxedTextColor.Error, true); int extraErrors = diagnostics.Length - MAX_DIAGS; if (extraErrors > 0) { if (extraErrors == 1) ReplEditor.OutputPrintLine(string.Format(dnSpy_Scripting_Roslyn_Resources.CompilationAdditionalError, extraErrors), BoxedTextColor.Error, true); else ReplEditor.OutputPrintLine(string.Format(dnSpy_Scripting_Roslyn_Resources.CompilationAdditionalErrors, extraErrors), BoxedTextColor.Error, true); } } void CommandExecuted() { ReplEditor.OnCommandExecuted(); OnCommandExecuted?.Invoke(this, EventArgs.Empty); } public event EventHandler? OnCommandExecuted; protected abstract ObjectFormatter ObjectFormatter { get; } protected abstract DiagnosticFormatter DiagnosticFormatter { get; } string Format(object? value, PrintOptions printOptions) => ObjectFormatter.FormatObject(value, printOptions); string Format(Exception ex) => ObjectFormatter.FormatException(ex); /// /// Returns true if it's the current script /// /// Globals /// bool IsCurrentScript(ScriptGlobals globals) => execState?.Globals == globals; void IScriptGlobalsHelper.Print(ScriptGlobals globals, object? color, string? text) { if (!IsCurrentScript(globals)) return; if (color is null) return; ReplEditor.OutputPrint(text, color); } void IScriptGlobalsHelper.PrintLine(ScriptGlobals globals, object? color, string? text) { if (!IsCurrentScript(globals)) return; if (color is null) return; ReplEditor.OutputPrintLine(text, color); } void IScriptGlobalsHelper.Print(ScriptGlobals globals, object? color, PrintOptionsImpl printOptions, object? value) { if (!IsCurrentScript(globals)) return; ObjectOutput(color, printOptions, value); } void IScriptGlobalsHelper.PrintLine(ScriptGlobals globals, object? color, PrintOptionsImpl printOptions, object? value) { if (!IsCurrentScript(globals)) return; ObjectOutputLine(color, printOptions, value); } void IScriptGlobalsHelper.Print(ScriptGlobals globals, object? color, Exception? ex) { if (!IsCurrentScript(globals)) return; if (color is null || ex is null) return; ReplEditor.OutputPrint(Format(ex), color); } void IScriptGlobalsHelper.PrintLine(ScriptGlobals globals, object? color, Exception? ex) { if (!IsCurrentScript(globals)) return; if (color is null || ex is null) return; ReplEditor.OutputPrintLine(Format(ex), color); } void IScriptGlobalsHelper.Print(ScriptGlobals globals, CachedWriter writer, object? color, PrintOptionsImpl printOptions, object? value) { if (!IsCurrentScript(globals)) return; ObjectOutput(writer, color, printOptions, value); } void IScriptGlobalsHelper.Print(ScriptGlobals globals, CachedWriter writer, object? color, Exception? ex) { if (!IsCurrentScript(globals)) return; if (color is null || ex is null) return; writer.Write(Format(ex), color); } void IScriptGlobalsHelper.Write(ScriptGlobals globals, List list) { if (!IsCurrentScript(globals)) return; ReplEditor.OutputPrint(list.Select(a => new ColorAndText(a.Color, a.Text))); } IOutputWritable? GetOutputWritable(PrintOptionsImpl printOptions, object? value) { if (!printOptions.AutoColorizeObjects) return null; return value as IOutputWritable; } sealed class OutputWriter : IOutputWriter { readonly ScriptControlVM owner; bool startOnNewLine; public static IOutputWriter Create(ScriptControlVM owner, bool startOnNewLine) { if (startOnNewLine) return new OutputWriter(owner, startOnNewLine); return normalOutputWriter = new OutputWriter(owner, false); } static IOutputWriter? normalOutputWriter; OutputWriter(ScriptControlVM owner, bool startOnNewLine) { this.owner = owner; } public void Write(string? text, object? color) { if (text is null) return; owner.ReplEditor.OutputPrint(text, color ?? BoxedTextColor.ReplScriptOutputText, startOnNewLine); startOnNewLine = false; } public void Write(string? text, TextColor color) => Write(text, color.Box()); } void ObjectOutput(CachedWriter writer, object? color, PrintOptionsImpl printOptions, object? value) { var writable = GetOutputWritable(printOptions, value); if (writable is not null) writable.WriteTo(writer); else writer.Write(Format(value, printOptions.RoslynPrintOptions), color); } void ObjectOutput(object? color, PrintOptionsImpl printOptions, object? value, bool startOnNewLine = false) { if (color is null) return; var writable = GetOutputWritable(printOptions, value); if (writable is not null) writable.WriteTo(OutputWriter.Create(this, startOnNewLine)); else ReplEditor.OutputPrint(Format(value, printOptions.RoslynPrintOptions), color, startOnNewLine); } void ObjectOutputLine(object? color, PrintOptionsImpl printOptions, object? value, bool startOnNewLine = false) { if (color is null) return; ObjectOutput(color, printOptions, value, startOnNewLine); ReplEditor.OutputPrintLine(string.Empty, color); } IServiceLocator IScriptGlobalsHelper.ServiceLocator => serviceLocator; readonly IServiceLocator serviceLocator; protected static string? GetResponseFile(string filename) { foreach (var dir in AppDirectories.GetDirectories(string.Empty)) { var path = Path.Combine(dir, filename); if (File.Exists(path)) return path; } Debug.Fail($"Couldn't find the response file: {filename}"); return null; } } }