Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
aa4a9a9
Improve error recovery around 'scoped' modifier parsing
CyrusNajmabadi Dec 10, 2025
6e7a144
Delete
CyrusNajmabadi Dec 10, 2025
70b8e1a
Update test
CyrusNajmabadi Dec 10, 2025
3e9d93b
Preserve modifier/identifier logic from before
CyrusNajmabadi Dec 10, 2025
0be99eb
Update tests
CyrusNajmabadi Dec 10, 2025
26b6685
Update tsts
CyrusNajmabadi Dec 10, 2025
332babb
Update tests
CyrusNajmabadi Dec 10, 2025
686ad93
Update tests
CyrusNajmabadi Dec 10, 2025
b3e35fb
Update tests
CyrusNajmabadi Dec 10, 2025
179a870
delete
CyrusNajmabadi Dec 10, 2025
6e0779a
delete
CyrusNajmabadi Dec 10, 2025
742fdb7
Update tests
CyrusNajmabadi Dec 10, 2025
4aa09cf
Simplify tests
CyrusNajmabadi Dec 10, 2025
2a7b3a1
Add test
CyrusNajmabadi Dec 10, 2025
85d0a2d
better error for 'scoped scoped'
CyrusNajmabadi Dec 10, 2025
3d8d9fc
Docs and simplifying
CyrusNajmabadi Dec 10, 2025
14db92b
Revert
CyrusNajmabadi Dec 10, 2025
29b92fb
Merge remote-tracking branch 'upstream/main' into scopedParsing
CyrusNajmabadi Dec 11, 2025
fb0a724
Update tests
CyrusNajmabadi Dec 11, 2025
e00ae77
Merge remote-tracking branch 'upstream/main' into scopedParsing
CyrusNajmabadi Dec 18, 2025
c2ad0e9
Fix comment
CyrusNajmabadi Dec 18, 2025
d80f672
Fix
CyrusNajmabadi Dec 23, 2025
b87389e
Fixup
CyrusNajmabadi Dec 23, 2025
4897d48
Add triple scoped tests
CyrusNajmabadi Dec 23, 2025
78793a0
lint
CyrusNajmabadi Dec 23, 2025
845fba9
Apply suggestion from @CyrusNajmabadi
CyrusNajmabadi Dec 23, 2025
5ff993b
Update comments
CyrusNajmabadi Dec 29, 2025
92947dc
Merge branch 'scopedParsing' of https://github.com/CyrusNajmabadi/ros…
CyrusNajmabadi Dec 29, 2025
05282bf
Tweak comment
CyrusNajmabadi Dec 29, 2025
077cfd2
Tweak comment
CyrusNajmabadi Dec 29, 2025
991bf4e
Ad tests
CyrusNajmabadi Dec 29, 2025
a216706
Merge remote-tracking branch 'upstream/main' into scopedParsing
CyrusNajmabadi Dec 30, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/Compilers/CSharp/Portable/CSharpResources.resx
Original file line number Diff line number Diff line change
Expand Up @@ -6996,6 +6996,12 @@ To remove the warning, you can use /reference instead (set the Embed Interop Typ
<data name="ERR_ScopedMismatchInParameterOfPartial" xml:space="preserve">
<value>The 'scoped' modifier of parameter '{0}' doesn't match partial definition.</value>
</data>
<data name="ERR_ScopedAfterInOutRefReadonly" xml:space="preserve">
<value>The 'scoped' modifier cannot come after an 'in', 'out', 'ref' or 'readonly' modifier.</value>
</data>
<data name="ERR_InvalidModifierAfterScoped" xml:space="preserve">
<value>The '{0}' modifier cannot immediately follow the 'scoped' modifier.</value>
</data>
<data name="ERR_FixedFieldMustNotBeRef" xml:space="preserve">
<value>A fixed field must not be a ref field.</value>
</data>
Expand Down
3 changes: 3 additions & 0 deletions src/Compilers/CSharp/Portable/Errors/ErrorCode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2448,6 +2448,9 @@ internal enum ErrorCode
ERR_ExtensionParameterInStaticContext = 9347,
ERR_CompilationUnitUnexpected = 9348,

ERR_ScopedAfterInOutRefReadonly = 9349,
ERR_InvalidModifierAfterScoped = 9350,

// Note: you will need to do the following after adding errors:
// 1) Update ErrorFacts.IsBuildOnlyDiagnostic (src/Compilers/CSharp/Portable/Errors/ErrorFacts.cs)
// 2) Add message to CSharpResources.resx
Expand Down
2 changes: 2 additions & 0 deletions src/Compilers/CSharp/Portable/Errors/ErrorFacts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2555,6 +2555,8 @@ or ErrorCode.ERR_EqualityOperatorInPatternNotSupported
or ErrorCode.ERR_InequalityOperatorInPatternNotSupported
or ErrorCode.ERR_DesignatorBeforePropertyPattern
or ErrorCode.ERR_CompilationUnitUnexpected
or ErrorCode.ERR_ScopedAfterInOutRefReadonly
or ErrorCode.ERR_InvalidModifierAfterScoped
=> false,
};
#pragma warning restore CS8524 // The switch expression does not handle some values of its input type (it is not exhaustive) involving an unnamed enum value.
Expand Down
2 changes: 1 addition & 1 deletion src/Compilers/CSharp/Portable/Errors/MessageID.cs
Original file line number Diff line number Diff line change
Expand Up @@ -487,7 +487,7 @@ internal static LanguageVersion RequiredVersion(this MessageID feature)
// C# preview features.
//return LanguageVersion.Preview;

