using System.Collections;
using System.Diagnostics;
using System.Reflection;
using System.Text.Json;
using System.Text.RegularExpressions;
using Autodesk.Revit.DB;
using Autodesk.Revit.UI;
using Autodesk.Revit.UI.Events;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
namespace RevitBridge.Tools
{
///
/// Globals visible to execute_csharp scripts: doc, uidoc, uiapp, and the Dump helper.
/// Public because the dynamically compiled script assembly must bind to it; the
/// lowercase member names are the script-facing contract.
///
public sealed class ScriptGlobals
{
internal ScriptGlobals(Document document, UIDocument uiDocument, UIApplication uiApplication, Action dump)
{
doc = document;
uidoc = uiDocument;
uiapp = uiApplication;
Dump = dump;
}
/// The active document.
public Document doc { get; }
/// The active UI document (selection, active view).
public UIDocument uidoc { get; }
/// The Revit UI application.
public UIApplication uiapp { get; }
/// Records a value into the result's dumps[] (safely projected at call time).
public Action Dump { get; }
}
///
/// Unrestricted C# escape hatch: compiles the script with Roslyn, runs it on the
/// Revit API thread inside ONE backend-owned Transaction("execute_csharp") — commit on
/// success, rollback on any exception. Results are projected through a depth-limited
/// serializer that never emits raw Revit API objects. A DialogBoxShowing guard
/// auto-dismisses modal dialogs so a script cannot hang the Revit thread behind a popup.
///
internal sealed class ExecuteCsharp : ITool
{
private const int MaxDumps = 200;
private const int MaxDepth = 6;
private const int MaxItems = 100;
private const int MaxProperties = 40;
private const int MaxStringChars = 4_000;
private const int MaxReportedErrors = 20;
public string Name => "execute_csharp";
public string Label => "Execute C#";
public string Description => "Compile and run a C# script on the Revit API thread against the open model — the escape hatch for anything without a dedicated tool: element creation, deletion, geometry edits (move/copy/rotate), views, sheets, schedules, tagging, families, links, worksets. Globals: doc (Document), uidoc (UIDocument), uiapp (UIApplication), and Dump(value) to record intermediate values into the result's dumps[]. Default imports: System, System.Linq, System.Collections.Generic, Autodesk.Revit.DB, Autodesk.Revit.UI — add using directives at the top for sub-namespaces (e.g. using Autodesk.Revit.DB.Architecture;). The entire run is wrapped in ONE transaction named 'execute_csharp': committed on success, rolled back on any exception, so a failed script never changes the model (do not open your own Transaction; sub-transactions are fine). The script's final expression or return statement becomes returnValue; return primitives, strings, or anonymous objects/lists — Revit API values are projected to safe shapes (Element -> {id,name,category,typeName,levelId}, ElementId -> number, XYZ -> {x,y,z}, Parameter -> {name,value,displayValue}; other API objects become strings) with depth and item caps, so never rely on raw API objects round-tripping. Lengths are in internal units (decimal feet) — convert with UnitUtils. Prefer collector-level filtering (FilteredElementCollector .OfCategory/.OfClass/.WhereElementIsNotElementType) and bounded loops: the call budget is 120s and Revit cannot be interrupted mid-script. Scripts must be fully synchronous — await/async is rejected at compile time, and blocking on tasks (Task.Result/.Wait()) can freeze Revit. Modal dialogs raised while running are auto-dismissed and reported in suppressedDialogs — unrecognized dialogs are answered dismissively (Cancel/Close/No) rather than confirmed, so an operation that raises a confirmation prompt may be cancelled; check suppressedDialogs when a result looks incomplete. Verify unfamiliar signatures with search_api_docs first.";
public bool Write => true;
public object ParametersSchema => new
{
type = "object",
properties = new
{
code = new
{
type = "string",
description = "C# script body (top-level statements; using directives allowed at the top). Globals doc/uidoc/uiapp and Dump(value) are in scope. The final expression or a return statement is the result.",
},
expected_document = new
{
type = "string",
description = "Optional safety check: title of the document this script is meant for (as reported by get_model_overview). If the active document differs (user switched models), the call fails before running anything.",
},
},
required = new[] { "code" },
};
public string? PromptSnippet => "Run a C# script against the Revit API inside one transaction (globals doc/uidoc/uiapp, Dump helper) — the escape hatch for operations without a dedicated tool.";
public IReadOnlyList? PromptGuidelines => new[]
{
"Use execute_csharp for Revit operations no dedicated tool covers (creation, deletion, geometry edits, views/sheets, tagging, ...); check exact signatures with search_api_docs before writing the script.",
"execute_csharp scripts must be fully synchronous (await/async is rejected; never block on Task.Result/.Wait()) and should return primitives or anonymous objects/lists, using Dump(...) for intermediates — raw Revit objects are projected to compact summaries; keep loops bounded and filter at the collector level.",
};
public object? Execute(JsonElement args, ToolContext context)
{
var doc = context.Document ?? throw new NoActiveDocumentException();
var uiapp = context.UIApplication ?? throw new NoActiveDocumentException();
var uidoc = uiapp.ActiveUIDocument ?? throw new NoActiveDocumentException();
DocumentGuard.CheckExpectedDocument(args, doc);
string code = JsonArgs.GetString(args, "code") ?? string.Empty;
if (string.IsNullOrWhiteSpace(code))
throw new ArgumentException("code must be a non-empty C# script.");
var stopwatch = Stopwatch.StartNew();
// Compile before opening the transaction: compile errors must not touch the model.
var script = CSharpScript.Create(code, Options.Value, typeof(ScriptGlobals));
var errors = script.Compile().Where(d => d.Severity == DiagnosticSeverity.Error).ToList();
if (errors.Count > 0)
throw new ArgumentException(FormatCompileErrors(errors));
RejectAsyncCode(script.GetCompilation());
var dumps = new List();
var globals = new ScriptGlobals(doc, uidoc, uiapp, value =>
{
if (dumps.Count < MaxDumps)
dumps.Add(Project(value, 0));
else if (dumps.Count == MaxDumps)
dumps.Add($"… Dump cap reached ({MaxDumps}); further values were ignored.");
});
using var dialogGuard = new DialogGuard(uiapp);
using var transaction = new Transaction(doc, "execute_csharp");
var failureGuard = FailureGuard.Attach(transaction);
if (transaction.Start() != TransactionStatus.Started)
throw new InvalidOperationException("Unable to start the execute_csharp transaction.");
object? returnValue;
string? projectionError = null;
try
{
// Runs synchronously on this (the Revit API) thread; await/async was
// rejected at compile time, so the task completes without suspending.
var state = script.RunAsync(globals).GetAwaiter().GetResult();
// Project inside the transaction so values reflect the script-end model
// state — but never let projection failure masquerade as script failure:
// a lazy sequence that faults while being walked (a deleted element, a
// throwing property) would otherwise roll back a script that fully
// succeeded. The script's changes stand; only the return value is lost.
try
{
returnValue = Project(state.ReturnValue, 0);
}
catch (Exception ex)
{
projectionError = $"{ex.GetType().Name}: {ex.Message}";
returnValue = $"";
}
}
catch (Exception ex)
{
try
{
if (transaction.GetStatus() == TransactionStatus.Started)
transaction.RollBack();
}
catch
{
// Reporting the script failure outranks a rollback hiccup.
}
throw new InvalidOperationException(FormatRuntimeError(ex, dialogGuard.Suppressed));
}
if (transaction.Commit() != TransactionStatus.Committed)
throw new InvalidOperationException(
"Revit rolled back the execute_csharp transaction during commit (failure processing rejected the changes); no model changes were saved."
+ failureGuard.DescribeErrors()
+ DescribeDialogs(dialogGuard.Suppressed));
stopwatch.Stop();
var payload = new Dictionary
{
["returnValue"] = returnValue,
["dumps"] = dumps,
["durationMs"] = stopwatch.ElapsedMilliseconds,
};
if (projectionError != null)
payload["returnValueError"] = projectionError;
if (dialogGuard.Suppressed.Count > 0)
payload["suppressedDialogs"] = dialogGuard.Suppressed;
if (failureGuard.Warnings.Count > 0)
payload["commitWarnings"] = failureGuard.Warnings;
return payload;
}
// -------------------------------------------------------------- scripting
/// Compile options: core BCL + LINQ + Revit API references; the five
/// default imports; debug info so runtime stack traces carry script line numbers.
private static readonly Lazy Options = new(() => ScriptOptions.Default
.WithReferences(
typeof(object).Assembly, // System.Private.CoreLib
Assembly.Load("System.Runtime"),
Assembly.Load("netstandard"),
typeof(Enumerable).Assembly, // System.Linq
typeof(Regex).Assembly, // System.Text.RegularExpressions
typeof(Console).Assembly, // System.Console
typeof(Document).Assembly, // RevitAPI
typeof(UIApplication).Assembly) // RevitAPIUI
.WithImports("System", "System.Linq", "System.Collections.Generic", "Autodesk.Revit.DB", "Autodesk.Revit.UI")
.WithEmitDebugInformation(true));
private static string FormatCompileErrors(IReadOnlyList errors)
{
var lines = errors.Take(MaxReportedErrors).Select(diagnostic =>
{
var position = diagnostic.Location.GetLineSpan().StartLinePosition;
return $" line {position.Line + 1}, col {position.Character + 1}: {diagnostic.Id} {diagnostic.GetMessage()}";
});
string more = errors.Count > MaxReportedErrors ? $"\n … +{errors.Count - MaxReportedErrors} more error(s)" : string.Empty;
return $"C# compilation failed with {errors.Count} error(s):\n{string.Join("\n", lines)}{more}\n"
+ "Globals: doc, uidoc, uiapp, Dump(value). Default imports: System, System.Linq, System.Collections.Generic, "
+ "Autodesk.Revit.DB, Autodesk.Revit.UI — add using directives for other namespaces.";
}
/// Rejects await/async at compile time. The script runs synchronously on the
/// Revit API thread (RunAsync + GetResult): an await would either deadlock Revit
/// forever (the dispatcher is blocked by GetResult, so a captured-context continuation
/// can never run) or resume the rest of the script on a thread-pool thread inside the
/// open transaction. Deterministic syntax-tree guard at the execution boundary, not
/// source-text sniffing.
private static void RejectAsyncCode(Compilation compilation)
{
foreach (var tree in compilation.SyntaxTrees)
{
foreach (var token in tree.GetRoot().DescendantTokens())
{
if (!token.IsKind(SyntaxKind.AwaitKeyword) && !token.IsKind(SyntaxKind.AsyncKeyword))
continue;
var position = token.GetLocation().GetLineSpan().StartLinePosition;
throw new ArgumentException(
$"'{token.ValueText}' at line {position.Line + 1}, col {position.Character + 1}: "
+ "scripts run synchronously on the Revit API thread — remove await/async "
+ "(and never block on Task.Result/.Wait()); write fully synchronous code.");
}
}
}
private static string FormatRuntimeError(Exception ex, IReadOnlyList suppressedDialogs)
{
string line = TryGetScriptLine(ex) is { } scriptLine ? $" at script line {scriptLine}" : string.Empty;
string inner = ex.InnerException is { } innerEx ? $" Inner: {innerEx.GetType().Name}: {innerEx.Message}" : string.Empty;
return $"C# script threw {ex.GetType().Name}{line}: {ex.Message}.{inner}"
+ " The execute_csharp transaction was rolled back; no model changes were saved."
+ DescribeDialogs(suppressedDialogs);
}
private static string DescribeDialogs(IReadOnlyList suppressed)
=> suppressed.Count > 0 ? $" Auto-dismissed dialog(s) during the run: {string.Join(", ", suppressed)}." : string.Empty;
/// Script frames carry sequence points (EmitDebugInformation): pull the
/// first ":line N" out of the stack trace.
private static int? TryGetScriptLine(Exception ex)
{
string? stackTrace = ex.StackTrace;
if (stackTrace is null)
return null;
var match = Regex.Match(stackTrace, @":line (\d+)");
return match.Success && int.TryParse(match.Groups[1].Value, out int line) ? line : null;
}
/// Auto-dismisses any modal Revit dialog raised while the script runs, so a
/// popup cannot hang the Revit thread; dismissed dialog ids and the answer given are
/// reported. Unrecognized dialogs get the dismissive answer (Cancel, then Close, then
/// No) — on several Revit dialogs OK is the destructive choice (e.g. "Delete
/// Element(s)"), so confirming blind can silently damage the model. OK leads only for
/// dialogs known to be safe to confirm, and is otherwise the last resort: every
/// override attempt failing would leave the dialog up and hang the Revit thread,
/// which is the one outcome this guard exists to prevent.
private sealed class DialogGuard : IDisposable
{
private const int IDOK = 1;
private const int IDCANCEL = 2;
private const int IDNO = 7;
private const int IDCLOSE = 8;
/// Dialogs where confirming is the benign answer and cancelling would
/// abort the operation the script deliberately started.
private static readonly HashSet ConfirmSafeDialogIds = new(StringComparer.OrdinalIgnoreCase)
{
// "Export with temporary hide/isolate": OK exports the view as displayed.
"TaskDialog_Really_Print_Or_Export_Temp_View_Modes",
};
private static readonly int[] ConfirmFirst = { IDOK, IDCANCEL, IDCLOSE };
private static readonly int[] DismissFirst = { IDCANCEL, IDCLOSE, IDNO, IDOK };
private readonly UIApplication _uiapp;
public List Suppressed { get; } = new();
public DialogGuard(UIApplication uiapp)
{
_uiapp = uiapp;
_uiapp.DialogBoxShowing += OnDialogBoxShowing;
}
private void OnDialogBoxShowing(object? sender, DialogBoxShowingEventArgs e)
{
string id = string.IsNullOrEmpty(e.DialogId) ? e.GetType().Name : e.DialogId;
int[] answers = ConfirmSafeDialogIds.Contains(id) ? ConfirmFirst : DismissFirst;
foreach (int answer in answers)
{
try
{
if (e.OverrideResult(answer))
{
Suppressed.Add($"{id} (answered {AnswerName(answer)})");
return;
}
}
catch
{
// Some dialogs reject specific overrides; try the next answer.
}
}
Suppressed.Add($"{id} (override rejected — dialog may need manual dismissal)");
}
private static string AnswerName(int answer) => answer switch
{
IDOK => "OK",
IDCANCEL => "Cancel",
IDNO => "No",
IDCLOSE => "Close",
_ => answer.ToString(),
};
public void Dispose()
{
try
{
_uiapp.DialogBoxShowing -= OnDialogBoxShowing;
}
catch
{
// Unsubscribe must never mask the tool result.
}
}
}
// ------------------------------------------------------------- projection
/// Safe serializer: depth-limited, item-capped, and never emits raw Revit
/// API objects — Element/ElementId/XYZ/Parameter/BoundingBoxXYZ/Category get compact
/// shapes, any other Autodesk type falls back to ToString().
private static object? Project(object? value, int depth)
{
if (value is null)
return null;
switch (value)
{
case string text:
return text.Length <= MaxStringChars ? text : text[..MaxStringChars] + "… [truncated]";
case bool or int or long or short or byte or sbyte or uint or ulong or ushort or decimal:
return value;
case double number:
return double.IsFinite(number) ? number : number.ToString();
case float number:
return float.IsFinite(number) ? (object)number : number.ToString();
case char or Guid or DateTime or DateTimeOffset or TimeSpan or Uri:
return value.ToString();
case Enum enumValue:
return enumValue.ToString();
case JsonElement json:
return json.Clone();
case ElementId id:
return id.Value;
case XYZ point:
return new Dictionary { ["x"] = point.X, ["y"] = point.Y, ["z"] = point.Z };
case UV uv:
return new Dictionary { ["u"] = uv.U, ["v"] = uv.V };
case BoundingBoxXYZ box:
return new Dictionary
{
["min"] = Safe(() => Project(box.Min, depth + 1)),
["max"] = Safe(() => Project(box.Max, depth + 1)),
};
case Element element:
return ProjectElement(element);
case Parameter parameter:
return ProjectParameter(parameter);
case Category category:
return new Dictionary
{
["id"] = Safe(() => category.Id.Value),
["name"] = Safe(() => category.Name),
};
}
if (depth >= MaxDepth)
return Safe(value.ToString);
if (value is IDictionary dictionary)
{
var result = new Dictionary();
foreach (DictionaryEntry entry in dictionary)
{
if (result.Count >= MaxItems)
{
result["…"] = $"truncated at {MaxItems} entries";
break;
}
result[entry.Key?.ToString() ?? "null"] = Project(entry.Value, depth + 1);
}
return result;
}
if (value is IEnumerable enumerable)
{
var items = new List();
foreach (object? item in enumerable)
{
if (items.Count >= MaxItems)
{
items.Add($"… truncated at {MaxItems} items");
break;
}
items.Add(Project(item, depth + 1));
}
return items;
}
var type = value.GetType();
// Any other Revit/Autodesk API object (Curve, Material asset, ...) has no safe
// JSON shape: string fallback instead of reflecting into native handles.
if (type.Namespace?.StartsWith("Autodesk.", StringComparison.Ordinal) == true)
return Safe(value.ToString);
if (value is Task)
return "";
// Plain CLR object (anonymous types, records, user classes): public properties.
var projected = new Dictionary();
foreach (var property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
if (projected.Count >= MaxProperties)
{
projected["…"] = $"truncated at {MaxProperties} properties";
break;
}
if (property.GetIndexParameters().Length > 0)
continue;
projected[property.Name] = Safe(() => Project(property.GetValue(value), depth + 1));
}
return projected.Count > 0 ? projected : Safe(value.ToString);
}
private static object ProjectElement(Element element) => new Dictionary
{
["id"] = Safe(() => element.Id.Value),
["name"] = Safe(() => element.Name),
["category"] = Safe(() => element.Category?.Name),
["typeName"] = Safe(() => element.Document.GetElement(element.GetTypeId())?.Name),
["levelId"] = Safe(() => element.LevelId != ElementId.InvalidElementId ? element.LevelId.Value : (object?)null),
};
private static object ProjectParameter(Parameter parameter) => new Dictionary
{
["name"] = Safe(() => parameter.Definition?.Name),
["storageType"] = Safe(() => parameter.StorageType.ToString()),
["value"] = Safe(() => parameter.StorageType switch
{
StorageType.String => parameter.AsString(),
StorageType.Double => parameter.AsDouble(),
StorageType.Integer => parameter.AsInteger(),
StorageType.ElementId => (object?)parameter.AsElementId().Value,
_ => null,
}),
["displayValue"] = Safe(() => parameter.AsValueString()),
};
private static object? Safe(Func producer)
{
try
{
return producer();
}
catch (Exception ex)
{
return $"";
}
}
}
}