Skip to content

Fix #2382: translate native C# top-level statements to G# top-level program code#2386

Merged
DavidObando merged 3 commits into
mainfrom
fix/2382-cs2gs-top-level-statements
Jul 16, 2026
Merged

Fix #2382: translate native C# top-level statements to G# top-level program code#2386
DavidObando merged 3 commits into
mainfrom
fix/2382-cs2gs-top-level-statements

Conversation

@DavidObando

Copy link
Copy Markdown
Owner

Closes #2382.

Problem

cs2gs already hoisted an explicit Main method into a G# top-level
program (DeclarationVisitor.TranslateEntryType), but native Roslyn
top-level statementsGlobalStatementSyntax members with no
enclosing type/method syntax at all, the default shape for a modern
Program.cs such as tools/Oahu.Diagnostics/Program.cs in Oahu — were
entirely unsupported. They fell through DeclarationVisitor.DefaultVisit
and were silently dropped with an "Unsupported" gate diagnostic, so any
such file (and the whole app, in --via-sdk mode) failed to translate.

Root cause

For a native top-level-statements file, Roslyn's synthesized <Main>$
entry-point method's (and its synthesized Program type's)
DeclaringSyntaxReferences both point at the file's own
CompilationUnitSyntax — not at any real MethodDeclarationSyntax/
TypeDeclarationSyntax. The existing TranslateEntryType-triggering
check (member is TypeDeclarationSyntax typeDecl && ... == entryType)
therefore never fires for this shape at all.