// C# 13.0 features.
// C# 14.0 features.
case MessageID.IDS_FeatureFieldKeyword:
case MessageID.IDS_FeatureFirstClassSpan:
case MessageID.IDS_FeatureUnboundGenericTypesInNameof:
Expand Down
114 changes: 65 additions & 49 deletions src/Compilers/CSharp/Portable/Parser/LanguageParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4834,7 +4834,9 @@ private bool IsPossibleParameter()
return this.IsTrueIdentifier();

default:
return IsParameterModifierExcludingScoped(this.CurrentToken) || IsPossibleScopedKeyword(isFunctionPointerParameter: false) || IsPredefinedType(this.CurrentToken.Kind);
return IsParameterModifierExcludingScoped(this.CurrentToken) ||
IsDefiniteScopedModifier(isFunctionPointerParameter: false, isLambdaParameter: false) ||
IsPredefinedType(this.CurrentToken.Kind);
}
}

Expand Down Expand Up @@ -4961,32 +4963,50 @@ private static bool IsParameterModifierExcludingScoped(SyntaxToken token)

private void ParseParameterModifiers(SyntaxListBuilder modifiers, bool isFunctionPointerParameter, bool isLambdaParameter)
{
bool tryScoped = true;
Debug.Assert(!(isFunctionPointerParameter && isLambdaParameter), "Can't be parsing parameters for both a function pointer and a lambda at the same time");

while (IsParameterModifierExcludingScoped(this.CurrentToken))
var seenScoped = false;
while (true)
{
if (this.CurrentToken.Kind is SyntaxKind.RefKeyword or SyntaxKind.OutKeyword or SyntaxKind.InKeyword or SyntaxKind.ReadOnlyKeyword)
// Normal keyword-modifier (in/out/ref/readonly/params/this). Always safe to consume.
if (IsParameterModifierExcludingScoped(this.CurrentToken))
{
tryScoped = false;
modifiers.Add(this.EatToken());
continue;
}

modifiers.Add(this.EatToken());
}

if (tryScoped)
{
SyntaxToken scopedKeyword = ParsePossibleScopedKeyword(isFunctionPointerParameter, isLambdaParameter);

if (scopedKeyword != null)
// 'scoped' modifier. May be ambiguous with a type/identifier. And has changed parsing rules between
// C#13/14 inside a lambda parameter list.
if (this.IsDefiniteScopedModifier(isFunctionPointerParameter, isLambdaParameter))
{
modifiers.Add(scopedKeyword);

// Look if ref/out/in/readonly are next
while (this.CurrentToken.Kind is SyntaxKind.RefKeyword or SyntaxKind.OutKeyword or SyntaxKind.InKeyword or SyntaxKind.ReadOnlyKeyword)
// First scoped-modifier is always considered the modifier.
if (!seenScoped)
{
modifiers.Add(this.EatToken());
seenScoped = true;
modifiers.Add(this.EatContextualToken(SyntaxKind.ScopedKeyword));
continue;
}
else
{
// If we've already seen `scoped` then we may have a situation like `scoped scoped`. This could
// be duplicated modifier, or it could be that the second `scoped` is actually the identifier of
// a parameter.
//
// Places where it is an identifier are:
//
// `(scoped scoped) =>`
// `(scoped scoped, ...) =>`
// `(scoped scoped = ...) =>`
if (this.PeekToken(1).Kind is not (SyntaxKind.CloseParenToken or SyntaxKind.CommaToken or SyntaxKind.EqualsToken))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this only happen when isLambdaParameter is true?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No. We also hit this in cases like ref scoped scoped X x in normal method parameter cases. In this case, the first scoped is considered a modifier (since scoped X looks like a type id). Then the second 'scoped' is also a modifier because it's also on type identifier.

So, we treat the first as a scoped modifier. Then, when we see the second, since we prove it's not an param-identifier, we make it a modifier as well.

{
modifiers.Add(this.EatContextualToken(SyntaxKind.ScopedKeyword));
continue;
}
}
}

// Not a modifier. We're done.
return;
}
}

