diff --git a/csharp/extractor/Semmle.Extraction.CSharp.Util/SymbolExtensions.cs b/csharp/extractor/Semmle.Extraction.CSharp.Util/SymbolExtensions.cs
index 92d7ecfad6bb..50604e2404e4 100644
--- a/csharp/extractor/Semmle.Extraction.CSharp.Util/SymbolExtensions.cs
+++ b/csharp/extractor/Semmle.Extraction.CSharp.Util/SymbolExtensions.cs
@@ -52,6 +52,13 @@ public static string GetName(this ISymbol symbol, bool useMetadataName = false)
{ "op_False", "false" }
});
+ ///
+ /// The operatorname for user-defined increment and decrement operators are "op_IncrementAssignment" and
+ /// "op_DecrementAssignment" respectively.
+ /// Thus we need to handle this explicitly to avoid postfixing them with an "=".
+ ///
+ private static bool isIncrementOrDecrement(string operatorName) => operatorName == "++" || operatorName == "--";
+
///
/// Convert an operator method name in to a symbolic name.
/// A return value indicates whether the conversion succeeded.
@@ -72,7 +79,7 @@ public static bool TryGetOperatorSymbol(this ISymbol symbol, out string operator
if (match.Success && methodToOperator.TryGetValue($"op_{match.Groups[2]}", out var rawOperatorName))
{
var prefix = match.Groups[1].Success ? "checked " : "";
- var postfix = match.Groups[3].Success ? "=" : "";
+ var postfix = match.Groups[3].Success && !isIncrementOrDecrement(rawOperatorName) ? "=" : "";
operatorName = $"{prefix}{rawOperatorName}{postfix}";
return true;
}
diff --git a/csharp/extractor/Semmle.Extraction.CSharp/Entities/Expression.cs b/csharp/extractor/Semmle.Extraction.CSharp/Entities/Expression.cs
index 4ab90def2c16..bf02ba49a2bd 100644
--- a/csharp/extractor/Semmle.Extraction.CSharp/Entities/Expression.cs
+++ b/csharp/extractor/Semmle.Extraction.CSharp/Entities/Expression.cs
@@ -234,9 +234,9 @@ type.SpecialType is SpecialType.System_IntPtr ||
///
/// The expression syntax node.
/// Returns the target method symbol, or null if it cannot be resolved.
- protected IMethodSymbol? GetTargetSymbol(ExpressionSyntax node)
+ protected static IMethodSymbol? GetTargetSymbol(Context cx, ExpressionSyntax node)
{
- var si = Context.GetSymbolInfo(node);
+ var si = cx.GetSymbolInfo(node);
if (si.Symbol is ISymbol symbol)
{
var method = symbol as IMethodSymbol;
@@ -255,7 +255,7 @@ type.SpecialType is SpecialType.System_IntPtr ||
.Where(method => method.Parameters.Length >= syntax.ArgumentList.Arguments.Count)
.Where(method => method.Parameters.Count(p => !p.HasExplicitDefaultValue) <= syntax.ArgumentList.Arguments.Count);
- return Context.ExtractionContext.IsStandalone ?
+ return cx.ExtractionContext.IsStandalone ?
candidates.FirstOrDefault() :
candidates.SingleOrDefault();
}
@@ -281,7 +281,7 @@ public static ExprKind UnaryOperatorKind(Context cx, ExprKind originalKind, Expr
/// The expression.
public void AddOperatorCall(TextWriter trapFile, ExpressionSyntax node)
{
- var @operator = GetTargetSymbol(node);
+ var @operator = GetTargetSymbol(Context, node);
if (@operator is IMethodSymbol method)
{
var callType = GetCallType(Context, node);
@@ -312,9 +312,9 @@ public enum CallType
/// The call type.
public static CallType GetCallType(Context cx, ExpressionSyntax node)
{
- var @operator = cx.GetSymbolInfo(node);
+ var @operator = GetTargetSymbol(cx, node);
- if (@operator.Symbol is IMethodSymbol method)
+ if (@operator is IMethodSymbol method)
{
if (method.ContainingSymbol is ITypeSymbol containingSymbol && containingSymbol.TypeKind == Microsoft.CodeAnalysis.TypeKind.Dynamic)
{
diff --git a/csharp/extractor/Semmle.Extraction.CSharp/Entities/Expressions/Invocation.cs b/csharp/extractor/Semmle.Extraction.CSharp/Entities/Expressions/Invocation.cs
index 343f288eeafe..5b25e53e8eef 100644
--- a/csharp/extractor/Semmle.Extraction.CSharp/Entities/Expressions/Invocation.cs
+++ b/csharp/extractor/Semmle.Extraction.CSharp/Entities/Expressions/Invocation.cs
@@ -44,7 +44,7 @@ protected override void PopulateExpression(TextWriter trapFile)
var child = -1;
string? memberName = null;
- var target = GetTargetSymbol(Syntax);
+ var target = GetTargetSymbol(Context, Syntax);
switch (Syntax.Expression)
{
case MemberAccessExpressionSyntax memberAccess when IsValidMemberAccessKind():
diff --git a/csharp/ql/lib/change-notes/2026-05-12-user-increment-decrement.md b/csharp/ql/lib/change-notes/2026-05-12-user-increment-decrement.md
new file mode 100644
index 000000000000..a840fdf4fe34
--- /dev/null
+++ b/csharp/ql/lib/change-notes/2026-05-12-user-increment-decrement.md
@@ -0,0 +1,4 @@
+---
+category: minorAnalysis
+---
+* C# 14: Added support for user-defined instance increment/decrement operators.
diff --git a/csharp/ql/lib/semmle/code/csharp/Callable.qll b/csharp/ql/lib/semmle/code/csharp/Callable.qll
index 9416a7d4d9c7..198ad2af1801 100644
--- a/csharp/ql/lib/semmle/code/csharp/Callable.qll
+++ b/csharp/ql/lib/semmle/code/csharp/Callable.qll
@@ -613,6 +613,9 @@ class UnaryOperator extends Operator {
this.getNumberOfParameters() = 1 and
not this instanceof ConversionOperator and
not this instanceof CompoundAssignmentOperator
+ or
+ // Instance increment and decrement operators don't have a parameter (only a qualifier).
+ this.getNumberOfParameters() = 0 and not this.isStatic()
}
}
diff --git a/csharp/ql/lib/semmle/code/csharp/dispatch/Dispatch.qll b/csharp/ql/lib/semmle/code/csharp/dispatch/Dispatch.qll
index 15a64d12b499..f1df963d72b8 100644
--- a/csharp/ql/lib/semmle/code/csharp/dispatch/Dispatch.qll
+++ b/csharp/ql/lib/semmle/code/csharp/dispatch/Dispatch.qll
@@ -73,6 +73,19 @@ class DispatchCall extends Internal::TDispatchCall {
}
}
+abstract private class InstanceOperatorCall extends OperatorCall {
+ abstract Expr getQualifier();
+}
+
+private class InstanceCompoundAssignment extends InstanceOperatorCall instanceof CompoundAssignmentOperatorCall
+{
+ override Expr getQualifier() { result = CompoundAssignmentOperatorCall.super.getQualifier() }
+}
+
+private class InstanceMutator extends InstanceOperatorCall instanceof InstanceMutatorOperatorCall {
+ override Expr getQualifier() { result = InstanceMutatorOperatorCall.super.getQualifier() }
+}
+
/** Internal implementation details. */
private module Internal {
private import OverridableCallable
@@ -101,9 +114,9 @@ private module Internal {
} or
TDispatchOperatorCall(OperatorCall oc) {
not oc.isLateBound() and
- not oc instanceof CompoundAssignmentOperatorCall
+ not oc instanceof InstanceOperatorCall
} or
- TDispatchCompoundAssignmentOperatorCall(CompoundAssignmentOperatorCall caoc) or
+ TDispatchInstanceOperatorCall(InstanceOperatorCall caoc) or
TDispatchReflectionCall(MethodCall mc, string name, Expr object, Expr qualifier, int args) {
isReflectionCall(mc, name, object, qualifier, args)
} or
@@ -890,12 +903,10 @@ private module Internal {
override Operator getAStaticTarget() { result = this.getCall().getTarget() }
}
- private class DispatchCompoundAssignmentOperatorCall extends DispatchOverridableCall,
- TDispatchCompoundAssignmentOperatorCall
+ private class DispatchInstanceOperatorCall extends DispatchOverridableCall,
+ TDispatchInstanceOperatorCall
{
- override CompoundAssignmentOperatorCall getCall() {
- this = TDispatchCompoundAssignmentOperatorCall(result)
- }
+ override InstanceOperatorCall getCall() { this = TDispatchInstanceOperatorCall(result) }
override Expr getArgument(int i) { result = this.getCall().getArgument(i) }
diff --git a/csharp/ql/lib/semmle/code/csharp/exprs/Call.qll b/csharp/ql/lib/semmle/code/csharp/exprs/Call.qll
index 9dbf898e2864..2ecbbc44a4d2 100644
--- a/csharp/ql/lib/semmle/code/csharp/exprs/Call.qll
+++ b/csharp/ql/lib/semmle/code/csharp/exprs/Call.qll
@@ -570,6 +570,29 @@ class MutatorOperatorCall extends OperatorCall {
predicate isPostfix() { mutator_invocation_mode(this, 2) }
}
+/**
+ * A call to an instance mutator operator, for example `a++` on
+ * line 5 in
+ *
+ * ```csharp
+ * class A {
+ * public void operator++() { ... }
+ *
+ * public static void Increment(A a) {
+ * a++;
+ * }
+ * }
+ * ```
+ */
+class InstanceMutatorOperatorCall extends MutatorOperatorCall {
+ InstanceMutatorOperatorCall() { this.getTarget().getNumberOfParameters() = 0 }
+
+ /** Gets the qualifier of this instance mutator operator call. */
+ Expr getQualifier() { result = this.getChildExpr(0) }
+
+ override Expr getArgument(int i) { none() }
+}
+
/**
* A call to a compound assignment operator, for example `this += other`
* on line 7 in
diff --git a/csharp/ql/test/library-tests/dataflow/operators/Operator.cs b/csharp/ql/test/library-tests/dataflow/operators/Operator.cs
index 5db1a82b9a4b..0b6aa2e8f90a 100644
--- a/csharp/ql/test/library-tests/dataflow/operators/Operator.cs
+++ b/csharp/ql/test/library-tests/dataflow/operators/Operator.cs
@@ -120,3 +120,36 @@ public void M1()
Sink(x.Field); // $ hasValueFlow=1
}
}
+
+public class MutatorOperators
+{
+ static void Sink(object o) { }
+ static T Source(object source) => throw null;
+
+ public class C1
+ {
+ public object Field { get; private set; }
+
+ public C1()
+ {
+ Field = new object();
+ }
+
+ public C1(object o)
+ {
+ Field = o;
+ }
+
+ public void operator ++()
+ {
+ Field = Source