Fix

  • DeclarationVisitor.TranslateTopLevelProgram — a new counterpart to
    TranslateEntryType, invoked once per file when
    entryPoint.DeclaringSyntaxReferences[0].SyntaxTree == root.SyntaxTree
    and its syntax is CompilationUnitSyntax (i.e. genuine top-level
    statements). It covers:
    • the implicit args string[] parameter (always literally named
      args, since there's no source spelling to rename from);
    • top-level await/return (Roslyn synthesizes the async
      Task/Task<int> entry-point shape transparently — no special
      handling needed beyond ordinary await/return statement
      translation);
    • ordinary global variable/expression statements and control flow,
      reusing TranslateStatement unchanged;
    • top-level local functions, split by whether they capture anything
      from their top-level-statement siblings:
      • A static local function (CS8421 guarantees no captures) or any
        other local function whose body references neither the implicit
        args parameter nor a sibling top-level local
        (IsTopLevelLocalFunctionCaptureFree) is hoisted to a genuine
        top-level G# func sibling (TranslateTopLevelLocalFunctionAsFunc,
        reusing MapParameters/MapMethodTypeParameters/
        MapDelegateLikeReturnType/TranslateBody — the same machinery
        TranslateEntryType's sibling static methods and
        TranslateLocalFunction's lambdas already use). Because G# funcs
        are pre-declared in scope regardless of textual order (ADR-0066),
        this correctly and automatically supports forward references and
        even mutual recursion between top-level local functions —
        neither of which a let binding could ever support (see
        LocalFunctionHoistTranslationTests.Issue2231MutualRecursionRemainsUnsupportedByGscLetBindings).
      • A local function that DOES capture a sibling top-level local (or
        args) is left exactly as before: an in-place, ordered let
        binding via the unchanged TranslateLocalFunction, reusing the
        existing HoistCallBeforeDeclLocalFunctions forward-reference
        reordering (now generalized to accept any flat statement list plus
        an enclosing TextSpan, not just a real BlockSyntax, so it also
        applies at top-level-statement scope with zero behavior change for
        its pre-existing method/constructor/accessor-body callers).
  • TranslateBodyCore gained a LocalFunctionStatementSyntax case so the
    new hoisted-func path can route a local function's body through the
    same TranslateBody/WithParameterShadows seam an ordinary method
    gets (WithParameterShadows's switch already anticipated this exact
    case — it was dead code until now). This has no effect on the
    pre-existing TranslateLocalFunction path, which never calls
    TranslateBody/TranslateBodyCore.
  • Mixed native top-level statements + an explicit Main-shaped method in
    the same file (C# CS7022, a warning, not an error) needs no special
    casing: the top-level statements simply win as the real entry point
    (the detection matches only the synthesized <Main>$), and the
    leftover explicit method becomes an ordinary, never-invoked member —
    exactly mirroring real C#/CLR semantics.

Tests

tools/cs2gs/Cs2Gs.Tests/Issue2382TopLevelStatementsTranslationTests.cs
— 13 new tests, each asserting translate + round-trip-parse + a real
gsc compile-and-run:

  • simple sync top-level program
  • return int
  • implicit args
  • top-level await
  • the exact combined Oahu.Diagnostics shape (await + return of an
    awaited call, through a hoisted async local function called
    before its own declaration)
  • a capture-free static local function hoisted and called before its
    declaration (RenderPretty-style)
  • a non-static but actually capture-free local function (exercises
    the body-walk branch of IsTopLevelLocalFunctionCaptureFree, not just
    the static-modifier fast path)
  • two mutually-referencing capture-free local functions (proving true
    forward/mutual reference works, unlike let)
  • a capturing local function staying an in-place let binding
  • a capturing local function called before its declaration (hoisted
    let ordering, generalizing the existing cs2gs: C# local functions emitted as let-lambdas at original position (used before declaration -> GS0125) #2231 test to top-level-
    statement scope)
  • a generic capture-free local function
  • the mixed explicit-Main scenario
  • multi-file coexistence (top-level statements in one file, an ordinary
    namespaced type in another, compiled together with gsc)

Verified against the real trigger: cs2gs migrate --app Oahu.Diagnostics against the actual Oahu repository now reports
translate=PASS, compile=PASS for tools/Oahu.Diagnostics (previously
this app could not be translated at all). The emitted Program.gs's
RenderPretty local function is correctly hoisted to a top-level func,
not a let binding.

Full suite results

  • Core.Tests: 6291/6291 passed (1 pre-existing skip)
  • Compiler.Tests: 3090/3090 passed
  • InternalAnalyzers.Tests: 7/7 passed
  • Cs2Gs.Tests (serialized, RunConfiguration.DisableParallelization=true): 1423/1423 passed (1410 baseline + 13 new)

Deferred / discovered but out of scope

The same cs2gs migrate --app Oahu.Diagnostics real-repo run surfaces
10 pre-existing ilverify failures that are unrelated to top-level-
statement translation itself
(translate and compile both fully pass —
this is a downstream, post-compile IL-verification concern):

  1. StackUnexpected in the pre-existing Checks/*.cs classes
    (e.g. Oahu.Diagnostics.Checks.ExportCheck::Run — a generic
    covariance/collection-initializer mismatch, List<object> found where
    List<DiagnosticCheck> was expected). Not touched by this PR.
  2. FieldAccess + 2× MethodAccess ("Field/Method is not
    visible"): the synthesized <Main>$, the hoisted RenderPretty func,
    and a top-level lambda end up emitted as members of the wrong
    package's
    synthesized <Program> static-holder type
    (Oahu.Diagnostics.Checks instead of the correct Default package,
    since Program.cs's top-level statements have no C# namespace). This
    looks like a gsc emitter-side defect (not a cs2gs translation
    defect) in how the per-package <Program> container is resolved when
    multiple packages in a large multi-file compilation each need one. I
    attempted several isolated 2-file gsc-only repros (package-less
    top-level file + a second real package; adding lambdas; adding async
    methods with captured-variable box classes) and could not
    reproduce the misplacement in isolation — it appears to need the
    fuller real 11-file, multi-package project shape to pin down further.
    Filing this as a separate issue (with the artifacts gathered) is
    recommended as a follow-up; it does not block this PR since translate
    and compile — the actual scope of cs2gs: translate C# top-level statements and local functions directly to G# top-level program code #2382 — both fully succeed.

DavidObando added 3 commits July 15, 2026 23:37
…rogram code

cs2gs previously only hoisted an EXPLICIT `Main` method into a G#
top-level program (TranslateEntryType). Native Roslyn top-level
statements (`GlobalStatementSyntax` members with no enclosing type/method
syntax at all — the default shape for a modern `Program.cs`, e.g.
Oahu.Diagnostics) were entirely unsupported: they fell through
DeclarationVisitor.DefaultVisit and were silently dropped with an
"Unsupported" gate diagnostic.

DeclarationVisitor.TranslateTopLevelProgram is the new counterpart to
TranslateEntryType, invoked once per file when the synthesized
`<Main>$` entry point's declaring syntax is the CompilationUnitSyntax
itself (i.e. genuine top-level statements, detected in TranslateDocument
via entryPoint.DeclaringSyntaxReferences[0]). It covers:

  * the implicit `args string[]` parameter (always literally named
    `args`, since there is no source spelling to rename from);
  * top-level `await`/`return` (Roslyn synthesizes the async Task/
    Task<int> entry-point shape transparently; no special-casing needed
    beyond ordinary await/return statement translation);
  * ordinary global variable/expression statements and control flow,
    reusing TranslateStatement as-is;
  * top-level local functions, split by whether they capture anything
    from their top-level-statement siblings:
      - a `static` local function (CS8421 guarantees no captures) or any
        other local function whose body references neither the implicit
        `args` parameter nor a sibling top-level local
        (IsTopLevelLocalFunctionCaptureFree) is hoisted to a genuine
        top-level G# `func` sibling (TranslateTopLevelLocalFunctionAsFunc,
        reusing MapParameters/MapMethodTypeParameters/
        MapDelegateLikeReturnType/TranslateBody — the same machinery
        TranslateEntryType's sibling static methods and
        TranslateLocalFunction's lambdas already use). Because G# funcs
        are pre-declared in scope regardless of textual order (ADR-0066),
        this correctly and automatically supports forward references and
        even mutual recursion between top-level local functions — neither
        of which a `let` binding could ever support (see
        LocalFunctionHoistTranslationTests.
        Issue2231MutualRecursionRemainsUnsupportedByGscLetBindings).
      - a local function that DOES capture a sibling top-level local (or
        `args`) is left exactly as before: an in-place, ordered `let`
        binding via the unchanged TranslateLocalFunction, reusing the
        existing HoistCallBeforeDeclLocalFunctions forward-reference
        reordering (now generalized to accept any flat statement list +
        enclosing TextSpan, not just a real BlockSyntax, so it also
        applies to the top-level-statement sequence).

TranslateBodyCore gained a `LocalFunctionStatementSyntax` case so the new
hoisted-func path can route through the same TranslateBody/
WithParameterShadows seam an ordinary method gets (WithParameterShadows
already anticipated this exact case). This has no effect on the
pre-existing TranslateLocalFunction path, which never calls
TranslateBody/TranslateBodyCore.

Mixed native top-level statements + an explicit Main-shaped method in the
same file (C# CS7022, a warning not an error) is handled without special
casing: the top-level statements simply win as the real entry point (the
`hasNativeTopLevelStatements` detection matches only the synthesized
`<Main>$`), and the leftover explicit method becomes an ordinary,
never-invoked member — exactly mirroring real C#/CLR semantics.

Tests: tools/cs2gs/Cs2Gs.Tests/Issue2382TopLevelStatementsTranslationTests.cs
(13 new tests, translate+round-trip-parse+real gsc compile-and-run for
each): simple sync program, `return int`, implicit `args`, top-level
`await`, the exact combined Oahu.Diagnostics shape (await + return-of-
await through a hoisted async local function called before its
declaration), a capture-free `static` local function hoisted and called
before its declaration (RenderPretty-style), a non-static-but-actually-
capture-free local function (exercises the body-walk branch, not just
the `static`-modifier fast path), two mutually-referencing capture-free
local functions (proving true forward/mutual reference works, unlike
`let`), a capturing local function staying an in-place `let` binding,
a capturing local function called before its declaration (hoisted `let`
ordering, generalizing the existing #2231 test to top-level-statement
scope), a generic capture-free local function, the mixed explicit-Main
scenario, and multi-file coexistence (top-level statements in one file,
an ordinary namespaced type in another, compiled together with gsc).

Verified against the real trigger: `cs2gs migrate --app Oahu.Diagnostics`
against the actual Oahu repository now reports translate=PASS,
compile=PASS for tools/Oahu.Diagnostics (previously this app could not be
translated at all). The emitted Program.gs's RenderPretty local function
is correctly hoisted to a top-level `func`, not a `let` binding.

Full suites (all serialized where applicable): Core.Tests 6291/6291
(1 skip), Compiler.Tests 3090/3090, InternalAnalyzers.Tests 7/7,
Cs2Gs.Tests 1423/1423 (RunConfiguration.DisableParallelization=true).

Deferred (out of scope for this fix, reported separately): the same
`cs2gs migrate --app Oahu.Diagnostics` run surfaces 10 pre-existing
ilverify failures unrelated to top-level-statement translation itself —
6 are StackUnexpected findings in the pre-existing Checks/*.cs classes
(generic covariance, e.g. List<object> vs List<DiagnosticCheck>) and 4
(2x FieldAccess, 2x MethodAccess) show the synthesized <Main>$,
RenderPretty, and a lambda emitted into the WRONG package's synthesized
<Program> holder type (Oahu.Diagnostics.Checks instead of the correct
Default package for a namespace-less top-level-statements file) in this
specific large multi-file, multi-package project shape. This appears to
be a gsc emitter-side defect (not a cs2gs translation defect): isolated
2-file repros with a package-less top-level file plus a second real
package (including one with its own lambdas/async closures) did NOT
reproduce it, so the trigger needs the fuller multi-file real-project
shape to pin down further.
@DavidObando
DavidObando merged commit 358e3e2 into main Jul 16, 2026
15 of 16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

cs2gs: translate C# top-level statements and local functions directly to G# top-level program code

1 participant