Skip to content
1 change: 0 additions & 1 deletion docs/parameter-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ Application-facing parameter DSL:
- optional per-parameter `CaseSensitivity`
- optional `Arity`
- `ReplArgumentAttribute`
- optional positional `Position`
- `Mode`
- `ReplValueAliasAttribute`
- maps a token to an injected parameter value (for example `--json` -> `output=json`)
Expand Down
11 changes: 9 additions & 2 deletions src/Repl.Core/Documentation/DocumentationEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,13 @@ internal ReplDocApp BuildDocumentationApp()
return new ReplDocApp(name, version, description);
}

// An explicit OneOrMore/ExactlyOne arity now fails binding when the option is absent
// (HandlerArgumentBinder), so exported docs must report it as required regardless of the
// CLR parameter shape.
private static bool HasExplicitRequiredArity(OptionSchema schema, string parameterName) =>
schema.TryGetParameter(parameterName, out var schemaParameter)
&& schemaParameter.ExplicitArity is ReplArity.OneOrMore or ReplArity.ExactlyOne;

private static bool IsRequiredParameter(ParameterInfo parameter)
{
if (parameter.HasDefaultValue)
Expand Down Expand Up @@ -406,7 +413,7 @@ private static ReplDocOption BuildDocumentationOptionFromProperty(
return new ReplDocOption(
Name: aliases.Length > 0 ? aliases[0].TrimStart('-') : property.Name,
Type: GetFriendlyTypeName(property.PropertyType),
Required: false,
Required: HasExplicitRequiredArity(schema, property.Name),
Description: property.GetCustomAttribute<DescriptionAttribute>()?.Description,
Aliases: aliases,
ReverseAliases: reverseAliases,
Expand Down Expand Up @@ -446,7 +453,7 @@ private static ReplDocOption BuildDocumentationOption(OptionSchema schema, Param
return new ReplDocOption(
Name: aliases.Length > 0 ? aliases[0].TrimStart('-') : parameter.Name!,
Type: GetFriendlyTypeName(parameter.ParameterType),
Required: IsRequiredParameter(parameter),
Required: IsRequiredParameter(parameter) || HasExplicitRequiredArity(schema, parameter.Name!),
Description: parameter.GetCustomAttribute<DescriptionAttribute>()?.Description,
Aliases: aliases,
ReverseAliases: reverseAliases,
Expand Down
33 changes: 32 additions & 1 deletion src/Repl.Core/Internal/Options/OptionSchema.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,11 @@ public OptionSchema(
// Benign race: concurrent first reads compute the same array.
private string[]? _knownTokens;

// Ordinal dedup: case-differing tokens can belong to DIFFERENT case-sensitive options
// (per-entry overrides), so collapsing them ignoring case would drop a real token and
// make "Did you mean" suggest the wrong casing.
public IReadOnlyCollection<string> KnownTokens =>
_knownTokens ??= [.. Entries.Select(entry => entry.Token).Distinct(StringComparer.OrdinalIgnoreCase)];
_knownTokens ??= [.. Entries.Select(entry => entry.Token).Distinct(StringComparer.Ordinal)];

public IReadOnlyList<OptionSchemaEntry> ResolveToken(string token, ReplCaseSensitivity globalCaseSensitivity)
{
Expand All @@ -50,4 +53,32 @@ public IReadOnlyList<OptionSchemaEntry> ResolveToken(string token, ReplCaseSensi

public bool TryGetParameter(string parameterName, out OptionSchemaParameter parameter) =>
Parameters.TryGetValue(parameterName, out parameter!);

/// <summary>
/// Effective arity of a parameter, resolved from its named-option/flag entry;
/// parameters without such an entry default to the permissive <see cref="ReplArity.ZeroOrMore"/>.
/// </summary>
public ReplArity ResolveParameterArity(string parameterName) =>
FindNamedEntry(parameterName)?.Arity ?? ReplArity.ZeroOrMore;

/// <summary>
/// Canonical display token of a parameter's named-option/flag entry (with prefix),
/// or null for parameters without one (e.g. ArgumentOnly).
/// </summary>
public string? ResolveDisplayToken(string parameterName) =>
FindNamedEntry(parameterName)?.Token;

private OptionSchemaEntry? FindNamedEntry(string parameterName)
{
foreach (var entry in Entries)
{
if (string.Equals(entry.ParameterName, parameterName, StringComparison.OrdinalIgnoreCase)
&& entry.TokenKind is OptionSchemaTokenKind.NamedOption or OptionSchemaTokenKind.BoolFlag)
{
return entry;
}
}

return null;
}
}
82 changes: 52 additions & 30 deletions src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ public static OptionSchema Build(
}

ValidatePositionalBindingCompatibility(regularPositionalParameterNames, groupPositionalPropertyNames);
ValidateTokenCollisions(entries, parsingOptions);
ValidateTokenCollisions(entries, parsingOptions, template);
return new OptionSchema(entries, parameters);
}

Expand Down Expand Up @@ -112,14 +112,15 @@ private static void AppendParameterSchemaEntries(
if (parameters.ContainsKey(parameter.Name!))
{
throw new InvalidOperationException(
$"Option token collision detected for parameter name '{parameter.Name}'.");
$"Duplicate parameter name '{parameter.Name}' in the command signature.");
}

parameters[parameter.Name!] = new OptionSchemaParameter(
parameter.Name!,
parameter.ParameterType,
mode,
CaseSensitivity: optionAttribute?.CaseSensitivity);
CaseSensitivity: optionAttribute?.CaseSensitivityOverride,
ExplicitArity: optionAttribute?.ArityOverride);
Comment thread
carldebilly marked this conversation as resolved.
if (mode == ReplParameterMode.ArgumentOnly)
{
return;
Expand All @@ -133,7 +134,7 @@ private static void AppendParameterSchemaEntries(
parameter.Name!,
tokenKind,
arity,
CaseSensitivity: optionAttribute?.CaseSensitivity));
CaseSensitivity: optionAttribute?.CaseSensitivityOverride));
Comment thread
carldebilly marked this conversation as resolved.
Comment thread
carldebilly marked this conversation as resolved.
AppendOptionAliases(parameter, tokenKind, arity, optionAttribute, entries);
AppendReverseAliases(parameter, optionAttribute, entries);
AppendValueAliases(parameter, optionAttribute, entries);
Expand Down Expand Up @@ -168,7 +169,7 @@ private static void AppendOptionAliases(
parameter.Name!,
tokenKind,
arity,
CaseSensitivity: optionAttribute?.CaseSensitivity));
CaseSensitivity: optionAttribute?.CaseSensitivityOverride));
}
}

