File: GoDeclarationScanner.cs
Web Access
Project: src\src\Aspire.Hosting.CodeGeneration.Go\Aspire.Hosting.CodeGeneration.Go.csproj (Aspire.Hosting.CodeGeneration.Go)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
namespace Aspire.Hosting.CodeGeneration.Go;
 
/// <summary>
/// The kinds of Go declarations <see cref="GoDeclarationScanner"/> reports.
/// </summary>
internal enum GoDeclarationKind
{
    Type,
    Function,
    Method,
    Field,
    Constant
}
 
/// <summary>
/// An exported Go declaration, reduced to the parts the API export documents.
/// </summary>
internal sealed record GoDeclaration
{
    public required GoDeclarationKind Kind { get; init; }
 
    /// <summary>
    /// Gets the declared identifier.
    /// </summary>
    public required string Name { get; init; }
 
    /// <summary>
    /// Gets the type a method, field or constant belongs to, without a pointer or type parameters.
    /// Empty for types and functions.
    /// </summary>
    public string Owner { get; init; } = string.Empty;
 
    /// <summary>
    /// Gets the declaration without its doc comment or body. Types keep <c>type</c> and functions keep
    /// <c>func</c>; methods, fields and constants start at their own name, for example
    /// <c>Count() (int, error)</c>, <c>Format string</c> or <c>Redis RedisKind = "redis"</c>.
    /// </summary>
    public required string Signature { get; init; }
 
    public string? Summary { get; init; }
 
    public string? Remarks { get; init; }
 
    /// <summary>
    /// Gets the unquoted value of a constant, or the struct tag of a field.
    /// </summary>
    public string? Value { get; init; }
 
    /// <summary>
    /// Gets the receiver type of a method, for example <c>*List[T]</c>.
    /// </summary>
    public string Receiver { get; init; } = string.Empty;
}
 
/// <summary>
/// Reads the exported declarations out of Go source the SDK generator controls: the embedded runtime
/// files and the text <see cref="AtsGoCodeGenerator"/> emits.
/// </summary>
/// <remarks>
/// <para>
/// This is a line scanner, not a Go parser. It relies on the layout gofmt produces and the generator
/// writes: declarations start in column 0, struct and const bodies are tab-indented and closed by a
/// column-0 <c>}</c> or <c>)</c>, and a doc comment is the run of <c>//</c> lines directly above its
/// declaration. A blank line detaches a comment from what follows it.
/// </para>
/// <para>
/// Only top-level declarations are recognized, and only the exported ones, except that methods are
/// reported with the name of their receiver even when the receiver type is unexported. Callers filter
/// on <see cref="GoDeclaration.Owner"/>. <see cref="ReadTypeDeclaration"/> reads the one unexported type
/// that a caller names.
/// </para>
/// </remarks>
internal static class GoDeclarationScanner
{
    /// <summary>
    /// Scans Go source and returns its exported declarations in source order.
    /// </summary>
    /// <param name="source">The Go source.</param>
    /// <param name="includeBodyDocs">
    /// Whether comments inside struct and const bodies document the field or constant below them. The
    /// hand-written runtime uses those comments as group headings instead, so it passes
    /// <see langword="false"/>.
    /// </param>
    public static IReadOnlyList<GoDeclaration> Scan(string source, bool includeBodyDocs)
    {
        ArgumentNullException.ThrowIfNull(source);
 
        var lines = source.ReplaceLineEndings("\n").Split('\n');
        var declarations = new List<GoDeclaration>();
        var doc = new List<string>();
 
        for (var i = 0; i < lines.Length; i++)
        {
            var line = lines[i];
 
            if (line.StartsWith("//", StringComparison.Ordinal))
            {
                doc.Add(StripCommentMarker(line));
                continue;
            }
 
            if (line.StartsWith("type ", StringComparison.Ordinal))
            {
                i = ScanType(lines, i, doc, includeBodyDocs, declarations);
            }
            else if (line.StartsWith("func ", StringComparison.Ordinal))
            {
                ScanFunction(line, doc, declarations);
            }
            else if (line.StartsWith("const (", StringComparison.Ordinal))
            {
                i = ScanConstBlock(lines, i, includeBodyDocs, declarations);
            }
 
            doc.Clear();
        }
 
        return declarations;
    }
 
