| 1 | using Microsoft.CodeAnalysis; |
| 2 | using Microsoft.CodeAnalysis.CSharp.Syntax; |
| 3 | using Microsoft.CodeAnalysis.Operations; |
| 4 | using Microsoft.CodeAnalysis.Text; |
| 5 | using System.Collections.Immutable; |
| 6 | |
| 7 | namespace CascadeIDE.Services; |
| 8 | |
| 9 | public sealed partial class CSharpLanguageService |
| 10 | { |
| 11 | private static readonly SymbolDisplayFormat InlayTypeDisplayFormat = new( |
| 12 | globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.OmittedAsContaining, |
| 13 | typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypes, |
| 14 | genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters, |
| 15 | miscellaneousOptions: SymbolDisplayMiscellaneousOptions.UseSpecialTypes); |
| 16 | |
| 17 | /// <summary> |
| 18 | /// Inlay-подсказки в буфер: <c>var → тип</c> (0085/0103), при необходимости имена аргументов |
| 19 | /// (<c> paramName: </c> у позиционных) и у индексаторов, как type/parameter hints в IDE. |
| 20 | /// Позиционные аргументы: не дублировать имя, если аргумент — простой идентификатор с тем же имени, что и параметр; |
| 21 | /// для <c>params</c> — одна подсказка на сгруппированные значения (не на каждый элемент). |
| 22 | /// Кэш по (path, text); вызывать с актуальным <paramref name="sourceText"/>. |
| 23 | /// </summary> |
| 24 | public IReadOnlyList<EditorTrailingInlayPart> GetVarInlayHintsForFile(string filePath, string sourceText, CancellationToken ct = default) |
| 25 | { |
| 26 | if (string.IsNullOrEmpty(filePath) || !filePath.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)) |
| 27 | return []; |
| 28 | var text = SourceText.From(sourceText); |
| 29 | var textHash = GetStableHash(text); |
| 30 | var fileKey = (filePath, textHash); |
| 31 | if (_inlayHintCache.TryGetValue(fileKey, out var cached)) |
| 32 | return cached; |
| 33 | try |
| 34 | { |
| 35 | var model = GetOrCreateModel(filePath, text, ct); |
| 36 | var root = model.SyntaxTree.GetRoot(ct); |
| 37 | var list = new List<EditorTrailingInlayPart>(32); |
| 38 | foreach (var node in root.DescendantNodes()) |
| 39 | { |
| 40 | ct.ThrowIfCancellationRequested(); |
| 41 | switch (node) |
| 42 | { |
| 43 | case LocalDeclarationStatementSyntax lds |
| 44 | when lds.Declaration is { Type: IdentifierNameSyntax { Identifier.Text: "var" } } && lds.Declaration.Variables.Count > 0: |
| 45 | { |
| 46 | var v0 = lds.Declaration.Variables[0]; |
| 47 | if (model.GetDeclaredSymbol(v0, ct) is not ILocalSymbol local) |
| 48 | break; |
| 49 | var label = " " + local.Type.ToDisplayString(InlayTypeDisplayFormat); |
| 50 | var anchor = lds.Declaration.Type.Span.End; |
| 51 | list.Add(new EditorTrailingInlayPart(anchor, label)); |
| 52 | break; |
| 53 | } |
| 54 | case ForEachStatementSyntax fes |
| 55 | when fes.Type is IdentifierNameSyntax { Identifier.Text: "var" }: |
| 56 | { |
| 57 | if (model.GetDeclaredSymbol(fes, ct) is not ILocalSymbol le) |
| 58 | break; |
| 59 | var label2 = " " + le.Type.ToDisplayString(InlayTypeDisplayFormat); |
| 60 | var anchor2 = fes.Type.Span.End; |
| 61 | list.Add(new EditorTrailingInlayPart(anchor2, label2)); |
| 62 | break; |
| 63 | } |
| 64 | case InvocationExpressionSyntax inv: |
| 65 | AddParameterNameInlaysForParenthesizedList(model, inv.ArgumentList, list, ct); |
| 66 | break; |
| 67 | case ObjectCreationExpressionSyntax oce: |
| 68 | AddParameterNameInlaysForParenthesizedList(model, oce.ArgumentList, list, ct); |
| 69 | break; |
| 70 | case ImplicitObjectCreationExpressionSyntax ioc: |
| 71 | AddParameterNameInlaysForParenthesizedList(model, ioc.ArgumentList, list, ct); |
| 72 | break; |
| 73 | case ElementAccessExpressionSyntax eae: |
| 74 | AddParameterNameInlaysForBracketedList(model, eae.ArgumentList, list, ct); |
| 75 | break; |
| 76 | } |
| 77 | } |
| 78 | // Normalize hints per anchor: drop blank labels and keep the most informative one. |
| 79 | // This prevents first-seen empty placeholders from hiding useful labels like "args:"/"message:". |
| 80 | var bestByAnchor = new Dictionary<int, string>(); |
| 81 | foreach (var p in list) |
| 82 | { |
| 83 | if (string.IsNullOrWhiteSpace(p.Label)) |
| 84 | continue; |
| 85 | if (!bestByAnchor.TryGetValue(p.AnchorOffset, out var existing) || p.Label.Length > existing.Length) |
| 86 | bestByAnchor[p.AnchorOffset] = p.Label; |
| 87 | } |
| 88 | IReadOnlyList<EditorTrailingInlayPart> result = bestByAnchor |
| 89 | .OrderBy(static kv => kv.Key) |
| 90 | .Select(static kv => new EditorTrailingInlayPart(kv.Key, kv.Value)) |
| 91 | .ToList(); |
| 92 | if (InlayHintTrace.IsEnabled) |
| 93 | { |
| 94 | const int cap = 64; |
| 95 | static string OneLineLabel(string? s) => |
| 96 | (s ?? "").ReplaceLineEndings(" ").Replace('\r', ' ').Replace('\n', ' '); |
| 97 | var preview = result.Count == 0 |
| 98 | ? "" |
| 99 | : string.Join(" | ", result.Take(cap).Select(p => $"{p.AnchorOffset}→{OneLineLabel(p.Label)}")); |
| 100 | if (result.Count > cap) |
| 101 | preview += $" | ...+{result.Count - cap}"; |
| 102 | InlayHintTrace.Log($"GetVarInlayHints count={result.Count} file={filePath} {preview}"); |
| 103 | } |
| 104 | TrimInlayCache(); |
| 105 | _inlayHintCache[fileKey] = result; |
| 106 | return result; |
| 107 | } |
| 108 | catch |
| 109 | { |
| 110 | return []; |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | private static void AddParameterNameInlaysForParenthesizedList( |
| 115 | SemanticModel model, |
| 116 | ArgumentListSyntax? list, |
| 117 | List<EditorTrailingInlayPart> outList, |
| 118 | CancellationToken ct) |
| 119 | { |
| 120 | if (list is null) |
| 121 | return; |
| 122 | AddParameterNameInlaysForArguments(model, list.Arguments, outList, ct); |
| 123 | } |
| 124 | |
| 125 | private static void AddParameterNameInlaysForBracketedList( |
| 126 | SemanticModel model, |
| 127 | BracketedArgumentListSyntax? list, |
| 128 | List<EditorTrailingInlayPart> outList, |
| 129 | CancellationToken ct) |
| 130 | { |
| 131 | if (list is null) |
| 132 | return; |
| 133 | AddParameterNameInlaysForArguments(model, list.Arguments, outList, ct); |
| 134 | } |
| 135 | |
| 136 | private static IArgumentOperation? TryGetArgumentOperation(SemanticModel model, ArgumentSyntax arg, CancellationToken ct) |
| 137 | { |
| 138 | IOperation? op; |
| 139 | try |
| 140 | { |
| 141 | op = model.GetOperation(arg, ct); |
| 142 | } |
| 143 | catch |
| 144 | { |
| 145 | return null; |
| 146 | } |
| 147 | while (op is IConversionOperation conv) |
| 148 | op = conv.Operand; |
| 149 | if (op is IArgumentOperation direct) |
| 150 | return direct; |
| 151 | // Иногда с аргументом связана операция над выражением (ILiteralOperation и т.д.) — поднимаемся по Parent. |
| 152 | var walk = op; |
| 153 | while (walk is not null && walk is not IArgumentOperation) |
| 154 | walk = walk.Parent; |
| 155 | if (walk is IArgumentOperation fromParent) |
| 156 | return fromParent; |
| 157 | |
| 158 | // Roslyn иногда не возвращает IArgumentOperation с узла (внутренняя конверсия/значение); |
| 159 | // привязываемся к IInvocationOperation / IObjectCreationOperation по Syntax аргумента. |
| 160 | if (arg.Parent is not BaseArgumentListSyntax alist || alist.Parent is null) |
| 161 | return null; |
| 162 | IOperation? parentOp; |
| 163 | try |
| 164 | { |
| 165 | parentOp = model.GetOperation(alist.Parent, ct); |
| 166 | } |
| 167 | catch |
| 168 | { |
| 169 | return null; |
| 170 | } |
| 171 | if (parentOp is IInvocationOperation invOp) |
| 172 | { |
| 173 | foreach (var a in invOp.Arguments) |
| 174 | { |
| 175 | if (a.Syntax == arg) |
| 176 | return a; |
| 177 | } |
| 178 | // params: IInvocationOperation.Arguments часто == 1; IArgumentOperation.Syntax не равен каждому ArgumentSyntax. Любой синтаксический arg из того же списка относится к тому же params. |
| 179 | if (alist is ArgumentListSyntax al3) |
| 180 | { |
| 181 | foreach (var a in invOp.Arguments) |
| 182 | { |
| 183 | if (a.Parameter is not { IsParams: true }) |
| 184 | continue; |
| 185 | foreach (var syn in al3.Arguments) |
| 186 | { |
| 187 | if (syn == arg) |
| 188 | return a; |
| 189 | } |
| 190 | } |
| 191 | } |
| 192 | } |
| 193 | if (parentOp is IObjectCreationOperation ocr) |
| 194 | { |
| 195 | foreach (var a in ocr.Arguments) |
| 196 | { |
| 197 | if (a.Syntax == arg) |
| 198 | return a; |
| 199 | } |
| 200 | } |
| 201 | return null; |
| 202 | } |
| 203 | |
| 204 | private static ISymbol? GetBestSymbol(SymbolInfo info) => |
| 205 | info.Symbol ?? info.CandidateSymbols.FirstOrDefault(); |
| 206 | |
| 207 | private static IParameterSymbol? TryResolveParameterBySymbolInfo( |
| 208 | SemanticModel model, |
| 209 | ArgumentSyntax arg, |
| 210 | CancellationToken ct) |
| 211 | { |
| 212 | if (arg.Parent is not BaseArgumentListSyntax list) |
| 213 | return null; |
| 214 | |
| 215 | var index = list.Arguments.IndexOf(arg); |
| 216 | if (index < 0) |
| 217 | return null; |
| 218 | |
| 219 | static IParameterSymbol? SelectParameter(ImmutableArray<IParameterSymbol> parameters, int argIndex) |
| 220 | { |
| 221 | if (parameters.IsDefaultOrEmpty) |
| 222 | return null; |
| 223 | var last = parameters.Length - 1; |
| 224 | if (parameters[last].IsParams && argIndex >= last) |
| 225 | return parameters[last]; |
| 226 | return argIndex < parameters.Length ? parameters[argIndex] : null; |
| 227 | } |
| 228 | |
| 229 | var parent = list.Parent; |
| 230 | ISymbol? symbol = parent switch |
| 231 | { |
| 232 | InvocationExpressionSyntax inv => GetBestSymbol(model.GetSymbolInfo(inv, ct)), |
| 233 | ObjectCreationExpressionSyntax oce => GetBestSymbol(model.GetSymbolInfo(oce, ct)), |
| 234 | ImplicitObjectCreationExpressionSyntax ioc => GetBestSymbol(model.GetSymbolInfo(ioc, ct)), |
| 235 | ElementAccessExpressionSyntax eae => GetBestSymbol(model.GetSymbolInfo(eae, ct)), |
| 236 | _ => null |
| 237 | }; |
| 238 | |
| 239 | var parameter = symbol switch |
| 240 | { |
| 241 | IMethodSymbol method => SelectParameter(method.Parameters, index), |
| 242 | IPropertySymbol { IsIndexer: true } property => SelectParameter(property.Parameters, index), |
| 243 | _ => null |
| 244 | }; |
| 245 | if (parameter is not null) |
| 246 | return parameter; |
| 247 | |
| 248 | // If constructor symbol isn't bound directly (e.g., reduced semantic model), |
| 249 | // resolve by created type and pick a matching ctor by argument count/optionality. |
| 250 | if (parent is ObjectCreationExpressionSyntax oce2 || parent is ImplicitObjectCreationExpressionSyntax) |
| 251 | { |
| 252 | INamedTypeSymbol? createdType = parent switch |
| 253 | { |
| 254 | ObjectCreationExpressionSyntax ocex => model.GetTypeInfo(ocex.Type, ct).Type as INamedTypeSymbol, |
| 255 | ImplicitObjectCreationExpressionSyntax iocx => (model.GetTypeInfo(iocx, ct).Type |
| 256 | ?? model.GetTypeInfo(iocx, ct).ConvertedType) as INamedTypeSymbol, |
| 257 | _ => null |
| 258 | }; |
| 259 | if (createdType is null) |
| 260 | return null; |
| 261 | var argCount = list.Arguments.Count; |
| 262 | IMethodSymbol? bestCtor = null; |
| 263 | var bestScore = int.MinValue; |
| 264 | foreach (var ctor in createdType.InstanceConstructors) |
| 265 | { |
| 266 | if (ctor.Parameters.IsDefaultOrEmpty) |
| 267 | continue; |
| 268 | |
| 269 | var parameters = ctor.Parameters; |
| 270 | var hasParams = parameters[^1].IsParams; |
| 271 | var required = parameters.Count(p => !p.IsOptional && !p.IsParams); |
| 272 | if (argCount < required) |
| 273 | continue; |
| 274 | if (!hasParams && argCount > parameters.Length) |
| 275 | continue; |
| 276 | |
| 277 | // Prefer exact arity, then the closest non-params shape. |
| 278 | var score = parameters.Length == argCount ? 1000 : 0; |
| 279 | if (hasParams) |
| 280 | score -= 100; |
| 281 | score -= Math.Abs(parameters.Length - argCount); |
| 282 | if (score <= bestScore) |
| 283 | continue; |
| 284 | bestScore = score; |
| 285 | bestCtor = ctor; |
| 286 | } |
| 287 | |
| 288 | return bestCtor is null ? null : SelectParameter(bestCtor.Parameters, index); |
| 289 | } |
| 290 | |
| 291 | return null; |
| 292 | } |
| 293 | |
| 294 | private static IParameterSymbol? TryGetArgumentParameter( |
| 295 | SemanticModel model, |
| 296 | ArgumentSyntax arg, |
| 297 | CancellationToken ct) |
| 298 | { |
| 299 | return TryGetArgumentOperation(model, arg, ct)?.Parameter |
| 300 | ?? TryResolveParameterBySymbolInfo(model, arg, ct); |
| 301 | } |
| 302 | |
| 303 | private static void AddParameterNameInlaysForArguments( |
| 304 | SemanticModel model, |
| 305 | SeparatedSyntaxList<ArgumentSyntax> args, |
| 306 | List<EditorTrailingInlayPart> outList, |
| 307 | CancellationToken ct) |
| 308 | { |
| 309 | // For params, Roslyn gives one IParameterSymbol per element — show a single inlay (first arg), not "items: " on every value. |
| 310 | IParameterSymbol? paramsHintEmittedFor = null; |
| 311 | foreach (var arg in args) |
| 312 | { |
| 313 | ct.ThrowIfCancellationRequested(); |
| 314 | if (arg.NameColon is not null) |
| 315 | continue; |
| 316 | var resolvedParameter = TryGetArgumentParameter(model, arg, ct); |
| 317 | if (resolvedParameter is not IParameterSymbol p) |
| 318 | continue; |
| 319 | if (string.IsNullOrEmpty(p.Name)) |
| 320 | continue; |
| 321 | var parameterName = p.Name; |
| 322 | var isParams = p.IsParams; |
| 323 | |
| 324 | if (isParams) |
| 325 | { |
| 326 | if (resolvedParameter is IParameterSymbol p2 && |
| 327 | paramsHintEmittedFor is not null && |
| 328 | SymbolEqualityComparer.Default.Equals(paramsHintEmittedFor, p2)) |
| 329 | { |
| 330 | continue; |
| 331 | } |
| 332 | if (resolvedParameter is IParameterSymbol p3) |
| 333 | paramsHintEmittedFor = p3; |
| 334 | } |
| 335 | else |
| 336 | { |
| 337 | paramsHintEmittedFor = null; |
| 338 | } |
| 339 | |
| 340 | // Drop redundant "args: " when the argument is just `args` (same as VS inlay policy). |
| 341 | if (arg.Expression is IdentifierNameSyntax idArg && |
| 342 | string.Equals(idArg.Identifier.ValueText, parameterName, StringComparison.Ordinal)) |
| 343 | continue; |
| 344 | |
| 345 | var label = " " + parameterName + ": "; |
| 346 | outList.Add(new EditorTrailingInlayPart(arg.Expression.SpanStart, label)); |
| 347 | } |
| 348 | } |
| 349 | } |
| 350 | |