Expand All @@ -185,7 +186,7 @@ private static void AppendReverseAliases(
parameter.Name!,
OptionSchemaTokenKind.ReverseFlag,
ReplArity.ZeroOrOne,
CaseSensitivity: optionAttribute?.CaseSensitivity,
CaseSensitivity: optionAttribute?.CaseSensitivityOverride,
InjectedValue: "false"));
}
}
Expand All @@ -203,14 +204,14 @@ private static void AppendValueAliases(
parameter.Name!,
OptionSchemaTokenKind.ValueAlias,
ReplArity.ZeroOrOne,
CaseSensitivity: valueAlias.CaseSensitivity ?? optionAttribute?.CaseSensitivity,
CaseSensitivity: valueAlias.CaseSensitivityOverride ?? optionAttribute?.CaseSensitivityOverride,
InjectedValue: valueAlias.Value));
}
}

private static ReplArity ResolveArity(ParameterInfo parameter, ReplOptionAttribute? optionAttribute)
{
if (optionAttribute?.Arity is { } explicitArity)
if (optionAttribute?.ArityOverride is { } explicitArity)
{
return explicitArity;
Comment thread
carldebilly marked this conversation as resolved.
}
Expand Down Expand Up @@ -293,7 +294,7 @@ private static void AppendEnumAliases(
parameter.Name!,
OptionSchemaTokenKind.EnumAlias,
ReplArity.ZeroOrOne,
CaseSensitivity: enumFlag.CaseSensitivity ?? optionAttribute?.CaseSensitivity,
CaseSensitivity: enumFlag.CaseSensitivityOverride ?? optionAttribute?.CaseSensitivityOverride,
InjectedValue: field.Name));
}
}
Expand Down Expand Up @@ -405,14 +406,15 @@ private static void AppendPropertySchemaEntries(
if (parameters.ContainsKey(property.Name))
{
throw new InvalidOperationException(
$"Option token collision detected for parameter name '{property.Name}'.");
$"Duplicate parameter name '{property.Name}' between options group '{property.DeclaringType?.Name}' and the command signature.");
}