    /// <summary>
    /// Scans the hand-written runtime source like <see cref="Scan"/> without body documentation, but throws
    /// when the source declares an exported construct that <see cref="Scan"/> does not model.
    /// </summary>
    /// <remarks>
    /// <see cref="Scan"/> skips such a construct or reads only the first line of it, so the export would
    /// silently leave it out or document a fragment of it. The text the generator emits legitimately holds
    /// some of them, such as exported variables and handle interfaces with bodies, so only the runtime is
    /// scanned this way.
    /// </remarks>
    /// <param name="source">The Go source.</param>
    /// <exception cref="InvalidOperationException">The source declares an exported construct that is not modeled.</exception>
    public static IReadOnlyList<GoDeclaration> ScanRuntime(string source)
    {
        ArgumentNullException.ThrowIfNull(source);
 
        var unmodeled = source.ReplaceLineEndings("\n").Split('\n').FirstOrDefault(IsUnmodeledExport);
 
        return unmodeled is null
            ? Scan(source, includeBodyDocs: false)
            : throw new InvalidOperationException($"The Go source declares an exported construct the API export does not model: {unmodeled}");
    }
 
    /// <summary>
    /// Returns the results of a function or method signature, or <see langword="null"/> when it declares
    /// none. For <c>Count() (int, error)</c> that is <c>(int, error)</c>.
    /// </summary>
    /// <param name="signature">A <see cref="GoDeclaration.Signature"/> of a function or method.</param>
    public static string? GetReturnType(string signature)
    {
        ArgumentNullException.ThrowIfNull(signature);
 
        var open = signature.IndexOf('(');
        if (open < 0)
        {
            return null;
        }
 
        // Parameters can hold parentheses of their own (`callback func(...any) any`), so the parameter
        // list ends at the parenthesis that balances the first one.
        var depth = 0;
        for (var i = open; i < signature.Length; i++)
        {
            if (signature[i] == '(')
            {
                depth++;
            }
            else if (signature[i] == ')' && --depth == 0)
            {
                var results = signature[(i + 1)..].Trim();
                return results.Length > 0 ? results : null;
            }
        }
 
        return null;
    }
 
    /// <summary>
    /// Returns the signature-only declaration of a top-level type: no doc comment, and the body of a
    /// struct or interface kept. The type does not have to be exported.
    /// </summary>
    /// <remarks>
    /// <see cref="Scan"/> skips unexported types, but some of them are part of what an exported type
    /// promises. A declaration set that mentions such a type has to declare it too, without documenting it
    /// as an item of its own.
    /// </remarks>
    /// <param name="source">The Go source.</param>
    /// <param name="typeName">The name of a type that is not generic.</param>
    /// <exception cref="InvalidOperationException">The source declares no such type.</exception>
    public static string ReadTypeDeclaration(string source, string typeName)
    {
        ArgumentNullException.ThrowIfNull(source);
        ArgumentException.ThrowIfNullOrEmpty(typeName);
 
        var lines = source.ReplaceLineEndings("\n").Split('\n');
        var headerPrefix = $"type {typeName} ";
        var start = Array.FindIndex(lines, line => line.StartsWith(headerPrefix, StringComparison.Ordinal));
        if (start < 0)
        {
            throw new InvalidOperationException($"The Go source declares no type {typeName}.");
        }
 
        var declaration = new List<string> { CollapseWhitespace(TrimTrailingComment(lines[start])) };
 
        // A one-line type, such as `type handleType = string`, has no body.
        if (lines[start].EndsWith('{'))
        {
            for (var i = start + 1; i < lines.Length; i++)
            {
                if (lines[i] == "}")
                {
                    declaration.Add("}");
                    break;
                }
 
                var bodyLine = lines[i].Trim();
                if (bodyLine.Length > 0 && !bodyLine.StartsWith("//", StringComparison.Ordinal))
                {
                    declaration.Add('\t' + CollapseBodyLine(bodyLine));
                }
            }
        }
 
        return string.Join('\n', declaration);
    }
 
    // Examples of the headers handled here:
    //   type AtsErrorCode string
    //   type List[T any] struct {
    //   type DistributedApplication interface { handleReference }
    private static int ScanType(
        string[] lines,
        int index,
        List<string> doc,
        bool includeBodyDocs,
        List<GoDeclaration> declarations)
    {
        var header = lines[index];
        var rest = header["type ".Length..];
        var nameEnd = rest.IndexOfAny([' ', '[']);
        if (nameEnd <= 0)
        {
            return index;
        }
 
        var name = rest[..nameEnd];
        if (!IsExported(name))
        {
            return index;
        }
 
        var signature = CutBody(header);
        var (summary, remarks) = SplitDoc(doc);
        declarations.Add(new GoDeclaration
        {
            Kind = GoDeclarationKind.Type,
            Name = name,
            Signature = signature,
            Summary = summary,
            Remarks = remarks
        });
 
        // Interface bodies carry no declarations of their own and one-line types have no body.
        if (!header.EndsWith('{') || !signature.EndsWith(" struct", StringComparison.Ordinal))
        {
            return index;
        }
 
        var bodyDoc = new List<string>();
        var end = index + 1;
        for (; end < lines.Length && lines[end] != "}"; end++)
        {
            var bodyLine = lines[end].Trim();
            if (bodyLine.Length == 0)
            {
                bodyDoc.Clear();
            }
            else if (bodyLine.StartsWith("//", StringComparison.Ordinal))
            {
                bodyDoc.Add(StripCommentMarker(bodyLine));
            }
            else
            {
                AddField(name, bodyLine, includeBodyDocs ? bodyDoc : [], declarations);
                bodyDoc.Clear();
            }
        }
 
        return end;
    }
 