Expand Down Expand Up @@ -8464,7 +8484,7 @@ private bool IsPossibleLocalDeclarationStatement(bool isGlobalScriptLevel)
return true;
}

if (IsPossibleScopedKeyword(isFunctionPointerParameter: false))
if (IsDefiniteScopedModifier(isFunctionPointerParameter: false, isLambdaParameter: false))
{
return true;
}
Expand All @@ -8482,12 +8502,6 @@ private bool IsPossibleLocalDeclarationStatement(bool isGlobalScriptLevel)
return IsPossibleFirstTypedIdentifierInLocalDeclarationStatement(isGlobalScriptLevel);
}

private bool IsPossibleScopedKeyword(bool isFunctionPointerParameter)
{
using var _ = this.GetDisposableResetPoint(resetOnDispose: true);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for those keeping count, this made for a triple nested reset point. We now just have one reset point.

return ParsePossibleScopedKeyword(isFunctionPointerParameter, isLambdaParameter: false) != null;
}

private bool IsPossibleFirstTypedIdentifierInLocalDeclarationStatement(bool isGlobalScriptLevel)
{
bool? typedIdentifier = IsPossibleTypedIdentifierStart(this.CurrentToken, this.PeekToken(1), allowThisKeyword: false);
Expand Down Expand Up @@ -8623,7 +8637,7 @@ private bool IsPossibleTopLevelUsingLocalDeclarationStatement()
// Skip 'using' keyword
EatToken();

if (IsPossibleScopedKeyword(isFunctionPointerParameter: false))
if (IsDefiniteScopedModifier(isFunctionPointerParameter: false, isLambdaParameter: false))
{
return true;
}
Expand Down Expand Up @@ -10518,41 +10532,34 @@ private StatementSyntax ParseLocalDeclarationStatement(SyntaxList<AttributeListS
}
}