parameters[property.Name] = new OptionSchemaParameter(
property.Name,
property.PropertyType,
mode,
CaseSensitivity: optionAttribute?.CaseSensitivity);
CaseSensitivity: optionAttribute?.CaseSensitivityOverride,
ExplicitArity: optionAttribute?.ArityOverride);
if (mode != ReplParameterMode.OptionOnly)
{
positionalPropertyNames.Add(property.Name);
Expand All @@ -430,7 +432,7 @@ private static void AppendPropertySchemaEntries(
property.Name,
tokenKind,
arity,
CaseSensitivity: optionAttribute?.CaseSensitivity));
CaseSensitivity: optionAttribute?.CaseSensitivityOverride));
AppendPropertyOptionAliases(property.Name, tokenKind, arity, optionAttribute, entries);
AppendPropertyReverseAliases(property.Name, optionAttribute, entries);
AppendPropertyValueAliases(property, optionAttribute, entries);
Expand All @@ -439,7 +441,7 @@ private static void AppendPropertySchemaEntries(

private static ReplArity ResolvePropertyArity(Type propertyType, ReplOptionAttribute? optionAttribute)
{
if (optionAttribute?.Arity is { } explicitArity)
if (optionAttribute?.ArityOverride is { } explicitArity)
{
return explicitArity;
Comment thread
carldebilly marked this conversation as resolved.
}
Expand Down Expand Up @@ -472,7 +474,7 @@ private static void AppendPropertyOptionAliases(
propertyName,
tokenKind,
arity,
CaseSensitivity: optionAttribute?.CaseSensitivity));
CaseSensitivity: optionAttribute?.CaseSensitivityOverride));
}
}

Expand All @@ -489,7 +491,7 @@ private static void AppendPropertyReverseAliases(
propertyName,
OptionSchemaTokenKind.ReverseFlag,
ReplArity.ZeroOrOne,
CaseSensitivity: optionAttribute?.CaseSensitivity,
CaseSensitivity: optionAttribute?.CaseSensitivityOverride,
InjectedValue: "false"));
}
}
Expand All @@ -507,7 +509,7 @@ private static void AppendPropertyValueAliases(
property.Name,
OptionSchemaTokenKind.ValueAlias,
ReplArity.ZeroOrOne,
CaseSensitivity: valueAlias.CaseSensitivity ?? optionAttribute?.CaseSensitivity,
CaseSensitivity: valueAlias.CaseSensitivityOverride ?? optionAttribute?.CaseSensitivityOverride,
InjectedValue: valueAlias.Value));
}
}
Expand Down Expand Up @@ -541,37 +543,57 @@ private static void AppendPropertyEnumAliases(
property.Name,
OptionSchemaTokenKind.EnumAlias,
ReplArity.ZeroOrOne,
CaseSensitivity: enumFlag.CaseSensitivity ?? optionAttribute?.CaseSensitivity,
CaseSensitivity: enumFlag.CaseSensitivityOverride ?? optionAttribute?.CaseSensitivityOverride,
InjectedValue: field.Name));
}
}
}