    // Examples of the field lines handled here:
    //   Format         string
    //   Name string `json:"name,omitempty"`
    //   *resourceBuilderBase                       (embedded; skipped)
    private static void AddField(string owner, string line, List<string> doc, List<GoDeclaration> declarations)
    {
        var tagStart = line.IndexOf('`');
        var tagEnd = line.LastIndexOf('`');
        var tag = tagStart >= 0 && tagEnd > tagStart ? line[(tagStart + 1)..tagEnd] : null;
 
        var declaration = tagStart >= 0 ? line[..tagStart] : TrimTrailingComment(line);
        declaration = CollapseWhitespace(declaration);
 
        var space = declaration.IndexOf(' ');
        if (space <= 0 || !IsExported(declaration[..space]))
        {
            return;
        }
 
        var (summary, remarks) = SplitDoc(doc);
        declarations.Add(new GoDeclaration
        {
            Kind = GoDeclarationKind.Field,
            Name = declaration[..space],
            Owner = owner,
            Signature = declaration,
            Summary = summary,
            Remarks = remarks,
            Value = tag
        });
    }
 
    // Examples of the function headers handled here:
    //   func RefExpr(format string, valueProviders ...any) *ReferenceExpression {
    //   func (l *List[T]) Count() (int, error) {
    //   func (e *CapabilityError) Code() string       { return e.err.Code }
    private static void ScanFunction(string line, List<string> doc, List<GoDeclaration> declarations)
    {
        var rest = line["func ".Length..];
        var (summary, remarks) = SplitDoc(doc);
 
        if (rest.StartsWith('('))
        {
            var close = rest.IndexOf(')');
            if (close < 0)
            {
                return;
            }
 
            var receiver = rest[1..close].Trim();
            var afterReceiver = rest[(close + 1)..].TrimStart();
            var nameEnd = afterReceiver.IndexOf('(');
            if (nameEnd <= 0)
            {
                return;
            }
 
            var name = afterReceiver[..nameEnd];
            if (!IsExported(name))
            {
                return;
            }
 
            // The receiver variable is optional and a generic receiver type holds spaces
            // (`d *Dict[K, V]`), so only a space before the type parameters ends the variable name.
            var firstSpace = receiver.IndexOf(' ');
            var bracket = receiver.IndexOf('[');
            var receiverType = firstSpace >= 0 && (bracket < 0 || firstSpace < bracket)
                ? receiver[(firstSpace + 1)..].Trim()
                : receiver;
            var typeName = receiverType.TrimStart('*');
            var typeParameters = typeName.IndexOf('[');
            var owner = typeParameters >= 0 ? typeName[..typeParameters] : typeName;
 
            declarations.Add(new GoDeclaration
            {
                Kind = GoDeclarationKind.Method,
                Name = name,
                Owner = owner,
                Signature = CollapseWhitespace(CutBody(afterReceiver)),
                Summary = summary,
                Remarks = remarks,
                Receiver = receiverType
            });
 
            return;
        }
 
        var functionNameEnd = rest.IndexOfAny(['(', '[']);
        if (functionNameEnd <= 0)
        {
            return;
        }
 
        var functionName = rest[..functionNameEnd];
        if (!IsExported(functionName))
        {
            return;
        }
 
        declarations.Add(new GoDeclaration
        {
            Kind = GoDeclarationKind.Function,
            Name = functionName,
            Signature = "func " + CollapseWhitespace(CutBody(rest)),
            Summary = summary,
            Remarks = remarks
        });
    }
 
