Fix #2382: translate native C# top-level statements to G# top-level program code#2386
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #2382.
Problem
cs2gs already hoisted an explicit
Mainmethod into a G# top-levelprogram (
DeclarationVisitor.TranslateEntryType), but native Roslyntop-level statements —
GlobalStatementSyntaxmembers with noenclosing type/method syntax at all, the default shape for a modern
Program.cssuch astools/Oahu.Diagnostics/Program.csin Oahu — wereentirely unsupported. They fell through
DeclarationVisitor.DefaultVisitand were silently dropped with an "Unsupported" gate diagnostic, so any
such file (and the whole app, in
--via-sdkmode) failed to translate.Root cause
For a native top-level-statements file, Roslyn's synthesized
<Main>$entry-point method's (and its synthesized
Programtype's)DeclaringSyntaxReferencesboth point at the file's ownCompilationUnitSyntax— not at any realMethodDeclarationSyntax/TypeDeclarationSyntax. The existingTranslateEntryType-triggeringcheck (
member is TypeDeclarationSyntax typeDecl && ... == entryType)therefore never fires for this shape at all.
Fix
DeclarationVisitor.TranslateTopLevelProgram— a new counterpart toTranslateEntryType, invoked once per file whenentryPoint.DeclaringSyntaxReferences[0].SyntaxTree == root.SyntaxTreeand its syntax
is CompilationUnitSyntax(i.e. genuine top-levelstatements). It covers:
args string[]parameter (always literally namedargs, since there's no source spelling to rename from);await/return(Roslyn synthesizes the asyncTask/Task<int>entry-point shape transparently — no specialhandling needed beyond ordinary
await/returnstatementtranslation);
reusing
TranslateStatementunchanged;from their top-level-statement siblings:
staticlocal function (CS8421 guarantees no captures) or anyother local function whose body references neither the implicit
argsparameter nor a sibling top-level local(
IsTopLevelLocalFunctionCaptureFree) is hoisted to a genuinetop-level G#
funcsibling (TranslateTopLevelLocalFunctionAsFunc,reusing
MapParameters/MapMethodTypeParameters/MapDelegateLikeReturnType/TranslateBody— the same machineryTranslateEntryType's sibling static methods andTranslateLocalFunction's lambdas already use). Because G#funcsare 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
letbinding could ever support (seeLocalFunctionHoistTranslationTests.Issue2231MutualRecursionRemainsUnsupportedByGscLetBindings).args) is left exactly as before: an in-place, orderedletbinding via the unchanged
TranslateLocalFunction, reusing theexisting
HoistCallBeforeDeclLocalFunctionsforward-referencereordering (now generalized to accept any flat statement list plus
an enclosing
TextSpan, not just a realBlockSyntax, so it alsoapplies at top-level-statement scope with zero behavior change for
its pre-existing method/constructor/accessor-body callers).
TranslateBodyCoregained aLocalFunctionStatementSyntaxcase so thenew hoisted-func path can route a local function's body through the
same
TranslateBody/WithParameterShadowsseam an ordinary methodgets (
WithParameterShadows's switch already anticipated this exactcase — it was dead code until now). This has no effect on the
pre-existing
TranslateLocalFunctionpath, which never callsTranslateBody/TranslateBodyCore.Main-shaped method inthe same file (C#
CS7022, a warning, not an error) needs no specialcasing: the top-level statements simply win as the real entry point
(the detection matches only the synthesized
<Main>$), and theleftover 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
gsccompile-and-run:return intargsawaitawait+returnof anawaited call, through a hoisted async local function called
before its own declaration)
staticlocal function hoisted and called before itsdeclaration (
RenderPretty-style)the body-walk branch of
IsTopLevelLocalFunctionCaptureFree, not justthe
static-modifier fast path)forward/mutual reference works, unlike
let)letbindingletordering, 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)
Mainscenarionamespaced type in another, compiled together with
gsc)Verified against the real trigger:
cs2gs migrate --app Oahu.Diagnosticsagainst the actual Oahu repository now reportstranslate=PASS,compile=PASSfortools/Oahu.Diagnostics(previouslythis app could not be translated at all). The emitted
Program.gs'sRenderPrettylocal function is correctly hoisted to a top-levelfunc,not a
letbinding.Full suite results
Core.Tests: 6291/6291 passed (1 pre-existing skip)Compiler.Tests: 3090/3090 passedInternalAnalyzers.Tests: 7/7 passedCs2Gs.Tests(serialized,RunConfiguration.DisableParallelization=true): 1423/1423 passed (1410 baseline + 13 new)Deferred / discovered but out of scope
The same
cs2gs migrate --app Oahu.Diagnosticsreal-repo run surfaces10 pre-existing
ilverifyfailures that are unrelated to top-level-statement translation itself (translate and compile both fully pass —
this is a downstream, post-compile IL-verification concern):
StackUnexpectedin the pre-existingChecks/*.csclasses(e.g.
Oahu.Diagnostics.Checks.ExportCheck::Run— a genericcovariance/collection-initializer mismatch,
List<object>found whereList<DiagnosticCheck>was expected). Not touched by this PR.FieldAccess+ 2×MethodAccess("Field/Method is notvisible"): the synthesized
<Main>$, the hoistedRenderPrettyfunc,and a top-level lambda end up emitted as members of the wrong
package's synthesized
<Program>static-holder type(
Oahu.Diagnostics.Checksinstead of the correctDefaultpackage,since
Program.cs's top-level statements have no C# namespace). Thislooks like a
gscemitter-side defect (not a cs2gs translationdefect) in how the per-package
<Program>container is resolved whenmultiple packages in a large multi-file compilation each need one. I
attempted several isolated 2-file
gsc-only repros (package-lesstop-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.