private static void ValidateTokenCollisions(
IReadOnlyList<OptionSchemaEntry> entries,
ParsingOptions parsingOptions)
ParsingOptions parsingOptions,
RouteTemplate template)
{
var comparer = parsingOptions.OptionCaseSensitivity == ReplCaseSensitivity.CaseInsensitive
? StringComparer.OrdinalIgnoreCase
: StringComparer.Ordinal;
var map = new Dictionary<string, OptionSchemaEntry>(comparer);
// Collisions are decided per PAIR under each entry's effective case sensitivity, not
// under the global comparer alone: ordinal-equal tokens always collide, while tokens
// differing only by casing collide only when BOTH entries are effectively
// case-insensitive (full mutual shadowing — no typed token can distinguish them).
// Two explicitly case-sensitive entries stay distinguishable ordinally even under a
// global case-insensitive default, and a mixed pair keeps per-token runtime
// resolution (the parser's "Ambiguous option" diagnostic) instead of failing
// registration. Buckets are keyed ignoring case to surface every candidate pair.
var map = new Dictionary<string, List<OptionSchemaEntry>>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in entries)
{
if (!map.TryGetValue(entry.Token, out var existing))
if (!map.TryGetValue(entry.Token, out var bucket))
{
map[entry.Token] = entry;
map[entry.Token] = [entry];
continue;
}

if (string.Equals(existing.ParameterName, entry.ParameterName, StringComparison.OrdinalIgnoreCase)
&& existing.TokenKind == entry.TokenKind
&& string.Equals(existing.InjectedValue, entry.InjectedValue, StringComparison.Ordinal))
foreach (var existing in bucket)
{
continue;
var ordinalEqual = string.Equals(existing.Token, entry.Token, StringComparison.Ordinal);
var bothInsensitive =
(existing.CaseSensitivity ?? parsingOptions.OptionCaseSensitivity) == ReplCaseSensitivity.CaseInsensitive
&& (entry.CaseSensitivity ?? parsingOptions.OptionCaseSensitivity) == ReplCaseSensitivity.CaseInsensitive;
if (!ordinalEqual && !bothInsensitive)
{
continue;
Comment thread
carldebilly marked this conversation as resolved.
}

if (string.Equals(existing.ParameterName, entry.ParameterName, StringComparison.OrdinalIgnoreCase)
&& existing.TokenKind == entry.TokenKind
&& string.Equals(existing.InjectedValue, entry.InjectedValue, StringComparison.Ordinal))
{
continue;
}

throw new InvalidOperationException(
$"Option token collision detected for '{entry.Token}' between '{existing.ParameterName}' and '{entry.ParameterName}' while registering command '{template.Template}'.");
}

throw new InvalidOperationException(
$"Option token collision detected for '{entry.Token}' between '{existing.ParameterName}' and '{entry.ParameterName}'.");
bucket.Add(entry);
}
}
}
3 changes: 2 additions & 1 deletion src/Repl.Core/Internal/Options/OptionSchemaParameter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ internal sealed record OptionSchemaParameter(
string Name,
Type ParameterType,
ReplParameterMode Mode,
ReplCaseSensitivity? CaseSensitivity = null);
ReplCaseSensitivity? CaseSensitivity = null,
ReplArity? ExplicitArity = null);
5 changes: 0 additions & 5 deletions src/Repl.Core/Parameters/Attributes/ReplArgumentAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,6 @@ namespace Repl.Parameters;
[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property, AllowMultiple = false, Inherited = true)]
public sealed class ReplArgumentAttribute : Attribute
{
/// <summary>
/// Optional explicit position for positional binding.
/// </summary>
public int? Position { get; set; }

/// <summary>
/// Binding mode for the parameter.
/// </summary>
Expand Down
19 changes: 18 additions & 1 deletion src/Repl.Core/Parameters/Attributes/ReplEnumFlagAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,25 @@ public ReplEnumFlagAttribute(params string[] aliases)
/// </summary>
public string[] Aliases { get; }

// Nullable enums are not legal attribute named arguments (CS0655), so the optional
// override exposes a non-nullable property and tracks the unset state in a nullable
// backing field surfaced through the read-only CaseSensitivityOverride property.
private ReplCaseSensitivity? _caseSensitivity;

/// <summary>
/// Optional case-sensitivity override for these aliases.
/// Only an explicit assignment overrides the global parsing default; when unset, the getter
/// returns the enum default and does not reflect the effective behavior — read
/// <see cref="CaseSensitivityOverride"/> to distinguish unset from an explicit value.
/// </summary>
public ReplCaseSensitivity CaseSensitivity
{
get => _caseSensitivity ?? default;
set => _caseSensitivity = value;
}

/// <summary>
/// Explicit case-sensitivity override, or null to inherit the global default.
/// </summary>
public ReplCaseSensitivity? CaseSensitivity { get; set; }
public ReplCaseSensitivity? CaseSensitivityOverride => _caseSensitivity;
}
34 changes: 32 additions & 2 deletions src/Repl.Core/Parameters/Attributes/ReplOptionAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,43 @@ public sealed class ReplOptionAttribute : Attribute
/// </summary>
public ReplParameterMode Mode { get; set; } = ReplParameterMode.OptionAndPositional;

// Nullable enums are not legal attribute named arguments (CS0655), so the optional
// overrides expose a non-nullable property and track the unset state in a nullable
// backing field surfaced through the read-only *Override properties.
private ReplCaseSensitivity? _caseSensitivity;
private ReplArity? _arity;

/// <summary>
/// Optional case-sensitivity override for this option.
/// Only an explicit assignment overrides the global parsing default; when unset, the getter
/// returns the enum default and does not reflect the effective behavior — read
/// <see cref="CaseSensitivityOverride"/> to distinguish unset from an explicit value.
/// </summary>
public ReplCaseSensitivity CaseSensitivity
{
get => _caseSensitivity ?? default;
set => _caseSensitivity = value;
}

/// <summary>
/// Explicit case-sensitivity override, or null to inherit the global default.
/// </summary>
public ReplCaseSensitivity? CaseSensitivity { get; set; }
public ReplCaseSensitivity? CaseSensitivityOverride => _caseSensitivity;

/// <summary>
/// Optional arity override.
/// Only an explicit assignment overrides the arity inferred from the parameter shape; when
/// unset, the getter returns the enum default and does not reflect the effective arity — read
/// <see cref="ArityOverride"/> to distinguish unset from an explicit value.
/// </summary>
public ReplArity Arity
{
get => _arity ?? default;
set => _arity = value;
}

/// <summary>
/// Explicit arity override, or null to use the arity inferred from the parameter shape.
/// </summary>
public ReplArity? Arity { get; set; }
public ReplArity? ArityOverride => _arity;
}
Loading
Loading