Skip to content

Commit

Permalink
Add new parser/lexer to the StackTraceAnalyzer
Browse files Browse the repository at this point in the history
  • Loading branch information
ryzngard committed Nov 5, 2021
1 parent 6ab724e commit 63823bb
Show file tree
Hide file tree
Showing 8 changed files with 296 additions and 497 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2,53 +2,27 @@
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using System;
using System.Diagnostics.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;
using Microsoft.CodeAnalysis.EmbeddedLanguages.StackFrame;

namespace Microsoft.CodeAnalysis.StackTraceExplorer
{
internal sealed class DotnetStackFrameParser : IStackFrameParser
{
private const string StackTraceAtStart = "at ";
private const string StackTraceSymbolAndFileSplit = " in ";

/// <summary>
/// Tries to parse a StackFrame following convention from Environment.StackTrace
/// https://docs.microsoft.com/en-us/dotnet/api/system.environment.stacktrace has
/// details on output format and expected strings
///
/// Example:
/// at ConsoleApp4.MyClass.M() in C:\repos\ConsoleApp4\ConsoleApp4\Program.cs:line 26
/// Uses <see cref="StackFrameParser"/> to parse a line if possible
/// </summary>
public bool TryParseLine(string line, [NotNullWhen(true)] out ParsedFrame? parsedFrame)
{
parsedFrame = null;
var tree = StackFrameParser.TryParse(line);

if (!line.Trim().StartsWith(StackTraceAtStart))
{
return false;
}

var success = StackFrameParserHelpers.TryParseMethodSignature(line.AsSpan(), out var classSpan, out var methodSpan, out var argsSpan);
if (!success)
if (tree is null)
{
return false;
}

var splitIndex = line.IndexOf(StackTraceSymbolAndFileSplit);

// The line has " in <filename>:line <line number>"
if (splitIndex > 0)
{
var fileInformationStart = splitIndex + StackTraceSymbolAndFileSplit.Length;
var fileInformationSpan = new TextSpan(fileInformationStart, line.Length - fileInformationStart);

parsedFrame = new ParsedStackFrame(line, classSpan, methodSpan, argsSpan, fileInformationSpan);
return true;
}

parsedFrame = new ParsedStackFrame(line, classSpan, methodSpan, argsSpan);
parsedFrame = new ParsedStackFrame(line, tree);
return true;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,21 @@
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using System;
using System.Collections.Generic;
using System.Text;
using Microsoft.CodeAnalysis.EmbeddedLanguages.StackFrame;
using Microsoft.CodeAnalysis.Host;

namespace Microsoft.CodeAnalysis.StackTraceExplorer
{
internal interface IStackTraceExplorerService : ILanguageService
{
/// <summary>
/// Given the <see cref="ParsedStackFrame.TypeSpan"/>, get the equivalent name
/// Given the type name from <see cref="StackFrameCompilationUnit.MethodDeclaration"/>, get the equivalent name
/// in metadata that can be used to look up the type
/// </summary>
string GetTypeMetadataName(string className);

/// <summary>
/// Given the <see cref="ParsedStackFrame.MethodSpan"/>, get the symbol name
/// Given the method name from <see cref="StackFrameCompilationUnit.MethodDeclaration"/>, get the symbol name
/// for to match <see cref="IMethodSymbol"/> of a given type
/// </summary>
string GetMethodSymbolName(string methodName);
Expand Down
196 changes: 68 additions & 128 deletions src/Features/Core/Portable/StackTraceExplorer/ParsedStackFrame.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,66 +10,52 @@
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.Classification;
using Microsoft.CodeAnalysis.EmbeddedLanguages.Common;
using Microsoft.CodeAnalysis.EmbeddedLanguages.StackFrame;
using Microsoft.CodeAnalysis.PooledObjects;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Text;
using Roslyn.Utilities;

namespace Microsoft.CodeAnalysis.StackTraceExplorer
{
using StackFrameNodeOrToken = EmbeddedSyntaxNodeOrToken<StackFrameKind, StackFrameNode>;
using StackFrameToken = EmbeddedSyntaxToken<StackFrameKind>;
using StackFrameTrivia = EmbeddedSyntaxTrivia<StackFrameKind>;

/// <summary>
/// A line of text that was parsed by <see cref="StackTraceAnalyzer" />
/// to provide metadata bout the line. Expected to be the parsed output
/// of a serialized <see cref="StackFrame"/>
/// A line from <see cref="StackTraceAnalyzer.Parse(string, CancellationToken)"/> that
/// was parsed by <see cref="StackFrameParser"/>
/// </summary>
internal sealed class ParsedStackFrame : ParsedFrame
{
public readonly StackFrameTree Tree;

public ParsedStackFrame(
string originalText,
TextSpan typeSpan,
TextSpan methodSpan,
TextSpan argsSpan,
TextSpan fileSpan = default)
StackFrameTree tree)
: base(originalText)
{
Contract.ThrowIfTrue(typeSpan.IsEmpty);
Contract.ThrowIfTrue(methodSpan.IsEmpty);

TypeSpan = typeSpan;
MethodSpan = methodSpan;
ArgsSpan = argsSpan;
FileSpan = fileSpan;
Tree = tree;
}

/// <summary>
/// The full type name parsed from the line.
/// ex: [|Microsoft.CodeAnalysis.Editor.CallstackExplorer.|]Example(arg1, arg2)
/// </summary>
public TextSpan TypeSpan { get; }
public StackFrameCompilationUnit Root => Tree.Root;

/// <summary>
/// The method name span
/// ex: Microsoft.CodeAnalysis.Editor.CallstackExplorer.[|Example|](arg1, arg2)
/// </summary>
public TextSpan MethodSpan { get; }
public async Task<ISymbol?> ResolveSymbolAsync(Solution solution, CancellationToken cancellationToken)
{
// MemberAccessExpression is [Expression].[Identifier], and Identifier is the
// method name.
var typeExpression = Root.MethodDeclaration.MemberAccessExpression.Left;
var fullyQualifiedTypeName = typeExpression.CreateString(Tree, skipTrivia: true);

/// <summary>
/// The span of comma seperated arguments.
/// ex: Microsoft.CodeAnalysis.Editor.CallstackExplorer.Example[|(arg1, arg2)|]
/// </summary>
public TextSpan ArgsSpan { get; }
RoslynDebug.AssertNotNull(fullyQualifiedTypeName);

/// <summary>
/// The span representing file information on the stack trace line. Is not always available, so it's
/// possible this span is <see langword="default"/>
/// </summary>
public TextSpan FileSpan { get; }
var methodIdentifier = Root.MethodDeclaration.MemberAccessExpression.Right;
var methodTypeArguments = Root.MethodDeclaration.TypeArguments;
var methodArguments = Root.MethodDeclaration.ArgumentList;

public async Task<ISymbol?> ResolveSymbolAsync(Solution solution, CancellationToken cancellationToken)
{
// The original span for type includes the trailing '.', which we don't want when
// looking for the class by metadata name
var fullyQualifiedTypeName = OriginalText[TypeSpan.Start..(TypeSpan.End - 1)];
var methodName = GetMethodText();
var methodName = methodIdentifier.CreateString(Tree, skipTrivia: true);

foreach (var project in solution.Projects)
{
Expand All @@ -92,7 +78,9 @@ public ParsedStackFrame(
var members = type.GetMembers();
var matchingMembers = members
.OfType<IMethodSymbol>()
.Where(m => MemberMatchesMethodName(m, memberName))
.Where(m => m.Name == memberName)
.Where(m => MatchTypeArguments(m.TypeArguments, methodTypeArguments))
.Where(m => MatchParameters(m.Parameters, methodArguments))
.ToImmutableArrayOrEmpty();

if (matchingMembers.Length == 0)
Expand All @@ -108,90 +96,49 @@ public ParsedStackFrame(

return null;

// TODO: Improve perf here. ToDisplayString is fairly expensive
static bool MemberMatchesMethodName(ISymbol member, string memberToSearchFor)
static bool MatchParameters(ImmutableArray<IParameterSymbol> parameters, StackFrameParameterList stackFrameParameters)
{
var displayName = member.ToDisplayString();
var dotIndex = displayName.LastIndexOf(".");
var memberName = dotIndex >= 0
? displayName[(dotIndex + 1)..]
: displayName;

return string.Equals(memberName, memberToSearchFor);
}
}

/// <summary>
/// Gets all of the text prior to the <see cref="TypeSpan"/>
/// </summary>
/// <returns></returns>
public string GetTextBeforeType()
{
return OriginalText[..TypeSpan.Start];
}
if (parameters.Length != stackFrameParameters.Parameters.Length)
{
return false;
}

/// <summary>
/// Gets the text representing the fully qualified type name
/// </summary>
public string GetQualifiedTypeText()
{
return OriginalText[TypeSpan.Start..TypeSpan.End];
}
for (var i = 0; i < stackFrameParameters.Parameters.Length; i++)
{
var stackFrameParameter = (StackFrameParameterDeclarationNode)stackFrameParameters.Parameters[i];
var paramSymbol = parameters[i];

/// <summary>
/// Gets the method text, including the arguments to the method
/// </summary>
public string GetMethodText()
{
return OriginalText[MethodSpan.Start..ArgsSpan.End];
}
if (paramSymbol.Name != stackFrameParameter.Identifier.CreateString())
{
return false;
}
}

/// <summary>
/// Gets the text after the last parsed span available. This is after
/// file information if it is available, otherwise after the argument information.
/// </summary>
public string GetTrailingText()
{
var lastSpan = FileSpan == default
? ArgsSpan
: FileSpan;
return true;
}

if (lastSpan.End + 1 == OriginalText.Length)
static bool MatchTypeArguments(ImmutableArray<ITypeSymbol> typeArguments, StackFrameTypeArgumentList? stackFrameTypeArgumentList)
{
return string.Empty;
}
if (stackFrameTypeArgumentList is null)
{
return typeArguments.IsDefaultOrEmpty;
}

return OriginalText[(lastSpan.End + 1)..];
}
if (typeArguments.IsDefaultOrEmpty)
{
return false;
}

/// <summary>
/// If the frame has file information, gets the text between the method and file information.
/// ex: at ConsoleApp4.MyClass.M[T](T t) in [|C:\repos\Test\MyClass.cs:line 7|]
/// </summary>
public string? GetFileText()
{
if (FileSpan == default)
{
return null;
var stackFrameTypeArguments = stackFrameTypeArgumentList.TypeArguments;
return typeArguments.Length == stackFrameTypeArguments.Length;
}

return OriginalText[FileSpan.Start..FileSpan.End];
}

/// <summary>
/// If the frame has file information, gets the text between the method and file information.
/// ex: at ConsoleApp4.MyClass.M[T](T t)[| in |]C:\repos\Test\MyClass.cs:line 7
/// If the <see cref="Root"/> has file information, attempts to map it to existing documents
/// in a solution. Does fulle filepath match if possible, otherwise does an approximate match
/// since the file path may be very different on different machines
/// </summary>
public string? GetTextBetweenTypeAndFile()
{
if (FileSpan == default)
{
return null;
}

return OriginalText[(ArgsSpan.End + 1)..FileSpan.Start];
}

internal (Document? document, int line) GetDocumentAndLine(Solution solution)
{
var fileMatches = GetFileMatches(solution, out var lineNumber);
Expand All @@ -203,25 +150,18 @@ public string GetTrailingText()
return (fileMatches.First(), lineNumber);
}

/// <summary>
/// If the <see cref="FileSpan"/> exists it attempts to map the file path that exists
/// in that span to a document in the solution. It's possible the file path won't match exactly,
/// so it does a best effort to match based on name.
/// </summary>
private ImmutableArray<Document> GetFileMatches(Solution solution, out int lineNumber)
{
var fileText = OriginalText[FileSpan.Start..FileSpan.End];
var regex = new Regex(@"(?<fileName>.+):(line)\s*(?<lineNumber>[0-9]+)");
var match = regex.Match(fileText);
Debug.Assert(match.Success);

var fileNameGroup = match.Groups["fileName"];
var lineNumberGroup = match.Groups["lineNumber"];

lineNumber = int.Parse(lineNumberGroup.Value);
lineNumber = 0;
if (Root.FileInformationExpression is null)
{
return ImmutableArray<Document>.Empty;
}

var fileName = fileNameGroup.Value;
Debug.Assert(!string.IsNullOrEmpty(fileName));
var fileName = Root.FileInformationExpression.Path.ToString();
var lineString = Root.FileInformationExpression.Line.ToString();
RoslynDebug.AssertNotNull(lineString);
lineNumber = int.Parse(lineString);

var documentName = Path.GetFileName(fileName);
var potentialMatches = new HashSet<Document>();
Expand Down
Loading

0 comments on commit 63823bb

Please sign in to comment.