private SyntaxToken ParsePossibleScopedKeyword(
private bool IsDefiniteScopedModifier(
bool isFunctionPointerParameter,
bool isLambdaParameter)
{
if (this.CurrentToken.ContextualKind != SyntaxKind.ScopedKeyword)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this got much easier as we just need to return a bool saying if this should be considered a modifier. We don't actually have to do all the setting-up/rewinding if we change our minds. This means we just need a single reset point in here, and the caller can just eat depending on what we return.

return null;
return false;

// In C# 14 we decided that within a lambda 'scoped' would *always* be a keyword.
// In C# 14 we decided that within a lambda 'scoped' would *always* be a modifier, not a type.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

refining this comment. It wasn't 100% accurate. It's not always a keyword. But it is a modifier if it previous would have been allowed as a type.

// so `scoped scoped` is `modifier-scoped identifier-scoped` not `type-scoped identifier-scoped`.
// Note: this only applies the modifier/type portion. We still allow the identifier of a lambda
// to be named 'scoped'.
if (isLambdaParameter && IsFeatureEnabled(MessageID.IDS_FeatureSimpleLambdaParameterModifiers))
return this.EatContextualToken(SyntaxKind.ScopedKeyword);
return true;

using var beforeScopedResetPoint = this.GetDisposableResetPoint(resetOnDispose: false);
using var beforeScopedResetPoint = this.GetDisposableResetPoint(resetOnDispose: true);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no need to for dual reset points. and we can always trivially clean up. we just let the caller then eat the token if this function says it is a keyword.


var scopedKeyword = this.EatContextualToken(SyntaxKind.ScopedKeyword);

// trivial case. scoped ref/out/in is definitely the scoped keyword.
if (this.CurrentToken.Kind is (SyntaxKind.RefKeyword or SyntaxKind.OutKeyword or SyntaxKind.InKeyword))
return scopedKeyword;
// trivial case. scoped ref/out/in/this is definitely the scoped keyword. Note: the only actual legal
// cases are `scoped ref`, `scoped out`, and `scoped in`. But we detect and allow `scoped this`, `scoped
// params` and `scoped readonly` as well. These will be reported as errors later in binding.
if (IsParameterModifierExcludingScoped(this.CurrentToken))
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we intentionally allow a broader set than before. so you can say things like scoped this. This is caught later in the binder.

return true;

// More complex cases. We have to check for `scoped Type ...` now.
using var afterScopedResetPoint = this.GetDisposableResetPoint(resetOnDispose: false);

if (ScanType() is ScanTypeFlags.NotType ||
!isValidScopedTypeCase())
{
// We didn't see a type, or it wasn't a legal usage of a type. This is not a scoped-keyword. Rollback to
// before the keyword so the caller has to handle it.
beforeScopedResetPoint.Reset();
return null;
}

// We had a Type syntax in a supported production. Roll back to just after the scoped-keyword and
// return it successfully.
afterScopedResetPoint.Reset();
return scopedKeyword;
//
// Note that `scoped scoped` can be valid here as a type called scoped and a variable called scoped.
return ScanType() is not ScanTypeFlags.NotType && isValidScopedTypeCase();

bool isValidScopedTypeCase()
{
Expand All @@ -10574,6 +10581,15 @@ bool isValidScopedTypeCase()
}
}

private SyntaxToken ParsePossibleScopedKeyword(
bool isFunctionPointerParameter,
bool isLambdaParameter)
{
return IsDefiniteScopedModifier(isFunctionPointerParameter, isLambdaParameter)
? this.EatContextualToken(SyntaxKind.ScopedKeyword)
: null;
}

private VariableDesignationSyntax ParseDesignation(bool forPattern)
{
// the two forms of designation are
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -608,8 +608,9 @@ internal static void CheckParameterModifiers(
bool seenReadonly = false;

SyntaxToken? previousModifier = null;
foreach (var modifier in parameter.Modifiers)
for (int i = 0, n = parameter.Modifiers.Count; i < n; i++)
{
var modifier = parameter.Modifiers[i];
switch (modifier.Kind())
{
case SyntaxKind.ThisKeyword:
Expand Down Expand Up @@ -775,10 +776,27 @@ internal static void CheckParameterModifiers(

case SyntaxKind.ScopedKeyword when parameterContext is not ParameterContext.FunctionPointer:
ModifierUtils.CheckScopedModifierAvailability(parameter, modifier, diagnostics);
Debug.Assert(!seenIn);
Debug.Assert(!seenOut);
Debug.Assert(!seenRef);
Debug.Assert(!seenScoped);

if (seenScoped)
{
addERR_DupParamMod(diagnostics, modifier);
}
else if (seenIn || seenOut || seenRef || seenReadonly)
{
// Disallow parsing out 'scoped' once in/out/ref/readonly had been seen.
diagnostics.Add(ErrorCode.ERR_ScopedAfterInOutRefReadonly, modifier.GetLocation());
}
else if (i < n - 1)
{
// Only allow `scoped` followed by `ref/out/in` to actually be considered a valid modifier.
// Anything else is an error.
//
// Note we don't add an error in the case of 'scoped scoped' as that is already handled by
// seenScoped above.
var nextModifier = parameter.Modifiers[i + 1];
if (nextModifier.Kind() is not (SyntaxKind.RefKeyword or SyntaxKind.OutKeyword or SyntaxKind.InKeyword or SyntaxKind.ScopedKeyword))
diagnostics.Add(ErrorCode.ERR_InvalidModifierAfterScoped, nextModifier.GetLocation(), nextModifier.Text);
}

seenScoped = true;
break;
Expand Down Expand Up @@ -1165,7 +1183,6 @@ internal static RefKind GetModifiers(SyntaxTokenList modifiers, bool ignoreParam
thisKeyword = modifier;
break;
case SyntaxKind.ScopedKeyword:
Debug.Assert(refKind == RefKind.None);
isScoped = true;
break;
case SyntaxKind.ReadOnlyKeyword:
Expand Down
10 changes: 10 additions & 0 deletions src/Compilers/CSharp/Portable/xlf/CSharpResources.cs.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions src/Compilers/CSharp/Portable/xlf/CSharpResources.de.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions src/Compilers/CSharp/Portable/xlf/CSharpResources.es.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading