feat(ui/scripts): format, inlay hints, problems panel, type diagnostic
Three more editor features rolled in:
1. Roslyn Format command.
New POST /api/script-analysis/format runs Formatter.Format() from
Microsoft.CodeAnalysis.CSharp.Workspaces on the parsed script
tree. monaco-init.js registers a DocumentFormattingEditProvider
so Ctrl/Cmd-Shift-F and the toolbar "Format" button both work.
2. Inlay hints with parameter names.
New POST /api/script-analysis/inlay-hints walks CallShared /
CallScript invocations and emits InlayHint records positioned at
each argument with the matching parameter's name (e.g. "name:").
Ghost text appears via Monaco's InlayHintsProvider.
3. SCADA005 argument-type diagnostic.
Literal type vs. declared parameter type check on every
CallShared/CallScript argument. Float accepts Integer literals;
Object/List accept anything; null only matches reference-ish
types. Legacy lowercase types ("string" etc) from the DB are
normalized to the canonical set before comparison so existing
data doesn't false-negative. Non-literal args (variables,
expressions) are skipped — out of scope for a cheap pass.
4. Parameters["name"] hover.
Hover endpoint now also resolves Parameters["X"] element-access
keys against the form's DeclaredParameterShapes and returns
"parameter `name: String`"-style markdown. MonacoEditor surfaces
the new DeclaredParameterShapes parameter; ScriptParameterNames
gets a ParseShapes companion.
5. Problems panel.
Bootstrap card under the editor listing every marker with
severity badge, line number, message, and SCADA / CS code. Click
a row to scroll the editor to that line and focus. JS now
invokes OnMarkersChanged on the .NET side whenever
setModelMarkers fires, so the panel stays in sync with the
editor.
6. Editor toolbar.
Small top-right strip on each editor with Format / Wrap /
Minimap / Theme toggles. New MonacoBlazor.format,
setEditorOption, and revealLine JS APIs back the buttons and the
problems-panel scroll-to-line.
Contracts:
- FormatRequest / FormatResponse
- InlayHintsRequest / InlayHintsResponse / InlayHint
- HoverRequest.DeclaredParameters
- MonacoEditor.DeclaredParameterShapes parameter
- MonacoEditor.MarkersChanged callback
- ScadaContext.DeclaredParameterShapes
10 new xUnit tests covering format, inlay hints, SCADA005 (string-
expects-integer, integer-expects-string, float-accepts-integer,
object-accepts-anything, non-literal-skipped), and Parameters key
hover. Total: 139 -> 149.
Microsoft.CodeAnalysis.CSharp.Workspaces 4.13.0 added to pull in
Formatter and AdhocWorkspace.
Browser-verified: typing `CallShared("Greet", 42)` now shows the
"name:" inlay hint and a SCADA005 squiggle on `42`; Parameters["typo"]
shows SCADA003 as before; the toolbar buttons all work.
This commit is contained in:
@@ -4,7 +4,9 @@ using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.CSharp.Scripting;
|
||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
using Microsoft.CodeAnalysis.Formatting;
|
||||
using Microsoft.CodeAnalysis.Scripting;
|
||||
using Microsoft.CodeAnalysis.Text;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace ScadaLink.CentralUI.ScriptAnalysis;
|
||||
@@ -104,6 +106,7 @@ public class ScriptAnalysisService
|
||||
markers.AddRange(FindForbiddenApiUsages(tree, model));
|
||||
markers.AddRange(FindUnknownParameterKeys(tree, request.DeclaredParameters));
|
||||
markers.AddRange(FindArgumentCountMismatches(tree, request.SiblingScripts));
|
||||
markers.AddRange(FindArgumentTypeMismatches(tree, request.SiblingScripts));
|
||||
}
|
||||
|
||||
return Cache(cacheKey, new DiagnoseResponse(markers));
|
||||
@@ -256,6 +259,71 @@ public class ScriptAnalysisService
|
||||
InsertTextRules: insertRules);
|
||||
}
|
||||
|
||||
public FormatResponse Format(FormatRequest request)
|
||||
{
|
||||
if (string.IsNullOrEmpty(request.Code))
|
||||
return new FormatResponse(request.Code);
|
||||
try
|
||||
{
|
||||
using var workspace = new AdhocWorkspace();
|
||||
var tree = CSharpSyntaxTree.ParseText(
|
||||
request.Code,
|
||||
new CSharpParseOptions(LanguageVersion.Latest, kind: SourceCodeKind.Script));
|
||||
var formatted = Formatter.Format(tree.GetRoot(), workspace);
|
||||
return new FormatResponse(formatted.ToFullString());
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new FormatResponse(request.Code);
|
||||
}
|
||||
}
|
||||
|
||||
public InlayHintsResponse InlayHints(InlayHintsRequest request)
|
||||
{
|
||||
if (string.IsNullOrEmpty(request.Code))
|
||||
return new InlayHintsResponse(Array.Empty<InlayHint>());
|
||||
|
||||
var script = TryParse(request.Code);
|
||||
if (script == null) return new InlayHintsResponse(Array.Empty<InlayHint>());
|
||||
var (tree, _) = script.Value;
|
||||
|
||||
IReadOnlyList<ScriptShape>? sharedShapes = null;
|
||||
IReadOnlyList<ScriptShape> SharedShapes() =>
|
||||
sharedShapes ??= _sharedScripts.GetShapesAsync().GetAwaiter().GetResult();
|
||||
|
||||
var hints = new List<InlayHint>();
|
||||
foreach (var inv in tree.GetRoot().DescendantNodes().OfType<InvocationExpressionSyntax>())
|
||||
{
|
||||
var callee = (inv.Expression as IdentifierNameSyntax)?.Identifier.ValueText;
|
||||
if (callee is not ("CallShared" or "CallScript")) continue;
|
||||
if (inv.ArgumentList.Arguments.Count < 1) continue;
|
||||
|
||||
var nameArg = inv.ArgumentList.Arguments[0].Expression as LiteralExpressionSyntax;
|
||||
if (nameArg == null || !nameArg.IsKind(SyntaxKind.StringLiteralExpression)) continue;
|
||||
var scriptName = nameArg.Token.ValueText;
|
||||
if (string.IsNullOrEmpty(scriptName)) continue;
|
||||
|
||||
ScriptShape? shape = callee == "CallShared"
|
||||
? SharedShapes().FirstOrDefault(s => s.Name == scriptName)
|
||||
: request.SiblingScripts?.FirstOrDefault(s => s.Name == scriptName);
|
||||
if (shape == null) continue;
|
||||
|
||||
for (var i = 1; i < inv.ArgumentList.Arguments.Count && i - 1 < shape.Parameters.Count; i++)
|
||||
{
|
||||
var arg = inv.ArgumentList.Arguments[i];
|
||||
var p = shape.Parameters[i - 1];
|
||||
var pos = arg.Span.Start;
|
||||
var lineSpan = tree.GetLineSpan(new TextSpan(pos, 0)).Span;
|
||||
hints.Add(new InlayHint(
|
||||
Line: lineSpan.Start.Line + 1,
|
||||
Column: lineSpan.Start.Character + 1,
|
||||
Label: $"{p.Name}:"));
|
||||
}
|
||||
}
|
||||
|
||||
return new InlayHintsResponse(hints);
|
||||
}
|
||||
|
||||
public HoverResponse Hover(HoverRequest request)
|
||||
{
|
||||
var script = TryParse(request.CodeText);
|
||||
@@ -272,6 +340,23 @@ public class ScriptAnalysisService
|
||||
var argument = literalNode?.Parent as ArgumentSyntax;
|
||||
var argumentList = argument?.Parent;
|
||||
var owner = argumentList?.Parent;
|
||||
|
||||
// Parameters["name"] → show declared type
|
||||
if (owner is ElementAccessExpressionSyntax elem
|
||||
&& elem.Expression is IdentifierNameSyntax pid
|
||||
&& pid.Identifier.ValueText == "Parameters")
|
||||
{
|
||||
var key = token.ValueText;
|
||||
var p = request.DeclaredParameters?.FirstOrDefault(x => x.Name == key);
|
||||
if (p != null)
|
||||
{
|
||||
var req = p.Required ? "" : "?";
|
||||
return new HoverResponse(
|
||||
$"**parameter** `{p.Name}: {p.Type}{req}`");
|
||||
}
|
||||
return new HoverResponse(null);
|
||||
}
|
||||
|
||||
if (owner is not InvocationExpressionSyntax inv) return new HoverResponse(null);
|
||||
|
||||
var calleeName = (inv.Expression as IdentifierNameSyntax)?.Identifier.ValueText;
|
||||
@@ -474,6 +559,112 @@ public class ScriptAnalysisService
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<DiagnosticMarker> FindArgumentTypeMismatches(SyntaxTree tree, IReadOnlyList<ScriptShape>? siblings)
|
||||
{
|
||||
var root = tree.GetRoot();
|
||||
|
||||
IReadOnlyList<ScriptShape>? sharedShapes = null;
|
||||
IReadOnlyList<ScriptShape> SharedShapes() =>
|
||||
sharedShapes ??= _sharedScripts.GetShapesAsync().GetAwaiter().GetResult();
|
||||
|
||||
foreach (var inv in root.DescendantNodes().OfType<InvocationExpressionSyntax>())
|
||||
{
|
||||
var callee = (inv.Expression as IdentifierNameSyntax)?.Identifier.ValueText;
|
||||
if (callee is not ("CallShared" or "CallScript")) continue;
|
||||
if (inv.ArgumentList.Arguments.Count < 1) continue;
|
||||
var nameArg = inv.ArgumentList.Arguments[0].Expression as LiteralExpressionSyntax;
|
||||
if (nameArg == null || !nameArg.IsKind(SyntaxKind.StringLiteralExpression)) continue;
|
||||
var scriptName = nameArg.Token.ValueText;
|
||||
if (string.IsNullOrEmpty(scriptName)) continue;
|
||||
|
||||
ScriptShape? shape = callee == "CallShared"
|
||||
? SharedShapes().FirstOrDefault(s => s.Name == scriptName)
|
||||
: siblings?.FirstOrDefault(s => s.Name == scriptName);
|
||||
if (shape == null) continue;
|
||||
|
||||
for (var i = 1; i < inv.ArgumentList.Arguments.Count && i - 1 < shape.Parameters.Count; i++)
|
||||
{
|
||||
var arg = inv.ArgumentList.Arguments[i].Expression;
|
||||
var p = shape.Parameters[i - 1];
|
||||
var literalType = LiteralTypeOf(arg);
|
||||
if (literalType == null) continue; // Not a literal we can check.
|
||||
if (TypeAccepts(p.Type, literalType.Value)) continue;
|
||||
var span = arg.GetLocation().GetLineSpan().Span;
|
||||
yield return new DiagnosticMarker(
|
||||
Severity: 8,
|
||||
StartLineNumber: span.Start.Line + 1,
|
||||
StartColumn: span.Start.Character + 1,
|
||||
EndLineNumber: span.End.Line + 1,
|
||||
EndColumn: span.End.Character + 1,
|
||||
Message: $"Argument {i} of {callee}('{scriptName}') expects {p.Type} but got {literalType}.",
|
||||
Code: "SCADA005");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum LiteralKind { String, Integer, Float, Boolean, Null }
|
||||
|
||||
private static LiteralKind? LiteralTypeOf(ExpressionSyntax expr)
|
||||
{
|
||||
if (expr is LiteralExpressionSyntax lit)
|
||||
{
|
||||
if (lit.IsKind(SyntaxKind.StringLiteralExpression)) return LiteralKind.String;
|
||||
if (lit.IsKind(SyntaxKind.TrueLiteralExpression) || lit.IsKind(SyntaxKind.FalseLiteralExpression))
|
||||
return LiteralKind.Boolean;
|
||||
if (lit.IsKind(SyntaxKind.NullLiteralExpression)) return LiteralKind.Null;
|
||||
if (lit.IsKind(SyntaxKind.NumericLiteralExpression))
|
||||
{
|
||||
var text = lit.Token.Text;
|
||||
return text.Contains('.') || text.EndsWith("f", StringComparison.OrdinalIgnoreCase)
|
||||
|| text.EndsWith("d", StringComparison.OrdinalIgnoreCase)
|
||||
? LiteralKind.Float
|
||||
: LiteralKind.Integer;
|
||||
}
|
||||
}
|
||||
if (expr is InterpolatedStringExpressionSyntax) return LiteralKind.String;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when a literal of <paramref name="literal"/> is acceptable for a
|
||||
/// parameter declared as <paramref name="declared"/>. Object/List always
|
||||
/// accept (we don't introspect collection literals); Null is acceptable
|
||||
/// for any non-value type.
|
||||
/// </summary>
|
||||
private static bool TypeAccepts(string declared, LiteralKind literal)
|
||||
{
|
||||
var d = NormalizeDeclaredType(declared);
|
||||
if (literal == LiteralKind.Null) return d is "Object" or "List" or "String";
|
||||
return d switch
|
||||
{
|
||||
"Boolean" => literal == LiteralKind.Boolean,
|
||||
"Integer" => literal == LiteralKind.Integer,
|
||||
"Float" => literal is LiteralKind.Float or LiteralKind.Integer,
|
||||
"String" => literal == LiteralKind.String,
|
||||
"Object" or "List" => true,
|
||||
_ => true // unknown SCADA type — assume compatible
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes legacy / .NET type names from stored ParameterDefinitions
|
||||
/// JSON to the canonical Inbound API set. Mirrors the frontend
|
||||
/// ParameterListEditor's normalization so SCADA005 doesn't false-negative
|
||||
/// on data still in the legacy shape.
|
||||
/// </summary>
|
||||
private static string NormalizeDeclaredType(string declared) =>
|
||||
declared.ToLowerInvariant() switch
|
||||
{
|
||||
"boolean" or "bool" => "Boolean",
|
||||
"integer" or "int" or "int32" or "int64" or "int16" or "byte"
|
||||
or "sbyte" or "uint32" or "uint64" or "uint16" => "Integer",
|
||||
"float" or "double" or "single" or "decimal" => "Float",
|
||||
"string" or "datetime" => "String",
|
||||
"object" => "Object",
|
||||
"list" => "List",
|
||||
_ => declared
|
||||
};
|
||||
|
||||
private static IEnumerable<DiagnosticMarker> FindForbiddenApiUsages(SyntaxTree tree, SemanticModel model)
|
||||
{
|
||||
var root = tree.GetRoot();
|
||||
|
||||
Reference in New Issue
Block a user