feat(ui/scripts): Roslyn-backed C# completions + diagnostics for Monaco
Adds Microsoft.CodeAnalysis.CSharp.Scripting (4.13.0). Scripts are
compiled as C# script fragments against a ScriptHost globals type
that mirrors what the runtime exposes (Parameters bag, CallShared,
CallScript) — Roslyn reads the signatures so those identifiers are
in scope for analysis without executing anything.
ScriptAnalysisService:
- Diagnose(code): Compilation.GetDiagnostics() projected to
Monaco-shaped DiagnosticMarker records (severity 8/4/2/1).
- Complete(code, line, col): dot-member lookup via SemanticModel
when the token at position is part of a MemberAccessExpression;
falls back to LookupSymbols at position for the general case.
Two endpoints exposed by the existing CentralUI endpoint pipeline,
both behind RequireDesign policy:
POST /api/script-analysis/diagnostics
POST /api/script-analysis/completions
monaco-init.js registers a csharp CompletionItemProvider with dot/
paren/quote trigger chars, plus a 500 ms debounced diagnostics pass
on every keystroke that pushes markers via setModelMarkers. Initial
pass fires on editor create so existing scripts surface errors right
away. Auth uses the existing cookie via credentials: same-origin.
Smoke-verified:
- Typing `DateTimeOffset.UtcNow` (no semicolon) shows the missing
semicolon squiggle in real time.
- Ctrl-Space at file scope returns the full type universe
(AccessViolationException, Action, Akka, AppDomain, ...).
Wave 2 of three. SCADA-specific extensions (declared param keys,
shared/sibling script names, forbidden-API diagnostic) follow.
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.CSharp.Scripting;
|
||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
using Microsoft.CodeAnalysis.Scripting;
|
||||
|
||||
namespace ScadaLink.CentralUI.ScriptAnalysis;
|
||||
|
||||
/// <summary>
|
||||
/// Compiles user scripts as Roslyn C# Scripting fragments against
|
||||
/// <see cref="ScriptHost"/> globals and surfaces diagnostics + completions
|
||||
/// in the shape Monaco's provider APIs expect. Lightweight — no caching;
|
||||
/// each request rebuilds the script. Acceptable for human-paced edits.
|
||||
/// </summary>
|
||||
public class ScriptAnalysisService
|
||||
{
|
||||
private static readonly ScriptOptions DefaultOptions = ScriptOptions.Default
|
||||
.AddReferences(
|
||||
typeof(object).Assembly,
|
||||
typeof(Enumerable).Assembly,
|
||||
typeof(System.Collections.Generic.Dictionary<,>).Assembly,
|
||||
typeof(System.ComponentModel.DescriptionAttribute).Assembly,
|
||||
typeof(ScriptHost).Assembly)
|
||||
.AddImports(
|
||||
"System",
|
||||
"System.Collections.Generic",
|
||||
"System.Linq",
|
||||
"System.Text",
|
||||
"System.Threading.Tasks");
|
||||
|
||||
public DiagnoseResponse Diagnose(DiagnoseRequest request)
|
||||
{
|
||||
if (string.IsNullOrEmpty(request.Code))
|
||||
return new DiagnoseResponse(Array.Empty<DiagnosticMarker>());
|
||||
|
||||
Script<object> script;
|
||||
try
|
||||
{
|
||||
script = CSharpScript.Create(request.Code, DefaultOptions, globalsType: typeof(ScriptHost));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new DiagnoseResponse(new[]
|
||||
{
|
||||
new DiagnosticMarker(8, 1, 1, 1, 2, ex.Message, "SCRIPT_BUILD")
|
||||
});
|
||||
}
|
||||
|
||||
var compilation = script.GetCompilation();
|
||||
var markers = compilation
|
||||
.GetDiagnostics()
|
||||
.Where(d => d.Severity >= DiagnosticSeverity.Info && d.Location.IsInSource)
|
||||
.Select(ToMarker)
|
||||
.ToList();
|
||||
|
||||
return new DiagnoseResponse(markers);
|
||||
}
|
||||
|
||||
public CompletionsResponse Complete(CompletionsRequest request)
|
||||
{
|
||||
if (string.IsNullOrEmpty(request.CodeText))
|
||||
return new CompletionsResponse(Array.Empty<CompletionItem>());
|
||||
|
||||
Script<object> script;
|
||||
try
|
||||
{
|
||||
script = CSharpScript.Create(request.CodeText, DefaultOptions, globalsType: typeof(ScriptHost));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new CompletionsResponse(Array.Empty<CompletionItem>());
|
||||
}
|
||||
|
||||
var compilation = script.GetCompilation();
|
||||
var tree = compilation.SyntaxTrees.FirstOrDefault();
|
||||
if (tree == null) return new CompletionsResponse(Array.Empty<CompletionItem>());
|
||||
|
||||
var semanticModel = compilation.GetSemanticModel(tree);
|
||||
var position = PositionToOffset(request.CodeText, request.Line, request.Column);
|
||||
position = Math.Clamp(position, 0, request.CodeText.Length);
|
||||
|
||||
var root = tree.GetRoot();
|
||||
var token = root.FindToken(Math.Max(0, position - 1));
|
||||
|
||||
// Dot completion: look up members of the type on the left of the dot.
|
||||
var dotMembers = TryGetDotMembers(token, semanticModel);
|
||||
if (dotMembers != null)
|
||||
return new CompletionsResponse(dotMembers);
|
||||
|
||||
// General completion: in-scope symbols at position.
|
||||
var scoped = semanticModel.LookupSymbols(position)
|
||||
.Where(s => !s.IsImplicitlyDeclared && !string.IsNullOrEmpty(s.Name))
|
||||
.GroupBy(s => s.Name)
|
||||
.Select(g => g.First())
|
||||
.Select(ToCompletionItem)
|
||||
.Take(200)
|
||||
.ToList();
|
||||
|
||||
return new CompletionsResponse(scoped);
|
||||
}
|
||||
|
||||
private static List<CompletionItem>? TryGetDotMembers(SyntaxToken token, SemanticModel model)
|
||||
{
|
||||
// The cursor may be positioned right after a '.'; resolve the
|
||||
// member-access node and look up the left-hand side's type members.
|
||||
var memberAccess = token.Parent as MemberAccessExpressionSyntax
|
||||
?? token.GetPreviousToken().Parent as MemberAccessExpressionSyntax;
|
||||
if (memberAccess == null) return null;
|
||||
|
||||
var typeInfo = model.GetTypeInfo(memberAccess.Expression);
|
||||
var type = typeInfo.Type ?? typeInfo.ConvertedType;
|
||||
if (type == null) return null;
|
||||
|
||||
return type.GetMembers()
|
||||
.Where(m => m.CanBeReferencedByName && !m.IsImplicitlyDeclared)
|
||||
.Where(m => m.DeclaredAccessibility == Accessibility.Public || m.DeclaredAccessibility == Accessibility.NotApplicable)
|
||||
.GroupBy(m => m.Name)
|
||||
.Select(g => g.First())
|
||||
.Select(ToCompletionItem)
|
||||
.Take(200)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static CompletionItem ToCompletionItem(ISymbol symbol)
|
||||
{
|
||||
var kind = symbol.Kind switch
|
||||
{
|
||||
SymbolKind.Method => "Method",
|
||||
SymbolKind.Property => "Property",
|
||||
SymbolKind.Field => "Field",
|
||||
SymbolKind.Event => "Event",
|
||||
SymbolKind.NamedType => "Class",
|
||||
SymbolKind.Local => "Variable",
|
||||
SymbolKind.Parameter => "Variable",
|
||||
SymbolKind.Namespace => "Module",
|
||||
_ => "Text"
|
||||
};
|
||||
return new CompletionItem(
|
||||
Label: symbol.Name,
|
||||
InsertText: symbol.Name,
|
||||
Detail: symbol.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat),
|
||||
Kind: kind);
|
||||
}
|
||||
|
||||
private static DiagnosticMarker ToMarker(Diagnostic d)
|
||||
{
|
||||
var span = d.Location.GetLineSpan().Span;
|
||||
var severity = d.Severity switch
|
||||
{
|
||||
DiagnosticSeverity.Error => 8,
|
||||
DiagnosticSeverity.Warning => 4,
|
||||
DiagnosticSeverity.Info => 2,
|
||||
_ => 1
|
||||
};
|
||||
return new DiagnosticMarker(
|
||||
Severity: severity,
|
||||
StartLineNumber: span.Start.Line + 1,
|
||||
StartColumn: span.Start.Character + 1,
|
||||
EndLineNumber: span.End.Line + 1,
|
||||
EndColumn: span.End.Character + 1,
|
||||
Message: d.GetMessage(),
|
||||
Code: d.Id);
|
||||
}
|
||||
|
||||
private static int PositionToOffset(string code, int line, int column)
|
||||
{
|
||||
var offset = 0;
|
||||
var currentLine = 1;
|
||||
var currentCol = 1;
|
||||
for (int i = 0; i < code.Length; i++)
|
||||
{
|
||||
if (currentLine == line && currentCol == column) return offset;
|
||||
if (code[i] == '\n') { currentLine++; currentCol = 1; }
|
||||
else { currentCol++; }
|
||||
offset = i + 1;
|
||||
}
|
||||
return code.Length;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user