    // Example of the entries handled here, one per line between `const (` and `)`:
    //   CapabilityNotFound AtsErrorCode = "CAPABILITY_NOT_FOUND"
    // Only entries that name their type are reported; the generators always do.
    private static int ScanConstBlock(
        string[] lines,
        int index,
        bool includeBodyDocs,
        List<GoDeclaration> declarations)
    {
        var bodyDoc = new List<string>();
        var end = index + 1;
        for (; end < lines.Length && lines[end] != ")"; end++)
        {
            var entry = lines[end].Trim();
            if (entry.Length == 0)
            {
                bodyDoc.Clear();
                continue;
            }
 
            if (entry.StartsWith("//", StringComparison.Ordinal))
            {
                bodyDoc.Add(StripCommentMarker(entry));
                continue;
            }
 
            var equals = entry.IndexOf('=');
            if (equals > 0)
            {
                var left = CollapseWhitespace(entry[..equals]);
                var literal = TrimTrailingComment(entry[(equals + 1)..]).Trim();
                var parts = left.Split(' ');
                if (parts.Length == 2 && IsExported(parts[0]))
                {
                    var (summary, remarks) = SplitDoc(includeBodyDocs ? bodyDoc : []);
                    declarations.Add(new GoDeclaration
                    {
                        Kind = GoDeclarationKind.Constant,
                        Name = parts[0],
                        Owner = parts[1],
                        Signature = $"{left} = {literal}",
                        Summary = summary,
                        Remarks = remarks,
                        Value = literal.Length >= 2 && literal[0] == '"' && literal[^1] == '"' ? literal[1..^1] : literal
                    });
                }
            }
 
            bodyDoc.Clear();
        }
 
        return end;
    }
 
    // Examples of the column-0 lines of exported constructs that Scan skips or reads only the first line of:
    //   var Default = newClient()
    //   type (
    //   const Version = "1"
    //   func (c *Client) Run(ctx context.Context,
    //   type Handle interface {
    // `const (` is modeled and unexported names are skipped on purpose, so neither is reported. Go ends a
    // line inside a parenthesized list with `(` or `,`, because any other token would end the declaration
    // there, so those endings are what tell that a signature goes on below its first line.
    private static bool IsUnmodeledExport(string line)
    {
        var space = line.IndexOf(' ');
        if (space <= 0)
        {
            return false;
        }
 
        var rest = line[(space + 1)..];
 
        return line[..space] switch
        {
            "var" => rest.StartsWith('(') || IsExported(rest),
            "const" => IsExported(rest),
            "type" => rest.StartsWith('(') || (IsExported(rest) && CutBody(line).EndsWith(" interface", StringComparison.Ordinal)),
            "func" => IsExported(rest.StartsWith('(') ? rest[(rest.IndexOf(')') + 1)..].TrimStart() : rest)
                && TrimTrailingComment(line).TrimEnd() is [.., '(' or ','],
            _ => false
        };
    }
 
    // Go exports every identifier that starts with a Unicode upper case letter (category Lu), and the
    // generator's naming can produce non-ASCII ones. A first letter outside the BMP is not recognized.
    private static bool IsExported(string name) => name.Length > 0 && char.IsUpper(name[0]);
 
    // The first " {" ends a header: a one-line body follows it, and gofmt never puts a space before the
    // brace of the empty `struct{}` and `interface{}` types.
    private static string CutBody(string header)
    {
        var brace = header.IndexOf(" {", StringComparison.Ordinal);
        return (brace >= 0 ? header[..brace] : header).TrimEnd();
    }
 
    private static string TrimTrailingComment(string text)
    {
        var comment = text.IndexOf(" //", StringComparison.Ordinal);
        return comment >= 0 ? text[..comment] : text;
    }
 
    // gofmt aligns the columns of neighboring declarations with runs of spaces.
    private static string CollapseWhitespace(string text)
    {
        var parts = text.Split([' ', '\t'], StringSplitOptions.RemoveEmptyEntries);
        return string.Join(' ', parts);
    }
 
    // Examples of the body lines handled here:
    //   HandleID string     `json:"$handle"`
    //   Err()      error // Reports the deferred failure.
    // The struct tag is kept as written because collapsing whitespace would change its value.
    private static string CollapseBodyLine(string line)
    {
        var tagStart = line.IndexOf('`');
        var tagEnd = line.LastIndexOf('`');
 
        return tagStart >= 0 && tagEnd > tagStart
            ? $"{CollapseWhitespace(line[..tagStart])} {line[tagStart..(tagEnd + 1)]}"
            : CollapseWhitespace(TrimTrailingComment(line));
    }
 
    private static string StripCommentMarker(string line)
    {
        var text = line.TrimStart()[2..].TrimEnd();
        return text.StartsWith(' ') ? text[1..] : text;
    }
 
    // The summary is the first paragraph, joined onto one line; everything after the first blank comment
    // line is the remarks and keeps its line breaks, because it can hold indented code.
    private static (string? Summary, string? Remarks) SplitDoc(IReadOnlyList<string> doc)
    {
        var paragraph = 0;
        while (paragraph < doc.Count && doc[paragraph].Length > 0)
        {
            paragraph++;
        }
 
        var summary = string.Join(' ', doc.Take(paragraph));
        var remarks = string.Join('\n', doc.Skip(paragraph + 1)).Trim();
 
        return (summary.Length > 0 ? summary : null, remarks.Length > 0 ? remarks : null);
    }
}