diff --git a/src/Nullean.Argh.Generator/CliParserGenerator.Analysis.cs b/src/Nullean.Argh.Generator/CliParserGenerator.Analysis.cs new file mode 100644 index 0000000..225d1fe --- /dev/null +++ b/src/Nullean.Argh.Generator/CliParserGenerator.Analysis.cs @@ -0,0 +1,1460 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Globalization; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Operations; +using Microsoft.CodeAnalysis.Text; + +namespace Nullean.Argh; + +public sealed partial class CliParserGenerator +{ + private static AnalyzedInvocation? AnalyzeInvocation( + InvocationExpressionSyntax invocation, + SemanticModel semanticModel, + CancellationToken ct) + { + if (semanticModel.GetSymbolInfo(invocation, ct).Symbol is not IMethodSymbol method) + return null; + + var filePath = invocation.SyntaxTree.FilePath; + var spanStart = invocation.SpanStart; + var parseOpts = invocation.SyntaxTree.Options as CSharpParseOptions ?? CSharpParseOptions.Default; + + switch (method.Name) + { + case "UseGlobalOptions" when method.IsGenericMethod && method.TypeArguments.Length > 0: + { + if (method.TypeArguments[0] is not INamedTypeSymbol go || go.TypeKind == TypeKind.Error) + return null; + var model = BuildOptionsTypeModel(go, semanticModel.Compilation); + if (model is null) return null; + return new AIUseGlobalOptions(filePath, spanStart, model); + } + case "UseNamespaceOptions" when method.IsGenericMethod && method.TypeArguments.Length > 0: + { + if (method.TypeArguments[0] is not INamedTypeSymbol gt || gt.TypeKind == TypeKind.Error) + return null; + var model = BuildOptionsTypeModel(gt, semanticModel.Compilation); + if (model is null) return null; + return new AIUseNamespaceOptions(filePath, spanStart, model); + } + case "UseMiddleware" when method.IsGenericMethod && method.TypeArguments.Length == 1: + { + if (method.TypeArguments[0] is not INamedTypeSymbol mwType || mwType.TypeKind == TypeKind.Error) + return null; + var reg = new GlobalMiddlewareRegistration( + mwType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + HasPublicParameterlessCtor(mwType)); + return new AIUseMiddleware(filePath, spanStart, reg); + } + case "UseMiddleware": + // Inline delegate — diagnostic will be reported by TryBuildAppEmitModel (option 2 from plan). + return new AIUseMiddleware(filePath, spanStart, new GlobalMiddlewareRegistration("", false)); + case "Map" when method.IsGenericMethod && method.TypeArguments.Length > 0: + { + if (method.TypeArguments[0] is not INamedTypeSymbol named || named.TypeKind == TypeKind.Error) + return null; + // Always hoist: merge the type's methods directly into the current scope (root or namespace). + var acc = new DiagnosticAccumulator(); + var wrapper = new RegistryNode(); + ExpandTypeRegistrationAcc(acc, invocation.GetLocation(), named, ImmutableArray.Empty, mergeOuterTypeSegment: true, wrapper, parseOpts, semanticModel.Compilation); + var snap = BuildRegistryNodeSnapshot(wrapper); + return new AIMapCommand(filePath, spanStart, ImmutableArray.Empty, TypeSnapshot: snap, EmbeddedDiagnostics: acc.ToImmutable()); + } + case "MapAndRootAlias" when method.IsGenericMethod && method.TypeArguments.Length > 0: + { + if (method.TypeArguments[0] is not INamedTypeSymbol named || named.TypeKind == TypeKind.Error) + return null; + var acc = new DiagnosticAccumulator(); + var wrapper = new RegistryNode(); + AddMethodsFromTypeAccForAlias(acc, invocation.GetLocation(), named, ImmutableArray.Empty, wrapper, parseOpts, semanticModel.Compilation); + var snap = BuildRegistryNodeSnapshot(wrapper); + return new AIMapAndRootAlias(filePath, spanStart, snap, acc.ToImmutable()); + } + case "Map" when invocation.ArgumentList.Arguments.Count >= 2: + { + var nameExpr = invocation.ArgumentList.Arguments[0].Expression; + var commandName = TryGetStringLiteral(nameExpr); + if (commandName is null || string.IsNullOrWhiteSpace(commandName)) + return null; + var handlerExpr = invocation.ArgumentList.Arguments[1].Expression; + if (handlerExpr is LambdaExpressionSyntax) + { + var node = new RegistryNode(); + TryExpandLambdaDelegateAcc(semanticModel, invocation, handlerExpr, commandName, ImmutableArray.Empty, node); + if (node.Commands.Count == 0) return null; + return new AIMapCommand(filePath, spanStart, node.Commands.ToImmutableArray()); + } + var handler = ResolveHandlerMethodForAnalyze(semanticModel, handlerExpr); + if (handler is null) return null; + var acc2 = new DiagnosticAccumulator(); + var cmd = CommandModel.FromMethod(commandName, handler, parseOpts, ImmutableArray.Empty, acc2, invocation.GetLocation(), semanticModel.Compilation); + return new AIMapCommand(filePath, spanStart, ImmutableArray.Create(cmd), EmbeddedDiagnostics: acc2.ToImmutable()); + } + case "UseCliDescription": + { + if (invocation.ArgumentList.Arguments.Count < 1) return null; + var descExpr = invocation.ArgumentList.Arguments[0].Expression; + var desc = TryGetStringLiteral(descExpr) ?? ""; + return new AIUseCliDescription(filePath, spanStart, desc); + } + case "UseSchemaVersion": + { + if (invocation.ArgumentList.Arguments.Count < 1) return null; + var verExpr = invocation.ArgumentList.Arguments[0].Expression; + var ver = TryGetStringLiteral(verExpr); + if (string.IsNullOrWhiteSpace(ver)) return null; + return new AIUseSchemaVersion(filePath, spanStart, ver!); + } + case "DocumentEnvironmentVariables": + return AnalyzeDocumentEnvironmentVariables(invocation, filePath, spanStart); + case "MapRoot": + { + if (invocation.ArgumentList.Arguments.Count < 1) return null; + var isNs = IsInvocationInsideMapNamespaceConfigure(invocation); + return AnalyzeMapRootInvocation(invocation, semanticModel, filePath, spanStart, parseOpts, isNamespaceRoot: isNs); + } + case "MapNamespace": + return AnalyzeMapNamespaceInvocation(invocation, semanticModel, filePath, spanStart, parseOpts, ct); + default: + return null; + } + } + + private static bool IsInvocationInsideMapNamespaceConfigure(InvocationExpressionSyntax invocation) + { + for (var n = invocation.Parent; n != null; n = n.Parent) + { + if (n is LambdaExpressionSyntax lambda && IsMapNamespaceConfigureLambda(lambda, out _)) + return true; + } + + return false; + } + + private static AIMapRootCommand? AnalyzeMapRootInvocation( + InvocationExpressionSyntax invocation, + SemanticModel semanticModel, + string filePath, + int spanStart, + CSharpParseOptions parseOpts, + bool isNamespaceRoot) + { + if (invocation.ArgumentList.Arguments.Count < 1) return null; + var handlerExpr = invocation.ArgumentList.Arguments[0].Expression; + if (handlerExpr is LambdaExpressionSyntax) + { + var node = new RegistryNode(); + TryExpandLambdaRootCommandAcc(semanticModel, invocation, handlerExpr, ImmutableArray.Empty, node); + if (node.RootCommand is null) return null; + return new AIMapRootCommand(filePath, spanStart, node.RootCommand, isNamespaceRoot); + } + var handler = ResolveHandlerMethodForAnalyze(semanticModel, handlerExpr); + if (handler is null) return null; + var acc = new DiagnosticAccumulator(); + var cmd = CommandModel.FromRootMethod(handler, parseOpts, ImmutableArray.Empty, acc, invocation.GetLocation(), semanticModel.Compilation); + return new AIMapRootCommand(filePath, spanStart, cmd, isNamespaceRoot); + } + + /// Resolves a method from a handler expression without reporting diagnostics — returns null on failure. + private static IMethodSymbol? ResolveHandlerMethodForAnalyze(SemanticModel model, ExpressionSyntax handlerExpr) + { + var symbol = model.GetSymbolInfo(handlerExpr).Symbol; + if (symbol is IMethodSymbol m) return m; + + var op = model.GetOperation(handlerExpr); + while (op is IConversionOperation conv) + op = conv.Operand; + + if (op is IMethodReferenceOperation directRef) return directRef.Method; + if (op is IDelegateCreationOperation del && del.Target is IMethodReferenceOperation reference) return reference.Method; + + return null; // handler not a method — diagnostic will be reported by old path / TryBuildAppEmitModel + } + + private static AIMapNamespace? AnalyzeMapNamespaceInvocation( + InvocationExpressionSyntax invocation, + SemanticModel semanticModel, + string filePath, + int spanStart, + CSharpParseOptions parseOpts, + CancellationToken ct) + { + if (invocation.ArgumentList.Arguments.Count < 1) + return null; + + if (semanticModel.GetSymbolInfo(invocation, ct).Symbol is not IMethodSymbol addNsMethod || addNsMethod.Name != "MapNamespace") + return null; + + var genericEntry = addNsMethod.IsGenericMethod && addNsMethod.TypeArguments.Length == 1; + var namespaceEntryType = genericEntry && addNsMethod.TypeArguments[0] is INamedTypeSymbol nt && nt.TypeKind != TypeKind.Error + ? nt + : null; + + var argCount = invocation.ArgumentList.Arguments.Count; + string? segmentName = null; + var nsSummary = ""; + var nsSummaryXml = ""; + var nsRemarksXml = ""; + var isArgless = false; + + if (genericEntry && argCount == 1 && namespaceEntryType is not null) + { + var firstExpr = invocation.ArgumentList.Arguments[0].Expression; + var strOnly = TryGetStringLiteral(firstExpr) ?? TryGetStringConstant(semanticModel, firstExpr); + if (strOnly is not null && !string.IsNullOrWhiteSpace(strOnly)) + { + // AddNamespace("segment") — no configure callback + segmentName = strOnly; + nsSummary = GetTypeListingSummaryOneLiner(namespaceEntryType); + } + else + { + // AddNamespace(Action) — segment from attribute/XML + if (!TryGetNamespaceSegmentAttribute(namespaceEntryType, out var attrSeg) && + !TryGetFirstCodeInTypeSummary(namespaceEntryType, out attrSeg)) + return null; // can't determine segment — will be caught as AGH0017 in old path + segmentName = attrSeg; + nsSummary = GetTypeListingSummaryOneLiner(namespaceEntryType); + isArgless = true; + } + } + else if (genericEntry && argCount >= 2 && namespaceEntryType is not null) + { + segmentName = TryGetStringLiteral(invocation.ArgumentList.Arguments[0].Expression); + if (string.IsNullOrWhiteSpace(segmentName)) + return null; + nsSummary = GetTypeListingSummaryOneLiner(namespaceEntryType); + } + else if (!genericEntry && argCount >= 3) + { + segmentName = TryGetStringLiteral(invocation.ArgumentList.Arguments[0].Expression); + if (string.IsNullOrWhiteSpace(segmentName)) + return null; + var desc = TryGetStringConstant(semanticModel, invocation.ArgumentList.Arguments[1].Expression); + nsSummary = desc ?? ""; + } + else + { + return null; // AGH0014 emitted in old path + } + + // Get XML docs if entry type is available. + if (namespaceEntryType is not null) + { + var typeXml = namespaceEntryType.GetDocumentationCommentXml(); + if (string.IsNullOrWhiteSpace(typeXml)) + typeXml = TryExtractFullDocumentationFromTypeTrivia(namespaceEntryType); + var (sx, rx) = Documentation.GetTypeDocumentation(typeXml); + nsSummaryXml = sx; + nsRemarksXml = rx; + } + + // Determine the lambda body span for positional child lookup. + var lambdaBodyStart = -1; + var lambdaBodyEnd = -1; + // The last argument is the configure lambda (if it exists) + var lastArg = invocation.ArgumentList.Arguments.LastOrDefault(); + if (lastArg?.Expression is LambdaExpressionSyntax lambdaSyntax) + { + lambdaBodyStart = lambdaSyntax.Body.SpanStart; + lambdaBodyEnd = lambdaSyntax.Body.Span.End; + } + + // Pre-compute entry type snapshot (commands from the type, nested classes as child namespaces). + RegistryNodeSnapshot? entryTypeSnapshot = null; + if (namespaceEntryType is not null) + { + var acc = new DiagnosticAccumulator(); + var entryNode = new RegistryNode(); + // Use mergeOuterTypeSegment=true — expand the type's own methods + nested classes + ExpandTypeRegistrationAcc(acc, invocation.GetLocation(), namespaceEntryType, ImmutableArray.Empty, mergeOuterTypeSegment: true, entryNode, parseOpts, semanticModel.Compilation); + entryTypeSnapshot = BuildRegistryNodeSnapshot(entryNode); + } + + return new AIMapNamespace( + filePath, + spanStart, + segmentName!, + lambdaBodyStart, + lambdaBodyEnd, + namespaceEntryType?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + isArgless, + nsSummary, + nsSummaryXml, + nsRemarksXml, + HasEntryType: namespaceEntryType is not null, + SourceSpanInfo.From(invocation.GetLocation()), + ImmutableArray.Empty, + entryTypeSnapshot); + } + + /// Recursively expands type registration using DiagnosticAccumulator (for Select-step analysis). + private static void ExpandTypeRegistrationAcc( + DiagnosticAccumulator acc, + Location location, + INamedTypeSymbol type, + ImmutableArray routePrefix, + bool mergeOuterTypeSegment, + RegistryNode attachTo, + CSharpParseOptions parseOpts, + Compilation? compilation) + { + if (mergeOuterTypeSegment) + { + AddMethodsFromTypeAcc(acc, location, type, routePrefix, attachTo, parseOpts, compilation); + } + else + { + var seg = Naming.ToTypeSegmentName(type.Name); + var wrapper = new RegistryNode(); + var outerPrefix = AppendSegment(routePrefix, seg); + ExpandTypeRegistrationAcc(acc, location, type, outerPrefix, mergeOuterTypeSegment: true, wrapper, parseOpts, compilation); + attachTo.Children.Add(new RegistryNode.NamedCommandNamespaceChild + { + Segment = seg, + Node = wrapper, + SummaryOneLiner = GetTypeListingSummaryOneLiner(type), + Location = location + }); + } + } + + /// Converts a RegistryNode to a symbol-free RegistryNodeSnapshot. + private static RegistryNodeSnapshot BuildRegistryNodeSnapshot(RegistryNode node) + { + var children = ImmutableArray.CreateBuilder(node.Children.Count); + foreach (var ch in node.Children) + children.Add(new ChildNamespaceSnapshot(ch.Segment, BuildRegistryNodeSnapshot(ch.Node), ch.SummaryOneLiner)); + return new RegistryNodeSnapshot( + node.RootCommand, + node.Commands.ToImmutableArray(), + children.ToImmutable(), + node.SummaryInnerXml, + node.RemarksInnerXml, + AliasCommand: node.RootAlias); + } + + // ───────────────────────────────────────────────────────────────────────────── + + + /// + /// Overload that builds the emit model from pre-analyzed (symbol-free) invocations — used by the truly incremental pipeline. + /// + private static bool TryBuildAppEmitModel( + SourceProductionContext context, + ImmutableArray allAnalyzed, + out AppEmitModel? model) + { + model = null; + + // Report any embedded diagnostics collected during AnalyzeInvocation. + foreach (var ai in allAnalyzed) + { + if (ai is AIMapNamespace ns) + foreach (var pd in ns.EmbeddedDiagnostics) + context.ReportDiagnostic(Diagnostic.Create(GetDescriptorById(pd.DescriptorId), pd.Span.ToLocation(), pd.Arg0, pd.Arg1)); + } + + var sorted = allAnalyzed + .OrderBy(a => a.FilePath, StringComparer.Ordinal) + .ThenBy(a => a.SpanStart) + .ToList(); + + // Identify root-level invocations: those NOT contained inside any AIMapNamespace lambda body. + var rootAnalyzed = new List(); + foreach (var ai in sorted) + { + if (!IsInsideAnyMapNamespaceLambda(ai, sorted)) + rootAnalyzed.Add(ai); + } + + var app = new AppEmitModel(); + + // Collect global middleware from root UseMiddleware invocations. + var mwBuilder = ImmutableArray.CreateBuilder(); + foreach (var ai in rootAnalyzed) + { + if (ai is AIUseMiddleware { Registration: { TypeFq: { Length: > 0 } } reg }) + mwBuilder.Add(reg); + else if (ai is AIUseMiddleware { Registration: { TypeFq: "" } }) + context.ReportDiagnostic(Diagnostic.Create(UseMiddlewareDelegateNotSupported, ai.GetType() == typeof(AIUseMiddleware) ? Location.None : Location.None)); + } + app.GlobalMiddleware = mwBuilder.ToImmutable(); + + foreach (var ai in rootAnalyzed) + { + if (ai is AIUseCliDescription { Description: var desc } && !string.IsNullOrWhiteSpace(desc)) + { + app.RootSummary = desc; + break; + } + } + + foreach (var ai in rootAnalyzed) + { + if (ai is AIDocumentEnvironmentVariables { Variables: var vars, ConfigFiles: var cfgs }) + { + if (!vars.IsDefaultOrEmpty) app.EnvironmentVars = vars; + if (!cfgs.IsDefaultOrEmpty) app.ConfigFiles = cfgs; + break; + } + } + + foreach (var ai in rootAnalyzed) + { + if (ai is AIUseSchemaVersion { Version: var v } && !string.IsNullOrWhiteSpace(v)) + { + app.SchemaVersionOverride = v; + break; + } + } + + ProcessAnalyzedInvocationsForNode(context, sorted, rootAnalyzed, app.Root, ImmutableArray.Empty, app, isRoot: true); + + if (!string.IsNullOrWhiteSpace(app.RootSummary) && app.Root.RootCommand is not null) + { + var descAi = rootAnalyzed.OfType().FirstOrDefault(); + var loc = descAi is not null + ? Location.Create(descAi.FilePath, new Microsoft.CodeAnalysis.Text.TextSpan(descAi.SpanStart, 0), default) + : Location.None; + context.ReportDiagnostic(Diagnostic.Create(UseCliDescriptionConflictsWithMapRoot, loc)); + } + + ValidateCommandNamespaceOptionsChain(context, app.Root, parentEffectiveOptionsMetadataName: app.GlobalOptionsModel?.TypeMetadataName); + if (!ValidateNamespaceSegmentSanitizationCollisions(context, app.Root)) + return false; + + // OptionsModels are already set from AIUseGlobalOptions / AIUseNamespaceOptions via ProcessAnalyzedInvocationsForNode. + + var flat = new List(); + CollectCommands(app.Root, flat); + model = app; + if (flat.Count == 0) + return false; + + var dedup = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var c in flat) + { + var key = string.Join("/", c.RoutePrefix) + "/" + c.CommandName; + if (dedup.ContainsKey(key)) + { + context.ReportDiagnostic(Diagnostic.Create(DuplicateCommandName, c.HandlerSpanInfo.ToLocation(), c.CommandName)); + continue; + } + dedup[key] = c; + } + + app.AllCommands = dedup.Values.ToImmutableArray(); + // GlobalOptionsModel is set during ProcessAnalyzedInvocationsForNode. + + // Pre-compute injection chains once per command; reused by validation, FixOptionsParamsInCommands, EmitOptionsReconstructLocals, and emit. + // [NoOptionsInjection] only suppresses handler parameters and AGH0021 — globals/namespaced flags must still splice as OptionsInjected + // for short/long parsing and static-field reconstruction after the route segment. + app.InjectionChains = app.AllCommands.ToImmutableDictionary( + cmd => cmd.RunMethodName, + cmd => BuildOptionsInjectionChain(app, cmd), + StringComparer.Ordinal); + + ValidateCommandOptionsInjection(context, app); + FixOptionsParamsInCommands(app); + ValidateDuplicateShortOptionLetters(context, app); + + return true; + } + + /// Determines if a given AnalyzedInvocation is positionally inside any AIMapNamespace lambda body. + private static bool IsInsideAnyMapNamespaceLambda(AnalyzedInvocation ai, List all) + { + foreach (var other in all) + { + if (other is not AIMapNamespace ns) continue; + if (ns.LambdaBodyStart < 0 || ns.LambdaBodyEnd < 0) continue; + if (!string.Equals(ns.FilePath, ai.FilePath, StringComparison.Ordinal)) continue; + // Inclusive lower bound: for expression-bodied lambdas (e.g. g => g.MapNamespace(...)), + // the nested invocation's SpanStart equals the lambda body's SpanStart and must count as inside. + if (ai.SpanStart >= ns.LambdaBodyStart && ai.SpanStart < ns.LambdaBodyEnd) + return true; + } + return false; + } + + /// Builds the registry tree from pre-analyzed invocations for a given node scope. + private static void ProcessAnalyzedInvocationsForNode( + SourceProductionContext context, + List allAnalyzed, + List nodeInvocations, + RegistryNode node, + ImmutableArray currentPath, + AppEmitModel app, + bool isRoot) + { + foreach (var ai in nodeInvocations) + { + switch (ai) + { + case AIUseGlobalOptions g when isRoot: + app.GlobalOptionsModel = g.Model; + break; + case AIUseGlobalOptions when !isRoot: + context.ReportDiagnostic(Diagnostic.Create( + CommandNamespaceOptionsRequiresParent, + Location.None, + "T")); + break; + case AIUseNamespaceOptions ns when !isRoot: + node.CommandNamespaceOptionsModel = ns.Model; + node.CommandNamespaceOptionsLocation = Location.None; + break; + case AIUseNamespaceOptions when isRoot: + context.ReportDiagnostic(Diagnostic.Create( + CommandNamespaceOptionsRequiresParent, + Location.None, + "T")); + break; + case AIMapCommand { TypeSnapshot: { } typeSnap } mapCmd: + { + foreach (var pd in mapCmd.EmbeddedDiagnosticsOrEmpty) + context.ReportDiagnostic(Diagnostic.Create(GetDescriptorById(pd.DescriptorId), pd.Span.ToLocation(), pd.Arg0, pd.Arg1)); + // Map always hoists: merge the snapshot's commands directly into the current node. + if (typeSnap.RootCommand is { } snapRc && node.RootCommand is not null) + context.ReportDiagnostic(Diagnostic.Create(DuplicateRootCommand, snapRc.HandlerSpanInfo.ToLocation())); + ApplyRegistryNodeSnapshot(typeSnap, node, currentPath); + break; + } + case AIMapAndRootAlias alias: + { + foreach (var pd in alias.EmbeddedDiagnostics) + context.ReportDiagnostic(Diagnostic.Create(GetDescriptorById(pd.DescriptorId), pd.Span.ToLocation(), pd.Arg0, pd.Arg1)); + if (node.RootAlias is not null || node.RootCommand is not null) + { + context.ReportDiagnostic(Diagnostic.Create(DuplicateRootCommand, Location.None)); + break; + } + ApplyRegistryNodeSnapshot(alias.TypeSnapshot, node, currentPath); + break; + } + case AIMapCommand ac: + foreach (var pd in ac.EmbeddedDiagnosticsOrEmpty) + context.ReportDiagnostic(Diagnostic.Create(GetDescriptorById(pd.DescriptorId), pd.Span.ToLocation(), pd.Arg0, pd.Arg1)); + foreach (var cmd in ac.Commands) + { + // Re-prefix with the current path (commands were analyzed with empty prefix). + var prefixed = cmd with + { + RoutePrefix = currentPath, + RunMethodName = currentPath.IsDefaultOrEmpty + ? cmd.RunMethodName + : CommandModel.BuildRunMethodNameStatic(currentPath, cmd.CommandName), + UsageHints = cmd.UsageHints + }; + if (cmd.IsRootDefault) + node.RootCommand = prefixed; + else + node.Commands.Add(prefixed); + } + break; + case AIMapRootCommand rc when isRoot && rc.IsNamespaceRoot: + context.ReportDiagnostic(Diagnostic.Create(AddNamespaceRootCommandOnlyInNamespace, Location.None)); + break; + case AIMapRootCommand rc when !isRoot && !rc.IsNamespaceRoot: + context.ReportDiagnostic(Diagnostic.Create(AddRootCommandOnlyAtAppRoot, Location.None)); + break; + case AIMapRootCommand rc: + { + if (node.RootCommand is not null) + { + context.ReportDiagnostic(Diagnostic.Create(DuplicateRootCommand, Location.None)); + break; + } + // Re-prefix with current path. + var prefixedRoot = rc.Cmd with + { + RoutePrefix = currentPath, + RunMethodName = CommandModel.BuildRootDefaultRunMethodName(currentPath), + }; + node.RootCommand = prefixedRoot; + break; + } + case AIUseMiddleware: + // Handled at root level for global middleware (done before this method is called). + break; + case AIMapNamespace ns: + ProcessAnalyzedMapNamespace(context, allAnalyzed, ns, node, currentPath, app, isRoot); + break; + } + } + } + + private static void ProcessAnalyzedMapNamespace( + SourceProductionContext context, + List allAnalyzed, + AIMapNamespace ns, + RegistryNode parentNode, + ImmutableArray parentPath, + AppEmitModel app, + bool isRoot) + { + var childNode = new RegistryNode(); + var childPath = AppendSegment(parentPath, ns.SegmentName); + + // Find child invocations positionally. + var childInvocations = new List(); + if (ns.LambdaBodyStart >= 0 && ns.LambdaBodyEnd >= 0) + { + foreach (var other in allAnalyzed) + { + if (!string.Equals(other.FilePath, ns.FilePath, StringComparison.Ordinal)) continue; + if (other.SpanStart < ns.LambdaBodyStart || other.SpanStart >= ns.LambdaBodyEnd) continue; + // Skip invocations that are nested inside a deeper lambda (not direct children). + if (IsInsideAnyNestedMapNamespaceLambda(other, allAnalyzed, ns)) continue; + childInvocations.Add(other); + } + childInvocations.Sort((a, b) => + { + var c = string.CompareOrdinal(a.FilePath, b.FilePath); + return c != 0 ? c : a.SpanStart.CompareTo(b.SpanStart); + }); + } + + // If we have a namespace entry type (AddNamespace), apply its pre-computed snapshot. + if (ns.EntryTypeSnapshot is { } snap) + { + ApplyRegistryNodeSnapshot(snap, childNode, childPath); + childNode.SummaryInnerXml = ns.NsSummaryInnerXml; + childNode.RemarksInnerXml = ns.NsRemarksInnerXml; + } + + // Register argless segment codegen. + if (ns.IsArglessSegment && ns.EntryTypeFq is { Length: > 0 } arglessFq) + { + foreach (var existing in app.ArglessNamespaceCodegen) + { + if (string.Equals(existing.TypeFq, arglessFq, StringComparison.Ordinal)) + goto skipArglessAdd; + } + app.ArglessNamespaceCodegen.Add(new ArglessNamespaceCodegenEntry(arglessFq, ns.SegmentName)); + skipArglessAdd:; + } + + ProcessAnalyzedInvocationsForNode(context, allAnalyzed, childInvocations, childNode, childPath, app, isRoot: false); + + if (IsRegistryNodeVacuous(childNode)) + context.ReportDiagnostic(Diagnostic.Create(VacuousNamespace, ns.DiagnosticSpanInfo.ToLocation())); + + parentNode.Children.Add(new RegistryNode.NamedCommandNamespaceChild + { + Segment = ns.SegmentName, + Node = childNode, + SummaryOneLiner = ns.NsSummary, + Location = ns.DiagnosticSpanInfo.ToLocation() + }); + } + + /// Checks if an invocation is inside a nested AddNamespace lambda that is itself inside ns. + private static bool IsInsideAnyNestedMapNamespaceLambda(AnalyzedInvocation ai, List all, AIMapNamespace parent) + { + foreach (var other in all) + { + if (other is not AIMapNamespace nested) continue; + if (ReferenceEquals(nested, parent)) continue; + if (nested.LambdaBodyStart < 0 || nested.LambdaBodyEnd < 0) continue; + if (!string.Equals(nested.FilePath, ai.FilePath, StringComparison.Ordinal)) continue; + // nested must itself be inside parent (inclusive lower bound for expression-bodied lambdas). + if (nested.SpanStart < parent.LambdaBodyStart || nested.SpanStart >= parent.LambdaBodyEnd) continue; + // ai must be inside nested + if (ai.SpanStart >= nested.LambdaBodyStart && ai.SpanStart < nested.LambdaBodyEnd) + return true; + } + return false; + } + + /// Applies a pre-computed RegistryNodeSnapshot to a live RegistryNode (re-prefixing commands). + private static void ApplyRegistryNodeSnapshot(RegistryNodeSnapshot snap, RegistryNode target, ImmutableArray path) + { + if (snap.RootCommand is { } rc) + { + var prefixed = rc with + { + RoutePrefix = path, + RunMethodName = CommandModel.BuildRootDefaultRunMethodName(path) + }; + // Only set if not already set by an explicit AddNamespaceRootCommand in the lambda body. + target.RootCommand ??= prefixed; + } + CommandModel? prefixedAlias = null; + foreach (var cmd in snap.Commands) + { + var prefixed = cmd with + { + RoutePrefix = path, + RunMethodName = CommandModel.BuildRunMethodNameStatic(path, cmd.CommandName) + }; + target.Commands.Add(prefixed); + // Track the re-prefixed alias if this command was designated as the alias target. + if (snap.AliasCommand is not null && cmd.CommandName == snap.AliasCommand.CommandName) + prefixedAlias = prefixed; + } + if (prefixedAlias is not null) + target.RootAlias ??= prefixedAlias; + foreach (var childSnap in snap.Children) + { + var childPath = AppendSegment(path, childSnap.Segment); + var childNode = new RegistryNode(); + childNode.SummaryInnerXml = childSnap.Node.SummaryInnerXml; + childNode.RemarksInnerXml = childSnap.Node.RemarksInnerXml; + ApplyRegistryNodeSnapshot(childSnap.Node, childNode, childPath); + target.Children.Add(new RegistryNode.NamedCommandNamespaceChild + { + Segment = childSnap.Segment, + Node = childNode, + SummaryOneLiner = childSnap.SummaryOneLiner, + Location = Location.None + }); + } + target.SummaryInnerXml = snap.SummaryInnerXml; + target.RemarksInnerXml = snap.RemarksInnerXml; + } + + private static void ValidateCommandNamespaceOptionsChain( + SourceProductionContext context, + RegistryNode node, + string? parentEffectiveOptionsMetadataName) + { + var nsModel = node.CommandNamespaceOptionsModel; + if (nsModel is not null) + { + if (parentEffectiveOptionsMetadataName is null) + { + context.ReportDiagnostic(Diagnostic.Create( + CommandNamespaceOptionsRequiresParent, + node.CommandNamespaceOptionsLocation ?? Location.None, + GetShortTypeName(nsModel.TypeMetadataName))); + } + else if (nsModel.TypeMetadataName != parentEffectiveOptionsMetadataName + && !nsModel.AllBaseTypeMetadataNames.Contains(parentEffectiveOptionsMetadataName)) + { + context.ReportDiagnostic(Diagnostic.Create( + CommandNamespaceOptionsMustExtendParent, + node.CommandNamespaceOptionsLocation ?? Location.None, + GetShortTypeName(nsModel.TypeMetadataName), + GetShortTypeName(parentEffectiveOptionsMetadataName))); + } + } + + var nextParent = nsModel?.TypeMetadataName ?? parentEffectiveOptionsMetadataName; + foreach (var child in node.Children) + ValidateCommandNamespaceOptionsChain(context, child.Node, nextParent); + } + + private static string GetShortTypeName(string metadataName) + { + var dot = metadataName.LastIndexOf('.'); + return dot >= 0 ? metadataName.Substring(dot + 1) : metadataName; + } + + private static bool ValidateNamespaceSegmentSanitizationCollisions(SourceProductionContext context, RegistryNode node) + { + var seen = new Dictionary(StringComparer.Ordinal); + var ok = true; + foreach (var child in node.Children) + { + var sanitized = Naming.SanitizeIdentifier(child.Segment); + if (seen.TryGetValue(sanitized, out var first)) + { + context.ReportDiagnostic(Diagnostic.Create( + NamespaceSegmentSanitizationCollision, + child.Location, + first, + child.Segment, + sanitized)); + ok = false; + } + else + { + seen[sanitized] = child.Segment; + } + } + foreach (var child in node.Children) + { + if (!ValidateNamespaceSegmentSanitizationCollisions(context, child.Node)) + ok = false; + } + return ok; + } + + /// + /// AGH0021: every non-lambda command must inject its most specific applicable options type + /// (global or namespace-scoped) as a method parameter or constructor parameter. + /// + private static void ValidateCommandOptionsInjection(SourceProductionContext context, AppEmitModel app) + { + foreach (var cmd in app.AllCommands) + { + if (cmd.IsLambda || cmd.HandlerParamTypes.IsDefaultOrEmpty && !cmd.RequiresInstance) + continue; + if (cmd.HandlerHasNoOptionsInjection) + continue; + + // Most specific required options type = last entry in the injection chain. + var chain = app.InjectionChains.TryGetValue(cmd.RunMethodName, out var precomputed) + ? precomputed + : BuildOptionsInjectionChain(app, cmd); + if (chain.IsEmpty) + continue; + var (requiredTypeFq, requiredMetaName, requiredBaseNames, _, _, _, _) = chain[chain.Length - 1]; + + // Check method parameters first. + var injected = false; + foreach (var mp in cmd.HandlerParamTypes) + { + // mp.TypeMetadataName == requiredMetaName: exact match + // mp.TypeAllBaseTypeMetadataNames.Contains(requiredMetaName): mp's type is a subclass of the required type + if (mp.TypeMetadataName == requiredMetaName || + mp.TypeAllBaseTypeMetadataNames.Contains(requiredMetaName)) + { + injected = true; + break; + } + } + + // For instance methods, also accept injection via constructor. + if (!injected && cmd.RequiresInstance) + { + foreach (var cp in cmd.ContainingTypeCtorParams) + { + if (cp.TypeMetadataName == requiredMetaName || + requiredBaseNames.Contains(cp.TypeMetadataName)) + { + injected = true; + break; + } + } + } + + if (!injected) + { + context.ReportDiagnostic(Diagnostic.Create( + CommandMustInjectOptions, + cmd.HandlerSpanInfo.ToLocation(), + cmd.MethodName, + requiredMetaName)); // use pre-computed metadata name instead of ToDisplayString + } + } + } + + /// + /// Returns the ordered chain of options entries (global → most-specific namespace) for injection into a command. + /// Walks the registry tree directly so namespace options types with zero own members are still included. + /// Each entry carries the static field name (pre-parsed fallback) and a local var name (command-runner reconstruction). + /// All fields are symbol-free (strings / pre-computed ParameterModel arrays). + /// + private static ImmutableArray<(string TypeFq, string TypeMetadataName, ImmutableArray AllBaseTypeMetadataNames, string StaticFieldName, string LocalVarName, ImmutableArray FlatMembers, ImmutableArray? BestCtorParamOrder)> + BuildOptionsInjectionChain(AppEmitModel app, CommandModel cmd) + { + var result = ImmutableArray.CreateBuilder<(string, string, ImmutableArray, string, string, ImmutableArray, ImmutableArray?)>(); + if (app.GlobalOptionsModel is { } gom) + result.Add(( + gom.TypeFq, + gom.TypeMetadataName, + gom.AllBaseTypeMetadataNames, + OptionsStaticFieldNameFq(gom.TypeFq), + OptionsLocalVarNameFq(gom.TypeFq), + gom.FlattenedMembers, + gom.BestCtorParamOrder)); + + var current = app.Root; + foreach (var seg in cmd.RoutePrefix) + { + RegistryNode.NamedCommandNamespaceChild? found = null; + foreach (var ch in current.Children) + { + if (string.Equals(ch.Segment, seg, StringComparison.OrdinalIgnoreCase)) + { + found = ch; + break; + } + } + if (found is null) break; + current = found.Node; + if (current.CommandNamespaceOptionsModel is { } nsModel) + result.Add(( + nsModel.TypeFq, + nsModel.TypeMetadataName, + nsModel.AllBaseTypeMetadataNames, + OptionsStaticFieldNameFq(nsModel.TypeFq), + OptionsLocalVarNameFq(nsModel.TypeFq), + nsModel.FlattenedMembers, + nsModel.BestCtorParamOrder)); + } + + return result.ToImmutable(); + } + + /// + /// Removes options-type parameters from each command's so the + /// flag-parsing codegen ignores them. They are injected separately via static fields in . + /// + private static void FixOptionsParamsInCommands(AppEmitModel app) + { + var updated = ImmutableArray.CreateBuilder(app.AllCommands.Length); + foreach (var cmd in app.AllCommands) + { + // Lambdas have no reconstructed options-instance surface; globals still participate via leading prefetch only when applicable. + if (cmd.IsLambda) + { + updated.Add(cmd); + continue; + } + + var injChain = app.InjectionChains.TryGetValue(cmd.RunMethodName, out var precomputed2) + ? precomputed2 + : BuildOptionsInjectionChain(app, cmd); + if (injChain.IsEmpty) + { + updated.Add(cmd); + continue; + } + + // Remove original options-type params; replace with OptionsInjected entries for each flattened + // member so bool-switch / short-opt / canon-name machinery still recognises those flags. + var filtered = cmd.Parameters.Where(p => + { + if (p.AsParametersOwnerParamName is not null) return true; + var handlerParam = cmd.HandlerParamTypes.FirstOrDefault(mp => mp.Name == p.SymbolName); + if (handlerParam is null) return true; + // Keep the param only if its type is NOT the options type and NOT a subclass of it. + // handlerParam.TypeAllBaseTypeMetadataNames.Contains(o.TypeMetadataName) = param's type inherits from the options type. + return !injChain.Any(o => + o.TypeMetadataName == handlerParam.TypeMetadataName || + handlerParam.TypeAllBaseTypeMetadataNames.Contains(o.TypeMetadataName)); + }).ToList(); + + // Add flattened options members as OptionsInjected so the flag parser handles them correctly. + // Pre-seed with CLI names already present (e.g. from [AsParameters] expansion) to avoid duplicates. + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var p in filtered) + if (p.Kind == ParameterKind.Flag) seen.Add(p.CliLongName); + + foreach (var (_, _, _, _, _, flatMembers, _) in injChain) + { + foreach (var m in flatMembers) + { + if (m.Kind != ParameterKind.Flag) continue; + if (!seen.Add(m.CliLongName)) continue; // dedup inherited members + // Create an OptionsInjected entry — only flag-recognition fields matter here. + filtered.Add(m with { Kind = ParameterKind.OptionsInjected }); + } + } + + var newParams = filtered.ToImmutableArray(); + updated.Add(cmd with + { + Parameters = newParams, + // Rebuild usage hints now that options params are stripped. + UsageHints = UsageSynopsis.Build(newParams) + }); + } + + app.AllCommands = updated.ToImmutable(); + + // Also update RootCommand references in RegistryNodes so help printers see the fixed parameters. + var fixedById = new Dictionary(StringComparer.Ordinal); + foreach (var cmd in app.AllCommands) + fixedById[cmd.RunMethodName] = cmd; + UpdateRegistryNodeRootCommands(app.Root, fixedById); + } + + private static void UpdateRegistryNodeRootCommands(RegistryNode node, Dictionary fixedById) + { + if (node.RootCommand is not null && fixedById.TryGetValue(node.RootCommand.RunMethodName, out var fixedRoot)) + node.RootCommand = fixedRoot; + if (node.RootAlias is not null && fixedById.TryGetValue(node.RootAlias.RunMethodName, out var fixedAlias)) + node.RootAlias = fixedAlias; + // node.Commands is read by schema emission — fix it too so injected options params are stripped from schema output + for (var i = 0; i < node.Commands.Count; i++) + { + if (fixedById.TryGetValue(node.Commands[i].RunMethodName, out var fixedCmd)) + node.Commands[i] = fixedCmd; + } + foreach (var child in node.Children) + UpdateRegistryNodeRootCommands(child.Node, fixedById); + } + + + private static void CollectCommands(RegistryNode node, List sink) + { + if (node.RootCommand is { } rc) + sink.Add(rc); + sink.AddRange(node.Commands); + foreach (var child in node.Children) + CollectCommands(child.Node, sink); + } + + + + private static bool TryGetNamespaceSegmentAttribute(INamedTypeSymbol type, out string segment) + { + segment = ""; + foreach (var ad in type.GetAttributes()) + { + if (ad.AttributeClass?.Name != "NamespaceSegmentAttribute" || + ad.AttributeClass.ContainingNamespace?.ToDisplayString() != "Nullean.Argh") + continue; + if (ad.ConstructorArguments.Length > 0 && ad.ConstructorArguments[0].Value is string s && !string.IsNullOrWhiteSpace(s)) + { + segment = s; + return true; + } + } + + return false; + } + + private static bool TryGetFirstCodeInTypeSummary(INamedTypeSymbol type, out string code) + { + code = ""; + var xml = type.GetDocumentationCommentXml(); + if (string.IsNullOrWhiteSpace(xml)) + return false; + try + { + var doc = XDocument.Parse("" + xml + "", LoadOptions.PreserveWhitespace); + var root = doc.Root; + var sum = root?.Descendants().FirstOrDefault(e => e.Name.LocalName == "summary"); + var c = sum?.Descendants().FirstOrDefault(e => e.Name.LocalName == "c"); + if (c is null || string.IsNullOrWhiteSpace(c.Value)) + return false; + code = c.Value.Trim(); + return IdentifierSegmentPattern.IsMatch(code); + } + catch + { + return false; + } + } + + private static bool TryResolveNamespaceSegmentForArgless( + SourceProductionContext context, + INamedTypeSymbol type, + Location errorLocation, + out string segment) + { + segment = ""; + var hasAttr = TryGetNamespaceSegmentAttribute(type, out var attrSeg); + var hasXml = TryGetFirstCodeInTypeSummary(type, out var xmlSeg); + if (!hasAttr && !hasXml) + { + context.ReportDiagnostic(Diagnostic.Create(NamespaceSegmentUnresolved, errorLocation, type.Name)); + return false; + } + + if (hasAttr && hasXml && !string.Equals(attrSeg, xmlSeg, StringComparison.Ordinal)) + { + context.ReportDiagnostic(Diagnostic.Create(NamespaceSegmentConflict, errorLocation, type.Name, attrSeg, xmlSeg)); + return false; + } + + segment = hasAttr ? attrSeg : xmlSeg; + return true; + } + + private static void ValidateNamespaceSegmentForExplicitName( + SourceProductionContext context, + INamedTypeSymbol type, + string literalSegment, + Location location) + { + var hasAttr = TryGetNamespaceSegmentAttribute(type, out var attrSeg); + var hasXml = TryGetFirstCodeInTypeSummary(type, out var xmlSeg); + if (hasAttr && hasXml && !string.Equals(attrSeg, xmlSeg, StringComparison.Ordinal)) + context.ReportDiagnostic(Diagnostic.Create(NamespaceSegmentConflict, location, type.Name, attrSeg, xmlSeg)); + if (hasAttr && !string.Equals(attrSeg, literalSegment, StringComparison.Ordinal)) + context.ReportDiagnostic(Diagnostic.Create(NamespaceSegmentConflict, location, type.Name, attrSeg, literalSegment)); + if (hasXml && !string.Equals(xmlSeg, literalSegment, StringComparison.Ordinal)) + context.ReportDiagnostic(Diagnostic.Create(NamespaceSegmentConflict, location, type.Name, xmlSeg, literalSegment)); + } + + private static void RegisterArglessNamespaceCodegen( + SourceProductionContext context, + AppEmitModel app, + INamedTypeSymbol type, + string segment, + Location location) + { + var typeFq = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + foreach (var existing in app.ArglessNamespaceCodegen) + { + if (!string.Equals(existing.TypeFq, typeFq, StringComparison.Ordinal)) + continue; + if (!string.Equals(existing.Segment, segment, StringComparison.Ordinal)) + context.ReportDiagnostic(Diagnostic.Create(NamespaceSegmentConflict, location, type.Name, existing.Segment, segment)); + return; + } + + app.ArglessNamespaceCodegen.Add(new ArglessNamespaceCodegenEntry(typeFq, segment)); + } + + private static bool IsRegistryNodeVacuous(RegistryNode node) => + node.RootCommand is null && node.Commands.Count == 0 && node.Children.Count == 0; + + + + private static bool IsMapNamespaceConfigureLambda(LambdaExpressionSyntax lambda, out InvocationExpressionSyntax addNamespaceInv) + { + addNamespaceInv = null!; + if (lambda.Parent is not ArgumentSyntax { Parent: ArgumentListSyntax al }) + return false; + if (al.Parent is not InvocationExpressionSyntax inv) + return false; + if (inv.Expression is not MemberAccessExpressionSyntax ma || ma.Name is not SimpleNameSyntax sns || + sns.Identifier.Text != "MapNamespace") + return false; + var last = al.Arguments.Count - 1; + if (last < 0 || !ReferenceEquals(al.Arguments[last].Expression, lambda)) + return false; + addNamespaceInv = inv; + return true; + } + + + /// + /// Synthesizes the fully-qualified BCL delegate type (System.Func<...> / System.Action<...>) + /// that the C# compiler infers as the "natural type" for a lambda with this signature. Used instead of reading the + /// converted-to type off the enclosing , because when a lambda is passed to a + /// Delegate-typed parameter (e.g. Map(string, Delegate)) that conversion's Type is + /// System.Delegate itself, not the lambda's actual runtime delegate type. + /// + private static string? BuildNaturalDelegateTypeFq(IMethodSymbol invokeMethod) + { + if (invokeMethod.Parameters.Length > 16) + return null; // Func<>/Action<> top out at 16 parameters; fall back to Delegate. + + foreach (var p in invokeMethod.Parameters) + if (p.RefKind != RefKind.None) + return null; // ref/out/in params have no Func<>/Action<> natural type; fall back to Delegate. + + var paramFqs = new string[invokeMethod.Parameters.Length]; + for (var i = 0; i < invokeMethod.Parameters.Length; i++) + paramFqs[i] = invokeMethod.Parameters[i].Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + if (invokeMethod.ReturnsVoid) + { + return paramFqs.Length == 0 + ? "global::System.Action" + : $"global::System.Action<{string.Join(", ", paramFqs)}>"; + } + + var retFq = invokeMethod.ReturnType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var allArgs = paramFqs.Length == 0 ? retFq : string.Join(", ", paramFqs) + ", " + retFq; + return $"global::System.Func<{allArgs}>"; + } + + /// Select-step (no SourceProductionContext) variant of . + private static void TryExpandLambdaDelegateAcc( + SemanticModel model, + InvocationExpressionSyntax invocation, + ExpressionSyntax handlerExpr, + string commandName, + ImmutableArray routePrefix, + RegistryNode targetNode) => + TryExpandLambdaDelegate(null, model, invocation, handlerExpr, commandName, routePrefix, targetNode); + + private static void TryExpandLambdaDelegate( + SourceProductionContext? context, + SemanticModel model, + InvocationExpressionSyntax invocation, + ExpressionSyntax handlerExpr, + string commandName, + ImmutableArray routePrefix, + RegistryNode targetNode) + { + // Get the converted delegate type via type info (the lambda is implicitly converted to Delegate) + var op = model.GetOperation(handlerExpr); + // Unwrap conversions + while (op is IConversionOperation conv) + op = conv.Operand; + + IMethodSymbol? invokeMethod = null; + + if (op is IAnonymousFunctionOperation anonFunc) + invokeMethod = anonFunc.Symbol; + + if (invokeMethod is null) + return; + + // Build the storage key: "namespace/name" for nested, "name" for root + var storageKey = routePrefix.IsDefaultOrEmpty + ? commandName + : string.Join("/", routePrefix) + "/" + commandName; + + // Get the FQ delegate type string for casting at runtime. Synthesized from the lambda's own signature + // rather than the enclosing conversion's Type, which — since the target parameter is `Delegate` — would + // otherwise resolve to `System.Delegate` itself and force a reflection-based DynamicInvoke fallback that + // silently discards the handler's return value (see BuildNaturalDelegateTypeFq). + var delegateFq = BuildNaturalDelegateTypeFq(invokeMethod) ?? "global::System.Delegate"; + + var parseOpts = invocation.SyntaxTree.Options as CSharpParseOptions ?? CSharpParseOptions.Default; + + // Build parameter models from the lambda's method symbol + var paramBuilder = ImmutableArray.CreateBuilder(); + foreach (var p in invokeMethod.Parameters) + { + paramBuilder.Add(ParameterModel.From(p, reportFallbackLocation: invocation.GetLocation())); + } + var parameters = paramBuilder.ToImmutable(); + var usage = UsageSynopsis.Build(parameters); + // Build run method name inline (mirrors CommandModel.BuildRunMethodName) + string runName; + if (routePrefix.IsDefaultOrEmpty) + runName = "Run_" + Naming.SanitizeIdentifier(commandName); + else + { + var rnSb = new StringBuilder(); + rnSb.Append("Run"); + foreach (var seg in routePrefix) { rnSb.Append('_'); rnSb.Append(Naming.SanitizeIdentifier(seg)); } + rnSb.Append('_'); rnSb.Append(Naming.SanitizeIdentifier(commandName)); + runName = rnSb.ToString(); + } + var retFq = invokeMethod.ReturnType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + // FullyQualifiedFormat renders special types via their C# keyword ("void"), not "global::System.Void". + var retIsVoid = retFq is "void" + or "global::System.Threading.Tasks.Task" + or "global::System.Threading.Tasks.ValueTask"; + var retIsAsync = retFq is "global::System.Threading.Tasks.Task" + or "global::System.Threading.Tasks.ValueTask" + || (invokeMethod.ReturnType is INamedTypeSymbol rNamed && rNamed.IsGenericType && + (rNamed.ConstructedFrom.Name is "Task" or "ValueTask") && + rNamed.ConstructedFrom.ContainingNamespace?.ToDisplayString() == "System.Threading.Tasks"); + + var cmd = new CommandModel( + routePrefix, + commandName, + runName, + "object", + "__lambda", + false, + false, + retFq, + retIsAsync, + retIsVoid, + parameters, + false, + ImmutableArray.Empty, + SourceSpanInfo.None, + ImmutableArray<(string, string)>.Empty, + "", // HandlerDocCommentId + "", + "", + "", + "", + "", + usage, + ImmutableArray<(string, bool)>.Empty, + IsLambda: true, + LambdaStorageKey: storageKey, + LambdaDelegateFq: delegateFq); + + targetNode.Commands.Add(cmd); + } + + private const string RootDefaultInternalCommandName = "__argh_root"; + + + /// Select-step (no SourceProductionContext) variant of . + private static void TryExpandLambdaRootCommandAcc( + SemanticModel model, + InvocationExpressionSyntax invocation, + ExpressionSyntax handlerExpr, + ImmutableArray routePrefix, + RegistryNode targetNode) => + TryExpandLambdaRootCommand(null, model, invocation, handlerExpr, routePrefix, targetNode); + + private static void TryExpandLambdaRootCommand( + SourceProductionContext? context, + SemanticModel model, + InvocationExpressionSyntax invocation, + ExpressionSyntax handlerExpr, + ImmutableArray routePrefix, + RegistryNode targetNode) + { + var op = model.GetOperation(handlerExpr); + while (op is IConversionOperation conv) + op = conv.Operand; + + if (op is not IAnonymousFunctionOperation anonFunc) + return; + + var invokeMethod = anonFunc.Symbol; + + if (invokeMethod is null) + return; + + var storageKey = routePrefix.IsDefaultOrEmpty + ? "__argh_root" + : string.Join("/", routePrefix) + "/__argh_root"; + // Synthesized from the lambda's own signature — see BuildNaturalDelegateTypeFq for why the enclosing + // conversion's Type (System.Delegate) can't be used here. + var delegateFq = BuildNaturalDelegateTypeFq(invokeMethod) ?? "global::System.Delegate"; + var parseOpts = invocation.SyntaxTree.Options as CSharpParseOptions ?? CSharpParseOptions.Default; + var paramBuilder = ImmutableArray.CreateBuilder(); + foreach (var p in invokeMethod.Parameters) + paramBuilder.Add(ParameterModel.From(p, reportFallbackLocation: invocation.GetLocation())); + var parameters = paramBuilder.ToImmutable(); + var usage = UsageSynopsis.Build(parameters); + var runName = CommandModel.BuildRootDefaultRunMethodName(routePrefix); + var lambdaRetFq = invokeMethod.ReturnType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + // FullyQualifiedFormat renders special types via their C# keyword ("void"), not "global::System.Void". + var lambdaRetIsVoid = lambdaRetFq is "void" + or "global::System.Threading.Tasks.Task" + or "global::System.Threading.Tasks.ValueTask"; + var lambdaRetIsAsync = lambdaRetFq is "global::System.Threading.Tasks.Task" + or "global::System.Threading.Tasks.ValueTask" + || (invokeMethod.ReturnType is INamedTypeSymbol lrNamed && lrNamed.IsGenericType && + (lrNamed.ConstructedFrom.Name is "Task" or "ValueTask") && + lrNamed.ConstructedFrom.ContainingNamespace?.ToDisplayString() == "System.Threading.Tasks"); + var cmd = new CommandModel( + routePrefix, + RootDefaultInternalCommandName, + runName, + "object", + "__lambda", + false, + false, + lambdaRetFq, + lambdaRetIsAsync, + lambdaRetIsVoid, + parameters, + false, + ImmutableArray.Empty, + SourceSpanInfo.None, + ImmutableArray<(string, string)>.Empty, + "", // HandlerDocCommentId + "", + "", + "", + "", + "", + usage, + ImmutableArray<(string, bool)>.Empty, + IsRootDefault: true, + IsLambda: true, + LambdaStorageKey: storageKey, + LambdaDelegateFq: delegateFq); + targetNode.RootCommand = cmd; + } + + + private static ImmutableArray AppendSegment(ImmutableArray prefix, string segment) + { + var b = ImmutableArray.CreateBuilder(prefix.Length + 1); + foreach (var s in prefix) + b.Add(s); + b.Add(segment); + return b.MoveToImmutable(); + } + + + /// DiagnosticAccumulator-based variant of for use in the Select-step analysis. + private static void AddMethodsFromTypeAcc( + DiagnosticAccumulator acc, + Location location, + INamedTypeSymbol type, + ImmutableArray routePrefix, + RegistryNode targetNode, + CSharpParseOptions parseOpts, + Compilation? compilation) + { + IMethodSymbol? defaultCommand = null; + foreach (var member in type.GetMembers()) + { + if (member is not IMethodSymbol method || method.MethodKind != MethodKind.Ordinary) continue; + if (method.AssociatedSymbol is not null) continue; + if (method.DeclaredAccessibility != Accessibility.Public) continue; + if (!HasDefaultCommandAttribute(method)) continue; + if (defaultCommand is not null) + { + acc.Add(MultipleDefaultCommandAttributes, method.Locations.FirstOrDefault() ?? location, type.Name); + continue; + } + defaultCommand = method; + } + if (defaultCommand is not null) + { + if (targetNode.RootCommand is not null) + acc.Add(DuplicateRootCommand, location); + else + targetNode.RootCommand = CommandModel.FromRootMethod(defaultCommand, parseOpts, routePrefix, acc, location, compilation); + } + foreach (var member in type.GetMembers()) + { + if (member is not IMethodSymbol method || method.MethodKind != MethodKind.Ordinary) continue; + if (method.AssociatedSymbol is not null) continue; + if (method.DeclaredAccessibility != Accessibility.Public) continue; + if (defaultCommand is not null && SymbolEqualityComparer.Default.Equals(method, defaultCommand)) continue; + var cmdName = TryGetCommandNameAttribute(method) ?? Naming.ToCommandName(method.Name); + targetNode.Commands.Add(CommandModel.FromMethod(cmdName, method, parseOpts, routePrefix, acc, location, compilation)); + } + } + + /// + /// Variant of used by MapAndRootAlias<T>. + /// All public methods are registered as regular named commands (none extracted to RootCommand). + /// The [DefaultCommand]-marked method (or the sole method for single-method types) is also stored + /// in as the alias target. + /// + private static void AddMethodsFromTypeAccForAlias( + DiagnosticAccumulator acc, + Location location, + INamedTypeSymbol type, + ImmutableArray routePrefix, + RegistryNode targetNode, + CSharpParseOptions parseOpts, + Compilation? compilation) + { + IMethodSymbol? defaultCommandMethod = null; + var publicOrdinaryMethods = new List(); + + foreach (var member in type.GetMembers()) + { + if (member is not IMethodSymbol method || method.MethodKind != MethodKind.Ordinary) continue; + if (method.AssociatedSymbol is not null) continue; + if (method.DeclaredAccessibility != Accessibility.Public) continue; + publicOrdinaryMethods.Add(method); + if (!HasDefaultCommandAttribute(method)) continue; + if (defaultCommandMethod is not null) + { + acc.Add(MultipleDefaultCommandAttributes, method.Locations.FirstOrDefault() ?? location, type.Name); + continue; + } + defaultCommandMethod = method; + } + + // Auto-select for single-method types; require [DefaultCommand] for multi-method types. + if (defaultCommandMethod is null) + { + if (publicOrdinaryMethods.Count == 1) + defaultCommandMethod = publicOrdinaryMethods[0]; + else if (publicOrdinaryMethods.Count > 1) + acc.Add(MapAndRootAliasAmbiguousTarget, location, type.Name); + } + + foreach (var method in publicOrdinaryMethods) + { + var cmdName = TryGetCommandNameAttribute(method) ?? Naming.ToCommandName(method.Name); + var cmd = CommandModel.FromMethod(cmdName, method, parseOpts, routePrefix, acc, location, compilation); + targetNode.Commands.Add(cmd); + if (defaultCommandMethod is not null && SymbolEqualityComparer.Default.Equals(method, defaultCommandMethod)) + targetNode.RootAlias = cmd; + } + } + +} diff --git a/src/Nullean.Argh.Generator/CliParserGenerator.CommandModel.cs b/src/Nullean.Argh.Generator/CliParserGenerator.CommandModel.cs new file mode 100644 index 0000000..777b4a6 --- /dev/null +++ b/src/Nullean.Argh.Generator/CliParserGenerator.CommandModel.cs @@ -0,0 +1,391 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Globalization; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Operations; +using Microsoft.CodeAnalysis.Text; + +namespace Nullean.Argh; + +public sealed partial class CliParserGenerator +{ + private sealed record CommandModel( + ImmutableArray RoutePrefix, + string CommandName, + string RunMethodName, + string ContainingTypeFq, + string MethodName, + bool RequiresInstance, + bool ContainingTypeHasParameterlessCtor, + string ReturnTypeFq, + bool ReturnIsAsync, + bool ReturnIsVoid, + ImmutableArray Parameters, + bool HandlerHasNoOptionsInjection, + ImmutableArray HandlerParamTypes, + SourceSpanInfo HandlerSpanInfo, + ImmutableArray<(string Name, string TypeMetadataName)> ContainingTypeCtorParams, + string HandlerDocCommentId, + string SummaryOneLiner, + string RemarksRendered, + string SummaryInnerXml, + string RemarksInnerXml, + string ExamplesRendered, + string UsageHints, + ImmutableArray<(string Fq, bool HasParameterlessCtor)> CommandMiddlewareData, + bool IsRootDefault = false, + bool IsLambda = false, + string LambdaStorageKey = "", + string LambdaDelegateFq = "", + bool IsIntrinsic = false, + ImmutableArray CommandAliases = default, + bool IsHidden = false, + bool IsDeprecated = false, + string? DeprecationMessage = null, + CommandIntentData? Intent = null, + CommandOutputData? Output = null) + { + + + /// Overload for the per-invocation Select step — uses instead of SourceProductionContext. + public static CommandModel FromRootMethod( + IMethodSymbol method, + CSharpParseOptions parseOptions, + ImmutableArray routePrefix, + DiagnosticAccumulator acc, + Location diagnosticLocation, + Compilation? compilation = null) + { + var parameters = BuildParameterModels(method, parseOptions, acc, diagnosticLocation, compilation); + ReportDuplicateCliNamesAcc(acc, diagnosticLocation, parameters); + ReportBoolNegationSwitchConflictsAcc(acc, diagnosticLocation, parameters, method); + ValidateExpandedParameterLayoutAcc(acc, diagnosticLocation, parameters); + ValidateVariadicPositionalIsLastAcc(acc, diagnosticLocation, parameters); + foreach (var p in parameters) + { + if (p.IsCollection && p.Kind == ParameterKind.Positional && !p.IsVariadic) + acc.Add(CollectionPositionalNotSupported, diagnosticLocation); + if (p.IsVariadic && !p.CollectionTargetIsArray) + acc.Add(VariadicCollectionMustBeArray, diagnosticLocation); + if (p.CollectionTargetIsReadOnlySet && !p.ElementIsValueType) + acc.Add(ReadOnlySetInvalidElementType, diagnosticLocation, p.ElementTypeName); + } + var docs = MergeMethodDocumentationFromTrivia(method, Documentation.ParseMethod(method.GetDocumentationCommentXml(), parseOptions), parseOptions); + var withDocs = ApplyParamDocumentation(parameters, method, docs.ParamDocsRaw); + withDocs = ApplyCollectionSeparatorsFromDocumentation(withDocs, method, docs.ParamSeparators); + var usage = UsageSynopsis.Build(withDocs); + var runName = BuildRootDefaultRunMethodName(routePrefix); + var containingFq = method.ContainingType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var hasParamlessCtor = method.ContainingType is INamedTypeSymbol namedCt && HasPublicParameterlessCtor(namedCt); + var (retFq, retIsAsync, retIsVoid, handlerNoInj, handlerParams, handlerLoc, ctorParams, mwData, docId) = ExtractHandlerAnalysis(method); + return new CommandModel(routePrefix, RootDefaultInternalCommandName, runName, containingFq, method.Name, !method.IsStatic, hasParamlessCtor, retFq, retIsAsync, retIsVoid, withDocs, handlerNoInj, handlerParams, handlerLoc, ctorParams, docId, docs.SummaryOneLiner, docs.RemarksRendered, docs.SummaryInnerXml, docs.RemarksInnerXml, docs.ExamplesRendered, usage, mwData, IsRootDefault: true); + } + + /// Overload for the per-invocation Select step — uses instead of SourceProductionContext. + public static CommandModel FromMethod( + string commandName, + IMethodSymbol method, + CSharpParseOptions parseOptions, + ImmutableArray routePrefix, + DiagnosticAccumulator acc, + Location diagnosticLocation, + Compilation? compilation = null) + { + var parameters = BuildParameterModels(method, parseOptions, acc, diagnosticLocation, compilation); + ReportDuplicateCliNamesAcc(acc, diagnosticLocation, parameters); + ReportBoolNegationSwitchConflictsAcc(acc, diagnosticLocation, parameters, method); + ValidateExpandedParameterLayoutAcc(acc, diagnosticLocation, parameters); + ValidateVariadicPositionalIsLastAcc(acc, diagnosticLocation, parameters); + foreach (var p in parameters) + { + if (p.IsCollection && p.Kind == ParameterKind.Positional && !p.IsVariadic) + acc.Add(CollectionPositionalNotSupported, diagnosticLocation); + if (p.IsVariadic && !p.CollectionTargetIsArray) + acc.Add(VariadicCollectionMustBeArray, diagnosticLocation); + if (p.CollectionTargetIsReadOnlySet && !p.ElementIsValueType) + acc.Add(ReadOnlySetInvalidElementType, diagnosticLocation, p.ElementTypeName); + } + var docs = MergeMethodDocumentationFromTrivia(method, Documentation.ParseMethod(method.GetDocumentationCommentXml(), parseOptions), parseOptions); + var withDocs = ApplyParamDocumentation(parameters, method, docs.ParamDocsRaw); + withDocs = ApplyCollectionSeparatorsFromDocumentation(withDocs, method, docs.ParamSeparators); + var usage = UsageSynopsis.Build(withDocs); + var runName = BuildRunMethodName(routePrefix, commandName); + var containingFq = method.ContainingType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var hasParamlessCtor = method.ContainingType is INamedTypeSymbol namedCt && HasPublicParameterlessCtor(namedCt); + var (retFq, retIsAsync, retIsVoid, handlerNoInj, handlerParams, handlerLoc, ctorParams, mwData, docId) = ExtractHandlerAnalysis(method); + var (isDeprecated, deprecationMsg) = TryGetObsoleteAttribute(method); + return new CommandModel(routePrefix, commandName, runName, containingFq, method.Name, !method.IsStatic, hasParamlessCtor, retFq, retIsAsync, retIsVoid, withDocs, handlerNoInj, handlerParams, handlerLoc, ctorParams, docId, docs.SummaryOneLiner, docs.RemarksRendered, docs.SummaryInnerXml, docs.RemarksInnerXml, docs.ExamplesRendered, usage, mwData, IsIntrinsic: HasCommandIntrinsicAttribute(method), CommandAliases: TryGetCommandAliasesFromAttribute(method), IsHidden: HasHiddenAttribute(method), IsDeprecated: isDeprecated, DeprecationMessage: deprecationMsg, Intent: TryGetCommandIntentData(method), Output: BuildCommandOutputFromParameters(withDocs)); + } + + private static ImmutableArray BuildParameterModels( + IMethodSymbol method, + CSharpParseOptions parseOptions, + DiagnosticAccumulator acc, + Location diagnosticLocation, + Compilation? compilation = null) + { + var builder = ImmutableArray.CreateBuilder(); + foreach (var p in method.Parameters) + { + if (IsInjected(p)) + { + builder.Add(ParameterModel.From(p)); + continue; + } + if (HasAsParametersAttribute(p)) + { + if (p.Type is not INamedTypeSymbol namedType || namedType.TypeKind == TypeKind.Error) + continue; + var prefix = GetAsParametersPrefix(p); + foreach (var pm in FlattenAsParametersTypeAcc(acc, diagnosticLocation, p, namedType, prefix, compilation, parseOptions)) + builder.Add(pm); + continue; + } + builder.Add(ParameterModel.From(p, acc, diagnosticLocation)); + } + return builder.ToImmutable(); + } + + + private static ImmutableArray ApplyCollectionSeparatorsFromDocumentation( + ImmutableArray parameters, + IMethodSymbol method, + ImmutableDictionary paramSeparators) + { + if (paramSeparators.IsEmpty) + return parameters; + + var b = ImmutableArray.CreateBuilder(parameters.Length); + foreach (var p in parameters) + { + if (!p.IsCollection || p.CollectionSeparator is not null) + { + b.Add(p); + continue; + } + + if (paramSeparators.TryGetValue(p.SymbolName, out var sep) && !string.IsNullOrWhiteSpace(sep)) + b.Add(p with { CollectionSeparator = sep }); + else + b.Add(p); + } + + return b.ToImmutable(); + } + + private static ImmutableArray CollectCommandMiddleware(IMethodSymbol method) + { + var b = ImmutableArray.CreateBuilder(); + foreach (var attr in method.GetAttributes()) + { + var ac = attr.AttributeClass; + if (ac is null || ac.Name != "MiddlewareAttribute" || ac.TypeArguments.Length != 1) + continue; + if (ac.TypeArguments[0] is INamedTypeSymbol ft && ft.TypeKind != TypeKind.Error) + b.Add(ft); + } + + return b.ToImmutable(); + } + + /// Returns the CSharp-error-message display string for a type — used as a stable, symbol-free metadata key. + private static string GetMetadataName(ITypeSymbol t) => + t.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat); + + private static ( + string ReturnTypeFq, + bool ReturnIsAsync, + bool ReturnIsVoid, + bool HasNoOptionsInjection, + ImmutableArray HandlerParamTypes, + SourceSpanInfo HandlerSpanInfo, + ImmutableArray<(string Name, string TypeMetadataName)> ContainingTypeCtorParams, + ImmutableArray<(string Fq, bool HasParameterlessCtor)> MiddlewareData, + string DocCommentId + ) ExtractHandlerAnalysis(IMethodSymbol method) + { + // Return type + var retFq = method.ReturnType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + // FullyQualifiedFormat renders special types via their C# keyword ("void"), not "global::System.Void". + var retIsVoid = retFq is "void" + or "global::System.Threading.Tasks.Task" + or "global::System.Threading.Tasks.ValueTask"; + var retIsAsync = retFq is "global::System.Threading.Tasks.Task" + or "global::System.Threading.Tasks.ValueTask" + || (method.ReturnType is INamedTypeSymbol named && named.IsGenericType && + (named.ConstructedFrom.Name is "Task" or "ValueTask") && + named.ConstructedFrom.ContainingNamespace?.ToDisplayString() == "System.Threading.Tasks"); + + // Parameters + var paramBuilder = ImmutableArray.CreateBuilder(method.Parameters.Length); + foreach (var p in method.Parameters) + { + var isInj = IsInjected(p); + var isAsParam = HasAsParametersAttribute(p); + var asParamPrefix = isAsParam ? GetAsParametersPrefix(p) : null; + string? asParamTypeFq = null; + ImmutableArray? asParamBestCtor = null; + var asParamIsPublic = true; + var asParamIsGeneric = false; + if (isAsParam && p.Type is INamedTypeSymbol asNt && asNt.TypeKind != TypeKind.Error) + { + asParamTypeFq = asNt.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + asParamIsPublic = asNt.DeclaredAccessibility == Accessibility.Public; + asParamIsGeneric = asNt.TypeParameters.Length > 0; + // Pre-compute the best ctor param order for DTO construction in emit. + var membersForCtor = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var member in asNt.GetMembers()) + { + if (member is IPropertySymbol prop && prop.DeclaredAccessibility == Accessibility.Public && !prop.IsStatic && !prop.IsIndexer && prop.GetMethod is not null && prop.SetMethod is not null) + membersForCtor.Add(prop.Name); + else if (member is IFieldSymbol field && field.DeclaredAccessibility == Accessibility.Public && !field.IsStatic) + membersForCtor.Add(field.Name); + } + // Walk primary ctor or most-parameterized public ctor + IMethodSymbol? bestCtor = null; + foreach (var ctor in asNt.InstanceConstructors) + { + if (ctor.DeclaredAccessibility != Accessibility.Public) continue; + if (ctor.Parameters.Length == 0) continue; + if (!ctor.Parameters.All(cp => membersForCtor.Contains(cp.Name))) continue; + if (bestCtor is null || ctor.Parameters.Length > bestCtor.Parameters.Length) + bestCtor = ctor; + } + if (bestCtor is not null) + { + var ctorB = ImmutableArray.CreateBuilder(bestCtor.Parameters.Length); + foreach (var cp in bestCtor.Parameters) + ctorB.Add(cp.Name); + asParamBestCtor = ctorB.MoveToImmutable(); + } + } + var paramBaseNames = p.Type is INamedTypeSymbol paramNt + ? CollectBaseTypeMetadataNames(paramNt) + : ImmutableArray.Empty; + paramBuilder.Add(new HandlerParam(p.Name, GetMetadataName(p.Type), paramBaseNames, isInj, isAsParam, asParamPrefix, asParamTypeFq, asParamIsPublic, asParamIsGeneric, asParamBestCtor)); + } + + // Handler location + var loc = method.Locations.FirstOrDefault() ?? Location.None; + + // Primary constructor parameters of containing type + var ctorParams = ImmutableArray<(string, string)>.Empty; + var primaryCtor = TryGetPrimaryConstructor(method.ContainingType); + if (primaryCtor is not null && primaryCtor.Parameters.Length > 0) + { + var ctorBuilder = ImmutableArray.CreateBuilder<(string, string)>(primaryCtor.Parameters.Length); + foreach (var cp in primaryCtor.Parameters) + ctorBuilder.Add((cp.Name, GetMetadataName(cp.Type))); + ctorParams = ctorBuilder.ToImmutable(); + } + + // Middleware data + var rawMiddleware = CollectCommandMiddleware(method); + var mwBuilder = ImmutableArray.CreateBuilder<(string, bool)>(rawMiddleware.Length); + foreach (var mw in rawMiddleware) + mwBuilder.Add((mw.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), HasPublicParameterlessCtor(mw))); + var middlewareData = mwBuilder.ToImmutable(); + + var docId = method.GetDocumentationCommentId() ?? ""; + return ( + ReturnTypeFq: retFq, + ReturnIsAsync: retIsAsync, + ReturnIsVoid: retIsVoid, + HasNoOptionsInjection: HasNoOptionsInjection(method), + HandlerParamTypes: paramBuilder.ToImmutable(), + HandlerSpanInfo: SourceSpanInfo.From(loc), + ContainingTypeCtorParams: ctorParams, + MiddlewareData: middlewareData, + DocCommentId: docId + ); + } + + private static string BuildRunMethodName(ImmutableArray routePrefix, string commandName) + { + if (routePrefix.IsDefaultOrEmpty) + return "Run_" + Naming.SanitizeIdentifier(commandName); + + var sb = new StringBuilder(); + sb.Append("Run"); + foreach (var seg in routePrefix) + { + sb.Append('_'); + sb.Append(Naming.SanitizeIdentifier(seg)); + } + + sb.Append('_'); + sb.Append(Naming.SanitizeIdentifier(commandName)); + return sb.ToString(); + } + + /// Visible to for lambda root commands (same naming as ). + internal static string BuildRootDefaultRunMethodName(ImmutableArray routePrefix) => + BuildRunMethodName(routePrefix, "RootDefault"); + + /// Public helper used by the analyzed-invocation pipeline to re-compute run method names when prefixing. + internal static string BuildRunMethodNameStatic(ImmutableArray routePrefix, string commandName) => + BuildRunMethodName(routePrefix, commandName); + + private static ImmutableArray ApplyParamDocumentation( + ImmutableArray parameters, + IMethodSymbol method, + ImmutableDictionary paramDocsRaw) + { + if (paramDocsRaw.IsEmpty) + return parameters; + + var map = new Dictionary(); + foreach (var p in parameters) + map[p.SymbolName] = p; + + foreach (var ps in method.Parameters) + { + if (!map.TryGetValue(ps.Name, out var existing)) + continue; + if (!paramDocsRaw.TryGetValue(ps.Name, out var raw) || string.IsNullOrWhiteSpace(raw)) + continue; + + if (existing.Kind == ParameterKind.Positional) + { + map[ps.Name] = existing with { Description = raw.Trim() }; + continue; + } + + var doc = ParamDocParser.Parse(raw); + map[ps.Name] = existing with + { + CliLongName = doc.ExplicitLongName ?? existing.CliLongName, + Description = doc.Description, + ShortOpt = doc.ShortOpt, + Aliases = doc.Aliases + }; + } + + var rebuilt = ImmutableArray.CreateBuilder(parameters.Length); + foreach (var p in parameters) + rebuilt.Add(map[p.SymbolName]); + + return rebuilt.ToImmutable(); + } + } + + /// Parse options property/field <summary> lines that may start with -x, --long, … synopsis prefixes (same rules as handler docs). + private static ParamDoc ParseOptionsFlagDocumentation(string? summaryLine) + { + if (string.IsNullOrWhiteSpace(summaryLine)) + return new ParamDoc(null, ImmutableArray.Empty, ""); + return ParamDocParser.Parse(summaryLine!.Trim()); + } + + // ── Validation constraint types ───────────────────────────────────────────── + // All fields are value types, strings, or ImmutableArray so these can be + // cached inside AnalyzedInvocation records in the Roslyn incremental pipeline. + +} diff --git a/src/Nullean.Argh.Generator/CliParserGenerator.Diagnostics.cs b/src/Nullean.Argh.Generator/CliParserGenerator.Diagnostics.cs new file mode 100644 index 0000000..1e7c10d --- /dev/null +++ b/src/Nullean.Argh.Generator/CliParserGenerator.Diagnostics.cs @@ -0,0 +1,341 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Globalization; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Operations; +using Microsoft.CodeAnalysis.Text; + +namespace Nullean.Argh; + +public sealed partial class CliParserGenerator +{ + private static readonly DiagnosticDescriptor CommandNamespaceOptionsMustExtendParent = new( + "AGH0004", + "Command namespace options type must extend the parent options type", + "'{0}' must inherit or implement '{1}' for this UseNamespaceOptions<> registration.", + "Argh", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor CommandNamespaceOptionsRequiresParent = new( + "AGH0005", + "Command namespace options require a parent options type", + "Register UseGlobalOptions() before UseNamespaceOptions<{0}>(), or ensure the parent namespace declares a compatible base options type.", + "Argh", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor HandlerMustBeMethod = new( + "AGH0002", + "Command handler must be a method group", + "The second argument to Map must be a method group (not a lambda or local function) so the generator can emit an AOT-compatible call.", + "Argh", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor ArgumentOrder = new( + "AGH0003", + "Invalid [Argument] parameter order", + "Parameters marked with [Argument] must start at position 0 and be consecutive.", + "Argh", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor UseMiddlewareDelegateNotSupported = new( + "AGH0006", + "Inline UseMiddleware delegate not emitted", + "UseMiddleware requires a type argument (UseMiddleware()) for source-generated middleware; inline delegates are not emitted.", + "Argh", + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor DuplicateCliNames = new( + "AGH0007", + "Duplicate CLI names", + "Multiple parameters map to the same CLI name '{0}' (conflicts when binding or generating help).", + "Argh", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor CollectionPositionalNotSupported = new( + "AGH0008", + "Collection parameters must be flags", + "Collection types are only supported for option flags, not for [Argument] positionals. Use a T[] type for a variadic positional.", + "Argh", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor VariadicMustBeLastPositional = new( + "AGH0031", + "Variadic positional must be last", + "A variadic positional (T[] with [Argument]) must be the last positional parameter; no [Argument] parameter may follow it.", + "Argh", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor VariadicCollectionMustBeArray = new( + "AGH0034", + "Variadic positional must be a T[] array", + "A variadic positional ([Argument] on a collection) must be declared as a T[] array type. List and other collection interfaces are not supported.", + "Argh", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor AsParametersEmptyType = new( + "AGH0009", + "AsParameters type has no bindable members", + "Type '{0}' must expose public primary constructor parameters and/or public settable properties (including inherited) for [AsParameters] binding.", + "Argh", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor DuplicateRootCommand = new( + "AGH0010", + "Duplicate default command", + "Only one default handler per scope: MapRoot, or [DefaultCommand].", + "Argh", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor AddRootCommandOnlyAtAppRoot = new( + "AGH0011", + "MapRoot only on the root app", + "Use MapRoot on the root ArghApp only (not inside MapNamespace). For a namespace default handler, call MapRoot inside the MapNamespace configure callback.", + "Argh", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor AddNamespaceRootCommandOnlyInNamespace = new( + "AGH0012", + "MapRoot only inside a namespace", + "Use MapRoot inside MapNamespace configuration. For the top-level default, use MapRoot at the app root.", + "Argh", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor ReservedCommandNameRoot = new( + "AGH0013", + "Reserved command name", + "The name '{0}' is reserved for root default commands; choose a different command name.", + "Argh", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor AddNamespaceRequiresExplicitDescriptionOrType = new( + "AGH0014", + "MapNamespace requires a description or entry type", + "Use MapNamespace(string name, string description, Action configure) with an explicit description (may be empty), or MapNamespace(string name, Action configure) to use type T's XML summary for the namespace listing.", + "Argh", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor AddNamespaceDescriptionNotConstant = new( + "AGH0015", + "MapNamespace description not a compile-time string", + "The description argument must be a string literal or const string so the generator can emit namespace help text.", + "Argh", + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor RedundantAddInsideAddNamespaceT = new( + "AGH0016", + "Redundant Map inside MapNamespace", + "MapNamespace<{0}> already registers public commands from that type; remove the inner Map<{0}> call.", + "Argh", + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor NamespaceSegmentUnresolved = new( + "AGH0017", + "Namespace segment could not be resolved", + "MapNamespace<{0}>() without a name requires [NamespaceSegment] with a string argument on the type and/or a single segment in the type XML .", + "Argh", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor NamespaceSegmentConflict = new( + "AGH0018", + "Conflicting namespace segment", + "Namespace segment for '{0}' is specified as '{1}' in one place and '{2}' in another; use a single source.", + "Argh", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor MultipleDefaultCommandAttributes = new( + "AGH0019", + "Multiple [DefaultCommand] attributes", + "Type '{0}' has more than one method marked [DefaultCommand]; keep at most one.", + "Argh", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor VacuousNamespace = new( + "AGH0020", + "Namespace registers no commands", + "This MapNamespace block does not register any commands, nested namespaces, or default handlers.", + "Argh", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor CommandMustInjectOptions = new( + "AGH0021", + "Command does not inject required options type", + "'{0}' must inject '{1}' as a method parameter or constructor parameter.", + "Argh", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor NamespaceSegmentSanitizationCollision = new( + "AGH0022", + "Namespace segment names collide after identifier sanitization", + "Namespace segment names '{0}' and '{1}' collide after identifier sanitization (both become '{2}').", + "Argh", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor UseCliDescriptionConflictsWithMapRoot = new( + "AGH0023", + "UseCliDescription conflicts with MapRoot", + "UseCliDescription cannot be combined with MapRoot: the root command handler's XML summary is already shown as the description. Remove one or the other.", + "Argh", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor UriSchemeOnNonUriParam = new( + "AGH0024", + "[UriScheme] applied to non-Uri parameter", + "'{0}' has [UriScheme] but its type is not Uri or Uri?; [UriScheme] only constrains Uri-typed parameters.", + "Argh", + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor TimeSpanRangeOnNonTimeSpanParam = new( + "AGH0025", + "[TimeSpanRange] applied to non-TimeSpan parameter", + "'{0}' has [TimeSpanRange] but its type is not TimeSpan or TimeSpan?; [TimeSpanRange] only constrains TimeSpan-typed parameters.", + "Argh", + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor BoolFlagCollidesWithNullableNegation = new( + "AGH0026", + "Bool flag collides with nullable bool negation", + "Parameter '{0}' maps to '--{1}', which duplicates the negation flag generated for a nullable bool on the same command. Rename one of the parameters.", + "Argh", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor DuplicateCommandName = new( + "AGH0027", + "Duplicate command name in scope", + "The command name '{0}' is registered more than once in the same scope. Only the first registration is used; rename one command or use [CommandName] to assign a unique name.", + "Argh", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor ReadOnlySetInvalidElementType = new( + "AGH0028", + "IReadOnlySet element type is not supported", + "IReadOnlySet only supports value-type or enum element types; '{0}' is not allowed", + "Usage", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor MapAndRootAliasAmbiguousTarget = new( + "AGH0029", + "MapAndRootAlias requires a [DefaultCommand] target", + "MapAndRootAlias<{0}> exposes multiple commands but none is marked [DefaultCommand]. Annotate exactly one method with [DefaultCommand] to designate the root alias target.", + "Argh", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor PathExistenceAttributesConflict = new( + "AGH0030", + "[Existing] and [NonExisting] conflict", + "Parameter '{0}' cannot declare both [Existing] and [NonExisting].", + "Argh", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor FilesystemPathAttributeTypeMismatch = new( + "AGH0032", + "Filesystem path attribute incompatible with parameter type", + "'{0}': {1}", + "Argh", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor DuplicateShortOption = new( + "AGH0033", + "Duplicate short option letter", + "The short option '-{0}' is used for more than one flag in the same parse scope: '--{1}' and '--{2}' ({3}).", + "Argh", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private sealed class DiagnosticAccumulator + { + private List? _diagnostics; + + public void Add(DiagnosticDescriptor descriptor, Location location, params string[] args) => + (_diagnostics ??= new()).Add(new PendingDiagnostic( + descriptor.Id, + SourceSpanInfo.From(location), + args.Length > 0 ? args[0] : "", + args.Length > 1 ? args[1] : "")); + + public ImmutableArray ToImmutable() => + _diagnostics is null ? ImmutableArray.Empty : _diagnostics.ToImmutableArray(); + } + + private static DiagnosticDescriptor GetDescriptorById(string id) => id switch + { + "AGH0002" => HandlerMustBeMethod, + "AGH0003" => ArgumentOrder, + "AGH0004" => CommandNamespaceOptionsMustExtendParent, + "AGH0005" => CommandNamespaceOptionsRequiresParent, + "AGH0006" => UseMiddlewareDelegateNotSupported, + "AGH0007" => DuplicateCliNames, + "AGH0008" => CollectionPositionalNotSupported, + "AGH0009" => AsParametersEmptyType, + "AGH0010" => DuplicateRootCommand, + "AGH0011" => AddRootCommandOnlyAtAppRoot, + "AGH0012" => AddNamespaceRootCommandOnlyInNamespace, + "AGH0013" => ReservedCommandNameRoot, + "AGH0014" => AddNamespaceRequiresExplicitDescriptionOrType, + "AGH0015" => AddNamespaceDescriptionNotConstant, + "AGH0016" => RedundantAddInsideAddNamespaceT, + "AGH0017" => NamespaceSegmentUnresolved, + "AGH0018" => NamespaceSegmentConflict, + "AGH0019" => MultipleDefaultCommandAttributes, + "AGH0020" => VacuousNamespace, + "AGH0021" => CommandMustInjectOptions, + "AGH0022" => NamespaceSegmentSanitizationCollision, + "AGH0023" => UseCliDescriptionConflictsWithMapRoot, + "AGH0024" => UriSchemeOnNonUriParam, + "AGH0025" => TimeSpanRangeOnNonTimeSpanParam, + "AGH0026" => BoolFlagCollidesWithNullableNegation, + "AGH0027" => DuplicateCommandName, + "AGH0028" => ReadOnlySetInvalidElementType, + "AGH0029" => MapAndRootAliasAmbiguousTarget, + "AGH0030" => PathExistenceAttributesConflict, + "AGH0032" => FilesystemPathAttributeTypeMismatch, + "AGH0033" => DuplicateShortOption, + _ => throw new ArgumentException($"Unknown diagnostic id: {id}") + }; + + /// + /// Per-invocation semantic analysis, intended for the CreateSyntaxProvider Select step. + /// Runs with a but produces a fully symbol-free + /// so the pipeline boundary data is stable across unrelated edits. + /// Diagnostics that cannot be reported here (no SourceProductionContext in Select step) are embedded + /// in the returned record via EmbeddedDiagnostics and reported later by TryBuildAppEmitModel. + /// +} diff --git a/src/Nullean.Argh.Generator/CliParserGenerator.Documentation.cs b/src/Nullean.Argh.Generator/CliParserGenerator.Documentation.cs new file mode 100644 index 0000000..938b375 --- /dev/null +++ b/src/Nullean.Argh.Generator/CliParserGenerator.Documentation.cs @@ -0,0 +1,792 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Globalization; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Operations; +using Microsoft.CodeAnalysis.Text; + +namespace Nullean.Argh; + +public sealed partial class CliParserGenerator +{ + private static string ExtractDocumentationFromTriviaList(SyntaxTriviaList triviaList) + { + // Fast path: structured documentation trivia (DocumentationMode=Parse or Diagnose) + foreach (var trivia in triviaList) + { + if (!trivia.IsKind(SyntaxKind.SingleLineDocumentationCommentTrivia) && + !trivia.IsKind(SyntaxKind.MultiLineDocumentationCommentTrivia)) + continue; + var stripped = DocTriviaStripPattern.Replace(trivia.ToFullString(), "").Trim(); + if (stripped.Length > 0) + return stripped; + } + + // Slow path: plain comment trivia (DocumentationMode=None, i.e. GenerateDocumentationFile not set). + // Collect consecutive /// lines immediately preceding the token. + var sb = new StringBuilder(); + foreach (var trivia in triviaList) + { + if (trivia.IsKind(SyntaxKind.SingleLineCommentTrivia)) + { + var s = trivia.ToString(); + if (s.StartsWith("///", StringComparison.Ordinal)) + { + sb.AppendLine(s); + continue; + } + } + // Non-doc trivia resets the accumulator so we only keep the block immediately before the declaration. + if (!trivia.IsKind(SyntaxKind.WhitespaceTrivia) && !trivia.IsKind(SyntaxKind.EndOfLineTrivia)) + sb.Clear(); + } + + if (sb.Length > 0) + { + var stripped = DocTriviaStripPattern.Replace(sb.ToString(), "").Trim(); + if (stripped.Length > 0) + return stripped; + } + + return ""; + } + + private static string TryExtractFullDocumentationFromTrivia(IMethodSymbol method) + { + foreach (var sr in method.DeclaringSyntaxReferences) + { + if (sr.GetSyntax() is not MethodDeclarationSyntax m) + continue; + var result = ExtractDocumentationFromTriviaList(m.GetLeadingTrivia()); + if (result.Length > 0) + return result; + } + + return ""; + } + + private static string TryExtractFullDocumentationFromPropertyTrivia(IPropertySymbol prop) + { + foreach (var sr in prop.DeclaringSyntaxReferences) + { + switch (sr.GetSyntax()) + { + case PropertyDeclarationSyntax p: + { + var result = ExtractDocumentationFromTriviaList(p.GetLeadingTrivia()); + if (result.Length > 0) + return result; + break; + } + // Positional record (and class primary-constructor) parameters: the public API is a property + // whose declaring syntax is the parameter, not a property declaration. + case ParameterSyntax par: + { + var result = ExtractDocumentationFromTriviaList(par.GetLeadingTrivia()); + if (result.Length > 0) + return result; + break; + } + } + } + + return ""; + } + + private static string TryExtractDocumentationFromParameterTrivia(IParameterSymbol param) + { + foreach (var sr in param.DeclaringSyntaxReferences) + { + if (sr.GetSyntax() is not ParameterSyntax par) + continue; + var result = ExtractDocumentationFromTriviaList(par.GetLeadingTrivia()); + if (result.Length > 0) + return result; + } + + return ""; + } + + private static string TryExtractFullDocumentationFromFieldTrivia(IFieldSymbol field) + { + foreach (var sr in field.DeclaringSyntaxReferences) + { + switch (sr.GetSyntax()) + { + case FieldDeclarationSyntax fd: + { + var result = ExtractDocumentationFromTriviaList(fd.GetLeadingTrivia()); + if (result.Length > 0) + return result; + break; + } + case VariableDeclaratorSyntax vd when vd.Parent is VariableDeclarationSyntax { Parent: FieldDeclarationSyntax fd }: + { + var result = ExtractDocumentationFromTriviaList(fd.GetLeadingTrivia()); + if (result.Length > 0) + return result; + break; + } + } + } + + return ""; + } + + private static string TryExtractFullDocumentationFromTypeTrivia(INamedTypeSymbol type) + { + foreach (var sr in type.DeclaringSyntaxReferences) + { + if (sr.GetSyntax() is not BaseTypeDeclarationSyntax typeDecl) + continue; + var result = ExtractDocumentationFromTriviaList(typeDecl.GetLeadingTrivia()); + if (result.Length > 0) + return result; + } + + return ""; + } + + private static string TryExtractTypeSummaryFromTrivia(INamedTypeSymbol type) + { + foreach (var sr in type.DeclaringSyntaxReferences) + { + if (sr.GetSyntax() is not BaseTypeDeclarationSyntax typeDecl) + continue; + foreach (var trivia in typeDecl.GetLeadingTrivia()) + { + if (!trivia.HasStructure || trivia.GetStructure() is not DocumentationCommentTriviaSyntax doc) + continue; + foreach (var xml in doc.Content) + { + if (xml is XmlElementSyntax xe && xe.StartTag.Name.LocalName.ValueText == "summary") + { + var s = FlattenXmlSummaryElementText(xe).Trim(); + if (s.Length > 0) + return s; + } + } + } + } + + return ""; + } + + private static string FlattenXmlSummaryElementText(XmlElementSyntax xe) + { + var sb = new StringBuilder(); + foreach (var n in xe.Content) + { + switch (n) + { + case XmlTextSyntax txt: + foreach (var t in txt.TextTokens) + sb.Append(t.ValueText); + break; + case XmlElementSyntax inner: + sb.Append(FlattenXmlSummaryElementText(inner)); + break; + case XmlEmptyElementSyntax: + break; + } + } + + return sb.ToString(); + } + + private static string GetTypeListingSummaryOneLiner(INamedTypeSymbol type) + { + var xml = type.GetDocumentationCommentXml(); + if (string.IsNullOrWhiteSpace(xml)) + xml = TryExtractFullDocumentationFromTypeTrivia(type); + if (!string.IsNullOrWhiteSpace(xml)) + { + var fromXml = Documentation.GetTypeSummaryLine(xml); + if (!string.IsNullOrWhiteSpace(fromXml)) + return fromXml.Trim(); + } + return ""; + } + + private static MethodDocumentation MergeMethodDocumentationFromTrivia( + IMethodSymbol method, + MethodDocumentation docs, + CSharpParseOptions parseOptions) + { + // GetDocumentationCommentXml() is empty when GenerateDocumentationFile is not set. + // In that case parse the full doc comment directly from syntax trivia so all fields + // (summary, remarks, params, examples) are recovered without requiring that MSBuild property. + if (string.IsNullOrWhiteSpace(docs.SummaryOneLiner)) + { + var full = TryExtractFullDocumentationFromTrivia(method); + if (full.Length > 0) + { + var fromTrivia = Documentation.ParseMethod(full, parseOptions); + if (!string.IsNullOrWhiteSpace(fromTrivia.SummaryOneLiner)) + return fromTrivia; + } + } + + return docs; + } + + private static string? TransformRemarksInnerXmlForHelp( + string? innerXml, + CommandModel forCommand, + ImmutableArray allCommands, + string entryAssemblyName) + { + if (string.IsNullOrWhiteSpace(innerXml)) + return innerXml; + + var crefToCommand = new Dictionary(StringComparer.Ordinal); + foreach (var c in allCommands) + { + if (c.IsLambda || string.IsNullOrEmpty(c.HandlerDocCommentId)) + continue; + if (crefToCommand.ContainsKey(c.HandlerDocCommentId)) + continue; + crefToCommand[c.HandlerDocCommentId] = c; + } + + var flagBySymbol = new Dictionary(StringComparer.Ordinal); + foreach (var p in forCommand.Parameters) + { + if (p.Kind == ParameterKind.Flag) + flagBySymbol[p.SymbolName] = p; + } + + XElement root; + try + { + root = XElement.Parse("" + innerXml + "", LoadOptions.PreserveWhitespace); + } + catch + { + return innerXml; + } + + foreach (var e in root.Descendants().ToList()) + { + if (e.Name.LocalName == "paramref") + { + var nameAttr = e.Attribute("name")?.Value; + if (!string.IsNullOrEmpty(nameAttr) && + flagBySymbol.TryGetValue(nameAttr!, out var pm)) + e.ReplaceWith(new XElement("c", "--" + pm.CliLongName)); + continue; + } + + if (e.Name.LocalName != "see") + continue; + + if (e.Attribute("langword") is not null || e.Attribute("href") is not null) + continue; + + var crefAttr = e.Attribute("cref")?.Value; + if (string.IsNullOrEmpty(crefAttr)) + continue; + + CommandModel? cmd = null; + if (crefToCommand.TryGetValue(crefAttr!, out var byId)) + cmd = byId; + else + { + foreach (var c in allCommands) + { + if (c.IsLambda || string.IsNullOrEmpty(c.HandlerDocCommentId)) + continue; + if (!DocumentationCrefMatchesDocId(crefAttr!, c.HandlerDocCommentId)) + continue; + cmd = c; + break; + } + } + + if (cmd is not null) + e.ReplaceWith(new XElement("c", BuildCommandUsageSynopsisTail(cmd, entryAssemblyName))); + } + + return string.Concat(root.Nodes().Select(n => n.ToString())); + } + + private static string BuildCommandUsageSynopsisTail(CommandModel cmd, string entryAssemblyName) + { + var routeUsage = cmd.RoutePrefix.IsDefaultOrEmpty + ? "" + : string.Join(" ", cmd.RoutePrefix) + " "; + return $"{entryAssemblyName} {routeUsage}{cmd.CommandName} {cmd.UsageHints}".TrimEnd(); + } + + /// + /// vs XML cref: compiler XML may use the full M:… id or a short form (e.g. Type.Method). + /// + private static bool DocumentationCrefMatchesMethod(string cref, IMethodSymbol method) + { + if (string.IsNullOrEmpty(cref)) + return false; + + cref = cref.Replace("global::", ""); + + if (method.GetDocumentationCommentId() is not { Length: > 0 } fullId) + return false; + + return DocumentationCrefMatchesDocId(cref, fullId); + } + + /// String-based version of that takes the pre-extracted doc comment id. + private static bool DocumentationCrefMatchesDocId(string cref, string fullId) + { + if (string.IsNullOrEmpty(cref) || string.IsNullOrEmpty(fullId)) + return false; + + cref = cref.Replace("global::", ""); + fullId = fullId.Replace("global::", ""); + + if (string.Equals(cref, fullId, StringComparison.Ordinal)) + return true; + + if (!fullId.StartsWith("M:", StringComparison.Ordinal) || fullId.Length < 3) + return false; + + var sigParen = fullId.IndexOf('(', 2); + var qualifiedMember = sigParen >= 2 ? fullId.Substring(2, sigParen - 2) : fullId.Substring(2); + + if (string.Equals(cref, qualifiedMember, StringComparison.Ordinal)) + return true; + + // e.g. cref "CliRegistrationModule.DocLambdaEcho" or "Demo" for "…DocsCommands.Demo(…)". + if (qualifiedMember.EndsWith(cref, StringComparison.Ordinal)) + return true; + + return false; + } + + private static class Documentation + { + public static MethodDocumentation ParseMethod(string? xml, CSharpParseOptions parseOptions) + { + if (string.IsNullOrWhiteSpace(xml)) + return new MethodDocumentation("", "", "", "", "", ImmutableDictionary.Empty, + ImmutableDictionary.Empty); + + try + { + var doc = XDocument.Parse("" + xml + "", LoadOptions.PreserveWhitespace); + var root = doc.Root; + if (root is null) + return new MethodDocumentation("", "", "", "", "", ImmutableDictionary.Empty, + ImmutableDictionary.Empty); + + var summary = WhitespaceCollapsePattern.Replace(FlattenBlock(root.Element("summary")).Replace("\r\n", "\n"), " ").Trim(); + var remarks = FlattenBlock(root.Element("remarks")).Replace("\r\n", "\n").Trim(); + var summaryInner = GetElementInnerXml(root.Element("summary")); + var remarksInner = GetElementInnerXml(root.Element("remarks")); + var examples = string.Join("\n\n", root.Elements("example") + .Select(e => FlattenBlock(e).Replace("\r\n", "\n").Trim()) + .Where(s => !string.IsNullOrWhiteSpace(s))); + var paramMap = + ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); + var sepMap = + ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); + foreach (var pe in root.Elements("param")) + { + var name = pe.Attribute("name")?.Value; + if (string.IsNullOrEmpty(name)) + continue; + + var sepEl = pe.Elements().FirstOrDefault(e => e.Name.LocalName == "separator"); + if (sepEl is not null && !string.IsNullOrEmpty(sepEl.Value)) + sepMap[name!] = sepEl.Value.Trim(); + + paramMap[name!] = FlattenParam(pe); + } + + return new MethodDocumentation(summary, remarks, examples, summaryInner, remarksInner, paramMap.ToImmutable(), sepMap.ToImmutable()); + } + catch + { + return new MethodDocumentation("", "", "", "", "", ImmutableDictionary.Empty, + ImmutableDictionary.Empty); + } + } + + private static string GetElementInnerXml(XElement? el) + { + if (el is null) + return ""; + return string.Concat(el.Nodes().Select(n => n.ToString())); + } + + public static string GetParamDocFromType(INamedTypeSymbol type, string parameterName, Compilation? compilation = null, string? fallbackXml = null) + { + var xml = type.GetDocumentationCommentXml(); + if (string.IsNullOrWhiteSpace(xml)) + xml = GetDocumentationXmlFromMetadataReference(type, compilation); + if (string.IsNullOrWhiteSpace(xml)) + xml = fallbackXml; + return GetParamDocFromXmlFragment(xml, parameterName); + } + + /// Extracts <param name="…"> text from documentation XML (handles <member>-wrapped compiler output). + public static string GetParamDocFromXmlFragment(string? xml, string parameterName) + { + if (string.IsNullOrWhiteSpace(xml)) + return ""; + try + { + var doc = XDocument.Parse("" + xml + "", LoadOptions.PreserveWhitespace); + var root = doc.Root; + if (root is null) + return ""; + foreach (var pe in root.Descendants()) + { + if (!string.Equals(pe.Name.LocalName, "param", StringComparison.Ordinal)) + continue; + if (string.Equals(pe.Attribute("name")?.Value, parameterName, StringComparison.Ordinal)) + return FlattenParam(pe); + } + } + catch + { + // ignore + } + + return ""; + } + + public static string GetPropertySummaryLine(IPropertySymbol prop, Compilation? compilation = null, string? fallbackXml = null) + { + var xml = prop.GetDocumentationCommentXml(); + if (string.IsNullOrWhiteSpace(xml)) + xml = GetDocumentationXmlFromMetadataReference(prop, compilation); + if (string.IsNullOrWhiteSpace(xml)) + xml = fallbackXml; + // Compiler / GetDocumentationCommentXml often wraps content in ; use the same + // summary resolution as types (descendant ) so help text is not dropped. + return GetTypeSummaryLine(xml); + } + + public static string GetFieldSummaryLine(IFieldSymbol field, Compilation? compilation = null, string? fallbackXml = null) + { + var xml = field.GetDocumentationCommentXml(); + if (string.IsNullOrWhiteSpace(xml)) + xml = GetDocumentationXmlFromMetadataReference(field, compilation); + if (string.IsNullOrWhiteSpace(xml)) + xml = fallbackXml; + return GetTypeSummaryLine(xml); + } + + public static string GetDocumentationXmlFromMetadataReference(ISymbol symbol, Compilation? compilation, string? artifactsPath = null) + { + if (compilation is null) + return ""; + var docId = symbol.GetDocumentationCommentId(); + if (string.IsNullOrWhiteSpace(docId)) + return ""; + var containingAssembly = symbol.ContainingAssembly; + if (containingAssembly is null) + return ""; + +#pragma warning disable RS1035 // Required to load companion XML docs for metadata references. + foreach (var reference in compilation.References) + { + if (compilation.GetAssemblyOrModuleSymbol(reference) is not IAssemblySymbol referenceAssembly) + continue; + if (!SymbolEqualityComparer.Default.Equals(referenceAssembly, containingAssembly)) + continue; + var referenceDisplay = reference.Display; + if (string.IsNullOrWhiteSpace(referenceDisplay)) + continue; + foreach (var xmlPath in GetXmlDocumentationCandidates(referenceDisplay!, containingAssembly.Name, artifactsPath)) + { + if (!global::System.IO.File.Exists(xmlPath)) + continue; + try + { + var doc = XDocument.Load(xmlPath, LoadOptions.PreserveWhitespace); + var member = doc.Root? + .Element("members")? + .Elements("member") + .FirstOrDefault(m => string.Equals(m.Attribute("name")?.Value, docId, StringComparison.Ordinal)); + if (member is not null) + return string.Concat(member.Nodes().Select(n => n.ToString())); + } + catch + { + // ignore malformed external XML docs + } + } + } +#pragma warning restore RS1035 + + return ""; + } + + private static IEnumerable GetXmlDocumentationCandidates(string referencePath, string assemblyName, string? artifactsPath = null) + { + var yielded = new HashSet(StringComparer.OrdinalIgnoreCase); + static string NormalizePathSeparators(string p) => p.Replace('\\', '/'); + + var direct = global::System.IO.Path.ChangeExtension(referencePath, ".xml"); + if (!string.IsNullOrWhiteSpace(direct) && yielded.Add(direct)) + yield return direct; + + var referenceDir = global::System.IO.Path.GetDirectoryName(referencePath); + if (!string.IsNullOrWhiteSpace(referenceDir)) + { + var byAssemblyName = global::System.IO.Path.Combine(referenceDir!, assemblyName + ".xml"); + if (yielded.Add(byAssemblyName)) + yield return byAssemblyName; + var leaf = global::System.IO.Path.GetFileName(referenceDir); + if (string.Equals(leaf, "ref", StringComparison.OrdinalIgnoreCase) || + string.Equals(leaf, "refint", StringComparison.OrdinalIgnoreCase)) + { + var parent = global::System.IO.Path.GetDirectoryName(referenceDir!); + if (!string.IsNullOrWhiteSpace(parent)) + { + var sibling = global::System.IO.Path.Combine(parent, assemblyName + ".xml"); + if (yielded.Add(sibling)) + yield return sibling; + } + } + } + + var normalized = NormalizePathSeparators(referencePath); + var objMarker = "/obj/"; + var idxObj = normalized.IndexOf(objMarker, StringComparison.OrdinalIgnoreCase); + if (idxObj >= 0) + { + var binPath = normalized.Substring(0, idxObj) + "/bin/" + normalized.Substring(idxObj + objMarker.Length); + binPath = binPath.Replace("/refint/", "/").Replace("/ref/", "/"); + var platformPath = binPath.Replace('/', global::System.IO.Path.DirectorySeparatorChar); + var binXml = global::System.IO.Path.ChangeExtension(platformPath, ".xml"); + if (yielded.Add(binXml)) + yield return binXml; + } + + // When is known, build the canonical bin/{Project}/{Pivot}/{Assembly}.xml + // path even when the reference points to a ref/, refint/, or obj/ subdirectory. + if (!string.IsNullOrWhiteSpace(artifactsPath)) + { + var normalizedArtifacts = NormalizePathSeparators(artifactsPath!.TrimEnd('/', '\\')); + if (normalized.Length > normalizedArtifacts.Length && + normalized[normalizedArtifacts.Length] == '/' && + normalized.StartsWith(normalizedArtifacts, StringComparison.OrdinalIgnoreCase)) + { + var relPath = normalized.Substring(normalizedArtifacts.Length + 1); + if (relPath.StartsWith("obj/", StringComparison.OrdinalIgnoreCase)) + relPath = "bin/" + relPath.Substring(4); + relPath = ReplaceOrdinalIgnoreCase(ReplaceOrdinalIgnoreCase(relPath, "/refint/", "/"), "/ref/", "/"); + var platformPath = (normalizedArtifacts + "/" + relPath) + .Replace('/', global::System.IO.Path.DirectorySeparatorChar); + var artifactXml = global::System.IO.Path.ChangeExtension(platformPath, ".xml"); + if (yielded.Add(artifactXml)) + yield return artifactXml; + } + } + } + + private static string ReplaceOrdinalIgnoreCase(string input, string oldValue, string newValue) + { + var idx = input.IndexOf(oldValue, StringComparison.OrdinalIgnoreCase); + if (idx < 0) return input; + return input.Substring(0, idx) + newValue + input.Substring(idx + oldValue.Length); + } + + /// First line of <summary> for a type symbol (handles <member>-wrapped XML from Roslyn). + public static string GetTypeSummaryLine(string? xml) + { + if (string.IsNullOrWhiteSpace(xml)) + return ""; + try + { + var doc = XDocument.Parse("" + xml + "", LoadOptions.PreserveWhitespace); + var root = doc.Root; + if (root is null) + return ""; + var sum = root.Element("summary"); + if (sum is null) + { + foreach (var e in root.Descendants()) + { + if (e.Name.LocalName == "summary") + { + sum = e; + break; + } + } + } + + if (sum is null) + return ""; + return FlattenBlock(sum).Replace("\r\n", "\n").Trim(); + } + catch + { + return ""; + } + } + + /// Inner XML of <summary> and <remarks> for a type symbol. + public static (string SummaryInnerXml, string RemarksInnerXml) GetTypeDocumentation(string? xml) + { + if (string.IsNullOrWhiteSpace(xml)) + return ("", ""); + try + { + var doc = XDocument.Parse("" + xml + "", LoadOptions.PreserveWhitespace); + var root = doc.Root; + if (root is null) + return ("", ""); + // Roslyn wraps type XML in a element + var search = root.Element("member") ?? root; + return (GetElementInnerXml(search.Element("summary")), GetElementInnerXml(search.Element("remarks"))); + } + catch + { + return ("", ""); + } + } + + private static string FlattenParam(XElement param) + { + var sb = new StringBuilder(); + foreach (var n in param.Nodes()) + { + if (n is XElement e && e.Name.LocalName == "separator") + continue; + FlattenNodes(new[] { n }, sb); + } + + return sb.ToString().Trim(); + } + + public static string FlattenBlockPublic(XElement? element) => FlattenBlock(element); + + private static string FlattenBlock(XElement? element) + { + if (element is null) + return ""; + + var sb = new StringBuilder(); + FlattenNodes(element.Nodes(), sb); + return sb.ToString(); + } + + private static void FlattenNodes(IEnumerable nodes, StringBuilder sb) + { + foreach (var n in nodes) + { + switch (n) + { + case XText t: + sb.Append(t.Value); + break; + case XElement e when e.Name.LocalName == "para": + if (sb.Length > 0) + sb.AppendLine(); + FlattenNodes(e.Nodes(), sb); + break; + case XElement e when e.Name.LocalName == "code": + sb.AppendLine(); + foreach (var c in e.Nodes()) + { + if (c is XText tx) + sb.Append(" ").AppendLine(tx.Value.TrimEnd()); + } + + break; + case XElement e when e.Name.LocalName == "list": + if (sb.Length > 0) + sb.AppendLine(); + foreach (var item in e.Elements().Where(x => x.Name.LocalName == "item")) + { + sb.Append(" - "); + var desc = item.Element("description"); + if (desc is not null) + FlattenNodes(desc.Nodes(), sb); + else + FlattenNodes(item.Nodes(), sb); + sb.AppendLine(); + } + + break; + case XElement e when e.Name.LocalName == "c": + sb.Append(e.Value.Trim()); + break; + case XElement e when e.Name.LocalName == "paramref": + { + var pn = e.Attribute("name")?.Value; + if (!string.IsNullOrEmpty(pn)) + sb.Append(pn); + break; + } + case XElement e when e.Name.LocalName == "typeparamref": + { + var tn = e.Attribute("name")?.Value; + if (!string.IsNullOrEmpty(tn)) + sb.Append(tn); + break; + } + case XElement e when e.Name.LocalName == "see": + AppendSeeForListing(e, sb); + break; + case XElement e: + FlattenNodes(e.Nodes(), sb); + break; + } + } + } + + private static void AppendSeeForListing(XElement e, StringBuilder sb) + { + var lang = e.Attribute("langword")?.Value; + if (!string.IsNullOrEmpty(lang)) + { + sb.Append(lang); + return; + } + + var href = e.Attribute("href")?.Value; + if (!string.IsNullOrEmpty(href)) + { + var vis = string.IsNullOrWhiteSpace(e.Value) ? href! : e.Value.Trim(); + sb.Append(vis); + return; + } + + var cref = e.Attribute("cref")?.Value; + if (!string.IsNullOrEmpty(cref)) + { + var vis = string.IsNullOrWhiteSpace(e.Value) ? CrefShortNameForListing(cref!) : e.Value.Trim(); + sb.Append(vis); + return; + } + + FlattenNodes(e.Nodes(), sb); + } + + private static string CrefShortNameForListing(string cref) + { + if (string.IsNullOrEmpty(cref)) + return ""; + var colon = cref.IndexOf(':'); + var tail = colon >= 0 ? cref.Substring(colon + 1) : cref; + var dot = tail.LastIndexOf('.'); + var name = dot >= 0 ? tail.Substring(dot + 1) : tail; + var paren = name.IndexOf('('); + if (paren >= 0) + name = name.Substring(0, paren); + return name; + } + } + +} diff --git a/src/Nullean.Argh.Generator/CliParserGenerator.Emit.Dispatch.cs b/src/Nullean.Argh.Generator/CliParserGenerator.Emit.Dispatch.cs new file mode 100644 index 0000000..20c1048 --- /dev/null +++ b/src/Nullean.Argh.Generator/CliParserGenerator.Emit.Dispatch.cs @@ -0,0 +1,913 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Globalization; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Operations; +using Microsoft.CodeAnalysis.Text; + +namespace Nullean.Argh; + +public sealed partial class CliParserGenerator +{ + private static string GetArghGeneratedRootTypeName(string assemblyName) + { + using var sha = SHA256.Create(); + var h = sha.ComputeHash(Encoding.UTF8.GetBytes(assemblyName)); + return "ArghGenerated_" + BitConverter.ToString(h, 0, 4).Replace("-", ""); + } + + private static string? TryGetStringConstant(SemanticModel model, ExpressionSyntax expr) + { + var lit = TryGetStringLiteral(expr); + if (lit is not null) + return lit; + var cv = model.GetConstantValue(expr); + if (cv.HasValue && cv.Value is string s) + return s; + return null; + } + + private static void EmitNamespaceSegmentCodegen(SourceProductionContext context, AppEmitModel app) + { + if (app.ArglessNamespaceCodegen.Count == 0) + return; + + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine("using Nullean.Argh;"); + sb.AppendLine(); + sb.AppendLine("internal static class ArghNamespaceSegmentInitializer"); + sb.AppendLine("{"); + sb.AppendLine(" [System.Runtime.CompilerServices.ModuleInitializer]"); + sb.AppendLine(" internal static void Init()"); + sb.AppendLine(" {"); + foreach (var e in app.ArglessNamespaceCodegen) + { + var escaped = e.Segment.Replace("\\", "\\\\").Replace("\"", "\\\""); + sb.AppendLine("\t\tglobal::Nullean.Argh.ArghNamespaceSegmentCodegen.Set<" + e.TypeFq + ">(\"" + escaped + "\");"); + } + sb.AppendLine(" }"); + sb.AppendLine("}"); + context.AddSource("ArghNamespaceSegmentInitializer.g.cs", SourceText.From(sb.ToString(), Encoding.UTF8)); + } + + + private static void EmitEmpty(SourceProductionContext context, string assemblyName, string assemblyVersion) + { + const string source = """ + // + #nullable enable + using System; + using System.Threading.Tasks; + using Nullean.Argh.Middleware; + using Nullean.Argh.Help; + using Nullean.Argh.Matching; + using Nullean.Argh.Runtime; + using Nullean.Argh.Schema; + + namespace Nullean.Argh + { + /// Source-generated CLI entry point from ArghApp registrations. At the root, __completion bash|zsh|fish prints a shell script from . + public static class ArghGenerated + { + public static Task RunAsync(string[] args) => + Task.FromResult(Run(args)); + + public static bool TryParseRoute(string[] args, out RouteMatch match) + { + match = default; + if (CompletionProtocol.IsArghMetaCompletionInvocation(args)) + return false; + return false; + } + + public static RouteMatch? Route(string[] args) + { + if (args is null) + throw new ArgumentNullException(nameof(args)); + if (!TryParseRoute(args, out var m)) + return null; + return m; + } + + private static int Run(string[] args) + { + if (CompletionProtocol.IsCompletionScriptInvocation(args)) + { + if (!CompletionProtocol.TryParseCompletionScriptInvocation(args, out var __scriptShell)) + { + System.Console.Error.WriteLine("Error: expected '__completion bash|zsh|fish'"); + return 2; + } + var appName = "__ARGH_EMBED_ASM_NAME__"; + switch (__scriptShell) + { + case CompletionShell.Bash: + System.Console.Out.Write(CompletionScriptTemplates.GetBash().Replace("{0}", appName)); + return 0; + case CompletionShell.Zsh: + System.Console.Out.Write(CompletionScriptTemplates.GetZsh().Replace("{0}", appName)); + return 0; + case CompletionShell.Fish: + System.Console.Out.Write(CompletionScriptTemplates.GetFish().Replace("{0}", appName)); + return 0; + default: + return 2; + } + } + + if (CompletionProtocol.IsCompleteInvocation(args)) + { + if (!CompletionProtocol.TryParseCompleteInvocation(args, out _, out var __words)) + { + System.Console.Error.WriteLine("Error: expected '__complete -- [words...]'"); + return 2; + } + Complete(default, __words); + return 0; + } + + if (CompletionProtocol.IsSchemaInvocation(args)) + { + System.Console.Out.Write(ArghRuntime.FormatCliSchemaJson()); + return 0; + } + + if (args.Length > 0 && (args[0] == "--help" || args[0] == "-h")) + { + System.Console.Out.WriteLine("No commands are registered."); + return 0; + } + + if (args.Length > 0 && args[0] == "--version") + { + PrintVersion(); + return 0; + } + + System.Console.Error.WriteLine("No commands are registered."); + return 2; + } + + private static void Complete(CompletionShell shell, ReadOnlySpan words) + { + _ = shell; + _ = words; + } + + private static void PrintVersion() + { + System.Console.Out.WriteLine("__ARGH_EMBED_ASM_VER__"); + } + + internal static bool IsIntrinsicCommand(string[] args) => false; + + internal static ArghCliSchemaDocument BuildCliSchemaDocument() => + new ArghCliSchemaDocument( + 1, + "__ARGH_EMBED_ASM_NAME__", + "__ARGH_EMBED_ASM_VER__", + null, + new[] { "__complete", "__completion", "__schema" }, + Array.Empty(), + null, + Array.Empty(), + Array.Empty()); + } + + internal static class ArghGeneratedRuntimeRegistration + { + [System.Runtime.CompilerServices.ModuleInitializer] + internal static void RegisterArghRuntime() + { + ArghRuntime.RegisterRunner(ArghGenerated.RunAsync); + ArghRuntime.RegisterRoute(ArghGenerated.Route); + ArghRuntime.RegisterCliSchema(ArghGenerated.BuildCliSchemaDocument); + ArghRuntime.RegisterIntrinsic(ArghGenerated.IsIntrinsicCommand); + } + } + } + """; + var root = GetArghGeneratedRootTypeName(assemblyName); + var resolved = source + .Replace("__ARGH_EMBED_ASM_NAME__", Escape(assemblyName)) + .Replace("__ARGH_EMBED_ASM_VER__", Escape(assemblyVersion)) + .Replace("internal static class ArghGeneratedRuntimeRegistration", "internal static class " + root + "RuntimeRegistration") + .Replace("public static class ArghGenerated", "public static class " + root) + .Replace("ArghGenerated.", root + "."); + context.AddSource("ArghGenerated.g.cs", SourceText.From(resolved, Encoding.UTF8)); + } + + + private static void AppendArghRuntimeModuleInitializer(StringBuilder sb, string rootTypeName) + { + sb.AppendLine(); + sb.AppendLine("\tinternal static class " + rootTypeName + "RuntimeRegistration"); + sb.AppendLine("\t{"); + sb.AppendLine("\t\t[System.Runtime.CompilerServices.ModuleInitializer]"); + sb.AppendLine("\t\tinternal static void RegisterArghRuntime()"); + sb.AppendLine("\t\t{"); + sb.AppendLine("\t\t\tArghRuntime.RegisterRunner(" + rootTypeName + ".RunAsync);"); + sb.AppendLine("\t\t\tArghRuntime.RegisterRoute(" + rootTypeName + ".Route);"); + sb.AppendLine("\t\t\tArghRuntime.RegisterCliSchema(" + rootTypeName + ".BuildCliSchemaDocument);"); + sb.AppendLine("\t\t\tArghRuntime.RegisterIntrinsic(" + rootTypeName + ".IsIntrinsicCommand);"); + sb.AppendLine("\t\t}"); + sb.AppendLine("\t}"); + } + + private const int FuzzyMaxDistance = 2; + + private static void EmitRootCompletionScriptBlock(StringBuilder sb, string indent, string entryAssemblyName) + { + sb.AppendLine(indent + "if (CompletionProtocol.IsCompletionScriptInvocation(args))"); + sb.AppendLine(indent + "{"); + sb.AppendLine(indent + "\tif (!CompletionProtocol.TryParseCompletionScriptInvocation(args, out var __scriptShell))"); + sb.AppendLine(indent + "\t{"); + sb.AppendLine(indent + "\t\tConsole.Error.WriteLine(\"Error: expected '__completion bash|zsh|fish'\");"); + sb.AppendLine(indent + "\t\treturn 2;"); + sb.AppendLine(indent + "\t}"); + sb.AppendLine(indent + "\tvar __entry = \"" + Escape(entryAssemblyName) + "\";"); + sb.AppendLine(indent + "\tswitch (__scriptShell)"); + sb.AppendLine(indent + "\t{"); + sb.AppendLine(indent + "\t\tcase CompletionShell.Bash:"); + sb.AppendLine(indent + "\t\t\tConsole.Out.Write(CompletionScriptTemplates.GetBash().Replace(\"{0}\", __entry));"); + sb.AppendLine(indent + "\t\t\treturn 0;"); + sb.AppendLine(indent + "\t\tcase CompletionShell.Zsh:"); + sb.AppendLine(indent + "\t\t\tConsole.Out.Write(CompletionScriptTemplates.GetZsh().Replace(\"{0}\", __entry));"); + sb.AppendLine(indent + "\t\t\treturn 0;"); + sb.AppendLine(indent + "\t\tcase CompletionShell.Fish:"); + sb.AppendLine(indent + "\t\t\tConsole.Out.Write(CompletionScriptTemplates.GetFish().Replace(\"{0}\", __entry));"); + sb.AppendLine(indent + "\t\t\treturn 0;"); + sb.AppendLine(indent + "\t\tdefault:"); + sb.AppendLine(indent + "\t\t\treturn 2;"); + sb.AppendLine(indent + "\t}"); + sb.AppendLine(indent + "}"); + sb.AppendLine(); + } + + + private static void EmitFuzzyDispatchDefault( + StringBuilder sb, + RegistryNode node, + ImmutableArray path, + string entryAssemblyName) + { + var entries = new List<(string Name, string Summary, string HelpPrinter)>(); + foreach (var cmd in node.Commands) + entries.Add((cmd.CommandName, cmd.SummaryOneLiner, $"PrintHelp_{cmd.RunMethodName}")); + foreach (var ch in node.Children) + { + var childPath = AppendSegment(path, ch.Segment); + var gk = CommandNamespacePathKey(childPath); + entries.Add((ch.Segment, "", $"PrintHelp_CommandNamespace_{gk}")); + } + + var sorted = + entries.OrderBy(e => e.Name, StringComparer.Ordinal).ToList(); + + var pathPrefix = path.IsDefaultOrEmpty ? "" : string.Join(" ", path) + " "; + var nsHelp = path.IsDefaultOrEmpty ? "--help" : Escape(string.Join(" ", path)) + " --help"; + + sb.AppendLine("\t\t\t\tvar __tok = tok;"); + sb.AppendLine("\t\t\t\tvar __app = \"" + Escape(entryAssemblyName) + "\";"); + sb.Append("\t\t\t\tvar __cands = new string[] { "); + for (var i = 0; i < sorted.Count; i++) + { + if (i > 0) + sb.Append(", "); + sb.Append('"').Append(Escape(sorted[i].Name)).Append('"'); + } + + sb.AppendLine(" };"); + sb.AppendLine($"\t\t\t\tvar __matches = FuzzyMatch.FindClosest(__tok, __cands, {FuzzyMaxDistance});"); + const string kind = "command or namespace"; + sb.AppendLine("\t\t\t\tif (__matches.Count == 0)"); + sb.AppendLine("\t\t\t\t{"); + sb.AppendLine($"\t\t\t\t\tConsole.Error.WriteLine($\"Error: unknown {kind} '{{__tok}}'.\");"); + sb.AppendLine("\t\t\t\t\tConsole.Error.WriteLine();"); + sb.AppendLine($"\t\t\t\t\tConsole.Error.WriteLine($\"Run '{{__app}} {nsHelp}' for usage.\");"); + sb.AppendLine("\t\t\t\t}"); + sb.AppendLine("\t\t\t\telse if (__matches.Count == 1)"); + sb.AppendLine("\t\t\t\t{"); + sb.AppendLine("\t\t\t\t\tvar __m = __matches[0];"); + sb.AppendLine($"\t\t\t\t\tConsole.Error.WriteLine($\"Error: unknown {kind} '{{__tok}}'. Did you mean '{Escape(pathPrefix)}{{__m}}'?\");"); + sb.AppendLine("\t\t\t\t\tConsole.Error.WriteLine();"); + sb.AppendLine($"\t\t\t\t\tConsole.Error.WriteLine($\"Run '{{__app}} {Escape(pathPrefix)}{{__m}} --help' for usage.\");"); + sb.AppendLine($"\t\t\t\t\tConsole.Error.WriteLine($\"Run '{{__app}} {nsHelp}' for usage.\");"); + sb.AppendLine("\t\t\t\t}"); + sb.AppendLine("\t\t\t\telse"); + sb.AppendLine("\t\t\t\t{"); + sb.AppendLine($"\t\t\t\t\tConsole.Error.WriteLine($\"Error: unknown {kind} '{{__tok}}'. Did you mean one of these?\");"); + sb.AppendLine("\t\t\t\t\tConsole.Error.WriteLine();"); + foreach (var e in sorted) + { + var qualifiedName = Escape(pathPrefix + e.Name); + var sum = Escape(e.Summary); + sb.AppendLine( + $"\t\t\t\t\tif (__matches.Any(__x => string.Equals(__x, \"{Escape(e.Name)}\", StringComparison.OrdinalIgnoreCase)))"); + sb.AppendLine("\t\t\t\t\t{"); + sb.AppendLine( + $"\t\t\t\t\t\tConsole.Error.WriteLine(\" \" + CliHelpFormatting.Accent(\"{qualifiedName}\") + \" {sum}\");"); + sb.AppendLine("\t\t\t\t\t}"); + } + + sb.AppendLine("\t\t\t\t\tConsole.Error.WriteLine();"); + sb.AppendLine($"\t\t\t\t\tConsole.Error.WriteLine($\"Run '{{__app}} {Escape(pathPrefix)} --help' for usage.\");"); + sb.AppendLine($"\t\t\t\t\tConsole.Error.WriteLine($\"Run '{{__app}} {nsHelp}' for usage.\");"); + sb.AppendLine("\t\t\t\t}"); + sb.AppendLine("\t\t\t\treturn 2;"); + } + + + private static void AppendRunWithCancellationAsyncMethod(StringBuilder sb) + { + sb.AppendLine("\t\tprivate static async Task RunWithCancellationAsync(string[] args)"); + sb.AppendLine("\t\t{"); + sb.AppendLine("\t\t\tusing var cts = new CancellationTokenSource();"); + sb.AppendLine("\t\t\tConsole.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };"); + sb.AppendLine("\t\t\tCancellationTokenSource? __linkedCts = null;"); + sb.AppendLine("\t\t\ttry"); + sb.AppendLine("\t\t\t{"); + sb.AppendLine("\t\t\t\tCancellationToken ct = cts.Token;"); + sb.AppendLine("\t\t\t\tif (ArghHostRuntime.ApplicationStopping is CancellationToken __hostStopping)"); + sb.AppendLine("\t\t\t\t{"); + sb.AppendLine("\t\t\t\t\t__linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, __hostStopping);"); + sb.AppendLine("\t\t\t\t\tct = __linkedCts.Token;"); + sb.AppendLine("\t\t\t\t}"); + sb.AppendLine("\t\t\t\treturn await RunCoreAsync(args, ct).ConfigureAwait(false);"); + sb.AppendLine("\t\t\t}"); + sb.AppendLine("\t\t\tcatch (Exception ex)"); + sb.AppendLine("\t\t\t{"); + sb.AppendLine("\t\t\t\tConsole.Error.WriteLine(ex.ToString());"); + sb.AppendLine("\t\t\t\treturn 1;"); + sb.AppendLine("\t\t\t}"); + sb.AppendLine("\t\t\tfinally"); + sb.AppendLine("\t\t\t{"); + sb.AppendLine("\t\t\t\t__linkedCts?.Dispose();"); + sb.AppendLine("\t\t\t}"); + sb.AppendLine("\t\t}"); + sb.AppendLine(); + } + + /// + /// Returns if the method or its containing type carries + /// [NoOptionsInjection], suppressing AGH0021 and handler-level options-parameter requirements. + /// Globals/namespaced flags still splice as for parsing/reconstruction. + /// + private static bool HasNoOptionsInjection(IMethodSymbol method) + { + const string attrName = "NoOptionsInjectionAttribute"; + const string ns = "Nullean.Argh"; + foreach (var a in method.GetAttributes()) + if (a.AttributeClass?.Name == attrName && a.AttributeClass.ContainingNamespace?.ToDisplayString() == ns) + return true; + foreach (var a in method.ContainingType.GetAttributes()) + if (a.AttributeClass?.Name == attrName && a.AttributeClass.ContainingNamespace?.ToDisplayString() == ns) + return true; + return false; + } + + private static bool HasPublicParameterlessCtor(INamedTypeSymbol type) + { + foreach (var ctor in type.InstanceConstructors) + { + if (ctor.Parameters.Length == 0 && ctor.DeclaredAccessibility == Accessibility.Public) + return true; + } + + return false; + } + + private static string DiResolveOrNew(string fullyQualifiedType, bool allowParameterlessFallback) + { + if (allowParameterlessFallback) + return $"((ArghServices.ServiceProvider?.GetService(typeof({fullyQualifiedType})) as {fullyQualifiedType}) ?? new {fullyQualifiedType}())"; + + return + $"((ArghServices.ServiceProvider?.GetService(typeof({fullyQualifiedType})) as {fullyQualifiedType}) ?? throw new global::System.InvalidOperationException(\"Register the type in DI for hosted execution, or add a public parameterless constructor for standalone CLI.\"))"; + } + + private static void EmitApp( + SourceProductionContext context, + AppEmitModel app, + CSharpParseOptions parseOptions, + string entryAssemblyName, + string entryAssemblyVersion, + string entrySchemaVersion, + ReferenceMetadataCapabilities.Capabilities referenceCapabilities) + { + _ = referenceCapabilities; + _ = parseOptions; // no longer needed for DTO building; kept in signature for future use + var dtoTargets = CollectDtoBindingTargets(app); + var root = GetArghGeneratedRootTypeName(entryAssemblyName); + EmitHierarchical(context, app, dtoTargets, entryAssemblyName, entryAssemblyVersion, entrySchemaVersion, root); + EmitDtoTypeExtensions(context, dtoTargets, root); + } + + private static void EmitHierarchical( + SourceProductionContext context, + AppEmitModel app, + ImmutableArray dtoTargets, + string entryAssemblyName, + string entryAssemblyVersion, + string entrySchemaVersion, + string arghGeneratedRootTypeName) + { + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine("using System;"); + sb.AppendLine("using System.Collections.Generic;"); + sb.AppendLine("using System.Globalization;"); + sb.AppendLine("using System.IO;"); + sb.AppendLine("using System.Linq;"); + sb.AppendLine("using System.Threading;"); + sb.AppendLine("using System.Threading.Tasks;"); + sb.AppendLine("using Nullean.Argh.Middleware;"); + sb.AppendLine("using Nullean.Argh.Help;"); + sb.AppendLine("using Nullean.Argh.Matching;"); + sb.AppendLine("using Nullean.Argh.Runtime;"); + sb.AppendLine("using Nullean.Argh.Schema;"); + sb.AppendLine(); + sb.AppendLine("namespace Nullean.Argh"); + sb.AppendLine("{"); + sb.AppendLine("\t/// Source-generated CLI entry point from ArghApp registrations. At the root, __completion bash|zsh|fish prints a shell script from ; each {0} in the template is replaced with the entry assembly name (same effect as string.Format, but substitution uses Replace so shell scripts can contain literal braces)."); + sb.AppendLine("\tpublic static class " + arghGeneratedRootTypeName); + sb.AppendLine("\t{"); + + // Static fields that hold the parsed global/namespace options instances. + // Commands inject these via method or constructor parameters. + if (app.GlobalOptionsModel is { } globalOptModel) + { + var fq = globalOptModel.TypeFq; + sb.AppendLine($"\t\tprivate static {fq} {OptionsStaticFieldNameFq(fq)} = new {fq}();"); + } + foreach ((var nsNode, _) in EnumerateCommandNamespaceNodesWithPath(app.Root, ImmutableArray.Empty)) + { + if (nsNode.CommandNamespaceOptionsModel is { } nsModel) + { + var fq = nsModel.TypeFq; + sb.AppendLine($"\t\tprivate static {fq} {OptionsStaticFieldNameFq(fq)} = new {fq}();"); + } + } + sb.AppendLine(); + + EmitCompletionForApp(sb, app); + sb.AppendLine("\t\tpublic static Task RunAsync(string[] args) =>"); + sb.AppendLine("\t\t\tRunWithCancellationAsync(args);"); + sb.AppendLine(); + AppendRunWithCancellationAsyncMethod(sb); + EmitRunCoreHierarchical(sb, app, entryAssemblyName); + sb.AppendLine(); + EmitPrintRootHelpHierarchical(sb, app, entryAssemblyName); + sb.AppendLine(); + foreach ((var node, var path) in EnumerateCommandNamespaceNodesWithPath(app.Root, ImmutableArray.Empty)) + EmitCommandNamespaceHelpPrinter(sb, path, node, app, entryAssemblyName); + + foreach (var cmd in app.AllCommands) + EmitCommandHelpPrinter(sb, cmd, app, entryAssemblyName); + + foreach (var cmd in app.AllCommands) + EmitCommandFlagHelpToStdErrMethod(sb, cmd, app); + + sb.AppendLine("\t\tprivate static void PrintVersion()"); + sb.AppendLine("\t\t{"); + sb.AppendLine($" Console.Out.WriteLine(\"{Escape(entryAssemblyVersion)}\");"); + sb.AppendLine("\t\t}"); + sb.AppendLine(); + + foreach (var cmd in app.AllCommands) + { + var injectedOpts = app.InjectionChains.TryGetValue(cmd.RunMethodName, out var precomputed3) + ? precomputed3 + : BuildOptionsInjectionChain(app, cmd); + EmitCommandRunner(sb, cmd, app.GlobalMiddleware, injectedOptions: injectedOpts, entryAssemblyName: entryAssemblyName); + } + + { + var globalPrefetchMembers = CollectRootPrefetchGlobalMembers(app); + var deferRootPrefetch = CollectDeferredRootAliasPrefetchFlags(app); + if (globalPrefetchMembers.Length > 0 || deferRootPrefetch.Length > 0) + { + if (app.GlobalOptionsModel is { } g) + { + EmitOptionsTryParse(sb, "TryParseGlobalOptions", + globalPrefetchMembers, + storeTypeFq: g.TypeFq, + storeFieldName: OptionsStaticFieldNameFq(g.TypeFq), + storeBestCtorParamOrder: g.BestCtorParamOrder, + entryAssemblyName: entryAssemblyName, + deferLeadingRootAliasFlags: deferRootPrefetch); + } + else + EmitOptionsTryParse(sb, "TryParseGlobalOptions", globalPrefetchMembers, + entryAssemblyName: entryAssemblyName, + deferLeadingRootAliasFlags: deferRootPrefetch); + } + } + + foreach ((var node, var path) in EnumerateCommandNamespaceNodesWithPath(app.Root, ImmutableArray.Empty)) + { + if (node.CommandNamespaceOptionsModel is { } nsModel) + { + // Use FLATTENED members (including inherited) so flags from parent options types are + // also recognised and consumed between the namespace segment and the sub-command. + if (nsModel.FlattenedMembers.Length > 0) + EmitOptionsTryParse(sb, CommandNamespaceOptionsParseMethodName(path), nsModel.FlattenedMembers, + storeTypeFq: nsModel.TypeFq, + storeFieldName: OptionsStaticFieldNameFq(nsModel.TypeFq), + storeBestCtorParamOrder: nsModel.BestCtorParamOrder, + entryAssemblyName: entryAssemblyName); + } + } + + EmitTryParseRouteHierarchical(sb, app); + EmitIsIntrinsicCommand(sb, app); + EmitDispatchForNode(sb, app, app.Root, ImmutableArray.Empty, "DispatchRoot", isRoot: true, entryAssemblyName); + sb.AppendLine("\t\tprivate static bool? ParseNullableBool(string? raw, bool fromYesSwitch)"); + sb.AppendLine("\t\t{"); + sb.AppendLine("\t\t\tif (string.IsNullOrEmpty(raw)) return fromYesSwitch;"); + sb.AppendLine("\t\t\tif (bool.TryParse(raw, out var b)) return b;"); + sb.AppendLine("\t\t\treturn fromYesSwitch;"); + sb.AppendLine("\t\t}"); + sb.AppendLine(); + sb.AppendLine("\t\tprivate static string[] TailFrom(string[] args, int start)"); + sb.AppendLine("\t\t{"); + sb.AppendLine("\t\t\tif (start >= args.Length) return Array.Empty();"); + sb.AppendLine("\t\t\tvar n = args.Length - start;"); + sb.AppendLine("\t\t\tvar r = new string[n];"); + sb.AppendLine("\t\t\tArray.Copy(args, start, r, 0, n);"); + sb.AppendLine("\t\t\treturn r;"); + sb.AppendLine("\t\t}"); + EmitDtoBindingMethods(sb, dtoTargets); + sb.AppendLine(); + var schemaVersion = app.SchemaVersionOverride ?? entrySchemaVersion; + EmitBuildCliSchemaDocumentHierarchical(sb, app, entryAssemblyName, schemaVersion); + sb.AppendLine("\t}"); + AppendArghRuntimeModuleInitializer(sb, arghGeneratedRootTypeName); + sb.AppendLine("}"); + context.AddSource("ArghGenerated.g.cs", SourceText.From(sb.ToString(), Encoding.UTF8)); + } + + private static IEnumerable<(RegistryNode node, ImmutableArray path)> EnumerateCommandNamespaceNodesWithPath( + RegistryNode root, + ImmutableArray prefix) + { + foreach (var ch in root.Children) + { + var p = AppendSegment(prefix, ch.Segment); + yield return (ch.Node, p); + foreach ((var node, var sub) in EnumerateCommandNamespaceNodesWithPath(ch.Node, p)) + yield return (node, sub); + } + } + + private static string CommandNamespacePathKey(ImmutableArray path) + { + if (path.IsDefaultOrEmpty) + return "Root"; + + var sb = new StringBuilder(); + for (var i = 0; i < path.Length; i++) + { + if (i > 0) + sb.Append('_'); + sb.Append(Naming.SanitizeIdentifier(path[i])); + } + + return sb.ToString(); + } + + private static string CommandNamespaceOptionsParseMethodName(ImmutableArray path) => + "TryParseCommandNamespaceOptions_" + CommandNamespacePathKey(path); + + private static void EmitRunCoreHierarchical(StringBuilder sb, AppEmitModel app, string entryAssemblyName) + { + var hasLeadingOptionPrefetch = + CollectRootPrefetchGlobalMembers(app).Length > 0 || CollectDeferredRootAliasPrefetchFlags(app).Length > 0; + sb.AppendLine("\t\tprivate static async Task RunCoreAsync(string[] args, CancellationToken ct)"); + sb.AppendLine("\t\t{"); + EmitRootCompletionScriptBlock(sb, "\t\t\t", entryAssemblyName); + EmitRootCompleteBlock(sb, "\t\t\t"); + EmitRootSchemaBlock(sb, "\t\t\t"); + sb.AppendLine("\t\t\tvar idx = new int[1];"); + if (hasLeadingOptionPrefetch) + sb.AppendLine("\t\t\tif (!TryParseGlobalOptions(args, idx)) return 2;"); + + sb.AppendLine("\t\t\tif (idx[0] < args.Length && (args[idx[0]] == \"--help\" || args[idx[0]] == \"-h\"))"); + sb.AppendLine("\t\t\t{"); + sb.AppendLine("\t\t\t\tPrintRootHelp();"); + sb.AppendLine("\t\t\t\treturn 0;"); + sb.AppendLine("\t\t\t}"); + sb.AppendLine("\t\t\tif (idx[0] < args.Length && args[idx[0]] == \"--version\")"); + sb.AppendLine("\t\t\t{"); + sb.AppendLine("\t\t\t\tPrintVersion();"); + sb.AppendLine("\t\t\t\treturn 0;"); + sb.AppendLine("\t\t\t}"); + sb.AppendLine("\t\t\tif (idx[0] >= args.Length)"); + sb.AppendLine("\t\t\t{"); + if (app.Root.RootCommand is { } runCoreRoot) + sb.AppendLine($"\t\t\t\treturn await {runCoreRoot.RunMethodName}(TailFrom(args, idx[0]), ct).ConfigureAwait(false);"); + else if (app.Root.RootAlias is { } runCoreAlias) + sb.AppendLine($"\t\t\t\treturn await {runCoreAlias.RunMethodName}(TailFrom(args, idx[0]), ct).ConfigureAwait(false);"); + else + { + sb.AppendLine("\t\t\t\tPrintRootHelp();"); + sb.AppendLine("\t\t\t\treturn 0;"); + } + + sb.AppendLine("\t\t\t}"); + sb.AppendLine("\t\t\treturn await DispatchRoot(args, idx, ct).ConfigureAwait(false);"); + sb.AppendLine("\t\t}"); + } + + private static void EmitPrintRootHelpHierarchical(StringBuilder sb, AppEmitModel app, string entryAssemblyName) + { + var rootGlobalFlags = new List(); + if ((app.Root.RootCommand is not null || app.Root.RootAlias is not null) && + app.GlobalOptionsModel is OptionsTypeModel gomH && gomH.Members.Length > 0) + { + foreach (var p in gomH.Members) + { + if (p.Kind == ParameterKind.Flag) + rootGlobalFlags.Add(p); + } + } + + var widthCandidatesGlobalRoot = new List { "-h, --help".Length, "--version".Length }; + widthCandidatesGlobalRoot.AddRange(rootGlobalFlags.Select(p => HelpLayout.FormatOptionLeftCell(p).Length)); + var maxOptWidthRoot = Math.Min(widthCandidatesGlobalRoot.Max(), 40); + maxOptWidthRoot = Math.Max(maxOptWidthRoot, "-h, --help".Length); + + var maxNsListingW = app.Root.Children.Count == 0 ? 0 : app.Root.Children.Max(ch => ch.Segment.Length); + var visibleRootCmds = app.Root.Commands.Where(static c => !c.IsHidden).ToList(); + var maxCmdListingW = visibleRootCmds.Count == 0 ? 0 : visibleRootCmds.Max(c => c.CommandName.Length); + + sb.AppendLine("\t\tprivate static void PrintRootHelp()"); + sb.AppendLine("\t\t{"); + sb.AppendLine( + $"\t\t\tConsole.Out.WriteLine(CliHelpFormatting.Section(\"Usage: \") + CliHelpFormatting.Accent(\"{Escape(entryAssemblyName)}\") + \" [options]\");"); + sb.AppendLine("\t\t\tConsole.Out.WriteLine();"); + if (app.Root.RootCommand is { } rootOverview) + EmitRootCommandHelpOverview(sb, rootOverview, "\t\t\t", app, entryAssemblyName); + else if (app.Root.RootAlias is { } rootAliasOverview) + // Preserve the legacy MapRoot root-help shape: "(default command)" plus handler docs — not the abbreviated + // root-alias synopsis (those details remain on the named sub-command's --help). + EmitRootCommandHelpOverview(sb, rootAliasOverview, "\t\t\t", app, entryAssemblyName, includeScopedDefaultHelpOptions: false); + else if (!string.IsNullOrWhiteSpace(app.RootSummary)) + { + EmitCommandHelpDocPrologue(sb, "\t\t\t", null, app.RootSummary, false); + sb.AppendLine("\t\t\tConsole.Out.WriteLine();"); + } + + sb.AppendLine("\t\t\tConsole.Out.WriteLine(CliHelpFormatting.Section(\"Global options:\"));"); + sb.AppendLine( + $"\t\t\tConsole.Out.WriteLine(\" \" + CliHelpFormatting.Placeholder(\"{Escape("-h, --help".PadRight(maxOptWidthRoot))}\") + \" Show help.\");"); + sb.AppendLine( + $"\t\t\tConsole.Out.WriteLine(\" \" + CliHelpFormatting.Placeholder(\"{Escape("--version".PadRight(maxOptWidthRoot))}\") + \" Show version.\");"); + EmitHelpOptionRows(sb, rootGlobalFlags, maxOptWidthRoot); + sb.AppendLine("\t\t\tConsole.Out.WriteLine();"); + if (app.Root.Children.Count > 0) + { + sb.AppendLine("\t\t\tConsole.Out.WriteLine(CliHelpFormatting.Section(\"Namespaces:\"));"); + foreach (var ch in app.Root.Children.OrderBy(ch => ch.Segment, StringComparer.OrdinalIgnoreCase).ThenBy(ch => ch.Segment, StringComparer.Ordinal)) + { + var sumArg = string.IsNullOrWhiteSpace(ch.SummaryOneLiner) + ? "null" + : $"\"{Escape(ch.SummaryOneLiner)}\""; + sb.AppendLine( + $"\t\t\tCliHelpFormatting.WriteHelpListNameAndDescription(true, \"{Escape(ch.Segment)}\", {sumArg}, {maxNsListingW});"); + } + + sb.AppendLine("\t\t\tConsole.Out.WriteLine();"); + } + + if (visibleRootCmds.Count > 0) + { + sb.AppendLine("\t\t\tConsole.Out.WriteLine(CliHelpFormatting.Section(\"Commands:\"));"); + foreach (var c in visibleRootCmds.OrderBy(c => c.CommandName, StringComparer.OrdinalIgnoreCase).ThenBy(c => c.CommandName, StringComparer.Ordinal)) + { + var sumArg = string.IsNullOrWhiteSpace(c.SummaryOneLiner) + ? "null" + : $"\"{Escape(c.SummaryOneLiner)}\""; + sb.AppendLine( + $"\t\t\tCliHelpFormatting.WriteHelpListNameAndDescription(false, \"{Escape(c.CommandName)}\", {sumArg}, {maxCmdListingW});"); + } + } + + sb.AppendLine("\t\t}"); + } + + private static void EmitCommandNamespaceHelpPrinter(StringBuilder sb, ImmutableArray path, RegistryNode node, AppEmitModel app, string entryAssemblyName) + { + var key = CommandNamespacePathKey(path); + var usagePrefix = string.Join(" ", path); + + var globalFlagMembers = EnumerateFlagMembers(app.GlobalOptionsModel).ToList(); + List<(string Segment, List Rows)> namespaceOptionSections = new(); + var namespaceOptionChain = GetCommandNamespaceOptionChain(app, path); + var suppressedForNamespaceDisplay = new HashSet(StringComparer.OrdinalIgnoreCase); + AddCliKeys(globalFlagMembers, suppressedForNamespaceDisplay); + foreach ((var seg, var gom) in namespaceOptionChain) + { + var allInNamespace = EnumerateFlagMembers(gom).ToList(); + var rows = allInNamespace.Where(p => !suppressedForNamespaceDisplay.Contains(p.CliLongName)).ToList(); + AddCliKeys(allInNamespace, suppressedForNamespaceDisplay); + if (rows.Count > 0) + namespaceOptionSections.Add((seg, rows)); + } + + var hasNsDoc = !string.IsNullOrWhiteSpace(node.SummaryInnerXml); + var showGlobalFlagsInNs = (node.RootCommand is not null || hasNsDoc) && globalFlagMembers.Count > 0; + var showNamespaceScopedOptionSections = node.RootCommand is not null || hasNsDoc; + var widthCandidatesNs = new List { "-h, --help".Length }; + if (showGlobalFlagsInNs) + widthCandidatesNs.AddRange(globalFlagMembers.Select(p => HelpLayout.FormatOptionLeftCell(p).Length)); + if (showNamespaceScopedOptionSections) + { + foreach ((_, var rows) in namespaceOptionSections) + widthCandidatesNs.AddRange(rows.Select(p => HelpLayout.FormatOptionLeftCell(p).Length)); + } + + var maxOptWidth = Math.Min(widthCandidatesNs.Max(), 40); + maxOptWidth = Math.Max(maxOptWidth, "-h, --help".Length); + + var maxChildNsListingW = 0; + if (node.Children.Count > 0) + maxChildNsListingW = node.Children.Max(ch => FormatQualifiedCliPath(path, ch.Segment).Length); + var maxChildCmdListingW = 0; + var visibleNodeCmds = node.Commands.Where(static c => !c.IsHidden).ToList(); + if (visibleNodeCmds.Count > 0) + maxChildCmdListingW = visibleNodeCmds.Max(c => FormatQualifiedCliPath(path, c.CommandName).Length); + + sb.AppendLine($"\t\tprivate static void PrintHelp_CommandNamespace_{key}()"); + sb.AppendLine("\t\t{"); + sb.AppendLine( + $"\t\t\tConsole.Out.WriteLine(CliHelpFormatting.Section(\"Usage: \") + CliHelpFormatting.Accent(\"{Escape(entryAssemblyName)}\") + \" {Escape(usagePrefix)} [options]\");"); + sb.AppendLine("\t\t\tConsole.Out.WriteLine();"); + if (node.RootCommand is { } nsRootOverview) + EmitRootCommandHelpOverview(sb, nsRootOverview, "\t\t\t", app, entryAssemblyName); + else if (node.RootAlias is { } nsAliasOverview) + EmitRootAliasHelpOverview(sb, nsAliasOverview, path, "\t\t\t", entryAssemblyName); + else if (!string.IsNullOrWhiteSpace(node.SummaryInnerXml)) + { + EmitCommandHelpDocPrologue(sb, "\t\t\t", node.SummaryInnerXml, "", false); + sb.AppendLine("\t\t\tConsole.Out.WriteLine();"); + } + + sb.AppendLine("\t\t\tConsole.Out.WriteLine(CliHelpFormatting.Section(\"Global options:\"));"); + sb.AppendLine( + $"\t\t\tConsole.Out.WriteLine(\" \" + CliHelpFormatting.Placeholder(\"{Escape("-h, --help".PadRight(maxOptWidth))}\") + \" Show help.\");"); + if (showGlobalFlagsInNs) + EmitHelpOptionRows(sb, globalFlagMembers, maxOptWidth); + + sb.AppendLine("\t\t\tConsole.Out.WriteLine();"); + + if (showNamespaceScopedOptionSections) + { + foreach ((var segment, var gRows) in namespaceOptionSections) + { + sb.AppendLine($"\t\t\tConsole.Out.WriteLine(CliHelpFormatting.Section(\"'{Escape(segment)}' options:\"));"); + EmitHelpOptionRows(sb, gRows, maxOptWidth); + sb.AppendLine("\t\t\tConsole.Out.WriteLine();"); + } + } + + if (node.Children.Count > 0) + { + sb.AppendLine("\t\t\tConsole.Out.WriteLine(CliHelpFormatting.Section(\"Namespaces:\"));"); + foreach (var ch in node.Children.OrderBy(ch => ch.Segment, StringComparer.OrdinalIgnoreCase).ThenBy(ch => ch.Segment, StringComparer.Ordinal)) + { + var fullNs = FormatQualifiedCliPath(path, ch.Segment); + var sumArg = string.IsNullOrWhiteSpace(ch.SummaryOneLiner) + ? "null" + : $"\"{Escape(ch.SummaryOneLiner)}\""; + sb.AppendLine( + $"\t\t\tCliHelpFormatting.WriteHelpListNameAndDescription(true, \"{Escape(fullNs)}\", {sumArg}, {maxChildNsListingW});"); + } + + sb.AppendLine("\t\t\tConsole.Out.WriteLine();"); + } + + if (visibleNodeCmds.Count > 0) + { + sb.AppendLine("\t\t\tConsole.Out.WriteLine(CliHelpFormatting.Section(\"Commands:\"));"); + foreach (var c in visibleNodeCmds.OrderBy(c => c.CommandName, StringComparer.OrdinalIgnoreCase).ThenBy(c => c.CommandName, StringComparer.Ordinal)) + { + var fullCmd = FormatQualifiedCliPath(path, c.CommandName); + var sumArg = string.IsNullOrWhiteSpace(c.SummaryOneLiner) + ? "null" + : $"\"{Escape(c.SummaryOneLiner)}\""; + sb.AppendLine( + $"\t\t\tCliHelpFormatting.WriteHelpListNameAndDescription(false, \"{Escape(fullCmd)}\", {sumArg}, {maxChildCmdListingW});"); + } + } + + if (node.RootCommand is null && !string.IsNullOrWhiteSpace(node.RemarksInnerXml)) + { + sb.AppendLine("\t\t\tConsole.Out.WriteLine();"); + EmitNotesSection(sb, "\t\t\t", node.RemarksInnerXml, ""); + } + + sb.AppendLine("\t\t}"); + sb.AppendLine(); + } + + private static void EmitDispatchForNode( + StringBuilder sb, + AppEmitModel app, + RegistryNode node, + ImmutableArray path, + string methodName, + bool isRoot, + string entryAssemblyName) + { + sb.AppendLine($"\t\tprivate static async Task {methodName}(string[] args, int[] idx, CancellationToken ct)"); + sb.AppendLine("\t\t{"); + // Call namespace options parse for any namespace that has a registered options type (even if it only + // inherits flags from a parent type) so that flags between the namespace segment and the sub-command + // are consumed correctly. + if (!isRoot && node.CommandNamespaceOptionsModel is { FlattenedMembers.Length: > 0 }) + sb.AppendLine($"\t\t\tif (!{CommandNamespaceOptionsParseMethodName(path)}(args, idx)) return 2;"); + + sb.AppendLine("\t\t\tif (idx[0] >= args.Length)"); + sb.AppendLine("\t\t\t{"); + if (node.RootCommand is { } dispatchRoot) + sb.AppendLine($"\t\t\t\treturn await {dispatchRoot.RunMethodName}(TailFrom(args, idx[0]), ct).ConfigureAwait(false);"); + else if (node.RootAlias is { } dispatchAlias) + sb.AppendLine($"\t\t\t\treturn await {dispatchAlias.RunMethodName}(TailFrom(args, idx[0]), ct).ConfigureAwait(false);"); + else + { + if (isRoot) + sb.AppendLine("\t\t\t\tPrintRootHelp();"); + else + sb.AppendLine($"\t\t\t\tPrintHelp_CommandNamespace_{CommandNamespacePathKey(path)}();"); + + sb.AppendLine("\t\t\t\treturn 0;"); + } + + sb.AppendLine("\t\t\t}"); + sb.AppendLine("\t\t\tif (args[idx[0]] == \"--help\" || args[idx[0]] == \"-h\")"); + sb.AppendLine("\t\t\t{"); + if (isRoot) + sb.AppendLine("\t\t\t\tPrintRootHelp();"); + else + sb.AppendLine($"\t\t\t\tPrintHelp_CommandNamespace_{CommandNamespacePathKey(path)}();"); + + sb.AppendLine("\t\t\t\treturn 0;"); + sb.AppendLine("\t\t\t}"); + sb.AppendLine("\t\t\tvar tok = args[idx[0]];"); + foreach (var cmd in node.Commands) + EmitOrdinalIgnoreCaseIf(sb, "\t\t\t", "tok", cmd.CommandName, s => + { + s.AppendLine("\t\t\t\tidx[0]++;"); + s.AppendLine($"\t\t\t\treturn await {cmd.RunMethodName}(TailFrom(args, idx[0]), ct).ConfigureAwait(false);"); + }); + + foreach (var ch in node.Children) + { + var childPath = AppendSegment(path, ch.Segment); + var childMethod = "DispatchCommandNamespace_" + CommandNamespacePathKey(childPath); + EmitOrdinalIgnoreCaseIf(sb, "\t\t\t", "tok", ch.Segment, s => + { + s.AppendLine("\t\t\t\tidx[0]++;"); + s.AppendLine($"\t\t\t\treturn await {childMethod}(args, idx, ct).ConfigureAwait(false);"); + }); + } + + var flagFallbackMethod = node.RootCommand?.RunMethodName ?? node.RootAlias?.RunMethodName; + if (flagFallbackMethod is not null) + { + sb.AppendLine("\t\t\tif (tok.Length > 0 && tok[0] == '-')"); + sb.AppendLine("\t\t\t{"); + sb.AppendLine($"\t\t\t\treturn await {flagFallbackMethod}(TailFrom(args, idx[0]), ct).ConfigureAwait(false);"); + sb.AppendLine("\t\t\t}"); + } + + sb.AppendLine("\t\t\t{"); + EmitFuzzyDispatchDefault(sb, node, path, entryAssemblyName); + sb.AppendLine("\t\t\t}"); + sb.AppendLine("\t\t}"); + sb.AppendLine(); + foreach (var ch in node.Children) + { + var childPath = AppendSegment(path, ch.Segment); + EmitDispatchForNode(sb, app, ch.Node, childPath, "DispatchCommandNamespace_" + CommandNamespacePathKey(childPath), isRoot: false, entryAssemblyName); + } + } + + /// + /// Extracts the full doc-comment XML from a trivia list. + /// Works regardless of DocumentationMode: when GenerateDocumentationFile is unset the compiler + /// uses DocumentationMode.None and stores /// lines as plain SingleLineCommentTrivia + /// rather than structured SingleLineDocumentationCommentTrivia. Both paths are handled here. + /// +} diff --git a/src/Nullean.Argh.Generator/CliParserGenerator.Emit.Dto.cs b/src/Nullean.Argh.Generator/CliParserGenerator.Emit.Dto.cs new file mode 100644 index 0000000..e552f09 --- /dev/null +++ b/src/Nullean.Argh.Generator/CliParserGenerator.Emit.Dto.cs @@ -0,0 +1,251 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Globalization; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Operations; +using Microsoft.CodeAnalysis.Text; + +namespace Nullean.Argh; + +public sealed partial class CliParserGenerator +{ + private sealed record DtoBindingTarget( + string TypeFq, + ImmutableArray Members, + bool IsOptionsDto, + bool IsGeneric, + bool IsPublic, + ImmutableArray? BestCtorParamOrder); + + private static ImmutableArray CollectDtoBindingTargets( + AppEmitModel app) + { + // Use string TypeFq as dedup key since we no longer have INamedTypeSymbol in the pipeline boundary. + var map = new Dictionary(StringComparer.Ordinal); + + if (app.GlobalOptionsModel is { } gom && gom.FlattenedMembers.Length > 0) + { + map[gom.TypeFq] = new DtoBindingTarget( + gom.TypeFq, + gom.FlattenedMembers, + IsOptionsDto: true, + IsGeneric: gom.IsGeneric, + IsPublic: gom.IsPublic, + gom.BestCtorParamOrder); + } + + foreach ((var node, _) in EnumerateCommandNamespaceNodesWithPath(app.Root, ImmutableArray.Empty)) + { + if (node.CommandNamespaceOptionsModel is not { } nsModel) + continue; + if (nsModel.FlattenedMembers.Length == 0) + continue; + if (map.ContainsKey(nsModel.TypeFq)) + continue; + map[nsModel.TypeFq] = new DtoBindingTarget( + nsModel.TypeFq, + nsModel.FlattenedMembers, + IsOptionsDto: true, + IsGeneric: nsModel.IsGeneric, + IsPublic: nsModel.IsPublic, + nsModel.BestCtorParamOrder); + } + + foreach (var cmd in app.AllCommands) + { + if (cmd.HandlerParamTypes.IsDefaultOrEmpty) + continue; + + foreach (var mp in cmd.HandlerParamTypes) + { + if (!mp.IsAsParameters || mp.AsParamTypeFq is not { } typeFq) + continue; + if (string.IsNullOrEmpty(typeFq) || map.ContainsKey(typeFq)) + continue; + + // Extract the already-flattened DTO members from the command's Parameters array + // (these were computed by FlattenAsParametersType during analysis and include proper prefix/AsParametersMeta). + var flat = cmd.Parameters + .Where(p => p.AsParametersOwnerParamName == mp.Name) + .ToImmutableArray(); + + if (flat.Length > 0) + map[typeFq] = new DtoBindingTarget( + typeFq, + flat, + IsOptionsDto: false, + IsGeneric: mp.AsParamIsGeneric, + IsPublic: mp.AsParamIsPublic, + mp.AsParamBestCtorParamOrder); + } + } + + return map.Values + .OrderBy(t => t.TypeFq, StringComparer.Ordinal) + .ToImmutableArray(); + } + + private static string OptionsStaticFieldName(INamedTypeSymbol type) => + "s_opts_" + DtoMethodSuffix(type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)); + + private static string OptionsStaticFieldNameFq(string typeFq) => + "s_opts_" + DtoMethodSuffix(typeFq); + + /// Name of the per-command-runner local variable that holds the reconstructed options instance. + private static string OptionsLocalVarName(INamedTypeSymbol type) => + "__opts_" + DtoMethodSuffix(type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)); + + private static string OptionsLocalVarNameFq(string typeFq) => + "__opts_" + DtoMethodSuffix(typeFq); + + private static string DtoMethodSuffix(INamedTypeSymbol type) => + DtoMethodSuffix(type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)); + + private static string DtoMethodSuffix(string typeFq) + { + var fq = typeFq; + if (fq.StartsWith("global::", StringComparison.Ordinal)) + fq = fq.Substring(8); + + var sb = new StringBuilder(); + foreach (var c in fq) + { + if (char.IsLetterOrDigit(c)) + sb.Append(c); + else + sb.Append('_'); + } + + return sb.Length == 0 ? "Dto" : sb.ToString(); + } + + private static void EmitDtoBindingMethods(StringBuilder sb, ImmutableArray targets) + { + foreach (var t in targets) + { + var suffix = DtoMethodSuffix(t.TypeFq); + var lenientName = "TryParseDto_" + suffix; + var strictName = "TryParseDtoExact_" + suffix; + var syn = SyntheticOptionsCommand(t.Members, lenientName); + + EmitCommandRunner( + sb, + syn, + ImmutableArray.Empty, + emitDtoTryParse: true, + dtoLenient: true, + dtoMethodName: lenientName, + dtoResultTypeFq: t.TypeFq, + dtoOptionsTypeFq: t.IsOptionsDto ? t.TypeFq : null, + dtoOptionsBestCtorParamOrder: t.IsOptionsDto ? t.BestCtorParamOrder : null); + + EmitCommandRunner( + sb, + syn, + ImmutableArray.Empty, + emitDtoTryParse: true, + dtoLenient: false, + dtoMethodName: strictName, + dtoResultTypeFq: t.TypeFq, + dtoOptionsTypeFq: t.IsOptionsDto ? t.TypeFq : null, + dtoOptionsBestCtorParamOrder: t.IsOptionsDto ? t.BestCtorParamOrder : null); + } + } + + private static void EmitDtoTypeExtensions( + SourceProductionContext context, + ImmutableArray targets, + string arghGeneratedRootTypeName) + { + if (targets.IsEmpty) + return; + + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine("using System;"); + sb.AppendLine("using System.Diagnostics.CodeAnalysis;"); + sb.AppendLine(); + sb.AppendLine("namespace Nullean.Argh"); + sb.AppendLine("{"); + sb.AppendLine("\t/// Source-generated DTO parsers. Uses C# 14 extension members (static extensions on each DTO type) plus a -based overload for generic dispatch."); + sb.AppendLine("\tpublic static class ArghTypeBindingExtensions"); + sb.AppendLine("\t{"); + sb.AppendLine("\t\tpublic static bool TryParseArgh(this Type type, string[] args, [NotNullWhen(true)] out T? value) where T : class"); + sb.AppendLine("\t\t{"); + sb.AppendLine("\t\t\tvalue = null;"); + sb.AppendLine("\t\t\tif (!ReferenceEquals(type, typeof(T)))"); + sb.AppendLine("\t\t\t\tthrow new ArgumentException(\"The receiver must be typeof(T).\", nameof(type));"); + foreach (var t in targets) + { + var fq = t.TypeFq; + var method = "TryParseDto_" + DtoMethodSuffix(fq); + sb.AppendLine($"\t\t\tif (typeof(T) == typeof({fq}))"); + sb.AppendLine("\t\t\t{"); + sb.AppendLine($"\t\t\t\tvar ok = {arghGeneratedRootTypeName}.{method}(args, out var v);"); + sb.AppendLine("\t\t\t\tvalue = (T?)(object?)v;"); + sb.AppendLine("\t\t\t\treturn ok;"); + sb.AppendLine("\t\t\t}"); + } + + sb.AppendLine( + "\t\t\tthrow new InvalidOperationException(\"No pregenerated Argh DTO parser for \" + typeof(T).FullName + \". Register the type as UseGlobalOptions/UseNamespaceOptions or use it with [AsParameters] on a command.\");"); + sb.AppendLine("\t\t}"); + sb.AppendLine(); + + sb.AppendLine("\t\tpublic static bool TryParseArghExact(this Type type, string[] args, [NotNullWhen(true)] out T? value) where T : class"); + sb.AppendLine("\t\t{"); + sb.AppendLine("\t\t\tvalue = null;"); + sb.AppendLine("\t\t\tif (!ReferenceEquals(type, typeof(T)))"); + sb.AppendLine("\t\t\t\tthrow new ArgumentException(\"The receiver must be typeof(T).\", nameof(type));"); + foreach (var t in targets) + { + var fq = t.TypeFq; + var method = "TryParseDtoExact_" + DtoMethodSuffix(fq); + sb.AppendLine($"\t\t\tif (typeof(T) == typeof({fq}))"); + sb.AppendLine("\t\t\t{"); + sb.AppendLine($"\t\t\t\tvar ok = {arghGeneratedRootTypeName}.{method}(args, out var v);"); + sb.AppendLine("\t\t\t\tvalue = (T?)(object?)v;"); + sb.AppendLine("\t\t\t\treturn ok;"); + sb.AppendLine("\t\t\t}"); + } + + sb.AppendLine( + "\t\t\tthrow new InvalidOperationException(\"No pregenerated Argh DTO parser for \" + typeof(T).FullName + \". Register the type as UseGlobalOptions/UseNamespaceOptions or use it with [AsParameters] on a command.\");"); + sb.AppendLine("\t\t}"); + sb.AppendLine(); + + foreach (var t in targets) + { + if (t.IsGeneric) + continue; + + var fq = t.TypeFq; + var lenientMethod = "TryParseDto_" + DtoMethodSuffix(fq); + var strictMethod = "TryParseDtoExact_" + DtoMethodSuffix(fq); + var vis = t.IsPublic ? "public" : "internal"; + sb.AppendLine($"\t\textension({fq})"); + sb.AppendLine("\t\t{"); + sb.AppendLine($"\t\t\t{vis} static bool TryParseArgh(string[] args, [NotNullWhen(true)] out {fq}? value) =>"); + sb.AppendLine($"\t\t\t\tglobal::Nullean.Argh.{arghGeneratedRootTypeName}.{lenientMethod}(args, out value);"); + sb.AppendLine($"\t\t\t{vis} static bool TryParseArghExact(string[] args, [NotNullWhen(true)] out {fq}? value) =>"); + sb.AppendLine($"\t\t\t\tglobal::Nullean.Argh.{arghGeneratedRootTypeName}.{strictMethod}(args, out value);"); + sb.AppendLine("\t\t}"); + sb.AppendLine(); + } + + sb.AppendLine("\t}"); + sb.AppendLine("}"); + context.AddSource("ArghTypeBindingExtensions.g.cs", SourceText.From(sb.ToString(), Encoding.UTF8)); + } + + + +} diff --git a/src/Nullean.Argh.Generator/CliParserGenerator.Emit.Help.cs b/src/Nullean.Argh.Generator/CliParserGenerator.Emit.Help.cs new file mode 100644 index 0000000..3ad35ed --- /dev/null +++ b/src/Nullean.Argh.Generator/CliParserGenerator.Emit.Help.cs @@ -0,0 +1,1022 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Globalization; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Operations; +using Microsoft.CodeAnalysis.Text; + +namespace Nullean.Argh; + +public sealed partial class CliParserGenerator +{ + private static string GetCommandRoutePath(CommandModel cmd) + { + if (cmd.IsRootDefault) + { + if (cmd.RoutePrefix.IsDefaultOrEmpty) + return ""; + return string.Join("/", cmd.RoutePrefix) + "/"; + } + + if (cmd.RoutePrefix.IsDefaultOrEmpty) + return cmd.CommandName; + return string.Join("/", cmd.RoutePrefix) + "/" + cmd.CommandName; + } + + /// Help printer invoked from generated command runners for default/root handlers (overview lives in root or namespace overview). + private static string HelpPrinterMethodForCommand(CommandModel cmd) + { + if (cmd.IsRootDefault) + { + if (cmd.RoutePrefix.IsDefaultOrEmpty) + return "PrintRootHelp"; + return "PrintHelp_CommandNamespace_" + CommandNamespacePathKey(cmd.RoutePrefix); + } + + return "PrintHelp_" + cmd.RunMethodName; + } + + /// Indented body lines for default-handler summary/remarks (one indent step less than before). + private static void EmitRootDefaultDocumentationLines(StringBuilder sb, string indent, string? innerXml, string? plainFallback, bool isRemarks) + { + if (!string.IsNullOrWhiteSpace(innerXml)) + { + // Concatenate (do not use $"..." interpolation): inner XML can contain `{`/`}` from generic cref text. + sb.AppendLine(indent + "global::Nullean.Argh.Help.XmlDocumentationRenderer.WriteIndentedDoc(Console.Out, \" \", \"" + EscapeDocXml(innerXml!) + "\", " + (isRemarks ? "true" : "false") + ");"); + return; + } + + if (string.IsNullOrWhiteSpace(plainFallback)) + return; + var text = plainFallback!; + foreach (var part in text.Replace("\r\n", "\n").Split('\n')) + { + var line = part.TrimEnd('\r'); + if (string.IsNullOrWhiteSpace(line)) + sb.AppendLine($"{indent}Console.Out.WriteLine();"); + else + sb.AppendLine($"{indent}Console.Out.WriteLine(\" \" + \"{Escape(line.Trim())}\");"); + } + } + + /// Summary (white) after usage, or remarks (gray) after options; caller emits Notes: before remarks when using per-command help. + private static void EmitCommandHelpDocPrologue(StringBuilder sb, string indent, string? innerXml, string? plainFallback, bool remarks) + { + if (!string.IsNullOrWhiteSpace(innerXml)) + { + sb.AppendLine(indent + "global::Nullean.Argh.Help.XmlDocumentationRenderer.WriteIndentedDoc(Console.Out, \" \", \"" + EscapeDocXml(innerXml!) + "\", " + (remarks ? "true" : "false") + ");"); + return; + } + + if (string.IsNullOrWhiteSpace(plainFallback)) + return; + var text = plainFallback!; + var styler = remarks ? "CliHelpFormatting.DocRemarksLine" : "CliHelpFormatting.DocSummaryLine"; + foreach (var part in text.Replace("\r\n", "\n").Split('\n')) + { + var line = part.TrimEnd('\r'); + if (string.IsNullOrWhiteSpace(line)) + sb.AppendLine($"{indent}Console.Out.WriteLine();"); + else + sb.AppendLine($"{indent}Console.Out.WriteLine(\" \" + {styler}(\"{Escape(line.Trim())}\"));"); + } + } + + /// Flatten remarks inner XML to plain text at generation time (used for single-line detection). + private static string FlattenRemarksXml(string? innerXml) + { + if (string.IsNullOrWhiteSpace(innerXml)) + return ""; + try + { + var el = XElement.Parse("" + innerXml + "", LoadOptions.PreserveWhitespace); + return Documentation.FlattenBlockPublic(el).Replace("\r\n", "\n").Trim(); + } + catch { return ""; } + } + + /// + /// Emits the Notes: section for command/namespace help. + /// Single-line remarks are inlined on the same line as "Notes:"; + /// multi-line remarks follow on the next line with 2-space indent to align with Commands/Options above. + /// + private static void EmitNotesSection(StringBuilder sb, string indent, string? innerXml, string plainRendered) + { + var flat = string.IsNullOrWhiteSpace(plainRendered) + ? FlattenRemarksXml(innerXml) + : plainRendered.Replace("\r\n", "\n").Trim(); + if (string.IsNullOrWhiteSpace(flat)) + return; + + var singleLine = !flat.Contains('\n'); + if (singleLine) + { + sb.AppendLine($"{indent}Console.Out.WriteLine(CliHelpFormatting.Section(\"Notes:\") + \" \" + CliHelpFormatting.DocRemarksLine(\"{Escape(flat)}\"));"); + return; + } + + sb.AppendLine($"{indent}Console.Out.WriteLine(CliHelpFormatting.Section(\"Notes:\"));"); + if (!string.IsNullOrWhiteSpace(innerXml)) + { + sb.AppendLine(indent + "global::Nullean.Argh.Help.XmlDocumentationRenderer.WriteIndentedDoc(Console.Out, \" \", \"" + EscapeDocXml(innerXml!) + "\", true);"); + return; + } + foreach (var part in flat.Split('\n')) + { + var line = part.TrimEnd('\r'); + if (string.IsNullOrWhiteSpace(line)) + sb.AppendLine($"{indent}Console.Out.WriteLine();"); + else + sb.AppendLine($"{indent}Console.Out.WriteLine(\" \" + CliHelpFormatting.DocRemarksLine(\"{Escape(line.Trim())}\"));"); + } + } + + private static void EmitRootCommandHelpOverview( + StringBuilder sb, + CommandModel rootCmd, + string indent, + AppEmitModel app, + string entryAssemblyName, + bool includeScopedDefaultHelpOptions = true) + { + // "(default command)" is not an argv token — labels the opt-in default handler; summary/remarks from XML on the handler. + sb.AppendLine($"{indent}Console.Out.WriteLine(\" \" + CliHelpFormatting.DefaultCommandLabel(\"(default command)\"));"); + EmitRootDefaultDocumentationLines(sb, indent, rootCmd.SummaryInnerXml, rootCmd.SummaryOneLiner, false); + var remarksXml = TransformRemarksInnerXmlForHelp(rootCmd.RemarksInnerXml, rootCmd, app.AllCommands, entryAssemblyName); + EmitRootDefaultDocumentationLines(sb, indent, remarksXml, rootCmd.RemarksRendered, true); + var rootFlags = rootCmd.Parameters.Where(static p => p.Kind == ParameterKind.Flag).ToList(); + if (includeScopedDefaultHelpOptions && rootFlags.Count > 0) + { + var mw = Math.Min( + Math.Max(rootFlags.Max(p => HelpLayout.FormatOptionLeftCell(p).Length), "-h, --help".Length), + 40); + sb.AppendLine($"{indent}Console.Out.WriteLine();"); + sb.AppendLine($"{indent}Console.Out.WriteLine(CliHelpFormatting.Section(\"Options for this default:\"));"); + foreach (var p in rootFlags) + { + var left = HelpLayout.FormatOptionLeftCell(p).PadRight(mw); + var desc = BuildDescriptionSuffix(p, forPositional: false); + sb.AppendLine( + $"{indent}Console.Out.WriteLine($\" {{CliHelpFormatting.Accent(\"{Escape(left)}\")}} {EscapeForHelpInterpolation(desc)}\");"); + } + } + + sb.AppendLine($"{indent}Console.Out.WriteLine();"); + } + + private static void EmitRootAliasHelpOverview(StringBuilder sb, CommandModel aliasCmd, ImmutableArray path, string indent, string entryAssemblyName) + { + var fullCommandPath = path.IsDefaultOrEmpty + ? $"{entryAssemblyName} {aliasCmd.CommandName}" + : $"{entryAssemblyName} {string.Join(" ", path)} {aliasCmd.CommandName}"; + sb.AppendLine($"{indent}Console.Out.WriteLine(\" \" + CliHelpFormatting.DefaultCommandLabel(\"(default: {Escape(aliasCmd.CommandName)})\"));"); + if (!string.IsNullOrWhiteSpace(aliasCmd.SummaryOneLiner)) + sb.AppendLine($"{indent}Console.Out.WriteLine(\" {Escape(aliasCmd.SummaryOneLiner)}\");"); + sb.AppendLine($"{indent}Console.Out.WriteLine(\" Alias for '{Escape(fullCommandPath)}'. Run '{Escape(fullCommandPath)} --help' for details.\");"); + sb.AppendLine($"{indent}Console.Out.WriteLine();"); + } + + /// Space-separated CLI path for help listings (e.g. storage blob upload). + private static string FormatQualifiedCliPath(ImmutableArray prefix, string segment) + { + if (prefix.IsDefaultOrEmpty) + return segment; + return string.Join(" ", prefix) + " " + segment; + } + + private static void EmitArghGeneratedRouteArgsMethod(StringBuilder sb) + { + sb.AppendLine("\t\tpublic static RouteMatch? Route(string[] args)"); + sb.AppendLine("\t\t{"); + sb.AppendLine("\t\t\tif (args is null)"); + sb.AppendLine("\t\t\t\tthrow new ArgumentNullException(nameof(args));"); + sb.AppendLine("\t\t\tif (!TryParseRoute(args, out var m))"); + sb.AppendLine("\t\t\t\treturn null;"); + sb.AppendLine("\t\t\treturn m;"); + sb.AppendLine("\t\t}"); + sb.AppendLine(); + } + + + private static void EmitIsIntrinsicCommand(StringBuilder sb, AppEmitModel app) + { + sb.AppendLine("\t\tinternal static bool IsIntrinsicCommand(string[] args)"); + sb.AppendLine("\t\t{"); + sb.AppendLine("\t\t\tif (args is null || args.Length == 0) return false;"); + + var intrinsicCommands = app.AllCommands.Where(static c => c.IsIntrinsic).ToList(); + if (intrinsicCommands.Count == 0) + { + sb.AppendLine("\t\t\treturn false;"); + sb.AppendLine("\t\t}"); + sb.AppendLine(); + return; + } + + sb.AppendLine("\t\t\tvar match = Route(args);"); + sb.AppendLine("\t\t\tif (match is null) return false;"); + sb.AppendLine("\t\t\tswitch (match.Value.CommandPath)"); + sb.AppendLine("\t\t\t{"); + foreach (var cmd in intrinsicCommands) + { + var routePath = Escape(GetCommandRoutePath(cmd)); + sb.AppendLine($"\t\t\t\tcase \"{routePath}\": return true;"); + } + sb.AppendLine("\t\t\t\tdefault: return false;"); + sb.AppendLine("\t\t\t}"); + sb.AppendLine("\t\t}"); + sb.AppendLine(); + } + + private static void EmitTryParseRouteHierarchical(StringBuilder sb, AppEmitModel app) + { + var hasLeadingOptionPrefetch = + CollectRootPrefetchGlobalMembers(app).Length > 0 || CollectDeferredRootAliasPrefetchFlags(app).Length > 0; + sb.AppendLine("\t\tpublic static bool TryParseRoute(string[] args, out RouteMatch match)"); + sb.AppendLine("\t\t{"); + sb.AppendLine("\t\t\tmatch = default;"); + sb.AppendLine("\t\t\tif (CompletionProtocol.IsArghMetaCompletionInvocation(args)) return false;"); + sb.AppendLine("\t\t\tvar idx = new int[1];"); + if (hasLeadingOptionPrefetch) + sb.AppendLine("\t\t\tif (!TryParseGlobalOptions(args, idx)) return false;"); + sb.AppendLine("\t\t\tif (args.Length == 0) return false;"); + sb.AppendLine("\t\t\tif (idx[0] < args.Length && (args[idx[0]] == \"--help\" || args[idx[0]] == \"-h\")) return false;"); + sb.AppendLine("\t\t\tif (idx[0] < args.Length && args[idx[0]] == \"--version\") return false;"); + sb.AppendLine("\t\t\tif (idx[0] >= args.Length)"); + sb.AppendLine("\t\t\t{"); + if (app.Root.RootCommand is { } routeRoot) + { + var rp = Escape(GetCommandRoutePath(routeRoot)); + sb.AppendLine($"\t\t\t\tmatch = new RouteMatch(\"{rp}\", TailFrom(args, idx[0]));"); + sb.AppendLine("\t\t\t\treturn true;"); + } + else if (app.Root.RootAlias is { } routeRa) + { + var rp = Escape(GetCommandRoutePath(routeRa)); + sb.AppendLine($"\t\t\t\tmatch = new RouteMatch(\"{rp}\", TailFrom(args, idx[0]));"); + sb.AppendLine("\t\t\t\treturn true;"); + } + else + { + sb.AppendLine("\t\t\t\treturn false;"); + } + + sb.AppendLine("\t\t\t}"); + sb.AppendLine("\t\t\treturn TryParseRouteRoot(args, idx, out match);"); + sb.AppendLine("\t\t}"); + sb.AppendLine(); + EmitTryParseRouteForNode(sb, app, app.Root, ImmutableArray.Empty, "TryParseRouteRoot", isRoot: true); + EmitArghGeneratedRouteArgsMethod(sb); + } + + private static void EmitTryParseRouteForNode( + StringBuilder sb, + AppEmitModel app, + RegistryNode node, + ImmutableArray path, + string methodName, + bool isRoot) + { + sb.AppendLine($"\t\tprivate static bool {methodName}(string[] args, int[] idx, out RouteMatch match)"); + sb.AppendLine("\t\t{"); + sb.AppendLine("\t\t\tmatch = default;"); + if (!isRoot && node.CommandNamespaceOptionsModel is { FlattenedMembers.Length: > 0 }) + sb.AppendLine($"\t\t\tif (!{CommandNamespaceOptionsParseMethodName(path)}(args, idx)) return false;"); + sb.AppendLine("\t\t\tif (idx[0] >= args.Length)"); + sb.AppendLine("\t\t\t{"); + if (node.RootCommand is { } routeNsRoot) + { + var rnp = Escape(GetCommandRoutePath(routeNsRoot)); + sb.AppendLine($"\t\t\t\tmatch = new RouteMatch(\"{rnp}\", TailFrom(args, idx[0]));"); + sb.AppendLine("\t\t\t\treturn true;"); + } + else if (node.RootAlias is { } routeNsRa) + { + var rnp = Escape(GetCommandRoutePath(routeNsRa)); + sb.AppendLine($"\t\t\t\tmatch = new RouteMatch(\"{rnp}\", TailFrom(args, idx[0]));"); + sb.AppendLine("\t\t\t\treturn true;"); + } + else + { + sb.AppendLine("\t\t\t\treturn false;"); + } + + sb.AppendLine("\t\t\t}"); + sb.AppendLine("\t\t\tif (args[idx[0]] == \"--help\" || args[idx[0]] == \"-h\") return false;"); + sb.AppendLine("\t\t\tvar tokKey = args[idx[0]].ToLowerInvariant();"); + sb.AppendLine("\t\t\tswitch (tokKey)"); + sb.AppendLine("\t\t\t{"); + foreach (var cmd in node.Commands) + { + var routePath = Escape(GetCommandRoutePath(cmd)); + var caseLabel = Escape(cmd.CommandName.ToLowerInvariant()); + sb.AppendLine($"\t\t\t\tcase \"{caseLabel}\":"); + sb.AppendLine("\t\t\t\t{"); + sb.AppendLine("\t\t\t\t\tidx[0]++;"); + sb.AppendLine($"\t\t\t\t\tmatch = new RouteMatch(\"{routePath}\", TailFrom(args, idx[0]));"); + sb.AppendLine("\t\t\t\t\treturn true;"); + sb.AppendLine("\t\t\t\t}"); + } + + foreach (var ch in node.Children) + { + var childPath = AppendSegment(path, ch.Segment); + var childMethod = "TryParseRouteCommandNamespace_" + CommandNamespacePathKey(childPath); + var caseLabel = Escape(ch.Segment.ToLowerInvariant()); + sb.AppendLine($"\t\t\t\tcase \"{caseLabel}\":"); + sb.AppendLine("\t\t\t\t{"); + sb.AppendLine("\t\t\t\t\tidx[0]++;"); + sb.AppendLine($"\t\t\t\t\treturn {childMethod}(args, idx, out match);"); + sb.AppendLine("\t\t\t\t}"); + } + + sb.AppendLine("\t\t\t\tdefault:"); + sb.AppendLine("\t\t\t\t\treturn false;"); + sb.AppendLine("\t\t\t}"); + sb.AppendLine("\t\t}"); + sb.AppendLine(); + foreach (var ch in node.Children) + { + var childPath = AppendSegment(path, ch.Segment); + EmitTryParseRouteForNode(sb, app, ch.Node, childPath, "TryParseRouteCommandNamespace_" + CommandNamespacePathKey(childPath), isRoot: false); + } + } + + private static ImmutableArray CollectRootPrefetchGlobalMembers(AppEmitModel app) => + app.GlobalOptionsModel?.FlattenedMembers ?? ImmutableArray.Empty; + + /// + /// Union of prefetch parse sets (flattened globals + flattened root-alias-command flags). + /// Used to decide which leading flags can be peeled in vs deferred to dispatch. + /// + private static ImmutableArray MergeRootPrefetchPredicateMembers(AppEmitModel app) + { + var globalFlattened = app.GlobalOptionsModel?.FlattenedMembers ?? ImmutableArray.Empty; + if (app.Root.RootAlias is not { } aliasCmd || aliasCmd.Parameters.IsDefaultOrEmpty) + return globalFlattened; + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var m in globalFlattened) + if (ParticipatesInOptionPrefetch(m.Kind)) + seen.Add(m.CliLongName); + + var trailing = ImmutableArray.CreateBuilder(); + foreach (var p in aliasCmd.Parameters) + { + if (!ParticipatesInOptionPrefetch(p.Kind)) + continue; + + if (!seen.Add(p.CliLongName)) + continue; + + trailing.Add(p); + } + + if (trailing.Count == 0) + return globalFlattened; + + var merged = ImmutableArray.CreateBuilder(globalFlattened.Length + trailing.Count); + merged.AddRange(globalFlattened); + merged.AddRange(trailing); + + return merged.ToImmutable(); + } + + /// + /// Root-alias-command flags minus anything already declared on . + /// When these appear as leading --prefixed tokens, must defer them + /// (break without consuming idx) so DispatchRoot can route them to . + /// + private static ImmutableArray CollectDeferredRootAliasPrefetchFlags(AppEmitModel app) + { + var merged = MergeRootPrefetchPredicateMembers(app); + var globals = CollectRootPrefetchGlobalMembers(app); + if (app.Root.RootAlias is null || merged.Length <= globals.Length) + return ImmutableArray.Empty; + + var defer = ImmutableArray.CreateBuilder(); + for (var i = globals.Length; i < merged.Length; i++) + { + var p = merged[i]; + if (ParticipatesInOptionPrefetch(p.Kind)) + defer.Add(p); + } + + return defer.Count == 0 ? ImmutableArray.Empty : defer.ToImmutable(); + } + + private static bool ParticipatesInOptionPrefetch(ParameterKind kind) => + kind == ParameterKind.Flag || kind == ParameterKind.OptionsInjected; + + private static CommandModel SyntheticOptionsCommand(ImmutableArray members, string runMethodName) => + new( + ImmutableArray.Empty, + "__opt__", + runMethodName, + "object", + "__noop", + false, + false, + "global::System.Void", + false, + true, + members, + false, + ImmutableArray.Empty, + SourceSpanInfo.None, + ImmutableArray<(string, string)>.Empty, + "", // HandlerDocCommentId + "", // SummaryOneLiner + "", // RemarksRendered + "", // SummaryInnerXml + "", // RemarksInnerXml + "", // ExamplesRendered + "", // UsageHints + ImmutableArray<(string, bool)>.Empty); + + private static void EmitAllowedFlagPredicate(StringBuilder sb, ImmutableArray members) + { + var allowed = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var p in members) + { + if (p.Kind != ParameterKind.Flag) + continue; + allowed.Add(p.CliLongName); + foreach (var al in p.Aliases) + { + if (string.Equals(al, p.CliLongName, StringComparison.OrdinalIgnoreCase)) + continue; + allowed.Add(al); + } + + if (p.Special == BoolSpecialKind.NullableBool) + { + allowed.Add("no-" + p.CliLongName); + foreach (var al in p.Aliases) + { + if (string.Equals(al, p.CliLongName, StringComparison.OrdinalIgnoreCase)) + continue; + allowed.Add("no-" + al); + } + } + } + + sb.AppendLine("\t\t\tbool IsAllowedFlag(string name) => name switch"); + sb.AppendLine("\t\t\t{"); + foreach (var n in allowed.OrderBy(static x => x, StringComparer.OrdinalIgnoreCase)) + sb.AppendLine($"\t\t\t\t\"{Escape(n)}\" => true,"); + + sb.AppendLine("\t\t\t\t_ => false"); + sb.AppendLine("\t\t\t};"); + } + + private static void EmitDeferLeadingRootAliasHelpers(StringBuilder sb, ImmutableArray defer) + { + if (defer.IsDefaultOrEmpty) + { + sb.AppendLine("\t\t\tbool ShouldDeferLeadingRootAliasCanon(string name) => false;"); + sb.AppendLine("\t\t\tbool ShouldDeferLeadingShortFlag(char c) => false;"); + return; + } + + var canonNames = new HashSet(StringComparer.OrdinalIgnoreCase); + var shortChars = new HashSet(); + foreach (var p in defer) + { + canonNames.Add(p.CliLongName); + foreach (var al in p.Aliases) + { + if (string.Equals(al, p.CliLongName, StringComparison.OrdinalIgnoreCase)) + continue; + canonNames.Add(al); + } + + if (p.Special == BoolSpecialKind.NullableBool) + { + canonNames.Add("no-" + p.CliLongName); + foreach (var al in p.Aliases) + { + if (string.Equals(al, p.CliLongName, StringComparison.OrdinalIgnoreCase)) + continue; + canonNames.Add("no-" + al); + } + } + + if (p.ShortOpt is char ch) + shortChars.Add(ch); + } + + sb.AppendLine("\t\t\tbool ShouldDeferLeadingRootAliasCanon(string name) => name switch"); + sb.AppendLine("\t\t\t{"); + foreach (var n in canonNames.OrderBy(static x => x, StringComparer.OrdinalIgnoreCase)) + sb.AppendLine($"\t\t\t\t\"{Escape(n)}\" => true,"); + + sb.AppendLine("\t\t\t\t_ => false"); + sb.AppendLine("\t\t\t};"); + + if (shortChars.Count == 0) + { + sb.AppendLine("\t\t\tbool ShouldDeferLeadingShortFlag(char c) => false;"); + return; + } + + sb.AppendLine("\t\t\tbool ShouldDeferLeadingShortFlag(char c) => c switch"); + sb.AppendLine("\t\t\t{"); + foreach (var ch in shortChars.OrderBy(static x => x)) + sb.AppendLine($"\t\t\t\t'{ch}' => true,"); + + sb.AppendLine("\t\t\t\t_ => false"); + sb.AppendLine("\t\t\t};"); + } + + private static bool HelpUsesEnumChoiceContinuationLayout(ParameterModel p) => + p.ScalarKind == CliScalarKind.Enum + || (p.IsCollection && p.ElementScalarKind == CliScalarKind.Enum && !p.ElementEnumMemberNames.IsDefaultOrEmpty); + + private static void EmitHelpOptionRows(StringBuilder sb, IReadOnlyList rows, int maxOptWidth) + { + var continuationIndent = new string(' ', maxOptWidth + 4); + foreach (var p in rows) + { + var left = HelpLayout.FormatOptionLeftCell(p).PadRight(maxOptWidth); + var desc = BuildDescriptionSuffix(p, forPositional: false); + var validationLine = BuildValidationLine(p); + var validationOnNewLine = validationLine != null && HelpUsesEnumChoiceContinuationLayout(p); + + if (validationLine is null) + { + sb.AppendLine($"\t\t\tConsole.Out.WriteLine($\" {{CliHelpFormatting.Accent(\"{Escape(left)}\")}} {EscapeForHelpInterpolation(desc)}\");"); + } + else if (validationOnNewLine) + { + sb.AppendLine($"\t\t\tConsole.Out.WriteLine($\" {{CliHelpFormatting.Accent(\"{Escape(left)}\")}} {EscapeForHelpInterpolation(desc)}\");"); + sb.AppendLine($"\t\t\tConsole.Out.WriteLine($\"{continuationIndent}{{CliHelpFormatting.DocRemarksLine(\"{Escape(validationLine)}\")}}\");"); + } + else if (string.IsNullOrEmpty(desc)) + { + sb.AppendLine($"\t\t\tConsole.Out.WriteLine($\" {{CliHelpFormatting.Accent(\"{Escape(left)}\")}} {{CliHelpFormatting.DocRemarksLine(\"{Escape(validationLine)}\")}}\");"); + } + else + { + sb.AppendLine($"\t\t\tConsole.Out.WriteLine($\" {{CliHelpFormatting.Accent(\"{Escape(left)}\")}} {EscapeForHelpInterpolation(desc)} {{CliHelpFormatting.DocRemarksLine(\"{Escape(validationLine)}\")}}\");"); + } + } + } + + /// Same layout as but to stderr (parse errors). + private static void EmitHelpOptionRowsStdErr(StringBuilder sb, ParameterModel p, int maxOptWidth, string lineIndent) + { + var continuationIndent = new string(' ', maxOptWidth + 4); + var left = HelpLayout.FormatOptionLeftCell(p).PadRight(maxOptWidth); + var desc = BuildDescriptionSuffix(p, forPositional: false); + var validationLine = BuildValidationLine(p); + var validationOnNewLine = validationLine != null && HelpUsesEnumChoiceContinuationLayout(p); + + if (validationLine is null) + { + sb.AppendLine( + $"{lineIndent}Console.Error.WriteLine($\" {{CliHelpFormatting.Accent(\"{Escape(left)}\")}} {EscapeForHelpInterpolation(desc)}\");"); + } + else if (validationOnNewLine) + { + sb.AppendLine( + $"{lineIndent}Console.Error.WriteLine($\" {{CliHelpFormatting.Accent(\"{Escape(left)}\")}} {EscapeForHelpInterpolation(desc)}\");"); + sb.AppendLine( + $"{lineIndent}Console.Error.WriteLine($\"{continuationIndent}{{CliHelpFormatting.DocRemarksLine(\"{Escape(validationLine)}\")}}\");"); + } + else if (string.IsNullOrEmpty(desc)) + { + sb.AppendLine( + $"{lineIndent}Console.Error.WriteLine($\" {{CliHelpFormatting.Accent(\"{Escape(left)}\")}} {{CliHelpFormatting.DocRemarksLine(\"{Escape(validationLine)}\")}}\");"); + } + else + { + sb.AppendLine( + $"{lineIndent}Console.Error.WriteLine($\" {{CliHelpFormatting.Accent(\"{Escape(left)}\")}} {EscapeForHelpInterpolation(desc)} {{CliHelpFormatting.DocRemarksLine(\"{Escape(validationLine)}\")}}\");"); + } + } + + private static void EmitOptionsTryParseFlagHelpPrinter( + StringBuilder sb, + string parseMethodName, + List flagMembers, + int maxOptWidth) + { + if (flagMembers.Count == 0) + return; + + var methodName = parseMethodName + "_FlagHelp_ToStdErr"; + sb.AppendLine($"\t\tprivate static void {methodName}(string canonFlagName)"); + sb.AppendLine("\t\t{"); + sb.AppendLine("\t\t\tswitch (canonFlagName)"); + sb.AppendLine("\t\t\t{"); + foreach (var p in flagMembers.OrderBy(static x => x.CliLongName, StringComparer.OrdinalIgnoreCase)) + { + sb.AppendLine($"\t\t\t\tcase \"{Escape(p.CliLongName)}\":"); + EmitHelpOptionRowsStdErr(sb, p, maxOptWidth, "\t\t\t\t\t"); + sb.AppendLine("\t\t\t\t\tbreak;"); + } + + sb.AppendLine("\t\t\t\tdefault:"); + sb.AppendLine("\t\t\t\t\tbreak;"); + sb.AppendLine("\t\t\t}"); + sb.AppendLine("\t\t}"); + sb.AppendLine(); + } + + /// After a CLI parse/validation error on stderr: optional flag rows (matching --help), then optional run hint. + private static void EmitAfterCliParseErrorHelp( + StringBuilder sb, + ParameterModel p, + string lineIndent, + string? helpMethodName, + string? flagHelpStdErrMethodName, + string? parseFailureRunHint) + { + if (p.Kind == ParameterKind.Flag && flagHelpStdErrMethodName is not null) + { + sb.AppendLine($"{lineIndent}Console.Error.WriteLine();"); + sb.AppendLine($"{lineIndent}{flagHelpStdErrMethodName}(\"{Escape(p.CliLongName)}\");"); + sb.AppendLine($"{lineIndent}Console.Error.WriteLine();"); + if (parseFailureRunHint is not null) + sb.AppendLine($"{lineIndent}Console.Error.WriteLine(\"{parseFailureRunHint}\");"); + } + else if (helpMethodName is not null) + sb.AppendLine($"{lineIndent}{helpMethodName}();"); + } + + /// After a validation-check error: optional flag rows on stderr, then optional run hint. + private static void EmitValidationErrorFooter( + StringBuilder sb, + ParameterModel p, + string cliName, + string indent, + string? flagHelpStdErrMethodName, + string? runHint) + { + if (p.Kind == ParameterKind.Flag && flagHelpStdErrMethodName is not null) + { + sb.AppendLine($"{indent}Console.Error.WriteLine();"); + sb.AppendLine($"{indent}{flagHelpStdErrMethodName}(\"{Escape(cliName)}\");"); + sb.AppendLine($"{indent}Console.Error.WriteLine();"); + } + + if (runHint is not null) + sb.AppendLine($"{indent}Console.Error.WriteLine(\"{runHint}\");"); + } + + private static void EmitCommandHelpPrinter(StringBuilder sb, CommandModel cmd, AppEmitModel app, string entryAssemblyName) + { + if (cmd.IsRootDefault) + return; + + var routeUsage = cmd.RoutePrefix.IsDefaultOrEmpty + ? "" + : string.Join(" ", cmd.RoutePrefix) + " "; + + var globalFlagMembers = EnumerateFlagMembers(app.GlobalOptionsModel).ToList(); + List<(string Segment, List Rows)> namespaceOptionSections = new(); + var namespaceOptionChain = GetCommandNamespaceOptionChain(app, cmd.RoutePrefix); + var suppressedForNamespaceDisplay = new HashSet(StringComparer.OrdinalIgnoreCase); + AddCliKeys(globalFlagMembers, suppressedForNamespaceDisplay); + foreach ((var seg, var gom) in namespaceOptionChain) + { + var allInNamespace = EnumerateFlagMembers(gom).ToList(); + var rows = allInNamespace.Where(p => !suppressedForNamespaceDisplay.Contains(p.CliLongName)).ToList(); + AddCliKeys(allInNamespace, suppressedForNamespaceDisplay); + if (rows.Count > 0) + namespaceOptionSections.Add((seg, rows)); + } + + var scopedKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + AddCliKeys(globalFlagMembers, scopedKeys); + foreach ((_, var gom) in namespaceOptionChain) + AddCliKeys(EnumerateFlagMembers(gom), scopedKeys); + + var commandOnlyFlags = cmd.Parameters + .Where(p => p.Kind == ParameterKind.Flag && !CommandFlagMatchesScopedKeys(p, scopedKeys)) + .ToList(); + + var widthCandidates = new List { "-h, --help".Length }; + widthCandidates.AddRange(globalFlagMembers.Select(p => HelpLayout.FormatOptionLeftCell(p).Length)); + foreach ((_, var rows) in namespaceOptionSections) + widthCandidates.AddRange(rows.Select(p => HelpLayout.FormatOptionLeftCell(p).Length)); + + widthCandidates.AddRange(commandOnlyFlags.Select(p => HelpLayout.FormatOptionLeftCell(p).Length)); + var maxOptWidth = Math.Min(widthCandidates.Max(), 40); + maxOptWidth = Math.Max(maxOptWidth, "-h, --help".Length); + + sb.AppendLine($"\t\tprivate static void PrintHelp_{cmd.RunMethodName}()"); + sb.AppendLine("\t\t{"); + sb.AppendLine( + $"\t\t\tConsole.Out.WriteLine(CliHelpFormatting.Section(\"Usage: \") + CliHelpFormatting.Accent(\"{Escape(entryAssemblyName)}\") + \" {Escape(routeUsage)}{Escape(cmd.CommandName)} {Escape(cmd.UsageHints)}\");"); + + sb.AppendLine("\t\t\tConsole.Out.WriteLine();"); + + EmitCommandHelpDocPrologue(sb, "\t\t\t", cmd.SummaryInnerXml, cmd.SummaryOneLiner, false); + if (!string.IsNullOrWhiteSpace(cmd.SummaryOneLiner) || !string.IsNullOrWhiteSpace(cmd.SummaryInnerXml)) + sb.AppendLine("\t\t\tConsole.Out.WriteLine();"); + + var hasArgs = false; + foreach (var p in cmd.Parameters) + { + if (p.Kind == ParameterKind.Positional) + hasArgs = true; + } + + if (hasArgs) + { + var maxArgWidth = cmd.Parameters + .Where(p => p.Kind == ParameterKind.Positional) + .Select(p => + { + if (p.IsVariadic) + return (p.IsRequired ? $"<{p.CliLongName}...>" : $"[<{p.CliLongName}...>]").Length; + return (p.IsRequired ? $"<{p.CliLongName}>" : $"[<{p.CliLongName}>]").Length; + }) + .DefaultIfEmpty(0).Max(); + maxArgWidth = Math.Min(maxArgWidth, 40); + + sb.AppendLine("\t\t\tConsole.Out.WriteLine(CliHelpFormatting.Section(\"Arguments:\"));"); + foreach (var p in cmd.Parameters) + { + if (p.Kind != ParameterKind.Positional) + continue; + + var nameCell = p.IsVariadic + ? (p.IsRequired ? $"<{p.CliLongName}...>" : $"[<{p.CliLongName}...>]") + : (p.IsRequired ? $"<{p.CliLongName}>" : $"[<{p.CliLongName}>]"); + var nameCellPadded = nameCell.PadRight(maxArgWidth); + var desc = BuildDescriptionSuffix(p, forPositional: true); + var argValidationLine = BuildValidationLine(p); + if (argValidationLine is null) + { + sb.AppendLine($"\t\t\tConsole.Out.WriteLine($\" {{CliHelpFormatting.Placeholder(\"{Escape(nameCellPadded)}\")}} {EscapeForHelpInterpolation(desc)}\");"); + } + else if (string.IsNullOrEmpty(desc)) + { + sb.AppendLine($"\t\t\tConsole.Out.WriteLine($\" {{CliHelpFormatting.Placeholder(\"{Escape(nameCellPadded)}\")}} {{CliHelpFormatting.DocRemarksLine(\"{Escape(argValidationLine)}\")}}\");"); + } + else + { + sb.AppendLine($"\t\t\tConsole.Out.WriteLine($\" {{CliHelpFormatting.Placeholder(\"{Escape(nameCellPadded)}\")}} {EscapeForHelpInterpolation(desc)} {{CliHelpFormatting.DocRemarksLine(\"{Escape(argValidationLine)}\")}}\");"); + } + } + + sb.AppendLine("\t\t\tConsole.Out.WriteLine();"); + } + + sb.AppendLine("\t\t\tConsole.Out.WriteLine(CliHelpFormatting.Section(\"Global options:\"));"); + sb.AppendLine( + $"\t\t\tConsole.Out.WriteLine(\" \" + CliHelpFormatting.Placeholder(\"{Escape("-h, --help".PadRight(maxOptWidth))}\") + \" Show help.\");"); + if (globalFlagMembers.Count > 0) + EmitHelpOptionRows(sb, globalFlagMembers, maxOptWidth); + + sb.AppendLine("\t\t\tConsole.Out.WriteLine();"); + + foreach ((var segment, var gRows) in namespaceOptionSections) + { + sb.AppendLine($"\t\t\tConsole.Out.WriteLine(CliHelpFormatting.Section(\"'{Escape(segment)}' options:\"));"); + EmitHelpOptionRows(sb, gRows, maxOptWidth); + sb.AppendLine("\t\t\tConsole.Out.WriteLine();"); + } + + if (commandOnlyFlags.Count > 0) + { + sb.AppendLine("\t\t\tConsole.Out.WriteLine(CliHelpFormatting.Section(\"Options:\"));"); + EmitHelpOptionRows(sb, commandOnlyFlags, maxOptWidth); + } + + var remarksXml = TransformRemarksInnerXmlForHelp(cmd.RemarksInnerXml, cmd, app.AllCommands, entryAssemblyName); + var hasRemarks = !string.IsNullOrWhiteSpace(cmd.RemarksRendered) || !string.IsNullOrWhiteSpace(remarksXml); + if (hasRemarks) + { + sb.AppendLine("\t\t\tConsole.Out.WriteLine();"); + EmitNotesSection(sb, "\t\t\t", remarksXml, cmd.RemarksRendered); + } + + if (!string.IsNullOrWhiteSpace(cmd.ExamplesRendered)) + { + sb.AppendLine("\t\t\tConsole.Out.WriteLine();"); + sb.AppendLine("\t\t\tConsole.Out.WriteLine(CliHelpFormatting.Section(\"Examples:\"));"); + foreach (var line in cmd.ExamplesRendered.Split('\n')) + { + var trimmed = line.TrimEnd('\r'); + if (trimmed.Length == 0) + sb.AppendLine("\t\t\tConsole.Out.WriteLine();"); + else + sb.AppendLine($"\t\t\tConsole.Out.WriteLine(\" {Escape(trimmed)}\");"); + } + } + + sb.AppendLine("\t\t}"); + sb.AppendLine(); + } + + private static void EmitCommandFlagHelpToStdErrMethod(StringBuilder sb, CommandModel cmd, AppEmitModel app) + { + if (cmd.IsRootDefault) + return; + + var globalFlagMembers = EnumerateFlagMembers(app.GlobalOptionsModel).ToList(); + List<(string Segment, List Rows)> namespaceOptionSections = new(); + var namespaceOptionChain = GetCommandNamespaceOptionChain(app, cmd.RoutePrefix); + var suppressedForNamespaceDisplay = new HashSet(StringComparer.OrdinalIgnoreCase); + AddCliKeys(globalFlagMembers, suppressedForNamespaceDisplay); + foreach ((var seg, var gom) in namespaceOptionChain) + { + var allInNamespace = EnumerateFlagMembers(gom).ToList(); + var rows = allInNamespace.Where(p => !suppressedForNamespaceDisplay.Contains(p.CliLongName)).ToList(); + AddCliKeys(allInNamespace, suppressedForNamespaceDisplay); + if (rows.Count > 0) + namespaceOptionSections.Add((seg, rows)); + } + + var scopedKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + AddCliKeys(globalFlagMembers, scopedKeys); + foreach ((_, var gom) in namespaceOptionChain) + AddCliKeys(EnumerateFlagMembers(gom), scopedKeys); + + var commandOnlyFlags = cmd.Parameters + .Where(p => p.Kind == ParameterKind.Flag && !CommandFlagMatchesScopedKeys(p, scopedKeys)) + .ToList(); + + var widthCandidates = new List { "-h, --help".Length }; + widthCandidates.AddRange(globalFlagMembers.Select(p => HelpLayout.FormatOptionLeftCell(p).Length)); + foreach ((_, var rows) in namespaceOptionSections) + widthCandidates.AddRange(rows.Select(p => HelpLayout.FormatOptionLeftCell(p).Length)); + + widthCandidates.AddRange(commandOnlyFlags.Select(p => HelpLayout.FormatOptionLeftCell(p).Length)); + var maxOptWidth = Math.Min(widthCandidates.Max(), 40); + maxOptWidth = Math.Max(maxOptWidth, "-h, --help".Length); + + var byCanon = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var p in globalFlagMembers) + byCanon[p.CliLongName] = p; + foreach ((_, var rows) in namespaceOptionSections) + foreach (var p in rows) + byCanon[p.CliLongName] = p; + foreach (var p in commandOnlyFlags) + byCanon[p.CliLongName] = p; + + sb.AppendLine($"\t\tprivate static void PrintHelp_{cmd.RunMethodName}_Flag_ToStdErr(string canonFlagName)"); + sb.AppendLine("\t\t{"); + if (byCanon.Count > 0) + { + sb.AppendLine("\t\t\tswitch (canonFlagName)"); + sb.AppendLine("\t\t\t{"); + foreach (var p in byCanon.Values.OrderBy(static x => x.CliLongName, StringComparer.OrdinalIgnoreCase)) + { + sb.AppendLine($"\t\t\t\tcase \"{Escape(p.CliLongName)}\":"); + EmitHelpOptionRowsStdErr(sb, p, maxOptWidth, "\t\t\t\t\t"); + sb.AppendLine("\t\t\t\t\tbreak;"); + } + + sb.AppendLine("\t\t\t\tdefault:"); + sb.AppendLine("\t\t\t\t\tbreak;"); + sb.AppendLine("\t\t\t}"); + } + + sb.AppendLine("\t\t}"); + sb.AppendLine(); + } + + private static string BuildDescriptionSuffix(ParameterModel p, bool forPositional) + { + var parts = new List(); + + if (!forPositional && p.Kind == ParameterKind.Flag && p.Special == BoolSpecialKind.None && p.IsRequired) + parts.Add("[required]"); + + if (!forPositional && p is { IsCollection: true, Kind: ParameterKind.Flag }) + parts.Add(p.CollectionSeparator is null ? "[repeatable]" : "[separated]"); + + if (forPositional && p.IsVariadic) + parts.Add("[variadic]"); + + if (!string.IsNullOrWhiteSpace(p.Description)) + parts.Add(p.Description.Trim()); + + if (p.Special == BoolSpecialKind.None) + { + if (p.DefaultValueLiteral is not null) + parts.Add($"[default: {FormatDefaultForHelp(p)}]"); + } + + return string.Join(" ", parts.Where(s => !string.IsNullOrWhiteSpace(s))); + } + + private static string FormatDefaultForHelp(ParameterModel p) + { + if (p.DefaultValueLiteral is null) + return ""; + + if (p.ScalarKind == CliScalarKind.Enum && !p.EnumMemberNames.IsDefaultOrEmpty) + { + var lit = p.DefaultValueLiteral.Trim(); + for (var i = 0; i < p.EnumMemberNames.Length; i++) + { + var member = p.EnumMemberNames[i]; + if (string.Equals(lit, member, StringComparison.Ordinal) || lit.EndsWith("." + member, StringComparison.Ordinal)) + return ResolveEnumMemberCliName(p.EnumMemberCliNames, i, member); + } + } + + return p.TypeName switch + { + "string" => p.DefaultValueLiteral.Trim('"'), + _ => p.DefaultValueLiteral + }; + } + + /// + /// Emits: if (string.Equals({varName}, "{value}", StringComparison.OrdinalIgnoreCase)) { {body} } + /// + private static void EmitOrdinalIgnoreCaseIf( + StringBuilder sb, + string indent, + string varName, + string value, + Action body) + { + sb.AppendLine($"{indent}if (string.Equals({varName}, \"{Escape(value)}\", StringComparison.OrdinalIgnoreCase))"); + sb.AppendLine($"{indent}{{"); + body(sb); + sb.AppendLine($"{indent}}}"); + } + + private static class UsageSynopsis + { + /// Minimal usage tail: required flags and positionals explicitly; optional switches and flags fold into a single [options]. + public static string Build(ImmutableArray parameters) + { + var parts = new List(); + var needsOptions = false; + + foreach (var p in parameters) + { + if (p.Kind == ParameterKind.Injected || p.Kind == ParameterKind.OptionsInjected) + continue; + + if (p.Kind == ParameterKind.Positional) + { + string seg; + if (p.IsVariadic) + seg = p.IsRequired ? $"<{p.CliLongName}...>" : $"[<{p.CliLongName}...>]"; + else + seg = p.IsRequired ? $"<{p.CliLongName}>" : $"[<{p.CliLongName}>]"; + parts.Add(seg); + continue; + } + + if (p.Kind != ParameterKind.Flag) + continue; + + if (p.Special == BoolSpecialKind.Bool) + { + needsOptions = true; + continue; + } + + if (p.Special == BoolSpecialKind.NullableBool) + { + needsOptions = true; + continue; + } + + if (p.IsCollection) + { + if (p.IsRequired) + { + var typeHint = HelpLayout.TypeHint(p); + parts.Add($"--{p.CliLongName} {typeHint}"); + } + else + { + needsOptions = true; + } + + continue; + } + + var typeHintScalar = HelpLayout.TypeHint(p); + if (p.IsRequired) + parts.Add($"--{p.CliLongName} {typeHintScalar}"); + else + needsOptions = true; + } + + if (needsOptions) + parts.Add("[options]"); + + return string.Join(" ", parts); + } + } +} diff --git a/src/Nullean.Argh.Generator/CliParserGenerator.Emit.Parsing.cs b/src/Nullean.Argh.Generator/CliParserGenerator.Emit.Parsing.cs new file mode 100644 index 0000000..f643a25 --- /dev/null +++ b/src/Nullean.Argh.Generator/CliParserGenerator.Emit.Parsing.cs @@ -0,0 +1,1339 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Globalization; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Operations; +using Microsoft.CodeAnalysis.Text; + +namespace Nullean.Argh; + +public sealed partial class CliParserGenerator +{ + private static void EmitOptionsTryParse( + StringBuilder sb, + string methodName, + ImmutableArray members, + string? storeTypeFq = null, + string? storeFieldName = null, + ImmutableArray? storeBestCtorParamOrder = null, + string? entryAssemblyName = null, + ImmutableArray? deferLeadingRootAliasFlags = null) + { + var defer = deferLeadingRootAliasFlags ?? ImmutableArray.Empty; + + var syn = SyntheticOptionsCommand(members, methodName); + var flagMembers = members.Where(static p => p.Kind == ParameterKind.Flag).ToList(); + var widthCandidates = new List { "-h, --help".Length }; + widthCandidates.AddRange(flagMembers.Select(static p => HelpLayout.FormatOptionLeftCell(p).Length)); + var maxOptWidth = flagMembers.Count == 0 + ? "-h, --help".Length + : Math.Max(Math.Min(widthCandidates.Max(), 40), "-h, --help".Length); + + if (flagMembers.Count > 0) + EmitOptionsTryParseFlagHelpPrinter(sb, methodName, flagMembers, maxOptWidth); + + var flagHelpMethodName = methodName + "_FlagHelp_ToStdErr"; + var emitRunHint = !string.IsNullOrEmpty(entryAssemblyName); + var runHintFailUnknown = emitRunHint + ? $"\t\t\t\t\tConsole.Error.WriteLine(\"Run '{Escape(entryAssemblyName!)} --help' for usage.\");" + : null; + var runHintMissingLong = emitRunHint + ? $"\t\t\t\t\t\t\tConsole.Error.WriteLine(\"Run '{Escape(entryAssemblyName!)} --help' for usage.\");" + : null; + + sb.AppendLine($"\t\tprivate static bool {methodName}(string[] args, int[] idx)"); + sb.AppendLine("\t\t{"); + sb.AppendLine("\t\t\tvar flags = new Dictionary(StringComparer.OrdinalIgnoreCase);"); + EmitBoolSwitchNames(sb, syn); + EmitCanonFlagNameMethod(sb, syn); + EmitShortFlagMethods(sb, syn, multiFlagsAvailable: false); + EmitAllowedFlagPredicate(sb, members); + EmitDeferLeadingRootAliasHelpers(sb, defer); + + if (flagMembers.Count > 0) + { + sb.Append("\t\t\tvar __flagFuzzyCands = new string[] { "); + var sortedNames = flagMembers + .Select(static p => p.CliLongName) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(static x => x, StringComparer.OrdinalIgnoreCase) + .ToList(); + for (var i = 0; i < sortedNames.Count; i++) + { + if (i > 0) + sb.Append(", "); + sb.Append('"').Append(Escape(sortedNames[i])).Append('"'); + } + + sb.AppendLine(" };"); + sb.AppendLine("\t\t\tbool FailUnknownLongOption(string flagName)"); + sb.AppendLine("\t\t\t{"); + sb.AppendLine($"\t\t\t\tvar __matches = FuzzyMatch.FindClosest(flagName, __flagFuzzyCands, {FuzzyMaxDistance});"); + sb.AppendLine("\t\t\t\tif (__matches.Count == 0)"); + sb.AppendLine("\t\t\t\t{"); + sb.AppendLine("\t\t\t\t\tConsole.Error.WriteLine($\"Error: unknown option '--{flagName}'.\");"); + if (runHintFailUnknown is not null) + { + sb.AppendLine("\t\t\t\t\tConsole.Error.WriteLine();"); + sb.AppendLine(runHintFailUnknown); + } + + sb.AppendLine("\t\t\t\t\treturn false;"); + sb.AppendLine("\t\t\t\t}"); + sb.AppendLine("\t\t\t\tif (__matches.Count == 1)"); + sb.AppendLine("\t\t\t\t{"); + sb.AppendLine("\t\t\t\t\tvar __m = __matches[0];"); + sb.AppendLine("\t\t\t\t\tConsole.Error.WriteLine($\"Error: unknown option '--{flagName}'. Did you mean '--{__m}'?\");"); + sb.AppendLine("\t\t\t\t\tConsole.Error.WriteLine();"); + sb.AppendLine($"\t\t\t\t\t{flagHelpMethodName}(__m);"); + if (runHintFailUnknown is not null) + { + sb.AppendLine("\t\t\t\t\tConsole.Error.WriteLine();"); + sb.AppendLine(runHintFailUnknown); + } + + sb.AppendLine("\t\t\t\t\treturn false;"); + sb.AppendLine("\t\t\t\t}"); + sb.AppendLine("\t\t\t\tConsole.Error.WriteLine($\"Error: unknown option '--{flagName}'. Did you mean one of these?\");"); + sb.AppendLine("\t\t\t\tConsole.Error.WriteLine();"); + sb.AppendLine("\t\t\t\tforeach (var __m in __matches)"); + sb.AppendLine("\t\t\t\t{"); + sb.AppendLine($"\t\t\t\t\t{flagHelpMethodName}(__m);"); + sb.AppendLine("\t\t\t\t}"); + if (runHintFailUnknown is not null) + { + sb.AppendLine("\t\t\t\tConsole.Error.WriteLine();"); + sb.AppendLine(runHintFailUnknown); + } + sb.AppendLine("\t\t\t\treturn false;"); + sb.AppendLine("\t\t\t}"); + sb.AppendLine(); + } + + sb.AppendLine("\t\t\twhile (idx[0] < args.Length && args[idx[0]].Length > 0 && args[idx[0]][0] == '-')"); + sb.AppendLine("\t\t\t{"); + sb.AppendLine("\t\t\t\tif (args[idx[0]] == \"--help\" || args[idx[0]] == \"-h\" || args[idx[0]] == \"--version\")"); + sb.AppendLine("\t\t\t\t\tbreak;"); + sb.AppendLine("\t\t\t\tvar a = args[idx[0]];"); + sb.AppendLine("\t\t\t\tif (a.StartsWith(\"--\", StringComparison.Ordinal))"); + sb.AppendLine("\t\t\t\t{"); + sb.AppendLine("\t\t\t\t\tvar eq = a.IndexOf('=');"); + sb.AppendLine("\t\t\t\t\tif (eq >= 0)"); + sb.AppendLine("\t\t\t\t\t{"); + sb.AppendLine("\t\t\t\t\t\tvar flagName = CanonFlagName(a.Substring(2, eq - 2));"); + sb.AppendLine("\t\t\t\t\t\tif (!IsAllowedFlag(flagName))"); + sb.AppendLine("\t\t\t\t\t\t{"); + sb.AppendLine("\t\t\t\t\t\t\tif (ShouldDeferLeadingRootAliasCanon(flagName))"); + sb.AppendLine("\t\t\t\t\t\t\t\tbreak;"); + if (flagMembers.Count > 0) + sb.AppendLine("\t\t\t\t\t\t\t\treturn FailUnknownLongOption(flagName);"); + else + { + sb.AppendLine("\t\t\t\t\t\t\tConsole.Error.WriteLine($\"Error: unknown option '--{flagName}'.\");"); + sb.AppendLine("\t\t\t\t\t\t\treturn false;"); + } + + sb.AppendLine("\t\t\t\t\t\t}"); + sb.AppendLine("\t\t\t\t\t\tvar flagValue = a.Substring(eq + 1);"); + sb.AppendLine("\t\t\t\t\t\tflags[flagName] = flagValue;"); + sb.AppendLine("\t\t\t\t\t\tidx[0]++;"); + sb.AppendLine("\t\t\t\t\t}"); + sb.AppendLine("\t\t\t\t\telse"); + sb.AppendLine("\t\t\t\t\t{"); + sb.AppendLine("\t\t\t\t\t\tvar flagName = CanonFlagName(a.Substring(2));"); + sb.AppendLine("\t\t\t\t\t\tif (!IsAllowedFlag(flagName))"); + sb.AppendLine("\t\t\t\t\t\t{"); + sb.AppendLine("\t\t\t\t\t\t\tif (ShouldDeferLeadingRootAliasCanon(flagName))"); + sb.AppendLine("\t\t\t\t\t\t\t\tbreak;"); + if (flagMembers.Count > 0) + sb.AppendLine("\t\t\t\t\t\t\t\treturn FailUnknownLongOption(flagName);"); + else + { + sb.AppendLine("\t\t\t\t\t\t\tConsole.Error.WriteLine($\"Error: unknown option '--{flagName}'.\");"); + sb.AppendLine("\t\t\t\t\t\t\treturn false;"); + } + + sb.AppendLine("\t\t\t\t\t\t}"); + sb.AppendLine("\t\t\t\t\t\tif (IsBoolSwitchName(flagName))"); + sb.AppendLine("\t\t\t\t\t\t{"); + sb.AppendLine("\t\t\t\t\t\t\tflags[flagName] = IsBoolSwitchNoName(flagName) ? null : \"true\";"); + sb.AppendLine("\t\t\t\t\t\t\tidx[0]++;"); + sb.AppendLine("\t\t\t\t\t\t}"); + sb.AppendLine("\t\t\t\t\t\telse"); + sb.AppendLine("\t\t\t\t\t\t{"); + sb.AppendLine("\t\t\t\t\t\t\tif (idx[0] + 1 >= args.Length)"); + sb.AppendLine("\t\t\t\t\t\t\t{"); + sb.AppendLine("\t\t\t\t\t\t\t\tConsole.Error.WriteLine($\"Error: missing value for flag --{flagName}.\");"); + if (flagMembers.Count > 0) + { + sb.AppendLine("\t\t\t\t\t\t\t\tConsole.Error.WriteLine();"); + sb.AppendLine($"\t\t\t\t\t\t\t\t{flagHelpMethodName}(flagName);"); + if (runHintMissingLong is not null) + { + sb.AppendLine("\t\t\t\t\t\t\t\tConsole.Error.WriteLine();"); + sb.AppendLine(runHintMissingLong); + } + } + + sb.AppendLine("\t\t\t\t\t\t\t\treturn false;"); + sb.AppendLine("\t\t\t\t\t\t\t}"); + sb.AppendLine("\t\t\t\t\t\t\tflags[flagName] = args[idx[0] + 1];"); + sb.AppendLine("\t\t\t\t\t\t\tidx[0] += 2;"); + sb.AppendLine("\t\t\t\t\t\t}"); + sb.AppendLine("\t\t\t\t\t}"); + sb.AppendLine("\t\t\t\t\tcontinue;"); + sb.AppendLine("\t\t\t\t}"); + sb.AppendLine("\t\t\t\tif (a.Length >= 2 && a[0] == '-' && a[1] != '-')"); + sb.AppendLine("\t\t\t\t{"); + sb.AppendLine("\t\t\t\t\tvar eqs = a.IndexOf('=');"); + sb.AppendLine("\t\t\t\t\tif (eqs >= 0)"); + sb.AppendLine("\t\t\t\t\t{"); + sb.AppendLine("\t\t\t\t\t\tvar shortKey = a.Substring(1, eqs - 1);"); + sb.AppendLine("\t\t\t\t\t\tif (shortKey.Length != 1)"); + sb.AppendLine("\t\t\t\t\t\t{"); + sb.AppendLine("\t\t\t\t\t\t\tConsole.Error.WriteLine(\"Error: short options must be a single letter (e.g. -e=value).\");"); + sb.AppendLine("\t\t\t\t\t\t\treturn false;"); + sb.AppendLine("\t\t\t\t\t\t}"); + sb.AppendLine("\t\t\t\t\t\tif (!TryApplyShortFlag(shortKey[0], a.Substring(eqs + 1)))"); + sb.AppendLine("\t\t\t\t\t\t{"); + sb.AppendLine("\t\t\t\t\t\t\tif (ShouldDeferLeadingShortFlag(shortKey[0])) break;"); + sb.AppendLine("\t\t\t\t\t\t\treturn false;"); + sb.AppendLine("\t\t\t\t\t\t}"); + sb.AppendLine("\t\t\t\t\t\tidx[0]++;"); + sb.AppendLine("\t\t\t\t\t\tcontinue;"); + sb.AppendLine("\t\t\t\t\t}"); + sb.AppendLine("\t\t\t\t\tif (a.Length == 2)"); + sb.AppendLine("\t\t\t\t\t{"); + sb.AppendLine("\t\t\t\t\t\tvar sc = a[1];"); + sb.AppendLine("\t\t\t\t\t\tif (IsShortBoolChar(sc))"); + sb.AppendLine("\t\t\t\t\t\t{"); + sb.AppendLine("\t\t\t\t\t\t\tif (!TryApplyShortFlag(sc, \"true\"))"); + sb.AppendLine("\t\t\t\t\t\t\t{"); + sb.AppendLine("\t\t\t\t\t\t\t\tif (ShouldDeferLeadingShortFlag(sc)) break;"); + sb.AppendLine("\t\t\t\t\t\t\t\treturn false;"); + sb.AppendLine("\t\t\t\t\t\t\t}"); + sb.AppendLine("\t\t\t\t\t\t\tidx[0]++;"); + sb.AppendLine("\t\t\t\t\t\t\tcontinue;"); + sb.AppendLine("\t\t\t\t\t\t}"); + sb.AppendLine("\t\t\t\t\t\tif (idx[0] + 1 >= args.Length)"); + sb.AppendLine("\t\t\t\t\t\t{"); + sb.AppendLine("\t\t\t\t\t\t\tConsole.Error.WriteLine($\"Error: missing value for short flag '-{sc}'.\");"); + sb.AppendLine("\t\t\t\t\t\t\treturn false;"); + sb.AppendLine("\t\t\t\t\t\t}"); + sb.AppendLine("\t\t\t\t\t\tif (!TryApplyShortFlag(sc, args[idx[0] + 1]))"); + sb.AppendLine("\t\t\t\t\t\t{"); + sb.AppendLine("\t\t\t\t\t\t\tif (ShouldDeferLeadingShortFlag(sc)) break;"); + sb.AppendLine("\t\t\t\t\t\t\treturn false;"); + sb.AppendLine("\t\t\t\t\t\t}"); + sb.AppendLine("\t\t\t\t\t\tidx[0] += 2;"); + sb.AppendLine("\t\t\t\t\t\tcontinue;"); + sb.AppendLine("\t\t\t\t\t}"); + sb.AppendLine("\t\t\t\t\tConsole.Error.WriteLine(\"Error: combined short flags (e.g. -abc) are not supported.\");"); + sb.AppendLine("\t\t\t\t\treturn false;"); + sb.AppendLine("\t\t\t\t}"); + sb.AppendLine("\t\t\t\tConsole.Error.WriteLine($\"Error: unexpected token '{a}'.\");"); + sb.AppendLine("\t\t\t\treturn false;"); + sb.AppendLine("\t\t\t}"); + if (storeTypeFq is not null && storeFieldName is not null && members.Length > 0) + EmitOptionsConstructAndStore(sb, storeTypeFq, members, storeFieldName, storeBestCtorParamOrder); + sb.AppendLine("\t\t\treturn true;"); + sb.AppendLine("\t\t}"); + sb.AppendLine(); + } + + /// + /// After parses flags into a flags dict, extract member values and + /// construct the options instance, then store it in . + /// Injected just before return true of the parse method. + /// + private static void EmitOptionsConstructAndStore( + StringBuilder sb, + string typeFq, + ImmutableArray members, + string storeFieldName, + ImmutableArray? bestCtorParamOrder) + { + var byName = members.ToDictionary(static m => m.SymbolName, StringComparer.OrdinalIgnoreCase); + + // For cross-assembly options types the property initializer syntax is not readable. + // Instantiate the type once to capture all C# runtime defaults. + var hasRtDefaults = members.Any(static m => m.UsesRuntimeDefault); + if (hasRtDefaults) + sb.AppendLine($"\t\t\tvar __rt_default = new {typeFq}();"); + + // Extract each member's value from the flags dict. + foreach (var m in members) + { + if (m.Kind != ParameterKind.Flag) + continue; + if (m.Special == BoolSpecialKind.Bool) + sb.AppendLine($"\t\t\tvar {m.LocalVarName} = flags.ContainsKey(\"{Escape(m.CliLongName)}\");"); + else if (m.Special == BoolSpecialKind.NullableBool) + { + sb.AppendLine($"\t\t\tbool? {m.LocalVarName} = null;"); + sb.AppendLine($"\t\t\tif (flags.TryGetValue(\"{Escape(m.CliLongName)}\", out var {m.LocalVarName}_yv))"); + sb.AppendLine($"\t\t\t\t{m.LocalVarName} = ParseNullableBool({m.LocalVarName}_yv, true);"); + sb.AppendLine($"\t\t\tif (flags.TryGetValue(\"no-{Escape(m.CliLongName)}\", out var {m.LocalVarName}_nv))"); + sb.AppendLine($"\t\t\t\t{m.LocalVarName} = ParseNullableBool({m.LocalVarName}_nv, false);"); + } + else + { + // Declare the local variable first (EmitParseAndAssign only assigns, does not declare). + // For cross-assembly runtime-default properties, seed from the pre-created instance. + var initializer = m.UsesRuntimeDefault && hasRtDefaults + ? $"__rt_default.{m.SymbolName}" + : GetCliInitializer(m); + sb.AppendLine($"\t\t\t{GetCSharpCliType(m)} {m.LocalVarName} = {initializer};"); + var canOmitFlag = !m.IsRequired || m.DefaultValueLiteral is not null; + if (canOmitFlag) + { + sb.AppendLine($"\t\t\tif (flags.TryGetValue(\"{Escape(m.CliLongName)}\", out var {m.LocalVarName}Text) && {m.LocalVarName}Text is not null)"); + sb.AppendLine("\t\t\t{"); + EmitParseAndAssign(sb, m, m.LocalVarName + "Text", m.LocalVarName, "return false", null); + sb.AppendLine("\t\t\t}"); + } + else + { + sb.AppendLine($"\t\t\tif (!flags.TryGetValue(\"{Escape(m.CliLongName)}\", out var {m.LocalVarName}Text) || {m.LocalVarName}Text is null)"); + sb.AppendLine("\t\t\t{"); + sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine($\"Error: missing required flag --{Escape(m.CliLongName)}.\");"); + sb.AppendLine("\t\t\t\treturn false;"); + sb.AppendLine("\t\t\t}"); + EmitParseAndAssign(sb, m, m.LocalVarName + "Text", m.LocalVarName, "return false", null); + } + } + } + + // Construct using the primary constructor if all members align, otherwise property assignment. + if (bestCtorParamOrder is { } ctorOrder && ctorOrder.Length == members.Length) + { + sb.Append($"\t\t\t{storeFieldName} = new {typeFq}("); + for (var i = 0; i < ctorOrder.Length; i++) + { + if (i > 0) sb.Append(", "); + sb.Append(byName[ctorOrder[i]].LocalVarName); + } + + sb.AppendLine(");"); + } + else + { + sb.AppendLine($"\t\t\t{storeFieldName} = new {typeFq}();"); + foreach (var m in members) + sb.AppendLine($"\t\t\t{storeFieldName}.{m.SymbolName} = {m.LocalVarName};"); + } + } + + private static void EmitIsMultiFlagPredicate(StringBuilder sb, CommandModel cmd) + { + var names = new List(); + foreach (var p in cmd.Parameters) + { + if (p is { IsCollection: true, Kind: ParameterKind.Flag } && p.CollectionSeparator is null) + names.Add(p.CliLongName); + } + + if (names.Count == 0) + { + sb.AppendLine("\t\t\tbool IsMultiFlag(string name) => false;"); + return; + } + + sb.AppendLine("\t\t\tbool IsMultiFlag(string name) => name switch"); + sb.AppendLine("\t\t\t{"); + foreach (var n in names) + sb.AppendLine($"\t\t\t\t\"{Escape(n)}\" => true,"); + + sb.AppendLine("\t\t\t\t_ => false"); + sb.AppendLine("\t\t\t};"); + } + + /// + /// Emits local variable reconstruction for each options type in the injection chain. + /// For every member: prefer the value from the command's flags dict (post-command flags), + /// fall back to the pre-parsed static field (pre-command flags). This ensures flags work in + /// either position: myapp --verbose cmd or myapp cmd --verbose. + /// + private static void EmitOptionsReconstructLocals( + StringBuilder sb, + ImmutableArray<(string TypeFq, string TypeMetadataName, ImmutableArray AllBaseTypeMetadataNames, string StaticFieldName, string LocalVarName, ImmutableArray FlatMembers, ImmutableArray? BestCtorParamOrder)> chain) + { + if (chain.IsDefaultOrEmpty) return; + + // Track which static provides the fallback for each CLI name (first in chain that declares it). + // Key = CliLongName, Value = "{staticFieldName}.{SymbolName}" + var fallbackMap = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (_, _, _, staticField, _, flatMembers, _) in chain) + { + foreach (var m in flatMembers) + { + if (!fallbackMap.ContainsKey(m.CliLongName)) + fallbackMap[m.CliLongName] = staticField + "." + m.SymbolName; + } + } + + // Track which member vars have already been emitted (across chain entries, to avoid re-declaration). + var emittedTmpVars = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var (typeFq, _, _, _, localVar, flatMembers, bestCtorParamOrder) in chain) + { + if (flatMembers.IsEmpty) continue; + var byName = flatMembers.ToDictionary(static m => m.SymbolName, StringComparer.OrdinalIgnoreCase); + + // Extract each member: command-level flags take precedence over pre-parsed static value. + foreach (var m in flatMembers) + { + var fallback = fallbackMap.TryGetValue(m.CliLongName, out var fb) ? fb : "default"; + var tmpName = "__ropt_" + m.LocalVarName; + // Skip if already emitted by a parent type in the chain (inherited members appear in multiple flat lists). + if (!emittedTmpVars.Add(tmpName)) continue; + if (m.Special == BoolSpecialKind.Bool) + { + sb.AppendLine($"\t\t\tvar {tmpName} = flags.ContainsKey(\"{Escape(m.CliLongName)}\") || {fallback};"); + } + else if (m.Special == BoolSpecialKind.NullableBool) + { + sb.AppendLine($"\t\t\tbool? {tmpName} = {fallback};"); + sb.AppendLine($"\t\t\tif (flags.TryGetValue(\"{Escape(m.CliLongName)}\", out var {tmpName}_yv))"); + sb.AppendLine($"\t\t\t\t{tmpName} = ParseNullableBool({tmpName}_yv, true);"); + sb.AppendLine($"\t\t\tif (flags.TryGetValue(\"no-{Escape(m.CliLongName)}\", out var {tmpName}_nv))"); + sb.AppendLine($"\t\t\t\t{tmpName} = ParseNullableBool({tmpName}_nv, false);"); + } + else + { + // For value-typed flags: if found in command flags use that; else keep static fallback value. + sb.AppendLine($"\t\t\tflags.TryGetValue(\"{Escape(m.CliLongName)}\", out var {tmpName}Txt);"); + sb.AppendLine($"\t\t\tvar {tmpName} = {fallback};"); + if (m.ScalarKind == CliScalarKind.Primitive) + { + // Re-parse from text if present, keeping static value if not. + var parseExpr = m.TypeName switch + { + "int" => $"int.TryParse({tmpName}Txt, out var {tmpName}P) ? {tmpName}P : {tmpName}", + "int?" => + $"int.TryParse({tmpName}Txt, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var {tmpName}P) ? (int?){tmpName}P : {tmpName}", + "long" => $"long.TryParse({tmpName}Txt, out var {tmpName}P) ? {tmpName}P : {tmpName}", + "long?" => + $"long.TryParse({tmpName}Txt, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var {tmpName}P) ? (long?){tmpName}P : {tmpName}", + "double" => + $"double.TryParse({tmpName}Txt, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var {tmpName}P) ? {tmpName}P : {tmpName}", + "double?" => + $"double.TryParse({tmpName}Txt, System.Globalization.NumberStyles.Float | System.Globalization.NumberStyles.AllowThousands, System.Globalization.CultureInfo.InvariantCulture, out var {tmpName}P) ? (double?){tmpName}P : {tmpName}", + "float" => + $"float.TryParse({tmpName}Txt, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var {tmpName}P) ? {tmpName}P : {tmpName}", + "float?" => + $"float.TryParse({tmpName}Txt, System.Globalization.NumberStyles.Float | System.Globalization.NumberStyles.AllowThousands, System.Globalization.CultureInfo.InvariantCulture, out var {tmpName}P) ? (float?){tmpName}P : {tmpName}", + "decimal" => + $"decimal.TryParse({tmpName}Txt, System.Globalization.NumberStyles.Number, System.Globalization.CultureInfo.InvariantCulture, out var {tmpName}P) ? {tmpName}P : {tmpName}", + "decimal?" => + $"decimal.TryParse({tmpName}Txt, System.Globalization.NumberStyles.Number, System.Globalization.CultureInfo.InvariantCulture, out var {tmpName}P) ? (decimal?){tmpName}P : {tmpName}", + "DateTime" => + $"global::System.DateTime.TryParse({tmpName}Txt, System.Globalization.CultureInfo.InvariantCulture, global::System.Globalization.DateTimeStyles.RoundtripKind | global::System.Globalization.DateTimeStyles.AllowWhiteSpaces, out var {tmpName}P) ? {tmpName}P : {tmpName}", + "DateTime?" => + $"global::System.DateTime.TryParse({tmpName}Txt, System.Globalization.CultureInfo.InvariantCulture, global::System.Globalization.DateTimeStyles.RoundtripKind | global::System.Globalization.DateTimeStyles.AllowWhiteSpaces, out var {tmpName}P) ? (global::System.DateTime?){tmpName}P : {tmpName}", + "DateTimeOffset" => + $"global::System.DateTimeOffset.TryParse({tmpName}Txt, System.Globalization.CultureInfo.InvariantCulture, global::System.Globalization.DateTimeStyles.RoundtripKind | global::System.Globalization.DateTimeStyles.AllowWhiteSpaces, out var {tmpName}P) ? {tmpName}P : {tmpName}", + "DateTimeOffset?" => + $"global::System.DateTimeOffset.TryParse({tmpName}Txt, System.Globalization.CultureInfo.InvariantCulture, global::System.Globalization.DateTimeStyles.RoundtripKind | global::System.Globalization.DateTimeStyles.AllowWhiteSpaces, out var {tmpName}P) ? (global::System.DateTimeOffset?){tmpName}P : {tmpName}", + "TimeSpan" => + $"global::Nullean.Argh.ArghTimeSpan.TryParse({tmpName}Txt, out var {tmpName}P) ? {tmpName}P : {tmpName}", + "TimeSpan?" => + $"global::Nullean.Argh.ArghTimeSpan.TryParse({tmpName}Txt, out var {tmpName}P) ? (global::System.TimeSpan?){tmpName}P : {tmpName}", + "DateOnly" => + $"global::System.DateOnly.TryParse({tmpName}Txt, System.Globalization.CultureInfo.InvariantCulture, global::System.Globalization.DateTimeStyles.None, out var {tmpName}P) ? {tmpName}P : {tmpName}", + "DateOnly?" => + $"global::System.DateOnly.TryParse({tmpName}Txt, System.Globalization.CultureInfo.InvariantCulture, global::System.Globalization.DateTimeStyles.None, out var {tmpName}P) ? (global::System.DateOnly?){tmpName}P : {tmpName}", + "string" or "string?" => $"{tmpName}Txt ?? {tmpName}", + _ => $"{tmpName}Txt != null ? {tmpName}Txt : {tmpName}" + }; + if (m.TypeName is "string" or "string?") + sb.AppendLine($"\t\t\t{tmpName} = {parseExpr};"); + else + sb.AppendLine($"\t\t\tif ({tmpName}Txt != null) {tmpName} = {parseExpr};"); + } + else if (m.ScalarKind == CliScalarKind.Enum && m.EnumTypeFq is not null) + { + // Re-parse enum from command-trailing flags; null-guard required (TryGetValue out-var is string?). + // On invalid value: keep static fallback silently (no user-visible error; leading globals were already validated). + var evVar = "__ev_ropt_" + m.LocalVarName; + sb.AppendLine($"\t\t\tif ({tmpName}Txt is not null)"); + sb.AppendLine("\t\t\t{"); + sb.AppendLine($"\t\t\t\tif (global::System.Enum.TryParse<{m.EnumTypeFq}>({tmpName}Txt, true, out var {evVar}) && global::System.Enum.IsDefined(typeof({m.EnumTypeFq}), {evVar}))"); + sb.AppendLine($"\t\t\t\t\t{tmpName} = {evVar};"); + sb.AppendLine("\t\t\t}"); + } + else if (m.ScalarKind == CliScalarKind.FileInfo) + { + sb.AppendLine($"\t\t\tif ({tmpName}Txt is not null)"); + sb.AppendLine("\t\t\t{"); + if (m.ExpandUserProfileBeforeBind) + { + var expanded = "__path_ropt_" + m.LocalVarName; + sb.AppendLine($"\t\t\t\tvar {expanded} = global::Nullean.Argh.ArghPath.ExpandUserProfilePath({tmpName}Txt);"); + sb.AppendLine($"\t\t\t\t{tmpName} = new global::System.IO.FileInfo({expanded});"); + } + else + sb.AppendLine($"\t\t\t\t{tmpName} = new global::System.IO.FileInfo({tmpName}Txt);"); + sb.AppendLine("\t\t\t}"); + } + else if (m.ScalarKind == CliScalarKind.DirectoryInfo) + { + sb.AppendLine($"\t\t\tif ({tmpName}Txt is not null)"); + sb.AppendLine("\t\t\t{"); + if (m.ExpandUserProfileBeforeBind) + { + var expanded = "__path_ropt_" + m.LocalVarName; + sb.AppendLine($"\t\t\t\tvar {expanded} = global::Nullean.Argh.ArghPath.ExpandUserProfilePath({tmpName}Txt);"); + sb.AppendLine($"\t\t\t\t{tmpName} = new global::System.IO.DirectoryInfo({expanded});"); + } + else + sb.AppendLine($"\t\t\t\t{tmpName} = new global::System.IO.DirectoryInfo({tmpName}Txt);"); + sb.AppendLine("\t\t\t}"); + } + else if (m.ScalarKind == CliScalarKind.Uri) + { + var uriVar = "__uri_ropt_" + m.LocalVarName; + sb.AppendLine($"\t\t\tif ({tmpName}Txt is not null && global::System.Uri.TryCreate({tmpName}Txt, global::System.UriKind.RelativeOrAbsolute, out var {uriVar}))"); + sb.AppendLine($"\t\t\t\t{tmpName} = {uriVar};"); + } + else if (m.ScalarKind == CliScalarKind.CustomParser && m.ParserTypeFq is not null) + { + var parserVar = "__parser_ropt_" + m.LocalVarName; + var pvVar = "__pv_ropt_" + m.LocalVarName; + sb.AppendLine($"\t\t\tif ({tmpName}Txt is not null)"); + sb.AppendLine("\t\t\t{"); + sb.AppendLine($"\t\t\t\tvar {parserVar} = new {m.ParserTypeFq}();"); + sb.AppendLine($"\t\t\t\tif ({parserVar}.TryParse({tmpName}Txt, out var {pvVar}))"); + sb.AppendLine($"\t\t\t\t\t{tmpName} = {pvVar};"); + sb.AppendLine("\t\t\t}"); + } + } + } + + // Construct the local options instance using pre-computed constructor order. + if (bestCtorParamOrder is { } ctorOrder && ctorOrder.Length == flatMembers.Length) + { + sb.Append($"\t\t\tvar {localVar} = new {typeFq}("); + for (var i = 0; i < ctorOrder.Length; i++) + { + if (i > 0) sb.Append(", "); + sb.Append("__ropt_" + byName[ctorOrder[i]].LocalVarName); + } + sb.AppendLine(");"); + } + else + { + sb.AppendLine($"\t\t\tvar {localVar} = new {typeFq}();"); + foreach (var m in flatMembers) + sb.AppendLine($"\t\t\t{localVar}.{m.SymbolName} = __ropt_{m.LocalVarName};"); + } + } + } + + private static void EmitBindCollectionParameter(StringBuilder sb, ParameterModel p, bool multiFlagsAvailable, string failureExit = "return 2", string? helpMethodName = null, + string? flagHelpStdErrMethodName = null, string? parseFailureRunHint = null) + { + var flagKey = Escape(p.CliLongName); + var acc = p.LocalVarName + "_acc"; + var elemModel = ForElementParsing(p); + if (p.CollectionSeparator is string sep) + { + sb.AppendLine($"\t\t\tif (!flags.TryGetValue(\"{flagKey}\", out var {p.LocalVarName}Joined))"); + if (p.IsRequired) + { + sb.AppendLine("\t\t\t{"); + sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine($\"Error: missing required flag --{flagKey}.\");"); + EmitAfterCliParseErrorHelp(sb, p, "\t\t\t\t", helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); + sb.AppendLine($"\t\t\t\t{failureExit};"); + sb.AppendLine("\t\t\t}"); + } + else + { + sb.AppendLine($"\t\t\t\t{p.LocalVarName}Joined = null;"); + } + + sb.AppendLine($"\t\t\tif (!string.IsNullOrEmpty({p.LocalVarName}Joined))"); + sb.AppendLine("\t\t\t{"); + sb.AppendLine($"\t\t\t\tvar __sep_{p.LocalVarName} = \"{Escape(sep)}\";"); + sb.AppendLine($"\t\t\t\tforeach (var __part in {p.LocalVarName}Joined.Split(__sep_{p.LocalVarName}, StringSplitOptions.None))"); + sb.AppendLine("\t\t\t\t{"); + sb.AppendLine("\t\t\t\t\tif (string.IsNullOrEmpty(__part)) continue;"); + EmitParseFromString(sb, elemModel, "__part", "__ce_" + p.LocalVarName, indentExtra: "\t\t", outVarKeyword: true, failureExit: failureExit, helpMethodName: helpMethodName, flagHelpStdErrMethodName: flagHelpStdErrMethodName, parseFailureRunHint: parseFailureRunHint); + if (p.CollectionTargetIsReadOnlySet) + { + sb.AppendLine($"\t\t\t\t\tif (!{acc}.Add(__ce_{p.LocalVarName}))"); + sb.AppendLine("\t\t\t\t\t{"); + sb.AppendLine($"\t\t\t\t\t\tConsole.Error.WriteLine($\"Error: duplicate value '{{__ce_{p.LocalVarName}}}' for --{flagKey}.\");"); + sb.AppendLine($"\t\t\t\t\t\t{failureExit};"); + sb.AppendLine("\t\t\t\t\t}"); + } + else + { + sb.AppendLine($"\t\t\t\t\t{acc}.Add(__ce_{p.LocalVarName});"); + } + sb.AppendLine("\t\t\t\t}"); + sb.AppendLine("\t\t\t}"); + } + else + { + if (!multiFlagsAvailable) + return; + + sb.AppendLine($"\t\t\tif (!multiFlags.TryGetValue(\"{flagKey}\", out var __rawList_{p.LocalVarName}))"); + sb.AppendLine($"\t\t\t\t__rawList_{p.LocalVarName} = new List();"); + sb.AppendLine($"\t\t\tforeach (var __raw in __rawList_{p.LocalVarName})"); + sb.AppendLine("\t\t\t{"); + EmitParseFromString(sb, elemModel, "__raw", "__ce_" + p.LocalVarName, indentExtra: "\t", outVarKeyword: true, failureExit: failureExit, helpMethodName: helpMethodName, flagHelpStdErrMethodName: flagHelpStdErrMethodName, parseFailureRunHint: parseFailureRunHint); + if (p.CollectionTargetIsReadOnlySet) + { + sb.AppendLine($"\t\t\t\tif (!{acc}.Add(__ce_{p.LocalVarName}))"); + sb.AppendLine("\t\t\t\t{"); + sb.AppendLine($"\t\t\t\t\tConsole.Error.WriteLine($\"Error: duplicate value '{{__ce_{p.LocalVarName}}}' for --{flagKey}.\");"); + sb.AppendLine($"\t\t\t\t\t{failureExit};"); + sb.AppendLine("\t\t\t\t}"); + } + else + { + sb.AppendLine($"\t\t\t\t{acc}.Add(__ce_{p.LocalVarName});"); + } + sb.AppendLine("\t\t\t}"); + if (p.IsRequired) + { + sb.AppendLine($"\t\t\tif ({acc}.Count == 0)"); + sb.AppendLine("\t\t\t{"); + sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine($\"Error: missing required flag --{flagKey}.\");"); + EmitAfterCliParseErrorHelp(sb, p, "\t\t\t\t", helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); + sb.AppendLine($"\t\t\t\t{failureExit};"); + sb.AppendLine("\t\t\t}"); + } + } + + var declType = p.FullDeclaredTypeFq ?? "object"; + var useNullWhenUnset = !p.IsRequired && p.DeclaredNullableAnnotated; + if (useNullWhenUnset) + { + if (p.CollectionTargetIsArray) + sb.AppendLine($"\t\t\t{declType} {p.LocalVarName} = {acc}.Count == 0 ? null : {acc}.ToArray();"); + else + sb.AppendLine($"\t\t\t{declType} {p.LocalVarName} = {acc}.Count == 0 ? null : {acc};"); + } + else if (p.CollectionTargetIsArray) + sb.AppendLine($"\t\t\t{declType} {p.LocalVarName} = {acc}.ToArray();"); + else + sb.AppendLine($"\t\t\t{declType} {p.LocalVarName} = {acc};"); + } + + private static void EmitAsParametersConstruction(StringBuilder sb, CommandModel cmd) + { + if (cmd.HandlerParamTypes.IsDefaultOrEmpty) + return; + + foreach (var mp in cmd.HandlerParamTypes) + { + if (!mp.IsAsParameters) + continue; + + var group = cmd.Parameters + .Where(p => p.AsParametersOwnerParamName == mp.Name) + .OrderBy(p => p.AsParametersMemberOrder) + .ToArray(); + if (group.Length == 0) + continue; + + var typeFq = group[0].AsParametersTypeFq; + if (typeFq is null) + continue; + + var varName = AsParametersConstructedVarName(mp.Name); + var ctor = group.Where(p => !p.AsParametersUseInit).ToArray(); + var init = group.Where(p => p.AsParametersUseInit).ToArray(); + sb.Append($"\t\t\tvar {varName} = new {typeFq}("); + for (var i = 0; i < ctor.Length; i++) + { + if (i > 0) + sb.Append(", "); + sb.Append(ctor[i].Kind == ParameterKind.Injected ? "ct" : ctor[i].LocalVarName); + } + + sb.Append(")"); + if (init.Length > 0) + { + sb.AppendLine(); + sb.AppendLine("\t\t\t{"); + foreach (var ip in init) + { + var rhs = ip.Kind == ParameterKind.Injected ? "ct" : ip.LocalVarName; + sb.AppendLine($"\t\t\t\t{ip.AsParametersClrName} = {rhs},"); + } + + sb.AppendLine("\t\t\t};"); + } + else + { + sb.AppendLine(";"); + } + } + } + + private static void EmitAsParametersConstructionForDto(StringBuilder sb, CommandModel cmd) + { + var group = cmd.Parameters + .Where(static p => p.AsParametersOwnerParamName is not null) + .OrderBy(static p => p.AsParametersMemberOrder) + .ToArray(); + if (group.Length == 0) + { + sb.AppendLine("\t\t\treturn false;"); + return; + } + + var typeFq = group[0].AsParametersTypeFq; + if (typeFq is null) + { + sb.AppendLine("\t\t\treturn false;"); + return; + } + + var ctor = group.Where(static p => !p.AsParametersUseInit).ToArray(); + var init = group.Where(static p => p.AsParametersUseInit).ToArray(); + sb.Append("\t\t\tvar __dto = new ").Append(typeFq).Append("("); + for (var i = 0; i < ctor.Length; i++) + { + if (i > 0) + sb.Append(", "); + sb.Append(ctor[i].Kind == ParameterKind.Injected + ? "default(global::System.Threading.CancellationToken)" + : ctor[i].LocalVarName); + } + + sb.Append(")"); + if (init.Length > 0) + { + sb.AppendLine(); + sb.AppendLine("\t\t\t{"); + foreach (var ip in init) + { + var rhs = ip.Kind == ParameterKind.Injected + ? "default(global::System.Threading.CancellationToken)" + : ip.LocalVarName; + sb.AppendLine($"\t\t\t\t{ip.AsParametersClrName} = {rhs},"); + } + + sb.AppendLine("\t\t\t};"); + } + else + { + sb.AppendLine(";"); + } + + sb.AppendLine("\t\t\tvalue = __dto;"); + sb.AppendLine("\t\t\treturn true;"); + } + + private static void EmitOptionsDtoConstructionAndReturn(StringBuilder sb, string typeFq, ImmutableArray members, ImmutableArray? bestCtorParamOrder) + { + var byName = members.ToDictionary(static m => m.SymbolName, StringComparer.OrdinalIgnoreCase); + + if (bestCtorParamOrder is { } ctorOrder && ctorOrder.Length > 0 && ctorOrder.Length == members.Length) + { + sb.Append("\t\t\tvalue = new ").Append(typeFq).Append("("); + for (var i = 0; i < ctorOrder.Length; i++) + { + if (i > 0) + sb.Append(", "); + sb.Append(byName[ctorOrder[i]].LocalVarName); + } + + sb.AppendLine(");"); + sb.AppendLine("\t\t\treturn true;"); + return; + } + + sb.AppendLine($"\t\t\tvar __dto = new {typeFq}();"); + foreach (var m in members) + sb.AppendLine($"\t\t\t__dto.{m.SymbolName} = {m.LocalVarName};"); + + sb.AppendLine("\t\t\tvalue = __dto;"); + sb.AppendLine("\t\t\treturn true;"); + } + + private static string AsParametersConstructedVarName(string methodParameterName) => + "__as_" + Naming.SanitizeIdentifier(methodParameterName); + + private static void EmitValidationChecks( + StringBuilder sb, + CommandModel cmd, + string failureExit, + string? entryAssemblyName, + string? flagHelpStdErrMethodName = null) + { + foreach (var p in cmd.Parameters) + { + if (p.Kind == ParameterKind.Injected || p.Kind == ParameterKind.OptionsInjected) + continue; + if (p.Validations.IsDefaultOrEmpty) + continue; + + var cliName = p.CliLongName; + var varName = p.LocalVarName; + + // Build the run-hint line (baked in as a string literal) + string? runHint = null; + if (entryAssemblyName is not null && !string.IsNullOrEmpty(cmd.CommandName)) + { + var route = cmd.RoutePrefix.IsDefaultOrEmpty + ? "" + : string.Join(" ", cmd.RoutePrefix) + " "; + runHint = $"Run '{Escape(entryAssemblyName)} {Escape(route)}{Escape(cmd.CommandName)} --help' for usage."; + } + + if (p.IsCollection) + EmitCollectionFilesystemValidation(sb, p, cliName, varName, failureExit, flagHelpStdErrMethodName, runHint); + + foreach (var constraint in p.Validations) + { + // Filesystem-path-family constraints on collections are handled per-element above + // (varName is the whole list/array, not a single FileInfo/DirectoryInfo instance). + if (p.IsCollection && IsCollectionFilesystemConstraint(constraint)) + continue; + + switch (constraint) + { + case RangeConstraint r: EmitRangeConstraintCheck(sb, r, p, cliName, varName, failureExit, flagHelpStdErrMethodName, runHint); break; + case TimeSpanRangeConstraint tsr: EmitTimeSpanRangeConstraintCheck(sb, tsr, p, cliName, varName, failureExit, flagHelpStdErrMethodName, runHint); break; + case CollectionCountConstraint cc: EmitCollectionCountConstraintCheck(sb, cc, p, cliName, varName, failureExit, flagHelpStdErrMethodName, runHint); break; + case StringLengthConstraint s: EmitStringLengthConstraintCheck(sb, s, p, cliName, varName, failureExit, flagHelpStdErrMethodName, runHint); break; + case RegexConstraint rx: EmitRegexConstraintCheck(sb, rx, p, cliName, varName, failureExit, flagHelpStdErrMethodName, runHint); break; + case AllowedValuesConstraint av: EmitAllowedValuesConstraintCheck(sb, av, p, cliName, varName, failureExit, flagHelpStdErrMethodName, runHint); break; + case DeniedValuesConstraint dv: EmitDeniedValuesConstraintCheck(sb, dv, p, cliName, varName, failureExit, flagHelpStdErrMethodName, runHint); break; + case EmailConstraint: EmitEmailConstraintCheck(sb, p, cliName, varName, failureExit, flagHelpStdErrMethodName, runHint); break; + case UrlConstraint: EmitUrlConstraintCheck(sb, p, cliName, varName, failureExit, flagHelpStdErrMethodName, runHint); break; + case UriSchemeConstraint us: EmitUriSchemeConstraintCheck(sb, us, p, cliName, varName, failureExit, flagHelpStdErrMethodName, runHint); break; + case RejectSymbolicLinksConstraint: EmitRejectSymbolicLinksConstraintCheck(sb, p, cliName, varName, failureExit, flagHelpStdErrMethodName, runHint); break; + case ExistingPathConstraint: EmitExistingPathConstraintCheck(sb, p, cliName, varName, failureExit, flagHelpStdErrMethodName, runHint); break; + case NonExistingPathConstraint: EmitNonExistingPathConstraintCheck(sb, p, cliName, varName, failureExit, flagHelpStdErrMethodName, runHint); break; + case FileExtensionsConstraint fe: EmitFileExtensionsConstraintCheck(sb, fe, p, cliName, varName, failureExit, flagHelpStdErrMethodName, runHint); break; + } + } + } + } + + + private static void EmitRangeConstraintCheck( + StringBuilder sb, RangeConstraint r, ParameterModel p, string cliName, string varName, + string failureExit, string? flagHelpStdErrMethodName, string? runHint) + { + var isNullable = !p.IsRequired; + var isNullableValueType = isNullable && p.Special == BoolSpecialKind.None + && p.ScalarKind == CliScalarKind.Primitive && p.TypeName != "string" + && p.TypeName.EndsWith("?", StringComparison.Ordinal); + var guard = isNullableValueType ? $"{varName}.HasValue && (" : ""; + var closeGuard = isNullableValueType ? ")" : ""; + var access = isNullableValueType ? $"{varName}.Value" : varName; + sb.AppendLine($"\t\t\tif ({guard}{access} < {r.MinLiteral} || {access} > {r.MaxLiteral}{closeGuard})"); + sb.AppendLine("\t\t\t{"); + sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine(\"Error: --{Escape(cliName)}: value must be between {Escape(r.MinLiteral.Trim('"'))} and {Escape(r.MaxLiteral.Trim('"'))}.\");"); + EmitValidationErrorFooter(sb, p, cliName, "\t\t\t\t", flagHelpStdErrMethodName, runHint); + sb.AppendLine($"\t\t\t\t{failureExit};"); + sb.AppendLine("\t\t\t}"); + } + + private static void EmitTimeSpanRangeConstraintCheck( + StringBuilder sb, TimeSpanRangeConstraint tsr, ParameterModel p, string cliName, string varName, + string failureExit, string? flagHelpStdErrMethodName, string? runHint) + { + var isNullable = !p.IsRequired; + var isNullableValueType = isNullable && p.Special == BoolSpecialKind.None + && p.ScalarKind == CliScalarKind.Primitive && p.TypeName != "string" + && p.TypeName.EndsWith("?", StringComparison.Ordinal); + var tsMin = "__tsRangeMin_" + varName; + var tsMax = "__tsRangeMax_" + varName; + sb.AppendLine($"\t\t\tif (!global::Nullean.Argh.ArghTimeSpan.TryParse({tsr.MinLiteral}, out var {tsMin}) || !global::Nullean.Argh.ArghTimeSpan.TryParse({tsr.MaxLiteral}, out var {tsMax}))"); + sb.AppendLine("\t\t\t{"); + sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine(\"Error: --{Escape(cliName)}: invalid TimeSpanRange bounds.\");"); + EmitValidationErrorFooter(sb, p, cliName, "\t\t\t\t", flagHelpStdErrMethodName, runHint); + sb.AppendLine($"\t\t\t\t{failureExit};"); + sb.AppendLine("\t\t\t}"); + var guard = isNullableValueType ? $"{varName}.HasValue && (" : ""; + var closeGuard = isNullableValueType ? ")" : ""; + var access = isNullableValueType ? $"{varName}.Value" : varName; + sb.AppendLine($"\t\t\tif ({guard}{access} < {tsMin} || {access} > {tsMax}{closeGuard})"); + sb.AppendLine("\t\t\t{"); + sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine(\"Error: --{Escape(cliName)}: value must be between {Escape(tsr.MinLiteral.Trim('"'))} and {Escape(tsr.MaxLiteral.Trim('"'))}.\");"); + EmitValidationErrorFooter(sb, p, cliName, "\t\t\t\t", flagHelpStdErrMethodName, runHint); + sb.AppendLine($"\t\t\t\t{failureExit};"); + sb.AppendLine("\t\t\t}"); + } + + private static void EmitCollectionCountConstraintCheck( + StringBuilder sb, CollectionCountConstraint cc, ParameterModel p, string cliName, string varName, + string failureExit, string? flagHelpStdErrMethodName, string? runHint) + { + var lenExpr = p.CollectionTargetIsArray ? $"{varName}.Length" : $"{varName}.Count"; + var nullGuard = !p.IsRequired && p.DeclaredNullableAnnotated ? $"{varName} != null && " : ""; + string ccCond; + string ccMsg; + if (cc.Min.HasValue && cc.Max.HasValue) + { + ccCond = $"{nullGuard}({lenExpr} < {cc.Min} || {lenExpr} > {cc.Max})"; + ccMsg = $"must have between {cc.Min} and {cc.Max} items."; + } + else if (cc.Min.HasValue) + { + ccCond = $"{nullGuard}{lenExpr} < {cc.Min}"; + ccMsg = $"must have at least {cc.Min} items."; + } + else + { + ccCond = $"{nullGuard}{lenExpr} > {cc.Max}"; + ccMsg = $"must have at most {cc.Max} items."; + } + var ccPrefix = p.Kind == ParameterKind.Positional ? $"<{Escape(cliName)}>" : $"--{Escape(cliName)}"; + sb.AppendLine($"\t\t\tif ({ccCond})"); + sb.AppendLine("\t\t\t{"); + sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine(\"Error: {ccPrefix}: {Escape(ccMsg)}\");"); + EmitValidationErrorFooter(sb, p, cliName, "\t\t\t\t", flagHelpStdErrMethodName, runHint); + sb.AppendLine($"\t\t\t\t{failureExit};"); + sb.AppendLine("\t\t\t}"); + } + + private static void EmitStringLengthConstraintCheck( + StringBuilder sb, StringLengthConstraint s, ParameterModel p, string cliName, string varName, + string failureExit, string? flagHelpStdErrMethodName, string? runHint) + { + var isNullable = !p.IsRequired; + // For required non-nullable strings: access with ! to avoid introducing a null-path + // in the condition (which would cause CS8604 at the handler call site). + // For optional strings: wrap with a null guard. + var sv = isNullable ? varName : varName + "!"; + var nullPrefix = isNullable ? $"{varName} != null && " : ""; + string cond; + string msg; + if (s.Min.HasValue && s.Max.HasValue) + { + cond = $"{nullPrefix}({sv}.Length < {s.Min} || {sv}.Length > {s.Max})"; + msg = $"value must be between {s.Min} and {s.Max} characters."; + } + else if (s.Min.HasValue) + { + cond = $"{nullPrefix}{sv}.Length < {s.Min}"; + msg = $"value must be at least {s.Min} characters."; + } + else + { + cond = $"{nullPrefix}{sv}.Length > {s.Max}"; + msg = $"value must be at most {s.Max} characters."; + } + sb.AppendLine($"\t\t\tif ({cond})"); + sb.AppendLine("\t\t\t{"); + sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine(\"Error: --{Escape(cliName)}: {Escape(msg)}\");"); + EmitValidationErrorFooter(sb, p, cliName, "\t\t\t\t", flagHelpStdErrMethodName, runHint); + sb.AppendLine($"\t\t\t\t{failureExit};"); + sb.AppendLine("\t\t\t}"); + } + + private static void EmitRegexConstraintCheck( + StringBuilder sb, RegexConstraint rx, ParameterModel p, string cliName, string varName, + string failureExit, string? flagHelpStdErrMethodName, string? runHint) + { + var isNullable = !p.IsRequired; + var rv = isNullable ? varName : varName + "!"; + var cond = isNullable + ? $"{varName} != null && !global::System.Text.RegularExpressions.Regex.IsMatch({rv}, @\"{EscapeVerbatimString(rx.Pattern)}\")" + : $"!global::System.Text.RegularExpressions.Regex.IsMatch({rv}, @\"{EscapeVerbatimString(rx.Pattern)}\")"; + sb.AppendLine($"\t\t\tif ({cond})"); + sb.AppendLine("\t\t\t{"); + sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine(\"Error: --{Escape(cliName)}: value does not match required pattern {Escape(rx.Pattern)}.\");"); + EmitValidationErrorFooter(sb, p, cliName, "\t\t\t\t", flagHelpStdErrMethodName, runHint); + sb.AppendLine($"\t\t\t\t{failureExit};"); + sb.AppendLine("\t\t\t}"); + } + + private static void EmitAllowedValuesConstraintCheck( + StringBuilder sb, AllowedValuesConstraint av, ParameterModel p, string cliName, string varName, + string failureExit, string? flagHelpStdErrMethodName, string? runHint) + { + var isNullable = !p.IsRequired; + var isNullableValueType = isNullable && p.Special == BoolSpecialKind.None + && p.ScalarKind == CliScalarKind.Primitive && p.TypeName != "string" + && p.TypeName.EndsWith("?", StringComparison.Ordinal); + var isStringType = p.TypeName == "string"; + string cond; + if (isStringType) + { + var avv = isNullable ? varName : varName + "!"; + var checks = av.Values + .Select(v => $"!string.Equals({avv}, {v}, global::System.StringComparison.Ordinal)") + .ToList(); + var nullGuard = isNullable ? $"{varName} != null && " : ""; + cond = $"{nullGuard}({string.Join(" && ", checks)})"; + } + else + { + var checks = av.Values.Select(v => $"{varName} != {v}").ToList(); + var nullGuard = isNullableValueType ? $"{varName}.HasValue && " : ""; + var access = isNullableValueType ? $"{varName}.Value" : varName; + cond = $"{nullGuard}({string.Join(" && ", checks.Select(c => c.Replace(varName, access)))})"; + } + var displayVals = string.Join(", ", av.Values.Select(v => v.Trim('"'))); + sb.AppendLine($"\t\t\tif ({cond})"); + sb.AppendLine("\t\t\t{"); + sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine(\"Error: --{Escape(cliName)}: value must be one of: {Escape(displayVals)}.\");"); + EmitValidationErrorFooter(sb, p, cliName, "\t\t\t\t", flagHelpStdErrMethodName, runHint); + sb.AppendLine($"\t\t\t\t{failureExit};"); + sb.AppendLine("\t\t\t}"); + } + + private static void EmitDeniedValuesConstraintCheck( + StringBuilder sb, DeniedValuesConstraint dv, ParameterModel p, string cliName, string varName, + string failureExit, string? flagHelpStdErrMethodName, string? runHint) + { + var isNullable = !p.IsRequired; + var isNullableValueType = isNullable && p.Special == BoolSpecialKind.None + && p.ScalarKind == CliScalarKind.Primitive && p.TypeName != "string" + && p.TypeName.EndsWith("?", StringComparison.Ordinal); + var isStringType = p.TypeName == "string"; + string cond; + if (isStringType) + { + var dvv = isNullable ? varName : varName + "!"; + var checks = dv.Values + .Select(v => $"string.Equals({dvv}, {v}, global::System.StringComparison.Ordinal)") + .ToList(); + var nullGuard = isNullable ? $"{varName} != null && " : ""; + cond = $"{nullGuard}({string.Join(" || ", checks)})"; + } + else + { + var checks = dv.Values.Select(v => $"{varName} == {v}").ToList(); + var nullGuard = isNullableValueType ? $"{varName}.HasValue && " : ""; + var access = isNullableValueType ? $"{varName}.Value" : varName; + cond = $"{nullGuard}({string.Join(" || ", checks.Select(c => c.Replace(varName, access)))})"; + } + var displayVals = string.Join(", ", dv.Values.Select(v => v.Trim('"'))); + sb.AppendLine($"\t\t\tif ({cond})"); + sb.AppendLine("\t\t\t{"); + sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine(\"Error: --{Escape(cliName)}: value must not be: {Escape(displayVals)}.\");"); + EmitValidationErrorFooter(sb, p, cliName, "\t\t\t\t", flagHelpStdErrMethodName, runHint); + sb.AppendLine($"\t\t\t\t{failureExit};"); + sb.AppendLine("\t\t\t}"); + } + + private static void EmitEmailConstraintCheck( + StringBuilder sb, ParameterModel p, string cliName, string varName, + string failureExit, string? flagHelpStdErrMethodName, string? runHint) + { + var isNullable = !p.IsRequired; + // Simple email check: at least one char, @, at least one char (DataAnnotations-compatible) + var ev = isNullable ? varName : varName + "!"; + var cond = isNullable + ? $"{varName} != null && ({ev}.IndexOf('@') < 1 || {ev}.IndexOf('@') == {ev}.Length - 1)" + : $"({ev}.IndexOf('@') < 1 || {ev}.IndexOf('@') == {ev}.Length - 1)"; + sb.AppendLine($"\t\t\tif ({cond})"); + sb.AppendLine("\t\t\t{"); + sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine(\"Error: --{Escape(cliName)}: value is not a valid email address.\");"); + EmitValidationErrorFooter(sb, p, cliName, "\t\t\t\t", flagHelpStdErrMethodName, runHint); + sb.AppendLine($"\t\t\t\t{failureExit};"); + sb.AppendLine("\t\t\t}"); + } + + private static void EmitUrlConstraintCheck( + StringBuilder sb, ParameterModel p, string cliName, string varName, + string failureExit, string? flagHelpStdErrMethodName, string? runHint) + { + var isNullable = !p.IsRequired; + // Validates absolute URL with http, https, or ftp scheme + sb.AppendLine($"\t\t\tif ({(isNullable ? $"{varName} != null && " : "")}!"); + sb.AppendLine($"\t\t\t\t(global::System.Uri.TryCreate({varName}, global::System.UriKind.Absolute, out var __urlCheck_{varName}) &&"); + sb.AppendLine($"\t\t\t\t (__urlCheck_{varName}.Scheme == \"http\" || __urlCheck_{varName}.Scheme == \"https\" || __urlCheck_{varName}.Scheme == \"ftp\")))"); + sb.AppendLine("\t\t\t{"); + sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine(\"Error: --{Escape(cliName)}: value is not a valid URL.\");"); + EmitValidationErrorFooter(sb, p, cliName, "\t\t\t\t", flagHelpStdErrMethodName, runHint); + sb.AppendLine($"\t\t\t\t{failureExit};"); + sb.AppendLine("\t\t\t}"); + } + + private static void EmitUriSchemeConstraintCheck( + StringBuilder sb, UriSchemeConstraint us, ParameterModel p, string cliName, string varName, + string failureExit, string? flagHelpStdErrMethodName, string? runHint) + { + var isNullable = !p.IsRequired; + // varName is a Uri? or Uri instance (already parsed) + var access = isNullable ? $"{varName}!" : varName; + var schemeChecks = us.Schemes + .Select(s => $"{access}.Scheme == \"{Escape(s)}\"") + .ToList(); + var nullGuard = isNullable ? $"{varName} != null && " : ""; + var displaySchemes = string.Join(", ", us.Schemes); + sb.AppendLine($"\t\t\tif ({nullGuard}(!{access}.IsAbsoluteUri || !({string.Join(" || ", schemeChecks)})))"); + sb.AppendLine("\t\t\t{"); + sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine(\"Error: --{Escape(cliName)}: URI scheme must be one of: {Escape(displaySchemes)}.\");"); + EmitValidationErrorFooter(sb, p, cliName, "\t\t\t\t", flagHelpStdErrMethodName, runHint); + sb.AppendLine($"\t\t\t\t{failureExit};"); + sb.AppendLine("\t\t\t}"); + } + + private static void EmitRejectSymbolicLinksConstraintCheck( + StringBuilder sb, ParameterModel p, string cliName, string varName, + string failureExit, string? flagHelpStdErrMethodName, string? runHint) + { + var isNullable = !p.IsRequired; + var access = isNullable ? $"{varName}!" : varName; + var nullGuard = isNullable ? $"{varName} != null && " : ""; + sb.AppendLine($"\t\t\tif ({nullGuard}global::Nullean.Argh.ArghIO.PathIsSymbolicOrReparsePoint({access}.FullName))"); + sb.AppendLine("\t\t\t{"); + sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine(\"Error: --{Escape(cliName)}: path must not be a symbolic link or reparse point.\");"); + EmitValidationErrorFooter(sb, p, cliName, "\t\t\t\t", flagHelpStdErrMethodName, runHint); + sb.AppendLine($"\t\t\t\t{failureExit};"); + sb.AppendLine("\t\t\t}"); + } + + private static void EmitExistingPathConstraintCheck( + StringBuilder sb, ParameterModel p, string cliName, string varName, + string failureExit, string? flagHelpStdErrMethodName, string? runHint) + { + var isNullable = !p.IsRequired; + var access = isNullable ? $"{varName}!" : varName; + var nullGuard = isNullable ? $"{varName} != null && " : ""; + if (p.ScalarKind == CliScalarKind.FileInfo) + { + sb.AppendLine($"\t\t\tif ({nullGuard}!global::System.IO.File.Exists({access}.FullName))"); + sb.AppendLine("\t\t\t{"); + sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine(\"Error: --{Escape(cliName)}: file does not exist.\");"); + EmitValidationErrorFooter(sb, p, cliName, "\t\t\t\t", flagHelpStdErrMethodName, runHint); + sb.AppendLine($"\t\t\t\t{failureExit};"); + sb.AppendLine("\t\t\t}"); + } + else + { + sb.AppendLine($"\t\t\tif ({nullGuard}!global::System.IO.Directory.Exists({access}.FullName))"); + sb.AppendLine("\t\t\t{"); + sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine(\"Error: --{Escape(cliName)}: directory does not exist.\");"); + EmitValidationErrorFooter(sb, p, cliName, "\t\t\t\t", flagHelpStdErrMethodName, runHint); + sb.AppendLine($"\t\t\t\t{failureExit};"); + sb.AppendLine("\t\t\t}"); + } + } + + private static void EmitNonExistingPathConstraintCheck( + StringBuilder sb, ParameterModel p, string cliName, string varName, + string failureExit, string? flagHelpStdErrMethodName, string? runHint) + { + var isNullable = !p.IsRequired; + var access = isNullable ? $"{varName}!" : varName; + var nullGuard = isNullable ? $"{varName} != null && " : ""; + sb.AppendLine($"\t\t\tif ({nullGuard}(global::System.IO.File.Exists({access}.FullName) || global::System.IO.Directory.Exists({access}.FullName)))"); + sb.AppendLine("\t\t\t{"); + if (p.ScalarKind == CliScalarKind.FileInfo) + sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine(\"Error: --{Escape(cliName)}: path already exists or is occupied by a directory.\");"); + else + sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine(\"Error: --{Escape(cliName)}: path already exists.\");"); + EmitValidationErrorFooter(sb, p, cliName, "\t\t\t\t", flagHelpStdErrMethodName, runHint); + sb.AppendLine($"\t\t\t\t{failureExit};"); + sb.AppendLine("\t\t\t}"); + } + + private static void EmitFileExtensionsConstraintCheck( + StringBuilder sb, FileExtensionsConstraint fe, ParameterModel p, string cliName, string varName, + string failureExit, string? flagHelpStdErrMethodName, string? runHint) + { + var isNullable = !p.IsRequired; + // varName is a FileInfo? or FileInfo instance + var access = isNullable ? $"{varName}!" : varName; + var extChecks = fe.Extensions + .Select(ext => $"!string.Equals(global::System.IO.Path.GetExtension({access}.Name).TrimStart('.'), \"{Escape(ext)}\", global::System.StringComparison.OrdinalIgnoreCase)") + .ToList(); + var nullGuard = isNullable ? $"{varName} != null && " : ""; + var displayExts = string.Join(", ", fe.Extensions); + sb.AppendLine($"\t\t\tif ({nullGuard}({string.Join(" && ", extChecks)}))"); + sb.AppendLine("\t\t\t{"); + sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine(\"Error: --{Escape(cliName)}: extension must be one of: {Escape(displayExts)}.\");"); + EmitValidationErrorFooter(sb, p, cliName, "\t\t\t\t", flagHelpStdErrMethodName, runHint); + sb.AppendLine($"\t\t\t\t{failureExit};"); + sb.AppendLine("\t\t\t}"); + } + + private static bool IsCollectionFilesystemConstraint(ValidationConstraint c) => + c is ExistingPathConstraint or NonExistingPathConstraint or RejectSymbolicLinksConstraint or FileExtensionsConstraint; + + /// + /// Collection-aware emission for the filesystem-path attribute family ([Existing], [NonExisting], + /// [RejectSymbolicLinks], [FileExtensions]) applied to a List<FileInfo>, + /// FileInfo[], DirectoryInfo[], etc. — including variadic [Argument] collections. + /// Unlike the scalar constraint switch above (which exits on the first violation), this loops over every + /// element and collects every failing item before printing one error block and exiting once, so a user + /// passing e.g. five files with two missing sees both, not just the first. + /// + private static void EmitCollectionFilesystemValidation( + StringBuilder sb, ParameterModel p, string cliName, string varName, + string failureExit, string? flagHelpStdErrMethodName, string? runHint) + { + if (p.Validations.IsDefaultOrEmpty) + return; + var fsConstraints = p.Validations.Where(IsCollectionFilesystemConstraint).ToList(); + if (fsConstraints.Count == 0) + return; + + var isDir = p.ElementScalarKind == CliScalarKind.DirectoryInfo; + var failuresVar = "__fsFailures_" + p.LocalVarName; + var itemVar = "__fsItem_" + p.LocalVarName; + var msgVar = "__fsMsg_" + p.LocalVarName; + var argToken = p.Kind == ParameterKind.Positional ? $"<{Escape(cliName)}>" : $"--{Escape(cliName)}"; + var nullGuard = !p.IsRequired && p.DeclaredNullableAnnotated; + const string outerIndent = "\t\t\t"; + var loopIndent = nullGuard ? outerIndent + "\t" : outerIndent; + var bodyIndent = loopIndent + "\t"; + + sb.AppendLine($"{outerIndent}var {failuresVar} = new List();"); + if (nullGuard) + { + sb.AppendLine($"{outerIndent}if ({varName} != null)"); + sb.AppendLine($"{outerIndent}{{"); + } + + sb.AppendLine($"{loopIndent}foreach (var {itemVar} in {varName})"); + sb.AppendLine($"{loopIndent}{{"); + + foreach (var c in fsConstraints) + { + switch (c) + { + case RejectSymbolicLinksConstraint: + // Runs before existence/extension checks; a rejected symlink skips further checks for that item. + sb.AppendLine($"{bodyIndent}if (global::Nullean.Argh.ArghIO.PathIsSymbolicOrReparsePoint({itemVar}.FullName))"); + sb.AppendLine($"{bodyIndent}{{"); + sb.AppendLine($"{bodyIndent}\t{failuresVar}.Add({itemVar}.FullName + \": path must not be a symbolic link or reparse point.\");"); + sb.AppendLine($"{bodyIndent}\tcontinue;"); + sb.AppendLine($"{bodyIndent}}}"); + break; + case ExistingPathConstraint: + if (isDir) + { + sb.AppendLine($"{bodyIndent}if (!global::System.IO.Directory.Exists({itemVar}.FullName))"); + sb.AppendLine($"{bodyIndent}\t{failuresVar}.Add({itemVar}.FullName + \": directory does not exist.\");"); + } + else + { + sb.AppendLine($"{bodyIndent}if (!global::System.IO.File.Exists({itemVar}.FullName))"); + sb.AppendLine($"{bodyIndent}\t{failuresVar}.Add({itemVar}.FullName + \": file does not exist.\");"); + } + break; + case NonExistingPathConstraint: + sb.AppendLine($"{bodyIndent}if (global::System.IO.File.Exists({itemVar}.FullName) || global::System.IO.Directory.Exists({itemVar}.FullName))"); + sb.AppendLine($"{bodyIndent}\t{failuresVar}.Add({itemVar}.FullName + \": path already exists.\");"); + break; + case FileExtensionsConstraint fe: + { + var extChecks = fe.Extensions + .Select(ext => $"!string.Equals(global::System.IO.Path.GetExtension({itemVar}.Name).TrimStart('.'), \"{Escape(ext)}\", global::System.StringComparison.OrdinalIgnoreCase)") + .ToList(); + var displayExts = string.Join(", ", fe.Extensions); + sb.AppendLine($"{bodyIndent}if ({string.Join(" && ", extChecks)})"); + sb.AppendLine($"{bodyIndent}\t{failuresVar}.Add({itemVar}.FullName + \": extension must be one of: {Escape(displayExts)}.\");"); + break; + } + } + } + + sb.AppendLine($"{loopIndent}}}"); + if (nullGuard) + sb.AppendLine($"{outerIndent}}}"); + + sb.AppendLine($"{outerIndent}if ({failuresVar}.Count > 0)"); + sb.AppendLine($"{outerIndent}{{"); + sb.AppendLine($"{outerIndent}\tforeach (var {msgVar} in {failuresVar})"); + sb.AppendLine($"{outerIndent}\t\tConsole.Error.WriteLine(\"Error: {argToken}: \" + {msgVar});"); + EmitValidationErrorFooter(sb, p, cliName, outerIndent + "\t", flagHelpStdErrMethodName, runHint); + sb.AppendLine($"{outerIndent}\t{failureExit};"); + sb.AppendLine($"{outerIndent}}}"); + } + + private static string EscapeVerbatimString(string s) => s.Replace("\"", "\"\""); + + private static void EmitCommandRunnerFuzzyFailHelper( + StringBuilder sb, + CommandModel cmd, + string? flagHelpStdErrMethodName, + string? parseFailureRunHint) + { + var flagParams = cmd.Parameters + .Where(static p => IsEmittedFlagLike(p.Kind)) + .ToList(); + + if (flagParams.Count > 0) + { + sb.Append("\t\t\tvar __flagFuzzyCands = new string[] { "); + var sortedNames = flagParams + .Select(static p => p.CliLongName) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(static x => x, StringComparer.OrdinalIgnoreCase) + .ToList(); + for (var i = 0; i < sortedNames.Count; i++) + { + if (i > 0) + sb.Append(", "); + sb.Append('"').Append(Escape(sortedNames[i])).Append('"'); + } + sb.AppendLine(" };"); + } + + sb.AppendLine("\t\t\tint FailUnknownLongOption(string flagName)"); + sb.AppendLine("\t\t\t{"); + if (flagParams.Count > 0) + { + sb.AppendLine($"\t\t\t\tvar __matches = FuzzyMatch.FindClosest(flagName, __flagFuzzyCands, {FuzzyMaxDistance});"); + sb.AppendLine("\t\t\t\tif (__matches.Count == 0)"); + sb.AppendLine("\t\t\t\t{"); + } + sb.AppendLine("\t\t\t\t\tConsole.Error.WriteLine($\"Error: unknown option '--{flagName}'.\");"); + if (parseFailureRunHint is not null) + { + sb.AppendLine("\t\t\t\t\tConsole.Error.WriteLine();"); + sb.AppendLine($"\t\t\t\t\tConsole.Error.WriteLine(\"{Escape(parseFailureRunHint)}\");"); + } + sb.AppendLine("\t\t\t\t\treturn 2;"); + if (flagParams.Count > 0) + { + sb.AppendLine("\t\t\t\t}"); + sb.AppendLine("\t\t\t\tif (__matches.Count == 1)"); + sb.AppendLine("\t\t\t\t{"); + sb.AppendLine("\t\t\t\t\tvar __m = __matches[0];"); + sb.AppendLine("\t\t\t\t\tConsole.Error.WriteLine($\"Error: unknown option '--{flagName}'. Did you mean '--{__m}'?\");"); + if (flagHelpStdErrMethodName is not null) + { + sb.AppendLine("\t\t\t\t\tConsole.Error.WriteLine();"); + sb.AppendLine($"\t\t\t\t\t{flagHelpStdErrMethodName}(__m);"); + } + if (parseFailureRunHint is not null) + { + sb.AppendLine("\t\t\t\t\tConsole.Error.WriteLine();"); + sb.AppendLine($"\t\t\t\t\tConsole.Error.WriteLine(\"{Escape(parseFailureRunHint)}\");"); + } + sb.AppendLine("\t\t\t\t\treturn 2;"); + sb.AppendLine("\t\t\t\t}"); + sb.AppendLine("\t\t\t\tConsole.Error.WriteLine($\"Error: unknown option '--{flagName}'. Did you mean one of these?\");"); + sb.AppendLine("\t\t\t\tConsole.Error.WriteLine();"); + sb.AppendLine("\t\t\t\tforeach (var __m in __matches)"); + sb.AppendLine("\t\t\t\t{"); + if (flagHelpStdErrMethodName is not null) + sb.AppendLine($"\t\t\t\t\t{flagHelpStdErrMethodName}(__m);"); + sb.AppendLine("\t\t\t\t}"); + if (parseFailureRunHint is not null) + { + sb.AppendLine("\t\t\t\tConsole.Error.WriteLine();"); + sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine(\"{Escape(parseFailureRunHint)}\");"); + } + sb.AppendLine("\t\t\t\treturn 2;"); + } + sb.AppendLine("\t\t\t}"); + sb.AppendLine(); + } + +} diff --git a/src/Nullean.Argh.Generator/CliParserGenerator.Emit.Runner.cs b/src/Nullean.Argh.Generator/CliParserGenerator.Emit.Runner.cs new file mode 100644 index 0000000..fbffb33 --- /dev/null +++ b/src/Nullean.Argh.Generator/CliParserGenerator.Emit.Runner.cs @@ -0,0 +1,1833 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Globalization; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Operations; +using Microsoft.CodeAnalysis.Text; + +namespace Nullean.Argh; + +public sealed partial class CliParserGenerator +{ + private static void EmitCommandRunner( + StringBuilder sb, + CommandModel cmd, + ImmutableArray globalMiddleware, + bool emitDtoTryParse = false, + bool dtoLenient = false, + string? dtoMethodName = null, + string? dtoResultTypeFq = null, + string? dtoOptionsTypeFq = null, + ImmutableArray? dtoOptionsBestCtorParamOrder = null, + ImmutableArray<(string TypeFq, string TypeMetadataName, ImmutableArray AllBaseTypeMetadataNames, string StaticFieldName, string LocalVarName, ImmutableArray FlatMembers, ImmutableArray? BestCtorParamOrder)> injectedOptions = default, + string? entryAssemblyName = null) + { + var anyRepeatedCollection = cmd.Parameters.Any(static p => + p is { IsCollection: true, Kind: ParameterKind.Flag } && p.CollectionSeparator is null); + + var failureExit = emitDtoTryParse ? "return false" : "return 2"; + var helpMethodName = emitDtoTryParse ? null : HelpPrinterMethodForCommand(cmd); + var flagHelpStdErrMethodName = emitDtoTryParse || cmd.IsRootDefault ? null : $"PrintHelp_{cmd.RunMethodName}_Flag_ToStdErr"; + string? parseFailureRunHint = null; + if (!emitDtoTryParse && entryAssemblyName is not null && !string.IsNullOrEmpty(cmd.CommandName)) + { + var routeForHint = cmd.RoutePrefix.IsDefaultOrEmpty ? "" : string.Join(" ", cmd.RoutePrefix) + " "; + parseFailureRunHint = $"Run '{Escape(entryAssemblyName)} {Escape(routeForHint)}{Escape(cmd.CommandName)} --help' for usage."; + } + + if (emitDtoTryParse) + { + if (dtoMethodName is null || dtoResultTypeFq is null) + throw new InvalidOperationException("DTO try-parse requires method name and result type."); + + sb.AppendLine($"\t\tinternal static bool {dtoMethodName}(string[] args, out {dtoResultTypeFq}? value)"); + sb.AppendLine("\t\t{"); + sb.AppendLine("\t\t\tvalue = null;"); + } + else + { + sb.AppendLine($"\t\tprivate static async Task {cmd.RunMethodName}(string[] args, CancellationToken ct)"); + sb.AppendLine("\t\t{"); + sb.AppendLine("\t\t\tfor (var i = 0; i < args.Length; i++)"); + sb.AppendLine("\t\t\t{"); + sb.AppendLine("\t\t\t\tif (args[i] == \"--help\" || args[i] == \"-h\")"); + sb.AppendLine("\t\t\t\t{"); + sb.AppendLine($"\t\t\t\t\t{helpMethodName}();"); + sb.AppendLine("\t\t\t\t\treturn 0;"); + sb.AppendLine("\t\t\t\t}"); + sb.AppendLine("\t\t\t}"); + sb.AppendLine(); + } + + EmitCliValueDeclarations(sb, cmd, dtoOptionsTypeFq); + + sb.AppendLine("\t\t\tvar flags = new Dictionary(StringComparer.OrdinalIgnoreCase);"); + if (anyRepeatedCollection) + { + sb.AppendLine("\t\t\tvar multiFlags = new Dictionary>(StringComparer.OrdinalIgnoreCase);"); + EmitIsMultiFlagPredicate(sb, cmd); + sb.AppendLine("\t\t\tvoid SetFlag(string name, string? value)"); + sb.AppendLine("\t\t\t{"); + sb.AppendLine("\t\t\t\tif (IsMultiFlag(name))"); + sb.AppendLine("\t\t\t\t{"); + sb.AppendLine("\t\t\t\t\tif (value is null) return;"); + sb.AppendLine("\t\t\t\t\tif (!multiFlags.TryGetValue(name, out var list)) { list = new List(); multiFlags[name] = list; }"); + sb.AppendLine("\t\t\t\t\tlist.Add(value);"); + sb.AppendLine("\t\t\t\t\treturn;"); + sb.AppendLine("\t\t\t\t}"); + sb.AppendLine("\t\t\t\tflags[name] = value;"); + sb.AppendLine("\t\t\t}"); + } + + sb.AppendLine("\t\t\tvar positionals = new List();"); + EmitBoolSwitchNames(sb, cmd); + EmitCanonFlagNameMethod(sb, cmd); + EmitShortFlagMethods(sb, cmd, multiFlagsAvailable: anyRepeatedCollection, + parseFailureRunHint: emitDtoTryParse ? null : parseFailureRunHint); + EmitKnownNonBoolFlagNames(sb, cmd); + if (!emitDtoTryParse) + EmitCommandRunnerFuzzyFailHelper(sb, cmd, flagHelpStdErrMethodName, parseFailureRunHint); + sb.AppendLine("\t\t\tfor (var i = 0; i < args.Length;)"); + sb.AppendLine("\t\t\t{"); + sb.AppendLine("\t\t\t\tvar a = args[i];"); + sb.AppendLine("\t\t\t\tif (a.StartsWith(\"--\", StringComparison.Ordinal))"); + sb.AppendLine("\t\t\t\t{"); + sb.AppendLine("\t\t\t\t\tvar eq = a.IndexOf('=');"); + sb.AppendLine("\t\t\t\t\tif (eq >= 0)"); + sb.AppendLine("\t\t\t\t\t{"); + sb.AppendLine("\t\t\t\t\t\tvar flagName = CanonFlagName(a.Substring(2, eq - 2));"); + sb.AppendLine("\t\t\t\t\t\tvar flagValue = a.Substring(eq + 1);"); + if (emitDtoTryParse && !dtoLenient) + { + sb.AppendLine("\t\t\t\t\t\tif (!IsBoolSwitchName(flagName) && !IsKnownNonBoolFlagName(flagName))"); + sb.AppendLine("\t\t\t\t\t\t{"); + sb.AppendLine("\t\t\t\t\t\t\tConsole.Error.WriteLine($\"Error: unknown option '--{flagName}'.\");"); + sb.AppendLine($"\t\t\t\t\t\t\t{failureExit};"); + sb.AppendLine("\t\t\t\t\t\t}"); + } + else if (!emitDtoTryParse) + { + sb.AppendLine("\t\t\t\t\t\tif (!IsBoolSwitchName(flagName) && !IsKnownNonBoolFlagName(flagName))"); + sb.AppendLine("\t\t\t\t\t\t\treturn FailUnknownLongOption(flagName);"); + } + if (anyRepeatedCollection) + { + sb.AppendLine("\t\t\t\t\t\tSetFlag(flagName, flagValue);"); + } + else + { + sb.AppendLine("\t\t\t\t\t\tflags[flagName] = flagValue;"); + } + + sb.AppendLine("\t\t\t\t\t\ti++;"); + sb.AppendLine("\t\t\t\t\t}"); + sb.AppendLine("\t\t\t\t\telse"); + sb.AppendLine("\t\t\t\t\t{"); + sb.AppendLine("\t\t\t\t\t\tvar flagName = CanonFlagName(a.Substring(2));"); + sb.AppendLine("\t\t\t\t\t\tif (IsBoolSwitchName(flagName))"); + sb.AppendLine("\t\t\t\t\t\t{"); + sb.AppendLine("\t\t\t\t\t\t\tflags[flagName] = IsBoolSwitchNoName(flagName) ? null : \"true\";"); + sb.AppendLine("\t\t\t\t\t\t\ti++;"); + sb.AppendLine("\t\t\t\t\t\t}"); + sb.AppendLine("\t\t\t\t\t\telse"); + sb.AppendLine("\t\t\t\t\t\t{"); + if (emitDtoTryParse) + { + if (dtoLenient) + { + sb.AppendLine("\t\t\t\t\t\t\tif (!IsKnownNonBoolFlagName(flagName))"); + sb.AppendLine("\t\t\t\t\t\t\t{"); + sb.AppendLine("\t\t\t\t\t\t\t\tif (i + 1 < args.Length && !args[i + 1].StartsWith(\"-\", StringComparison.Ordinal))"); + sb.AppendLine("\t\t\t\t\t\t\t\t\ti += 2;"); + sb.AppendLine("\t\t\t\t\t\t\t\telse"); + sb.AppendLine("\t\t\t\t\t\t\t\t\ti++;"); + sb.AppendLine("\t\t\t\t\t\t\t\tcontinue;"); + sb.AppendLine("\t\t\t\t\t\t\t}"); + } + else + { + sb.AppendLine("\t\t\t\t\t\t\tif (!IsKnownNonBoolFlagName(flagName))"); + sb.AppendLine("\t\t\t\t\t\t\t{"); + sb.AppendLine("\t\t\t\t\t\t\t\tConsole.Error.WriteLine($\"Error: unknown option '--{flagName}'.\");"); + sb.AppendLine($"\t\t\t\t\t\t\t\t{failureExit};"); + sb.AppendLine("\t\t\t\t\t\t\t}"); + } + } + else + { + sb.AppendLine("\t\t\t\t\t\t\tif (!IsKnownNonBoolFlagName(flagName))"); + sb.AppendLine("\t\t\t\t\t\t\t\treturn FailUnknownLongOption(flagName);"); + } + sb.AppendLine("\t\t\t\t\t\t\tif (i + 1 >= args.Length)"); + sb.AppendLine("\t\t\t\t\t\t\t{"); + sb.AppendLine("\t\t\t\t\t\t\t\tConsole.Error.WriteLine($\"Error: missing value for flag --{flagName}.\");"); + if (flagHelpStdErrMethodName is not null) + { + sb.AppendLine("\t\t\t\t\t\t\t\tConsole.Error.WriteLine();"); + sb.AppendLine($"\t\t\t\t\t\t\t\t{flagHelpStdErrMethodName}(flagName);"); + sb.AppendLine("\t\t\t\t\t\t\t\tConsole.Error.WriteLine();"); + if (parseFailureRunHint is not null) + sb.AppendLine($"\t\t\t\t\t\t\t\tConsole.Error.WriteLine(\"{parseFailureRunHint}\");"); + } + else if (helpMethodName is not null) + sb.AppendLine($"\t\t\t\t\t\t\t\t{helpMethodName}();"); + sb.AppendLine($"\t\t\t\t\t\t\t\t{failureExit};"); + sb.AppendLine("\t\t\t\t\t\t\t}"); + if (anyRepeatedCollection) + { + sb.AppendLine("\t\t\t\t\t\t\tSetFlag(flagName, args[i + 1]);"); + } + else + { + sb.AppendLine("\t\t\t\t\t\t\tflags[flagName] = args[i + 1];"); + } + + sb.AppendLine("\t\t\t\t\t\t\ti += 2;"); + sb.AppendLine("\t\t\t\t\t\t}"); + sb.AppendLine("\t\t\t\t\t}"); + sb.AppendLine("\t\t\t\t\tcontinue;"); + sb.AppendLine("\t\t\t\t}"); + sb.AppendLine("\t\t\t\tif (a.Length >= 2 && a[0] == '-' && a[1] != '-')"); + sb.AppendLine("\t\t\t\t{"); + sb.AppendLine("\t\t\t\t\tvar eqs = a.IndexOf('=');"); + sb.AppendLine("\t\t\t\t\tif (eqs >= 0)"); + sb.AppendLine("\t\t\t\t\t{"); + sb.AppendLine("\t\t\t\t\t\tvar shortKey = a.Substring(1, eqs - 1);"); + sb.AppendLine("\t\t\t\t\t\tif (shortKey.Length != 1)"); + sb.AppendLine("\t\t\t\t\t\t{"); + sb.AppendLine("\t\t\t\t\t\t\tConsole.Error.WriteLine(\"Error: short options must be a single letter (e.g. -e=value).\");"); + if (helpMethodName is not null) + sb.AppendLine($"\t\t\t\t\t\t\t{helpMethodName}();"); + sb.AppendLine($"\t\t\t\t\t\t\t{failureExit};"); + sb.AppendLine("\t\t\t\t\t\t}"); + if (emitDtoTryParse && dtoLenient) + sb.AppendLine("\t\t\t\t\t\tTryApplyShortFlag(shortKey[0], a.Substring(eqs + 1));"); + else + { + sb.AppendLine("\t\t\t\t\t\tif (!TryApplyShortFlag(shortKey[0], a.Substring(eqs + 1)))"); + sb.AppendLine($"\t\t\t\t\t\t\t{failureExit};"); + } + sb.AppendLine("\t\t\t\t\t\ti++;"); + sb.AppendLine("\t\t\t\t\t\tcontinue;"); + sb.AppendLine("\t\t\t\t\t}"); + sb.AppendLine("\t\t\t\t\tif (a.Length == 2)"); + sb.AppendLine("\t\t\t\t\t{"); + sb.AppendLine("\t\t\t\t\t\tvar sc = a[1];"); + sb.AppendLine("\t\t\t\t\t\tif (IsShortBoolChar(sc))"); + sb.AppendLine("\t\t\t\t\t\t{"); + sb.AppendLine("\t\t\t\t\t\t\tif (!TryApplyShortFlag(sc, \"true\"))"); + sb.AppendLine($"\t\t\t\t\t\t\t\t{failureExit};"); + sb.AppendLine("\t\t\t\t\t\t\ti++;"); + sb.AppendLine("\t\t\t\t\t\t\tcontinue;"); + sb.AppendLine("\t\t\t\t\t\t}"); + if (emitDtoTryParse && dtoLenient) + { + // lenient: skip unknown value-taking short flags using same heuristic as long flags + sb.AppendLine("\t\t\t\t\t\tif (i + 1 < args.Length && !args[i + 1].StartsWith(\"-\", StringComparison.Ordinal))"); + sb.AppendLine("\t\t\t\t\t\t{"); + sb.AppendLine("\t\t\t\t\t\t\tTryApplyShortFlag(sc, args[i + 1]);"); + sb.AppendLine("\t\t\t\t\t\t\ti += 2;"); + sb.AppendLine("\t\t\t\t\t\t}"); + sb.AppendLine("\t\t\t\t\t\telse"); + sb.AppendLine("\t\t\t\t\t\t{"); + sb.AppendLine("\t\t\t\t\t\t\tTryApplyShortFlag(sc, \"true\");"); + sb.AppendLine("\t\t\t\t\t\t\ti++;"); + sb.AppendLine("\t\t\t\t\t\t}"); + sb.AppendLine("\t\t\t\t\t\tcontinue;"); + } + else + { + sb.AppendLine("\t\t\t\t\t\tif (i + 1 >= args.Length)"); + sb.AppendLine("\t\t\t\t\t\t{"); + sb.AppendLine("\t\t\t\t\t\t\tConsole.Error.WriteLine($\"Error: missing value for short flag '-{sc}'.\");"); + if (helpMethodName is not null) + sb.AppendLine($"\t\t\t\t\t\t\t{helpMethodName}();"); + sb.AppendLine($"\t\t\t\t\t\t\t{failureExit};"); + sb.AppendLine("\t\t\t\t\t\t}"); + sb.AppendLine("\t\t\t\t\t\tif (!TryApplyShortFlag(sc, args[i + 1]))"); + sb.AppendLine($"\t\t\t\t\t\t\t{failureExit};"); + sb.AppendLine("\t\t\t\t\t\ti += 2;"); + sb.AppendLine("\t\t\t\t\t\tcontinue;"); + } + sb.AppendLine("\t\t\t\t\t}"); + sb.AppendLine("\t\t\t\t\tConsole.Error.WriteLine(\"Error: combined short flags (e.g. -abc) are not supported.\");"); + if (helpMethodName is not null) + sb.AppendLine($"\t\t\t\t\t{helpMethodName}();"); + sb.AppendLine($"\t\t\t\t\t{failureExit};"); + sb.AppendLine("\t\t\t\t}"); + sb.AppendLine("\t\t\t\tpositionals.Add(a);"); + sb.AppendLine("\t\t\t\ti++;"); + sb.AppendLine("\t\t\t}"); + sb.AppendLine(); + + foreach (var p in cmd.Parameters) + { + if (p.Kind == ParameterKind.Injected || p.Kind == ParameterKind.OptionsInjected) + continue; + + if (p.Kind == ParameterKind.Positional) + continue; + + if (p.Special == BoolSpecialKind.Bool) + { + sb.AppendLine($"\t\t\tvar {p.LocalVarName} = flags.ContainsKey(\"{Escape(p.CliLongName)}\");"); + continue; + } + + if (p.Special == BoolSpecialKind.NullableBool) + { + sb.AppendLine($"\t\t\tbool? {p.LocalVarName} = null;"); + sb.AppendLine($"\t\t\tif (flags.TryGetValue(\"{Escape(p.CliLongName)}\", out var {p.LocalVarName}_yesVal))"); + sb.AppendLine($"\t\t\t\t{p.LocalVarName} = ParseNullableBool({p.LocalVarName}_yesVal, true);"); + sb.AppendLine($"\t\t\tif (flags.TryGetValue(\"no-{Escape(p.CliLongName)}\", out var {p.LocalVarName}_noVal))"); + sb.AppendLine($"\t\t\t\t{p.LocalVarName} = ParseNullableBool({p.LocalVarName}_noVal, false);"); + continue; + } + + if (p.IsCollection && p.Kind == ParameterKind.Flag) + { + EmitBindCollectionParameter(sb, p, anyRepeatedCollection, failureExit, helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); + continue; + } + + var flagKey = Escape(p.CliLongName); + sb.AppendLine($"\t\t\tif (!flags.TryGetValue(\"{flagKey}\", out var {p.LocalVarName}Text))"); + if (p.IsRequired) + { + sb.AppendLine("\t\t\t{"); + sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine($\"Error: missing required flag --{flagKey}.\");"); + EmitAfterCliParseErrorHelp(sb, p, "\t\t\t\t", helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); + sb.AppendLine($"\t\t\t\t{failureExit};"); + sb.AppendLine("\t\t\t}"); + } + else + sb.AppendLine($"\t\t\t\t{p.LocalVarName}Text = null;"); + + EmitParseAndAssign(sb, p, p.LocalVarName + "Text", p.LocalVarName, failureExit, helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); + } + + var posIndex = 0; + foreach (var p in cmd.Parameters) + { + if (p.Kind != ParameterKind.Positional) + continue; + + if (p.IsVariadic) + { + EmitVariadicPositionalParse(sb, p, posIndex, failureExit, helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); + posIndex++; + continue; + } + + if (p.IsRequired) + { + sb.AppendLine($"\t\t\tif (positionals.Count <= {posIndex})"); + sb.AppendLine("\t\t\t{"); + sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine(\"Error: missing required argument <{Escape(p.CliLongName)}>.\");"); + if (helpMethodName is not null) + sb.AppendLine($"\t\t\t\t{helpMethodName}();"); + sb.AppendLine($"\t\t\t\t{failureExit};"); + sb.AppendLine("\t\t\t}"); + sb.AppendLine("\t\t\telse"); + sb.AppendLine("\t\t\t{"); + EmitParseFromString(sb, p, $"positionals[{posIndex}]", p.LocalVarName, indentExtra: "\t", failureExit: failureExit, helpMethodName: helpMethodName, flagHelpStdErrMethodName: flagHelpStdErrMethodName, parseFailureRunHint: parseFailureRunHint); + sb.AppendLine("\t\t\t}"); + } + else + { + var fallback = p.DefaultValueLiteral ?? "default!"; + sb.AppendLine($"\t\t\tif (positionals.Count <= {posIndex})"); + sb.AppendLine($"\t\t\t\t{p.LocalVarName} = {fallback};"); + sb.AppendLine("\t\t\telse"); + sb.AppendLine("\t\t\t{"); + EmitParseFromString(sb, p, $"positionals[{posIndex}]", p.LocalVarName, indentExtra: "\t", failureExit: failureExit, helpMethodName: helpMethodName, flagHelpStdErrMethodName: flagHelpStdErrMethodName, parseFailureRunHint: parseFailureRunHint); + sb.AppendLine("\t\t\t}"); + } + + posIndex++; + } + + if (emitDtoTryParse) + { + EmitValidationChecks(sb, cmd, failureExit, entryAssemblyName: null); + if (dtoOptionsTypeFq is not null) + EmitOptionsDtoConstructionAndReturn(sb, dtoOptionsTypeFq, cmd.Parameters, dtoOptionsBestCtorParamOrder); + else + EmitAsParametersConstructionForDto(sb, cmd); + + sb.AppendLine("\t\t}"); + sb.AppendLine(); + return; + } + + EmitAsParametersConstruction(sb, cmd); + + EmitValidationChecks(sb, cmd, failureExit, entryAssemblyName, flagHelpStdErrMethodName); + + // Reconstruct options instances merging command-level flags with pre-parsed statics. + EmitOptionsReconstructLocals(sb, injectedOptions); + + if (cmd.RequiresInstance) + { + // Try to construct from options-injected ctor parameters before falling back to DI or parameterless ctor. + string? optionsCtorArgs = null; + if (!injectedOptions.IsDefaultOrEmpty && !cmd.ContainingTypeCtorParams.IsDefaultOrEmpty) + { + var ctorParams = cmd.ContainingTypeCtorParams; + if (ctorParams.Length > 0) + { + var ctorArgs = new List(); + var allResolved = true; + foreach (var (_, cpMetaName) in ctorParams) + { + // Exact match first, then most-derived (from end of chain); use LocalVarName (reconstructed) + string? bestLocal = null; + foreach (var o in injectedOptions) + if (o.TypeMetadataName == cpMetaName) { bestLocal = o.LocalVarName; break; } + if (bestLocal is null) + for (var _i = injectedOptions.Length - 1; _i >= 0; _i--) + if (injectedOptions[_i].AllBaseTypeMetadataNames.Contains(cpMetaName)) { bestLocal = injectedOptions[_i].LocalVarName; break; } + if (bestLocal is null) { allResolved = false; break; } + ctorArgs.Add(bestLocal); + } + if (allResolved) + optionsCtorArgs = string.Join(", ", ctorArgs); + } + } + + if (optionsCtorArgs is not null) + { + sb.AppendLine( + $"\t\t\tvar __cmdHandler = (ArghServices.ServiceProvider?.GetService(typeof({cmd.ContainingTypeFq})) as {cmd.ContainingTypeFq}) ?? new {cmd.ContainingTypeFq}({optionsCtorArgs});"); + } + else if (cmd.ContainingTypeHasParameterlessCtor) + { + sb.AppendLine( + $"\t\t\tvar __cmdHandler = (ArghServices.ServiceProvider?.GetService(typeof({cmd.ContainingTypeFq})) as {cmd.ContainingTypeFq}) ?? new {cmd.ContainingTypeFq}();"); + } + else + { + sb.AppendLine( + $"\t\t\tvar __cmdHandler = (ArghServices.ServiceProvider?.GetService(typeof({cmd.ContainingTypeFq})) as {cmd.ContainingTypeFq}) ?? throw new global::System.InvalidOperationException(\"Register the command type in DI for hosted execution, or add a public parameterless constructor for standalone CLI.\");"); + } + + sb.AppendLine(); + } + + sb.AppendLine(); + var useMiddleware = globalMiddleware.Length > 0 || cmd.CommandMiddlewareData.Length > 0; + if (!useMiddleware) + { + sb.Append("\t\t\t"); + EmitInvocation(sb, cmd, injectedOptions: injectedOptions); + sb.AppendLine(); + } + else + { + EmitCommandPathLiteral(sb, cmd); + sb.AppendLine("\t\t\tvar ctx = new CommandContext(commandPath, args, ct);"); + sb.AppendLine("\t\t\tCommandMiddlewareDelegate next = async c =>"); + sb.AppendLine("\t\t\t{"); + EmitInvocation(sb, cmd, "c.CancellationToken", "c", "\t\t\t\t", injectedOptions: injectedOptions); + sb.AppendLine("\t\t\t};"); + var cap = 0; + for (var i = cmd.CommandMiddlewareData.Length - 1; i >= 0; i--) + { + var (fq, middlewareParamless) = cmd.CommandMiddlewareData[i]; + var name = "__cap" + cap++; + sb.AppendLine($"\t\t\tvar {name} = next;"); + sb.AppendLine($"\t\t\tnext = async c => await {DiResolveOrNew(fq, middlewareParamless)}.InvokeAsync(c, {name});"); + } + + for (var i = globalMiddleware.Length - 1; i >= 0; i--) + { + var gFq = globalMiddleware[i].TypeFq; + var gParamless = globalMiddleware[i].HasParameterlessCtor; + var name = "__cap" + cap++; + sb.AppendLine($"\t\t\tvar {name} = next;"); + sb.AppendLine($"\t\t\tnext = async c => await {DiResolveOrNew(gFq, gParamless)}.InvokeAsync(c, {name});"); + } + + sb.AppendLine("\t\t\tawait next(ctx).ConfigureAwait(false);"); + sb.AppendLine("\t\t\treturn ctx.ExitCode;"); + } + + sb.AppendLine("\t\t}"); + sb.AppendLine(); + } + + private static void EmitCommandPathLiteral(StringBuilder sb, CommandModel cmd) + { + sb.Append("\t\t\tvar commandPath = new string[] { "); + for (var i = 0; i < cmd.RoutePrefix.Length; i++) + { + if (i > 0) + sb.Append(", "); + sb.Append('"').Append(Escape(cmd.RoutePrefix[i])).Append('"'); + } + + if (cmd.RoutePrefix.Length > 0) + sb.Append(", "); + sb.Append('"').Append(Escape(cmd.CommandName)).Append('"'); + sb.AppendLine(" };"); + } + + private static void EmitCliValueDeclarations(StringBuilder sb, CommandModel cmd, string? rtDefaultTypeFq = null) + { + // For cross-assembly options types, seed non-nullable properties from a runtime instance. + // rtDefaultTypeFq covers UseGlobalOptions / UseNamespaceOptions DTO paths. + // AsParametersTypeFq covers [AsParameters] init-property paths from cross-assembly types. + var hasRtDefaults = rtDefaultTypeFq is not null && cmd.Parameters.Any(static p => p.UsesRuntimeDefault); + if (hasRtDefaults) + sb.AppendLine($"\t\t\tvar __rt_default = new {rtDefaultTypeFq}();"); + + // Emit one runtime-default instance per unique cross-assembly [AsParameters] type. + var asParamsRtTypes = new Dictionary(StringComparer.Ordinal); + foreach (var p in cmd.Parameters) + { + if (!p.UsesRuntimeDefault || p.AsParametersTypeFq is null) + continue; + if (asParamsRtTypes.ContainsKey(p.AsParametersTypeFq)) + continue; + var suffix = DtoMethodSuffix(p.AsParametersTypeFq); + asParamsRtTypes[p.AsParametersTypeFq] = suffix; + sb.AppendLine($"\t\t\tvar __rt_default_{suffix} = new {p.AsParametersTypeFq}();"); + } + + foreach (var p in cmd.Parameters) + { + if (p.Kind == ParameterKind.Injected || p.Kind == ParameterKind.OptionsInjected) + continue; + + if (p.Special == BoolSpecialKind.Bool || p.Special == BoolSpecialKind.NullableBool) + continue; + + if (p.IsCollection && p.Kind == ParameterKind.Flag) + { + var elemFq = GetElementCSharpFq(p); + var accType = p.CollectionTargetIsReadOnlySet + ? "global::System.Collections.Generic.HashSet" + : "global::System.Collections.Generic.List"; + sb.AppendLine( + $"\t\t\tvar {p.LocalVarName}_acc = new {accType}<{elemFq}>();"); + continue; + } + + string initializer; + if (p.UsesRuntimeDefault) + { + if (hasRtDefaults && rtDefaultTypeFq is not null) + initializer = $"__rt_default.{p.SymbolName}"; + else if (p.AsParametersTypeFq is not null && asParamsRtTypes.TryGetValue(p.AsParametersTypeFq, out var asParamsSuffix)) + initializer = $"__rt_default_{asParamsSuffix}.{p.SymbolName}"; + else + initializer = GetCliInitializer(p); + } + else + initializer = GetCliInitializer(p); + + sb.AppendLine($"\t\t\t{GetCSharpCliType(p)} {p.LocalVarName} = {initializer};"); + } + } + + private static string GetElementCSharpFq(ParameterModel p) + { + switch (p.ElementScalarKind) + { + case CliScalarKind.Enum when p.ElementEnumTypeFq is not null: + return p.ElementEnumTypeFq; + case CliScalarKind.FileInfo: + return "global::System.IO.FileInfo"; + case CliScalarKind.DirectoryInfo: + return "global::System.IO.DirectoryInfo"; + case CliScalarKind.Uri: + return "global::System.Uri"; + case CliScalarKind.CustomParser when p.ElementCustomValueTypeFq is not null: + return p.ElementCustomValueTypeFq; + default: + break; + } + + return p.ElementTypeName switch + { + "string" => "string", + "int" => "int", + "long" => "long", + "float" => "float", + "double" => "double", + "decimal" => "decimal", + "bool" => "bool", + "DateTime" => "global::System.DateTime", + "DateTimeOffset" => "global::System.DateTimeOffset", + "TimeSpan" => "global::System.TimeSpan", + "DateOnly" => "global::System.DateOnly", + "DateTime?" => "global::System.DateTime?", + "DateTimeOffset?" => "global::System.DateTimeOffset?", + "TimeSpan?" => "global::System.TimeSpan?", + "DateOnly?" => "global::System.DateOnly?", + _ => "string" + }; + } + + private static ParameterModel ForElementParsing(ParameterModel p) => + p with + { + ScalarKind = p.ElementScalarKind, + TypeName = p.ElementTypeName, + EnumTypeFq = p.ElementEnumTypeFq, + EnumMemberNames = p.ElementEnumMemberNames, + EnumMemberCliNames = p.ElementEnumMemberCliNames, + ParserTypeFq = p.ElementParserTypeFq, + CustomValueTypeFq = p.ElementCustomValueTypeFq, + Special = BoolSpecialKind.None, + IsCollection = false, + IsRequired = true + }; + + private static void EmitVariadicPositionalParse( + StringBuilder sb, + ParameterModel p, + int startIndex, + string failureExit, + string? helpMethodName, + string? flagHelpStdErrMethodName, + string? parseFailureRunHint) + { + var argName = Escape(p.CliLongName); + var countVar = "__varCount_" + p.LocalVarName; + var arrVar = "__arr_" + p.LocalVarName; + var elemModel = ForElementParsing(p); + var elemCsharpType = GetCSharpCliType(elemModel); + + // For [Argument] params T[] with no [MinLength], zero items is valid (C# params convention). + // If the user added [MinLength(n)], CollectionCountConstraint validation below enforces at-least-n. + // However if IsRequired is true (non-nullable, no default), we require at least 1. + if (p.IsRequired) + { + sb.AppendLine($"\t\t\tif (positionals.Count <= {startIndex})"); + sb.AppendLine("\t\t\t{"); + sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine(\"Error: missing required argument <{argName}...>.\");"); + if (helpMethodName is not null) + sb.AppendLine($"\t\t\t\t{helpMethodName}();"); + sb.AppendLine($"\t\t\t\t{failureExit};"); + sb.AppendLine("\t\t\t}"); + } + + sb.AppendLine($"\t\t\tvar {countVar} = positionals.Count > {startIndex} ? positionals.Count - {startIndex} : 0;"); + sb.AppendLine($"\t\t\tvar {arrVar} = new {elemCsharpType}[{countVar}];"); + sb.AppendLine($"\t\t\tfor (var __vi_{p.LocalVarName} = 0; __vi_{p.LocalVarName} < {countVar}; __vi_{p.LocalVarName}++)"); + sb.AppendLine("\t\t\t{"); + + if (p.ElementScalarKind == CliScalarKind.Primitive && p.ElementTypeName == "string") + { + sb.AppendLine($"\t\t\t\t{arrVar}[__vi_{p.LocalVarName}] = positionals[{startIndex} + __vi_{p.LocalVarName}];"); + } + else + { + EmitParseFromString(sb, elemModel, + $"positionals[{startIndex} + __vi_{p.LocalVarName}]", + $"{arrVar}[__vi_{p.LocalVarName}]", + indentExtra: "\t", + outVarKeyword: false, + failureExit: failureExit, + helpMethodName: helpMethodName, + flagHelpStdErrMethodName: flagHelpStdErrMethodName, + parseFailureRunHint: parseFailureRunHint); + } + + sb.AppendLine("\t\t\t}"); + sb.AppendLine($"\t\t\t{p.LocalVarName} = {arrVar};"); + } + + private static string GetCSharpCliType(ParameterModel p) + { + if (p.ScalarKind == CliScalarKind.Collection && p.FullDeclaredTypeFq is not null) + return p.FullDeclaredTypeFq; + + switch (p.ScalarKind) + { + case CliScalarKind.Enum when p.EnumTypeFq is not null: + // Optional on the CLI but backed by a non-nullable enum + default (e.g. options properties): keep a non-nullable temp. + // Also keep non-nullable for cross-assembly runtime-default properties (no null initial value). + // IsNullableAnnotated guards against NRT nullable enums (e.g. MyEnum?) on cross-assembly types. + if ((p.IsRequired || p.DefaultValueLiteral is not null || p.UsesRuntimeDefault) && !p.IsNullableAnnotated) + return p.EnumTypeFq; + return p.EnumTypeFq + "?"; + case CliScalarKind.FileInfo: + return (p.IsRequired || p.UsesRuntimeDefault) && !p.IsNullableAnnotated ? "global::System.IO.FileInfo" : "global::System.IO.FileInfo?"; + case CliScalarKind.DirectoryInfo: + return (p.IsRequired || p.UsesRuntimeDefault) && !p.IsNullableAnnotated ? "global::System.IO.DirectoryInfo" : "global::System.IO.DirectoryInfo?"; + case CliScalarKind.Uri: + return (p.IsRequired || p.UsesRuntimeDefault) && !p.IsNullableAnnotated ? "global::System.Uri" : "global::System.Uri?"; + case CliScalarKind.CustomParser when p.CustomValueTypeFq is not null: + return (p.IsRequired || p.UsesRuntimeDefault) && !p.IsNullableAnnotated ? p.CustomValueTypeFq : p.CustomValueTypeFq + "?"; + default: + break; + } + + if (p.TypeName == "string") + return (p.IsRequired || p.UsesRuntimeDefault) && !p.IsNullableAnnotated ? "string" : "string?"; + + return p.TypeName switch + { + "int" => "int", + "int?" => "int?", + "long" => "long", + "long?" => "long?", + "float" => "float", + "float?" => "float?", + "double" => "double", + "double?" => "double?", + "decimal" => "decimal", + "decimal?" => "decimal?", + "bool" => "bool", + "bool?" => "bool?", + "DateTime" => "global::System.DateTime", + "DateTime?" => "global::System.DateTime?", + "DateTimeOffset" => "global::System.DateTimeOffset", + "DateTimeOffset?" => "global::System.DateTimeOffset?", + "TimeSpan" => "global::System.TimeSpan", + "TimeSpan?" => "global::System.TimeSpan?", + "DateOnly" => "global::System.DateOnly", + "DateOnly?" => "global::System.DateOnly?", + _ => "string?" + }; + } + + private static string GetCliInitializer(ParameterModel p) + { + if (p.DefaultValueLiteral is not null) + return p.DefaultValueLiteral; + + if (p.ScalarKind == CliScalarKind.Collection) + return "null!"; + + if (!p.IsRequired && p.ScalarKind is CliScalarKind.Enum or CliScalarKind.FileInfo or CliScalarKind.DirectoryInfo + or CliScalarKind.Uri or CliScalarKind.CustomParser) + return "null"; + + if (!p.IsRequired && p.TypeName.EndsWith("?", StringComparison.Ordinal)) + return "null"; + + if (p.TypeName == "string") + return p.IsRequired ? "null!" : "null"; + + return "default!"; + } + + /// + /// Global/namespace options flattened into a command via use + /// but must still participate in long-name aliases, short-option + /// binding, and bare bool switch recognition the same as . + /// + private static bool IsEmittedFlagLike(ParameterKind kind) => + kind is ParameterKind.Flag or ParameterKind.OptionsInjected; + + private static void EmitBoolSwitchNames(StringBuilder sb, CommandModel cmd, bool suppressNoNameHelper = false) + { + var names = new List(); + var noNames = new List(); + foreach (var p in cmd.Parameters) + { + if (!IsEmittedFlagLike(p.Kind)) + continue; + if (p.Special == BoolSpecialKind.Bool) + names.Add(p.CliLongName); + if (p.Special == BoolSpecialKind.NullableBool) + { + names.Add(p.CliLongName); + noNames.Add("no-" + p.CliLongName); + } + } + + if (names.Count == 0 && noNames.Count == 0) + { + sb.AppendLine("\t\t\tbool IsBoolSwitchName(string name) => false;"); + if (!suppressNoNameHelper) + sb.AppendLine("\t\t\tbool IsBoolSwitchNoName(string name) => false;"); + return; + } + + var boolSwitchNameCases = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var n in names) + boolSwitchNameCases.Add(n); + foreach (var n in noNames) + boolSwitchNameCases.Add(n); + + sb.AppendLine("\t\t\tbool IsBoolSwitchName(string name) => name switch"); + sb.AppendLine("\t\t\t{"); + foreach (var n in boolSwitchNameCases.OrderBy(static x => x, StringComparer.OrdinalIgnoreCase)) + sb.AppendLine($"\t\t\t\t\"{Escape(n)}\" => true,"); + + sb.AppendLine("\t\t\t\t_ => false"); + sb.AppendLine("\t\t\t};"); + + if (suppressNoNameHelper) + return; + if (noNames.Count == 0) + { + sb.AppendLine("\t\t\tbool IsBoolSwitchNoName(string name) => false;"); + } + else + { + var noNameCases = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var n in noNames) + noNameCases.Add(n); + sb.AppendLine("\t\t\tbool IsBoolSwitchNoName(string name) => name switch"); + sb.AppendLine("\t\t\t{"); + foreach (var n in noNameCases.OrderBy(static x => x, StringComparer.OrdinalIgnoreCase)) + sb.AppendLine($"\t\t\t\t\"{Escape(n)}\" => true,"); + sb.AppendLine("\t\t\t\t_ => false"); + sb.AppendLine("\t\t\t};"); + } + } + + private static void EmitKnownNonBoolFlagNames(StringBuilder sb, CommandModel cmd) + { + var names = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var p in cmd.Parameters) + { + if (!IsEmittedFlagLike(p.Kind)) + continue; + if (p.Special == BoolSpecialKind.Bool || p.Special == BoolSpecialKind.NullableBool) + continue; + names.Add(p.CliLongName); + foreach (var al in p.Aliases) + names.Add(al); + } + + if (names.Count == 0) + { + sb.AppendLine("\t\t\tbool IsKnownNonBoolFlagName(string name) => false;"); + return; + } + + sb.AppendLine("\t\t\tbool IsKnownNonBoolFlagName(string name) => name switch"); + sb.AppendLine("\t\t\t{"); + foreach (var n in names.OrderBy(static x => x, StringComparer.OrdinalIgnoreCase)) + sb.AppendLine($"\t\t\t\t\"{Escape(n)}\" => true,"); + sb.AppendLine("\t\t\t\t_ => false"); + sb.AppendLine("\t\t\t};"); + } + + private static void EmitCanonFlagNameMethod(StringBuilder sb, CommandModel cmd) + { + var cases = new List<(string from, string to)>(); + foreach (var p in cmd.Parameters) + { + if (!IsEmittedFlagLike(p.Kind)) + continue; + foreach (var al in p.Aliases) + { + if (string.Equals(al, p.CliLongName, StringComparison.OrdinalIgnoreCase)) + continue; + cases.Add((al, p.CliLongName)); + } + + if (p.Special == BoolSpecialKind.NullableBool) + { + foreach (var al in p.Aliases) + { + if (string.Equals(al, p.CliLongName, StringComparison.OrdinalIgnoreCase)) + continue; + cases.Add(("no-" + al, "no-" + p.CliLongName)); + } + } + } + + if (cases.Count == 0) + { + sb.AppendLine("\t\t\tstring CanonFlagName(string raw) => raw;"); + return; + } + + sb.AppendLine("\t\t\tstring CanonFlagName(string raw) => raw switch"); + sb.AppendLine("\t\t\t{"); + foreach ((var from, var to) in cases) + sb.AppendLine($"\t\t\t\t\"{Escape(from)}\" => \"{Escape(to)}\","); + + sb.AppendLine("\t\t\t\t_ => raw"); + sb.AppendLine("\t\t\t};"); + } + + private static void EmitShortFlagMethods(StringBuilder sb, CommandModel cmd, bool multiFlagsAvailable = true, string? parseFailureRunHint = null) + { + var shortCases = new List<(char c, string Primary, bool IsBool, bool IsRepeatableCollection)>(); + foreach (var p in cmd.Parameters) + { + if (!IsEmittedFlagLike(p.Kind)) + continue; + if (p.ShortOpt is not char ch) + continue; + // IsRepeatableCollection only applies when multiFlags is available in the emitted context. + var isRepeatableCollection = multiFlagsAvailable && p.IsCollection && p.CollectionSeparator is null; + shortCases.Add((ch, p.CliLongName, p.Special == BoolSpecialKind.Bool, isRepeatableCollection)); + } + + if (shortCases.Count == 0) + { + sb.AppendLine("\t\t\tbool TryApplyShortFlag(char c, string val)"); + sb.AppendLine("\t\t\t{"); + sb.AppendLine("\t\t\t\tConsole.Error.WriteLine($\"Error: unknown short option '-{c}'.\");"); + if (parseFailureRunHint is not null) + { + sb.AppendLine("\t\t\t\tConsole.Error.WriteLine();"); + sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine(\"{Escape(parseFailureRunHint)}\");"); + } + sb.AppendLine("\t\t\t\treturn false;"); + sb.AppendLine("\t\t\t}"); + sb.AppendLine("\t\t\tbool IsShortBoolChar(char c) => false;"); + return; + } + + sb.AppendLine("\t\t\tbool TryApplyShortFlag(char c, string val)"); + sb.AppendLine("\t\t\t{"); + sb.AppendLine("\t\t\t\tswitch (c)"); + sb.AppendLine("\t\t\t\t{"); + foreach ((var c, var primary, _, var isRepeatableCol) in shortCases) + { + var esc = Escape(primary); + sb.AppendLine($"\t\t\t\t\tcase '{c}':"); + if (isRepeatableCol) + { + // Repeatable flag: append to multiFlags so short opt and long opt collect into the same list. + sb.AppendLine($"\t\t\t\t\t\tif (!multiFlags.TryGetValue(\"{esc}\", out var __scList_{esc.Replace("-", "_")})) {{ __scList_{esc.Replace("-", "_")} = new List(); multiFlags[\"{esc}\"] = __scList_{esc.Replace("-", "_")}; }}"); + sb.AppendLine($"\t\t\t\t\t\t__scList_{esc.Replace("-", "_")}.Add(val);"); + } + else + { + sb.AppendLine($"\t\t\t\t\t\tflags[\"{esc}\"] = val;"); + } + sb.AppendLine("\t\t\t\t\t\treturn true;"); + } + + sb.AppendLine("\t\t\t\t\tdefault:"); + sb.AppendLine("\t\t\t\t\t\tConsole.Error.WriteLine($\"Error: unknown short option '-{c}'.\");"); + if (parseFailureRunHint is not null) + { + sb.AppendLine("\t\t\t\t\t\tConsole.Error.WriteLine();"); + sb.AppendLine($"\t\t\t\t\t\tConsole.Error.WriteLine(\"{Escape(parseFailureRunHint)}\");"); + } + sb.AppendLine("\t\t\t\t\t\treturn false;"); + sb.AppendLine("\t\t\t\t}"); + sb.AppendLine("\t\t\t}"); + + var anyBool = shortCases.Exists(static x => x.IsBool); + if (!anyBool) + { + sb.AppendLine("\t\t\tbool IsShortBoolChar(char c) => false;"); + return; + } + + sb.AppendLine("\t\t\tbool IsShortBoolChar(char c) => c switch"); + sb.AppendLine("\t\t\t{"); + foreach ((var c, _, var isBool, _) in shortCases) + { + if (isBool) + sb.AppendLine($"\t\t\t\t'{c}' => true,"); + } + + sb.AppendLine("\t\t\t\t_ => false"); + sb.AppendLine("\t\t\t};"); + } + + private static void EmitParseAndAssign(StringBuilder sb, ParameterModel p, string rawExpr, string targetVar, string failureExit = "return 2", string? helpMethodName = null, + string? flagHelpStdErrMethodName = null, string? parseFailureRunHint = null) + { + if (!p.IsRequired && p.DefaultValueLiteral is not null) + { + sb.AppendLine($"\t\t\tif ({rawExpr} is null)"); + sb.AppendLine($"\t\t\t\t{targetVar} = {p.DefaultValueLiteral};"); + sb.AppendLine("\t\t\telse"); + sb.AppendLine("\t\t\t{"); + EmitParseFromString(sb, p, rawExpr, targetVar, indentExtra: "\t", outVarKeyword: false, failureExit: failureExit, helpMethodName: helpMethodName, flagHelpStdErrMethodName: flagHelpStdErrMethodName, parseFailureRunHint: parseFailureRunHint); + sb.AppendLine("\t\t\t}"); + } + else if (!p.IsRequired && p.DefaultValueLiteral is null) + { + // Optional parameter with no explicit default: only parse when a value was actually provided. + // Guards against passing null into type-specific parsers (e.g. Enum.TryParse) when the flag is absent. + sb.AppendLine($"\t\t\tif ({rawExpr} is not null)"); + sb.AppendLine("\t\t\t{"); + EmitParseFromString(sb, p, rawExpr, targetVar, indentExtra: "\t", outVarKeyword: false, failureExit: failureExit, helpMethodName: helpMethodName, flagHelpStdErrMethodName: flagHelpStdErrMethodName, parseFailureRunHint: parseFailureRunHint); + sb.AppendLine("\t\t\t}"); + } + else + EmitParseFromString(sb, p, rawExpr, targetVar, failureExit: failureExit, helpMethodName: helpMethodName, flagHelpStdErrMethodName: flagHelpStdErrMethodName, parseFailureRunHint: parseFailureRunHint); + } + + private static void EmitParseFromString(StringBuilder sb, ParameterModel p, string rawExpr, string targetVar, string indentExtra = "", + bool outVarKeyword = false, string failureExit = "return 2", string? helpMethodName = null, string? flagHelpStdErrMethodName = null, string? parseFailureRunHint = null) + { + var ind = "\t\t\t" + indentExtra; + + if (p.ScalarKind == CliScalarKind.Enum && p.EnumTypeFq is not null && !p.EnumMemberNames.IsDefaultOrEmpty) + { + EmitEnumParseFromString(sb, p, rawExpr, targetVar, ind, outVarKeyword, failureExit, helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); + return; + } + + if (p.ScalarKind == CliScalarKind.FileInfo) + { + EmitFileInfoParseFromString(sb, p, rawExpr, targetVar, ind, outVarKeyword, failureExit, helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); + return; + } + + if (p.ScalarKind == CliScalarKind.DirectoryInfo) + { + EmitDirectoryInfoParseFromString(sb, p, rawExpr, targetVar, ind, outVarKeyword, failureExit, helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); + return; + } + + if (p.ScalarKind == CliScalarKind.Uri) + { + EmitUriParseFromString(sb, p, rawExpr, targetVar, ind, outVarKeyword, failureExit, helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); + return; + } + + if (p.ScalarKind == CliScalarKind.CustomParser && p.ParserTypeFq is not null && p.CustomValueTypeFq is not null) + { + EmitCustomParserFromString(sb, p, rawExpr, targetVar, ind, outVarKeyword, failureExit, helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); + return; + } + + if (p.Special == BoolSpecialKind.None && p.TypeName is "int?" or "long?" or "float?" or "double?" or "decimal?") + { + EmitNullableNumericParseFromString(sb, p, rawExpr, targetVar, ind, outVarKeyword, failureExit, helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); + return; + } + + if (p.Special == BoolSpecialKind.None && p.TypeName is "DateTime?" or "DateTimeOffset?" or "TimeSpan?" or "DateOnly?") + { + EmitNullableTemporalParseFromString(sb, p, rawExpr, targetVar, ind, outVarKeyword, failureExit, helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); + return; + } + + EmitPrimitiveScalarParseFromString(sb, p, rawExpr, targetVar, ind, outVarKeyword, failureExit, helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); + } + + + + private static void EmitEnumParseFromString(StringBuilder sb, ParameterModel p, string rawExpr, string targetVar, string ind, bool outVarKeyword, string failureExit, string? helpMethodName, string? flagHelpStdErrMethodName, string? parseFailureRunHint) + { + var e = Escape(p.CliLongName); + string Out(string name) => outVarKeyword ? "out var " + name : "out " + name; +var evVar = "__ev_" + p.LocalVarName; +var evParsed = "__evp_" + p.LocalVarName; +sb.AppendLine($"{ind}var {evParsed} = false;"); +sb.AppendLine($"{ind}{p.EnumTypeFq} {evVar} = default;"); +sb.AppendLine($"{ind}switch (({rawExpr} ?? \"\").ToLowerInvariant())"); +sb.AppendLine($"{ind}{{"); +for (var i = 0; i < p.EnumMemberNames.Length; i++) +{ + var memberName = p.EnumMemberNames[i]; + var cliName = ResolveEnumMemberCliName(p.EnumMemberCliNames, i, memberName); + sb.AppendLine($"{ind}\tcase \"{Escape(cliName.ToLowerInvariant())}\": {evVar} = {p.EnumTypeFq}.{memberName}; {evParsed} = true; break;"); +} +sb.AppendLine($"{ind}}}"); +sb.AppendLine($"{ind}if (!{evParsed})"); +sb.AppendLine($"{ind}{{"); +sb.AppendLine($"{ind}\tConsole.Error.WriteLine($\"Error: invalid value for --{e}: '{{{rawExpr}}}'.\");"); +EmitAfterCliParseErrorHelp(sb, p, $"{ind}\t", helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); +sb.AppendLine($"{ind}\t{failureExit};"); +sb.AppendLine($"{ind}}}"); +if (outVarKeyword) + sb.AppendLine($"{ind}var {targetVar} = {evVar};"); +else + sb.AppendLine($"{ind}{targetVar} = {evVar};"); +return; + } + + private static void EmitFileInfoParseFromString(StringBuilder sb, ParameterModel p, string rawExpr, string targetVar, string ind, bool outVarKeyword, string failureExit, string? helpMethodName, string? flagHelpStdErrMethodName, string? parseFailureRunHint) + { +// Optional FileInfo? must omit new FileInfo when the flag was not provided (null), not pass null into the ctor (ArgumentNullException). +if (!p.IsRequired) +{ + var csharpNullableFi = GetCSharpCliType(p); + var tmpFi = "__nullableFileInfo_" + Naming.SanitizeIdentifier(p.LocalVarName); + sb.AppendLine($"{ind}{csharpNullableFi} {tmpFi} = null;"); + sb.AppendLine($"{ind}if (!string.IsNullOrWhiteSpace({rawExpr}))"); + sb.AppendLine($"{ind}{{"); + var innerFi = ind + "\t"; + string pathSrcOpt = $"{rawExpr}!"; + if (p.ExpandUserProfileBeforeBind) + { + var expandedOpt = "__path_" + Naming.SanitizeIdentifier(p.LocalVarName); + sb.AppendLine($"{innerFi}var {expandedOpt} = global::Nullean.Argh.ArghPath.ExpandUserProfilePath({rawExpr}!);"); + pathSrcOpt = expandedOpt; + } + + sb.AppendLine($"{innerFi}{tmpFi} = new global::System.IO.FileInfo({pathSrcOpt});"); + sb.AppendLine($"{ind}}}"); + if (outVarKeyword) + sb.AppendLine($"{ind}var {targetVar} = {tmpFi};"); + else + sb.AppendLine($"{ind}{targetVar} = {tmpFi};"); + return; +} + +string pathSrc = $"{rawExpr}!"; +if (p.ExpandUserProfileBeforeBind) +{ + var expandedName = "__path_" + Naming.SanitizeIdentifier(p.LocalVarName); + sb.AppendLine($"{ind}var {expandedName} = global::Nullean.Argh.ArghPath.ExpandUserProfilePath({rawExpr}!);"); + pathSrc = expandedName; +} + +if (outVarKeyword) + sb.AppendLine($"{ind}var {targetVar} = new global::System.IO.FileInfo({pathSrc});"); +else + sb.AppendLine($"{ind}{targetVar} = new global::System.IO.FileInfo({pathSrc});"); +return; + } + + private static void EmitDirectoryInfoParseFromString(StringBuilder sb, ParameterModel p, string rawExpr, string targetVar, string ind, bool outVarKeyword, string failureExit, string? helpMethodName, string? flagHelpStdErrMethodName, string? parseFailureRunHint) + { +// Optional DirectoryInfo? must omit new DirectoryInfo when the flag was not provided (null), not pass null into the ctor (ArgumentNullException). +if (!p.IsRequired) +{ + var csharpNullableDi = GetCSharpCliType(p); + var tmpDi = "__nullableDirectoryInfo_" + Naming.SanitizeIdentifier(p.LocalVarName); + sb.AppendLine($"{ind}{csharpNullableDi} {tmpDi} = null;"); + sb.AppendLine($"{ind}if (!string.IsNullOrWhiteSpace({rawExpr}))"); + sb.AppendLine($"{ind}{{"); + var innerDi = ind + "\t"; + string pathSrcDirOpt = $"{rawExpr}!"; + if (p.ExpandUserProfileBeforeBind) + { + var expandedOptDir = "__dir_" + Naming.SanitizeIdentifier(p.LocalVarName); + sb.AppendLine($"{innerDi}var {expandedOptDir} = global::Nullean.Argh.ArghPath.ExpandUserProfilePath({rawExpr}!);"); + pathSrcDirOpt = expandedOptDir; + } + + sb.AppendLine($"{innerDi}{tmpDi} = new global::System.IO.DirectoryInfo({pathSrcDirOpt});"); + sb.AppendLine($"{ind}}}"); + if (outVarKeyword) + sb.AppendLine($"{ind}var {targetVar} = {tmpDi};"); + else + sb.AppendLine($"{ind}{targetVar} = {tmpDi};"); + return; +} + +string pathSrcDir = $"{rawExpr}!"; +if (p.ExpandUserProfileBeforeBind) +{ + var expandedDir = "__dir_" + Naming.SanitizeIdentifier(p.LocalVarName); + sb.AppendLine($"{ind}var {expandedDir} = global::Nullean.Argh.ArghPath.ExpandUserProfilePath({rawExpr}!);"); + pathSrcDir = expandedDir; +} + +if (outVarKeyword) + sb.AppendLine($"{ind}var {targetVar} = new global::System.IO.DirectoryInfo({pathSrcDir});"); +else + sb.AppendLine($"{ind}{targetVar} = new global::System.IO.DirectoryInfo({pathSrcDir});"); +return; + } + + private static void EmitUriParseFromString(StringBuilder sb, ParameterModel p, string rawExpr, string targetVar, string ind, bool outVarKeyword, string failureExit, string? helpMethodName, string? flagHelpStdErrMethodName, string? parseFailureRunHint) + { + var e = Escape(p.CliLongName); +// Optional Uri? must treat omitted flags as null (raw text is null), not run Uri.TryCreate on null/whitespace. +if (!p.IsRequired) +{ + var csharpNullableUri = GetCSharpCliType(p); + var tmpUri = "__nullableUriParsed_" + Naming.SanitizeIdentifier(p.LocalVarName); + sb.AppendLine($"{ind}{csharpNullableUri} {tmpUri} = null;"); + sb.AppendLine($"{ind}if (!string.IsNullOrWhiteSpace({rawExpr}))"); + sb.AppendLine($"{ind}{{"); + sb.AppendLine( + $"{ind}\tif (!global::System.Uri.TryCreate({rawExpr}, global::System.UriKind.RelativeOrAbsolute, out var __uri))"); + sb.AppendLine($"{ind}\t{{"); + sb.AppendLine($"{ind}\t\tConsole.Error.WriteLine($\"Error: invalid URI for --{e}: '{{{rawExpr}}}'.\");"); + EmitAfterCliParseErrorHelp(sb, p, $"{ind}\t\t", helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); + sb.AppendLine($"{ind}\t\t{failureExit};"); + sb.AppendLine($"{ind}\t}}"); + sb.AppendLine($"{ind}\t{tmpUri} = __uri;"); + sb.AppendLine($"{ind}}}"); + if (outVarKeyword) + sb.AppendLine($"{ind}var {targetVar} = {tmpUri};"); + else + sb.AppendLine($"{ind}{targetVar} = {tmpUri};"); + return; +} + +sb.AppendLine($"{ind}if (!global::System.Uri.TryCreate({rawExpr}, global::System.UriKind.RelativeOrAbsolute, out var __uri))"); +sb.AppendLine($"{ind}{{"); +sb.AppendLine($"{ind}\tConsole.Error.WriteLine($\"Error: invalid URI for --{e}: '{{{rawExpr}}}'.\");"); +EmitAfterCliParseErrorHelp(sb, p, $"{ind}\t", helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); +sb.AppendLine($"{ind}\t{failureExit};"); +sb.AppendLine($"{ind}}}"); +if (outVarKeyword) + sb.AppendLine($"{ind}var {targetVar} = __uri;"); +else + sb.AppendLine($"{ind}{targetVar} = __uri;"); +return; + } + + private static void EmitCustomParserFromString(StringBuilder sb, ParameterModel p, string rawExpr, string targetVar, string ind, bool outVarKeyword, string failureExit, string? helpMethodName, string? flagHelpStdErrMethodName, string? parseFailureRunHint) + { + var e = Escape(p.CliLongName); +sb.AppendLine($"{ind}var __parser = new {p.ParserTypeFq}();"); +sb.AppendLine($"{ind}if (!__parser.TryParse({rawExpr}!, out var __pv))"); +sb.AppendLine($"{ind}{{"); +sb.AppendLine($"{ind}\tConsole.Error.WriteLine($\"Error: invalid value for --{e}.\");"); +EmitAfterCliParseErrorHelp(sb, p, $"{ind}\t", helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); +sb.AppendLine($"{ind}\t{failureExit};"); +sb.AppendLine($"{ind}}}"); +if (outVarKeyword) + sb.AppendLine($"{ind}var {targetVar} = __pv;"); +else + sb.AppendLine($"{ind}{targetVar} = __pv;"); +return; + } + + private static void EmitPrimitiveScalarParseFromString(StringBuilder sb, ParameterModel p, string rawExpr, string targetVar, string ind, bool outVarKeyword, string failureExit, string? helpMethodName, string? flagHelpStdErrMethodName, string? parseFailureRunHint) + { + var e = Escape(p.CliLongName); + string Out(string name) => outVarKeyword ? "out var " + name : "out " + name; +switch (p.Special) +{ + case BoolSpecialKind.None when p.TypeName == "string": + { + if (outVarKeyword) + sb.AppendLine($"{ind}var {targetVar} = {rawExpr};"); + else + { + var nonNull = p.IsRequired ? "!" : ""; + sb.AppendLine($"{ind}{targetVar} = {rawExpr}{nonNull};"); + } + + break; + } + case BoolSpecialKind.None when p.TypeName == "int": + sb.AppendLine( + $"{ind}if (!int.TryParse({rawExpr}, NumberStyles.Integer, CultureInfo.InvariantCulture, {Out(targetVar)}))"); + sb.AppendLine($"{ind}{{"); + sb.AppendLine($"{ind}\tConsole.Error.WriteLine($\"Error: invalid int for --{e}: '{{{rawExpr}}}'.\");"); + EmitAfterCliParseErrorHelp(sb, p, $"{ind}\t", helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); + sb.AppendLine($"{ind}\t{failureExit};"); + sb.AppendLine($"{ind}}}"); + break; + case BoolSpecialKind.None when p.TypeName == "long": + sb.AppendLine( + $"{ind}if (!long.TryParse({rawExpr}, NumberStyles.Integer, CultureInfo.InvariantCulture, {Out(targetVar)}))"); + sb.AppendLine($"{ind}{{"); + sb.AppendLine($"{ind}\tConsole.Error.WriteLine($\"Error: invalid long for --{e}: '{{{rawExpr}}}'.\");"); + EmitAfterCliParseErrorHelp(sb, p, $"{ind}\t", helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); + sb.AppendLine($"{ind}\t{failureExit};"); + sb.AppendLine($"{ind}}}"); + break; + case BoolSpecialKind.None when p.TypeName == "float": + sb.AppendLine( + $"{ind}if (!float.TryParse({rawExpr}, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, {Out(targetVar)}))"); + sb.AppendLine($"{ind}{{"); + sb.AppendLine($"{ind}\tConsole.Error.WriteLine($\"Error: invalid float for --{e}: '{{{rawExpr}}}'.\");"); + EmitAfterCliParseErrorHelp(sb, p, $"{ind}\t", helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); + sb.AppendLine($"{ind}\t{failureExit};"); + sb.AppendLine($"{ind}}}"); + break; + case BoolSpecialKind.None when p.TypeName == "double": + sb.AppendLine( + $"{ind}if (!double.TryParse({rawExpr}, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, {Out(targetVar)}))"); + sb.AppendLine($"{ind}{{"); + sb.AppendLine($"{ind}\tConsole.Error.WriteLine($\"Error: invalid double for --{e}: '{{{rawExpr}}}'.\");"); + EmitAfterCliParseErrorHelp(sb, p, $"{ind}\t", helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); + sb.AppendLine($"{ind}\t{failureExit};"); + sb.AppendLine($"{ind}}}"); + break; + case BoolSpecialKind.None when p.TypeName == "decimal": + sb.AppendLine( + $"{ind}if (!decimal.TryParse({rawExpr}, NumberStyles.Number, CultureInfo.InvariantCulture, {Out(targetVar)}))"); + sb.AppendLine($"{ind}{{"); + sb.AppendLine($"{ind}\tConsole.Error.WriteLine($\"Error: invalid decimal for --{e}: '{{{rawExpr}}}'.\");"); + EmitAfterCliParseErrorHelp(sb, p, $"{ind}\t", helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); + sb.AppendLine($"{ind}\t{failureExit};"); + sb.AppendLine($"{ind}}}"); + break; + case BoolSpecialKind.None when p.TypeName == "DateTime": + { + var tmp = "__dt_" + p.LocalVarName; + sb.AppendLine( + $"{ind}if (!global::System.DateTime.TryParse({rawExpr}, CultureInfo.InvariantCulture, global::System.Globalization.DateTimeStyles.RoundtripKind | global::System.Globalization.DateTimeStyles.AllowWhiteSpaces, out var {tmp}))"); + sb.AppendLine($"{ind}{{"); + sb.AppendLine($"{ind}\tConsole.Error.WriteLine($\"Error: invalid DateTime for --{e}: '{{{rawExpr}}}'.\");"); + EmitAfterCliParseErrorHelp(sb, p, $"{ind}\t", helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); + sb.AppendLine($"{ind}\t{failureExit};"); + sb.AppendLine($"{ind}}}"); + if (outVarKeyword) + sb.AppendLine($"{ind}var {targetVar} = {tmp};"); + else + sb.AppendLine($"{ind}{targetVar} = {tmp};"); + break; + } + case BoolSpecialKind.None when p.TypeName == "DateTimeOffset": + { + var tmp = "__dto_" + p.LocalVarName; + sb.AppendLine( + $"{ind}if (!global::System.DateTimeOffset.TryParse({rawExpr}, CultureInfo.InvariantCulture, global::System.Globalization.DateTimeStyles.RoundtripKind | global::System.Globalization.DateTimeStyles.AllowWhiteSpaces, out var {tmp}))"); + sb.AppendLine($"{ind}{{"); + sb.AppendLine($"{ind}\tConsole.Error.WriteLine($\"Error: invalid DateTimeOffset for --{e}: '{{{rawExpr}}}'.\");"); + EmitAfterCliParseErrorHelp(sb, p, $"{ind}\t", helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); + sb.AppendLine($"{ind}\t{failureExit};"); + sb.AppendLine($"{ind}}}"); + if (outVarKeyword) + sb.AppendLine($"{ind}var {targetVar} = {tmp};"); + else + sb.AppendLine($"{ind}{targetVar} = {tmp};"); + break; + } + case BoolSpecialKind.None when p.TypeName == "TimeSpan": + { + var tmp = "__ts_" + p.LocalVarName; + sb.AppendLine($"{ind}if (!global::Nullean.Argh.ArghTimeSpan.TryParse({rawExpr}, out var {tmp}))"); + sb.AppendLine($"{ind}{{"); + sb.AppendLine($"{ind}\tConsole.Error.WriteLine($\"Error: invalid TimeSpan for --{e}: '{{{rawExpr}}}'.\");"); + EmitAfterCliParseErrorHelp(sb, p, $"{ind}\t", helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); + sb.AppendLine($"{ind}\t{failureExit};"); + sb.AppendLine($"{ind}}}"); + if (outVarKeyword) + sb.AppendLine($"{ind}var {targetVar} = {tmp};"); + else + sb.AppendLine($"{ind}{targetVar} = {tmp};"); + break; + } + case BoolSpecialKind.None when p.TypeName == "DateOnly": + { + var tmp = "__do_" + p.LocalVarName; + sb.AppendLine( + $"{ind}if (!global::System.DateOnly.TryParse({rawExpr}, CultureInfo.InvariantCulture, global::System.Globalization.DateTimeStyles.None, out var {tmp}))"); + sb.AppendLine($"{ind}{{"); + sb.AppendLine($"{ind}\tConsole.Error.WriteLine($\"Error: invalid DateOnly for --{e}: '{{{rawExpr}}}'.\");"); + EmitAfterCliParseErrorHelp(sb, p, $"{ind}\t", helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); + sb.AppendLine($"{ind}\t{failureExit};"); + sb.AppendLine($"{ind}}}"); + if (outVarKeyword) + sb.AppendLine($"{ind}var {targetVar} = {tmp};"); + else + sb.AppendLine($"{ind}{targetVar} = {tmp};"); + break; + } + case BoolSpecialKind.None when p.TypeName == "bool": + if (outVarKeyword) + sb.AppendLine( + $"{ind}var {targetVar} = bool.TryParse({rawExpr}, out var tmpBool) ? tmpBool : true;"); + else + sb.AppendLine($"{ind}{targetVar} = bool.TryParse({rawExpr}, out var tmpBool) ? tmpBool : true;"); + break; + default: + if (outVarKeyword) + sb.AppendLine($"{ind}var {targetVar} = {rawExpr};"); + else + sb.AppendLine($"{ind}{targetVar} = {rawExpr}; // fallback"); + break; +} + } + + private static void EmitNullableNumericParseFromString(StringBuilder sb, ParameterModel p, string rawExpr, string targetVar, + string ind, bool outVarKeyword, string failureExit, string? helpMethodName, string? flagHelpStdErrMethodName = null, string? parseFailureRunHint = null) + { + var e = Escape(p.CliLongName); + var tmpVar = "__nullableNumericParsed_" + p.LocalVarName; + var parsedOut = "__nv_" + p.LocalVarName; + var csharpNullable = GetCSharpCliType(p); + + sb.AppendLine($"{ind}{csharpNullable} {tmpVar} = null;"); + sb.AppendLine($"{ind}if ({rawExpr} is not null)"); + sb.AppendLine($"{ind}{{"); + + switch (p.TypeName) + { + case "int?": + sb.AppendLine( + $"{ind}\tif (!int.TryParse({rawExpr}, NumberStyles.Integer, CultureInfo.InvariantCulture, out var {parsedOut}))"); + break; + case "long?": + sb.AppendLine( + $"{ind}\tif (!long.TryParse({rawExpr}, NumberStyles.Integer, CultureInfo.InvariantCulture, out var {parsedOut}))"); + break; + case "float?": + sb.AppendLine( + $"{ind}\tif (!float.TryParse({rawExpr}, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var {parsedOut}))"); + break; + case "double?": + sb.AppendLine( + $"{ind}\tif (!double.TryParse({rawExpr}, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var {parsedOut}))"); + break; + case "decimal?": + sb.AppendLine( + $"{ind}\tif (!decimal.TryParse({rawExpr}, NumberStyles.Number, CultureInfo.InvariantCulture, out var {parsedOut}))"); + break; + default: + throw new InvalidOperationException($"Unexpected nullable numeric type '{p.TypeName}'."); + } + + sb.AppendLine($"{ind}\t{{"); + sb.AppendLine($"{ind}\t\tConsole.Error.WriteLine($\"Error: invalid {p.TypeName.TrimEnd('?')} for --{e}: '{{{rawExpr}}}'.\");"); + EmitAfterCliParseErrorHelp(sb, p, $"{ind}\t\t", helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); + sb.AppendLine($"{ind}\t\t{failureExit};"); + sb.AppendLine($"{ind}\t}}"); + sb.AppendLine($"{ind}\t{tmpVar} = {parsedOut};"); + sb.AppendLine($"{ind}}}"); + + if (outVarKeyword) + sb.AppendLine($"{ind}var {targetVar} = {tmpVar};"); + else + sb.AppendLine($"{ind}{targetVar} = {tmpVar};"); + } + + private static void EmitNullableTemporalParseFromString(StringBuilder sb, ParameterModel p, string rawExpr, string targetVar, + string ind, bool outVarKeyword, string failureExit, string? helpMethodName, string? flagHelpStdErrMethodName = null, string? parseFailureRunHint = null) + { + var e = Escape(p.CliLongName); + var tmpVar = "__nullableTemporalParsed_" + p.LocalVarName; + var parsedOut = "__nt_" + p.LocalVarName; + var csharpNullable = GetCSharpCliType(p); + + sb.AppendLine($"{ind}{csharpNullable} {tmpVar} = null;"); + sb.AppendLine($"{ind}if ({rawExpr} is not null)"); + sb.AppendLine($"{ind}{{"); + + switch (p.TypeName) + { + case "DateTime?": + sb.AppendLine( + $"{ind}\tif (!global::System.DateTime.TryParse({rawExpr}, CultureInfo.InvariantCulture, global::System.Globalization.DateTimeStyles.RoundtripKind | global::System.Globalization.DateTimeStyles.AllowWhiteSpaces, out var {parsedOut}))"); + break; + case "DateTimeOffset?": + sb.AppendLine( + $"{ind}\tif (!global::System.DateTimeOffset.TryParse({rawExpr}, CultureInfo.InvariantCulture, global::System.Globalization.DateTimeStyles.RoundtripKind | global::System.Globalization.DateTimeStyles.AllowWhiteSpaces, out var {parsedOut}))"); + break; + case "TimeSpan?": + sb.AppendLine($"{ind}\tif (!global::Nullean.Argh.ArghTimeSpan.TryParse({rawExpr}, out var {parsedOut}))"); + break; + case "DateOnly?": + sb.AppendLine( + $"{ind}\tif (!global::System.DateOnly.TryParse({rawExpr}, CultureInfo.InvariantCulture, global::System.Globalization.DateTimeStyles.None, out var {parsedOut}))"); + break; + default: + throw new InvalidOperationException($"Unexpected nullable temporal type '{p.TypeName}'."); + } + + sb.AppendLine($"{ind}\t{{"); + sb.AppendLine($"{ind}\t\tConsole.Error.WriteLine($\"Error: invalid {p.TypeName.TrimEnd('?')} for --{e}: '{{{rawExpr}}}'.\");"); + EmitAfterCliParseErrorHelp(sb, p, $"{ind}\t\t", helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); + sb.AppendLine($"{ind}\t\t{failureExit};"); + sb.AppendLine($"{ind}\t}}"); + sb.AppendLine($"{ind}\t{tmpVar} = {parsedOut};"); + sb.AppendLine($"{ind}}}"); + + if (outVarKeyword) + sb.AppendLine($"{ind}var {targetVar} = {tmpVar};"); + else + sb.AppendLine($"{ind}{targetVar} = {tmpVar};"); + } + + private static void EmitInvocation( + StringBuilder sb, + CommandModel cmd, + string ctExpr = "ct", + string? commandContextVar = null, + string lineIndent = "\t\t\t", + ImmutableArray<(string TypeFq, string TypeMetadataName, ImmutableArray AllBaseTypeMetadataNames, string StaticFieldName, string LocalVarName, ImmutableArray FlatMembers, ImmutableArray? BestCtorParamOrder)> injectedOptions = default) + { + // Lambda commands: invoke through ArghApp.GetRegisteredLambda with a cast + if (cmd.IsLambda && !string.IsNullOrEmpty(cmd.LambdaStorageKey)) + { + EmitLambdaInvocation(sb, cmd, ctExpr, commandContextVar, lineIndent); + return; + } + + var args = new List(); + if (cmd.HandlerParamTypes.IsDefaultOrEmpty) + { + foreach (var p in cmd.Parameters) + { + if (p.Kind == ParameterKind.Injected) + args.Add(ctExpr); + else if (p.Kind != ParameterKind.OptionsInjected) + // OptionsInjected entries are flag-recognition markers added by FixOptionsParamsInCommands; + // they are not method arguments — the options instance is passed as a reconstructed local. + args.Add(p.LocalVarName); + } + } + else + { + foreach (var mp in cmd.HandlerParamTypes) + { + if (mp.IsInjectedParam) + { + args.Add(ctExpr); + continue; + } + + if (mp.IsAsParameters) + { + args.Add(AsParametersConstructedVarName(mp.Name)); + continue; + } + + // Options-type parameters are injected as locally-reconstructed instances that merge + // command-level flags (post-command) with pre-parsed static values (pre-command). + if (!injectedOptions.IsDefaultOrEmpty) + { + string? localVar = null; + foreach (var o in injectedOptions) + if (o.TypeMetadataName == mp.TypeMetadataName) { localVar = o.LocalVarName; break; } + if (localVar is null) + for (var _i = injectedOptions.Length - 1; _i >= 0; _i--) + if (injectedOptions[_i].AllBaseTypeMetadataNames.Contains(mp.TypeMetadataName)) { localVar = injectedOptions[_i].LocalVarName; break; } + + if (localVar is not null) + { + args.Add(localVar); + continue; + } + } + + foreach (var p in cmd.Parameters) + { + if (p.AsParametersOwnerParamName is not null) + continue; + if (p.SymbolName != mp.Name) + continue; + args.Add(p.LocalVarName); + break; + } + } + } + + var argList = string.Join(", ", args); + var call = cmd.RequiresInstance + ? $"__cmdHandler.{cmd.MethodName}({argList})" + : $"{cmd.ContainingTypeFq}.{cmd.MethodName}({argList})"; + + var ret0 = commandContextVar is null + ? $"{lineIndent}return 0;" + : $"{lineIndent}{commandContextVar}.ExitCode = 0;\n{lineIndent}return;"; + + var retFq = cmd.ReturnTypeFq; + // Empty string means no return type info (shouldn't happen for method handlers). + // Note: retFq comes from SymbolDisplayFormat.FullyQualifiedFormat, which renders special + // types using their C# keyword ("void", "int") rather than "global::System.Void"/"global::System.Int32" — + // keep these checks in that form (see also the "int" checks inside Task/ValueTask below). + if (retFq == "" || retFq == "void") + { + sb.AppendLine($"{lineIndent}{call};"); + sb.AppendLine(ret0); + return; + } + + if (retFq == "int") + { + if (commandContextVar is null) + sb.AppendLine($"{lineIndent}return {call};"); + else + { + sb.AppendLine($"{lineIndent}{commandContextVar}.ExitCode = {call};"); + sb.AppendLine($"{lineIndent}return;"); + } + + return; + } + + if (retFq == "global::System.Threading.Tasks.Task") + { + sb.AppendLine($"{lineIndent}await {call}.ConfigureAwait(false);"); + sb.AppendLine(ret0); + return; + } + + if (retFq == "global::System.Threading.Tasks.Task") + { + if (commandContextVar is null) + sb.AppendLine($"{lineIndent}return await {call}.ConfigureAwait(false);"); + else + { + sb.AppendLine($"{lineIndent}{commandContextVar}.ExitCode = await {call}.ConfigureAwait(false);"); + sb.AppendLine($"{lineIndent}return;"); + } + return; + } + + if (retFq.StartsWith("global::System.Threading.Tasks.Task<", StringComparison.Ordinal)) + { + sb.AppendLine($"{lineIndent}await {call}.ConfigureAwait(false);"); + sb.AppendLine(ret0); + return; + } + + if (retFq == "global::System.Threading.Tasks.ValueTask") + { + sb.AppendLine($"{lineIndent}await {call}.ConfigureAwait(false);"); + sb.AppendLine(ret0); + return; + } + + if (retFq == "global::System.Threading.Tasks.ValueTask") + { + if (commandContextVar is null) + sb.AppendLine($"{lineIndent}return await {call}.ConfigureAwait(false);"); + else + { + sb.AppendLine($"{lineIndent}{commandContextVar}.ExitCode = await {call}.ConfigureAwait(false);"); + sb.AppendLine($"{lineIndent}return;"); + } + return; + } + + if (retFq.StartsWith("global::System.Threading.Tasks.ValueTask<", StringComparison.Ordinal)) + { + sb.AppendLine($"{lineIndent}await {call}.ConfigureAwait(false);"); + sb.AppendLine(ret0); + return; + } + + sb.AppendLine($"{lineIndent}{call};"); + sb.AppendLine(ret0); + } + + private static void EmitLambdaInvocation( + StringBuilder sb, + CommandModel cmd, + string ctExpr, + string? commandContextVar, + string lineIndent) + { + var lambdaArgs = new List(); + foreach (var p in cmd.Parameters) + { + if (p.Kind == ParameterKind.Injected) + lambdaArgs.Add(ctExpr); + else + lambdaArgs.Add(p.LocalVarName); + } + var lambdaArgList = string.Join(", ", lambdaArgs); + var castType = string.IsNullOrEmpty(cmd.LambdaDelegateFq) || cmd.LambdaDelegateFq == "global::System.Delegate" + ? "global::System.Delegate" + : cmd.LambdaDelegateFq; + + var lambdaRet0 = commandContextVar is null + ? $"{lineIndent}return 0;" + : $"{lineIndent}{commandContextVar}.ExitCode = 0;\n{lineIndent}return;"; + + var lambdaRetFq = cmd.ReturnTypeFq; + var lambdaIsTaskOfInt = lambdaRetFq == "global::System.Threading.Tasks.Task" + || lambdaRetFq == "global::System.Threading.Tasks.ValueTask"; + + if (castType == "global::System.Delegate") + { + // Fallback: use DynamicInvoke + sb.AppendLine($"{lineIndent}var __lambdaDelegate = ArghApp.GetRegisteredLambda(\"{Escape(cmd.LambdaStorageKey)}\");"); + sb.AppendLine($"{lineIndent}__lambdaDelegate?.DynamicInvoke({lambdaArgList});"); + sb.AppendLine(lambdaRet0); + } + else + { + sb.AppendLine($"{lineIndent}var __lambdaDelegate = (({castType})ArghApp.GetRegisteredLambda(\"{Escape(cmd.LambdaStorageKey)}\")!);"); + if (lambdaRetFq == "global::System.Threading.Tasks.Task" || + (lambdaRetFq.StartsWith("global::System.Threading.Tasks.Task<", System.StringComparison.Ordinal) && !lambdaIsTaskOfInt)) + { + sb.AppendLine($"{lineIndent}await __lambdaDelegate({lambdaArgList}).ConfigureAwait(false);"); + sb.AppendLine(lambdaRet0); + } + else if (lambdaIsTaskOfInt) + { + if (commandContextVar is null) + sb.AppendLine($"{lineIndent}return await __lambdaDelegate({lambdaArgList}).ConfigureAwait(false);"); + else + { + sb.AppendLine($"{lineIndent}{commandContextVar}.ExitCode = await __lambdaDelegate({lambdaArgList}).ConfigureAwait(false);"); + sb.AppendLine($"{lineIndent}return;"); + } + } + else if (lambdaRetFq == "int") + { + if (commandContextVar is null) + sb.AppendLine($"{lineIndent}return __lambdaDelegate({lambdaArgList});"); + else + { + sb.AppendLine($"{lineIndent}{commandContextVar}.ExitCode = __lambdaDelegate({lambdaArgList});"); + sb.AppendLine($"{lineIndent}return;"); + } + } + else + { + sb.AppendLine($"{lineIndent}__lambdaDelegate({lambdaArgList});"); + sb.AppendLine(lambdaRet0); + } + } + } + + private static IEnumerable EnumerateFlagMembers(OptionsTypeModel? model) + { + if (model is null) + yield break; + + foreach (var p in model.Members) + { + if (p.Kind == ParameterKind.Flag) + yield return p; + } + } + + private static void AddCliKeys(IEnumerable flags, HashSet keys) + { + foreach (var p in flags) + { + keys.Add(p.CliLongName); + foreach (var a in p.Aliases) + { + if (!string.IsNullOrEmpty(a)) + keys.Add(a); + } + } + } + + private static List<(string Segment, OptionsTypeModel Model)> GetCommandNamespaceOptionChain(AppEmitModel app, ImmutableArray routePrefix) + { + var list = new List<(string, OptionsTypeModel)>(); + var current = app.Root; + foreach (var seg in routePrefix) + { + RegistryNode.NamedCommandNamespaceChild? found = null; + foreach (var c in current.Children) + { + if (string.Equals(c.Segment, seg, StringComparison.OrdinalIgnoreCase)) + { + found = c; + break; + } + } + + if (found is null) + break; + + current = found.Node; + if (current.CommandNamespaceOptionsModel is { Members: { Length: > 0 } } gom) + list.Add((seg, gom)); + } + + return list; + } + + private static bool CommandFlagMatchesScopedKeys(ParameterModel p, HashSet scopedKeys) + { + if (scopedKeys.Contains(p.CliLongName)) + return true; + + foreach (var a in p.Aliases) + { + if (!string.IsNullOrEmpty(a) && scopedKeys.Contains(a)) + return true; + } + + return false; + } + + + /// + /// Diagnoses misuse of the filesystem-path attribute family. lets + /// collection call sites pass the *element* kind (e.g. FileInfo for List<FileInfo>) so these + /// attributes are correctly recognized on collections of FileInfo/DirectoryInfo, not just scalars. + /// + private static void ReportFilesystemPathAttributeIssues( + ISymbol host, + CliScalarKind scalarKind, + string declaredName, + DiagnosticAccumulator? acc, + Location? fallbackLocation, + CliScalarKind? filesystemScalarKind = null) + { + Location loc = host.Locations.FirstOrDefault() ?? fallbackLocation ?? Location.None; + var fsKind = filesystemScalarKind ?? scalarKind; + + var hasExisting = false; + var hasNonExisting = false; + var hasExpandProfile = false; + var hasRejectSymlinks = false; + var hasFileExtensions = false; + + foreach (var attr in host.GetAttributes()) + { + var fqn = attr.AttributeClass?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) ?? ""; + switch (fqn) + { + case "global::Nullean.Argh.ExistingAttribute": + hasExisting = true; + break; + case "global::Nullean.Argh.NonExistingAttribute": + hasNonExisting = true; + break; + case "global::Nullean.Argh.ExpandUserProfileAttribute": + hasExpandProfile = true; + break; + case "global::Nullean.Argh.RejectSymbolicLinksAttribute": + hasRejectSymlinks = true; + break; + case "global::System.ComponentModel.DataAnnotations.FileExtensionsAttribute": + hasFileExtensions = true; + break; + } + } + + if (hasExisting && hasNonExisting) + acc?.Add(PathExistenceAttributesConflict, loc, declaredName); + + var isFileInfo = fsKind == CliScalarKind.FileInfo; + var isDirInfo = fsKind == CliScalarKind.DirectoryInfo; + var isFileOrDir = isFileInfo || isDirInfo; + + if (hasExisting && !isFileOrDir) + acc?.Add(FilesystemPathAttributeTypeMismatch, loc, declaredName, + "[Existing] only applies to FileInfo, FileInfo?, DirectoryInfo, DirectoryInfo?, or a collection of FileInfo/DirectoryInfo parameters and properties."); + + if (hasNonExisting && !isFileOrDir) + acc?.Add(FilesystemPathAttributeTypeMismatch, loc, declaredName, + "[NonExisting] only applies to FileInfo, FileInfo?, DirectoryInfo, DirectoryInfo?, or a collection of FileInfo/DirectoryInfo parameters and properties."); + + if (hasExpandProfile && !isFileOrDir) + acc?.Add(FilesystemPathAttributeTypeMismatch, loc, declaredName, + "[ExpandUserProfile] only applies to FileInfo, DirectoryInfo, or a collection of FileInfo/DirectoryInfo parameters and properties."); + + if (hasRejectSymlinks && !isFileOrDir) + acc?.Add(FilesystemPathAttributeTypeMismatch, loc, declaredName, + "[RejectSymbolicLinks] only applies to FileInfo, DirectoryInfo, or a collection of FileInfo/DirectoryInfo parameters and properties."); + + if (hasFileExtensions && !isFileInfo) + acc?.Add(FilesystemPathAttributeTypeMismatch, loc, declaredName, + "[FileExtensions] only applies to FileInfo, FileInfo?, or a collection of FileInfo parameters and properties."); + } + + private static bool TryReadExpandUserProfileBeforeBind(ISymbol host, CliScalarKind scalarKind) + { + foreach (var attr in host.GetAttributes()) + { + if (attr.AttributeClass?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == + "global::Nullean.Argh.ExpandUserProfileAttribute") + { + return scalarKind is CliScalarKind.FileInfo or CliScalarKind.DirectoryInfo; + } + } + + return false; + } + + + /// + /// Reads DataAnnotations/Argh validation attributes off . + /// + /// + /// Kind used to gate the filesystem-path family ([Existing], [NonExisting], [RejectSymbolicLinks], [FileExtensions]). + /// For scalar parameters this equals (the default when null). For collection + /// parameters (List<FileInfo>, DirectoryInfo[], ...) callers pass the *element* kind here so + /// these attributes are recognized per-item while stays + /// for the other (non filesystem-family) constraint decisions such as [Url] vs Uri-scheme. + /// +} diff --git a/src/Nullean.Argh.Generator/CliParserGenerator.Models.cs b/src/Nullean.Argh.Generator/CliParserGenerator.Models.cs new file mode 100644 index 0000000..cd2dfdb --- /dev/null +++ b/src/Nullean.Argh.Generator/CliParserGenerator.Models.cs @@ -0,0 +1,315 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Globalization; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Operations; +using Microsoft.CodeAnalysis.Text; + +namespace Nullean.Argh; + +public sealed partial class CliParserGenerator +{ + private sealed class RegistryNode + { + public CommandModel? RootCommand; + /// Pointer to a named command that acts as the scope's root alias (set by MapAndRootAlias<T>). + public CommandModel? RootAlias; + public readonly List Commands = new(); + public readonly List Children = new(); + public Location? CommandNamespaceOptionsLocation; + public OptionsTypeModel? CommandNamespaceOptionsModel; + /// Inner XML of <summary> from the namespace entry type (populated when a generic AddNamespace<T> is used). + public string SummaryInnerXml = ""; + /// Inner XML of <remarks> from the namespace entry type. + public string RemarksInnerXml = ""; + + public sealed class NamedCommandNamespaceChild + { + public string Segment = ""; + public RegistryNode Node = null!; + /// First non-empty XML summary from the first generic Add handler type in this namespace block. + public string SummaryOneLiner = ""; + public Location Location = Location.None; + } + } + + private sealed class AppEmitModel + { + public OptionsTypeModel? GlobalOptionsModel; + public string RootSummary = ""; + public string? SchemaVersionOverride; + public readonly RegistryNode Root = new(); + public ImmutableArray AllCommands = ImmutableArray.Empty; + public ImmutableArray GlobalMiddleware = ImmutableArray.Empty; + public readonly List ArglessNamespaceCodegen = new(); + public ImmutableArray EnvironmentVars = ImmutableArray.Empty; + public ImmutableArray ConfigFiles = ImmutableArray.Empty; + /// Pre-computed injection chains per command (keyed by ). Set once in TryBuildAppEmitModel after AllCommands is populated. + public ImmutableDictionary AllBaseTypeMetadataNames, string StaticFieldName, string LocalVarName, ImmutableArray FlatMembers, ImmutableArray? BestCtorParamOrder)>> InjectionChains + = ImmutableDictionary, string, string, ImmutableArray, ImmutableArray?)>>.Empty; + } + + private sealed record ArglessNamespaceCodegenEntry(string TypeFq, string Segment); + + private sealed record GlobalMiddlewareRegistration(string TypeFq, bool HasParameterlessCtor); + + private sealed record OptionsTypeModel( + string TypeFq, + string TypeMetadataName, + ImmutableArray AllBaseTypeMetadataNames, + ImmutableArray Members, + ImmutableArray FlattenedMembers, + /// Parameter names of the best public non-empty constructor whose parameters all match member names; null if none or property-init should be used. + ImmutableArray? BestCtorParamOrder, + bool IsPublic, + bool IsGeneric); + + /// Per-parameter data extracted at analysis time, stored in . + private sealed record HandlerParam( + string Name, + string TypeMetadataName, + /// All ancestor metadata names of this parameter's type — used for subclass-aware options injection matching. + ImmutableArray TypeAllBaseTypeMetadataNames, + bool IsInjectedParam, + bool IsAsParameters, + string? AsParametersPrefix, + /// Non-null only for [AsParameters]-annotated params — the FQ type name for DTO building in emit. + string? AsParamTypeFq = null, + bool AsParamIsPublic = true, + bool AsParamIsGeneric = false, + /// Pre-computed best ctor param order for [AsParameters] DTO construction (symbol-free). + ImmutableArray? AsParamBestCtorParamOrder = null); + + private readonly record struct AsParametersMeta( + string OwnerParamName, + int MemberOrder, + string TypeFq, + bool UseInit, + string ClrName); + + /// + /// Value-type location snapshot used in pipeline records instead of (a reference type + /// that embeds a SyntaxTree reference and breaks incremental caching on every file edit). + /// Reconstructed to a real only when reporting a diagnostic. + /// + private readonly record struct SourceSpanInfo( + string FilePath, + int Start, + int Length, + int Line, + int Character) + { + public static readonly SourceSpanInfo None = new("", 0, 0, 0, 0); + + public static SourceSpanInfo From(Location loc) + { + if (!loc.IsInSource) return None; + var lp = loc.GetLineSpan(); + return new SourceSpanInfo( + lp.Path, + loc.SourceSpan.Start, + loc.SourceSpan.Length, + lp.StartLinePosition.Line, + lp.StartLinePosition.Character); + } + + public Location ToLocation() => + FilePath.Length == 0 + ? Location.None + : Location.Create( + FilePath, + new TextSpan(Start, Length), + new LinePositionSpan( + new LinePosition(Line, Character), + new LinePosition(Line, Character + Length))); + } + + /// + /// Value-type diagnostic snapshot used in AnalyzedInvocation records instead of + /// (a reference type that breaks incremental caching). Reconstructed in TryBuildAppEmitModel. + /// + private readonly record struct PendingDiagnostic( + string DescriptorId, + SourceSpanInfo Span, + string Arg0 = "", + string Arg1 = ""); + + // ─── AnalyzedInvocation discriminated union ──────────────────────────────── + // Symbol-free records representing each pre-analysed ArghApp builder invocation. + // Produced by AnalyzeInvocation() in the Select step (which has SemanticModel), + // and consumed by TryBuildAppEmitModel() in the RegisterSourceOutput Execute step. + // All AnalyzedInvocation subtypes are symbol-free: only strings, value types, and pre-computed + // ImmutableArrays. No ISymbol references. This ensures Roslyn's pipeline can cache them by + // structural equality between compilations. + + private abstract record AnalyzedInvocation(string FilePath, int SpanStart); + + /// A GlobalOptions<T>() invocation — only valid at root scope. + private sealed record AIUseGlobalOptions(string FilePath, int SpanStart, OptionsTypeModel Model) + : AnalyzedInvocation(FilePath, SpanStart); + + /// A CommandNamespaceOptions<T>() invocation — only valid inside a namespace. + private sealed record AIUseNamespaceOptions(string FilePath, int SpanStart, OptionsTypeModel Model) + : AnalyzedInvocation(FilePath, SpanStart); + + /// A UseMiddleware<T>() invocation — only valid at root scope. + private sealed record AIUseMiddleware(string FilePath, int SpanStart, GlobalMiddlewareRegistration Registration) + : AnalyzedInvocation(FilePath, SpanStart); + + /// A UseCliDescription(string) invocation — only meaningful at root scope. + private sealed record AIUseCliDescription(string FilePath, int SpanStart, string Description) + : AnalyzedInvocation(FilePath, SpanStart); + + /// A UseSchemaVersion(string) invocation — overrides the version field in the __schema document. + private sealed record AIUseSchemaVersion(string FilePath, int SpanStart, string Version) + : AnalyzedInvocation(FilePath, SpanStart); + + /// A DocumentEnvironmentVariables(...) invocation — only meaningful at root scope. + private sealed record AIDocumentEnvironmentVariables( + string FilePath, + int SpanStart, + ImmutableArray Variables, + ImmutableArray ConfigFiles) + : AnalyzedInvocation(FilePath, SpanStart); + + /// Symbol-free representation of a passed to DocumentEnvironmentVariables. + private sealed record EnvVarDocEntry(string Name, string? Description, bool Required, string? DefaultValue); + + /// Symbol-free representation of a passed to DocumentEnvironmentVariables. + private sealed record ConfigFileDocEntry(string Path, string? Description, bool Required); + + /// Symbol-free intent data extracted from [CommandIntent]. + private sealed record CommandIntentData(bool? Destructive, bool? Idempotent, string? Scope, bool? RequiresConfirmation, bool? RequiresAuth); + + /// Symbol-free output data extracted from [CommandOutput]. + private sealed record CommandOutputData(ImmutableArray Formats, string? FormatFlag); + + /// + /// An Add<T>() or Add(name, handler) invocation. + /// For Add<T>, holds the full registry structure; + /// for Add(name, handler), holds the single command. + /// + private sealed record AIMapCommand( + string FilePath, + int SpanStart, + ImmutableArray Commands, + RegistryNodeSnapshot? TypeSnapshot = null, + /// + /// Diagnostics accumulated while expanding (e.g. AGH0007 duplicate CLI names, + /// AGH0032 filesystem attribute misuse) — empty for the Map(name, handler) overload, which reports + /// directly via its own plumbing. + /// + ImmutableArray EmbeddedDiagnostics = default) + : AnalyzedInvocation(FilePath, SpanStart) + { + public ImmutableArray EmbeddedDiagnosticsOrEmpty => + EmbeddedDiagnostics.IsDefault ? ImmutableArray.Empty : EmbeddedDiagnostics; + } + + /// An AddRootCommand(handler) or AddNamespaceRootCommand(handler) invocation. + private sealed record AIMapRootCommand(string FilePath, int SpanStart, CommandModel Cmd, bool IsNamespaceRoot) + : AnalyzedInvocation(FilePath, SpanStart); + + /// A MapAndRootAlias<T>() invocation — registers all T methods as named commands and marks one as the root alias. + private sealed record AIMapAndRootAlias( + string FilePath, + int SpanStart, + RegistryNodeSnapshot TypeSnapshot, + ImmutableArray EmbeddedDiagnostics) + : AnalyzedInvocation(FilePath, SpanStart); + + /// + /// An AddNamespace(…) invocation. + /// LambdaBodyStart/End are character offsets into FilePath used to identify child invocations positionally. + /// + private sealed record AIMapNamespace( + string FilePath, + int SpanStart, + string SegmentName, + int LambdaBodyStart, + int LambdaBodyEnd, + /// FQ name of the generic type argument (for AddNamespace<T>), or null for AddNamespace(string, string, Action). + string? EntryTypeFq, + /// True when AddNamespace<T>(Action) with no explicit segment — requires codegen module initializer. + bool IsArglessSegment, + /// Pre-computed namespace summary one-liner for help listing. + string NsSummary, + /// Pre-computed namespace XML documentation. + string NsSummaryInnerXml, + string NsRemarksInnerXml, + /// Whether a redundancy check should be applied (AddNamespace<T> registers its own commands). + bool HasEntryType, + SourceSpanInfo DiagnosticSpanInfo, + /// Embedded diagnostics to report from TryBuildAppEmitModel (e.g. AGH0016 redundant Add<T>). + ImmutableArray EmbeddedDiagnostics, + /// + /// Pre-registered commands and sub-namespaces from the entry type T (for AddNamespace<T>). + /// Contains root commands, regular commands, and nested children from ExpandTypeRegistration. + /// Null when there is no entry type. + /// + RegistryNodeSnapshot? EntryTypeSnapshot) + : AnalyzedInvocation(FilePath, SpanStart); + + /// Symbol-free snapshot of a RegistryNode subtree produced during analysis. + private sealed record RegistryNodeSnapshot( + CommandModel? RootCommand, + ImmutableArray Commands, + ImmutableArray Children, + string SummaryInnerXml, + string RemarksInnerXml, + /// Alias target set by MapAndRootAlias<T> — a reference to a command already in . + CommandModel? AliasCommand = null); + + /// Symbol-free snapshot of a child namespace (nested type) produced during analysis. + private sealed record ChildNamespaceSnapshot( + string Segment, + RegistryNodeSnapshot Node, + string SummaryOneLiner); + + // ───────────────────────────────────────────────────────────────────────────── + + /// + /// Lightweight diagnostic collection wrapper used in where no + /// is available. Collected diagnostics are embedded in the + /// returned record and reported later by TryBuildAppEmitModel. + /// + private enum ParameterKind + { + Flag, + Positional, + Injected, + /// + /// A flattened member from a global or namespace options type injected into this command. + /// Participates in bool-switch / short-opt / canon-name detection so the flag is parsed correctly, + /// but is skipped by value-declaration and binding emission (the value is obtained from a + /// locally-reconstructed options instance instead). + /// + OptionsInjected + } + + private enum CliScalarKind + { + Primitive, + Enum, + FileInfo, + DirectoryInfo, + Uri, + CustomParser, + Collection + } + + private enum BoolSpecialKind + { + None, + Bool, + NullableBool + } + +} diff --git a/src/Nullean.Argh.Generator/CliParserGenerator.Naming.cs b/src/Nullean.Argh.Generator/CliParserGenerator.Naming.cs new file mode 100644 index 0000000..29d6964 --- /dev/null +++ b/src/Nullean.Argh.Generator/CliParserGenerator.Naming.cs @@ -0,0 +1,260 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Globalization; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Operations; +using Microsoft.CodeAnalysis.Text; + +namespace Nullean.Argh; + +public sealed partial class CliParserGenerator +{ + private static string Escape(string s) => s.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\r", "\\r").Replace("\n", "\\n"); + + /// Doubles { and } so text can be embedded in generated C# $"…" without forming interpolation holes. + private static string EscapeInterpolationBraces(string s) => + s.Replace("{", "{{").Replace("}", "}}"); + + private static string EscapeForHelpInterpolation(string s) => EscapeInterpolationBraces(Escape(s)); + + private static string EscapeDocXml(string s) => s.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\r", "\\r").Replace("\n", "\\n"); + + /// + /// Remarks XML only: <paramref name="x"/> for a CLI flag becomes <c>--long-name</c>; + /// <see cref="M:…"/> for another command handler becomes <c>entryAsm route cmd usage-hints</c> (same tail as the emitted Usage line after the assembly name). + /// + private static class Naming + { + public static string ToCommandName(string name) => ToKebabCase(StripCommandSuffixes(name)); + + public static string ToCliLongName(string name) => ToKebabCase(name); + + public static string ToTypeSegmentName(string typeName) => ToKebabCase(StripCommandSuffixes(typeName)); + + public static string SanitizeIdentifier(string commandName) + { + var sb = new StringBuilder(); + foreach (var c in commandName) + { + if (char.IsLetterOrDigit(c)) + sb.Append(c); + else + sb.Append('_'); + } + + return sb.Length == 0 ? "cmd" : sb.ToString(); + } + + private static string StripCommandSuffixes(string typeName) + { + string[] suffixes = ["Commands", "Command", "Handlers", "Handler"]; + foreach (var s in suffixes) + { + if (typeName.EndsWith(s, StringComparison.Ordinal) && typeName.Length > s.Length) + return typeName.Substring(0, typeName.Length - s.Length); + } + + return typeName; + } + + private static string ToKebabCase(string name) + { + if (string.IsNullOrEmpty(name)) + return name; + + var sb = new StringBuilder(); + for (var i = 0; i < name.Length; i++) + { + var c = name[i]; + if (char.IsUpper(c)) + { + if (i > 0 && (char.IsLower(name[i - 1]) || (i + 1 < name.Length && char.IsLower(name[i + 1])))) + sb.Append('-'); + sb.Append(char.ToLowerInvariant(c)); + } + else + sb.Append(c); + } + + return sb.ToString(); + } + } + + private readonly record struct ParamDoc(char? ShortOpt, ImmutableArray Aliases, string Description, string? ExplicitLongName = null); + + private static class ParamDocParser + { + public static ParamDoc Parse(string text) + { + text = text.Trim(); + if (text.Length == 0) + return new ParamDoc(null, ImmutableArray.Empty, ""); + + var parts = text.Split(','); + char? shortOpt = null; + string? explicitLongName = null; + var aliases = ImmutableArray.CreateBuilder(); + var i = 0; + for (; i < parts.Length; i++) + { + var seg = parts[i].Trim(); + if (seg.Length == 0) + { + i++; + break; + } + + if (LooksLikeShortFlag(seg)) + { + if (shortOpt is null) + shortOpt = seg[1]; + continue; + } + + if (LooksLikeLongFlag(seg)) + { + // First --long-name becomes the primary CLI name (overrides the derived name). + // Subsequent --long-names become aliases. + if (explicitLongName is null) + explicitLongName = seg.Substring(2); + else + aliases.Add(seg.Substring(2)); + continue; + } + + break; + } + + var desc = i >= parts.Length ? "" : string.Join(",", parts, i, parts.Length - i).Trim(); + return new ParamDoc(shortOpt, aliases.ToImmutable(), desc, explicitLongName); + } + + private static bool LooksLikeShortFlag(string seg) => + seg.Length == 2 && seg[0] == '-' && seg[1] != '-' && (char.IsLetterOrDigit(seg[1])); + + private static bool LooksLikeLongFlag(string seg) => + seg.Length > 2 && seg.StartsWith("--", StringComparison.Ordinal); + } + + private static class HelpLayout + { + public static string FormatOptionLeftCell(ParameterModel p) + { + if (p.Special == BoolSpecialKind.Bool) + { + if (p.ShortOpt is char c) + return "-" + c + ", " + "--" + p.CliLongName; + return "--" + p.CliLongName; + } + + if (p.Special == BoolSpecialKind.NullableBool) + { + if (p.ShortOpt is char nc) + return "-" + nc + ", " + "--[no-]" + p.CliLongName; + return "--[no-]" + p.CliLongName; + } + + var th = TypeHint(p); + var sb = new StringBuilder(); + if (p.ShortOpt is char ch) + { + sb.Append('-').Append(ch).Append(", "); + } + + foreach (var a in p.Aliases) + { + if (string.Equals(a, p.CliLongName, StringComparison.OrdinalIgnoreCase)) + continue; + sb.Append("--").Append(a).Append(", "); + } + + sb.Append("--").Append(p.CliLongName); + if (p.Special == BoolSpecialKind.None) + sb.Append(' ').Append(th); + + return sb.ToString(); + } + + public static string TypeHint(ParameterModel p) + { + if (InferValidationDerivedTypeHint(p) is string vh) + return vh; + + switch (p.ScalarKind) + { + case CliScalarKind.Collection: + return ""; + case CliScalarKind.Enum: + return ""; + case CliScalarKind.FileInfo: + return ""; + case CliScalarKind.DirectoryInfo: + return ""; + case CliScalarKind.Uri: + return ""; + case CliScalarKind.CustomParser: + return ""; + default: + break; + } + + return p.TypeName switch + { + "string" => "", + "int" => "", + "long" => "", + "float" => "", + "double" => "", + "decimal" => "", + "bool" => "", + "bool?" => "", + "DateTime" or "DateTime?" => "", + "DateTimeOffset" or "DateTimeOffset?" => "", + "TimeSpan" or "TimeSpan?" => "", + "DateOnly" or "DateOnly?" => "", + _ => "" + }; + } + + private static string? InferValidationDerivedTypeHint(ParameterModel p) + { + if (p.Validations.IsDefaultOrEmpty || p.ScalarKind == CliScalarKind.Collection) + return null; + + // Nullable reference/value does not switch placeholders: email is only for CLR string bindings; url for string/Uri scheme rules. + if (p.Validations.Any(static v => v is EmailConstraint)) + { + if (HasClrSemanticStringBinding(p)) + return ""; + } + + if (p.Validations.Any(static v => v is UrlConstraint or UriSchemeConstraint)) + return ""; + + return null; + } + + private static bool HasClrSemanticStringBinding(ParameterModel p) => + p.ScalarKind == CliScalarKind.Primitive && IsClrStringParameterTypeName(p.TypeName); + + private static bool IsClrStringParameterTypeName(string? typeName) => + typeName is not null && (typeName == "string" || typeName == "string?"); + } + + private readonly record struct MethodDocumentation( + string SummaryOneLiner, + string RemarksRendered, + string ExamplesRendered, + string SummaryInnerXml, + string RemarksInnerXml, + ImmutableDictionary ParamDocsRaw, + ImmutableDictionary ParamSeparators); + +} diff --git a/src/Nullean.Argh.Generator/CliParserGenerator.ParameterModel.cs b/src/Nullean.Argh.Generator/CliParserGenerator.ParameterModel.cs new file mode 100644 index 0000000..c830183 --- /dev/null +++ b/src/Nullean.Argh.Generator/CliParserGenerator.ParameterModel.cs @@ -0,0 +1,999 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Globalization; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Operations; +using Microsoft.CodeAnalysis.Text; + +namespace Nullean.Argh; + +public sealed partial class CliParserGenerator +{ + private sealed record ParameterModel( + string SymbolName, + string LocalVarName, + string CliLongName, + ParameterKind Kind, + BoolSpecialKind Special, + CliScalarKind ScalarKind, + string TypeName, + string? EnumTypeFq, + ImmutableArray EnumMemberNames, + string? ParserTypeFq, + string? CustomValueTypeFq, + bool IsRequired, + string? DefaultValueLiteral, + string Description, + char? ShortOpt, + ImmutableArray Aliases, + bool IsCollection = false, + string? CollectionSeparator = null, + CliScalarKind ElementScalarKind = CliScalarKind.Primitive, + string ElementTypeName = "string", + string? ElementEnumTypeFq = null, + ImmutableArray ElementEnumMemberNames = default, + ImmutableArray EnumMemberCliNames = default, + ImmutableArray ElementEnumMemberCliNames = default, + string? ElementParserTypeFq = null, + string? ElementCustomValueTypeFq = null, + string? FullDeclaredTypeFq = null, + string? AsParametersOwnerParamName = null, + int AsParametersMemberOrder = -1, + string? AsParametersTypeFq = null, + bool AsParametersUseInit = false, + string? AsParametersClrName = null, + bool CollectionTargetIsArray = false, + bool CollectionTargetIsReadOnlySet = false, + /// True when the declared collection type uses NRT annotation (e.g. IReadOnlySet<int>?). Optional params with this shape default to null when no values were parsed. + bool DeclaredNullableAnnotated = false, + bool ElementIsValueType = false, + ImmutableDictionary? EnumMemberDocs = null, + ImmutableDictionary? ElementEnumMemberDocs = null, + bool ExpandUserProfileBeforeBind = false, + ImmutableArray Validations = default, + bool IsHidden = false, + bool IsVariadic = false, + /// + /// True when the property is from a cross-assembly type (DeclaringSyntaxReferences empty) and has no + /// detectable static default. The emit uses new T().PropName at runtime for the initial value. + /// + bool UsesRuntimeDefault = false, + /// + /// True when the source property/parameter is a nullable reference type (NRT, e.g. string?, + /// FileInfo?) as opposed to a value-type Nullable<T> (e.g. int?). + /// Used by to emit string? even when + /// is true, preventing CS8600 when the runtime default for a nullable property is null. + /// + bool IsNullableAnnotated = false, + bool IsConfirmationSkip = false, + bool IsDryRun = false, + bool IsCommandOutput = false, + ImmutableArray CommandOutputExplicitFormats = default, + bool IsDeprecated = false, + string? DeprecationMessage = null) + { + // ── shared helpers ────────────────────────────────────────────────────── + + private static ImmutableDictionary? TryGetEnumDocs(ITypeSymbol type) + { + var t = type; + if (t is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nul) + t = nul.TypeArguments[0]; + return t is INamedTypeSymbol en ? GetEnumMemberDocs(en) : null; + } + + private static void ClassifyScalarUnified( + ITypeSymbol type, + ISymbol attributeHost, + BoolSpecialKind bs, + bool isSeparateType, + out CliScalarKind sk, + out string typeName, + out string? enumFq, + out ImmutableArray enumMembers, + out string? parserFq, + out string? customValueTypeFq) + { + if (isSeparateType) + ClassifyScalarForType(type, attributeHost, bs, out sk, out typeName, out enumFq, out enumMembers, out parserFq, out customValueTypeFq); + else + ClassifyScalar((IParameterSymbol)attributeHost, bs, out sk, out typeName, out enumFq, out enumMembers, out parserFq, out customValueTypeFq); + } + + private static ParameterModel BuildCollectionParameterModel( + ITypeSymbol collectionType, + ITypeSymbol elementType, + ISymbol attributeHost, + ParameterKind kind, + string cliLongName, + string localVarName, + string symbolName, + bool isSeparateType, + string? defaultLiteral, + string description, + AsParametersMeta? asParams, + char? flagShortOpt = null, + ImmutableArray synopsisAliasesFromSummary = default, + bool isVariadic = false, + DiagnosticAccumulator? reportAcc = null, + Location? reportFallbackLocation = null) + { + ClassifyScalarForType(elementType, attributeHost, BoolSpecialKind.None, + out var elemSk, out var elemTn, out var eFq, out var eMem, out var pFq, out var cFq); + var eCliMem = elemSk == CliScalarKind.Enum ? TryGetEnumCliNames(elementType) : default; + var elemEnumDocs = elemSk == CliScalarKind.Enum ? TryGetEnumDocs(elementType) : null; + if (reportAcc is not null) + ReportFilesystemPathAttributeIssues(attributeHost, CliScalarKind.Collection, symbolName, reportAcc, + reportFallbackLocation, filesystemScalarKind: elemSk); + var sep = TryGetCollectionSeparatorFromAttribute(attributeHost); + var required = isSeparateType + ? ComputeRequiredForOptionsType(collectionType, BoolSpecialKind.None) + : ComputeRequired((IParameterSymbol)attributeHost, BoolSpecialKind.None); + var defFq = (collectionType as INamedTypeSymbol)?.OriginalDefinition + .ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) ?? ""; + var synopsisAliasesResolved = synopsisAliasesFromSummary.IsDefault + ? ImmutableArray.Empty + : synopsisAliasesFromSummary; + var fq = collectionType.ToDisplayString(FullyQualifiedFormatWithNullableRefAnnotations); + var declaredNullableAnnotated = collectionType.NullableAnnotation == NullableAnnotation.Annotated; + var collValidations = ReadValidationConstraints(attributeHost, CliScalarKind.Collection, "values", isCollection: true, + filesystemScalarKind: elemSk); + var expandProfileElem = TryReadExpandUserProfileBeforeBind(attributeHost, elemSk); + // Variadic positionals always allow zero items by C# params convention. + // Minimum count enforcement is handled via CollectionCountConstraint ([MinLength]). + if (isVariadic) required = false; + var (isOutputColl, outputFormatsColl) = TryGetCommandOutputAttribute(attributeHost); + var (isDeprecatedColl, deprecationMsgColl) = TryGetObsoleteAttribute(attributeHost); + return new ParameterModel( + symbolName, + localVarName, + cliLongName, + kind, + BoolSpecialKind.None, + CliScalarKind.Collection, + "values", + null, + ImmutableArray.Empty, + null, + null, + required, + defaultLiteral, + description, + flagShortOpt, + synopsisAliasesResolved, + IsCollection: true, + CollectionSeparator: sep, + ElementScalarKind: elemSk, + ElementTypeName: elemTn, + ElementEnumTypeFq: eFq, + ElementEnumMemberNames: eMem, + ElementEnumMemberCliNames: eCliMem, + ElementParserTypeFq: pFq, + ElementCustomValueTypeFq: cFq, + FullDeclaredTypeFq: fq, + CollectionTargetIsArray: collectionType is IArrayTypeSymbol, + CollectionTargetIsReadOnlySet: defFq == "global::System.Collections.Generic.IReadOnlySet", + DeclaredNullableAnnotated: declaredNullableAnnotated, + ElementIsValueType: elementType.IsValueType, + ElementEnumMemberDocs: elemEnumDocs, + AsParametersOwnerParamName: asParams?.OwnerParamName, + AsParametersMemberOrder: asParams?.MemberOrder ?? -1, + AsParametersTypeFq: asParams?.TypeFq, + AsParametersUseInit: asParams?.UseInit ?? false, + AsParametersClrName: asParams?.ClrName, + ExpandUserProfileBeforeBind: expandProfileElem, + Validations: collValidations, + IsHidden: HasHiddenAttribute(attributeHost), + IsVariadic: isVariadic, + IsConfirmationSkip: HasConfirmationSkipAttribute(attributeHost), + IsDryRun: HasDryRunAttribute(attributeHost), + IsCommandOutput: isOutputColl, + CommandOutputExplicitFormats: outputFormatsColl, + IsDeprecated: isDeprecatedColl, + DeprecationMessage: deprecationMsgColl); + } + + // ── five factory methods ───────────────────────────────────────────── + + public static ParameterModel From(IParameterSymbol p, DiagnosticAccumulator? reportAcc = null, + Location? reportFallbackLocation = null) + { + var isArg = HasArgumentAttribute(p); + + if (IsInjectedStatic(p)) + return new ParameterModel( + p.Name, + SafeLocalName(p.Name), + Naming.ToCliLongName(p.Name), + ParameterKind.Injected, + BoolSpecialKind.None, + CliScalarKind.Primitive, + "CancellationToken", + null, + ImmutableArray.Empty, + null, + null, + false, + null, + "", + null, + ImmutableArray.Empty); + + var kind = isArg ? ParameterKind.Positional : ParameterKind.Flag; + var bs = ClassifyBool(p.Type); + if (TryUnwrapCollectionType(p.Type, out var elemType) && bs == BoolSpecialKind.None + && TryParserTypeFqFromSymbol(p) is null) + { + var isVariadic = isArg && p.Type is IArrayTypeSymbol; + var defLitColl = TryGetDefaultLiteral(p, BoolSpecialKind.None); + return BuildCollectionParameterModel(p.Type, elemType, p, kind, + Naming.ToCliLongName(p.Name), SafeLocalName(p.Name), p.Name, + isSeparateType: false, defLitColl, "", asParams: null, isVariadic: isVariadic, + reportAcc: reportAcc, reportFallbackLocation: reportFallbackLocation); + } + + ClassifyScalarUnified(p.Type, p, bs, isSeparateType: false, + out var sk, out var typeName, out var enumFq, out var enumMembers, out var parserFq, out var customValFq); + if (reportAcc is not null) + ReportFilesystemPathAttributeIssues(p, sk, p.Name, reportAcc, reportFallbackLocation); + + var required = ComputeRequired(p, bs); + var defLit = TryGetDefaultLiteral(p, bs); + var enumDocs = sk == CliScalarKind.Enum ? TryGetEnumDocs(p.Type) : null; + var enumCliNames = sk == CliScalarKind.Enum ? TryGetEnumCliNames(p.Type) : default; + var validations = ReadValidationConstraints(p, sk, typeName); + var expandProf = TryReadExpandUserProfileBeforeBind(p, sk); + var (isOutputP, outputFormatsP) = TryGetCommandOutputAttribute(p); + var (isDeprecatedP, deprecationMsgP) = TryGetObsoleteAttribute(p); + return new ParameterModel( + p.Name, + SafeLocalName(p.Name), + Naming.ToCliLongName(p.Name), + kind, + bs, + sk, + typeName, + enumFq, + enumMembers, + parserFq, + customValFq, + required, + defLit, + "", + null, + ImmutableArray.Empty, + EnumMemberCliNames: enumCliNames, + EnumMemberDocs: enumDocs, + ExpandUserProfileBeforeBind: expandProf, + Validations: validations, + IsHidden: HasHiddenAttribute(p), + IsConfirmationSkip: HasConfirmationSkipAttribute(p), + IsDryRun: HasDryRunAttribute(p), + IsCommandOutput: isOutputP, + CommandOutputExplicitFormats: outputFormatsP, + IsDeprecated: isDeprecatedP, + DeprecationMessage: deprecationMsgP); + } + + public static ParameterModel FromOptionsProperty(IPropertySymbol prop, Compilation? compilation = null, string? defaultValueLiteral = null) + { + var rawSummary = Documentation.GetPropertySummaryLine(prop, compilation, TryExtractFullDocumentationFromPropertyTrivia(prop)); + var doc = ParseOptionsFlagDocumentation(rawSummary); + var derivedLongNameProp = Naming.ToCliLongName(prop.Name); + var effectiveLongNameProp = doc.ExplicitLongName ?? derivedLongNameProp; + var bs = ClassifyBool(prop.Type); + if (TryUnwrapCollectionType(prop.Type, out var elemType) && bs == BoolSpecialKind.None + && TryParserTypeFqFromSymbol(prop) is null) + { + return BuildCollectionParameterModel(prop.Type, elemType, prop, ParameterKind.Flag, + effectiveLongNameProp, SafeLocalName(prop.Name), prop.Name, + isSeparateType: true, defaultLiteral: null, doc.Description, asParams: null, + flagShortOpt: doc.ShortOpt, synopsisAliasesFromSummary: doc.Aliases); + } + + ClassifyScalarUnified(prop.Type, prop, bs, isSeparateType: true, + out var sk, out var typeName, out var enumFq, out var enumMembers, out var parserFq, out var customValFq); + // A property initializer supplies a CLI default: the flag is not required on the command line. + // For cross-assembly types, DeclaringSyntaxReferences is empty so we can't read the initializer + // expression from syntax. Mark the property as using a runtime default instead of "required". + var isCrossAssemblyDefault = defaultValueLiteral is null && prop.DeclaringSyntaxReferences.IsEmpty; + var required = !isCrossAssemblyDefault && ComputeRequiredForOptionsType(prop.Type, bs) && defaultValueLiteral is null; + var enumDocs = sk == CliScalarKind.Enum ? TryGetEnumDocs(prop.Type) : null; + var enumCliNames = sk == CliScalarKind.Enum ? TryGetEnumCliNames(prop.Type) : default; + var validations = ReadValidationConstraints(prop, sk, typeName); + var defLit = QualifyOptionsEnumDefaultLiteral(defaultValueLiteral, sk, enumFq, enumMembers); + var expandProf = TryReadExpandUserProfileBeforeBind(prop, sk); + return new ParameterModel( + prop.Name, + SafeLocalName(prop.Name), + effectiveLongNameProp, + ParameterKind.Flag, + bs, + sk, + typeName, + enumFq, + enumMembers, + parserFq, + customValFq, + required, + defLit, + doc.Description, + doc.ShortOpt, + doc.Aliases, + EnumMemberCliNames: enumCliNames, + EnumMemberDocs: enumDocs, + ExpandUserProfileBeforeBind: expandProf, + Validations: validations, + IsHidden: HasHiddenAttribute(prop), + UsesRuntimeDefault: isCrossAssemblyDefault, + IsNullableAnnotated: prop.Type.NullableAnnotation == NullableAnnotation.Annotated + || prop.Type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T }, + IsConfirmationSkip: HasConfirmationSkipAttribute(prop), + IsDryRun: HasDryRunAttribute(prop), + IsCommandOutput: TryGetCommandOutputAttribute(prop).IsOutput, + CommandOutputExplicitFormats: TryGetCommandOutputAttribute(prop).ExplicitFormats, + IsDeprecated: TryGetObsoleteAttribute(prop).IsDeprecated, + DeprecationMessage: TryGetObsoleteAttribute(prop).Message); + } + + public static ParameterModel FromOptionsField(IFieldSymbol field, Compilation? compilation = null, string? defaultValueLiteral = null) + { + var rawSummary = Documentation.GetFieldSummaryLine(field, compilation, TryExtractFullDocumentationFromFieldTrivia(field)); + var doc = ParseOptionsFlagDocumentation(rawSummary); + var derivedLongNameField = Naming.ToCliLongName(field.Name); + var effectiveLongNameField = doc.ExplicitLongName ?? derivedLongNameField; + var bs = ClassifyBool(field.Type); + if (TryUnwrapCollectionType(field.Type, out var elemType) && bs == BoolSpecialKind.None + && TryParserTypeFqFromSymbol(field) is null) + { + return BuildCollectionParameterModel(field.Type, elemType, field, ParameterKind.Flag, + effectiveLongNameField, SafeLocalName(field.Name), field.Name, + isSeparateType: true, defaultLiteral: null, doc.Description, asParams: null, + flagShortOpt: doc.ShortOpt, synopsisAliasesFromSummary: doc.Aliases); + } + + ClassifyScalarUnified(field.Type, field, bs, isSeparateType: true, + out var sk, out var typeName, out var enumFq, out var enumMembers, out var parserFq, out var customValFq); + var isCrossAssemblyDefault = defaultValueLiteral is null && field.DeclaringSyntaxReferences.IsEmpty; + var required = !isCrossAssemblyDefault && ComputeRequiredForOptionsType(field.Type, bs) && defaultValueLiteral is null; + var enumCliNames = sk == CliScalarKind.Enum ? TryGetEnumCliNames(field.Type) : default; + var validations = ReadValidationConstraints(field, sk, typeName); + var defLit = QualifyOptionsEnumDefaultLiteral(defaultValueLiteral, sk, enumFq, enumMembers); + var expandProf = TryReadExpandUserProfileBeforeBind(field, sk); + return new ParameterModel( + field.Name, + SafeLocalName(field.Name), + effectiveLongNameField, + ParameterKind.Flag, + bs, + sk, + typeName, + enumFq, + enumMembers, + parserFq, + customValFq, + required, + defLit, + doc.Description, + doc.ShortOpt, + doc.Aliases, + EnumMemberCliNames: enumCliNames, + ExpandUserProfileBeforeBind: expandProf, + Validations: validations, + IsHidden: HasHiddenAttribute(field), + UsesRuntimeDefault: isCrossAssemblyDefault, + IsNullableAnnotated: field.Type.NullableAnnotation == NullableAnnotation.Annotated + || field.Type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T }); + } + public static ParameterModel FromAsParametersCtorParameter( + string methodParamName, + string typeFq, + INamedTypeSymbol containingType, + IParameterSymbol cp, + string namePrefix, + int memberOrder, + Compilation? compilation, + CSharpParseOptions parseOptions, + DiagnosticAccumulator? reportAcc = null, + Location? reportFallbackLocation = null) + { + if (IsInjectedType(cp.Type)) + { + var injCli = namePrefix + Naming.ToCliLongName(cp.Name); + var injLocal = SafeLocalName(methodParamName + "_" + cp.Name); + return new ParameterModel( + cp.Name, + injLocal, + injCli, + ParameterKind.Injected, + BoolSpecialKind.None, + CliScalarKind.Primitive, + "CancellationToken", + null, + ImmutableArray.Empty, + null, + null, + false, + null, + "", + null, + ImmutableArray.Empty, + AsParametersOwnerParamName: methodParamName, + AsParametersMemberOrder: memberOrder, + AsParametersTypeFq: typeFq, + AsParametersUseInit: false, + AsParametersClrName: cp.Name); + } + + var isArg = HasArgumentAttribute(cp); + var kind = isArg ? ParameterKind.Positional : ParameterKind.Flag; + var bs = ClassifyBool(cp.Type); + var cli = namePrefix + Naming.ToCliLongName(cp.Name); + var local = SafeLocalName(methodParamName + "_" + cp.Name); + var desc = Documentation.GetParamDocFromType(containingType, cp.Name, compilation, TryExtractFullDocumentationFromTypeTrivia(containingType)); + if (string.IsNullOrWhiteSpace(desc)) + { + var pxml = cp.GetDocumentationCommentXml(); + if (string.IsNullOrWhiteSpace(pxml)) + pxml = Documentation.GetDocumentationXmlFromMetadataReference(cp, compilation); + if (!string.IsNullOrWhiteSpace(pxml)) + { + desc = Documentation.GetParamDocFromXmlFragment(pxml, cp.Name); + if (string.IsNullOrWhiteSpace(desc)) + desc = Documentation.GetTypeSummaryLine(pxml); + } + } + + if (string.IsNullOrWhiteSpace(desc)) + desc = Documentation.GetTypeSummaryLine(TryExtractDocumentationFromParameterTrivia(cp)); + var meta = new AsParametersMeta(methodParamName, memberOrder, typeFq, UseInit: false, cp.Name); + if (TryUnwrapCollectionType(cp.Type, out var elemType) && bs == BoolSpecialKind.None + && TryParserTypeFqFromSymbol(cp) is null) + { + var isVariadicCp = isArg && cp.Type is IArrayTypeSymbol; + var defLitColl = TryGetDefaultLiteral(cp, BoolSpecialKind.None); + return BuildCollectionParameterModel(cp.Type, elemType, cp, kind, cli, local, cp.Name, + isSeparateType: false, defLitColl, desc, meta, isVariadic: isVariadicCp, + reportAcc: reportAcc, + reportFallbackLocation: cp.Locations.FirstOrDefault() ?? reportFallbackLocation); + } + + ClassifyScalarUnified(cp.Type, cp, bs, isSeparateType: false, + out var sk, out var typeName, out var enumFq, out var enumMembers, out var parserFq, out var customValFq); + if (reportAcc is not null) + ReportFilesystemPathAttributeIssues(cp, sk, cp.Name, reportAcc, + cp.Locations.FirstOrDefault() ?? reportFallbackLocation); + + var required = ComputeRequired(cp, bs); + var defLit = TryGetDefaultLiteral(cp, bs); + var enumCliNames = sk == CliScalarKind.Enum ? TryGetEnumCliNames(cp.Type) : default; + var validations = ReadValidationConstraints(cp, sk, typeName); + var expandProf = TryReadExpandUserProfileBeforeBind(cp, sk); + var (isDeprecatedCp, deprecationMsgCp) = TryGetObsoleteAttribute(cp); + var (isOutputCp, outputFormatsCp) = TryGetCommandOutputAttribute(cp); + return new ParameterModel( + cp.Name, + local, + cli, + kind, + bs, + sk, + typeName, + enumFq, + enumMembers, + parserFq, + customValFq, + required, + defLit, + desc, + null, + ImmutableArray.Empty, + EnumMemberCliNames: enumCliNames, + AsParametersOwnerParamName: methodParamName, + AsParametersMemberOrder: memberOrder, + AsParametersTypeFq: typeFq, + AsParametersUseInit: false, + AsParametersClrName: cp.Name, + ExpandUserProfileBeforeBind: expandProf, + Validations: validations, + IsConfirmationSkip: HasConfirmationSkipAttribute(cp), + IsDryRun: HasDryRunAttribute(cp), + IsCommandOutput: isOutputCp, + CommandOutputExplicitFormats: outputFormatsCp, + IsDeprecated: isDeprecatedCp, + DeprecationMessage: deprecationMsgCp); + } + + public static ParameterModel FromAsParametersInitProperty( + string methodParamName, + string typeFq, + IPropertySymbol prop, + string namePrefix, + int memberOrder, + Compilation? compilation, + CSharpParseOptions parseOptions, + DiagnosticAccumulator? reportAcc = null, + Location? reportFallbackLocation = null) + { + if (IsInjectedType(prop.Type)) + { + var injCli = namePrefix + Naming.ToCliLongName(prop.Name); + var injLocal = SafeLocalName(methodParamName + "_" + prop.Name); + return new ParameterModel( + prop.Name, + injLocal, + injCli, + ParameterKind.Injected, + BoolSpecialKind.None, + CliScalarKind.Primitive, + "CancellationToken", + null, + ImmutableArray.Empty, + null, + null, + false, + null, + "", + null, + ImmutableArray.Empty, + AsParametersOwnerParamName: methodParamName, + AsParametersMemberOrder: memberOrder, + AsParametersTypeFq: typeFq, + AsParametersUseInit: true, + AsParametersClrName: prop.Name); + } + + var isArg = HasArgumentAttribute(prop); + var kind = isArg ? ParameterKind.Positional : ParameterKind.Flag; + var bs = ClassifyBool(prop.Type); + var local = SafeLocalName(methodParamName + "_" + prop.Name); + var rawSummary = Documentation.GetPropertySummaryLine(prop, compilation, TryExtractFullDocumentationFromPropertyTrivia(prop)); + var doc = ParseOptionsFlagDocumentation(rawSummary); + var derivedCli = namePrefix + Naming.ToCliLongName(prop.Name); + var cli = doc.ExplicitLongName is not null ? namePrefix + doc.ExplicitLongName : derivedCli; + var meta = new AsParametersMeta(methodParamName, memberOrder, typeFq, UseInit: true, prop.Name); + if (TryUnwrapCollectionType(prop.Type, out var elemType) && bs == BoolSpecialKind.None + && TryParserTypeFqFromSymbol(prop) is null) + { + var isVariadicProp = isArg && prop.Type is IArrayTypeSymbol; + return BuildCollectionParameterModel(prop.Type, elemType, prop, kind, cli, local, prop.Name, + isSeparateType: true, defaultLiteral: null, doc.Description, meta, + flagShortOpt: doc.ShortOpt, synopsisAliasesFromSummary: doc.Aliases, isVariadic: isVariadicProp, + reportAcc: reportAcc, + reportFallbackLocation: prop.Locations.FirstOrDefault() ?? reportFallbackLocation); + } + + ClassifyScalarUnified(prop.Type, prop, bs, isSeparateType: true, + out var sk, out var typeName, out var enumFq, out var enumMembers, out var parserFq, out var customValFq); + if (reportAcc is not null) + ReportFilesystemPathAttributeIssues(prop, sk, prop.Name, reportAcc, + prop.Locations.FirstOrDefault() ?? reportFallbackLocation); + + var defaultValueLiteral = compilation is not null ? TryGetOptionsPropertyDefaultLiteral(prop, compilation) : null; + // Cross-assembly [AsParameters] types: syntax refs empty, can't read initializer. + var isCrossAssemblyDefault = defaultValueLiteral is null && prop.DeclaringSyntaxReferences.IsEmpty; + var required = !isCrossAssemblyDefault && ComputeRequiredForOptionsType(prop.Type, bs) && defaultValueLiteral is null; + var defLit = QualifyOptionsEnumDefaultLiteral(defaultValueLiteral, sk, enumFq, enumMembers); + var enumCliNames = sk == CliScalarKind.Enum ? TryGetEnumCliNames(prop.Type) : default; + var validations = ReadValidationConstraints(prop, sk, typeName); + var expandProf = TryReadExpandUserProfileBeforeBind(prop, sk); + var (isDeprecatedProp, deprecationMsgProp) = TryGetObsoleteAttribute(prop); + var (isOutputProp, outputFormatsProp) = TryGetCommandOutputAttribute(prop); + return new ParameterModel( + prop.Name, + local, + cli, + kind, + bs, + sk, + typeName, + enumFq, + enumMembers, + parserFq, + customValFq, + required, + defLit, + doc.Description, + doc.ShortOpt, + doc.Aliases, + EnumMemberCliNames: enumCliNames, + AsParametersOwnerParamName: methodParamName, + AsParametersMemberOrder: memberOrder, + AsParametersTypeFq: typeFq, + AsParametersUseInit: true, + AsParametersClrName: prop.Name, + ExpandUserProfileBeforeBind: expandProf, + Validations: validations, + UsesRuntimeDefault: isCrossAssemblyDefault, + IsNullableAnnotated: prop.Type.NullableAnnotation == NullableAnnotation.Annotated + || prop.Type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T }, + IsConfirmationSkip: HasConfirmationSkipAttribute(prop), + IsDryRun: HasDryRunAttribute(prop), + IsCommandOutput: isOutputProp, + CommandOutputExplicitFormats: outputFormatsProp, + IsDeprecated: isDeprecatedProp, + DeprecationMessage: deprecationMsgProp); + } + + private static bool ComputeRequiredForOptionsType(ITypeSymbol type, BoolSpecialKind bs) + { + if (bs == BoolSpecialKind.Bool) + return false; + + if (type.NullableAnnotation == NullableAnnotation.Annotated) + return false; + + if (type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T }) + return false; + + if (type.IsReferenceType && type.NullableAnnotation != NullableAnnotation.Annotated) + return true; + + return type.IsValueType && type.NullableAnnotation != NullableAnnotation.Annotated; + } + + private static void ClassifyScalarForType( + ITypeSymbol type, + ISymbol attributeHost, + BoolSpecialKind bs, + out CliScalarKind kind, + out string primitiveName, + out string? enumFq, + out ImmutableArray enumMembers, + out string? parserFq, + out string? customValueFq) + { + enumFq = null; + enumMembers = ImmutableArray.Empty; + parserFq = null; + customValueFq = null; + if (bs != BoolSpecialKind.None) + { + kind = CliScalarKind.Primitive; + primitiveName = GetSimpleTypeName(type); + return; + } + + parserFq = TryParserTypeFqFromSymbol(attributeHost); + if (parserFq is not null) + { + kind = CliScalarKind.CustomParser; + primitiveName = "custom"; + customValueFq = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + return; + } + + var t = type; + if (t is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nn) + t = nn.TypeArguments[0]; + + if (t.TypeKind == TypeKind.Enum && t is INamedTypeSymbol en) + { + kind = CliScalarKind.Enum; + enumFq = en.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + enumMembers = GetEnumMemberNames(en); + primitiveName = "enum"; + return; + } + + if (t is INamedTypeSymbol named) + { + var fq = named.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + if (fq == "global::System.IO.FileInfo") + { + kind = CliScalarKind.FileInfo; + primitiveName = "FileInfo"; + return; + } + + if (fq == "global::System.IO.DirectoryInfo") + { + kind = CliScalarKind.DirectoryInfo; + primitiveName = "DirectoryInfo"; + return; + } + + if (fq == "global::System.Uri") + { + kind = CliScalarKind.Uri; + primitiveName = "Uri"; + return; + } + } + + kind = CliScalarKind.Primitive; + primitiveName = GetSimpleTypeName(type); + } + + private static string? TryParserTypeFqFromSymbol(ISymbol symbol) + { + foreach (var attr in symbol.GetAttributes()) + { + if (attr.AttributeClass?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) != + "global::Nullean.Argh.ArgumentParserAttribute") + continue; + + if (attr.ConstructorArguments.Length > 0 && + attr.ConstructorArguments[0].Value is INamedTypeSymbol parser) + return parser.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + } + + return null; + } + + private static void ClassifyScalar( + IParameterSymbol p, + BoolSpecialKind bs, + out CliScalarKind kind, + out string primitiveName, + out string? enumFq, + out ImmutableArray enumMembers, + out string? parserFq, + out string? customValueFq) + { + enumFq = null; + enumMembers = ImmutableArray.Empty; + parserFq = null; + customValueFq = null; + if (bs != BoolSpecialKind.None) + { + kind = CliScalarKind.Primitive; + primitiveName = GetSimpleTypeName(p.Type); + return; + } + + parserFq = TryParserTypeFqFromSymbol(p); + if (parserFq is not null) + { + kind = CliScalarKind.CustomParser; + primitiveName = "custom"; + customValueFq = p.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + return; + } + + var t = p.Type; + if (t is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nn) + t = nn.TypeArguments[0]; + + if (t.TypeKind == TypeKind.Enum && t is INamedTypeSymbol en) + { + kind = CliScalarKind.Enum; + enumFq = en.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + enumMembers = GetEnumMemberNames(en); + primitiveName = "enum"; + return; + } + + if (t is INamedTypeSymbol named) + { + var fq = named.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + if (fq == "global::System.IO.FileInfo") + { + kind = CliScalarKind.FileInfo; + primitiveName = "FileInfo"; + return; + } + + if (fq == "global::System.IO.DirectoryInfo") + { + kind = CliScalarKind.DirectoryInfo; + primitiveName = "DirectoryInfo"; + return; + } + + if (fq == "global::System.Uri") + { + kind = CliScalarKind.Uri; + primitiveName = "Uri"; + return; + } + } + + kind = CliScalarKind.Primitive; + primitiveName = GetSimpleTypeName(p.Type); + } + + private static ImmutableArray GetEnumMemberNames(INamedTypeSymbol enumType) + { + var b = ImmutableArray.CreateBuilder(); + foreach (var m in enumType.GetMembers()) + { + if (m is IFieldSymbol { HasConstantValue: true, IsImplicitlyDeclared: false }) + b.Add(m.Name); + } + + return b.ToImmutable(); + } + + private static ImmutableArray GetEnumMemberCliNames(INamedTypeSymbol enumType) + { + var hasAny = false; + var b = ImmutableArray.CreateBuilder(); + foreach (var m in enumType.GetMembers()) + { + if (m is not IFieldSymbol { HasConstantValue: true, IsImplicitlyDeclared: false }) + continue; + string? cliName = null; + foreach (var attr in m.GetAttributes()) + { + if (attr.AttributeClass?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == "global::Nullean.Argh.EnumValueAttribute" + && attr.ConstructorArguments.Length > 0 + && attr.ConstructorArguments[0].Value is string v) + { + cliName = v; + hasAny = true; + break; + } + } + b.Add(cliName ?? m.Name.ToLowerInvariant()); + } + return hasAny ? b.ToImmutable() : default; + } + + private static ImmutableArray TryGetEnumCliNames(ITypeSymbol type) + { + var t = type; + if (t is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nn) + t = nn.TypeArguments[0]; + return t is INamedTypeSymbol { TypeKind: TypeKind.Enum } en ? GetEnumMemberCliNames(en) : default; + } + + private static ImmutableDictionary GetEnumMemberDocs(INamedTypeSymbol enumType) + { + var b = ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); + foreach (var m in enumType.GetMembers()) + { + if (m is not IFieldSymbol { HasConstantValue: true, IsImplicitlyDeclared: false } field) + continue; + var xml = field.GetDocumentationCommentXml(); + if (string.IsNullOrWhiteSpace(xml)) + continue; + try + { + var doc = System.Xml.Linq.XDocument.Parse("" + xml + "", System.Xml.Linq.LoadOptions.PreserveWhitespace); + var summary = Documentation.FlattenBlockPublic(doc.Root?.Element("summary")).Replace("\r\n", "\n").Trim(); + if (!string.IsNullOrWhiteSpace(summary)) + b[field.Name] = summary; + } + catch { } + } + return b.ToImmutable(); + } + + private static bool IsInjectedStatic(IParameterSymbol p) => IsInjectedType(p.Type); + + private static BoolSpecialKind ClassifyBool(ITypeSymbol type) + { + if (type.SpecialType == SpecialType.System_Boolean) + return BoolSpecialKind.Bool; + + if (type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } named && + named.TypeArguments[0].SpecialType == SpecialType.System_Boolean) + return BoolSpecialKind.NullableBool; + + return BoolSpecialKind.None; + } + + private static string GetSimpleTypeName(ITypeSymbol type) + { + if (type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nn) + { + var inner = GetSimpleTypeName(nn.TypeArguments[0]); + if (inner == "bool") + return "bool?"; + return inner + "?"; + } + + if (type.SpecialType == SpecialType.System_String) + return "string"; + if (type.SpecialType == SpecialType.System_Int32) + return "int"; + if (type.SpecialType == SpecialType.System_Int64) + return "long"; + if (type.SpecialType == SpecialType.System_Single) + return "float"; + if (type.SpecialType == SpecialType.System_Double) + return "double"; + if (type.SpecialType == SpecialType.System_Decimal) + return "decimal"; + if (type.SpecialType == SpecialType.System_Boolean) + return "bool"; + + if (type is INamedTypeSymbol named) + { + var fq = named.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + switch (fq) + { + case "global::System.DateTime": + return "DateTime"; + case "global::System.DateTimeOffset": + return "DateTimeOffset"; + case "global::System.TimeSpan": + return "TimeSpan"; + case "global::System.DateOnly": + return "DateOnly"; + } + } + + return "string"; + } + + private static bool ComputeRequired(IParameterSymbol p, BoolSpecialKind bs) + { + if (bs == BoolSpecialKind.Bool) + return false; + + if (p.HasExplicitDefaultValue) + return false; + + if (p.Type.NullableAnnotation == NullableAnnotation.Annotated) + return false; + + if (p.Type.IsReferenceType && p.Type.NullableAnnotation == NullableAnnotation.Annotated) + return false; + + if (p.Type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T }) + return false; + + if (p.Type.IsReferenceType && p.Type.NullableAnnotation != NullableAnnotation.Annotated) + return true; + + return p.Type.IsValueType && !p.HasExplicitDefaultValue && p.Type.NullableAnnotation != NullableAnnotation.Annotated; + } + + private static string? TryGetDefaultLiteral(IParameterSymbol p, BoolSpecialKind bs) + { + if (bs == BoolSpecialKind.Bool) + return "false"; + + if (!p.HasExplicitDefaultValue) + return null; + + var v = p.ExplicitDefaultValue; + if (v is null) + return p.Type.IsReferenceType ? "null" : "default"; + + return v switch + { + string s => SymbolDisplay.FormatPrimitive(s, quoteStrings: true, useHexadecimalNumbers: false), + char ch => SymbolDisplay.FormatPrimitive(ch, quoteStrings: true, useHexadecimalNumbers: false), + bool b => b ? "true" : "false", + IFormattable => Convert.ToString(v, CultureInfo.InvariantCulture) ?? "default", + _ => "default" + }; + } + + private static string SafeLocalName(string name) + { + var k = Naming.ToCliLongName(name).Replace("-", "_"); + if (k.Length == 0) + return "arg"; + if (!char.IsLetter(k[0]) && k[0] != '_') + return "v_" + k; + if (CSharpKeywords.Contains(k)) + return "@" + k; + return k; + } + + private static readonly HashSet CSharpKeywords = new HashSet(StringComparer.Ordinal) + { + "abstract", "as", "base", "bool", "break", "byte", "case", "catch", "char", "checked", + "class", "const", "continue", "decimal", "default", "delegate", "do", "double", "else", + "enum", "event", "explicit", "extern", "false", "finally", "fixed", "float", "for", + "foreach", "goto", "if", "implicit", "in", "int", "interface", "internal", "is", "lock", + "long", "namespace", "new", "null", "object", "operator", "out", "override", "params", + "private", "protected", "public", "readonly", "ref", "return", "sbyte", "sealed", "short", + "sizeof", "stackalloc", "static", "string", "struct", "switch", "this", "throw", "true", + "try", "typeof", "uint", "ulong", "unchecked", "unsafe", "ushort", "using", "virtual", + "void", "volatile", "while" + }; + } + +} diff --git a/src/Nullean.Argh.Generator/CliParserGenerator.SymbolReaders.cs b/src/Nullean.Argh.Generator/CliParserGenerator.SymbolReaders.cs new file mode 100644 index 0000000..748010e --- /dev/null +++ b/src/Nullean.Argh.Generator/CliParserGenerator.SymbolReaders.cs @@ -0,0 +1,757 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Globalization; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Operations; +using Microsoft.CodeAnalysis.Text; + +namespace Nullean.Argh; + +public sealed partial class CliParserGenerator +{ + private static bool HasDefaultCommandAttribute(IMethodSymbol method) + { + foreach (var ad in method.GetAttributes()) + { + if (ad.AttributeClass?.Name == "DefaultCommandAttribute" && + ad.AttributeClass.ContainingNamespace?.ToDisplayString() == "Nullean.Argh") + return true; + } + + return false; + } + + private static bool HasCommandIntrinsicAttribute(IMethodSymbol method) + { + foreach (var ad in method.GetAttributes()) + { + if (ad.AttributeClass?.Name == "CommandIntrinsicAttribute" && + ad.AttributeClass.ContainingNamespace?.ToDisplayString() == "Nullean.Argh") + return true; + } + + return false; + } + + private static bool HasHiddenAttribute(ISymbol symbol) + { + foreach (var ad in symbol.GetAttributes()) + { + if (ad.AttributeClass?.Name == "HiddenAttribute" && + ad.AttributeClass.ContainingNamespace?.ToDisplayString() == "Nullean.Argh") + return true; + } + + return false; + } + + private static string? TryGetCommandNameAttribute(IMethodSymbol method) + { + foreach (var ad in method.GetAttributes()) + { + if (ad.AttributeClass?.Name == "CommandNameAttribute" && + ad.AttributeClass.ContainingNamespace?.ToDisplayString() == "Nullean.Argh" && + ad.ConstructorArguments.Length >= 1 && + ad.ConstructorArguments[0].Value is string name && + !string.IsNullOrWhiteSpace(name)) + return name; + } + + return null; + } + + private static ImmutableArray TryGetCommandAliasesFromAttribute(IMethodSymbol method) + { + foreach (var ad in method.GetAttributes()) + { + if (ad.AttributeClass?.Name == "CommandNameAttribute" && + ad.AttributeClass.ContainingNamespace?.ToDisplayString() == "Nullean.Argh" && + ad.ConstructorArguments.Length >= 2) + { + var aliasArg = ad.ConstructorArguments[1]; + if (aliasArg.Kind == TypedConstantKind.Array) + { + var builder = ImmutableArray.CreateBuilder(); + foreach (var v in aliasArg.Values) + { + if (v.Value is string s && !string.IsNullOrWhiteSpace(s)) + builder.Add(s); + } + return builder.ToImmutable(); + } + } + } + + return ImmutableArray.Empty; + } + + private static (bool IsDeprecated, string? Message) TryGetObsoleteAttribute(ISymbol symbol) + { + foreach (var ad in symbol.GetAttributes()) + { + if (ad.AttributeClass?.Name is "ObsoleteAttribute" or "Obsolete" && + (ad.AttributeClass.ContainingNamespace?.ToDisplayString() is "System" or "")) + { + var msg = ad.ConstructorArguments.Length >= 1 && ad.ConstructorArguments[0].Value is string s && !string.IsNullOrWhiteSpace(s) + ? s + : null; + return (true, msg); + } + } + + return (false, null); + } + + private const string DocNs = "Nullean.Argh.Documentation"; + + private static CommandIntentData? TryGetCommandIntentData(IMethodSymbol method) + { + bool? destructive = null, idempotent = null, requiresConfirmation = null, requiresAuth = null; + string? scope = null; + + foreach (var ad in method.GetAttributes()) + { + if (ad.AttributeClass?.ContainingNamespace?.ToDisplayString() != DocNs) continue; + + switch (ad.AttributeClass.Name) + { + case "CommandIntentAttribute": + { + // Constructor arg 0 is the Intent flags enum (underlying int) + // Destructive=1, Idempotent=2, RequiresConfirmation=4 + var flagsInt = 0; + if (ad.ConstructorArguments.Length >= 1 && ad.ConstructorArguments[0].Value is int f) + flagsInt = f; + if ((flagsInt & 1) != 0) destructive = true; + if ((flagsInt & 2) != 0) idempotent = true; + if ((flagsInt & 4) != 0) requiresConfirmation = true; + break; + } + case "MutationScopeAttribute": + { + // Constructor arg 0 is MutationScope enum: 0=File, 1=Directory, 2=Global + if (ad.ConstructorArguments.Length >= 1 && ad.ConstructorArguments[0].Value is int s) + scope = s switch { 0 => "file", 1 => "directory", 2 => "global", _ => null }; + break; + } + case "RequiresAuthAttribute": + requiresAuth = true; + break; + } + } + + if (destructive is null && idempotent is null && requiresConfirmation is null && requiresAuth is null && scope is null) + return null; + return new CommandIntentData(destructive, idempotent, scope, requiresConfirmation, requiresAuth); + } + + private static (bool IsOutput, ImmutableArray ExplicitFormats) TryGetCommandOutputAttribute(ISymbol symbol) + { + foreach (var ad in symbol.GetAttributes()) + { + if (ad.AttributeClass?.Name == "CommandOutputAttribute" && + ad.AttributeClass.ContainingNamespace?.ToDisplayString() == DocNs) + { + var formats = ImmutableArray.Empty; + if (ad.ConstructorArguments.Length >= 1 && ad.ConstructorArguments[0].Kind == TypedConstantKind.Array) + { + var builder = ImmutableArray.CreateBuilder(); + foreach (var v in ad.ConstructorArguments[0].Values) + { + if (v.Value is string s && !string.IsNullOrWhiteSpace(s)) + builder.Add(s); + } + formats = builder.ToImmutable(); + } + return (true, formats); + } + } + return (false, ImmutableArray.Empty); + } + + private static bool HasConfirmationSkipAttribute(ISymbol symbol) + { + foreach (var ad in symbol.GetAttributes()) + { + if (ad.AttributeClass?.Name == "ConfirmationSkipAttribute" && + ad.AttributeClass.ContainingNamespace?.ToDisplayString() == DocNs) + return true; + } + return false; + } + + private static bool HasDryRunAttribute(ISymbol symbol) + { + foreach (var ad in symbol.GetAttributes()) + { + if (ad.AttributeClass?.Name == "DryRunAttribute" && + ad.AttributeClass.ContainingNamespace?.ToDisplayString() == DocNs) + return true; + } + return false; + } + + private static CommandOutputData? BuildCommandOutputFromParameters(ImmutableArray parameters) + { + foreach (var p in parameters) + { + if (!p.IsCommandOutput) continue; + var flagName = "--" + p.CliLongName; + ImmutableArray formats; + if (!p.CommandOutputExplicitFormats.IsDefaultOrEmpty) + formats = p.CommandOutputExplicitFormats; + else if (p.ScalarKind == CliScalarKind.Enum && !p.EnumMemberNames.IsDefaultOrEmpty) + { + // Resolve CLI names the same way the help/schema emitter does + var builder = ImmutableArray.CreateBuilder(p.EnumMemberNames.Length); + for (var i = 0; i < p.EnumMemberNames.Length; i++) + builder.Add(ResolveEnumMemberCliName(p.EnumMemberCliNames, i, p.EnumMemberNames[i])); + formats = builder.ToImmutable(); + } + else + formats = ImmutableArray.Empty; + return new CommandOutputData(formats, flagName); + } + return null; + } + + private static AIDocumentEnvironmentVariables? AnalyzeDocumentEnvironmentVariables( + InvocationExpressionSyntax invocation, string filePath, int spanStart) + { + var varsBuilder = ImmutableArray.CreateBuilder(); + var cfgBuilder = ImmutableArray.CreateBuilder(); + + foreach (var arg in invocation.ArgumentList.Arguments) + { + var nameColon = arg.NameColon?.Name.Identifier.Text; + + if (arg.Expression is not (ObjectCreationExpressionSyntax or ImplicitObjectCreationExpressionSyntax)) + continue; + + ArgumentListSyntax? ctorArgs = arg.Expression switch + { + ObjectCreationExpressionSyntax o => o.ArgumentList, + ImplicitObjectCreationExpressionSyntax i => i.ArgumentList, + _ => null + }; + if (ctorArgs is null) continue; + + // Determine type from name colon or array element pattern + var typeName = arg.Expression is ObjectCreationExpressionSyntax oce + ? oce.Type.ToString() + : null; + bool isConfigFile = typeName?.Contains("ConfigFile") == true || nameColon == "configFiles"; + + if (isConfigFile) + { + var path = ctorArgs.Arguments.Count >= 1 + ? TryGetStringLiteral(ctorArgs.Arguments[0].Expression) + : null; + if (path is null) continue; + string? desc = null; + bool req = false; + foreach (var ca in ctorArgs.Arguments) + { + var n = ca.NameColon?.Name.Identifier.Text; + if (n == "Description") desc = TryGetStringLiteral(ca.Expression); + if (n == "Required") req = TryGetBoolLiteral(ca.Expression) ?? false; + } + cfgBuilder.Add(new ConfigFileDocEntry(path, desc, req)); + } + else + { + var name = ctorArgs.Arguments.Count >= 1 + ? TryGetStringLiteral(ctorArgs.Arguments[0].Expression) + : null; + if (name is null) continue; + string? desc = null; + bool req = false; + string? defVal = null; + foreach (var ca in ctorArgs.Arguments) + { + var n = ca.NameColon?.Name.Identifier.Text; + if (n == "Description") desc = TryGetStringLiteral(ca.Expression); + if (n == "Required") req = TryGetBoolLiteral(ca.Expression) ?? false; + if (n == "DefaultValue") defVal = TryGetStringLiteral(ca.Expression); + } + varsBuilder.Add(new EnvVarDocEntry(name, desc, req, defVal)); + } + } + + if (varsBuilder.Count == 0 && cfgBuilder.Count == 0) return null; + return new AIDocumentEnvironmentVariables(filePath, spanStart, varsBuilder.ToImmutable(), cfgBuilder.ToImmutable()); + } + + private static bool? TryGetBoolLiteral(ExpressionSyntax expr) => + expr.Kind() switch + { + Microsoft.CodeAnalysis.CSharp.SyntaxKind.TrueLiteralExpression => true, + Microsoft.CodeAnalysis.CSharp.SyntaxKind.FalseLiteralExpression => false, + _ => null + }; + + private static ExpressionSyntax? TryGetPropertyInitializerValueSyntax(IPropertySymbol prop) + { + foreach (var syntaxRef in prop.DeclaringSyntaxReferences) + { + if (syntaxRef.GetSyntax() is PropertyDeclarationSyntax { Initializer: { Value: var expr } }) + return expr; + } + + return null; + } + + private static ExpressionSyntax? TryGetFieldInitializerValueSyntax(IFieldSymbol field) + { + foreach (var syntaxRef in field.DeclaringSyntaxReferences) + { + if (syntaxRef.GetSyntax() is VariableDeclaratorSyntax { Initializer: { Value: var expr } }) + return expr; + } + + return null; + } + + /// + /// Some initializer shapes yield a bare enum member name (e.g. Information). Emit must use a type-qualified form. + /// + private static string? QualifyOptionsEnumDefaultLiteral( + string? literal, + CliScalarKind sk, + string? enumFq, + ImmutableArray enumMembers) + { + if (literal is null || sk != CliScalarKind.Enum || string.IsNullOrEmpty(enumFq)) + return literal; + if (literal.StartsWith("global::", StringComparison.Ordinal) || literal.StartsWith("(", StringComparison.Ordinal)) + return literal; + if (literal.Contains("::", StringComparison.Ordinal)) + return literal; + foreach (var m in enumMembers) + { + if (!string.Equals(m, literal, StringComparison.Ordinal)) + continue; + return enumFq + "." + literal; + } + + return literal; + } + + private static bool EnumConstantValuesEqual(object fieldConst, object literalConst) + { + if (Equals(fieldConst, literalConst)) + return true; + try + { + return Convert.ToDecimal(fieldConst, CultureInfo.InvariantCulture) == + Convert.ToDecimal(literalConst, CultureInfo.InvariantCulture); + } + catch + { + return false; + } + } + + private static string? TryFormatInitializerOperation(IOperation? op, INamedTypeSymbol? enumTypeHint = null) + { + while (op is IConversionOperation conv) + op = conv.Operand; + while (op is IParenthesizedOperation paren) + op = paren.Operand; + + switch (op) + { + case IFieldReferenceOperation { Field: var ef } when ef.IsStatic && (ef.HasConstantValue || ef.ContainingType?.TypeKind == TypeKind.Enum): + return ef.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + case ILiteralOperation lit when lit.ConstantValue.HasValue && lit.ConstantValue.Value is { } v: + { + var enm = lit.Type is INamedTypeSymbol litEnum && litEnum.TypeKind == TypeKind.Enum + ? litEnum + : enumTypeHint is { TypeKind: TypeKind.Enum } hintEnum + ? hintEnum + : null; + if (enm is not null) + { + foreach (var m in enm.GetMembers()) + { + if (m is not IFieldSymbol fld || !fld.HasConstantValue) + continue; + if (EnumConstantValuesEqual(fld.ConstantValue, lit.ConstantValue.Value)) + return fld.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + } + + return null; + } + + return v switch + { + string s => SymbolDisplay.FormatPrimitive(s, quoteStrings: true, useHexadecimalNumbers: false), + char ch => SymbolDisplay.FormatPrimitive(ch, quoteStrings: true, useHexadecimalNumbers: false), + bool b => b ? "true" : "false", + IFormattable => Convert.ToString(v, CultureInfo.InvariantCulture) ?? "default", + _ => "default" + }; + } + default: + return null; + } + } + + /// + /// Resolves a for even when it does not belong to + /// directly. In multi-project solution builds (e.g. Rider/VS design-time + /// builds, which use instead of metadata for ProjectReferences), a + /// symbol's DeclaringSyntaxReferences can point at a syntax tree that only lives in a *referenced* + /// project's compilation. Calling compilation.GetSemanticModel on such a tree throws + /// ("SyntaxTree is not part of the compilation"). We walk compilation + /// references to find the compilation that actually owns the tree, and return null if none does + /// (e.g. plain metadata references) so callers can degrade gracefully instead of crashing the generator. + /// + private static SemanticModel? TryGetSemanticModelForSyntaxTree(Compilation compilation, SyntaxTree tree) + { + if (compilation.ContainsSyntaxTree(tree)) + return compilation.GetSemanticModel(tree); + + foreach (var reference in compilation.References) + { + if (reference is not CompilationReference compilationReference) + continue; + var model = TryGetSemanticModelForSyntaxTree(compilationReference.Compilation, tree); + if (model is not null) + return model; + } + + return null; + } + + private static string? TryFormatOptionsInitializerExpression(Compilation compilation, ExpressionSyntax expr, ITypeSymbol? enumTypeHint = null) + { + var model = TryGetSemanticModelForSyntaxTree(compilation, expr.SyntaxTree); + if (model is null) + return null; + var hint = enumTypeHint is INamedTypeSymbol namedHint && namedHint.TypeKind == TypeKind.Enum ? namedHint : null; + var fromOp = TryFormatInitializerOperation(model.GetOperation(expr), hint); + if (fromOp is not null) + return fromOp; + + // Fallback when IOperation shape is unexpected (e.g. some enum constant shapes in property initializers). + var sym = model.GetSymbolInfo(expr).Symbol; + if (sym is IFieldSymbol { ContainingType.TypeKind: TypeKind.Enum } ef) + return ef.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + return null; + } + + private static string? TryGetOptionsPropertyDefaultLiteral(IPropertySymbol prop, Compilation compilation) => + TryGetPropertyInitializerValueSyntax(prop) is { } expr ? TryFormatOptionsInitializerExpression(compilation, expr, prop.Type) : null; + + private static string? TryGetOptionsFieldDefaultLiteral(IFieldSymbol field, Compilation compilation) => + TryGetFieldInitializerValueSyntax(field) is { } expr ? TryFormatOptionsInitializerExpression(compilation, expr, field.Type) : null; + + private static OptionsTypeModel? BuildOptionsTypeModel(INamedTypeSymbol type, Compilation compilation) + { + var members = ImmutableArray.CreateBuilder(); + foreach (var member in type.GetMembers()) + { + switch (member) + { + case IPropertySymbol prop when prop.DeclaredAccessibility == Accessibility.Public && !prop.IsStatic: + { + if (prop.IsIndexer) + continue; + if (prop.GetMethod is null || prop.SetMethod is null) + continue; + members.Add(ParameterModel.FromOptionsProperty(prop, compilation, TryGetOptionsPropertyDefaultLiteral(prop, compilation))); + break; + } + case IFieldSymbol field when field.DeclaredAccessibility == Accessibility.Public && !field.IsStatic: + members.Add(ParameterModel.FromOptionsField(field, compilation, TryGetOptionsFieldDefaultLiteral(field, compilation))); + break; + } + } + + var typeFq = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var typeMetaName = GetMetadataNameStatic(type); + var baseNames = CollectBaseTypeMetadataNames(type); + var flattenedMembers = BuildFlattenedOptionsMembers(type, compilation); + var bestCtorParamOrder = ComputeBestCtorParamOrder(type, members.Count > 0 ? members.ToImmutable() : ImmutableArray.Empty); + var isPublic = type.DeclaredAccessibility == Accessibility.Public; + var isGeneric = type.TypeParameters.Length > 0; + + if (members.Count == 0) + return new OptionsTypeModel(typeFq, typeMetaName, baseNames, ImmutableArray.Empty, flattenedMembers, bestCtorParamOrder, isPublic, isGeneric); + + return new OptionsTypeModel(typeFq, typeMetaName, baseNames, members.ToImmutable(), flattenedMembers, bestCtorParamOrder, isPublic, isGeneric); + } + + private static ImmutableArray BuildFlattenedOptionsMembers(INamedTypeSymbol type, Compilation compilation) + { + var chain = new List(); + for (var t = type; t is not null && t.SpecialType != SpecialType.System_Object; t = t.BaseType) + chain.Add(t); + + var members = ImmutableArray.CreateBuilder(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + for (var i = chain.Count - 1; i >= 0; i--) + { + var tt = chain[i]; + foreach (var member in tt.GetMembers()) + { + switch (member) + { + case IPropertySymbol prop when prop.DeclaredAccessibility == Accessibility.Public && !prop.IsStatic: + { + if (prop.IsIndexer) + continue; + if (prop.GetMethod is null || prop.SetMethod is null) + continue; + if (!seen.Add(prop.Name)) + continue; + + members.Add(ParameterModel.FromOptionsProperty(prop, compilation, TryGetOptionsPropertyDefaultLiteral(prop, compilation))); + break; + } + case IFieldSymbol field when field.DeclaredAccessibility == Accessibility.Public && !field.IsStatic: + { + if (!seen.Add(field.Name)) + continue; + + members.Add(ParameterModel.FromOptionsField(field, compilation, TryGetOptionsFieldDefaultLiteral(field, compilation))); + break; + } + } + } + } + + return members.ToImmutable(); + } + + /// Pre-computes the parameter name order for the best public non-empty constructor (for symbol-free emit). + private static ImmutableArray? ComputeBestCtorParamOrder(INamedTypeSymbol type, ImmutableArray members) + { + if (members.IsDefaultOrEmpty) + return null; + var byName = new HashSet(members.Select(m => m.SymbolName), StringComparer.OrdinalIgnoreCase); + IMethodSymbol? bestCtor = null; + foreach (var ctor in type.InstanceConstructors) + { + if (ctor.DeclaredAccessibility != Accessibility.Public) continue; + if (ctor.Parameters.Length == 0) continue; + if (!ctor.Parameters.All(p => byName.Contains(p.Name))) continue; + if (bestCtor is null || ctor.Parameters.Length > bestCtor.Parameters.Length) + bestCtor = ctor; + } + if (bestCtor is null || bestCtor.Parameters.Length != members.Length) + return null; + var b = ImmutableArray.CreateBuilder(bestCtor.Parameters.Length); + foreach (var p in bestCtor.Parameters) + b.Add(p.Name); + return b.MoveToImmutable(); + } + + private static string GetMetadataNameStatic(ITypeSymbol t) => + t.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat); + + private static ImmutableArray CollectBaseTypeMetadataNames(INamedTypeSymbol type) + { + var b = ImmutableArray.CreateBuilder(); + var current = type.BaseType; + while (current is not null && current.SpecialType != SpecialType.System_Object) + { + b.Add(GetMetadataNameStatic(current)); + current = current.BaseType; + } + foreach (var iface in type.AllInterfaces) + b.Add(GetMetadataNameStatic(iface)); + return b.ToImmutable(); + } + + + + private static bool IsInjectedType(ITypeSymbol type) + { + if (type is INamedTypeSymbol named && named.TypeKind == TypeKind.Struct) + { + var fq = named.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + return fq == "global::System.Threading.CancellationToken"; + } + + return false; + } + + private static bool IsInjected(IParameterSymbol p) => IsInjectedType(p.Type); + + private static bool HasArgumentAttribute(IParameterSymbol p) + { + foreach (var attr in p.GetAttributes()) + { + if (attr.AttributeClass?.Name == "ArgumentAttribute") + return true; + } + + return false; + } + + private static bool HasArgumentAttribute(IPropertySymbol p) + { + foreach (var attr in p.GetAttributes()) + { + if (attr.AttributeClass?.Name == "ArgumentAttribute") + return true; + } + + return false; + } + + private static bool HasAsParametersAttribute(IParameterSymbol p) + { + foreach (var attr in p.GetAttributes()) + { + if (attr.AttributeClass?.Name == "AsParametersAttribute") + return true; + } + + return false; + } + + private static string? GetAsParametersPrefix(IParameterSymbol p) + { + foreach (var attr in p.GetAttributes()) + { + if (attr.AttributeClass?.Name != "AsParametersAttribute") + continue; + if (attr.ConstructorArguments.Length > 0 && attr.ConstructorArguments[0].Value is string s && !string.IsNullOrWhiteSpace(s)) + return s.Trim(); + } + + return null; + } + + private static bool TryUnwrapCollectionType(ITypeSymbol type, out ITypeSymbol elementType) + { + elementType = null!; + switch (type) + { + case IArrayTypeSymbol arr: + elementType = arr.ElementType; + return true; + case INamedTypeSymbol named: + { + var def = named.OriginalDefinition; + var fq = def.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + if (fq is "global::System.Collections.Generic.IEnumerable" + or "global::System.Collections.Generic.IReadOnlyList" + or "global::System.Collections.Generic.IReadOnlySet" + or "global::System.Collections.Generic.List") + { + if (named.TypeArguments.Length == 1) + { + elementType = named.TypeArguments[0]; + return true; + } + } + + return false; + } + default: + return false; + } + } + + private static string? TryGetCollectionSeparatorFromAttribute(ISymbol symbol) + { + var fromSymbol = TryGetCollectionSeparatorFromSymbol(symbol); + if (fromSymbol is not null) + return fromSymbol; + + // Positional record members can target [CollectionSyntax] at the synthesized property + // ([property: ...]) instead of the constructor parameter ([param: ...]). + if (symbol is IParameterSymbol { ContainingSymbol: IMethodSymbol { MethodKind: MethodKind.Constructor } ctor } ctorParam) + { + var mirroredProperty = TryFindCtorMirroredProperty(ctor.ContainingType, ctorParam.Name); + if (mirroredProperty is not null) + return TryGetCollectionSeparatorFromSymbol(mirroredProperty); + } + + return null; + } + + private static string? TryGetCollectionSeparatorFromSymbol(ISymbol symbol) + { + foreach (var attr in symbol.GetAttributes()) + { + if (attr.AttributeClass?.Name != "CollectionSyntaxAttribute") + continue; + foreach (var na in attr.NamedArguments) + { + if (na.Key == "Separator" && na.Value.Value is string s && s.Length > 0) + return s; + } + } + + return null; + } + + private static IPropertySymbol? TryFindCtorMirroredProperty(INamedTypeSymbol type, string ctorParameterName) + { + foreach (var member in type.GetMembers()) + { + if (member is not IPropertySymbol prop) + continue; + if (!string.Equals(prop.Name, ctorParameterName, StringComparison.OrdinalIgnoreCase)) + continue; + return prop; + } + + return null; + } + + private static IMethodSymbol? TryGetPrimaryConstructor(INamedTypeSymbol type) + { + IMethodSymbol? best = null; + foreach (var m in type.GetMembers()) + { + if (m is not IMethodSymbol { MethodKind: MethodKind.Constructor } ctor) + continue; + if (ctor.IsStatic) + continue; + if (ctor.DeclaredAccessibility != Accessibility.Public) + continue; + if (best is null || ctor.Parameters.Length > best.Parameters.Length) + best = ctor; + } + + return best; + } + + private static bool IsInitOnlySettable(IPropertySymbol prop) + { + if (prop.IsStatic) + return false; + if (prop.GetMethod is null) + return false; + var set = prop.SetMethod; + if (set is null) + return false; + return set.IsInitOnly; + } + + /// Properties eligible for [AsParameters] object-initializer binding (init or normal setter). + private static bool IsSettableForAsParameters(IPropertySymbol prop) + { + if (prop.IsStatic) + return false; + if (prop.GetMethod is null) + return false; + return prop.SetMethod is not null; + } + + +} diff --git a/src/Nullean.Argh.Generator/CliParserGenerator.Validation.cs b/src/Nullean.Argh.Generator/CliParserGenerator.Validation.cs new file mode 100644 index 0000000..2954b38 --- /dev/null +++ b/src/Nullean.Argh.Generator/CliParserGenerator.Validation.cs @@ -0,0 +1,523 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Globalization; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Operations; +using Microsoft.CodeAnalysis.Text; + +namespace Nullean.Argh; + +public sealed partial class CliParserGenerator +{ + private static void ReportBoolNegationSwitchConflictsAcc( + DiagnosticAccumulator acc, + Location fallbackLocation, + ImmutableArray parameters, + IMethodSymbol method) + { + var locByParamName = new Dictionary(StringComparer.Ordinal); + foreach (var sym in method.Parameters) + { + if (sym.Locations.Length == 0) + continue; + var loc = sym.Locations[0]; + if (loc.IsInSource) + locByParamName[sym.Name] = loc; + } + + foreach (var nullable in parameters) + { + if (nullable.Kind != ParameterKind.Flag || nullable.Special != BoolSpecialKind.NullableBool) + continue; + var negCli = "no-" + nullable.CliLongName; + foreach (var plain in parameters) + { + if (plain.Kind != ParameterKind.Flag || plain.Special != BoolSpecialKind.Bool) + continue; + if (!string.Equals(plain.CliLongName, negCli, StringComparison.OrdinalIgnoreCase)) + continue; + var loc = locByParamName.TryGetValue(plain.SymbolName, out var l) ? l : fallbackLocation; + acc.Add(BoolFlagCollidesWithNullableNegation, loc, plain.SymbolName, plain.CliLongName); + } + } + } + + + + + /// DiagnosticAccumulator-based overload for Select-step analysis. + private static void ReportDuplicateCliNamesAcc(DiagnosticAccumulator acc, Location location, ImmutableArray parameters) + { + var seen = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var p in parameters) + { + if (p.Kind != ParameterKind.Flag) continue; + void check(string name) + { + if (string.IsNullOrEmpty(name)) return; + if (seen.TryGetValue(name, out var first)) + { + if (!string.Equals(first, p.SymbolName, StringComparison.Ordinal)) + acc.Add(DuplicateCliNames, location, name); + } + else + seen[name] = p.SymbolName; + } + check(p.CliLongName); + foreach (var al in p.Aliases) check(al); + if (p.Special == BoolSpecialKind.NullableBool) check("no-" + p.CliLongName); + } + } + + /// + /// Each generated TryApplyShortFlag groups all flag-like params in one scope (global prefetch, namespace + /// prefetch, or a single handler). Duplicate single-letter shortcuts would emit invalid duplicate case labels. + /// + private static void ValidateDuplicateShortOptionLetters(SourceProductionContext context, AppEmitModel app) + { + if (app.GlobalOptionsModel is { FlattenedMembers: var gm } && !gm.IsDefaultOrEmpty) + ReportDuplicateShortsAmongMembers(context, Location.None, gm, "global options"); + + static void walkNs(RegistryNode node, SourceProductionContext ctx) + { + if (node.CommandNamespaceOptionsModel is { FlattenedMembers: var nm } && !nm.IsDefaultOrEmpty) + { + var loc = node.CommandNamespaceOptionsLocation ?? Location.None; + ReportDuplicateShortsAmongMembers(ctx, loc, nm, "namespace-scoped options"); + } + + foreach (var ch in node.Children) + walkNs(ch.Node, ctx); + } + + walkNs(app.Root, context); + + foreach (var cmd in app.AllCommands) + { + if (cmd.Parameters.IsDefaultOrEmpty) + continue; + var loc = cmd.HandlerSpanInfo.ToLocation(); + var scope = + cmd.RoutePrefix.IsDefaultOrEmpty + ? $"command '{cmd.CommandName}'" + : $"command '{string.Join(" ", cmd.RoutePrefix)} {cmd.CommandName}'"; + ReportDuplicateShortsAmongMembers(context, loc, cmd.Parameters, scope); + } + } + + private static void ReportDuplicateShortsAmongMembers( + SourceProductionContext context, + Location location, + ImmutableArray members, + string scopeDescription) + { + var byChar = new Dictionary(); + foreach (var p in members) + { + if (!IsEmittedFlagLike(p.Kind)) + continue; + if (p.ShortOpt is not char ch) + continue; + if (byChar.TryGetValue(ch, out var firstLong)) + { + context.ReportDiagnostic(Diagnostic.Create( + DuplicateShortOption, + location, + ch.ToString(), + firstLong, + p.CliLongName, + scopeDescription)); + } + else + { + byChar[ch] = p.CliLongName; + } + } + } + + /// DiagnosticAccumulator-based overload for Select-step analysis. + private static void ValidateExpandedParameterLayoutAcc(DiagnosticAccumulator acc, Location location, ImmutableArray expanded) + { + var seenFlag = false; + foreach (var p in expanded) + { + if (p.Kind == ParameterKind.Injected) continue; + if (p.Kind == ParameterKind.Flag) { seenFlag = true; continue; } + // A variadic positional is allowed after flags — C# requires params to be last. + if (p.Kind == ParameterKind.Positional && seenFlag && !p.IsVariadic) + { + acc.Add(ArgumentOrder, location); + return; + } + } + } + + private static void ValidateVariadicPositionalIsLastAcc(DiagnosticAccumulator acc, Location location, ImmutableArray parameters) + { + var sawVariadic = false; + foreach (var p in parameters) + { + if (p.Kind != ParameterKind.Positional) continue; + if (sawVariadic) { acc.Add(VariadicMustBeLastPositional, location); return; } + if (p.IsVariadic) sawVariadic = true; + } + } + + /// DiagnosticAccumulator-based overload for Select-step analysis. + private static ImmutableArray FlattenAsParametersTypeAcc( + DiagnosticAccumulator acc, + Location location, + IParameterSymbol methodParam, + INamedTypeSymbol type, + string? prefix, + Compilation? compilation, + CSharpParseOptions parseOptions) + { + var pfx = string.IsNullOrWhiteSpace(prefix) ? "" : Naming.ToCliLongName(prefix!.Trim()) + "-"; + var owner = methodParam.Name; + var typeFq = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var primary = TryGetPrimaryConstructor(type); + var ctorNames = new HashSet(StringComparer.Ordinal); + var list = new List(); + var order = 0; + if (primary is not null) + { + foreach (var cp in primary.Parameters) + { + ctorNames.Add(cp.Name); + list.Add(ParameterModel.FromAsParametersCtorParameter(owner, typeFq, type, cp, pfx, order++, compilation, parseOptions, + acc, + location)); + } + } + var chain = new List(); + for (var t = type; t is not null && t.SpecialType != SpecialType.System_Object; t = t.BaseType) + chain.Add(t); + var seenPropNames = new HashSet(StringComparer.Ordinal); + for (var i = chain.Count - 1; i >= 0; i--) + { + var tt = chain[i]; + foreach (var member in tt.GetMembers()) + { + if (member is not IPropertySymbol prop) continue; + if (prop.DeclaredAccessibility != Accessibility.Public || prop.IsStatic || prop.IsIndexer) continue; + if (!IsSettableForAsParameters(prop)) continue; + if (ctorNames.Contains(prop.Name)) continue; + if (!seenPropNames.Add(prop.Name)) continue; + list.Add(ParameterModel.FromAsParametersInitProperty(methodParamName: owner, typeFq, prop, pfx, order++, compilation, parseOptions, + acc, + location)); + } + } + if (list.Count == 0) + acc.Add(AsParametersEmptyType, location, type.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat)); + return list.ToImmutableArray(); + } + + + private static string? TryGetStringLiteral(ExpressionSyntax expr) => + expr switch + { + LiteralExpressionSyntax { RawKind: (int)SyntaxKind.StringLiteralExpression } lit => lit.Token.ValueText, + _ => null + }; + + /// Unique per compilation assembly so generated CLI types do not collide across referenced assemblies (e.g. CS0436 with InternalsVisibleTo). + private static ImmutableArray ReadValidationConstraints(ISymbol attributeHost, CliScalarKind scalarKind, + string primitiveTypeName, bool isCollection = false, CliScalarKind? filesystemScalarKind = null) + { + var fsKind = filesystemScalarKind ?? scalarKind; + var builder = ImmutableArray.CreateBuilder(); + foreach (var attr in attributeHost.GetAttributes()) + { + var fqn = attr.AttributeClass?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) ?? ""; + switch (fqn) + { + case "global::System.ComponentModel.DataAnnotations.RangeAttribute": + if (attr.ConstructorArguments.Length >= 2) + builder.Add(new RangeConstraint(attr.ConstructorArguments[0].ToCSharpString(), attr.ConstructorArguments[1].ToCSharpString())); + break; + case "global::Nullean.Argh.TimeSpanRangeAttribute": + if (primitiveTypeName is "TimeSpan" or "TimeSpan?" && + attr.ConstructorArguments.Length >= 2) + builder.Add(new TimeSpanRangeConstraint( + attr.ConstructorArguments[0].ToCSharpString(), + attr.ConstructorArguments[1].ToCSharpString())); + break; + case "global::System.ComponentModel.DataAnnotations.StringLengthAttribute": + if (attr.ConstructorArguments.Length >= 1) + { + var max = (int?)(int?)attr.ConstructorArguments[0].Value; + int? min = null; + foreach (var n in attr.NamedArguments) + if (n.Key == "MinimumLength") min = (int?)n.Value.Value; + if (isCollection) + builder.Add(new CollectionCountConstraint(min, max)); + else + builder.Add(new StringLengthConstraint(min, max)); + } + break; + case "global::System.ComponentModel.DataAnnotations.MinLengthAttribute": + if (attr.ConstructorArguments.Length >= 1) + { + if (isCollection) + builder.Add(new CollectionCountConstraint((int?)attr.ConstructorArguments[0].Value, null)); + else + builder.Add(new StringLengthConstraint((int?)attr.ConstructorArguments[0].Value, null)); + } + break; + case "global::System.ComponentModel.DataAnnotations.MaxLengthAttribute": + if (attr.ConstructorArguments.Length >= 1) + { + if (isCollection) + builder.Add(new CollectionCountConstraint(null, (int?)attr.ConstructorArguments[0].Value)); + else + builder.Add(new StringLengthConstraint(null, (int?)attr.ConstructorArguments[0].Value)); + } + break; + case "global::System.ComponentModel.DataAnnotations.LengthAttribute": + if (attr.ConstructorArguments.Length >= 2) + { + if (isCollection) + builder.Add(new CollectionCountConstraint((int?)attr.ConstructorArguments[0].Value, (int?)attr.ConstructorArguments[1].Value)); + else + builder.Add(new StringLengthConstraint((int?)attr.ConstructorArguments[0].Value, (int?)attr.ConstructorArguments[1].Value)); + } + break; + case "global::System.ComponentModel.DataAnnotations.RegularExpressionAttribute": + if (attr.ConstructorArguments.Length >= 1 && attr.ConstructorArguments[0].Value is string pat) + builder.Add(new RegexConstraint(pat)); + break; + case "global::System.ComponentModel.DataAnnotations.AllowedValuesAttribute": + if (attr.ConstructorArguments.Length >= 1 && attr.ConstructorArguments[0].Kind == TypedConstantKind.Array) + { + var vals = attr.ConstructorArguments[0].Values.Select(v => v.ToCSharpString()).ToImmutableArray(); + if (!vals.IsEmpty) builder.Add(new AllowedValuesConstraint(vals)); + } + break; + case "global::System.ComponentModel.DataAnnotations.DeniedValuesAttribute": + if (attr.ConstructorArguments.Length >= 1 && attr.ConstructorArguments[0].Kind == TypedConstantKind.Array) + { + var vals = attr.ConstructorArguments[0].Values.Select(v => v.ToCSharpString()).ToImmutableArray(); + if (!vals.IsEmpty) builder.Add(new DeniedValuesConstraint(vals)); + } + break; + case "global::System.ComponentModel.DataAnnotations.EmailAddressAttribute": + builder.Add(new EmailConstraint()); + break; + case "global::System.ComponentModel.DataAnnotations.UrlAttribute": + if (scalarKind == CliScalarKind.Uri) + builder.Add(new UriSchemeConstraint(ImmutableArray.Create("http", "https"))); + else + builder.Add(new UrlConstraint()); + break; + case "global::System.ComponentModel.DataAnnotations.FileExtensionsAttribute": + { + if (fsKind != CliScalarKind.FileInfo) + break; + string? extsStr = null; + foreach (var n in attr.NamedArguments) + if (n.Key == "Extensions") extsStr = n.Value.Value as string; + extsStr ??= "png,jpg,jpeg,gif"; + var exts = extsStr.Split(',').Select(e => e.Trim().TrimStart('.')).ToImmutableArray(); + builder.Add(new FileExtensionsConstraint(exts)); + break; + } + case "global::Nullean.Argh.UriSchemeAttribute": + if (attr.ConstructorArguments.Length >= 1 && attr.ConstructorArguments[0].Kind == TypedConstantKind.Array) + { + var schemes = attr.ConstructorArguments[0].Values + .Select(v => v.Value as string).Where(s => s is not null).Select(s => s!) + .ToImmutableArray(); + if (!schemes.IsEmpty) builder.Add(new UriSchemeConstraint(schemes)); + } + break; + case "global::Nullean.Argh.ExistingAttribute": + if (fsKind is CliScalarKind.FileInfo or CliScalarKind.DirectoryInfo) + builder.Add(new ExistingPathConstraint()); + break; + case "global::Nullean.Argh.NonExistingAttribute": + if (fsKind is CliScalarKind.FileInfo or CliScalarKind.DirectoryInfo) + builder.Add(new NonExistingPathConstraint()); + break; + case "global::Nullean.Argh.RejectSymbolicLinksAttribute": + if (fsKind is CliScalarKind.FileInfo or CliScalarKind.DirectoryInfo) + builder.Add(new RejectSymbolicLinksConstraint()); + break; + } + } + return OrderPathValidations(builder.ToImmutable()); + } + + private static ImmutableArray OrderPathValidations(ImmutableArray validations) + { + if (validations.IsDefaultOrEmpty) + return validations; + + var hasReject = false; + foreach (var c in validations) + { + if (c is RejectSymbolicLinksConstraint) + { + hasReject = true; + break; + } + } + + if (!hasReject) + return validations; + + var b = ImmutableArray.CreateBuilder(validations.Length); + foreach (var c in validations) + { + if (c is RejectSymbolicLinksConstraint) + b.Add(c); + } + + foreach (var c in validations) + { + if (c is not RejectSymbolicLinksConstraint) + b.Add(c); + } + + return b.ToImmutable(); + } + + private static string ResolveEnumMemberCliName(ImmutableArray cliNames, int index, string memberName) + => !cliNames.IsDefaultOrEmpty ? cliNames[index] : memberName.ToLowerInvariant(); + + private static string? BuildValidationLine(ParameterModel p) + { + var tokens = new List(); + + if (p.ScalarKind == CliScalarKind.Enum && !p.EnumMemberNames.IsDefaultOrEmpty) + { + tokens.Add("One of: <" + string.Join("|", p.EnumMemberNames.Select((m, i) => ResolveEnumMemberCliName(p.EnumMemberCliNames, i, m))) + ">"); + if (p.EnumMemberDocs is { Count: > 0 } docs) + { + var memberDescParts = new List(); + for (var i = 0; i < p.EnumMemberNames.Length; i++) + { + var member = p.EnumMemberNames[i]; + var cliName = ResolveEnumMemberCliName(p.EnumMemberCliNames, i, member); + if (docs.TryGetValue(member, out var memberDoc) && !string.IsNullOrWhiteSpace(memberDoc)) + memberDescParts.Add($"{cliName}: {memberDoc.Trim()}"); + } + if (memberDescParts.Count > 0) + tokens.Add("(" + string.Join("; ", memberDescParts) + ")"); + } + } + + if (p.IsCollection && p.ElementScalarKind == CliScalarKind.Enum && !p.ElementEnumMemberNames.IsDefaultOrEmpty) + { + var label = p.CollectionTargetIsReadOnlySet ? "Combination of:" : "One or more of:"; + tokens.Add(label + " <" + string.Join("|", p.ElementEnumMemberNames.Select((m, i) => ResolveEnumMemberCliName(p.ElementEnumMemberCliNames, i, m))) + ">"); + if (p.ElementEnumMemberDocs is { Count: > 0 } elemDocs) + { + var memberDescParts = new List(); + for (var i = 0; i < p.ElementEnumMemberNames.Length; i++) + { + var member = p.ElementEnumMemberNames[i]; + var cliName = ResolveEnumMemberCliName(p.ElementEnumMemberCliNames, i, member); + if (elemDocs.TryGetValue(member, out var memberDoc) && !string.IsNullOrWhiteSpace(memberDoc)) + memberDescParts.Add($"{cliName}: {memberDoc.Trim()}"); + } + if (memberDescParts.Count > 0) + tokens.Add("(" + string.Join("; ", memberDescParts) + ")"); + } + } + + if (!p.Validations.IsDefaultOrEmpty) + { + foreach (var v in p.Validations) + { + switch (v) + { + case RangeConstraint r: + tokens.Add($"[range: {r.MinLiteral.Trim('"')}–{r.MaxLiteral.Trim('"')}]"); + break; + case CollectionCountConstraint cc when cc.Min.HasValue && cc.Max.HasValue: + tokens.Add($"[count: {cc.Min}–{cc.Max}]"); + break; + case CollectionCountConstraint cc when cc.Min.HasValue: + tokens.Add($"[min-count: {cc.Min}]"); + break; + case CollectionCountConstraint cc when cc.Max.HasValue: + tokens.Add($"[max-count: {cc.Max}]"); + break; + case StringLengthConstraint s when s.Min.HasValue && s.Max.HasValue: + tokens.Add($"[length: {s.Min}–{s.Max}]"); + break; + case StringLengthConstraint s when s.Min.HasValue: + tokens.Add($"[min-length: {s.Min}]"); + break; + case StringLengthConstraint s when s.Max.HasValue: + tokens.Add($"[max-length: {s.Max}]"); + break; + case RegexConstraint rx: + tokens.Add($"[pattern: {rx.Pattern}]"); + break; + case AllowedValuesConstraint av: + tokens.Add("[allowed: " + string.Join("|", av.Values.Select(val => val.Trim('"'))) + "]"); + break; + case DeniedValuesConstraint dv: + tokens.Add("[denied: " + string.Join("|", dv.Values.Select(val => val.Trim('"'))) + "]"); + break; + case EmailConstraint: + tokens.Add("[email]"); + break; + case UrlConstraint: + tokens.Add("[url]"); + break; + case UriSchemeConstraint us: + tokens.Add("[schemes: " + string.Join("|", us.Schemes) + "]"); + break; + case FileExtensionsConstraint fe: + tokens.Add("[extensions: " + string.Join("|", fe.Extensions) + "]"); + break; + case ExistingPathConstraint: + tokens.Add("[existing]"); + break; + case NonExistingPathConstraint: + tokens.Add("[unused path]"); + break; + case RejectSymbolicLinksConstraint: + tokens.Add("[no symlinks]"); + break; + case TimeSpanRangeConstraint ts: + tokens.Add($"[time-span-range: {ts.MinLiteral.Trim('"')}–{ts.MaxLiteral.Trim('"')}]"); + break; + } + } + } + + if (p.ExpandUserProfileBeforeBind) + tokens.Add("[expand ~ profile]"); + + return tokens.Count > 0 ? string.Join(" ", tokens) : null; + } + + private abstract record ValidationConstraint; + private sealed record CollectionCountConstraint(int? Min, int? Max) : ValidationConstraint; + private sealed record RangeConstraint(string MinLiteral, string MaxLiteral) : ValidationConstraint; + private sealed record TimeSpanRangeConstraint(string MinLiteral, string MaxLiteral) : ValidationConstraint; + private sealed record StringLengthConstraint(int? Min, int? Max) : ValidationConstraint; + private sealed record RegexConstraint(string Pattern) : ValidationConstraint; + private sealed record AllowedValuesConstraint(ImmutableArray Values) : ValidationConstraint; + private sealed record DeniedValuesConstraint(ImmutableArray Values) : ValidationConstraint; + private sealed record EmailConstraint : ValidationConstraint; + private sealed record UrlConstraint : ValidationConstraint; + private sealed record UriSchemeConstraint(ImmutableArray Schemes) : ValidationConstraint; + private sealed record FileExtensionsConstraint(ImmutableArray Extensions) : ValidationConstraint; + private sealed record ExistingPathConstraint : ValidationConstraint; + private sealed record NonExistingPathConstraint : ValidationConstraint; + private sealed record RejectSymbolicLinksConstraint : ValidationConstraint; + +} diff --git a/src/Nullean.Argh.Generator/CliParserGenerator.cs b/src/Nullean.Argh.Generator/CliParserGenerator.cs index 6703f74..4f9833c 100644 --- a/src/Nullean.Argh.Generator/CliParserGenerator.cs +++ b/src/Nullean.Argh.Generator/CliParserGenerator.cs @@ -31,270 +31,6 @@ public sealed partial class CliParserGenerator : IIncrementalGenerator SymbolDisplayFormat.FullyQualifiedFormat.MiscellaneousOptions | SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier); - private static readonly DiagnosticDescriptor CommandNamespaceOptionsMustExtendParent = new( - "AGH0004", - "Command namespace options type must extend the parent options type", - "'{0}' must inherit or implement '{1}' for this UseNamespaceOptions<> registration.", - "Argh", - DiagnosticSeverity.Error, - isEnabledByDefault: true); - - private static readonly DiagnosticDescriptor CommandNamespaceOptionsRequiresParent = new( - "AGH0005", - "Command namespace options require a parent options type", - "Register UseGlobalOptions() before UseNamespaceOptions<{0}>(), or ensure the parent namespace declares a compatible base options type.", - "Argh", - DiagnosticSeverity.Error, - isEnabledByDefault: true); - - private static readonly DiagnosticDescriptor HandlerMustBeMethod = new( - "AGH0002", - "Command handler must be a method group", - "The second argument to Map must be a method group (not a lambda or local function) so the generator can emit an AOT-compatible call.", - "Argh", - DiagnosticSeverity.Error, - isEnabledByDefault: true); - - private static readonly DiagnosticDescriptor ArgumentOrder = new( - "AGH0003", - "Invalid [Argument] parameter order", - "Parameters marked with [Argument] must start at position 0 and be consecutive.", - "Argh", - DiagnosticSeverity.Error, - isEnabledByDefault: true); - - private static readonly DiagnosticDescriptor UseMiddlewareDelegateNotSupported = new( - "AGH0006", - "Inline UseMiddleware delegate not emitted", - "UseMiddleware requires a type argument (UseMiddleware()) for source-generated middleware; inline delegates are not emitted.", - "Argh", - DiagnosticSeverity.Warning, - isEnabledByDefault: true); - - private static readonly DiagnosticDescriptor DuplicateCliNames = new( - "AGH0007", - "Duplicate CLI names", - "Multiple parameters map to the same CLI name '{0}' (conflicts when binding or generating help).", - "Argh", - DiagnosticSeverity.Error, - isEnabledByDefault: true); - - private static readonly DiagnosticDescriptor CollectionPositionalNotSupported = new( - "AGH0008", - "Collection parameters must be flags", - "Collection types are only supported for option flags, not for [Argument] positionals. Use a T[] type for a variadic positional.", - "Argh", - DiagnosticSeverity.Error, - isEnabledByDefault: true); - - private static readonly DiagnosticDescriptor VariadicMustBeLastPositional = new( - "AGH0031", - "Variadic positional must be last", - "A variadic positional (T[] with [Argument]) must be the last positional parameter; no [Argument] parameter may follow it.", - "Argh", - DiagnosticSeverity.Error, - isEnabledByDefault: true); - - private static readonly DiagnosticDescriptor VariadicCollectionMustBeArray = new( - "AGH0034", - "Variadic positional must be a T[] array", - "A variadic positional ([Argument] on a collection) must be declared as a T[] array type. List and other collection interfaces are not supported.", - "Argh", - DiagnosticSeverity.Error, - isEnabledByDefault: true); - - private static readonly DiagnosticDescriptor AsParametersEmptyType = new( - "AGH0009", - "AsParameters type has no bindable members", - "Type '{0}' must expose public primary constructor parameters and/or public settable properties (including inherited) for [AsParameters] binding.", - "Argh", - DiagnosticSeverity.Error, - isEnabledByDefault: true); - - private static readonly DiagnosticDescriptor DuplicateRootCommand = new( - "AGH0010", - "Duplicate default command", - "Only one default handler per scope: MapRoot, or [DefaultCommand].", - "Argh", - DiagnosticSeverity.Error, - isEnabledByDefault: true); - - private static readonly DiagnosticDescriptor AddRootCommandOnlyAtAppRoot = new( - "AGH0011", - "MapRoot only on the root app", - "Use MapRoot on the root ArghApp only (not inside MapNamespace). For a namespace default handler, call MapRoot inside the MapNamespace configure callback.", - "Argh", - DiagnosticSeverity.Error, - isEnabledByDefault: true); - - private static readonly DiagnosticDescriptor AddNamespaceRootCommandOnlyInNamespace = new( - "AGH0012", - "MapRoot only inside a namespace", - "Use MapRoot inside MapNamespace configuration. For the top-level default, use MapRoot at the app root.", - "Argh", - DiagnosticSeverity.Error, - isEnabledByDefault: true); - - private static readonly DiagnosticDescriptor ReservedCommandNameRoot = new( - "AGH0013", - "Reserved command name", - "The name '{0}' is reserved for root default commands; choose a different command name.", - "Argh", - DiagnosticSeverity.Error, - isEnabledByDefault: true); - - private static readonly DiagnosticDescriptor AddNamespaceRequiresExplicitDescriptionOrType = new( - "AGH0014", - "MapNamespace requires a description or entry type", - "Use MapNamespace(string name, string description, Action configure) with an explicit description (may be empty), or MapNamespace(string name, Action configure) to use type T's XML summary for the namespace listing.", - "Argh", - DiagnosticSeverity.Error, - isEnabledByDefault: true); - - private static readonly DiagnosticDescriptor AddNamespaceDescriptionNotConstant = new( - "AGH0015", - "MapNamespace description not a compile-time string", - "The description argument must be a string literal or const string so the generator can emit namespace help text.", - "Argh", - DiagnosticSeverity.Warning, - isEnabledByDefault: true); - - private static readonly DiagnosticDescriptor RedundantAddInsideAddNamespaceT = new( - "AGH0016", - "Redundant Map inside MapNamespace", - "MapNamespace<{0}> already registers public commands from that type; remove the inner Map<{0}> call.", - "Argh", - DiagnosticSeverity.Warning, - isEnabledByDefault: true); - - private static readonly DiagnosticDescriptor NamespaceSegmentUnresolved = new( - "AGH0017", - "Namespace segment could not be resolved", - "MapNamespace<{0}>() without a name requires [NamespaceSegment] with a string argument on the type and/or a single segment in the type XML .", - "Argh", - DiagnosticSeverity.Error, - isEnabledByDefault: true); - - private static readonly DiagnosticDescriptor NamespaceSegmentConflict = new( - "AGH0018", - "Conflicting namespace segment", - "Namespace segment for '{0}' is specified as '{1}' in one place and '{2}' in another; use a single source.", - "Argh", - DiagnosticSeverity.Error, - isEnabledByDefault: true); - - private static readonly DiagnosticDescriptor MultipleDefaultCommandAttributes = new( - "AGH0019", - "Multiple [DefaultCommand] attributes", - "Type '{0}' has more than one method marked [DefaultCommand]; keep at most one.", - "Argh", - DiagnosticSeverity.Error, - isEnabledByDefault: true); - - private static readonly DiagnosticDescriptor VacuousNamespace = new( - "AGH0020", - "Namespace registers no commands", - "This MapNamespace block does not register any commands, nested namespaces, or default handlers.", - "Argh", - DiagnosticSeverity.Error, - isEnabledByDefault: true); - - private static readonly DiagnosticDescriptor CommandMustInjectOptions = new( - "AGH0021", - "Command does not inject required options type", - "'{0}' must inject '{1}' as a method parameter or constructor parameter.", - "Argh", - DiagnosticSeverity.Error, - isEnabledByDefault: true); - - private static readonly DiagnosticDescriptor NamespaceSegmentSanitizationCollision = new( - "AGH0022", - "Namespace segment names collide after identifier sanitization", - "Namespace segment names '{0}' and '{1}' collide after identifier sanitization (both become '{2}').", - "Argh", - DiagnosticSeverity.Error, - isEnabledByDefault: true); - - private static readonly DiagnosticDescriptor UseCliDescriptionConflictsWithMapRoot = new( - "AGH0023", - "UseCliDescription conflicts with MapRoot", - "UseCliDescription cannot be combined with MapRoot: the root command handler's XML summary is already shown as the description. Remove one or the other.", - "Argh", - DiagnosticSeverity.Error, - isEnabledByDefault: true); - - private static readonly DiagnosticDescriptor UriSchemeOnNonUriParam = new( - "AGH0024", - "[UriScheme] applied to non-Uri parameter", - "'{0}' has [UriScheme] but its type is not Uri or Uri?; [UriScheme] only constrains Uri-typed parameters.", - "Argh", - DiagnosticSeverity.Warning, - isEnabledByDefault: true); - - private static readonly DiagnosticDescriptor TimeSpanRangeOnNonTimeSpanParam = new( - "AGH0025", - "[TimeSpanRange] applied to non-TimeSpan parameter", - "'{0}' has [TimeSpanRange] but its type is not TimeSpan or TimeSpan?; [TimeSpanRange] only constrains TimeSpan-typed parameters.", - "Argh", - DiagnosticSeverity.Warning, - isEnabledByDefault: true); - - private static readonly DiagnosticDescriptor BoolFlagCollidesWithNullableNegation = new( - "AGH0026", - "Bool flag collides with nullable bool negation", - "Parameter '{0}' maps to '--{1}', which duplicates the negation flag generated for a nullable bool on the same command. Rename one of the parameters.", - "Argh", - DiagnosticSeverity.Error, - isEnabledByDefault: true); - - private static readonly DiagnosticDescriptor DuplicateCommandName = new( - "AGH0027", - "Duplicate command name in scope", - "The command name '{0}' is registered more than once in the same scope. Only the first registration is used; rename one command or use [CommandName] to assign a unique name.", - "Argh", - DiagnosticSeverity.Error, - isEnabledByDefault: true); - - private static readonly DiagnosticDescriptor ReadOnlySetInvalidElementType = new( - "AGH0028", - "IReadOnlySet element type is not supported", - "IReadOnlySet only supports value-type or enum element types; '{0}' is not allowed", - "Usage", - DiagnosticSeverity.Error, - isEnabledByDefault: true); - - private static readonly DiagnosticDescriptor MapAndRootAliasAmbiguousTarget = new( - "AGH0029", - "MapAndRootAlias requires a [DefaultCommand] target", - "MapAndRootAlias<{0}> exposes multiple commands but none is marked [DefaultCommand]. Annotate exactly one method with [DefaultCommand] to designate the root alias target.", - "Argh", - DiagnosticSeverity.Error, - isEnabledByDefault: true); - - private static readonly DiagnosticDescriptor PathExistenceAttributesConflict = new( - "AGH0030", - "[Existing] and [NonExisting] conflict", - "Parameter '{0}' cannot declare both [Existing] and [NonExisting].", - "Argh", - DiagnosticSeverity.Error, - isEnabledByDefault: true); - - private static readonly DiagnosticDescriptor FilesystemPathAttributeTypeMismatch = new( - "AGH0032", - "Filesystem path attribute incompatible with parameter type", - "'{0}': {1}", - "Argh", - DiagnosticSeverity.Error, - isEnabledByDefault: true); - - private static readonly DiagnosticDescriptor DuplicateShortOption = new( - "AGH0033", - "Duplicate short option letter", - "The short option '-{0}' is used for more than one flag in the same parse scope: '--{1}' and '--{2}' ({3}).", - "Argh", - DiagnosticSeverity.Error, - isEnabledByDefault: true); - // ── Pre-compiled Regex patterns ── compiled once, reused for every handler method analyzed private static readonly Regex SummaryXmlPattern = new(@"\s*([\s\S]*?)\s*", RegexOptions.Compiled); @@ -540,11178 +276,4 @@ private static bool IsArghRegistrationReceiver( return false; } - private sealed class RegistryNode - { - public CommandModel? RootCommand; - /// Pointer to a named command that acts as the scope's root alias (set by MapAndRootAlias<T>). - public CommandModel? RootAlias; - public readonly List Commands = new(); - public readonly List Children = new(); - public Location? CommandNamespaceOptionsLocation; - public OptionsTypeModel? CommandNamespaceOptionsModel; - /// Inner XML of <summary> from the namespace entry type (populated when a generic AddNamespace<T> is used). - public string SummaryInnerXml = ""; - /// Inner XML of <remarks> from the namespace entry type. - public string RemarksInnerXml = ""; - - public sealed class NamedCommandNamespaceChild - { - public string Segment = ""; - public RegistryNode Node = null!; - /// First non-empty XML summary from the first generic Add handler type in this namespace block. - public string SummaryOneLiner = ""; - public Location Location = Location.None; - } - } - - private sealed class AppEmitModel - { - public OptionsTypeModel? GlobalOptionsModel; - public string RootSummary = ""; - public string? SchemaVersionOverride; - public readonly RegistryNode Root = new(); - public ImmutableArray AllCommands = ImmutableArray.Empty; - public ImmutableArray GlobalMiddleware = ImmutableArray.Empty; - public readonly List ArglessNamespaceCodegen = new(); - public ImmutableArray EnvironmentVars = ImmutableArray.Empty; - public ImmutableArray ConfigFiles = ImmutableArray.Empty; - /// Pre-computed injection chains per command (keyed by ). Set once in TryBuildAppEmitModel after AllCommands is populated. - public ImmutableDictionary AllBaseTypeMetadataNames, string StaticFieldName, string LocalVarName, ImmutableArray FlatMembers, ImmutableArray? BestCtorParamOrder)>> InjectionChains - = ImmutableDictionary, string, string, ImmutableArray, ImmutableArray?)>>.Empty; - } - - private sealed record ArglessNamespaceCodegenEntry(string TypeFq, string Segment); - - private sealed record GlobalMiddlewareRegistration(string TypeFq, bool HasParameterlessCtor); - - private sealed record OptionsTypeModel( - string TypeFq, - string TypeMetadataName, - ImmutableArray AllBaseTypeMetadataNames, - ImmutableArray Members, - ImmutableArray FlattenedMembers, - /// Parameter names of the best public non-empty constructor whose parameters all match member names; null if none or property-init should be used. - ImmutableArray? BestCtorParamOrder, - bool IsPublic, - bool IsGeneric); - - /// Per-parameter data extracted at analysis time, stored in . - private sealed record HandlerParam( - string Name, - string TypeMetadataName, - /// All ancestor metadata names of this parameter's type — used for subclass-aware options injection matching. - ImmutableArray TypeAllBaseTypeMetadataNames, - bool IsInjectedParam, - bool IsAsParameters, - string? AsParametersPrefix, - /// Non-null only for [AsParameters]-annotated params — the FQ type name for DTO building in emit. - string? AsParamTypeFq = null, - bool AsParamIsPublic = true, - bool AsParamIsGeneric = false, - /// Pre-computed best ctor param order for [AsParameters] DTO construction (symbol-free). - ImmutableArray? AsParamBestCtorParamOrder = null); - - private readonly record struct AsParametersMeta( - string OwnerParamName, - int MemberOrder, - string TypeFq, - bool UseInit, - string ClrName); - - /// - /// Value-type location snapshot used in pipeline records instead of (a reference type - /// that embeds a SyntaxTree reference and breaks incremental caching on every file edit). - /// Reconstructed to a real only when reporting a diagnostic. - /// - private readonly record struct SourceSpanInfo( - string FilePath, - int Start, - int Length, - int Line, - int Character) - { - public static readonly SourceSpanInfo None = new("", 0, 0, 0, 0); - - public static SourceSpanInfo From(Location loc) - { - if (!loc.IsInSource) return None; - var lp = loc.GetLineSpan(); - return new SourceSpanInfo( - lp.Path, - loc.SourceSpan.Start, - loc.SourceSpan.Length, - lp.StartLinePosition.Line, - lp.StartLinePosition.Character); - } - - public Location ToLocation() => - FilePath.Length == 0 - ? Location.None - : Location.Create( - FilePath, - new TextSpan(Start, Length), - new LinePositionSpan( - new LinePosition(Line, Character), - new LinePosition(Line, Character + Length))); - } - - /// - /// Value-type diagnostic snapshot used in AnalyzedInvocation records instead of - /// (a reference type that breaks incremental caching). Reconstructed in TryBuildAppEmitModel. - /// - private readonly record struct PendingDiagnostic( - string DescriptorId, - SourceSpanInfo Span, - string Arg0 = "", - string Arg1 = ""); - - // ─── AnalyzedInvocation discriminated union ──────────────────────────────── - // Symbol-free records representing each pre-analysed ArghApp builder invocation. - // Produced by AnalyzeInvocation() in the Select step (which has SemanticModel), - // and consumed by TryBuildAppEmitModel() in the RegisterSourceOutput Execute step. - // All AnalyzedInvocation subtypes are symbol-free: only strings, value types, and pre-computed - // ImmutableArrays. No ISymbol references. This ensures Roslyn's pipeline can cache them by - // structural equality between compilations. - - private abstract record AnalyzedInvocation(string FilePath, int SpanStart); - - /// A GlobalOptions<T>() invocation — only valid at root scope. - private sealed record AIUseGlobalOptions(string FilePath, int SpanStart, OptionsTypeModel Model) - : AnalyzedInvocation(FilePath, SpanStart); - - /// A CommandNamespaceOptions<T>() invocation — only valid inside a namespace. - private sealed record AIUseNamespaceOptions(string FilePath, int SpanStart, OptionsTypeModel Model) - : AnalyzedInvocation(FilePath, SpanStart); - - /// A UseMiddleware<T>() invocation — only valid at root scope. - private sealed record AIUseMiddleware(string FilePath, int SpanStart, GlobalMiddlewareRegistration Registration) - : AnalyzedInvocation(FilePath, SpanStart); - - /// A UseCliDescription(string) invocation — only meaningful at root scope. - private sealed record AIUseCliDescription(string FilePath, int SpanStart, string Description) - : AnalyzedInvocation(FilePath, SpanStart); - - /// A UseSchemaVersion(string) invocation — overrides the version field in the __schema document. - private sealed record AIUseSchemaVersion(string FilePath, int SpanStart, string Version) - : AnalyzedInvocation(FilePath, SpanStart); - - /// A DocumentEnvironmentVariables(...) invocation — only meaningful at root scope. - private sealed record AIDocumentEnvironmentVariables( - string FilePath, - int SpanStart, - ImmutableArray Variables, - ImmutableArray ConfigFiles) - : AnalyzedInvocation(FilePath, SpanStart); - - /// Symbol-free representation of a passed to DocumentEnvironmentVariables. - private sealed record EnvVarDocEntry(string Name, string? Description, bool Required, string? DefaultValue); - - /// Symbol-free representation of a passed to DocumentEnvironmentVariables. - private sealed record ConfigFileDocEntry(string Path, string? Description, bool Required); - - /// Symbol-free intent data extracted from [CommandIntent]. - private sealed record CommandIntentData(bool? Destructive, bool? Idempotent, string? Scope, bool? RequiresConfirmation, bool? RequiresAuth); - - /// Symbol-free output data extracted from [CommandOutput]. - private sealed record CommandOutputData(ImmutableArray Formats, string? FormatFlag); - - /// - /// An Add<T>() or Add(name, handler) invocation. - /// For Add<T>, holds the full registry structure; - /// for Add(name, handler), holds the single command. - /// - private sealed record AIMapCommand( - string FilePath, - int SpanStart, - ImmutableArray Commands, - RegistryNodeSnapshot? TypeSnapshot = null, - /// - /// Diagnostics accumulated while expanding (e.g. AGH0007 duplicate CLI names, - /// AGH0032 filesystem attribute misuse) — empty for the Map(name, handler) overload, which reports - /// directly via its own plumbing. - /// - ImmutableArray EmbeddedDiagnostics = default) - : AnalyzedInvocation(FilePath, SpanStart) - { - public ImmutableArray EmbeddedDiagnosticsOrEmpty => - EmbeddedDiagnostics.IsDefault ? ImmutableArray.Empty : EmbeddedDiagnostics; - } - - /// An AddRootCommand(handler) or AddNamespaceRootCommand(handler) invocation. - private sealed record AIMapRootCommand(string FilePath, int SpanStart, CommandModel Cmd, bool IsNamespaceRoot) - : AnalyzedInvocation(FilePath, SpanStart); - - /// A MapAndRootAlias<T>() invocation — registers all T methods as named commands and marks one as the root alias. - private sealed record AIMapAndRootAlias( - string FilePath, - int SpanStart, - RegistryNodeSnapshot TypeSnapshot, - ImmutableArray EmbeddedDiagnostics) - : AnalyzedInvocation(FilePath, SpanStart); - - /// - /// An AddNamespace(…) invocation. - /// LambdaBodyStart/End are character offsets into FilePath used to identify child invocations positionally. - /// - private sealed record AIMapNamespace( - string FilePath, - int SpanStart, - string SegmentName, - int LambdaBodyStart, - int LambdaBodyEnd, - /// FQ name of the generic type argument (for AddNamespace<T>), or null for AddNamespace(string, string, Action). - string? EntryTypeFq, - /// True when AddNamespace<T>(Action) with no explicit segment — requires codegen module initializer. - bool IsArglessSegment, - /// Pre-computed namespace summary one-liner for help listing. - string NsSummary, - /// Pre-computed namespace XML documentation. - string NsSummaryInnerXml, - string NsRemarksInnerXml, - /// Whether a redundancy check should be applied (AddNamespace<T> registers its own commands). - bool HasEntryType, - SourceSpanInfo DiagnosticSpanInfo, - /// Embedded diagnostics to report from TryBuildAppEmitModel (e.g. AGH0016 redundant Add<T>). - ImmutableArray EmbeddedDiagnostics, - /// - /// Pre-registered commands and sub-namespaces from the entry type T (for AddNamespace<T>). - /// Contains root commands, regular commands, and nested children from ExpandTypeRegistration. - /// Null when there is no entry type. - /// - RegistryNodeSnapshot? EntryTypeSnapshot) - : AnalyzedInvocation(FilePath, SpanStart); - - /// Symbol-free snapshot of a RegistryNode subtree produced during analysis. - private sealed record RegistryNodeSnapshot( - CommandModel? RootCommand, - ImmutableArray Commands, - ImmutableArray Children, - string SummaryInnerXml, - string RemarksInnerXml, - /// Alias target set by MapAndRootAlias<T> — a reference to a command already in . - CommandModel? AliasCommand = null); - - /// Symbol-free snapshot of a child namespace (nested type) produced during analysis. - private sealed record ChildNamespaceSnapshot( - string Segment, - RegistryNodeSnapshot Node, - string SummaryOneLiner); - - // ───────────────────────────────────────────────────────────────────────────── - - /// - /// Lightweight diagnostic collection wrapper used in where no - /// is available. Collected diagnostics are embedded in the - /// returned record and reported later by TryBuildAppEmitModel. - /// - private sealed class DiagnosticAccumulator - { - private List? _diagnostics; - - public void Add(DiagnosticDescriptor descriptor, Location location, params string[] args) => - (_diagnostics ??= new()).Add(new PendingDiagnostic( - descriptor.Id, - SourceSpanInfo.From(location), - args.Length > 0 ? args[0] : "", - args.Length > 1 ? args[1] : "")); - - public ImmutableArray ToImmutable() => - _diagnostics is null ? ImmutableArray.Empty : _diagnostics.ToImmutableArray(); - } - - private static DiagnosticDescriptor GetDescriptorById(string id) => id switch - { - "AGH0002" => HandlerMustBeMethod, - "AGH0003" => ArgumentOrder, - "AGH0004" => CommandNamespaceOptionsMustExtendParent, - "AGH0005" => CommandNamespaceOptionsRequiresParent, - "AGH0006" => UseMiddlewareDelegateNotSupported, - "AGH0007" => DuplicateCliNames, - "AGH0008" => CollectionPositionalNotSupported, - "AGH0009" => AsParametersEmptyType, - "AGH0010" => DuplicateRootCommand, - "AGH0011" => AddRootCommandOnlyAtAppRoot, - "AGH0012" => AddNamespaceRootCommandOnlyInNamespace, - "AGH0013" => ReservedCommandNameRoot, - "AGH0014" => AddNamespaceRequiresExplicitDescriptionOrType, - "AGH0015" => AddNamespaceDescriptionNotConstant, - "AGH0016" => RedundantAddInsideAddNamespaceT, - "AGH0017" => NamespaceSegmentUnresolved, - "AGH0018" => NamespaceSegmentConflict, - "AGH0019" => MultipleDefaultCommandAttributes, - "AGH0020" => VacuousNamespace, - "AGH0021" => CommandMustInjectOptions, - "AGH0022" => NamespaceSegmentSanitizationCollision, - "AGH0023" => UseCliDescriptionConflictsWithMapRoot, - "AGH0024" => UriSchemeOnNonUriParam, - "AGH0025" => TimeSpanRangeOnNonTimeSpanParam, - "AGH0026" => BoolFlagCollidesWithNullableNegation, - "AGH0027" => DuplicateCommandName, - "AGH0028" => ReadOnlySetInvalidElementType, - "AGH0029" => MapAndRootAliasAmbiguousTarget, - "AGH0030" => PathExistenceAttributesConflict, - "AGH0032" => FilesystemPathAttributeTypeMismatch, - "AGH0033" => DuplicateShortOption, - _ => throw new ArgumentException($"Unknown diagnostic id: {id}") - }; - - /// - /// Per-invocation semantic analysis, intended for the CreateSyntaxProvider Select step. - /// Runs with a but produces a fully symbol-free - /// so the pipeline boundary data is stable across unrelated edits. - /// Diagnostics that cannot be reported here (no SourceProductionContext in Select step) are embedded - /// in the returned record via EmbeddedDiagnostics and reported later by TryBuildAppEmitModel. - /// - private static AnalyzedInvocation? AnalyzeInvocation( - InvocationExpressionSyntax invocation, - SemanticModel semanticModel, - CancellationToken ct) - { - if (semanticModel.GetSymbolInfo(invocation, ct).Symbol is not IMethodSymbol method) - return null; - - var filePath = invocation.SyntaxTree.FilePath; - var spanStart = invocation.SpanStart; - var parseOpts = invocation.SyntaxTree.Options as CSharpParseOptions ?? CSharpParseOptions.Default; - - switch (method.Name) - { - case "UseGlobalOptions" when method.IsGenericMethod && method.TypeArguments.Length > 0: - { - if (method.TypeArguments[0] is not INamedTypeSymbol go || go.TypeKind == TypeKind.Error) - return null; - var model = BuildOptionsTypeModel(go, semanticModel.Compilation); - if (model is null) return null; - return new AIUseGlobalOptions(filePath, spanStart, model); - } - case "UseNamespaceOptions" when method.IsGenericMethod && method.TypeArguments.Length > 0: - { - if (method.TypeArguments[0] is not INamedTypeSymbol gt || gt.TypeKind == TypeKind.Error) - return null; - var model = BuildOptionsTypeModel(gt, semanticModel.Compilation); - if (model is null) return null; - return new AIUseNamespaceOptions(filePath, spanStart, model); - } - case "UseMiddleware" when method.IsGenericMethod && method.TypeArguments.Length == 1: - { - if (method.TypeArguments[0] is not INamedTypeSymbol mwType || mwType.TypeKind == TypeKind.Error) - return null; - var reg = new GlobalMiddlewareRegistration( - mwType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), - HasPublicParameterlessCtor(mwType)); - return new AIUseMiddleware(filePath, spanStart, reg); - } - case "UseMiddleware": - // Inline delegate — diagnostic will be reported by TryBuildAppEmitModel (option 2 from plan). - return new AIUseMiddleware(filePath, spanStart, new GlobalMiddlewareRegistration("", false)); - case "Map" when method.IsGenericMethod && method.TypeArguments.Length > 0: - { - if (method.TypeArguments[0] is not INamedTypeSymbol named || named.TypeKind == TypeKind.Error) - return null; - // Always hoist: merge the type's methods directly into the current scope (root or namespace). - var acc = new DiagnosticAccumulator(); - var wrapper = new RegistryNode(); - ExpandTypeRegistrationAcc(acc, invocation.GetLocation(), named, ImmutableArray.Empty, mergeOuterTypeSegment: true, wrapper, parseOpts, semanticModel.Compilation); - var snap = BuildRegistryNodeSnapshot(wrapper); - return new AIMapCommand(filePath, spanStart, ImmutableArray.Empty, TypeSnapshot: snap, EmbeddedDiagnostics: acc.ToImmutable()); - } - case "MapAndRootAlias" when method.IsGenericMethod && method.TypeArguments.Length > 0: - { - if (method.TypeArguments[0] is not INamedTypeSymbol named || named.TypeKind == TypeKind.Error) - return null; - var acc = new DiagnosticAccumulator(); - var wrapper = new RegistryNode(); - AddMethodsFromTypeAccForAlias(acc, invocation.GetLocation(), named, ImmutableArray.Empty, wrapper, parseOpts, semanticModel.Compilation); - var snap = BuildRegistryNodeSnapshot(wrapper); - return new AIMapAndRootAlias(filePath, spanStart, snap, acc.ToImmutable()); - } - case "Map" when invocation.ArgumentList.Arguments.Count >= 2: - { - var nameExpr = invocation.ArgumentList.Arguments[0].Expression; - var commandName = TryGetStringLiteral(nameExpr); - if (commandName is null || string.IsNullOrWhiteSpace(commandName)) - return null; - var handlerExpr = invocation.ArgumentList.Arguments[1].Expression; - if (handlerExpr is LambdaExpressionSyntax) - { - var node = new RegistryNode(); - TryExpandLambdaDelegateAcc(semanticModel, invocation, handlerExpr, commandName, ImmutableArray.Empty, node); - if (node.Commands.Count == 0) return null; - return new AIMapCommand(filePath, spanStart, node.Commands.ToImmutableArray()); - } - var handler = ResolveHandlerMethodForAnalyze(semanticModel, handlerExpr); - if (handler is null) return null; - var acc2 = new DiagnosticAccumulator(); - var cmd = CommandModel.FromMethod(commandName, handler, parseOpts, ImmutableArray.Empty, acc2, invocation.GetLocation(), semanticModel.Compilation); - return new AIMapCommand(filePath, spanStart, ImmutableArray.Create(cmd), EmbeddedDiagnostics: acc2.ToImmutable()); - } - case "UseCliDescription": - { - if (invocation.ArgumentList.Arguments.Count < 1) return null; - var descExpr = invocation.ArgumentList.Arguments[0].Expression; - var desc = TryGetStringLiteral(descExpr) ?? ""; - return new AIUseCliDescription(filePath, spanStart, desc); - } - case "UseSchemaVersion": - { - if (invocation.ArgumentList.Arguments.Count < 1) return null; - var verExpr = invocation.ArgumentList.Arguments[0].Expression; - var ver = TryGetStringLiteral(verExpr); - if (string.IsNullOrWhiteSpace(ver)) return null; - return new AIUseSchemaVersion(filePath, spanStart, ver!); - } - case "DocumentEnvironmentVariables": - return AnalyzeDocumentEnvironmentVariables(invocation, filePath, spanStart); - case "MapRoot": - { - if (invocation.ArgumentList.Arguments.Count < 1) return null; - var isNs = IsInvocationInsideMapNamespaceConfigure(invocation); - return AnalyzeMapRootInvocation(invocation, semanticModel, filePath, spanStart, parseOpts, isNamespaceRoot: isNs); - } - case "MapNamespace": - return AnalyzeMapNamespaceInvocation(invocation, semanticModel, filePath, spanStart, parseOpts, ct); - default: - return null; - } - } - - private static bool IsInvocationInsideMapNamespaceConfigure(InvocationExpressionSyntax invocation) - { - for (var n = invocation.Parent; n != null; n = n.Parent) - { - if (n is LambdaExpressionSyntax lambda && IsMapNamespaceConfigureLambda(lambda, out _)) - return true; - } - - return false; - } - - private static AIMapRootCommand? AnalyzeMapRootInvocation( - InvocationExpressionSyntax invocation, - SemanticModel semanticModel, - string filePath, - int spanStart, - CSharpParseOptions parseOpts, - bool isNamespaceRoot) - { - if (invocation.ArgumentList.Arguments.Count < 1) return null; - var handlerExpr = invocation.ArgumentList.Arguments[0].Expression; - if (handlerExpr is LambdaExpressionSyntax) - { - var node = new RegistryNode(); - TryExpandLambdaRootCommandAcc(semanticModel, invocation, handlerExpr, ImmutableArray.Empty, node); - if (node.RootCommand is null) return null; - return new AIMapRootCommand(filePath, spanStart, node.RootCommand, isNamespaceRoot); - } - var handler = ResolveHandlerMethodForAnalyze(semanticModel, handlerExpr); - if (handler is null) return null; - var acc = new DiagnosticAccumulator(); - var cmd = CommandModel.FromRootMethod(handler, parseOpts, ImmutableArray.Empty, acc, invocation.GetLocation(), semanticModel.Compilation); - return new AIMapRootCommand(filePath, spanStart, cmd, isNamespaceRoot); - } - - /// Resolves a method from a handler expression without reporting diagnostics — returns null on failure. - private static IMethodSymbol? ResolveHandlerMethodForAnalyze(SemanticModel model, ExpressionSyntax handlerExpr) - { - var symbol = model.GetSymbolInfo(handlerExpr).Symbol; - if (symbol is IMethodSymbol m) return m; - - var op = model.GetOperation(handlerExpr); - while (op is IConversionOperation conv) - op = conv.Operand; - - if (op is IMethodReferenceOperation directRef) return directRef.Method; - if (op is IDelegateCreationOperation del && del.Target is IMethodReferenceOperation reference) return reference.Method; - - return null; // handler not a method — diagnostic will be reported by old path / TryBuildAppEmitModel - } - - private static AIMapNamespace? AnalyzeMapNamespaceInvocation( - InvocationExpressionSyntax invocation, - SemanticModel semanticModel, - string filePath, - int spanStart, - CSharpParseOptions parseOpts, - CancellationToken ct) - { - if (invocation.ArgumentList.Arguments.Count < 1) - return null; - - if (semanticModel.GetSymbolInfo(invocation, ct).Symbol is not IMethodSymbol addNsMethod || addNsMethod.Name != "MapNamespace") - return null; - - var genericEntry = addNsMethod.IsGenericMethod && addNsMethod.TypeArguments.Length == 1; - var namespaceEntryType = genericEntry && addNsMethod.TypeArguments[0] is INamedTypeSymbol nt && nt.TypeKind != TypeKind.Error - ? nt - : null; - - var argCount = invocation.ArgumentList.Arguments.Count; - string? segmentName = null; - var nsSummary = ""; - var nsSummaryXml = ""; - var nsRemarksXml = ""; - var isArgless = false; - - if (genericEntry && argCount == 1 && namespaceEntryType is not null) - { - var firstExpr = invocation.ArgumentList.Arguments[0].Expression; - var strOnly = TryGetStringLiteral(firstExpr) ?? TryGetStringConstant(semanticModel, firstExpr); - if (strOnly is not null && !string.IsNullOrWhiteSpace(strOnly)) - { - // AddNamespace("segment") — no configure callback - segmentName = strOnly; - nsSummary = GetTypeListingSummaryOneLiner(namespaceEntryType); - } - else - { - // AddNamespace(Action) — segment from attribute/XML - if (!TryGetNamespaceSegmentAttribute(namespaceEntryType, out var attrSeg) && - !TryGetFirstCodeInTypeSummary(namespaceEntryType, out attrSeg)) - return null; // can't determine segment — will be caught as AGH0017 in old path - segmentName = attrSeg; - nsSummary = GetTypeListingSummaryOneLiner(namespaceEntryType); - isArgless = true; - } - } - else if (genericEntry && argCount >= 2 && namespaceEntryType is not null) - { - segmentName = TryGetStringLiteral(invocation.ArgumentList.Arguments[0].Expression); - if (string.IsNullOrWhiteSpace(segmentName)) - return null; - nsSummary = GetTypeListingSummaryOneLiner(namespaceEntryType); - } - else if (!genericEntry && argCount >= 3) - { - segmentName = TryGetStringLiteral(invocation.ArgumentList.Arguments[0].Expression); - if (string.IsNullOrWhiteSpace(segmentName)) - return null; - var desc = TryGetStringConstant(semanticModel, invocation.ArgumentList.Arguments[1].Expression); - nsSummary = desc ?? ""; - } - else - { - return null; // AGH0014 emitted in old path - } - - // Get XML docs if entry type is available. - if (namespaceEntryType is not null) - { - var typeXml = namespaceEntryType.GetDocumentationCommentXml(); - if (string.IsNullOrWhiteSpace(typeXml)) - typeXml = TryExtractFullDocumentationFromTypeTrivia(namespaceEntryType); - var (sx, rx) = Documentation.GetTypeDocumentation(typeXml); - nsSummaryXml = sx; - nsRemarksXml = rx; - } - - // Determine the lambda body span for positional child lookup. - var lambdaBodyStart = -1; - var lambdaBodyEnd = -1; - // The last argument is the configure lambda (if it exists) - var lastArg = invocation.ArgumentList.Arguments.LastOrDefault(); - if (lastArg?.Expression is LambdaExpressionSyntax lambdaSyntax) - { - lambdaBodyStart = lambdaSyntax.Body.SpanStart; - lambdaBodyEnd = lambdaSyntax.Body.Span.End; - } - - // Pre-compute entry type snapshot (commands from the type, nested classes as child namespaces). - RegistryNodeSnapshot? entryTypeSnapshot = null; - if (namespaceEntryType is not null) - { - var acc = new DiagnosticAccumulator(); - var entryNode = new RegistryNode(); - // Use mergeOuterTypeSegment=true — expand the type's own methods + nested classes - ExpandTypeRegistrationAcc(acc, invocation.GetLocation(), namespaceEntryType, ImmutableArray.Empty, mergeOuterTypeSegment: true, entryNode, parseOpts, semanticModel.Compilation); - entryTypeSnapshot = BuildRegistryNodeSnapshot(entryNode); - } - - return new AIMapNamespace( - filePath, - spanStart, - segmentName!, - lambdaBodyStart, - lambdaBodyEnd, - namespaceEntryType?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), - isArgless, - nsSummary, - nsSummaryXml, - nsRemarksXml, - HasEntryType: namespaceEntryType is not null, - SourceSpanInfo.From(invocation.GetLocation()), - ImmutableArray.Empty, - entryTypeSnapshot); - } - - /// Recursively expands type registration using DiagnosticAccumulator (for Select-step analysis). - private static void ExpandTypeRegistrationAcc( - DiagnosticAccumulator acc, - Location location, - INamedTypeSymbol type, - ImmutableArray routePrefix, - bool mergeOuterTypeSegment, - RegistryNode attachTo, - CSharpParseOptions parseOpts, - Compilation? compilation) - { - if (mergeOuterTypeSegment) - { - AddMethodsFromTypeAcc(acc, location, type, routePrefix, attachTo, parseOpts, compilation); - } - else - { - var seg = Naming.ToTypeSegmentName(type.Name); - var wrapper = new RegistryNode(); - var outerPrefix = AppendSegment(routePrefix, seg); - ExpandTypeRegistrationAcc(acc, location, type, outerPrefix, mergeOuterTypeSegment: true, wrapper, parseOpts, compilation); - attachTo.Children.Add(new RegistryNode.NamedCommandNamespaceChild - { - Segment = seg, - Node = wrapper, - SummaryOneLiner = GetTypeListingSummaryOneLiner(type), - Location = location - }); - } - } - - /// Converts a RegistryNode to a symbol-free RegistryNodeSnapshot. - private static RegistryNodeSnapshot BuildRegistryNodeSnapshot(RegistryNode node) - { - var children = ImmutableArray.CreateBuilder(node.Children.Count); - foreach (var ch in node.Children) - children.Add(new ChildNamespaceSnapshot(ch.Segment, BuildRegistryNodeSnapshot(ch.Node), ch.SummaryOneLiner)); - return new RegistryNodeSnapshot( - node.RootCommand, - node.Commands.ToImmutableArray(), - children.ToImmutable(), - node.SummaryInnerXml, - node.RemarksInnerXml, - AliasCommand: node.RootAlias); - } - - // ───────────────────────────────────────────────────────────────────────────── - - - /// - /// Overload that builds the emit model from pre-analyzed (symbol-free) invocations — used by the truly incremental pipeline. - /// - private static bool TryBuildAppEmitModel( - SourceProductionContext context, - ImmutableArray allAnalyzed, - out AppEmitModel? model) - { - model = null; - - // Report any embedded diagnostics collected during AnalyzeInvocation. - foreach (var ai in allAnalyzed) - { - if (ai is AIMapNamespace ns) - foreach (var pd in ns.EmbeddedDiagnostics) - context.ReportDiagnostic(Diagnostic.Create(GetDescriptorById(pd.DescriptorId), pd.Span.ToLocation(), pd.Arg0, pd.Arg1)); - } - - var sorted = allAnalyzed - .OrderBy(a => a.FilePath, StringComparer.Ordinal) - .ThenBy(a => a.SpanStart) - .ToList(); - - // Identify root-level invocations: those NOT contained inside any AIMapNamespace lambda body. - var rootAnalyzed = new List(); - foreach (var ai in sorted) - { - if (!IsInsideAnyMapNamespaceLambda(ai, sorted)) - rootAnalyzed.Add(ai); - } - - var app = new AppEmitModel(); - - // Collect global middleware from root UseMiddleware invocations. - var mwBuilder = ImmutableArray.CreateBuilder(); - foreach (var ai in rootAnalyzed) - { - if (ai is AIUseMiddleware { Registration: { TypeFq: { Length: > 0 } } reg }) - mwBuilder.Add(reg); - else if (ai is AIUseMiddleware { Registration: { TypeFq: "" } }) - context.ReportDiagnostic(Diagnostic.Create(UseMiddlewareDelegateNotSupported, ai.GetType() == typeof(AIUseMiddleware) ? Location.None : Location.None)); - } - app.GlobalMiddleware = mwBuilder.ToImmutable(); - - foreach (var ai in rootAnalyzed) - { - if (ai is AIUseCliDescription { Description: var desc } && !string.IsNullOrWhiteSpace(desc)) - { - app.RootSummary = desc; - break; - } - } - - foreach (var ai in rootAnalyzed) - { - if (ai is AIDocumentEnvironmentVariables { Variables: var vars, ConfigFiles: var cfgs }) - { - if (!vars.IsDefaultOrEmpty) app.EnvironmentVars = vars; - if (!cfgs.IsDefaultOrEmpty) app.ConfigFiles = cfgs; - break; - } - } - - foreach (var ai in rootAnalyzed) - { - if (ai is AIUseSchemaVersion { Version: var v } && !string.IsNullOrWhiteSpace(v)) - { - app.SchemaVersionOverride = v; - break; - } - } - - ProcessAnalyzedInvocationsForNode(context, sorted, rootAnalyzed, app.Root, ImmutableArray.Empty, app, isRoot: true); - - if (!string.IsNullOrWhiteSpace(app.RootSummary) && app.Root.RootCommand is not null) - { - var descAi = rootAnalyzed.OfType().FirstOrDefault(); - var loc = descAi is not null - ? Location.Create(descAi.FilePath, new Microsoft.CodeAnalysis.Text.TextSpan(descAi.SpanStart, 0), default) - : Location.None; - context.ReportDiagnostic(Diagnostic.Create(UseCliDescriptionConflictsWithMapRoot, loc)); - } - - ValidateCommandNamespaceOptionsChain(context, app.Root, parentEffectiveOptionsMetadataName: app.GlobalOptionsModel?.TypeMetadataName); - if (!ValidateNamespaceSegmentSanitizationCollisions(context, app.Root)) - return false; - - // OptionsModels are already set from AIUseGlobalOptions / AIUseNamespaceOptions via ProcessAnalyzedInvocationsForNode. - - var flat = new List(); - CollectCommands(app.Root, flat); - model = app; - if (flat.Count == 0) - return false; - - var dedup = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var c in flat) - { - var key = string.Join("/", c.RoutePrefix) + "/" + c.CommandName; - if (dedup.ContainsKey(key)) - { - context.ReportDiagnostic(Diagnostic.Create(DuplicateCommandName, c.HandlerSpanInfo.ToLocation(), c.CommandName)); - continue; - } - dedup[key] = c; - } - - app.AllCommands = dedup.Values.ToImmutableArray(); - // GlobalOptionsModel is set during ProcessAnalyzedInvocationsForNode. - - // Pre-compute injection chains once per command; reused by validation, FixOptionsParamsInCommands, EmitOptionsReconstructLocals, and emit. - // [NoOptionsInjection] only suppresses handler parameters and AGH0021 — globals/namespaced flags must still splice as OptionsInjected - // for short/long parsing and static-field reconstruction after the route segment. - app.InjectionChains = app.AllCommands.ToImmutableDictionary( - cmd => cmd.RunMethodName, - cmd => BuildOptionsInjectionChain(app, cmd), - StringComparer.Ordinal); - - ValidateCommandOptionsInjection(context, app); - FixOptionsParamsInCommands(app); - ValidateDuplicateShortOptionLetters(context, app); - - return true; - } - - /// Determines if a given AnalyzedInvocation is positionally inside any AIMapNamespace lambda body. - private static bool IsInsideAnyMapNamespaceLambda(AnalyzedInvocation ai, List all) - { - foreach (var other in all) - { - if (other is not AIMapNamespace ns) continue; - if (ns.LambdaBodyStart < 0 || ns.LambdaBodyEnd < 0) continue; - if (!string.Equals(ns.FilePath, ai.FilePath, StringComparison.Ordinal)) continue; - // Inclusive lower bound: for expression-bodied lambdas (e.g. g => g.MapNamespace(...)), - // the nested invocation's SpanStart equals the lambda body's SpanStart and must count as inside. - if (ai.SpanStart >= ns.LambdaBodyStart && ai.SpanStart < ns.LambdaBodyEnd) - return true; - } - return false; - } - - /// Builds the registry tree from pre-analyzed invocations for a given node scope. - private static void ProcessAnalyzedInvocationsForNode( - SourceProductionContext context, - List allAnalyzed, - List nodeInvocations, - RegistryNode node, - ImmutableArray currentPath, - AppEmitModel app, - bool isRoot) - { - foreach (var ai in nodeInvocations) - { - switch (ai) - { - case AIUseGlobalOptions g when isRoot: - app.GlobalOptionsModel = g.Model; - break; - case AIUseGlobalOptions when !isRoot: - context.ReportDiagnostic(Diagnostic.Create( - CommandNamespaceOptionsRequiresParent, - Location.None, - "T")); - break; - case AIUseNamespaceOptions ns when !isRoot: - node.CommandNamespaceOptionsModel = ns.Model; - node.CommandNamespaceOptionsLocation = Location.None; - break; - case AIUseNamespaceOptions when isRoot: - context.ReportDiagnostic(Diagnostic.Create( - CommandNamespaceOptionsRequiresParent, - Location.None, - "T")); - break; - case AIMapCommand { TypeSnapshot: { } typeSnap } mapCmd: - { - foreach (var pd in mapCmd.EmbeddedDiagnosticsOrEmpty) - context.ReportDiagnostic(Diagnostic.Create(GetDescriptorById(pd.DescriptorId), pd.Span.ToLocation(), pd.Arg0, pd.Arg1)); - // Map always hoists: merge the snapshot's commands directly into the current node. - if (typeSnap.RootCommand is { } snapRc && node.RootCommand is not null) - context.ReportDiagnostic(Diagnostic.Create(DuplicateRootCommand, snapRc.HandlerSpanInfo.ToLocation())); - ApplyRegistryNodeSnapshot(typeSnap, node, currentPath); - break; - } - case AIMapAndRootAlias alias: - { - foreach (var pd in alias.EmbeddedDiagnostics) - context.ReportDiagnostic(Diagnostic.Create(GetDescriptorById(pd.DescriptorId), pd.Span.ToLocation(), pd.Arg0, pd.Arg1)); - if (node.RootAlias is not null || node.RootCommand is not null) - { - context.ReportDiagnostic(Diagnostic.Create(DuplicateRootCommand, Location.None)); - break; - } - ApplyRegistryNodeSnapshot(alias.TypeSnapshot, node, currentPath); - break; - } - case AIMapCommand ac: - foreach (var pd in ac.EmbeddedDiagnosticsOrEmpty) - context.ReportDiagnostic(Diagnostic.Create(GetDescriptorById(pd.DescriptorId), pd.Span.ToLocation(), pd.Arg0, pd.Arg1)); - foreach (var cmd in ac.Commands) - { - // Re-prefix with the current path (commands were analyzed with empty prefix). - var prefixed = cmd with - { - RoutePrefix = currentPath, - RunMethodName = currentPath.IsDefaultOrEmpty - ? cmd.RunMethodName - : CommandModel.BuildRunMethodNameStatic(currentPath, cmd.CommandName), - UsageHints = cmd.UsageHints - }; - if (cmd.IsRootDefault) - node.RootCommand = prefixed; - else - node.Commands.Add(prefixed); - } - break; - case AIMapRootCommand rc when isRoot && rc.IsNamespaceRoot: - context.ReportDiagnostic(Diagnostic.Create(AddNamespaceRootCommandOnlyInNamespace, Location.None)); - break; - case AIMapRootCommand rc when !isRoot && !rc.IsNamespaceRoot: - context.ReportDiagnostic(Diagnostic.Create(AddRootCommandOnlyAtAppRoot, Location.None)); - break; - case AIMapRootCommand rc: - { - if (node.RootCommand is not null) - { - context.ReportDiagnostic(Diagnostic.Create(DuplicateRootCommand, Location.None)); - break; - } - // Re-prefix with current path. - var prefixedRoot = rc.Cmd with - { - RoutePrefix = currentPath, - RunMethodName = CommandModel.BuildRootDefaultRunMethodName(currentPath), - }; - node.RootCommand = prefixedRoot; - break; - } - case AIUseMiddleware: - // Handled at root level for global middleware (done before this method is called). - break; - case AIMapNamespace ns: - ProcessAnalyzedMapNamespace(context, allAnalyzed, ns, node, currentPath, app, isRoot); - break; - } - } - } - - private static void ProcessAnalyzedMapNamespace( - SourceProductionContext context, - List allAnalyzed, - AIMapNamespace ns, - RegistryNode parentNode, - ImmutableArray parentPath, - AppEmitModel app, - bool isRoot) - { - var childNode = new RegistryNode(); - var childPath = AppendSegment(parentPath, ns.SegmentName); - - // Find child invocations positionally. - var childInvocations = new List(); - if (ns.LambdaBodyStart >= 0 && ns.LambdaBodyEnd >= 0) - { - foreach (var other in allAnalyzed) - { - if (!string.Equals(other.FilePath, ns.FilePath, StringComparison.Ordinal)) continue; - if (other.SpanStart < ns.LambdaBodyStart || other.SpanStart >= ns.LambdaBodyEnd) continue; - // Skip invocations that are nested inside a deeper lambda (not direct children). - if (IsInsideAnyNestedMapNamespaceLambda(other, allAnalyzed, ns)) continue; - childInvocations.Add(other); - } - childInvocations.Sort((a, b) => - { - var c = string.CompareOrdinal(a.FilePath, b.FilePath); - return c != 0 ? c : a.SpanStart.CompareTo(b.SpanStart); - }); - } - - // If we have a namespace entry type (AddNamespace), apply its pre-computed snapshot. - if (ns.EntryTypeSnapshot is { } snap) - { - ApplyRegistryNodeSnapshot(snap, childNode, childPath); - childNode.SummaryInnerXml = ns.NsSummaryInnerXml; - childNode.RemarksInnerXml = ns.NsRemarksInnerXml; - } - - // Register argless segment codegen. - if (ns.IsArglessSegment && ns.EntryTypeFq is { Length: > 0 } arglessFq) - { - foreach (var existing in app.ArglessNamespaceCodegen) - { - if (string.Equals(existing.TypeFq, arglessFq, StringComparison.Ordinal)) - goto skipArglessAdd; - } - app.ArglessNamespaceCodegen.Add(new ArglessNamespaceCodegenEntry(arglessFq, ns.SegmentName)); - skipArglessAdd:; - } - - ProcessAnalyzedInvocationsForNode(context, allAnalyzed, childInvocations, childNode, childPath, app, isRoot: false); - - if (IsRegistryNodeVacuous(childNode)) - context.ReportDiagnostic(Diagnostic.Create(VacuousNamespace, ns.DiagnosticSpanInfo.ToLocation())); - - parentNode.Children.Add(new RegistryNode.NamedCommandNamespaceChild - { - Segment = ns.SegmentName, - Node = childNode, - SummaryOneLiner = ns.NsSummary, - Location = ns.DiagnosticSpanInfo.ToLocation() - }); - } - - /// Checks if an invocation is inside a nested AddNamespace lambda that is itself inside ns. - private static bool IsInsideAnyNestedMapNamespaceLambda(AnalyzedInvocation ai, List all, AIMapNamespace parent) - { - foreach (var other in all) - { - if (other is not AIMapNamespace nested) continue; - if (ReferenceEquals(nested, parent)) continue; - if (nested.LambdaBodyStart < 0 || nested.LambdaBodyEnd < 0) continue; - if (!string.Equals(nested.FilePath, ai.FilePath, StringComparison.Ordinal)) continue; - // nested must itself be inside parent (inclusive lower bound for expression-bodied lambdas). - if (nested.SpanStart < parent.LambdaBodyStart || nested.SpanStart >= parent.LambdaBodyEnd) continue; - // ai must be inside nested - if (ai.SpanStart >= nested.LambdaBodyStart && ai.SpanStart < nested.LambdaBodyEnd) - return true; - } - return false; - } - - /// Applies a pre-computed RegistryNodeSnapshot to a live RegistryNode (re-prefixing commands). - private static void ApplyRegistryNodeSnapshot(RegistryNodeSnapshot snap, RegistryNode target, ImmutableArray path) - { - if (snap.RootCommand is { } rc) - { - var prefixed = rc with - { - RoutePrefix = path, - RunMethodName = CommandModel.BuildRootDefaultRunMethodName(path) - }; - // Only set if not already set by an explicit AddNamespaceRootCommand in the lambda body. - target.RootCommand ??= prefixed; - } - CommandModel? prefixedAlias = null; - foreach (var cmd in snap.Commands) - { - var prefixed = cmd with - { - RoutePrefix = path, - RunMethodName = CommandModel.BuildRunMethodNameStatic(path, cmd.CommandName) - }; - target.Commands.Add(prefixed); - // Track the re-prefixed alias if this command was designated as the alias target. - if (snap.AliasCommand is not null && cmd.CommandName == snap.AliasCommand.CommandName) - prefixedAlias = prefixed; - } - if (prefixedAlias is not null) - target.RootAlias ??= prefixedAlias; - foreach (var childSnap in snap.Children) - { - var childPath = AppendSegment(path, childSnap.Segment); - var childNode = new RegistryNode(); - childNode.SummaryInnerXml = childSnap.Node.SummaryInnerXml; - childNode.RemarksInnerXml = childSnap.Node.RemarksInnerXml; - ApplyRegistryNodeSnapshot(childSnap.Node, childNode, childPath); - target.Children.Add(new RegistryNode.NamedCommandNamespaceChild - { - Segment = childSnap.Segment, - Node = childNode, - SummaryOneLiner = childSnap.SummaryOneLiner, - Location = Location.None - }); - } - target.SummaryInnerXml = snap.SummaryInnerXml; - target.RemarksInnerXml = snap.RemarksInnerXml; - } - - private static void ValidateCommandNamespaceOptionsChain( - SourceProductionContext context, - RegistryNode node, - string? parentEffectiveOptionsMetadataName) - { - var nsModel = node.CommandNamespaceOptionsModel; - if (nsModel is not null) - { - if (parentEffectiveOptionsMetadataName is null) - { - context.ReportDiagnostic(Diagnostic.Create( - CommandNamespaceOptionsRequiresParent, - node.CommandNamespaceOptionsLocation ?? Location.None, - GetShortTypeName(nsModel.TypeMetadataName))); - } - else if (nsModel.TypeMetadataName != parentEffectiveOptionsMetadataName - && !nsModel.AllBaseTypeMetadataNames.Contains(parentEffectiveOptionsMetadataName)) - { - context.ReportDiagnostic(Diagnostic.Create( - CommandNamespaceOptionsMustExtendParent, - node.CommandNamespaceOptionsLocation ?? Location.None, - GetShortTypeName(nsModel.TypeMetadataName), - GetShortTypeName(parentEffectiveOptionsMetadataName))); - } - } - - var nextParent = nsModel?.TypeMetadataName ?? parentEffectiveOptionsMetadataName; - foreach (var child in node.Children) - ValidateCommandNamespaceOptionsChain(context, child.Node, nextParent); - } - - private static string GetShortTypeName(string metadataName) - { - var dot = metadataName.LastIndexOf('.'); - return dot >= 0 ? metadataName.Substring(dot + 1) : metadataName; - } - - private static bool ValidateNamespaceSegmentSanitizationCollisions(SourceProductionContext context, RegistryNode node) - { - var seen = new Dictionary(StringComparer.Ordinal); - var ok = true; - foreach (var child in node.Children) - { - var sanitized = Naming.SanitizeIdentifier(child.Segment); - if (seen.TryGetValue(sanitized, out var first)) - { - context.ReportDiagnostic(Diagnostic.Create( - NamespaceSegmentSanitizationCollision, - child.Location, - first, - child.Segment, - sanitized)); - ok = false; - } - else - { - seen[sanitized] = child.Segment; - } - } - foreach (var child in node.Children) - { - if (!ValidateNamespaceSegmentSanitizationCollisions(context, child.Node)) - ok = false; - } - return ok; - } - - /// - /// AGH0021: every non-lambda command must inject its most specific applicable options type - /// (global or namespace-scoped) as a method parameter or constructor parameter. - /// - private static void ValidateCommandOptionsInjection(SourceProductionContext context, AppEmitModel app) - { - foreach (var cmd in app.AllCommands) - { - if (cmd.IsLambda || cmd.HandlerParamTypes.IsDefaultOrEmpty && !cmd.RequiresInstance) - continue; - if (cmd.HandlerHasNoOptionsInjection) - continue; - - // Most specific required options type = last entry in the injection chain. - var chain = app.InjectionChains.TryGetValue(cmd.RunMethodName, out var precomputed) - ? precomputed - : BuildOptionsInjectionChain(app, cmd); - if (chain.IsEmpty) - continue; - var (requiredTypeFq, requiredMetaName, requiredBaseNames, _, _, _, _) = chain[chain.Length - 1]; - - // Check method parameters first. - var injected = false; - foreach (var mp in cmd.HandlerParamTypes) - { - // mp.TypeMetadataName == requiredMetaName: exact match - // mp.TypeAllBaseTypeMetadataNames.Contains(requiredMetaName): mp's type is a subclass of the required type - if (mp.TypeMetadataName == requiredMetaName || - mp.TypeAllBaseTypeMetadataNames.Contains(requiredMetaName)) - { - injected = true; - break; - } - } - - // For instance methods, also accept injection via constructor. - if (!injected && cmd.RequiresInstance) - { - foreach (var cp in cmd.ContainingTypeCtorParams) - { - if (cp.TypeMetadataName == requiredMetaName || - requiredBaseNames.Contains(cp.TypeMetadataName)) - { - injected = true; - break; - } - } - } - - if (!injected) - { - context.ReportDiagnostic(Diagnostic.Create( - CommandMustInjectOptions, - cmd.HandlerSpanInfo.ToLocation(), - cmd.MethodName, - requiredMetaName)); // use pre-computed metadata name instead of ToDisplayString - } - } - } - - /// - /// Returns the ordered chain of options entries (global → most-specific namespace) for injection into a command. - /// Walks the registry tree directly so namespace options types with zero own members are still included. - /// Each entry carries the static field name (pre-parsed fallback) and a local var name (command-runner reconstruction). - /// All fields are symbol-free (strings / pre-computed ParameterModel arrays). - /// - private static ImmutableArray<(string TypeFq, string TypeMetadataName, ImmutableArray AllBaseTypeMetadataNames, string StaticFieldName, string LocalVarName, ImmutableArray FlatMembers, ImmutableArray? BestCtorParamOrder)> - BuildOptionsInjectionChain(AppEmitModel app, CommandModel cmd) - { - var result = ImmutableArray.CreateBuilder<(string, string, ImmutableArray, string, string, ImmutableArray, ImmutableArray?)>(); - if (app.GlobalOptionsModel is { } gom) - result.Add(( - gom.TypeFq, - gom.TypeMetadataName, - gom.AllBaseTypeMetadataNames, - OptionsStaticFieldNameFq(gom.TypeFq), - OptionsLocalVarNameFq(gom.TypeFq), - gom.FlattenedMembers, - gom.BestCtorParamOrder)); - - var current = app.Root; - foreach (var seg in cmd.RoutePrefix) - { - RegistryNode.NamedCommandNamespaceChild? found = null; - foreach (var ch in current.Children) - { - if (string.Equals(ch.Segment, seg, StringComparison.OrdinalIgnoreCase)) - { - found = ch; - break; - } - } - if (found is null) break; - current = found.Node; - if (current.CommandNamespaceOptionsModel is { } nsModel) - result.Add(( - nsModel.TypeFq, - nsModel.TypeMetadataName, - nsModel.AllBaseTypeMetadataNames, - OptionsStaticFieldNameFq(nsModel.TypeFq), - OptionsLocalVarNameFq(nsModel.TypeFq), - nsModel.FlattenedMembers, - nsModel.BestCtorParamOrder)); - } - - return result.ToImmutable(); - } - - /// - /// Removes options-type parameters from each command's so the - /// flag-parsing codegen ignores them. They are injected separately via static fields in . - /// - private static void FixOptionsParamsInCommands(AppEmitModel app) - { - var updated = ImmutableArray.CreateBuilder(app.AllCommands.Length); - foreach (var cmd in app.AllCommands) - { - // Lambdas have no reconstructed options-instance surface; globals still participate via leading prefetch only when applicable. - if (cmd.IsLambda) - { - updated.Add(cmd); - continue; - } - - var injChain = app.InjectionChains.TryGetValue(cmd.RunMethodName, out var precomputed2) - ? precomputed2 - : BuildOptionsInjectionChain(app, cmd); - if (injChain.IsEmpty) - { - updated.Add(cmd); - continue; - } - - // Remove original options-type params; replace with OptionsInjected entries for each flattened - // member so bool-switch / short-opt / canon-name machinery still recognises those flags. - var filtered = cmd.Parameters.Where(p => - { - if (p.AsParametersOwnerParamName is not null) return true; - var handlerParam = cmd.HandlerParamTypes.FirstOrDefault(mp => mp.Name == p.SymbolName); - if (handlerParam is null) return true; - // Keep the param only if its type is NOT the options type and NOT a subclass of it. - // handlerParam.TypeAllBaseTypeMetadataNames.Contains(o.TypeMetadataName) = param's type inherits from the options type. - return !injChain.Any(o => - o.TypeMetadataName == handlerParam.TypeMetadataName || - handlerParam.TypeAllBaseTypeMetadataNames.Contains(o.TypeMetadataName)); - }).ToList(); - - // Add flattened options members as OptionsInjected so the flag parser handles them correctly. - // Pre-seed with CLI names already present (e.g. from [AsParameters] expansion) to avoid duplicates. - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var p in filtered) - if (p.Kind == ParameterKind.Flag) seen.Add(p.CliLongName); - - foreach (var (_, _, _, _, _, flatMembers, _) in injChain) - { - foreach (var m in flatMembers) - { - if (m.Kind != ParameterKind.Flag) continue; - if (!seen.Add(m.CliLongName)) continue; // dedup inherited members - // Create an OptionsInjected entry — only flag-recognition fields matter here. - filtered.Add(m with { Kind = ParameterKind.OptionsInjected }); - } - } - - var newParams = filtered.ToImmutableArray(); - updated.Add(cmd with - { - Parameters = newParams, - // Rebuild usage hints now that options params are stripped. - UsageHints = UsageSynopsis.Build(newParams) - }); - } - - app.AllCommands = updated.ToImmutable(); - - // Also update RootCommand references in RegistryNodes so help printers see the fixed parameters. - var fixedById = new Dictionary(StringComparer.Ordinal); - foreach (var cmd in app.AllCommands) - fixedById[cmd.RunMethodName] = cmd; - UpdateRegistryNodeRootCommands(app.Root, fixedById); - } - - private static void UpdateRegistryNodeRootCommands(RegistryNode node, Dictionary fixedById) - { - if (node.RootCommand is not null && fixedById.TryGetValue(node.RootCommand.RunMethodName, out var fixedRoot)) - node.RootCommand = fixedRoot; - if (node.RootAlias is not null && fixedById.TryGetValue(node.RootAlias.RunMethodName, out var fixedAlias)) - node.RootAlias = fixedAlias; - // node.Commands is read by schema emission — fix it too so injected options params are stripped from schema output - for (var i = 0; i < node.Commands.Count; i++) - { - if (fixedById.TryGetValue(node.Commands[i].RunMethodName, out var fixedCmd)) - node.Commands[i] = fixedCmd; - } - foreach (var child in node.Children) - UpdateRegistryNodeRootCommands(child.Node, fixedById); - } - - private static bool TypeInheritsFromOrImplements(INamedTypeSymbol type, INamedTypeSymbol baseOrInterface) - { - var current = type; - while (current is not null) - { - if (SymbolEqualityComparer.Default.Equals(current, baseOrInterface)) - return true; - current = current.BaseType; - } - - foreach (var iface in type.AllInterfaces) - { - if (SymbolEqualityComparer.Default.Equals(iface, baseOrInterface)) - return true; - } - - return false; - } - - private static void CollectCommands(RegistryNode node, List sink) - { - if (node.RootCommand is { } rc) - sink.Add(rc); - sink.AddRange(node.Commands); - foreach (var child in node.Children) - CollectCommands(child.Node, sink); - } - - - - private static bool TryGetNamespaceSegmentAttribute(INamedTypeSymbol type, out string segment) - { - segment = ""; - foreach (var ad in type.GetAttributes()) - { - if (ad.AttributeClass?.Name != "NamespaceSegmentAttribute" || - ad.AttributeClass.ContainingNamespace?.ToDisplayString() != "Nullean.Argh") - continue; - if (ad.ConstructorArguments.Length > 0 && ad.ConstructorArguments[0].Value is string s && !string.IsNullOrWhiteSpace(s)) - { - segment = s; - return true; - } - } - - return false; - } - - private static bool TryGetFirstCodeInTypeSummary(INamedTypeSymbol type, out string code) - { - code = ""; - var xml = type.GetDocumentationCommentXml(); - if (string.IsNullOrWhiteSpace(xml)) - return false; - try - { - var doc = XDocument.Parse("" + xml + "", LoadOptions.PreserveWhitespace); - var root = doc.Root; - var sum = root?.Descendants().FirstOrDefault(e => e.Name.LocalName == "summary"); - var c = sum?.Descendants().FirstOrDefault(e => e.Name.LocalName == "c"); - if (c is null || string.IsNullOrWhiteSpace(c.Value)) - return false; - code = c.Value.Trim(); - return IdentifierSegmentPattern.IsMatch(code); - } - catch - { - return false; - } - } - - private static bool TryResolveNamespaceSegmentForArgless( - SourceProductionContext context, - INamedTypeSymbol type, - Location errorLocation, - out string segment) - { - segment = ""; - var hasAttr = TryGetNamespaceSegmentAttribute(type, out var attrSeg); - var hasXml = TryGetFirstCodeInTypeSummary(type, out var xmlSeg); - if (!hasAttr && !hasXml) - { - context.ReportDiagnostic(Diagnostic.Create(NamespaceSegmentUnresolved, errorLocation, type.Name)); - return false; - } - - if (hasAttr && hasXml && !string.Equals(attrSeg, xmlSeg, StringComparison.Ordinal)) - { - context.ReportDiagnostic(Diagnostic.Create(NamespaceSegmentConflict, errorLocation, type.Name, attrSeg, xmlSeg)); - return false; - } - - segment = hasAttr ? attrSeg : xmlSeg; - return true; - } - - private static void ValidateNamespaceSegmentForExplicitName( - SourceProductionContext context, - INamedTypeSymbol type, - string literalSegment, - Location location) - { - var hasAttr = TryGetNamespaceSegmentAttribute(type, out var attrSeg); - var hasXml = TryGetFirstCodeInTypeSummary(type, out var xmlSeg); - if (hasAttr && hasXml && !string.Equals(attrSeg, xmlSeg, StringComparison.Ordinal)) - context.ReportDiagnostic(Diagnostic.Create(NamespaceSegmentConflict, location, type.Name, attrSeg, xmlSeg)); - if (hasAttr && !string.Equals(attrSeg, literalSegment, StringComparison.Ordinal)) - context.ReportDiagnostic(Diagnostic.Create(NamespaceSegmentConflict, location, type.Name, attrSeg, literalSegment)); - if (hasXml && !string.Equals(xmlSeg, literalSegment, StringComparison.Ordinal)) - context.ReportDiagnostic(Diagnostic.Create(NamespaceSegmentConflict, location, type.Name, xmlSeg, literalSegment)); - } - - private static void RegisterArglessNamespaceCodegen( - SourceProductionContext context, - AppEmitModel app, - INamedTypeSymbol type, - string segment, - Location location) - { - var typeFq = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - foreach (var existing in app.ArglessNamespaceCodegen) - { - if (!string.Equals(existing.TypeFq, typeFq, StringComparison.Ordinal)) - continue; - if (!string.Equals(existing.Segment, segment, StringComparison.Ordinal)) - context.ReportDiagnostic(Diagnostic.Create(NamespaceSegmentConflict, location, type.Name, existing.Segment, segment)); - return; - } - - app.ArglessNamespaceCodegen.Add(new ArglessNamespaceCodegenEntry(typeFq, segment)); - } - - private static bool IsRegistryNodeVacuous(RegistryNode node) => - node.RootCommand is null && node.Commands.Count == 0 && node.Children.Count == 0; - - - private static InvocationExpressionSyntax? FindParentMapNamespaceInvocation(InvocationExpressionSyntax invocation) - { - for (var n = invocation.Parent; n != null; n = n.Parent) - { - if (n is LambdaExpressionSyntax lambda && IsMapNamespaceConfigureLambda(lambda, out var addNamespaceInv)) - return addNamespaceInv; - } - - return null; - } - - private static bool IsMapNamespaceConfigureLambda(LambdaExpressionSyntax lambda, out InvocationExpressionSyntax addNamespaceInv) - { - addNamespaceInv = null!; - if (lambda.Parent is not ArgumentSyntax { Parent: ArgumentListSyntax al }) - return false; - if (al.Parent is not InvocationExpressionSyntax inv) - return false; - if (inv.Expression is not MemberAccessExpressionSyntax ma || ma.Name is not SimpleNameSyntax sns || - sns.Identifier.Text != "MapNamespace") - return false; - var last = al.Arguments.Count - 1; - if (last < 0 || !ReferenceEquals(al.Arguments[last].Expression, lambda)) - return false; - addNamespaceInv = inv; - return true; - } - private static bool IsRedundantGenericAddForNamespaceEntry( - InvocationExpressionSyntax inv, - INamedTypeSymbol namespaceEntryType, - Compilation compilation) - { - var model = TryGetSemanticModelForSyntaxTree(compilation, inv.SyntaxTree); - if (model is null) - return false; - if (model.GetSymbolInfo(inv).Symbol is not IMethodSymbol method || method.Name != "Map" || !method.IsGenericMethod) - return false; - if (method.TypeArguments.Length != 1) - return false; - if (method.TypeArguments[0] is not INamedTypeSymbol addType || addType.TypeKind == TypeKind.Error) - return false; - return SymbolEqualityComparer.Default.Equals(addType, namespaceEntryType); - } - - private static void ExpandMapStringDelegate( - SourceProductionContext context, - SemanticModel model, - InvocationExpressionSyntax invocation, - ImmutableArray routePrefix, - RegistryNode targetNode) - { - var nameExpr = invocation.ArgumentList.Arguments[0].Expression; - var handlerExpr = invocation.ArgumentList.Arguments[1].Expression; - - var commandName = TryGetStringLiteral(nameExpr); - if (commandName is null || string.IsNullOrWhiteSpace(commandName)) - return; - - if (commandName.Equals("__argh_root", StringComparison.OrdinalIgnoreCase)) - { - context.ReportDiagnostic(Diagnostic.Create(ReservedCommandNameRoot, nameExpr.GetLocation(), commandName)); - return; - } - - // Detect lambda expressions — handle them as stored-delegate commands - if (handlerExpr is LambdaExpressionSyntax) - { - TryExpandLambdaDelegate(context, model, invocation, handlerExpr, commandName, routePrefix, targetNode); - return; - } - - var handler = ResolveHandlerMethod(model, handlerExpr, context, invocation); - if (handler is null) - return; - - var parseOpts = invocation.SyntaxTree.Options as CSharpParseOptions ?? CSharpParseOptions.Default; - targetNode.Commands.Add(CommandModel.FromMethod(commandName, handler, parseOpts, routePrefix, context, invocation.GetLocation())); - } - - /// - /// Synthesizes the fully-qualified BCL delegate type (System.Func<...> / System.Action<...>) - /// that the C# compiler infers as the "natural type" for a lambda with this signature. Used instead of reading the - /// converted-to type off the enclosing , because when a lambda is passed to a - /// Delegate-typed parameter (e.g. Map(string, Delegate)) that conversion's Type is - /// System.Delegate itself, not the lambda's actual runtime delegate type. - /// - private static string? BuildNaturalDelegateTypeFq(IMethodSymbol invokeMethod) - { - if (invokeMethod.Parameters.Length > 16) - return null; // Func<>/Action<> top out at 16 parameters; fall back to Delegate. - - foreach (var p in invokeMethod.Parameters) - if (p.RefKind != RefKind.None) - return null; // ref/out/in params have no Func<>/Action<> natural type; fall back to Delegate. - - var paramFqs = new string[invokeMethod.Parameters.Length]; - for (var i = 0; i < invokeMethod.Parameters.Length; i++) - paramFqs[i] = invokeMethod.Parameters[i].Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - - if (invokeMethod.ReturnsVoid) - { - return paramFqs.Length == 0 - ? "global::System.Action" - : $"global::System.Action<{string.Join(", ", paramFqs)}>"; - } - - var retFq = invokeMethod.ReturnType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - var allArgs = paramFqs.Length == 0 ? retFq : string.Join(", ", paramFqs) + ", " + retFq; - return $"global::System.Func<{allArgs}>"; - } - - /// Select-step (no SourceProductionContext) variant of . - private static void TryExpandLambdaDelegateAcc( - SemanticModel model, - InvocationExpressionSyntax invocation, - ExpressionSyntax handlerExpr, - string commandName, - ImmutableArray routePrefix, - RegistryNode targetNode) => - TryExpandLambdaDelegate(null, model, invocation, handlerExpr, commandName, routePrefix, targetNode); - - private static void TryExpandLambdaDelegate( - SourceProductionContext? context, - SemanticModel model, - InvocationExpressionSyntax invocation, - ExpressionSyntax handlerExpr, - string commandName, - ImmutableArray routePrefix, - RegistryNode targetNode) - { - // Get the converted delegate type via type info (the lambda is implicitly converted to Delegate) - var op = model.GetOperation(handlerExpr); - // Unwrap conversions - while (op is IConversionOperation conv) - op = conv.Operand; - - IMethodSymbol? invokeMethod = null; - - if (op is IAnonymousFunctionOperation anonFunc) - invokeMethod = anonFunc.Symbol; - - if (invokeMethod is null) - return; - - // Build the storage key: "namespace/name" for nested, "name" for root - var storageKey = routePrefix.IsDefaultOrEmpty - ? commandName - : string.Join("/", routePrefix) + "/" + commandName; - - // Get the FQ delegate type string for casting at runtime. Synthesized from the lambda's own signature - // rather than the enclosing conversion's Type, which — since the target parameter is `Delegate` — would - // otherwise resolve to `System.Delegate` itself and force a reflection-based DynamicInvoke fallback that - // silently discards the handler's return value (see BuildNaturalDelegateTypeFq). - var delegateFq = BuildNaturalDelegateTypeFq(invokeMethod) ?? "global::System.Delegate"; - - var parseOpts = invocation.SyntaxTree.Options as CSharpParseOptions ?? CSharpParseOptions.Default; - - // Build parameter models from the lambda's method symbol - var paramBuilder = ImmutableArray.CreateBuilder(); - foreach (var p in invokeMethod.Parameters) - { - paramBuilder.Add(ParameterModel.From(p, context, null, invocation.GetLocation())); - } - var parameters = paramBuilder.ToImmutable(); - var usage = UsageSynopsis.Build(parameters); - // Build run method name inline (mirrors CommandModel.BuildRunMethodName) - string runName; - if (routePrefix.IsDefaultOrEmpty) - runName = "Run_" + Naming.SanitizeIdentifier(commandName); - else - { - var rnSb = new StringBuilder(); - rnSb.Append("Run"); - foreach (var seg in routePrefix) { rnSb.Append('_'); rnSb.Append(Naming.SanitizeIdentifier(seg)); } - rnSb.Append('_'); rnSb.Append(Naming.SanitizeIdentifier(commandName)); - runName = rnSb.ToString(); - } - var retFq = invokeMethod.ReturnType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - // FullyQualifiedFormat renders special types via their C# keyword ("void"), not "global::System.Void". - var retIsVoid = retFq is "void" - or "global::System.Threading.Tasks.Task" - or "global::System.Threading.Tasks.ValueTask"; - var retIsAsync = retFq is "global::System.Threading.Tasks.Task" - or "global::System.Threading.Tasks.ValueTask" - || (invokeMethod.ReturnType is INamedTypeSymbol rNamed && rNamed.IsGenericType && - (rNamed.ConstructedFrom.Name is "Task" or "ValueTask") && - rNamed.ConstructedFrom.ContainingNamespace?.ToDisplayString() == "System.Threading.Tasks"); - - var cmd = new CommandModel( - routePrefix, - commandName, - runName, - "object", - "__lambda", - false, - false, - retFq, - retIsAsync, - retIsVoid, - parameters, - false, - ImmutableArray.Empty, - SourceSpanInfo.None, - ImmutableArray<(string, string)>.Empty, - "", // HandlerDocCommentId - "", - "", - "", - "", - "", - usage, - ImmutableArray<(string, bool)>.Empty, - IsLambda: true, - LambdaStorageKey: storageKey, - LambdaDelegateFq: delegateFq); - - targetNode.Commands.Add(cmd); - } - - private const string RootDefaultInternalCommandName = "__argh_root"; - - private static void ExpandMapRootCommand( - SourceProductionContext context, - SemanticModel model, - InvocationExpressionSyntax invocation, - ImmutableArray routePrefix, - RegistryNode targetNode) - { - if (targetNode.RootCommand is not null) - { - context.ReportDiagnostic(Diagnostic.Create(DuplicateRootCommand, invocation.GetLocation())); - return; - } - - if (invocation.ArgumentList.Arguments.Count < 1) - return; - - var handlerExpr = invocation.ArgumentList.Arguments[0].Expression; - if (handlerExpr is LambdaExpressionSyntax) - { - TryExpandLambdaRootCommand(context, model, invocation, handlerExpr, routePrefix, targetNode); - return; - } - - var handler = ResolveHandlerMethod(model, handlerExpr, context, invocation); - if (handler is null) - return; - - var parseOpts = invocation.SyntaxTree.Options as CSharpParseOptions ?? CSharpParseOptions.Default; - targetNode.RootCommand = CommandModel.FromRootMethod(handler, parseOpts, routePrefix, context, invocation.GetLocation()); - } - - /// Select-step (no SourceProductionContext) variant of . - private static void TryExpandLambdaRootCommandAcc( - SemanticModel model, - InvocationExpressionSyntax invocation, - ExpressionSyntax handlerExpr, - ImmutableArray routePrefix, - RegistryNode targetNode) => - TryExpandLambdaRootCommand(null, model, invocation, handlerExpr, routePrefix, targetNode); - - private static void TryExpandLambdaRootCommand( - SourceProductionContext? context, - SemanticModel model, - InvocationExpressionSyntax invocation, - ExpressionSyntax handlerExpr, - ImmutableArray routePrefix, - RegistryNode targetNode) - { - var op = model.GetOperation(handlerExpr); - while (op is IConversionOperation conv) - op = conv.Operand; - - if (op is not IAnonymousFunctionOperation anonFunc) - return; - - var invokeMethod = anonFunc.Symbol; - - if (invokeMethod is null) - return; - - var storageKey = routePrefix.IsDefaultOrEmpty - ? "__argh_root" - : string.Join("/", routePrefix) + "/__argh_root"; - // Synthesized from the lambda's own signature — see BuildNaturalDelegateTypeFq for why the enclosing - // conversion's Type (System.Delegate) can't be used here. - var delegateFq = BuildNaturalDelegateTypeFq(invokeMethod) ?? "global::System.Delegate"; - var parseOpts = invocation.SyntaxTree.Options as CSharpParseOptions ?? CSharpParseOptions.Default; - var paramBuilder = ImmutableArray.CreateBuilder(); - foreach (var p in invokeMethod.Parameters) - paramBuilder.Add(ParameterModel.From(p, context, null, invocation.GetLocation())); - var parameters = paramBuilder.ToImmutable(); - var usage = UsageSynopsis.Build(parameters); - var runName = CommandModel.BuildRootDefaultRunMethodName(routePrefix); - var lambdaRetFq = invokeMethod.ReturnType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - // FullyQualifiedFormat renders special types via their C# keyword ("void"), not "global::System.Void". - var lambdaRetIsVoid = lambdaRetFq is "void" - or "global::System.Threading.Tasks.Task" - or "global::System.Threading.Tasks.ValueTask"; - var lambdaRetIsAsync = lambdaRetFq is "global::System.Threading.Tasks.Task" - or "global::System.Threading.Tasks.ValueTask" - || (invokeMethod.ReturnType is INamedTypeSymbol lrNamed && lrNamed.IsGenericType && - (lrNamed.ConstructedFrom.Name is "Task" or "ValueTask") && - lrNamed.ConstructedFrom.ContainingNamespace?.ToDisplayString() == "System.Threading.Tasks"); - var cmd = new CommandModel( - routePrefix, - RootDefaultInternalCommandName, - runName, - "object", - "__lambda", - false, - false, - lambdaRetFq, - lambdaRetIsAsync, - lambdaRetIsVoid, - parameters, - false, - ImmutableArray.Empty, - SourceSpanInfo.None, - ImmutableArray<(string, string)>.Empty, - "", // HandlerDocCommentId - "", - "", - "", - "", - "", - usage, - ImmutableArray<(string, bool)>.Empty, - IsRootDefault: true, - IsLambda: true, - LambdaStorageKey: storageKey, - LambdaDelegateFq: delegateFq); - targetNode.RootCommand = cmd; - } - - private static void ExpandTypeRegistration( - SourceProductionContext context, - InvocationExpressionSyntax invocation, - INamedTypeSymbol type, - ImmutableArray routePrefix, - bool mergeOuterTypeSegment, - RegistryNode attachTo, - CSharpParseOptions parseOpts) - { - if (mergeOuterTypeSegment) - { - AddMethodsFromType(context, invocation, type, routePrefix, attachTo, parseOpts); - } - else - { - var seg = Naming.ToTypeSegmentName(type.Name); - var wrapper = new RegistryNode(); - var outerPrefix = AppendSegment(routePrefix, seg); - ExpandTypeRegistration(context, invocation, type, outerPrefix, mergeOuterTypeSegment: true, wrapper, parseOpts); - attachTo.Children.Add(new RegistryNode.NamedCommandNamespaceChild - { - Segment = seg, - Node = wrapper, - SummaryOneLiner = GetTypeListingSummaryOneLiner(type), - Location = invocation.GetLocation() - }); - } - } - - private static ImmutableArray AppendSegment(ImmutableArray prefix, string segment) - { - var b = ImmutableArray.CreateBuilder(prefix.Length + 1); - foreach (var s in prefix) - b.Add(s); - b.Add(segment); - return b.MoveToImmutable(); - } - - private static void AddMethodsFromType( - SourceProductionContext context, - InvocationExpressionSyntax invocation, - INamedTypeSymbol type, - ImmutableArray routePrefix, - RegistryNode targetNode, - CSharpParseOptions parseOpts) - { - IMethodSymbol? defaultCommand = null; - foreach (var member in type.GetMembers()) - { - if (member is not IMethodSymbol method || method.MethodKind != MethodKind.Ordinary) - continue; - if (method.AssociatedSymbol is not null) - continue; - if (method.DeclaredAccessibility != Accessibility.Public) - continue; - if (!HasDefaultCommandAttribute(method)) - continue; - if (defaultCommand is not null) - { - context.ReportDiagnostic(Diagnostic.Create( - MultipleDefaultCommandAttributes, - method.Locations.FirstOrDefault() ?? invocation.GetLocation(), - type.Name)); - continue; - } - - defaultCommand = method; - } - - if (defaultCommand is not null) - { - if (targetNode.RootCommand is not null) - context.ReportDiagnostic(Diagnostic.Create(DuplicateRootCommand, invocation.GetLocation())); - else - targetNode.RootCommand = CommandModel.FromRootMethod(defaultCommand, parseOpts, routePrefix, context, invocation.GetLocation()); - } - - foreach (var member in type.GetMembers()) - { - if (member is not IMethodSymbol method || method.MethodKind != MethodKind.Ordinary) - continue; - - if (method.AssociatedSymbol is not null) - continue; - - if (method.DeclaredAccessibility != Accessibility.Public) - continue; - - if (defaultCommand is not null && SymbolEqualityComparer.Default.Equals(method, defaultCommand)) - continue; - - var cmdName = TryGetCommandNameAttribute(method) ?? Naming.ToCommandName(method.Name); - targetNode.Commands.Add(CommandModel.FromMethod(cmdName, method, parseOpts, routePrefix, context, invocation.GetLocation())); - } - } - - /// DiagnosticAccumulator-based variant of for use in the Select-step analysis. - private static void AddMethodsFromTypeAcc( - DiagnosticAccumulator acc, - Location location, - INamedTypeSymbol type, - ImmutableArray routePrefix, - RegistryNode targetNode, - CSharpParseOptions parseOpts, - Compilation? compilation) - { - IMethodSymbol? defaultCommand = null; - foreach (var member in type.GetMembers()) - { - if (member is not IMethodSymbol method || method.MethodKind != MethodKind.Ordinary) continue; - if (method.AssociatedSymbol is not null) continue; - if (method.DeclaredAccessibility != Accessibility.Public) continue; - if (!HasDefaultCommandAttribute(method)) continue; - if (defaultCommand is not null) - { - acc.Add(MultipleDefaultCommandAttributes, method.Locations.FirstOrDefault() ?? location, type.Name); - continue; - } - defaultCommand = method; - } - if (defaultCommand is not null) - { - if (targetNode.RootCommand is not null) - acc.Add(DuplicateRootCommand, location); - else - targetNode.RootCommand = CommandModel.FromRootMethod(defaultCommand, parseOpts, routePrefix, acc, location, compilation); - } - foreach (var member in type.GetMembers()) - { - if (member is not IMethodSymbol method || method.MethodKind != MethodKind.Ordinary) continue; - if (method.AssociatedSymbol is not null) continue; - if (method.DeclaredAccessibility != Accessibility.Public) continue; - if (defaultCommand is not null && SymbolEqualityComparer.Default.Equals(method, defaultCommand)) continue; - var cmdName = TryGetCommandNameAttribute(method) ?? Naming.ToCommandName(method.Name); - targetNode.Commands.Add(CommandModel.FromMethod(cmdName, method, parseOpts, routePrefix, acc, location, compilation)); - } - } - - /// - /// Variant of used by MapAndRootAlias<T>. - /// All public methods are registered as regular named commands (none extracted to RootCommand). - /// The [DefaultCommand]-marked method (or the sole method for single-method types) is also stored - /// in as the alias target. - /// - private static void AddMethodsFromTypeAccForAlias( - DiagnosticAccumulator acc, - Location location, - INamedTypeSymbol type, - ImmutableArray routePrefix, - RegistryNode targetNode, - CSharpParseOptions parseOpts, - Compilation? compilation) - { - IMethodSymbol? defaultCommandMethod = null; - var publicOrdinaryMethods = new List(); - - foreach (var member in type.GetMembers()) - { - if (member is not IMethodSymbol method || method.MethodKind != MethodKind.Ordinary) continue; - if (method.AssociatedSymbol is not null) continue; - if (method.DeclaredAccessibility != Accessibility.Public) continue; - publicOrdinaryMethods.Add(method); - if (!HasDefaultCommandAttribute(method)) continue; - if (defaultCommandMethod is not null) - { - acc.Add(MultipleDefaultCommandAttributes, method.Locations.FirstOrDefault() ?? location, type.Name); - continue; - } - defaultCommandMethod = method; - } - - // Auto-select for single-method types; require [DefaultCommand] for multi-method types. - if (defaultCommandMethod is null) - { - if (publicOrdinaryMethods.Count == 1) - defaultCommandMethod = publicOrdinaryMethods[0]; - else if (publicOrdinaryMethods.Count > 1) - acc.Add(MapAndRootAliasAmbiguousTarget, location, type.Name); - } - - foreach (var method in publicOrdinaryMethods) - { - var cmdName = TryGetCommandNameAttribute(method) ?? Naming.ToCommandName(method.Name); - var cmd = CommandModel.FromMethod(cmdName, method, parseOpts, routePrefix, acc, location, compilation); - targetNode.Commands.Add(cmd); - if (defaultCommandMethod is not null && SymbolEqualityComparer.Default.Equals(method, defaultCommandMethod)) - targetNode.RootAlias = cmd; - } - } - - private static bool HasDefaultCommandAttribute(IMethodSymbol method) - { - foreach (var ad in method.GetAttributes()) - { - if (ad.AttributeClass?.Name == "DefaultCommandAttribute" && - ad.AttributeClass.ContainingNamespace?.ToDisplayString() == "Nullean.Argh") - return true; - } - - return false; - } - - private static bool HasCommandIntrinsicAttribute(IMethodSymbol method) - { - foreach (var ad in method.GetAttributes()) - { - if (ad.AttributeClass?.Name == "CommandIntrinsicAttribute" && - ad.AttributeClass.ContainingNamespace?.ToDisplayString() == "Nullean.Argh") - return true; - } - - return false; - } - - private static bool HasHiddenAttribute(ISymbol symbol) - { - foreach (var ad in symbol.GetAttributes()) - { - if (ad.AttributeClass?.Name == "HiddenAttribute" && - ad.AttributeClass.ContainingNamespace?.ToDisplayString() == "Nullean.Argh") - return true; - } - - return false; - } - - private static string? TryGetCommandNameAttribute(IMethodSymbol method) - { - foreach (var ad in method.GetAttributes()) - { - if (ad.AttributeClass?.Name == "CommandNameAttribute" && - ad.AttributeClass.ContainingNamespace?.ToDisplayString() == "Nullean.Argh" && - ad.ConstructorArguments.Length >= 1 && - ad.ConstructorArguments[0].Value is string name && - !string.IsNullOrWhiteSpace(name)) - return name; - } - - return null; - } - - private static ImmutableArray TryGetCommandAliasesFromAttribute(IMethodSymbol method) - { - foreach (var ad in method.GetAttributes()) - { - if (ad.AttributeClass?.Name == "CommandNameAttribute" && - ad.AttributeClass.ContainingNamespace?.ToDisplayString() == "Nullean.Argh" && - ad.ConstructorArguments.Length >= 2) - { - var aliasArg = ad.ConstructorArguments[1]; - if (aliasArg.Kind == TypedConstantKind.Array) - { - var builder = ImmutableArray.CreateBuilder(); - foreach (var v in aliasArg.Values) - { - if (v.Value is string s && !string.IsNullOrWhiteSpace(s)) - builder.Add(s); - } - return builder.ToImmutable(); - } - } - } - - return ImmutableArray.Empty; - } - - private static (bool IsDeprecated, string? Message) TryGetObsoleteAttribute(ISymbol symbol) - { - foreach (var ad in symbol.GetAttributes()) - { - if (ad.AttributeClass?.Name is "ObsoleteAttribute" or "Obsolete" && - (ad.AttributeClass.ContainingNamespace?.ToDisplayString() is "System" or "")) - { - var msg = ad.ConstructorArguments.Length >= 1 && ad.ConstructorArguments[0].Value is string s && !string.IsNullOrWhiteSpace(s) - ? s - : null; - return (true, msg); - } - } - - return (false, null); - } - - private const string DocNs = "Nullean.Argh.Documentation"; - - private static CommandIntentData? TryGetCommandIntentData(IMethodSymbol method) - { - bool? destructive = null, idempotent = null, requiresConfirmation = null, requiresAuth = null; - string? scope = null; - - foreach (var ad in method.GetAttributes()) - { - if (ad.AttributeClass?.ContainingNamespace?.ToDisplayString() != DocNs) continue; - - switch (ad.AttributeClass.Name) - { - case "CommandIntentAttribute": - { - // Constructor arg 0 is the Intent flags enum (underlying int) - // Destructive=1, Idempotent=2, RequiresConfirmation=4 - var flagsInt = 0; - if (ad.ConstructorArguments.Length >= 1 && ad.ConstructorArguments[0].Value is int f) - flagsInt = f; - if ((flagsInt & 1) != 0) destructive = true; - if ((flagsInt & 2) != 0) idempotent = true; - if ((flagsInt & 4) != 0) requiresConfirmation = true; - break; - } - case "MutationScopeAttribute": - { - // Constructor arg 0 is MutationScope enum: 0=File, 1=Directory, 2=Global - if (ad.ConstructorArguments.Length >= 1 && ad.ConstructorArguments[0].Value is int s) - scope = s switch { 0 => "file", 1 => "directory", 2 => "global", _ => null }; - break; - } - case "RequiresAuthAttribute": - requiresAuth = true; - break; - } - } - - if (destructive is null && idempotent is null && requiresConfirmation is null && requiresAuth is null && scope is null) - return null; - return new CommandIntentData(destructive, idempotent, scope, requiresConfirmation, requiresAuth); - } - - private static (bool IsOutput, ImmutableArray ExplicitFormats) TryGetCommandOutputAttribute(ISymbol symbol) - { - foreach (var ad in symbol.GetAttributes()) - { - if (ad.AttributeClass?.Name == "CommandOutputAttribute" && - ad.AttributeClass.ContainingNamespace?.ToDisplayString() == DocNs) - { - var formats = ImmutableArray.Empty; - if (ad.ConstructorArguments.Length >= 1 && ad.ConstructorArguments[0].Kind == TypedConstantKind.Array) - { - var builder = ImmutableArray.CreateBuilder(); - foreach (var v in ad.ConstructorArguments[0].Values) - { - if (v.Value is string s && !string.IsNullOrWhiteSpace(s)) - builder.Add(s); - } - formats = builder.ToImmutable(); - } - return (true, formats); - } - } - return (false, ImmutableArray.Empty); - } - - private static bool HasConfirmationSkipAttribute(ISymbol symbol) - { - foreach (var ad in symbol.GetAttributes()) - { - if (ad.AttributeClass?.Name == "ConfirmationSkipAttribute" && - ad.AttributeClass.ContainingNamespace?.ToDisplayString() == DocNs) - return true; - } - return false; - } - - private static bool HasDryRunAttribute(ISymbol symbol) - { - foreach (var ad in symbol.GetAttributes()) - { - if (ad.AttributeClass?.Name == "DryRunAttribute" && - ad.AttributeClass.ContainingNamespace?.ToDisplayString() == DocNs) - return true; - } - return false; - } - - private static CommandOutputData? BuildCommandOutputFromParameters(ImmutableArray parameters) - { - foreach (var p in parameters) - { - if (!p.IsCommandOutput) continue; - var flagName = "--" + p.CliLongName; - ImmutableArray formats; - if (!p.CommandOutputExplicitFormats.IsDefaultOrEmpty) - formats = p.CommandOutputExplicitFormats; - else if (p.ScalarKind == CliScalarKind.Enum && !p.EnumMemberNames.IsDefaultOrEmpty) - { - // Resolve CLI names the same way the help/schema emitter does - var builder = ImmutableArray.CreateBuilder(p.EnumMemberNames.Length); - for (var i = 0; i < p.EnumMemberNames.Length; i++) - builder.Add(ResolveEnumMemberCliName(p.EnumMemberCliNames, i, p.EnumMemberNames[i])); - formats = builder.ToImmutable(); - } - else - formats = ImmutableArray.Empty; - return new CommandOutputData(formats, flagName); - } - return null; - } - - private static AIDocumentEnvironmentVariables? AnalyzeDocumentEnvironmentVariables( - InvocationExpressionSyntax invocation, string filePath, int spanStart) - { - var varsBuilder = ImmutableArray.CreateBuilder(); - var cfgBuilder = ImmutableArray.CreateBuilder(); - - foreach (var arg in invocation.ArgumentList.Arguments) - { - var nameColon = arg.NameColon?.Name.Identifier.Text; - - if (arg.Expression is not (ObjectCreationExpressionSyntax or ImplicitObjectCreationExpressionSyntax)) - continue; - - ArgumentListSyntax? ctorArgs = arg.Expression switch - { - ObjectCreationExpressionSyntax o => o.ArgumentList, - ImplicitObjectCreationExpressionSyntax i => i.ArgumentList, - _ => null - }; - if (ctorArgs is null) continue; - - // Determine type from name colon or array element pattern - var typeName = arg.Expression is ObjectCreationExpressionSyntax oce - ? oce.Type.ToString() - : null; - bool isConfigFile = typeName?.Contains("ConfigFile") == true || nameColon == "configFiles"; - - if (isConfigFile) - { - var path = ctorArgs.Arguments.Count >= 1 - ? TryGetStringLiteral(ctorArgs.Arguments[0].Expression) - : null; - if (path is null) continue; - string? desc = null; - bool req = false; - foreach (var ca in ctorArgs.Arguments) - { - var n = ca.NameColon?.Name.Identifier.Text; - if (n == "Description") desc = TryGetStringLiteral(ca.Expression); - if (n == "Required") req = TryGetBoolLiteral(ca.Expression) ?? false; - } - cfgBuilder.Add(new ConfigFileDocEntry(path, desc, req)); - } - else - { - var name = ctorArgs.Arguments.Count >= 1 - ? TryGetStringLiteral(ctorArgs.Arguments[0].Expression) - : null; - if (name is null) continue; - string? desc = null; - bool req = false; - string? defVal = null; - foreach (var ca in ctorArgs.Arguments) - { - var n = ca.NameColon?.Name.Identifier.Text; - if (n == "Description") desc = TryGetStringLiteral(ca.Expression); - if (n == "Required") req = TryGetBoolLiteral(ca.Expression) ?? false; - if (n == "DefaultValue") defVal = TryGetStringLiteral(ca.Expression); - } - varsBuilder.Add(new EnvVarDocEntry(name, desc, req, defVal)); - } - } - - if (varsBuilder.Count == 0 && cfgBuilder.Count == 0) return null; - return new AIDocumentEnvironmentVariables(filePath, spanStart, varsBuilder.ToImmutable(), cfgBuilder.ToImmutable()); - } - - private static bool? TryGetBoolLiteral(ExpressionSyntax expr) => - expr.Kind() switch - { - Microsoft.CodeAnalysis.CSharp.SyntaxKind.TrueLiteralExpression => true, - Microsoft.CodeAnalysis.CSharp.SyntaxKind.FalseLiteralExpression => false, - _ => null - }; - - private static ExpressionSyntax? TryGetPropertyInitializerValueSyntax(IPropertySymbol prop) - { - foreach (var syntaxRef in prop.DeclaringSyntaxReferences) - { - if (syntaxRef.GetSyntax() is PropertyDeclarationSyntax { Initializer: { Value: var expr } }) - return expr; - } - - return null; - } - - private static ExpressionSyntax? TryGetFieldInitializerValueSyntax(IFieldSymbol field) - { - foreach (var syntaxRef in field.DeclaringSyntaxReferences) - { - if (syntaxRef.GetSyntax() is VariableDeclaratorSyntax { Initializer: { Value: var expr } }) - return expr; - } - - return null; - } - - /// - /// Some initializer shapes yield a bare enum member name (e.g. Information). Emit must use a type-qualified form. - /// - private static string? QualifyOptionsEnumDefaultLiteral( - string? literal, - CliScalarKind sk, - string? enumFq, - ImmutableArray enumMembers) - { - if (literal is null || sk != CliScalarKind.Enum || string.IsNullOrEmpty(enumFq)) - return literal; - if (literal.StartsWith("global::", StringComparison.Ordinal) || literal.StartsWith("(", StringComparison.Ordinal)) - return literal; - if (literal.Contains("::", StringComparison.Ordinal)) - return literal; - foreach (var m in enumMembers) - { - if (!string.Equals(m, literal, StringComparison.Ordinal)) - continue; - return enumFq + "." + literal; - } - - return literal; - } - - private static bool EnumConstantValuesEqual(object fieldConst, object literalConst) - { - if (Equals(fieldConst, literalConst)) - return true; - try - { - return Convert.ToDecimal(fieldConst, CultureInfo.InvariantCulture) == - Convert.ToDecimal(literalConst, CultureInfo.InvariantCulture); - } - catch - { - return false; - } - } - - private static string? TryFormatInitializerOperation(IOperation? op, INamedTypeSymbol? enumTypeHint = null) - { - while (op is IConversionOperation conv) - op = conv.Operand; - while (op is IParenthesizedOperation paren) - op = paren.Operand; - - switch (op) - { - case IFieldReferenceOperation { Field: var ef } when ef.IsStatic && (ef.HasConstantValue || ef.ContainingType?.TypeKind == TypeKind.Enum): - return ef.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - case ILiteralOperation lit when lit.ConstantValue.HasValue && lit.ConstantValue.Value is { } v: - { - var enm = lit.Type is INamedTypeSymbol litEnum && litEnum.TypeKind == TypeKind.Enum - ? litEnum - : enumTypeHint is { TypeKind: TypeKind.Enum } hintEnum - ? hintEnum - : null; - if (enm is not null) - { - foreach (var m in enm.GetMembers()) - { - if (m is not IFieldSymbol fld || !fld.HasConstantValue) - continue; - if (EnumConstantValuesEqual(fld.ConstantValue, lit.ConstantValue.Value)) - return fld.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - } - - return null; - } - - return v switch - { - string s => SymbolDisplay.FormatPrimitive(s, quoteStrings: true, useHexadecimalNumbers: false), - char ch => SymbolDisplay.FormatPrimitive(ch, quoteStrings: true, useHexadecimalNumbers: false), - bool b => b ? "true" : "false", - IFormattable => Convert.ToString(v, CultureInfo.InvariantCulture) ?? "default", - _ => "default" - }; - } - default: - return null; - } - } - - /// - /// Resolves a for even when it does not belong to - /// directly. In multi-project solution builds (e.g. Rider/VS design-time - /// builds, which use instead of metadata for ProjectReferences), a - /// symbol's DeclaringSyntaxReferences can point at a syntax tree that only lives in a *referenced* - /// project's compilation. Calling compilation.GetSemanticModel on such a tree throws - /// ("SyntaxTree is not part of the compilation"). We walk compilation - /// references to find the compilation that actually owns the tree, and return null if none does - /// (e.g. plain metadata references) so callers can degrade gracefully instead of crashing the generator. - /// - private static SemanticModel? TryGetSemanticModelForSyntaxTree(Compilation compilation, SyntaxTree tree) - { - if (compilation.ContainsSyntaxTree(tree)) - return compilation.GetSemanticModel(tree); - - foreach (var reference in compilation.References) - { - if (reference is not CompilationReference compilationReference) - continue; - var model = TryGetSemanticModelForSyntaxTree(compilationReference.Compilation, tree); - if (model is not null) - return model; - } - - return null; - } - - private static string? TryFormatOptionsInitializerExpression(Compilation compilation, ExpressionSyntax expr, ITypeSymbol? enumTypeHint = null) - { - var model = TryGetSemanticModelForSyntaxTree(compilation, expr.SyntaxTree); - if (model is null) - return null; - var hint = enumTypeHint is INamedTypeSymbol namedHint && namedHint.TypeKind == TypeKind.Enum ? namedHint : null; - var fromOp = TryFormatInitializerOperation(model.GetOperation(expr), hint); - if (fromOp is not null) - return fromOp; - - // Fallback when IOperation shape is unexpected (e.g. some enum constant shapes in property initializers). - var sym = model.GetSymbolInfo(expr).Symbol; - if (sym is IFieldSymbol { ContainingType.TypeKind: TypeKind.Enum } ef) - return ef.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - - return null; - } - - private static string? TryGetOptionsPropertyDefaultLiteral(IPropertySymbol prop, Compilation compilation) => - TryGetPropertyInitializerValueSyntax(prop) is { } expr ? TryFormatOptionsInitializerExpression(compilation, expr, prop.Type) : null; - - private static string? TryGetOptionsFieldDefaultLiteral(IFieldSymbol field, Compilation compilation) => - TryGetFieldInitializerValueSyntax(field) is { } expr ? TryFormatOptionsInitializerExpression(compilation, expr, field.Type) : null; - - private static OptionsTypeModel? BuildOptionsTypeModel(INamedTypeSymbol type, Compilation compilation) - { - var members = ImmutableArray.CreateBuilder(); - foreach (var member in type.GetMembers()) - { - switch (member) - { - case IPropertySymbol prop when prop.DeclaredAccessibility == Accessibility.Public && !prop.IsStatic: - { - if (prop.IsIndexer) - continue; - if (prop.GetMethod is null || prop.SetMethod is null) - continue; - members.Add(ParameterModel.FromOptionsProperty(prop, compilation, TryGetOptionsPropertyDefaultLiteral(prop, compilation))); - break; - } - case IFieldSymbol field when field.DeclaredAccessibility == Accessibility.Public && !field.IsStatic: - members.Add(ParameterModel.FromOptionsField(field, compilation, TryGetOptionsFieldDefaultLiteral(field, compilation))); - break; - } - } - - var typeFq = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - var typeMetaName = GetMetadataNameStatic(type); - var baseNames = CollectBaseTypeMetadataNames(type); - var flattenedMembers = BuildFlattenedOptionsMembers(type, compilation); - var bestCtorParamOrder = ComputeBestCtorParamOrder(type, members.Count > 0 ? members.ToImmutable() : ImmutableArray.Empty); - var isPublic = type.DeclaredAccessibility == Accessibility.Public; - var isGeneric = type.TypeParameters.Length > 0; - - if (members.Count == 0) - return new OptionsTypeModel(typeFq, typeMetaName, baseNames, ImmutableArray.Empty, flattenedMembers, bestCtorParamOrder, isPublic, isGeneric); - - return new OptionsTypeModel(typeFq, typeMetaName, baseNames, members.ToImmutable(), flattenedMembers, bestCtorParamOrder, isPublic, isGeneric); - } - - private static ImmutableArray BuildFlattenedOptionsMembers(INamedTypeSymbol type, Compilation compilation) - { - var chain = new List(); - for (var t = type; t is not null && t.SpecialType != SpecialType.System_Object; t = t.BaseType) - chain.Add(t); - - var members = ImmutableArray.CreateBuilder(); - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - for (var i = chain.Count - 1; i >= 0; i--) - { - var tt = chain[i]; - foreach (var member in tt.GetMembers()) - { - switch (member) - { - case IPropertySymbol prop when prop.DeclaredAccessibility == Accessibility.Public && !prop.IsStatic: - { - if (prop.IsIndexer) - continue; - if (prop.GetMethod is null || prop.SetMethod is null) - continue; - if (!seen.Add(prop.Name)) - continue; - - members.Add(ParameterModel.FromOptionsProperty(prop, compilation, TryGetOptionsPropertyDefaultLiteral(prop, compilation))); - break; - } - case IFieldSymbol field when field.DeclaredAccessibility == Accessibility.Public && !field.IsStatic: - { - if (!seen.Add(field.Name)) - continue; - - members.Add(ParameterModel.FromOptionsField(field, compilation, TryGetOptionsFieldDefaultLiteral(field, compilation))); - break; - } - } - } - } - - return members.ToImmutable(); - } - - /// Pre-computes the parameter name order for the best public non-empty constructor (for symbol-free emit). - private static ImmutableArray? ComputeBestCtorParamOrder(INamedTypeSymbol type, ImmutableArray members) - { - if (members.IsDefaultOrEmpty) - return null; - var byName = new HashSet(members.Select(m => m.SymbolName), StringComparer.OrdinalIgnoreCase); - IMethodSymbol? bestCtor = null; - foreach (var ctor in type.InstanceConstructors) - { - if (ctor.DeclaredAccessibility != Accessibility.Public) continue; - if (ctor.Parameters.Length == 0) continue; - if (!ctor.Parameters.All(p => byName.Contains(p.Name))) continue; - if (bestCtor is null || ctor.Parameters.Length > bestCtor.Parameters.Length) - bestCtor = ctor; - } - if (bestCtor is null || bestCtor.Parameters.Length != members.Length) - return null; - var b = ImmutableArray.CreateBuilder(bestCtor.Parameters.Length); - foreach (var p in bestCtor.Parameters) - b.Add(p.Name); - return b.MoveToImmutable(); - } - - private static string GetMetadataNameStatic(ITypeSymbol t) => - t.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat); - - private static ImmutableArray CollectBaseTypeMetadataNames(INamedTypeSymbol type) - { - var b = ImmutableArray.CreateBuilder(); - var current = type.BaseType; - while (current is not null && current.SpecialType != SpecialType.System_Object) - { - b.Add(GetMetadataNameStatic(current)); - current = current.BaseType; - } - foreach (var iface in type.AllInterfaces) - b.Add(GetMetadataNameStatic(iface)); - return b.ToImmutable(); - } - - private static IMethodSymbol? ResolveHandlerMethod( - SemanticModel model, - ExpressionSyntax handlerExpr, - SourceProductionContext context, - InvocationExpressionSyntax invocation) - { - var symbol = model.GetSymbolInfo(handlerExpr).Symbol; - switch (symbol) - { - case IMethodSymbol m: - return m; - case IFieldSymbol { IsStatic: true, ConstantValue: { } }: - context.ReportDiagnostic(Diagnostic.Create(HandlerMustBeMethod, handlerExpr.GetLocation())); - return null; - } - - var op = model.GetOperation(handlerExpr); - while (op is IConversionOperation conv) - op = conv.Operand; - - if (op is IMethodReferenceOperation directRef) - return directRef.Method; - - if (op is IDelegateCreationOperation del && del.Target is IMethodReferenceOperation reference) - return reference.Method; - - context.ReportDiagnostic(Diagnostic.Create(HandlerMustBeMethod, handlerExpr.GetLocation())); - return null; - } - - - private static bool IsInjectedType(ITypeSymbol type) - { - if (type is INamedTypeSymbol named && named.TypeKind == TypeKind.Struct) - { - var fq = named.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - return fq == "global::System.Threading.CancellationToken"; - } - - return false; - } - - private static bool IsInjected(IParameterSymbol p) => IsInjectedType(p.Type); - - private static bool HasArgumentAttribute(IParameterSymbol p) - { - foreach (var attr in p.GetAttributes()) - { - if (attr.AttributeClass?.Name == "ArgumentAttribute") - return true; - } - - return false; - } - - private static bool HasArgumentAttribute(IPropertySymbol p) - { - foreach (var attr in p.GetAttributes()) - { - if (attr.AttributeClass?.Name == "ArgumentAttribute") - return true; - } - - return false; - } - - private static bool HasAsParametersAttribute(IParameterSymbol p) - { - foreach (var attr in p.GetAttributes()) - { - if (attr.AttributeClass?.Name == "AsParametersAttribute") - return true; - } - - return false; - } - - private static string? GetAsParametersPrefix(IParameterSymbol p) - { - foreach (var attr in p.GetAttributes()) - { - if (attr.AttributeClass?.Name != "AsParametersAttribute") - continue; - if (attr.ConstructorArguments.Length > 0 && attr.ConstructorArguments[0].Value is string s && !string.IsNullOrWhiteSpace(s)) - return s.Trim(); - } - - return null; - } - - private static bool TryUnwrapCollectionType(ITypeSymbol type, out ITypeSymbol elementType) - { - elementType = null!; - switch (type) - { - case IArrayTypeSymbol arr: - elementType = arr.ElementType; - return true; - case INamedTypeSymbol named: - { - var def = named.OriginalDefinition; - var fq = def.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - if (fq is "global::System.Collections.Generic.IEnumerable" - or "global::System.Collections.Generic.IReadOnlyList" - or "global::System.Collections.Generic.IReadOnlySet" - or "global::System.Collections.Generic.List") - { - if (named.TypeArguments.Length == 1) - { - elementType = named.TypeArguments[0]; - return true; - } - } - - return false; - } - default: - return false; - } - } - - private static string? TryGetCollectionSeparatorFromAttribute(ISymbol symbol) - { - var fromSymbol = TryGetCollectionSeparatorFromSymbol(symbol); - if (fromSymbol is not null) - return fromSymbol; - - // Positional record members can target [CollectionSyntax] at the synthesized property - // ([property: ...]) instead of the constructor parameter ([param: ...]). - if (symbol is IParameterSymbol { ContainingSymbol: IMethodSymbol { MethodKind: MethodKind.Constructor } ctor } ctorParam) - { - var mirroredProperty = TryFindCtorMirroredProperty(ctor.ContainingType, ctorParam.Name); - if (mirroredProperty is not null) - return TryGetCollectionSeparatorFromSymbol(mirroredProperty); - } - - return null; - } - - private static string? TryGetCollectionSeparatorFromSymbol(ISymbol symbol) - { - foreach (var attr in symbol.GetAttributes()) - { - if (attr.AttributeClass?.Name != "CollectionSyntaxAttribute") - continue; - foreach (var na in attr.NamedArguments) - { - if (na.Key == "Separator" && na.Value.Value is string s && s.Length > 0) - return s; - } - } - - return null; - } - - private static IPropertySymbol? TryFindCtorMirroredProperty(INamedTypeSymbol type, string ctorParameterName) - { - foreach (var member in type.GetMembers()) - { - if (member is not IPropertySymbol prop) - continue; - if (!string.Equals(prop.Name, ctorParameterName, StringComparison.OrdinalIgnoreCase)) - continue; - return prop; - } - - return null; - } - - private static IMethodSymbol? TryGetPrimaryConstructor(INamedTypeSymbol type) - { - IMethodSymbol? best = null; - foreach (var m in type.GetMembers()) - { - if (m is not IMethodSymbol { MethodKind: MethodKind.Constructor } ctor) - continue; - if (ctor.IsStatic) - continue; - if (ctor.DeclaredAccessibility != Accessibility.Public) - continue; - if (best is null || ctor.Parameters.Length > best.Parameters.Length) - best = ctor; - } - - return best; - } - - private static bool IsInitOnlySettable(IPropertySymbol prop) - { - if (prop.IsStatic) - return false; - if (prop.GetMethod is null) - return false; - var set = prop.SetMethod; - if (set is null) - return false; - return set.IsInitOnly; - } - - /// Properties eligible for [AsParameters] object-initializer binding (init or normal setter). - private static bool IsSettableForAsParameters(IPropertySymbol prop) - { - if (prop.IsStatic) - return false; - if (prop.GetMethod is null) - return false; - return prop.SetMethod is not null; - } - - private static void ReportBoolNegationSwitchConflicts( - SourceProductionContext context, - Location fallbackLocation, - ImmutableArray parameters, - IMethodSymbol method) - { - var locByParamName = new Dictionary(StringComparer.Ordinal); - foreach (var sym in method.Parameters) - { - if (sym.Locations.Length == 0) - continue; - var loc = sym.Locations[0]; - if (loc.IsInSource) - locByParamName[sym.Name] = loc; - } - - foreach (var nullable in parameters) - { - if (nullable.Kind != ParameterKind.Flag || nullable.Special != BoolSpecialKind.NullableBool) - continue; - var negCli = "no-" + nullable.CliLongName; - foreach (var plain in parameters) - { - if (plain.Kind != ParameterKind.Flag || plain.Special != BoolSpecialKind.Bool) - continue; - if (!string.Equals(plain.CliLongName, negCli, StringComparison.OrdinalIgnoreCase)) - continue; - var loc = locByParamName.TryGetValue(plain.SymbolName, out var l) ? l : fallbackLocation; - context.ReportDiagnostic(Diagnostic.Create(BoolFlagCollidesWithNullableNegation, loc, plain.SymbolName, plain.CliLongName)); - } - } - } - - private static void ReportBoolNegationSwitchConflictsAcc( - DiagnosticAccumulator acc, - Location fallbackLocation, - ImmutableArray parameters, - IMethodSymbol method) - { - var locByParamName = new Dictionary(StringComparer.Ordinal); - foreach (var sym in method.Parameters) - { - if (sym.Locations.Length == 0) - continue; - var loc = sym.Locations[0]; - if (loc.IsInSource) - locByParamName[sym.Name] = loc; - } - - foreach (var nullable in parameters) - { - if (nullable.Kind != ParameterKind.Flag || nullable.Special != BoolSpecialKind.NullableBool) - continue; - var negCli = "no-" + nullable.CliLongName; - foreach (var plain in parameters) - { - if (plain.Kind != ParameterKind.Flag || plain.Special != BoolSpecialKind.Bool) - continue; - if (!string.Equals(plain.CliLongName, negCli, StringComparison.OrdinalIgnoreCase)) - continue; - var loc = locByParamName.TryGetValue(plain.SymbolName, out var l) ? l : fallbackLocation; - acc.Add(BoolFlagCollidesWithNullableNegation, loc, plain.SymbolName, plain.CliLongName); - } - } - } - - private static void ReportDuplicateCliNames(SourceProductionContext context, Location location, ImmutableArray parameters) - { - var seen = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var p in parameters) - { - if (p.Kind != ParameterKind.Flag) - continue; - void check(string name) - { - if (string.IsNullOrEmpty(name)) - return; - if (seen.TryGetValue(name, out var first)) - { - if (!string.Equals(first, p.SymbolName, StringComparison.Ordinal)) - context.ReportDiagnostic(Diagnostic.Create(DuplicateCliNames, location, name)); - } - else - { - seen[name] = p.SymbolName; - } - } - - check(p.CliLongName); - foreach (var al in p.Aliases) - check(al); - if (p.Special == BoolSpecialKind.NullableBool) - check("no-" + p.CliLongName); - } - } - - private static void ValidateExpandedParameterLayout(SourceProductionContext context, Location location, ImmutableArray expanded) - { - var seenFlag = false; - foreach (var p in expanded) - { - if (p.Kind == ParameterKind.Injected) - continue; - if (p.Kind == ParameterKind.Flag) - { - seenFlag = true; - continue; - } - - // A variadic positional (e.g. params T[]) is allowed after flags — C# requires params to be last. - if (p.Kind == ParameterKind.Positional && seenFlag && !p.IsVariadic) - { - context.ReportDiagnostic(Diagnostic.Create(ArgumentOrder, location)); - return; - } - } - } - - private static void ValidateVariadicPositionalIsLast(SourceProductionContext context, Location location, ImmutableArray parameters) - { - var sawVariadic = false; - foreach (var p in parameters) - { - if (p.Kind != ParameterKind.Positional) - continue; - if (sawVariadic) - { - context.ReportDiagnostic(Diagnostic.Create(VariadicMustBeLastPositional, location)); - return; - } - if (p.IsVariadic) - sawVariadic = true; - } - } - - /// DiagnosticAccumulator-based overload for Select-step analysis. - private static void ReportDuplicateCliNamesAcc(DiagnosticAccumulator acc, Location location, ImmutableArray parameters) - { - var seen = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var p in parameters) - { - if (p.Kind != ParameterKind.Flag) continue; - void check(string name) - { - if (string.IsNullOrEmpty(name)) return; - if (seen.TryGetValue(name, out var first)) - { - if (!string.Equals(first, p.SymbolName, StringComparison.Ordinal)) - acc.Add(DuplicateCliNames, location, name); - } - else - seen[name] = p.SymbolName; - } - check(p.CliLongName); - foreach (var al in p.Aliases) check(al); - if (p.Special == BoolSpecialKind.NullableBool) check("no-" + p.CliLongName); - } - } - - /// - /// Each generated TryApplyShortFlag groups all flag-like params in one scope (global prefetch, namespace - /// prefetch, or a single handler). Duplicate single-letter shortcuts would emit invalid duplicate case labels. - /// - private static void ValidateDuplicateShortOptionLetters(SourceProductionContext context, AppEmitModel app) - { - if (app.GlobalOptionsModel is { FlattenedMembers: var gm } && !gm.IsDefaultOrEmpty) - ReportDuplicateShortsAmongMembers(context, Location.None, gm, "global options"); - - static void walkNs(RegistryNode node, SourceProductionContext ctx) - { - if (node.CommandNamespaceOptionsModel is { FlattenedMembers: var nm } && !nm.IsDefaultOrEmpty) - { - var loc = node.CommandNamespaceOptionsLocation ?? Location.None; - ReportDuplicateShortsAmongMembers(ctx, loc, nm, "namespace-scoped options"); - } - - foreach (var ch in node.Children) - walkNs(ch.Node, ctx); - } - - walkNs(app.Root, context); - - foreach (var cmd in app.AllCommands) - { - if (cmd.Parameters.IsDefaultOrEmpty) - continue; - var loc = cmd.HandlerSpanInfo.ToLocation(); - var scope = - cmd.RoutePrefix.IsDefaultOrEmpty - ? $"command '{cmd.CommandName}'" - : $"command '{string.Join(" ", cmd.RoutePrefix)} {cmd.CommandName}'"; - ReportDuplicateShortsAmongMembers(context, loc, cmd.Parameters, scope); - } - } - - private static void ReportDuplicateShortsAmongMembers( - SourceProductionContext context, - Location location, - ImmutableArray members, - string scopeDescription) - { - var byChar = new Dictionary(); - foreach (var p in members) - { - if (!IsEmittedFlagLike(p.Kind)) - continue; - if (p.ShortOpt is not char ch) - continue; - if (byChar.TryGetValue(ch, out var firstLong)) - { - context.ReportDiagnostic(Diagnostic.Create( - DuplicateShortOption, - location, - ch.ToString(), - firstLong, - p.CliLongName, - scopeDescription)); - } - else - { - byChar[ch] = p.CliLongName; - } - } - } - - /// DiagnosticAccumulator-based overload for Select-step analysis. - private static void ValidateExpandedParameterLayoutAcc(DiagnosticAccumulator acc, Location location, ImmutableArray expanded) - { - var seenFlag = false; - foreach (var p in expanded) - { - if (p.Kind == ParameterKind.Injected) continue; - if (p.Kind == ParameterKind.Flag) { seenFlag = true; continue; } - // A variadic positional is allowed after flags — C# requires params to be last. - if (p.Kind == ParameterKind.Positional && seenFlag && !p.IsVariadic) - { - acc.Add(ArgumentOrder, location); - return; - } - } - } - - private static void ValidateVariadicPositionalIsLastAcc(DiagnosticAccumulator acc, Location location, ImmutableArray parameters) - { - var sawVariadic = false; - foreach (var p in parameters) - { - if (p.Kind != ParameterKind.Positional) continue; - if (sawVariadic) { acc.Add(VariadicMustBeLastPositional, location); return; } - if (p.IsVariadic) sawVariadic = true; - } - } - - /// DiagnosticAccumulator-based overload for Select-step analysis. - private static ImmutableArray FlattenAsParametersTypeAcc( - DiagnosticAccumulator acc, - Location location, - IParameterSymbol methodParam, - INamedTypeSymbol type, - string? prefix, - Compilation? compilation, - CSharpParseOptions parseOptions) - { - var pfx = string.IsNullOrWhiteSpace(prefix) ? "" : Naming.ToCliLongName(prefix!.Trim()) + "-"; - var owner = methodParam.Name; - var typeFq = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - var primary = TryGetPrimaryConstructor(type); - var ctorNames = new HashSet(StringComparer.Ordinal); - var list = new List(); - var order = 0; - if (primary is not null) - { - foreach (var cp in primary.Parameters) - { - ctorNames.Add(cp.Name); - list.Add(ParameterModel.FromAsParametersCtorParameter(owner, typeFq, type, cp, pfx, order++, compilation, parseOptions, - null, - acc, - location)); - } - } - var chain = new List(); - for (var t = type; t is not null && t.SpecialType != SpecialType.System_Object; t = t.BaseType) - chain.Add(t); - var seenPropNames = new HashSet(StringComparer.Ordinal); - for (var i = chain.Count - 1; i >= 0; i--) - { - var tt = chain[i]; - foreach (var member in tt.GetMembers()) - { - if (member is not IPropertySymbol prop) continue; - if (prop.DeclaredAccessibility != Accessibility.Public || prop.IsStatic || prop.IsIndexer) continue; - if (!IsSettableForAsParameters(prop)) continue; - if (ctorNames.Contains(prop.Name)) continue; - if (!seenPropNames.Add(prop.Name)) continue; - list.Add(ParameterModel.FromAsParametersInitProperty(methodParamName: owner, typeFq, prop, pfx, order++, compilation, parseOptions, - null, - acc, - location)); - } - } - if (list.Count == 0) - acc.Add(AsParametersEmptyType, location, type.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat)); - return list.ToImmutableArray(); - } - - private static ImmutableArray FlattenAsParametersType( - SourceProductionContext context, - Location location, - HandlerParam handlerParam, - INamedTypeSymbol type, - string? prefix, - Compilation? compilation, - CSharpParseOptions parseOptions) - { - return FlattenAsParametersType(context, location, handlerParam.Name, type, prefix, compilation, parseOptions); - } - - private static ImmutableArray FlattenAsParametersType( - SourceProductionContext context, - Location location, - IParameterSymbol methodParam, - INamedTypeSymbol type, - string? prefix, - Compilation? compilation, - CSharpParseOptions parseOptions) - { - return FlattenAsParametersType(context, location, methodParam.Name, type, prefix, compilation, parseOptions); - } - - private static ImmutableArray FlattenAsParametersType( - SourceProductionContext context, - Location location, - string methodParamName, - INamedTypeSymbol type, - string? prefix, - Compilation? compilation, - CSharpParseOptions parseOptions) - { - var pfx = string.IsNullOrWhiteSpace(prefix) ? "" : Naming.ToCliLongName(prefix!.Trim()) + "-"; - var owner = methodParamName; - var typeFq = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - var primary = TryGetPrimaryConstructor(type); - var ctorNames = new HashSet(StringComparer.Ordinal); - var list = new List(); - var order = 0; - - if (primary is not null) - { - foreach (var cp in primary.Parameters) - { - ctorNames.Add(cp.Name); - list.Add(ParameterModel.FromAsParametersCtorParameter( - owner, - typeFq, - type, - cp, - pfx, - order++, - compilation, - parseOptions, - context, - null, - location)); - } - } - - var chain = new List(); - for (var t = type; t is not null && t.SpecialType != SpecialType.System_Object; t = t.BaseType) - chain.Add(t); - - var seenPropNames = new HashSet(StringComparer.Ordinal); - for (var i = chain.Count - 1; i >= 0; i--) - { - var tt = chain[i]; - foreach (var member in tt.GetMembers()) - { - if (member is not IPropertySymbol prop) - continue; - if (prop.DeclaredAccessibility != Accessibility.Public || prop.IsStatic) - continue; - if (prop.IsIndexer) - continue; - if (!IsSettableForAsParameters(prop)) - continue; - if (ctorNames.Contains(prop.Name)) - continue; - if (!seenPropNames.Add(prop.Name)) - continue; - - list.Add(ParameterModel.FromAsParametersInitProperty( - methodParamName: owner, - typeFq, - prop, - pfx, - order++, - compilation, - parseOptions, - context, - null, - location)); - } - } - - if (list.Count == 0) - context.ReportDiagnostic(Diagnostic.Create(AsParametersEmptyType, location, type.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat))); - - return list.ToImmutableArray(); - } - - private static string? TryGetStringLiteral(ExpressionSyntax expr) => - expr switch - { - LiteralExpressionSyntax { RawKind: (int)SyntaxKind.StringLiteralExpression } lit => lit.Token.ValueText, - _ => null - }; - - /// Unique per compilation assembly so generated CLI types do not collide across referenced assemblies (e.g. CS0436 with InternalsVisibleTo). - private static string GetArghGeneratedRootTypeName(string assemblyName) - { - using var sha = SHA256.Create(); - var h = sha.ComputeHash(Encoding.UTF8.GetBytes(assemblyName)); - return "ArghGenerated_" + BitConverter.ToString(h, 0, 4).Replace("-", ""); - } - - private static string? TryGetStringConstant(SemanticModel model, ExpressionSyntax expr) - { - var lit = TryGetStringLiteral(expr); - if (lit is not null) - return lit; - var cv = model.GetConstantValue(expr); - if (cv.HasValue && cv.Value is string s) - return s; - return null; - } - - private static void EmitNamespaceSegmentCodegen(SourceProductionContext context, AppEmitModel app) - { - if (app.ArglessNamespaceCodegen.Count == 0) - return; - - var sb = new StringBuilder(); - sb.AppendLine("// "); - sb.AppendLine("#nullable enable"); - sb.AppendLine("using Nullean.Argh;"); - sb.AppendLine(); - sb.AppendLine("internal static class ArghNamespaceSegmentInitializer"); - sb.AppendLine("{"); - sb.AppendLine(" [System.Runtime.CompilerServices.ModuleInitializer]"); - sb.AppendLine(" internal static void Init()"); - sb.AppendLine(" {"); - foreach (var e in app.ArglessNamespaceCodegen) - { - var escaped = e.Segment.Replace("\\", "\\\\").Replace("\"", "\\\""); - sb.AppendLine("\t\tglobal::Nullean.Argh.ArghNamespaceSegmentCodegen.Set<" + e.TypeFq + ">(\"" + escaped + "\");"); - } - sb.AppendLine(" }"); - sb.AppendLine("}"); - context.AddSource("ArghNamespaceSegmentInitializer.g.cs", SourceText.From(sb.ToString(), Encoding.UTF8)); - } - - - private static void EmitEmpty(SourceProductionContext context, string assemblyName, string assemblyVersion) - { - const string source = """ - // - #nullable enable - using System; - using System.Threading.Tasks; - using Nullean.Argh.Middleware; - using Nullean.Argh.Help; - using Nullean.Argh.Matching; - using Nullean.Argh.Runtime; - using Nullean.Argh.Schema; - - namespace Nullean.Argh - { - /// Source-generated CLI entry point from ArghApp registrations. At the root, __completion bash|zsh|fish prints a shell script from . - public static class ArghGenerated - { - public static Task RunAsync(string[] args) => - Task.FromResult(Run(args)); - - public static bool TryParseRoute(string[] args, out RouteMatch match) - { - match = default; - if (CompletionProtocol.IsArghMetaCompletionInvocation(args)) - return false; - return false; - } - - public static RouteMatch? Route(string[] args) - { - if (args is null) - throw new ArgumentNullException(nameof(args)); - if (!TryParseRoute(args, out var m)) - return null; - return m; - } - - private static int Run(string[] args) - { - if (CompletionProtocol.IsCompletionScriptInvocation(args)) - { - if (!CompletionProtocol.TryParseCompletionScriptInvocation(args, out var __scriptShell)) - { - System.Console.Error.WriteLine("Error: expected '__completion bash|zsh|fish'"); - return 2; - } - var appName = "__ARGH_EMBED_ASM_NAME__"; - switch (__scriptShell) - { - case CompletionShell.Bash: - System.Console.Out.Write(CompletionScriptTemplates.GetBash().Replace("{0}", appName)); - return 0; - case CompletionShell.Zsh: - System.Console.Out.Write(CompletionScriptTemplates.GetZsh().Replace("{0}", appName)); - return 0; - case CompletionShell.Fish: - System.Console.Out.Write(CompletionScriptTemplates.GetFish().Replace("{0}", appName)); - return 0; - default: - return 2; - } - } - - if (CompletionProtocol.IsCompleteInvocation(args)) - { - if (!CompletionProtocol.TryParseCompleteInvocation(args, out _, out var __words)) - { - System.Console.Error.WriteLine("Error: expected '__complete -- [words...]'"); - return 2; - } - Complete(default, __words); - return 0; - } - - if (CompletionProtocol.IsSchemaInvocation(args)) - { - System.Console.Out.Write(ArghRuntime.FormatCliSchemaJson()); - return 0; - } - - if (args.Length > 0 && (args[0] == "--help" || args[0] == "-h")) - { - System.Console.Out.WriteLine("No commands are registered."); - return 0; - } - - if (args.Length > 0 && args[0] == "--version") - { - PrintVersion(); - return 0; - } - - System.Console.Error.WriteLine("No commands are registered."); - return 2; - } - - private static void Complete(CompletionShell shell, ReadOnlySpan words) - { - _ = shell; - _ = words; - } - - private static void PrintVersion() - { - System.Console.Out.WriteLine("__ARGH_EMBED_ASM_VER__"); - } - - internal static bool IsIntrinsicCommand(string[] args) => false; - - internal static ArghCliSchemaDocument BuildCliSchemaDocument() => - new ArghCliSchemaDocument( - 1, - "__ARGH_EMBED_ASM_NAME__", - "__ARGH_EMBED_ASM_VER__", - null, - new[] { "__complete", "__completion", "__schema" }, - Array.Empty(), - null, - Array.Empty(), - Array.Empty()); - } - - internal static class ArghGeneratedRuntimeRegistration - { - [System.Runtime.CompilerServices.ModuleInitializer] - internal static void RegisterArghRuntime() - { - ArghRuntime.RegisterRunner(ArghGenerated.RunAsync); - ArghRuntime.RegisterRoute(ArghGenerated.Route); - ArghRuntime.RegisterCliSchema(ArghGenerated.BuildCliSchemaDocument); - ArghRuntime.RegisterIntrinsic(ArghGenerated.IsIntrinsicCommand); - } - } - } - """; - var root = GetArghGeneratedRootTypeName(assemblyName); - var resolved = source - .Replace("__ARGH_EMBED_ASM_NAME__", Escape(assemblyName)) - .Replace("__ARGH_EMBED_ASM_VER__", Escape(assemblyVersion)) - .Replace("internal static class ArghGeneratedRuntimeRegistration", "internal static class " + root + "RuntimeRegistration") - .Replace("public static class ArghGenerated", "public static class " + root) - .Replace("ArghGenerated.", root + "."); - context.AddSource("ArghGenerated.g.cs", SourceText.From(resolved, Encoding.UTF8)); - } - - - private static void AppendArghRuntimeModuleInitializer(StringBuilder sb, string rootTypeName) - { - sb.AppendLine(); - sb.AppendLine("\tinternal static class " + rootTypeName + "RuntimeRegistration"); - sb.AppendLine("\t{"); - sb.AppendLine("\t\t[System.Runtime.CompilerServices.ModuleInitializer]"); - sb.AppendLine("\t\tinternal static void RegisterArghRuntime()"); - sb.AppendLine("\t\t{"); - sb.AppendLine("\t\t\tArghRuntime.RegisterRunner(" + rootTypeName + ".RunAsync);"); - sb.AppendLine("\t\t\tArghRuntime.RegisterRoute(" + rootTypeName + ".Route);"); - sb.AppendLine("\t\t\tArghRuntime.RegisterCliSchema(" + rootTypeName + ".BuildCliSchemaDocument);"); - sb.AppendLine("\t\t\tArghRuntime.RegisterIntrinsic(" + rootTypeName + ".IsIntrinsicCommand);"); - sb.AppendLine("\t\t}"); - sb.AppendLine("\t}"); - } - - private const int FuzzyMaxDistance = 2; - - private static void EmitRootCompletionScriptBlock(StringBuilder sb, string indent, string entryAssemblyName) - { - sb.AppendLine(indent + "if (CompletionProtocol.IsCompletionScriptInvocation(args))"); - sb.AppendLine(indent + "{"); - sb.AppendLine(indent + "\tif (!CompletionProtocol.TryParseCompletionScriptInvocation(args, out var __scriptShell))"); - sb.AppendLine(indent + "\t{"); - sb.AppendLine(indent + "\t\tConsole.Error.WriteLine(\"Error: expected '__completion bash|zsh|fish'\");"); - sb.AppendLine(indent + "\t\treturn 2;"); - sb.AppendLine(indent + "\t}"); - sb.AppendLine(indent + "\tvar __entry = \"" + Escape(entryAssemblyName) + "\";"); - sb.AppendLine(indent + "\tswitch (__scriptShell)"); - sb.AppendLine(indent + "\t{"); - sb.AppendLine(indent + "\t\tcase CompletionShell.Bash:"); - sb.AppendLine(indent + "\t\t\tConsole.Out.Write(CompletionScriptTemplates.GetBash().Replace(\"{0}\", __entry));"); - sb.AppendLine(indent + "\t\t\treturn 0;"); - sb.AppendLine(indent + "\t\tcase CompletionShell.Zsh:"); - sb.AppendLine(indent + "\t\t\tConsole.Out.Write(CompletionScriptTemplates.GetZsh().Replace(\"{0}\", __entry));"); - sb.AppendLine(indent + "\t\t\treturn 0;"); - sb.AppendLine(indent + "\t\tcase CompletionShell.Fish:"); - sb.AppendLine(indent + "\t\t\tConsole.Out.Write(CompletionScriptTemplates.GetFish().Replace(\"{0}\", __entry));"); - sb.AppendLine(indent + "\t\t\treturn 0;"); - sb.AppendLine(indent + "\t\tdefault:"); - sb.AppendLine(indent + "\t\t\treturn 2;"); - sb.AppendLine(indent + "\t}"); - sb.AppendLine(indent + "}"); - sb.AppendLine(); - } - - - private static void EmitFuzzyDispatchDefault( - StringBuilder sb, - RegistryNode node, - ImmutableArray path, - string entryAssemblyName) - { - var entries = new List<(string Name, string Summary, string HelpPrinter)>(); - foreach (var cmd in node.Commands) - entries.Add((cmd.CommandName, cmd.SummaryOneLiner, $"PrintHelp_{cmd.RunMethodName}")); - foreach (var ch in node.Children) - { - var childPath = AppendSegment(path, ch.Segment); - var gk = CommandNamespacePathKey(childPath); - entries.Add((ch.Segment, "", $"PrintHelp_CommandNamespace_{gk}")); - } - - var sorted = - entries.OrderBy(e => e.Name, StringComparer.Ordinal).ToList(); - - var pathPrefix = path.IsDefaultOrEmpty ? "" : string.Join(" ", path) + " "; - var nsHelp = path.IsDefaultOrEmpty ? "--help" : Escape(string.Join(" ", path)) + " --help"; - - sb.AppendLine("\t\t\t\tvar __tok = tok;"); - sb.AppendLine("\t\t\t\tvar __app = \"" + Escape(entryAssemblyName) + "\";"); - sb.Append("\t\t\t\tvar __cands = new string[] { "); - for (var i = 0; i < sorted.Count; i++) - { - if (i > 0) - sb.Append(", "); - sb.Append('"').Append(Escape(sorted[i].Name)).Append('"'); - } - - sb.AppendLine(" };"); - sb.AppendLine($"\t\t\t\tvar __matches = FuzzyMatch.FindClosest(__tok, __cands, {FuzzyMaxDistance});"); - const string kind = "command or namespace"; - sb.AppendLine("\t\t\t\tif (__matches.Count == 0)"); - sb.AppendLine("\t\t\t\t{"); - sb.AppendLine($"\t\t\t\t\tConsole.Error.WriteLine($\"Error: unknown {kind} '{{__tok}}'.\");"); - sb.AppendLine("\t\t\t\t\tConsole.Error.WriteLine();"); - sb.AppendLine($"\t\t\t\t\tConsole.Error.WriteLine($\"Run '{{__app}} {nsHelp}' for usage.\");"); - sb.AppendLine("\t\t\t\t}"); - sb.AppendLine("\t\t\t\telse if (__matches.Count == 1)"); - sb.AppendLine("\t\t\t\t{"); - sb.AppendLine("\t\t\t\t\tvar __m = __matches[0];"); - sb.AppendLine($"\t\t\t\t\tConsole.Error.WriteLine($\"Error: unknown {kind} '{{__tok}}'. Did you mean '{Escape(pathPrefix)}{{__m}}'?\");"); - sb.AppendLine("\t\t\t\t\tConsole.Error.WriteLine();"); - sb.AppendLine($"\t\t\t\t\tConsole.Error.WriteLine($\"Run '{{__app}} {Escape(pathPrefix)}{{__m}} --help' for usage.\");"); - sb.AppendLine($"\t\t\t\t\tConsole.Error.WriteLine($\"Run '{{__app}} {nsHelp}' for usage.\");"); - sb.AppendLine("\t\t\t\t}"); - sb.AppendLine("\t\t\t\telse"); - sb.AppendLine("\t\t\t\t{"); - sb.AppendLine($"\t\t\t\t\tConsole.Error.WriteLine($\"Error: unknown {kind} '{{__tok}}'. Did you mean one of these?\");"); - sb.AppendLine("\t\t\t\t\tConsole.Error.WriteLine();"); - foreach (var e in sorted) - { - var qualifiedName = Escape(pathPrefix + e.Name); - var sum = Escape(e.Summary); - sb.AppendLine( - $"\t\t\t\t\tif (__matches.Any(__x => string.Equals(__x, \"{Escape(e.Name)}\", StringComparison.OrdinalIgnoreCase)))"); - sb.AppendLine("\t\t\t\t\t{"); - sb.AppendLine( - $"\t\t\t\t\t\tConsole.Error.WriteLine(\" \" + CliHelpFormatting.Accent(\"{qualifiedName}\") + \" {sum}\");"); - sb.AppendLine("\t\t\t\t\t}"); - } - - sb.AppendLine("\t\t\t\t\tConsole.Error.WriteLine();"); - sb.AppendLine($"\t\t\t\t\tConsole.Error.WriteLine($\"Run '{{__app}} {Escape(pathPrefix)} --help' for usage.\");"); - sb.AppendLine($"\t\t\t\t\tConsole.Error.WriteLine($\"Run '{{__app}} {nsHelp}' for usage.\");"); - sb.AppendLine("\t\t\t\t}"); - sb.AppendLine("\t\t\t\treturn 2;"); - } - - - private static void AppendRunWithCancellationAsyncMethod(StringBuilder sb) - { - sb.AppendLine("\t\tprivate static async Task RunWithCancellationAsync(string[] args)"); - sb.AppendLine("\t\t{"); - sb.AppendLine("\t\t\tusing var cts = new CancellationTokenSource();"); - sb.AppendLine("\t\t\tConsole.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };"); - sb.AppendLine("\t\t\tCancellationTokenSource? __linkedCts = null;"); - sb.AppendLine("\t\t\ttry"); - sb.AppendLine("\t\t\t{"); - sb.AppendLine("\t\t\t\tCancellationToken ct = cts.Token;"); - sb.AppendLine("\t\t\t\tif (ArghHostRuntime.ApplicationStopping is CancellationToken __hostStopping)"); - sb.AppendLine("\t\t\t\t{"); - sb.AppendLine("\t\t\t\t\t__linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, __hostStopping);"); - sb.AppendLine("\t\t\t\t\tct = __linkedCts.Token;"); - sb.AppendLine("\t\t\t\t}"); - sb.AppendLine("\t\t\t\treturn await RunCoreAsync(args, ct).ConfigureAwait(false);"); - sb.AppendLine("\t\t\t}"); - sb.AppendLine("\t\t\tcatch (Exception ex)"); - sb.AppendLine("\t\t\t{"); - sb.AppendLine("\t\t\t\tConsole.Error.WriteLine(ex.ToString());"); - sb.AppendLine("\t\t\t\treturn 1;"); - sb.AppendLine("\t\t\t}"); - sb.AppendLine("\t\t\tfinally"); - sb.AppendLine("\t\t\t{"); - sb.AppendLine("\t\t\t\t__linkedCts?.Dispose();"); - sb.AppendLine("\t\t\t}"); - sb.AppendLine("\t\t}"); - sb.AppendLine(); - } - - /// - /// Returns if the method or its containing type carries - /// [NoOptionsInjection], suppressing AGH0021 and handler-level options-parameter requirements. - /// Globals/namespaced flags still splice as for parsing/reconstruction. - /// - private static bool HasNoOptionsInjection(IMethodSymbol method) - { - const string attrName = "NoOptionsInjectionAttribute"; - const string ns = "Nullean.Argh"; - foreach (var a in method.GetAttributes()) - if (a.AttributeClass?.Name == attrName && a.AttributeClass.ContainingNamespace?.ToDisplayString() == ns) - return true; - foreach (var a in method.ContainingType.GetAttributes()) - if (a.AttributeClass?.Name == attrName && a.AttributeClass.ContainingNamespace?.ToDisplayString() == ns) - return true; - return false; - } - - private static bool HasPublicParameterlessCtor(INamedTypeSymbol type) - { - foreach (var ctor in type.InstanceConstructors) - { - if (ctor.Parameters.Length == 0 && ctor.DeclaredAccessibility == Accessibility.Public) - return true; - } - - return false; - } - - private static string DiResolveOrNew(string fullyQualifiedType, bool allowParameterlessFallback) - { - if (allowParameterlessFallback) - return $"((ArghServices.ServiceProvider?.GetService(typeof({fullyQualifiedType})) as {fullyQualifiedType}) ?? new {fullyQualifiedType}())"; - - return - $"((ArghServices.ServiceProvider?.GetService(typeof({fullyQualifiedType})) as {fullyQualifiedType}) ?? throw new global::System.InvalidOperationException(\"Register the type in DI for hosted execution, or add a public parameterless constructor for standalone CLI.\"))"; - } - - private static void EmitApp( - SourceProductionContext context, - AppEmitModel app, - CSharpParseOptions parseOptions, - string entryAssemblyName, - string entryAssemblyVersion, - string entrySchemaVersion, - ReferenceMetadataCapabilities.Capabilities referenceCapabilities) - { - _ = referenceCapabilities; - _ = parseOptions; // no longer needed for DTO building; kept in signature for future use - var dtoTargets = CollectDtoBindingTargets(app); - var root = GetArghGeneratedRootTypeName(entryAssemblyName); - EmitHierarchical(context, app, dtoTargets, entryAssemblyName, entryAssemblyVersion, entrySchemaVersion, root); - EmitDtoTypeExtensions(context, dtoTargets, root); - } - - private sealed record DtoBindingTarget( - string TypeFq, - ImmutableArray Members, - bool IsOptionsDto, - bool IsGeneric, - bool IsPublic, - ImmutableArray? BestCtorParamOrder); - - private static ImmutableArray CollectDtoBindingTargets( - AppEmitModel app) - { - // Use string TypeFq as dedup key since we no longer have INamedTypeSymbol in the pipeline boundary. - var map = new Dictionary(StringComparer.Ordinal); - - if (app.GlobalOptionsModel is { } gom && gom.FlattenedMembers.Length > 0) - { - map[gom.TypeFq] = new DtoBindingTarget( - gom.TypeFq, - gom.FlattenedMembers, - IsOptionsDto: true, - IsGeneric: gom.IsGeneric, - IsPublic: gom.IsPublic, - gom.BestCtorParamOrder); - } - - foreach ((var node, _) in EnumerateCommandNamespaceNodesWithPath(app.Root, ImmutableArray.Empty)) - { - if (node.CommandNamespaceOptionsModel is not { } nsModel) - continue; - if (nsModel.FlattenedMembers.Length == 0) - continue; - if (map.ContainsKey(nsModel.TypeFq)) - continue; - map[nsModel.TypeFq] = new DtoBindingTarget( - nsModel.TypeFq, - nsModel.FlattenedMembers, - IsOptionsDto: true, - IsGeneric: nsModel.IsGeneric, - IsPublic: nsModel.IsPublic, - nsModel.BestCtorParamOrder); - } - - foreach (var cmd in app.AllCommands) - { - if (cmd.HandlerParamTypes.IsDefaultOrEmpty) - continue; - - foreach (var mp in cmd.HandlerParamTypes) - { - if (!mp.IsAsParameters || mp.AsParamTypeFq is not { } typeFq) - continue; - if (string.IsNullOrEmpty(typeFq) || map.ContainsKey(typeFq)) - continue; - - // Extract the already-flattened DTO members from the command's Parameters array - // (these were computed by FlattenAsParametersType during analysis and include proper prefix/AsParametersMeta). - var flat = cmd.Parameters - .Where(p => p.AsParametersOwnerParamName == mp.Name) - .ToImmutableArray(); - - if (flat.Length > 0) - map[typeFq] = new DtoBindingTarget( - typeFq, - flat, - IsOptionsDto: false, - IsGeneric: mp.AsParamIsGeneric, - IsPublic: mp.AsParamIsPublic, - mp.AsParamBestCtorParamOrder); - } - } - - return map.Values - .OrderBy(t => t.TypeFq, StringComparer.Ordinal) - .ToImmutableArray(); - } - - private static string OptionsStaticFieldName(INamedTypeSymbol type) => - "s_opts_" + DtoMethodSuffix(type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)); - - private static string OptionsStaticFieldNameFq(string typeFq) => - "s_opts_" + DtoMethodSuffix(typeFq); - - /// Name of the per-command-runner local variable that holds the reconstructed options instance. - private static string OptionsLocalVarName(INamedTypeSymbol type) => - "__opts_" + DtoMethodSuffix(type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)); - - private static string OptionsLocalVarNameFq(string typeFq) => - "__opts_" + DtoMethodSuffix(typeFq); - - private static string DtoMethodSuffix(INamedTypeSymbol type) => - DtoMethodSuffix(type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)); - - private static string DtoMethodSuffix(string typeFq) - { - var fq = typeFq; - if (fq.StartsWith("global::", StringComparison.Ordinal)) - fq = fq.Substring(8); - - var sb = new StringBuilder(); - foreach (var c in fq) - { - if (char.IsLetterOrDigit(c)) - sb.Append(c); - else - sb.Append('_'); - } - - return sb.Length == 0 ? "Dto" : sb.ToString(); - } - - private static void EmitDtoBindingMethods(StringBuilder sb, ImmutableArray targets) - { - foreach (var t in targets) - { - var suffix = DtoMethodSuffix(t.TypeFq); - var lenientName = "TryParseDto_" + suffix; - var strictName = "TryParseDtoExact_" + suffix; - var syn = SyntheticOptionsCommand(t.Members, lenientName); - - EmitCommandRunner( - sb, - syn, - ImmutableArray.Empty, - emitDtoTryParse: true, - dtoLenient: true, - dtoMethodName: lenientName, - dtoResultTypeFq: t.TypeFq, - dtoOptionsTypeFq: t.IsOptionsDto ? t.TypeFq : null, - dtoOptionsBestCtorParamOrder: t.IsOptionsDto ? t.BestCtorParamOrder : null); - - EmitCommandRunner( - sb, - syn, - ImmutableArray.Empty, - emitDtoTryParse: true, - dtoLenient: false, - dtoMethodName: strictName, - dtoResultTypeFq: t.TypeFq, - dtoOptionsTypeFq: t.IsOptionsDto ? t.TypeFq : null, - dtoOptionsBestCtorParamOrder: t.IsOptionsDto ? t.BestCtorParamOrder : null); - } - } - - private static void EmitDtoTypeExtensions( - SourceProductionContext context, - ImmutableArray targets, - string arghGeneratedRootTypeName) - { - if (targets.IsEmpty) - return; - - var sb = new StringBuilder(); - sb.AppendLine("// "); - sb.AppendLine("#nullable enable"); - sb.AppendLine("using System;"); - sb.AppendLine("using System.Diagnostics.CodeAnalysis;"); - sb.AppendLine(); - sb.AppendLine("namespace Nullean.Argh"); - sb.AppendLine("{"); - sb.AppendLine("\t/// Source-generated DTO parsers. Uses C# 14 extension members (static extensions on each DTO type) plus a -based overload for generic dispatch."); - sb.AppendLine("\tpublic static class ArghTypeBindingExtensions"); - sb.AppendLine("\t{"); - sb.AppendLine("\t\tpublic static bool TryParseArgh(this Type type, string[] args, [NotNullWhen(true)] out T? value) where T : class"); - sb.AppendLine("\t\t{"); - sb.AppendLine("\t\t\tvalue = null;"); - sb.AppendLine("\t\t\tif (!ReferenceEquals(type, typeof(T)))"); - sb.AppendLine("\t\t\t\tthrow new ArgumentException(\"The receiver must be typeof(T).\", nameof(type));"); - foreach (var t in targets) - { - var fq = t.TypeFq; - var method = "TryParseDto_" + DtoMethodSuffix(fq); - sb.AppendLine($"\t\t\tif (typeof(T) == typeof({fq}))"); - sb.AppendLine("\t\t\t{"); - sb.AppendLine($"\t\t\t\tvar ok = {arghGeneratedRootTypeName}.{method}(args, out var v);"); - sb.AppendLine("\t\t\t\tvalue = (T?)(object?)v;"); - sb.AppendLine("\t\t\t\treturn ok;"); - sb.AppendLine("\t\t\t}"); - } - - sb.AppendLine( - "\t\t\tthrow new InvalidOperationException(\"No pregenerated Argh DTO parser for \" + typeof(T).FullName + \". Register the type as UseGlobalOptions/UseNamespaceOptions or use it with [AsParameters] on a command.\");"); - sb.AppendLine("\t\t}"); - sb.AppendLine(); - - sb.AppendLine("\t\tpublic static bool TryParseArghExact(this Type type, string[] args, [NotNullWhen(true)] out T? value) where T : class"); - sb.AppendLine("\t\t{"); - sb.AppendLine("\t\t\tvalue = null;"); - sb.AppendLine("\t\t\tif (!ReferenceEquals(type, typeof(T)))"); - sb.AppendLine("\t\t\t\tthrow new ArgumentException(\"The receiver must be typeof(T).\", nameof(type));"); - foreach (var t in targets) - { - var fq = t.TypeFq; - var method = "TryParseDtoExact_" + DtoMethodSuffix(fq); - sb.AppendLine($"\t\t\tif (typeof(T) == typeof({fq}))"); - sb.AppendLine("\t\t\t{"); - sb.AppendLine($"\t\t\t\tvar ok = {arghGeneratedRootTypeName}.{method}(args, out var v);"); - sb.AppendLine("\t\t\t\tvalue = (T?)(object?)v;"); - sb.AppendLine("\t\t\t\treturn ok;"); - sb.AppendLine("\t\t\t}"); - } - - sb.AppendLine( - "\t\t\tthrow new InvalidOperationException(\"No pregenerated Argh DTO parser for \" + typeof(T).FullName + \". Register the type as UseGlobalOptions/UseNamespaceOptions or use it with [AsParameters] on a command.\");"); - sb.AppendLine("\t\t}"); - sb.AppendLine(); - - foreach (var t in targets) - { - if (t.IsGeneric) - continue; - - var fq = t.TypeFq; - var lenientMethod = "TryParseDto_" + DtoMethodSuffix(fq); - var strictMethod = "TryParseDtoExact_" + DtoMethodSuffix(fq); - var vis = t.IsPublic ? "public" : "internal"; - sb.AppendLine($"\t\textension({fq})"); - sb.AppendLine("\t\t{"); - sb.AppendLine($"\t\t\t{vis} static bool TryParseArgh(string[] args, [NotNullWhen(true)] out {fq}? value) =>"); - sb.AppendLine($"\t\t\t\tglobal::Nullean.Argh.{arghGeneratedRootTypeName}.{lenientMethod}(args, out value);"); - sb.AppendLine($"\t\t\t{vis} static bool TryParseArghExact(string[] args, [NotNullWhen(true)] out {fq}? value) =>"); - sb.AppendLine($"\t\t\t\tglobal::Nullean.Argh.{arghGeneratedRootTypeName}.{strictMethod}(args, out value);"); - sb.AppendLine("\t\t}"); - sb.AppendLine(); - } - - sb.AppendLine("\t}"); - sb.AppendLine("}"); - context.AddSource("ArghTypeBindingExtensions.g.cs", SourceText.From(sb.ToString(), Encoding.UTF8)); - } - - - - private static void EmitHierarchical( - SourceProductionContext context, - AppEmitModel app, - ImmutableArray dtoTargets, - string entryAssemblyName, - string entryAssemblyVersion, - string entrySchemaVersion, - string arghGeneratedRootTypeName) - { - var sb = new StringBuilder(); - sb.AppendLine("// "); - sb.AppendLine("#nullable enable"); - sb.AppendLine("using System;"); - sb.AppendLine("using System.Collections.Generic;"); - sb.AppendLine("using System.Globalization;"); - sb.AppendLine("using System.IO;"); - sb.AppendLine("using System.Linq;"); - sb.AppendLine("using System.Threading;"); - sb.AppendLine("using System.Threading.Tasks;"); - sb.AppendLine("using Nullean.Argh.Middleware;"); - sb.AppendLine("using Nullean.Argh.Help;"); - sb.AppendLine("using Nullean.Argh.Matching;"); - sb.AppendLine("using Nullean.Argh.Runtime;"); - sb.AppendLine("using Nullean.Argh.Schema;"); - sb.AppendLine(); - sb.AppendLine("namespace Nullean.Argh"); - sb.AppendLine("{"); - sb.AppendLine("\t/// Source-generated CLI entry point from ArghApp registrations. At the root, __completion bash|zsh|fish prints a shell script from ; each {0} in the template is replaced with the entry assembly name (same effect as string.Format, but substitution uses Replace so shell scripts can contain literal braces)."); - sb.AppendLine("\tpublic static class " + arghGeneratedRootTypeName); - sb.AppendLine("\t{"); - - // Static fields that hold the parsed global/namespace options instances. - // Commands inject these via method or constructor parameters. - if (app.GlobalOptionsModel is { } globalOptModel) - { - var fq = globalOptModel.TypeFq; - sb.AppendLine($"\t\tprivate static {fq} {OptionsStaticFieldNameFq(fq)} = new {fq}();"); - } - foreach ((var nsNode, _) in EnumerateCommandNamespaceNodesWithPath(app.Root, ImmutableArray.Empty)) - { - if (nsNode.CommandNamespaceOptionsModel is { } nsModel) - { - var fq = nsModel.TypeFq; - sb.AppendLine($"\t\tprivate static {fq} {OptionsStaticFieldNameFq(fq)} = new {fq}();"); - } - } - sb.AppendLine(); - - EmitCompletionForApp(sb, app); - sb.AppendLine("\t\tpublic static Task RunAsync(string[] args) =>"); - sb.AppendLine("\t\t\tRunWithCancellationAsync(args);"); - sb.AppendLine(); - AppendRunWithCancellationAsyncMethod(sb); - EmitRunCoreHierarchical(sb, app, entryAssemblyName); - sb.AppendLine(); - EmitPrintRootHelpHierarchical(sb, app, entryAssemblyName); - sb.AppendLine(); - foreach ((var node, var path) in EnumerateCommandNamespaceNodesWithPath(app.Root, ImmutableArray.Empty)) - EmitCommandNamespaceHelpPrinter(sb, path, node, app, entryAssemblyName); - - foreach (var cmd in app.AllCommands) - EmitCommandHelpPrinter(sb, cmd, app, entryAssemblyName); - - foreach (var cmd in app.AllCommands) - EmitCommandFlagHelpToStdErrMethod(sb, cmd, app); - - sb.AppendLine("\t\tprivate static void PrintVersion()"); - sb.AppendLine("\t\t{"); - sb.AppendLine($" Console.Out.WriteLine(\"{Escape(entryAssemblyVersion)}\");"); - sb.AppendLine("\t\t}"); - sb.AppendLine(); - - foreach (var cmd in app.AllCommands) - { - var injectedOpts = app.InjectionChains.TryGetValue(cmd.RunMethodName, out var precomputed3) - ? precomputed3 - : BuildOptionsInjectionChain(app, cmd); - EmitCommandRunner(sb, cmd, app.GlobalMiddleware, injectedOptions: injectedOpts, entryAssemblyName: entryAssemblyName); - } - - { - var globalPrefetchMembers = CollectRootPrefetchGlobalMembers(app); - var deferRootPrefetch = CollectDeferredRootAliasPrefetchFlags(app); - if (globalPrefetchMembers.Length > 0 || deferRootPrefetch.Length > 0) - { - if (app.GlobalOptionsModel is { } g) - { - EmitOptionsTryParse(sb, "TryParseGlobalOptions", - globalPrefetchMembers, - storeTypeFq: g.TypeFq, - storeFieldName: OptionsStaticFieldNameFq(g.TypeFq), - storeBestCtorParamOrder: g.BestCtorParamOrder, - entryAssemblyName: entryAssemblyName, - deferLeadingRootAliasFlags: deferRootPrefetch); - } - else - EmitOptionsTryParse(sb, "TryParseGlobalOptions", globalPrefetchMembers, - entryAssemblyName: entryAssemblyName, - deferLeadingRootAliasFlags: deferRootPrefetch); - } - } - - foreach ((var node, var path) in EnumerateCommandNamespaceNodesWithPath(app.Root, ImmutableArray.Empty)) - { - if (node.CommandNamespaceOptionsModel is { } nsModel) - { - // Use FLATTENED members (including inherited) so flags from parent options types are - // also recognised and consumed between the namespace segment and the sub-command. - if (nsModel.FlattenedMembers.Length > 0) - EmitOptionsTryParse(sb, CommandNamespaceOptionsParseMethodName(path), nsModel.FlattenedMembers, - storeTypeFq: nsModel.TypeFq, - storeFieldName: OptionsStaticFieldNameFq(nsModel.TypeFq), - storeBestCtorParamOrder: nsModel.BestCtorParamOrder, - entryAssemblyName: entryAssemblyName); - } - } - - EmitTryParseRouteHierarchical(sb, app); - EmitIsIntrinsicCommand(sb, app); - EmitDispatchForNode(sb, app, app.Root, ImmutableArray.Empty, "DispatchRoot", isRoot: true, entryAssemblyName); - sb.AppendLine("\t\tprivate static bool? ParseNullableBool(string? raw, bool fromYesSwitch)"); - sb.AppendLine("\t\t{"); - sb.AppendLine("\t\t\tif (string.IsNullOrEmpty(raw)) return fromYesSwitch;"); - sb.AppendLine("\t\t\tif (bool.TryParse(raw, out var b)) return b;"); - sb.AppendLine("\t\t\treturn fromYesSwitch;"); - sb.AppendLine("\t\t}"); - sb.AppendLine(); - sb.AppendLine("\t\tprivate static string[] TailFrom(string[] args, int start)"); - sb.AppendLine("\t\t{"); - sb.AppendLine("\t\t\tif (start >= args.Length) return Array.Empty();"); - sb.AppendLine("\t\t\tvar n = args.Length - start;"); - sb.AppendLine("\t\t\tvar r = new string[n];"); - sb.AppendLine("\t\t\tArray.Copy(args, start, r, 0, n);"); - sb.AppendLine("\t\t\treturn r;"); - sb.AppendLine("\t\t}"); - EmitDtoBindingMethods(sb, dtoTargets); - sb.AppendLine(); - var schemaVersion = app.SchemaVersionOverride ?? entrySchemaVersion; - EmitBuildCliSchemaDocumentHierarchical(sb, app, entryAssemblyName, schemaVersion); - sb.AppendLine("\t}"); - AppendArghRuntimeModuleInitializer(sb, arghGeneratedRootTypeName); - sb.AppendLine("}"); - context.AddSource("ArghGenerated.g.cs", SourceText.From(sb.ToString(), Encoding.UTF8)); - } - - private static IEnumerable<(RegistryNode node, ImmutableArray path)> EnumerateCommandNamespaceNodesWithPath( - RegistryNode root, - ImmutableArray prefix) - { - foreach (var ch in root.Children) - { - var p = AppendSegment(prefix, ch.Segment); - yield return (ch.Node, p); - foreach ((var node, var sub) in EnumerateCommandNamespaceNodesWithPath(ch.Node, p)) - yield return (node, sub); - } - } - - private static string CommandNamespacePathKey(ImmutableArray path) - { - if (path.IsDefaultOrEmpty) - return "Root"; - - var sb = new StringBuilder(); - for (var i = 0; i < path.Length; i++) - { - if (i > 0) - sb.Append('_'); - sb.Append(Naming.SanitizeIdentifier(path[i])); - } - - return sb.ToString(); - } - - private static string CommandNamespaceOptionsParseMethodName(ImmutableArray path) => - "TryParseCommandNamespaceOptions_" + CommandNamespacePathKey(path); - - private static void EmitRunCoreHierarchical(StringBuilder sb, AppEmitModel app, string entryAssemblyName) - { - var hasLeadingOptionPrefetch = - CollectRootPrefetchGlobalMembers(app).Length > 0 || CollectDeferredRootAliasPrefetchFlags(app).Length > 0; - sb.AppendLine("\t\tprivate static async Task RunCoreAsync(string[] args, CancellationToken ct)"); - sb.AppendLine("\t\t{"); - EmitRootCompletionScriptBlock(sb, "\t\t\t", entryAssemblyName); - EmitRootCompleteBlock(sb, "\t\t\t"); - EmitRootSchemaBlock(sb, "\t\t\t"); - sb.AppendLine("\t\t\tvar idx = new int[1];"); - if (hasLeadingOptionPrefetch) - sb.AppendLine("\t\t\tif (!TryParseGlobalOptions(args, idx)) return 2;"); - - sb.AppendLine("\t\t\tif (idx[0] < args.Length && (args[idx[0]] == \"--help\" || args[idx[0]] == \"-h\"))"); - sb.AppendLine("\t\t\t{"); - sb.AppendLine("\t\t\t\tPrintRootHelp();"); - sb.AppendLine("\t\t\t\treturn 0;"); - sb.AppendLine("\t\t\t}"); - sb.AppendLine("\t\t\tif (idx[0] < args.Length && args[idx[0]] == \"--version\")"); - sb.AppendLine("\t\t\t{"); - sb.AppendLine("\t\t\t\tPrintVersion();"); - sb.AppendLine("\t\t\t\treturn 0;"); - sb.AppendLine("\t\t\t}"); - sb.AppendLine("\t\t\tif (idx[0] >= args.Length)"); - sb.AppendLine("\t\t\t{"); - if (app.Root.RootCommand is { } runCoreRoot) - sb.AppendLine($"\t\t\t\treturn await {runCoreRoot.RunMethodName}(TailFrom(args, idx[0]), ct).ConfigureAwait(false);"); - else if (app.Root.RootAlias is { } runCoreAlias) - sb.AppendLine($"\t\t\t\treturn await {runCoreAlias.RunMethodName}(TailFrom(args, idx[0]), ct).ConfigureAwait(false);"); - else - { - sb.AppendLine("\t\t\t\tPrintRootHelp();"); - sb.AppendLine("\t\t\t\treturn 0;"); - } - - sb.AppendLine("\t\t\t}"); - sb.AppendLine("\t\t\treturn await DispatchRoot(args, idx, ct).ConfigureAwait(false);"); - sb.AppendLine("\t\t}"); - } - - private static void EmitPrintRootHelpHierarchical(StringBuilder sb, AppEmitModel app, string entryAssemblyName) - { - var rootGlobalFlags = new List(); - if ((app.Root.RootCommand is not null || app.Root.RootAlias is not null) && - app.GlobalOptionsModel is OptionsTypeModel gomH && gomH.Members.Length > 0) - { - foreach (var p in gomH.Members) - { - if (p.Kind == ParameterKind.Flag) - rootGlobalFlags.Add(p); - } - } - - var widthCandidatesGlobalRoot = new List { "-h, --help".Length, "--version".Length }; - widthCandidatesGlobalRoot.AddRange(rootGlobalFlags.Select(p => HelpLayout.FormatOptionLeftCell(p).Length)); - var maxOptWidthRoot = Math.Min(widthCandidatesGlobalRoot.Max(), 40); - maxOptWidthRoot = Math.Max(maxOptWidthRoot, "-h, --help".Length); - - var maxNsListingW = app.Root.Children.Count == 0 ? 0 : app.Root.Children.Max(ch => ch.Segment.Length); - var visibleRootCmds = app.Root.Commands.Where(static c => !c.IsHidden).ToList(); - var maxCmdListingW = visibleRootCmds.Count == 0 ? 0 : visibleRootCmds.Max(c => c.CommandName.Length); - - sb.AppendLine("\t\tprivate static void PrintRootHelp()"); - sb.AppendLine("\t\t{"); - sb.AppendLine( - $"\t\t\tConsole.Out.WriteLine(CliHelpFormatting.Section(\"Usage: \") + CliHelpFormatting.Accent(\"{Escape(entryAssemblyName)}\") + \" [options]\");"); - sb.AppendLine("\t\t\tConsole.Out.WriteLine();"); - if (app.Root.RootCommand is { } rootOverview) - EmitRootCommandHelpOverview(sb, rootOverview, "\t\t\t", app, entryAssemblyName); - else if (app.Root.RootAlias is { } rootAliasOverview) - // Preserve the legacy MapRoot root-help shape: "(default command)" plus handler docs — not the abbreviated - // root-alias synopsis (those details remain on the named sub-command's --help). - EmitRootCommandHelpOverview(sb, rootAliasOverview, "\t\t\t", app, entryAssemblyName, includeScopedDefaultHelpOptions: false); - else if (!string.IsNullOrWhiteSpace(app.RootSummary)) - { - EmitCommandHelpDocPrologue(sb, "\t\t\t", null, app.RootSummary, false); - sb.AppendLine("\t\t\tConsole.Out.WriteLine();"); - } - - sb.AppendLine("\t\t\tConsole.Out.WriteLine(CliHelpFormatting.Section(\"Global options:\"));"); - sb.AppendLine( - $"\t\t\tConsole.Out.WriteLine(\" \" + CliHelpFormatting.Placeholder(\"{Escape("-h, --help".PadRight(maxOptWidthRoot))}\") + \" Show help.\");"); - sb.AppendLine( - $"\t\t\tConsole.Out.WriteLine(\" \" + CliHelpFormatting.Placeholder(\"{Escape("--version".PadRight(maxOptWidthRoot))}\") + \" Show version.\");"); - EmitHelpOptionRows(sb, rootGlobalFlags, maxOptWidthRoot); - sb.AppendLine("\t\t\tConsole.Out.WriteLine();"); - if (app.Root.Children.Count > 0) - { - sb.AppendLine("\t\t\tConsole.Out.WriteLine(CliHelpFormatting.Section(\"Namespaces:\"));"); - foreach (var ch in app.Root.Children.OrderBy(ch => ch.Segment, StringComparer.OrdinalIgnoreCase).ThenBy(ch => ch.Segment, StringComparer.Ordinal)) - { - var sumArg = string.IsNullOrWhiteSpace(ch.SummaryOneLiner) - ? "null" - : $"\"{Escape(ch.SummaryOneLiner)}\""; - sb.AppendLine( - $"\t\t\tCliHelpFormatting.WriteHelpListNameAndDescription(true, \"{Escape(ch.Segment)}\", {sumArg}, {maxNsListingW});"); - } - - sb.AppendLine("\t\t\tConsole.Out.WriteLine();"); - } - - if (visibleRootCmds.Count > 0) - { - sb.AppendLine("\t\t\tConsole.Out.WriteLine(CliHelpFormatting.Section(\"Commands:\"));"); - foreach (var c in visibleRootCmds.OrderBy(c => c.CommandName, StringComparer.OrdinalIgnoreCase).ThenBy(c => c.CommandName, StringComparer.Ordinal)) - { - var sumArg = string.IsNullOrWhiteSpace(c.SummaryOneLiner) - ? "null" - : $"\"{Escape(c.SummaryOneLiner)}\""; - sb.AppendLine( - $"\t\t\tCliHelpFormatting.WriteHelpListNameAndDescription(false, \"{Escape(c.CommandName)}\", {sumArg}, {maxCmdListingW});"); - } - } - - sb.AppendLine("\t\t}"); - } - - private static void EmitCommandNamespaceHelpPrinter(StringBuilder sb, ImmutableArray path, RegistryNode node, AppEmitModel app, string entryAssemblyName) - { - var key = CommandNamespacePathKey(path); - var usagePrefix = string.Join(" ", path); - - var globalFlagMembers = EnumerateFlagMembers(app.GlobalOptionsModel).ToList(); - List<(string Segment, List Rows)> namespaceOptionSections = new(); - var namespaceOptionChain = GetCommandNamespaceOptionChain(app, path); - var suppressedForNamespaceDisplay = new HashSet(StringComparer.OrdinalIgnoreCase); - AddCliKeys(globalFlagMembers, suppressedForNamespaceDisplay); - foreach ((var seg, var gom) in namespaceOptionChain) - { - var allInNamespace = EnumerateFlagMembers(gom).ToList(); - var rows = allInNamespace.Where(p => !suppressedForNamespaceDisplay.Contains(p.CliLongName)).ToList(); - AddCliKeys(allInNamespace, suppressedForNamespaceDisplay); - if (rows.Count > 0) - namespaceOptionSections.Add((seg, rows)); - } - - var hasNsDoc = !string.IsNullOrWhiteSpace(node.SummaryInnerXml); - var showGlobalFlagsInNs = (node.RootCommand is not null || hasNsDoc) && globalFlagMembers.Count > 0; - var showNamespaceScopedOptionSections = node.RootCommand is not null || hasNsDoc; - var widthCandidatesNs = new List { "-h, --help".Length }; - if (showGlobalFlagsInNs) - widthCandidatesNs.AddRange(globalFlagMembers.Select(p => HelpLayout.FormatOptionLeftCell(p).Length)); - if (showNamespaceScopedOptionSections) - { - foreach ((_, var rows) in namespaceOptionSections) - widthCandidatesNs.AddRange(rows.Select(p => HelpLayout.FormatOptionLeftCell(p).Length)); - } - - var maxOptWidth = Math.Min(widthCandidatesNs.Max(), 40); - maxOptWidth = Math.Max(maxOptWidth, "-h, --help".Length); - - var maxChildNsListingW = 0; - if (node.Children.Count > 0) - maxChildNsListingW = node.Children.Max(ch => FormatQualifiedCliPath(path, ch.Segment).Length); - var maxChildCmdListingW = 0; - var visibleNodeCmds = node.Commands.Where(static c => !c.IsHidden).ToList(); - if (visibleNodeCmds.Count > 0) - maxChildCmdListingW = visibleNodeCmds.Max(c => FormatQualifiedCliPath(path, c.CommandName).Length); - - sb.AppendLine($"\t\tprivate static void PrintHelp_CommandNamespace_{key}()"); - sb.AppendLine("\t\t{"); - sb.AppendLine( - $"\t\t\tConsole.Out.WriteLine(CliHelpFormatting.Section(\"Usage: \") + CliHelpFormatting.Accent(\"{Escape(entryAssemblyName)}\") + \" {Escape(usagePrefix)} [options]\");"); - sb.AppendLine("\t\t\tConsole.Out.WriteLine();"); - if (node.RootCommand is { } nsRootOverview) - EmitRootCommandHelpOverview(sb, nsRootOverview, "\t\t\t", app, entryAssemblyName); - else if (node.RootAlias is { } nsAliasOverview) - EmitRootAliasHelpOverview(sb, nsAliasOverview, path, "\t\t\t", entryAssemblyName); - else if (!string.IsNullOrWhiteSpace(node.SummaryInnerXml)) - { - EmitCommandHelpDocPrologue(sb, "\t\t\t", node.SummaryInnerXml, "", false); - sb.AppendLine("\t\t\tConsole.Out.WriteLine();"); - } - - sb.AppendLine("\t\t\tConsole.Out.WriteLine(CliHelpFormatting.Section(\"Global options:\"));"); - sb.AppendLine( - $"\t\t\tConsole.Out.WriteLine(\" \" + CliHelpFormatting.Placeholder(\"{Escape("-h, --help".PadRight(maxOptWidth))}\") + \" Show help.\");"); - if (showGlobalFlagsInNs) - EmitHelpOptionRows(sb, globalFlagMembers, maxOptWidth); - - sb.AppendLine("\t\t\tConsole.Out.WriteLine();"); - - if (showNamespaceScopedOptionSections) - { - foreach ((var segment, var gRows) in namespaceOptionSections) - { - sb.AppendLine($"\t\t\tConsole.Out.WriteLine(CliHelpFormatting.Section(\"'{Escape(segment)}' options:\"));"); - EmitHelpOptionRows(sb, gRows, maxOptWidth); - sb.AppendLine("\t\t\tConsole.Out.WriteLine();"); - } - } - - if (node.Children.Count > 0) - { - sb.AppendLine("\t\t\tConsole.Out.WriteLine(CliHelpFormatting.Section(\"Namespaces:\"));"); - foreach (var ch in node.Children.OrderBy(ch => ch.Segment, StringComparer.OrdinalIgnoreCase).ThenBy(ch => ch.Segment, StringComparer.Ordinal)) - { - var fullNs = FormatQualifiedCliPath(path, ch.Segment); - var sumArg = string.IsNullOrWhiteSpace(ch.SummaryOneLiner) - ? "null" - : $"\"{Escape(ch.SummaryOneLiner)}\""; - sb.AppendLine( - $"\t\t\tCliHelpFormatting.WriteHelpListNameAndDescription(true, \"{Escape(fullNs)}\", {sumArg}, {maxChildNsListingW});"); - } - - sb.AppendLine("\t\t\tConsole.Out.WriteLine();"); - } - - if (visibleNodeCmds.Count > 0) - { - sb.AppendLine("\t\t\tConsole.Out.WriteLine(CliHelpFormatting.Section(\"Commands:\"));"); - foreach (var c in visibleNodeCmds.OrderBy(c => c.CommandName, StringComparer.OrdinalIgnoreCase).ThenBy(c => c.CommandName, StringComparer.Ordinal)) - { - var fullCmd = FormatQualifiedCliPath(path, c.CommandName); - var sumArg = string.IsNullOrWhiteSpace(c.SummaryOneLiner) - ? "null" - : $"\"{Escape(c.SummaryOneLiner)}\""; - sb.AppendLine( - $"\t\t\tCliHelpFormatting.WriteHelpListNameAndDescription(false, \"{Escape(fullCmd)}\", {sumArg}, {maxChildCmdListingW});"); - } - } - - if (node.RootCommand is null && !string.IsNullOrWhiteSpace(node.RemarksInnerXml)) - { - sb.AppendLine("\t\t\tConsole.Out.WriteLine();"); - EmitNotesSection(sb, "\t\t\t", node.RemarksInnerXml, ""); - } - - sb.AppendLine("\t\t}"); - sb.AppendLine(); - } - - private static void EmitDispatchForNode( - StringBuilder sb, - AppEmitModel app, - RegistryNode node, - ImmutableArray path, - string methodName, - bool isRoot, - string entryAssemblyName) - { - sb.AppendLine($"\t\tprivate static async Task {methodName}(string[] args, int[] idx, CancellationToken ct)"); - sb.AppendLine("\t\t{"); - // Call namespace options parse for any namespace that has a registered options type (even if it only - // inherits flags from a parent type) so that flags between the namespace segment and the sub-command - // are consumed correctly. - if (!isRoot && node.CommandNamespaceOptionsModel is { FlattenedMembers.Length: > 0 }) - sb.AppendLine($"\t\t\tif (!{CommandNamespaceOptionsParseMethodName(path)}(args, idx)) return 2;"); - - sb.AppendLine("\t\t\tif (idx[0] >= args.Length)"); - sb.AppendLine("\t\t\t{"); - if (node.RootCommand is { } dispatchRoot) - sb.AppendLine($"\t\t\t\treturn await {dispatchRoot.RunMethodName}(TailFrom(args, idx[0]), ct).ConfigureAwait(false);"); - else if (node.RootAlias is { } dispatchAlias) - sb.AppendLine($"\t\t\t\treturn await {dispatchAlias.RunMethodName}(TailFrom(args, idx[0]), ct).ConfigureAwait(false);"); - else - { - if (isRoot) - sb.AppendLine("\t\t\t\tPrintRootHelp();"); - else - sb.AppendLine($"\t\t\t\tPrintHelp_CommandNamespace_{CommandNamespacePathKey(path)}();"); - - sb.AppendLine("\t\t\t\treturn 0;"); - } - - sb.AppendLine("\t\t\t}"); - sb.AppendLine("\t\t\tif (args[idx[0]] == \"--help\" || args[idx[0]] == \"-h\")"); - sb.AppendLine("\t\t\t{"); - if (isRoot) - sb.AppendLine("\t\t\t\tPrintRootHelp();"); - else - sb.AppendLine($"\t\t\t\tPrintHelp_CommandNamespace_{CommandNamespacePathKey(path)}();"); - - sb.AppendLine("\t\t\t\treturn 0;"); - sb.AppendLine("\t\t\t}"); - sb.AppendLine("\t\t\tvar tok = args[idx[0]];"); - foreach (var cmd in node.Commands) - EmitOrdinalIgnoreCaseIf(sb, "\t\t\t", "tok", cmd.CommandName, s => - { - s.AppendLine("\t\t\t\tidx[0]++;"); - s.AppendLine($"\t\t\t\treturn await {cmd.RunMethodName}(TailFrom(args, idx[0]), ct).ConfigureAwait(false);"); - }); - - foreach (var ch in node.Children) - { - var childPath = AppendSegment(path, ch.Segment); - var childMethod = "DispatchCommandNamespace_" + CommandNamespacePathKey(childPath); - EmitOrdinalIgnoreCaseIf(sb, "\t\t\t", "tok", ch.Segment, s => - { - s.AppendLine("\t\t\t\tidx[0]++;"); - s.AppendLine($"\t\t\t\treturn await {childMethod}(args, idx, ct).ConfigureAwait(false);"); - }); - } - - var flagFallbackMethod = node.RootCommand?.RunMethodName ?? node.RootAlias?.RunMethodName; - if (flagFallbackMethod is not null) - { - sb.AppendLine("\t\t\tif (tok.Length > 0 && tok[0] == '-')"); - sb.AppendLine("\t\t\t{"); - sb.AppendLine($"\t\t\t\treturn await {flagFallbackMethod}(TailFrom(args, idx[0]), ct).ConfigureAwait(false);"); - sb.AppendLine("\t\t\t}"); - } - - sb.AppendLine("\t\t\t{"); - EmitFuzzyDispatchDefault(sb, node, path, entryAssemblyName); - sb.AppendLine("\t\t\t}"); - sb.AppendLine("\t\t}"); - sb.AppendLine(); - foreach (var ch in node.Children) - { - var childPath = AppendSegment(path, ch.Segment); - EmitDispatchForNode(sb, app, ch.Node, childPath, "DispatchCommandNamespace_" + CommandNamespacePathKey(childPath), isRoot: false, entryAssemblyName); - } - } - - /// - /// Extracts the full doc-comment XML from a trivia list. - /// Works regardless of DocumentationMode: when GenerateDocumentationFile is unset the compiler - /// uses DocumentationMode.None and stores /// lines as plain SingleLineCommentTrivia - /// rather than structured SingleLineDocumentationCommentTrivia. Both paths are handled here. - /// - private static string ExtractDocumentationFromTriviaList(SyntaxTriviaList triviaList) - { - // Fast path: structured documentation trivia (DocumentationMode=Parse or Diagnose) - foreach (var trivia in triviaList) - { - if (!trivia.IsKind(SyntaxKind.SingleLineDocumentationCommentTrivia) && - !trivia.IsKind(SyntaxKind.MultiLineDocumentationCommentTrivia)) - continue; - var stripped = DocTriviaStripPattern.Replace(trivia.ToFullString(), "").Trim(); - if (stripped.Length > 0) - return stripped; - } - - // Slow path: plain comment trivia (DocumentationMode=None, i.e. GenerateDocumentationFile not set). - // Collect consecutive /// lines immediately preceding the token. - var sb = new StringBuilder(); - foreach (var trivia in triviaList) - { - if (trivia.IsKind(SyntaxKind.SingleLineCommentTrivia)) - { - var s = trivia.ToString(); - if (s.StartsWith("///", StringComparison.Ordinal)) - { - sb.AppendLine(s); - continue; - } - } - // Non-doc trivia resets the accumulator so we only keep the block immediately before the declaration. - if (!trivia.IsKind(SyntaxKind.WhitespaceTrivia) && !trivia.IsKind(SyntaxKind.EndOfLineTrivia)) - sb.Clear(); - } - - if (sb.Length > 0) - { - var stripped = DocTriviaStripPattern.Replace(sb.ToString(), "").Trim(); - if (stripped.Length > 0) - return stripped; - } - - return ""; - } - - private static string TryExtractFullDocumentationFromTrivia(IMethodSymbol method) - { - foreach (var sr in method.DeclaringSyntaxReferences) - { - if (sr.GetSyntax() is not MethodDeclarationSyntax m) - continue; - var result = ExtractDocumentationFromTriviaList(m.GetLeadingTrivia()); - if (result.Length > 0) - return result; - } - - return ""; - } - - private static string TryExtractFullDocumentationFromPropertyTrivia(IPropertySymbol prop) - { - foreach (var sr in prop.DeclaringSyntaxReferences) - { - switch (sr.GetSyntax()) - { - case PropertyDeclarationSyntax p: - { - var result = ExtractDocumentationFromTriviaList(p.GetLeadingTrivia()); - if (result.Length > 0) - return result; - break; - } - // Positional record (and class primary-constructor) parameters: the public API is a property - // whose declaring syntax is the parameter, not a property declaration. - case ParameterSyntax par: - { - var result = ExtractDocumentationFromTriviaList(par.GetLeadingTrivia()); - if (result.Length > 0) - return result; - break; - } - } - } - - return ""; - } - - private static string TryExtractDocumentationFromParameterTrivia(IParameterSymbol param) - { - foreach (var sr in param.DeclaringSyntaxReferences) - { - if (sr.GetSyntax() is not ParameterSyntax par) - continue; - var result = ExtractDocumentationFromTriviaList(par.GetLeadingTrivia()); - if (result.Length > 0) - return result; - } - - return ""; - } - - private static string TryExtractFullDocumentationFromFieldTrivia(IFieldSymbol field) - { - foreach (var sr in field.DeclaringSyntaxReferences) - { - switch (sr.GetSyntax()) - { - case FieldDeclarationSyntax fd: - { - var result = ExtractDocumentationFromTriviaList(fd.GetLeadingTrivia()); - if (result.Length > 0) - return result; - break; - } - case VariableDeclaratorSyntax vd when vd.Parent is VariableDeclarationSyntax { Parent: FieldDeclarationSyntax fd }: - { - var result = ExtractDocumentationFromTriviaList(fd.GetLeadingTrivia()); - if (result.Length > 0) - return result; - break; - } - } - } - - return ""; - } - - private static string TryExtractFullDocumentationFromTypeTrivia(INamedTypeSymbol type) - { - foreach (var sr in type.DeclaringSyntaxReferences) - { - if (sr.GetSyntax() is not BaseTypeDeclarationSyntax typeDecl) - continue; - var result = ExtractDocumentationFromTriviaList(typeDecl.GetLeadingTrivia()); - if (result.Length > 0) - return result; - } - - return ""; - } - - private static string TryExtractTypeSummaryFromTrivia(INamedTypeSymbol type) - { - foreach (var sr in type.DeclaringSyntaxReferences) - { - if (sr.GetSyntax() is not BaseTypeDeclarationSyntax typeDecl) - continue; - foreach (var trivia in typeDecl.GetLeadingTrivia()) - { - if (!trivia.HasStructure || trivia.GetStructure() is not DocumentationCommentTriviaSyntax doc) - continue; - foreach (var xml in doc.Content) - { - if (xml is XmlElementSyntax xe && xe.StartTag.Name.LocalName.ValueText == "summary") - { - var s = FlattenXmlSummaryElementText(xe).Trim(); - if (s.Length > 0) - return s; - } - } - } - } - - return ""; - } - - private static string FlattenXmlSummaryElementText(XmlElementSyntax xe) - { - var sb = new StringBuilder(); - foreach (var n in xe.Content) - { - switch (n) - { - case XmlTextSyntax txt: - foreach (var t in txt.TextTokens) - sb.Append(t.ValueText); - break; - case XmlElementSyntax inner: - sb.Append(FlattenXmlSummaryElementText(inner)); - break; - case XmlEmptyElementSyntax: - break; - } - } - - return sb.ToString(); - } - - private static string GetTypeListingSummaryOneLiner(INamedTypeSymbol type) - { - var xml = type.GetDocumentationCommentXml(); - if (string.IsNullOrWhiteSpace(xml)) - xml = TryExtractFullDocumentationFromTypeTrivia(type); - if (!string.IsNullOrWhiteSpace(xml)) - { - var fromXml = Documentation.GetTypeSummaryLine(xml); - if (!string.IsNullOrWhiteSpace(fromXml)) - return fromXml.Trim(); - } - return ""; - } - - private static MethodDocumentation MergeMethodDocumentationFromTrivia( - IMethodSymbol method, - MethodDocumentation docs, - CSharpParseOptions parseOptions) - { - // GetDocumentationCommentXml() is empty when GenerateDocumentationFile is not set. - // In that case parse the full doc comment directly from syntax trivia so all fields - // (summary, remarks, params, examples) are recovered without requiring that MSBuild property. - if (string.IsNullOrWhiteSpace(docs.SummaryOneLiner)) - { - var full = TryExtractFullDocumentationFromTrivia(method); - if (full.Length > 0) - { - var fromTrivia = Documentation.ParseMethod(full, parseOptions); - if (!string.IsNullOrWhiteSpace(fromTrivia.SummaryOneLiner)) - return fromTrivia; - } - } - - return docs; - } - - private static string GetCommandRoutePath(CommandModel cmd) - { - if (cmd.IsRootDefault) - { - if (cmd.RoutePrefix.IsDefaultOrEmpty) - return ""; - return string.Join("/", cmd.RoutePrefix) + "/"; - } - - if (cmd.RoutePrefix.IsDefaultOrEmpty) - return cmd.CommandName; - return string.Join("/", cmd.RoutePrefix) + "/" + cmd.CommandName; - } - - /// Help printer invoked from generated command runners for default/root handlers (overview lives in root or namespace overview). - private static string HelpPrinterMethodForCommand(CommandModel cmd) - { - if (cmd.IsRootDefault) - { - if (cmd.RoutePrefix.IsDefaultOrEmpty) - return "PrintRootHelp"; - return "PrintHelp_CommandNamespace_" + CommandNamespacePathKey(cmd.RoutePrefix); - } - - return "PrintHelp_" + cmd.RunMethodName; - } - - /// Indented body lines for default-handler summary/remarks (one indent step less than before). - private static void EmitRootDefaultDocumentationLines(StringBuilder sb, string indent, string? innerXml, string? plainFallback, bool isRemarks) - { - if (!string.IsNullOrWhiteSpace(innerXml)) - { - // Concatenate (do not use $"..." interpolation): inner XML can contain `{`/`}` from generic cref text. - sb.AppendLine(indent + "global::Nullean.Argh.Help.XmlDocumentationRenderer.WriteIndentedDoc(Console.Out, \" \", \"" + EscapeDocXml(innerXml!) + "\", " + (isRemarks ? "true" : "false") + ");"); - return; - } - - if (string.IsNullOrWhiteSpace(plainFallback)) - return; - var text = plainFallback!; - foreach (var part in text.Replace("\r\n", "\n").Split('\n')) - { - var line = part.TrimEnd('\r'); - if (string.IsNullOrWhiteSpace(line)) - sb.AppendLine($"{indent}Console.Out.WriteLine();"); - else - sb.AppendLine($"{indent}Console.Out.WriteLine(\" \" + \"{Escape(line.Trim())}\");"); - } - } - - /// Summary (white) after usage, or remarks (gray) after options; caller emits Notes: before remarks when using per-command help. - private static void EmitCommandHelpDocPrologue(StringBuilder sb, string indent, string? innerXml, string? plainFallback, bool remarks) - { - if (!string.IsNullOrWhiteSpace(innerXml)) - { - sb.AppendLine(indent + "global::Nullean.Argh.Help.XmlDocumentationRenderer.WriteIndentedDoc(Console.Out, \" \", \"" + EscapeDocXml(innerXml!) + "\", " + (remarks ? "true" : "false") + ");"); - return; - } - - if (string.IsNullOrWhiteSpace(plainFallback)) - return; - var text = plainFallback!; - var styler = remarks ? "CliHelpFormatting.DocRemarksLine" : "CliHelpFormatting.DocSummaryLine"; - foreach (var part in text.Replace("\r\n", "\n").Split('\n')) - { - var line = part.TrimEnd('\r'); - if (string.IsNullOrWhiteSpace(line)) - sb.AppendLine($"{indent}Console.Out.WriteLine();"); - else - sb.AppendLine($"{indent}Console.Out.WriteLine(\" \" + {styler}(\"{Escape(line.Trim())}\"));"); - } - } - - /// Flatten remarks inner XML to plain text at generation time (used for single-line detection). - private static string FlattenRemarksXml(string? innerXml) - { - if (string.IsNullOrWhiteSpace(innerXml)) - return ""; - try - { - var el = XElement.Parse("" + innerXml + "", LoadOptions.PreserveWhitespace); - return Documentation.FlattenBlockPublic(el).Replace("\r\n", "\n").Trim(); - } - catch { return ""; } - } - - /// - /// Emits the Notes: section for command/namespace help. - /// Single-line remarks are inlined on the same line as "Notes:"; - /// multi-line remarks follow on the next line with 2-space indent to align with Commands/Options above. - /// - private static void EmitNotesSection(StringBuilder sb, string indent, string? innerXml, string plainRendered) - { - var flat = string.IsNullOrWhiteSpace(plainRendered) - ? FlattenRemarksXml(innerXml) - : plainRendered.Replace("\r\n", "\n").Trim(); - if (string.IsNullOrWhiteSpace(flat)) - return; - - var singleLine = !flat.Contains('\n'); - if (singleLine) - { - sb.AppendLine($"{indent}Console.Out.WriteLine(CliHelpFormatting.Section(\"Notes:\") + \" \" + CliHelpFormatting.DocRemarksLine(\"{Escape(flat)}\"));"); - return; - } - - sb.AppendLine($"{indent}Console.Out.WriteLine(CliHelpFormatting.Section(\"Notes:\"));"); - if (!string.IsNullOrWhiteSpace(innerXml)) - { - sb.AppendLine(indent + "global::Nullean.Argh.Help.XmlDocumentationRenderer.WriteIndentedDoc(Console.Out, \" \", \"" + EscapeDocXml(innerXml!) + "\", true);"); - return; - } - foreach (var part in flat.Split('\n')) - { - var line = part.TrimEnd('\r'); - if (string.IsNullOrWhiteSpace(line)) - sb.AppendLine($"{indent}Console.Out.WriteLine();"); - else - sb.AppendLine($"{indent}Console.Out.WriteLine(\" \" + CliHelpFormatting.DocRemarksLine(\"{Escape(line.Trim())}\"));"); - } - } - - private static void EmitRootCommandHelpOverview( - StringBuilder sb, - CommandModel rootCmd, - string indent, - AppEmitModel app, - string entryAssemblyName, - bool includeScopedDefaultHelpOptions = true) - { - // "(default command)" is not an argv token — labels the opt-in default handler; summary/remarks from XML on the handler. - sb.AppendLine($"{indent}Console.Out.WriteLine(\" \" + CliHelpFormatting.DefaultCommandLabel(\"(default command)\"));"); - EmitRootDefaultDocumentationLines(sb, indent, rootCmd.SummaryInnerXml, rootCmd.SummaryOneLiner, false); - var remarksXml = TransformRemarksInnerXmlForHelp(rootCmd.RemarksInnerXml, rootCmd, app.AllCommands, entryAssemblyName); - EmitRootDefaultDocumentationLines(sb, indent, remarksXml, rootCmd.RemarksRendered, true); - var rootFlags = rootCmd.Parameters.Where(static p => p.Kind == ParameterKind.Flag).ToList(); - if (includeScopedDefaultHelpOptions && rootFlags.Count > 0) - { - var mw = Math.Min( - Math.Max(rootFlags.Max(p => HelpLayout.FormatOptionLeftCell(p).Length), "-h, --help".Length), - 40); - sb.AppendLine($"{indent}Console.Out.WriteLine();"); - sb.AppendLine($"{indent}Console.Out.WriteLine(CliHelpFormatting.Section(\"Options for this default:\"));"); - foreach (var p in rootFlags) - { - var left = HelpLayout.FormatOptionLeftCell(p).PadRight(mw); - var desc = BuildDescriptionSuffix(p, forPositional: false); - sb.AppendLine( - $"{indent}Console.Out.WriteLine($\" {{CliHelpFormatting.Accent(\"{Escape(left)}\")}} {EscapeForHelpInterpolation(desc)}\");"); - } - } - - sb.AppendLine($"{indent}Console.Out.WriteLine();"); - } - - private static void EmitRootAliasHelpOverview(StringBuilder sb, CommandModel aliasCmd, ImmutableArray path, string indent, string entryAssemblyName) - { - var fullCommandPath = path.IsDefaultOrEmpty - ? $"{entryAssemblyName} {aliasCmd.CommandName}" - : $"{entryAssemblyName} {string.Join(" ", path)} {aliasCmd.CommandName}"; - sb.AppendLine($"{indent}Console.Out.WriteLine(\" \" + CliHelpFormatting.DefaultCommandLabel(\"(default: {Escape(aliasCmd.CommandName)})\"));"); - if (!string.IsNullOrWhiteSpace(aliasCmd.SummaryOneLiner)) - sb.AppendLine($"{indent}Console.Out.WriteLine(\" {Escape(aliasCmd.SummaryOneLiner)}\");"); - sb.AppendLine($"{indent}Console.Out.WriteLine(\" Alias for '{Escape(fullCommandPath)}'. Run '{Escape(fullCommandPath)} --help' for details.\");"); - sb.AppendLine($"{indent}Console.Out.WriteLine();"); - } - - /// Space-separated CLI path for help listings (e.g. storage blob upload). - private static string FormatQualifiedCliPath(ImmutableArray prefix, string segment) - { - if (prefix.IsDefaultOrEmpty) - return segment; - return string.Join(" ", prefix) + " " + segment; - } - - private static void EmitArghGeneratedRouteArgsMethod(StringBuilder sb) - { - sb.AppendLine("\t\tpublic static RouteMatch? Route(string[] args)"); - sb.AppendLine("\t\t{"); - sb.AppendLine("\t\t\tif (args is null)"); - sb.AppendLine("\t\t\t\tthrow new ArgumentNullException(nameof(args));"); - sb.AppendLine("\t\t\tif (!TryParseRoute(args, out var m))"); - sb.AppendLine("\t\t\t\treturn null;"); - sb.AppendLine("\t\t\treturn m;"); - sb.AppendLine("\t\t}"); - sb.AppendLine(); - } - - - private static void EmitIsIntrinsicCommand(StringBuilder sb, AppEmitModel app) - { - sb.AppendLine("\t\tinternal static bool IsIntrinsicCommand(string[] args)"); - sb.AppendLine("\t\t{"); - sb.AppendLine("\t\t\tif (args is null || args.Length == 0) return false;"); - - var intrinsicCommands = app.AllCommands.Where(static c => c.IsIntrinsic).ToList(); - if (intrinsicCommands.Count == 0) - { - sb.AppendLine("\t\t\treturn false;"); - sb.AppendLine("\t\t}"); - sb.AppendLine(); - return; - } - - sb.AppendLine("\t\t\tvar match = Route(args);"); - sb.AppendLine("\t\t\tif (match is null) return false;"); - sb.AppendLine("\t\t\tswitch (match.Value.CommandPath)"); - sb.AppendLine("\t\t\t{"); - foreach (var cmd in intrinsicCommands) - { - var routePath = Escape(GetCommandRoutePath(cmd)); - sb.AppendLine($"\t\t\t\tcase \"{routePath}\": return true;"); - } - sb.AppendLine("\t\t\t\tdefault: return false;"); - sb.AppendLine("\t\t\t}"); - sb.AppendLine("\t\t}"); - sb.AppendLine(); - } - - private static void EmitTryParseRouteHierarchical(StringBuilder sb, AppEmitModel app) - { - var hasLeadingOptionPrefetch = - CollectRootPrefetchGlobalMembers(app).Length > 0 || CollectDeferredRootAliasPrefetchFlags(app).Length > 0; - sb.AppendLine("\t\tpublic static bool TryParseRoute(string[] args, out RouteMatch match)"); - sb.AppendLine("\t\t{"); - sb.AppendLine("\t\t\tmatch = default;"); - sb.AppendLine("\t\t\tif (CompletionProtocol.IsArghMetaCompletionInvocation(args)) return false;"); - sb.AppendLine("\t\t\tvar idx = new int[1];"); - if (hasLeadingOptionPrefetch) - sb.AppendLine("\t\t\tif (!TryParseGlobalOptions(args, idx)) return false;"); - sb.AppendLine("\t\t\tif (args.Length == 0) return false;"); - sb.AppendLine("\t\t\tif (idx[0] < args.Length && (args[idx[0]] == \"--help\" || args[idx[0]] == \"-h\")) return false;"); - sb.AppendLine("\t\t\tif (idx[0] < args.Length && args[idx[0]] == \"--version\") return false;"); - sb.AppendLine("\t\t\tif (idx[0] >= args.Length)"); - sb.AppendLine("\t\t\t{"); - if (app.Root.RootCommand is { } routeRoot) - { - var rp = Escape(GetCommandRoutePath(routeRoot)); - sb.AppendLine($"\t\t\t\tmatch = new RouteMatch(\"{rp}\", TailFrom(args, idx[0]));"); - sb.AppendLine("\t\t\t\treturn true;"); - } - else if (app.Root.RootAlias is { } routeRa) - { - var rp = Escape(GetCommandRoutePath(routeRa)); - sb.AppendLine($"\t\t\t\tmatch = new RouteMatch(\"{rp}\", TailFrom(args, idx[0]));"); - sb.AppendLine("\t\t\t\treturn true;"); - } - else - { - sb.AppendLine("\t\t\t\treturn false;"); - } - - sb.AppendLine("\t\t\t}"); - sb.AppendLine("\t\t\treturn TryParseRouteRoot(args, idx, out match);"); - sb.AppendLine("\t\t}"); - sb.AppendLine(); - EmitTryParseRouteForNode(sb, app, app.Root, ImmutableArray.Empty, "TryParseRouteRoot", isRoot: true); - EmitArghGeneratedRouteArgsMethod(sb); - } - - private static void EmitTryParseRouteForNode( - StringBuilder sb, - AppEmitModel app, - RegistryNode node, - ImmutableArray path, - string methodName, - bool isRoot) - { - sb.AppendLine($"\t\tprivate static bool {methodName}(string[] args, int[] idx, out RouteMatch match)"); - sb.AppendLine("\t\t{"); - sb.AppendLine("\t\t\tmatch = default;"); - if (!isRoot && node.CommandNamespaceOptionsModel is { FlattenedMembers.Length: > 0 }) - sb.AppendLine($"\t\t\tif (!{CommandNamespaceOptionsParseMethodName(path)}(args, idx)) return false;"); - sb.AppendLine("\t\t\tif (idx[0] >= args.Length)"); - sb.AppendLine("\t\t\t{"); - if (node.RootCommand is { } routeNsRoot) - { - var rnp = Escape(GetCommandRoutePath(routeNsRoot)); - sb.AppendLine($"\t\t\t\tmatch = new RouteMatch(\"{rnp}\", TailFrom(args, idx[0]));"); - sb.AppendLine("\t\t\t\treturn true;"); - } - else if (node.RootAlias is { } routeNsRa) - { - var rnp = Escape(GetCommandRoutePath(routeNsRa)); - sb.AppendLine($"\t\t\t\tmatch = new RouteMatch(\"{rnp}\", TailFrom(args, idx[0]));"); - sb.AppendLine("\t\t\t\treturn true;"); - } - else - { - sb.AppendLine("\t\t\t\treturn false;"); - } - - sb.AppendLine("\t\t\t}"); - sb.AppendLine("\t\t\tif (args[idx[0]] == \"--help\" || args[idx[0]] == \"-h\") return false;"); - sb.AppendLine("\t\t\tvar tokKey = args[idx[0]].ToLowerInvariant();"); - sb.AppendLine("\t\t\tswitch (tokKey)"); - sb.AppendLine("\t\t\t{"); - foreach (var cmd in node.Commands) - { - var routePath = Escape(GetCommandRoutePath(cmd)); - var caseLabel = Escape(cmd.CommandName.ToLowerInvariant()); - sb.AppendLine($"\t\t\t\tcase \"{caseLabel}\":"); - sb.AppendLine("\t\t\t\t{"); - sb.AppendLine("\t\t\t\t\tidx[0]++;"); - sb.AppendLine($"\t\t\t\t\tmatch = new RouteMatch(\"{routePath}\", TailFrom(args, idx[0]));"); - sb.AppendLine("\t\t\t\t\treturn true;"); - sb.AppendLine("\t\t\t\t}"); - } - - foreach (var ch in node.Children) - { - var childPath = AppendSegment(path, ch.Segment); - var childMethod = "TryParseRouteCommandNamespace_" + CommandNamespacePathKey(childPath); - var caseLabel = Escape(ch.Segment.ToLowerInvariant()); - sb.AppendLine($"\t\t\t\tcase \"{caseLabel}\":"); - sb.AppendLine("\t\t\t\t{"); - sb.AppendLine("\t\t\t\t\tidx[0]++;"); - sb.AppendLine($"\t\t\t\t\treturn {childMethod}(args, idx, out match);"); - sb.AppendLine("\t\t\t\t}"); - } - - sb.AppendLine("\t\t\t\tdefault:"); - sb.AppendLine("\t\t\t\t\treturn false;"); - sb.AppendLine("\t\t\t}"); - sb.AppendLine("\t\t}"); - sb.AppendLine(); - foreach (var ch in node.Children) - { - var childPath = AppendSegment(path, ch.Segment); - EmitTryParseRouteForNode(sb, app, ch.Node, childPath, "TryParseRouteCommandNamespace_" + CommandNamespacePathKey(childPath), isRoot: false); - } - } - - private static ImmutableArray CollectRootPrefetchGlobalMembers(AppEmitModel app) => - app.GlobalOptionsModel?.FlattenedMembers ?? ImmutableArray.Empty; - - /// - /// Union of prefetch parse sets (flattened globals + flattened root-alias-command flags). - /// Used to decide which leading flags can be peeled in vs deferred to dispatch. - /// - private static ImmutableArray MergeRootPrefetchPredicateMembers(AppEmitModel app) - { - var globalFlattened = app.GlobalOptionsModel?.FlattenedMembers ?? ImmutableArray.Empty; - if (app.Root.RootAlias is not { } aliasCmd || aliasCmd.Parameters.IsDefaultOrEmpty) - return globalFlattened; - - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var m in globalFlattened) - if (ParticipatesInOptionPrefetch(m.Kind)) - seen.Add(m.CliLongName); - - var trailing = ImmutableArray.CreateBuilder(); - foreach (var p in aliasCmd.Parameters) - { - if (!ParticipatesInOptionPrefetch(p.Kind)) - continue; - - if (!seen.Add(p.CliLongName)) - continue; - - trailing.Add(p); - } - - if (trailing.Count == 0) - return globalFlattened; - - var merged = ImmutableArray.CreateBuilder(globalFlattened.Length + trailing.Count); - merged.AddRange(globalFlattened); - merged.AddRange(trailing); - - return merged.ToImmutable(); - } - - /// - /// Root-alias-command flags minus anything already declared on . - /// When these appear as leading --prefixed tokens, must defer them - /// (break without consuming idx) so DispatchRoot can route them to . - /// - private static ImmutableArray CollectDeferredRootAliasPrefetchFlags(AppEmitModel app) - { - var merged = MergeRootPrefetchPredicateMembers(app); - var globals = CollectRootPrefetchGlobalMembers(app); - if (app.Root.RootAlias is null || merged.Length <= globals.Length) - return ImmutableArray.Empty; - - var defer = ImmutableArray.CreateBuilder(); - for (var i = globals.Length; i < merged.Length; i++) - { - var p = merged[i]; - if (ParticipatesInOptionPrefetch(p.Kind)) - defer.Add(p); - } - - return defer.Count == 0 ? ImmutableArray.Empty : defer.ToImmutable(); - } - - private static bool ParticipatesInOptionPrefetch(ParameterKind kind) => - kind == ParameterKind.Flag || kind == ParameterKind.OptionsInjected; - - private static CommandModel SyntheticOptionsCommand(ImmutableArray members, string runMethodName) => - new( - ImmutableArray.Empty, - "__opt__", - runMethodName, - "object", - "__noop", - false, - false, - "global::System.Void", - false, - true, - members, - false, - ImmutableArray.Empty, - SourceSpanInfo.None, - ImmutableArray<(string, string)>.Empty, - "", // HandlerDocCommentId - "", // SummaryOneLiner - "", // RemarksRendered - "", // SummaryInnerXml - "", // RemarksInnerXml - "", // ExamplesRendered - "", // UsageHints - ImmutableArray<(string, bool)>.Empty); - - private static void EmitAllowedFlagPredicate(StringBuilder sb, ImmutableArray members) - { - var allowed = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var p in members) - { - if (p.Kind != ParameterKind.Flag) - continue; - allowed.Add(p.CliLongName); - foreach (var al in p.Aliases) - { - if (string.Equals(al, p.CliLongName, StringComparison.OrdinalIgnoreCase)) - continue; - allowed.Add(al); - } - - if (p.Special == BoolSpecialKind.NullableBool) - { - allowed.Add("no-" + p.CliLongName); - foreach (var al in p.Aliases) - { - if (string.Equals(al, p.CliLongName, StringComparison.OrdinalIgnoreCase)) - continue; - allowed.Add("no-" + al); - } - } - } - - sb.AppendLine("\t\t\tbool IsAllowedFlag(string name) => name switch"); - sb.AppendLine("\t\t\t{"); - foreach (var n in allowed.OrderBy(static x => x, StringComparer.OrdinalIgnoreCase)) - sb.AppendLine($"\t\t\t\t\"{Escape(n)}\" => true,"); - - sb.AppendLine("\t\t\t\t_ => false"); - sb.AppendLine("\t\t\t};"); - } - - private static void EmitDeferLeadingRootAliasHelpers(StringBuilder sb, ImmutableArray defer) - { - if (defer.IsDefaultOrEmpty) - { - sb.AppendLine("\t\t\tbool ShouldDeferLeadingRootAliasCanon(string name) => false;"); - sb.AppendLine("\t\t\tbool ShouldDeferLeadingShortFlag(char c) => false;"); - return; - } - - var canonNames = new HashSet(StringComparer.OrdinalIgnoreCase); - var shortChars = new HashSet(); - foreach (var p in defer) - { - canonNames.Add(p.CliLongName); - foreach (var al in p.Aliases) - { - if (string.Equals(al, p.CliLongName, StringComparison.OrdinalIgnoreCase)) - continue; - canonNames.Add(al); - } - - if (p.Special == BoolSpecialKind.NullableBool) - { - canonNames.Add("no-" + p.CliLongName); - foreach (var al in p.Aliases) - { - if (string.Equals(al, p.CliLongName, StringComparison.OrdinalIgnoreCase)) - continue; - canonNames.Add("no-" + al); - } - } - - if (p.ShortOpt is char ch) - shortChars.Add(ch); - } - - sb.AppendLine("\t\t\tbool ShouldDeferLeadingRootAliasCanon(string name) => name switch"); - sb.AppendLine("\t\t\t{"); - foreach (var n in canonNames.OrderBy(static x => x, StringComparer.OrdinalIgnoreCase)) - sb.AppendLine($"\t\t\t\t\"{Escape(n)}\" => true,"); - - sb.AppendLine("\t\t\t\t_ => false"); - sb.AppendLine("\t\t\t};"); - - if (shortChars.Count == 0) - { - sb.AppendLine("\t\t\tbool ShouldDeferLeadingShortFlag(char c) => false;"); - return; - } - - sb.AppendLine("\t\t\tbool ShouldDeferLeadingShortFlag(char c) => c switch"); - sb.AppendLine("\t\t\t{"); - foreach (var ch in shortChars.OrderBy(static x => x)) - sb.AppendLine($"\t\t\t\t'{ch}' => true,"); - - sb.AppendLine("\t\t\t\t_ => false"); - sb.AppendLine("\t\t\t};"); - } - - private static void EmitOptionsTryParse( - StringBuilder sb, - string methodName, - ImmutableArray members, - string? storeTypeFq = null, - string? storeFieldName = null, - ImmutableArray? storeBestCtorParamOrder = null, - string? entryAssemblyName = null, - ImmutableArray? deferLeadingRootAliasFlags = null) - { - var defer = deferLeadingRootAliasFlags ?? ImmutableArray.Empty; - - var syn = SyntheticOptionsCommand(members, methodName); - var flagMembers = members.Where(static p => p.Kind == ParameterKind.Flag).ToList(); - var widthCandidates = new List { "-h, --help".Length }; - widthCandidates.AddRange(flagMembers.Select(static p => HelpLayout.FormatOptionLeftCell(p).Length)); - var maxOptWidth = flagMembers.Count == 0 - ? "-h, --help".Length - : Math.Max(Math.Min(widthCandidates.Max(), 40), "-h, --help".Length); - - if (flagMembers.Count > 0) - EmitOptionsTryParseFlagHelpPrinter(sb, methodName, flagMembers, maxOptWidth); - - var flagHelpMethodName = methodName + "_FlagHelp_ToStdErr"; - var emitRunHint = !string.IsNullOrEmpty(entryAssemblyName); - var runHintFailUnknown = emitRunHint - ? $"\t\t\t\t\tConsole.Error.WriteLine(\"Run '{Escape(entryAssemblyName!)} --help' for usage.\");" - : null; - var runHintMissingLong = emitRunHint - ? $"\t\t\t\t\t\t\tConsole.Error.WriteLine(\"Run '{Escape(entryAssemblyName!)} --help' for usage.\");" - : null; - - sb.AppendLine($"\t\tprivate static bool {methodName}(string[] args, int[] idx)"); - sb.AppendLine("\t\t{"); - sb.AppendLine("\t\t\tvar flags = new Dictionary(StringComparer.OrdinalIgnoreCase);"); - EmitBoolSwitchNames(sb, syn); - EmitCanonFlagNameMethod(sb, syn); - EmitShortFlagMethods(sb, syn, multiFlagsAvailable: false); - EmitAllowedFlagPredicate(sb, members); - EmitDeferLeadingRootAliasHelpers(sb, defer); - - if (flagMembers.Count > 0) - { - sb.Append("\t\t\tvar __flagFuzzyCands = new string[] { "); - var sortedNames = flagMembers - .Select(static p => p.CliLongName) - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(static x => x, StringComparer.OrdinalIgnoreCase) - .ToList(); - for (var i = 0; i < sortedNames.Count; i++) - { - if (i > 0) - sb.Append(", "); - sb.Append('"').Append(Escape(sortedNames[i])).Append('"'); - } - - sb.AppendLine(" };"); - sb.AppendLine("\t\t\tbool FailUnknownLongOption(string flagName)"); - sb.AppendLine("\t\t\t{"); - sb.AppendLine($"\t\t\t\tvar __matches = FuzzyMatch.FindClosest(flagName, __flagFuzzyCands, {FuzzyMaxDistance});"); - sb.AppendLine("\t\t\t\tif (__matches.Count == 0)"); - sb.AppendLine("\t\t\t\t{"); - sb.AppendLine("\t\t\t\t\tConsole.Error.WriteLine($\"Error: unknown option '--{flagName}'.\");"); - if (runHintFailUnknown is not null) - { - sb.AppendLine("\t\t\t\t\tConsole.Error.WriteLine();"); - sb.AppendLine(runHintFailUnknown); - } - - sb.AppendLine("\t\t\t\t\treturn false;"); - sb.AppendLine("\t\t\t\t}"); - sb.AppendLine("\t\t\t\tif (__matches.Count == 1)"); - sb.AppendLine("\t\t\t\t{"); - sb.AppendLine("\t\t\t\t\tvar __m = __matches[0];"); - sb.AppendLine("\t\t\t\t\tConsole.Error.WriteLine($\"Error: unknown option '--{flagName}'. Did you mean '--{__m}'?\");"); - sb.AppendLine("\t\t\t\t\tConsole.Error.WriteLine();"); - sb.AppendLine($"\t\t\t\t\t{flagHelpMethodName}(__m);"); - if (runHintFailUnknown is not null) - { - sb.AppendLine("\t\t\t\t\tConsole.Error.WriteLine();"); - sb.AppendLine(runHintFailUnknown); - } - - sb.AppendLine("\t\t\t\t\treturn false;"); - sb.AppendLine("\t\t\t\t}"); - sb.AppendLine("\t\t\t\tConsole.Error.WriteLine($\"Error: unknown option '--{flagName}'. Did you mean one of these?\");"); - sb.AppendLine("\t\t\t\tConsole.Error.WriteLine();"); - sb.AppendLine("\t\t\t\tforeach (var __m in __matches)"); - sb.AppendLine("\t\t\t\t{"); - sb.AppendLine($"\t\t\t\t\t{flagHelpMethodName}(__m);"); - sb.AppendLine("\t\t\t\t}"); - if (runHintFailUnknown is not null) - { - sb.AppendLine("\t\t\t\tConsole.Error.WriteLine();"); - sb.AppendLine(runHintFailUnknown); - } - sb.AppendLine("\t\t\t\treturn false;"); - sb.AppendLine("\t\t\t}"); - sb.AppendLine(); - } - - sb.AppendLine("\t\t\twhile (idx[0] < args.Length && args[idx[0]].Length > 0 && args[idx[0]][0] == '-')"); - sb.AppendLine("\t\t\t{"); - sb.AppendLine("\t\t\t\tif (args[idx[0]] == \"--help\" || args[idx[0]] == \"-h\" || args[idx[0]] == \"--version\")"); - sb.AppendLine("\t\t\t\t\tbreak;"); - sb.AppendLine("\t\t\t\tvar a = args[idx[0]];"); - sb.AppendLine("\t\t\t\tif (a.StartsWith(\"--\", StringComparison.Ordinal))"); - sb.AppendLine("\t\t\t\t{"); - sb.AppendLine("\t\t\t\t\tvar eq = a.IndexOf('=');"); - sb.AppendLine("\t\t\t\t\tif (eq >= 0)"); - sb.AppendLine("\t\t\t\t\t{"); - sb.AppendLine("\t\t\t\t\t\tvar flagName = CanonFlagName(a.Substring(2, eq - 2));"); - sb.AppendLine("\t\t\t\t\t\tif (!IsAllowedFlag(flagName))"); - sb.AppendLine("\t\t\t\t\t\t{"); - sb.AppendLine("\t\t\t\t\t\t\tif (ShouldDeferLeadingRootAliasCanon(flagName))"); - sb.AppendLine("\t\t\t\t\t\t\t\tbreak;"); - if (flagMembers.Count > 0) - sb.AppendLine("\t\t\t\t\t\t\t\treturn FailUnknownLongOption(flagName);"); - else - { - sb.AppendLine("\t\t\t\t\t\t\tConsole.Error.WriteLine($\"Error: unknown option '--{flagName}'.\");"); - sb.AppendLine("\t\t\t\t\t\t\treturn false;"); - } - - sb.AppendLine("\t\t\t\t\t\t}"); - sb.AppendLine("\t\t\t\t\t\tvar flagValue = a.Substring(eq + 1);"); - sb.AppendLine("\t\t\t\t\t\tflags[flagName] = flagValue;"); - sb.AppendLine("\t\t\t\t\t\tidx[0]++;"); - sb.AppendLine("\t\t\t\t\t}"); - sb.AppendLine("\t\t\t\t\telse"); - sb.AppendLine("\t\t\t\t\t{"); - sb.AppendLine("\t\t\t\t\t\tvar flagName = CanonFlagName(a.Substring(2));"); - sb.AppendLine("\t\t\t\t\t\tif (!IsAllowedFlag(flagName))"); - sb.AppendLine("\t\t\t\t\t\t{"); - sb.AppendLine("\t\t\t\t\t\t\tif (ShouldDeferLeadingRootAliasCanon(flagName))"); - sb.AppendLine("\t\t\t\t\t\t\t\tbreak;"); - if (flagMembers.Count > 0) - sb.AppendLine("\t\t\t\t\t\t\t\treturn FailUnknownLongOption(flagName);"); - else - { - sb.AppendLine("\t\t\t\t\t\t\tConsole.Error.WriteLine($\"Error: unknown option '--{flagName}'.\");"); - sb.AppendLine("\t\t\t\t\t\t\treturn false;"); - } - - sb.AppendLine("\t\t\t\t\t\t}"); - sb.AppendLine("\t\t\t\t\t\tif (IsBoolSwitchName(flagName))"); - sb.AppendLine("\t\t\t\t\t\t{"); - sb.AppendLine("\t\t\t\t\t\t\tflags[flagName] = IsBoolSwitchNoName(flagName) ? null : \"true\";"); - sb.AppendLine("\t\t\t\t\t\t\tidx[0]++;"); - sb.AppendLine("\t\t\t\t\t\t}"); - sb.AppendLine("\t\t\t\t\t\telse"); - sb.AppendLine("\t\t\t\t\t\t{"); - sb.AppendLine("\t\t\t\t\t\t\tif (idx[0] + 1 >= args.Length)"); - sb.AppendLine("\t\t\t\t\t\t\t{"); - sb.AppendLine("\t\t\t\t\t\t\t\tConsole.Error.WriteLine($\"Error: missing value for flag --{flagName}.\");"); - if (flagMembers.Count > 0) - { - sb.AppendLine("\t\t\t\t\t\t\t\tConsole.Error.WriteLine();"); - sb.AppendLine($"\t\t\t\t\t\t\t\t{flagHelpMethodName}(flagName);"); - if (runHintMissingLong is not null) - { - sb.AppendLine("\t\t\t\t\t\t\t\tConsole.Error.WriteLine();"); - sb.AppendLine(runHintMissingLong); - } - } - - sb.AppendLine("\t\t\t\t\t\t\t\treturn false;"); - sb.AppendLine("\t\t\t\t\t\t\t}"); - sb.AppendLine("\t\t\t\t\t\t\tflags[flagName] = args[idx[0] + 1];"); - sb.AppendLine("\t\t\t\t\t\t\tidx[0] += 2;"); - sb.AppendLine("\t\t\t\t\t\t}"); - sb.AppendLine("\t\t\t\t\t}"); - sb.AppendLine("\t\t\t\t\tcontinue;"); - sb.AppendLine("\t\t\t\t}"); - sb.AppendLine("\t\t\t\tif (a.Length >= 2 && a[0] == '-' && a[1] != '-')"); - sb.AppendLine("\t\t\t\t{"); - sb.AppendLine("\t\t\t\t\tvar eqs = a.IndexOf('=');"); - sb.AppendLine("\t\t\t\t\tif (eqs >= 0)"); - sb.AppendLine("\t\t\t\t\t{"); - sb.AppendLine("\t\t\t\t\t\tvar shortKey = a.Substring(1, eqs - 1);"); - sb.AppendLine("\t\t\t\t\t\tif (shortKey.Length != 1)"); - sb.AppendLine("\t\t\t\t\t\t{"); - sb.AppendLine("\t\t\t\t\t\t\tConsole.Error.WriteLine(\"Error: short options must be a single letter (e.g. -e=value).\");"); - sb.AppendLine("\t\t\t\t\t\t\treturn false;"); - sb.AppendLine("\t\t\t\t\t\t}"); - sb.AppendLine("\t\t\t\t\t\tif (!TryApplyShortFlag(shortKey[0], a.Substring(eqs + 1)))"); - sb.AppendLine("\t\t\t\t\t\t{"); - sb.AppendLine("\t\t\t\t\t\t\tif (ShouldDeferLeadingShortFlag(shortKey[0])) break;"); - sb.AppendLine("\t\t\t\t\t\t\treturn false;"); - sb.AppendLine("\t\t\t\t\t\t}"); - sb.AppendLine("\t\t\t\t\t\tidx[0]++;"); - sb.AppendLine("\t\t\t\t\t\tcontinue;"); - sb.AppendLine("\t\t\t\t\t}"); - sb.AppendLine("\t\t\t\t\tif (a.Length == 2)"); - sb.AppendLine("\t\t\t\t\t{"); - sb.AppendLine("\t\t\t\t\t\tvar sc = a[1];"); - sb.AppendLine("\t\t\t\t\t\tif (IsShortBoolChar(sc))"); - sb.AppendLine("\t\t\t\t\t\t{"); - sb.AppendLine("\t\t\t\t\t\t\tif (!TryApplyShortFlag(sc, \"true\"))"); - sb.AppendLine("\t\t\t\t\t\t\t{"); - sb.AppendLine("\t\t\t\t\t\t\t\tif (ShouldDeferLeadingShortFlag(sc)) break;"); - sb.AppendLine("\t\t\t\t\t\t\t\treturn false;"); - sb.AppendLine("\t\t\t\t\t\t\t}"); - sb.AppendLine("\t\t\t\t\t\t\tidx[0]++;"); - sb.AppendLine("\t\t\t\t\t\t\tcontinue;"); - sb.AppendLine("\t\t\t\t\t\t}"); - sb.AppendLine("\t\t\t\t\t\tif (idx[0] + 1 >= args.Length)"); - sb.AppendLine("\t\t\t\t\t\t{"); - sb.AppendLine("\t\t\t\t\t\t\tConsole.Error.WriteLine($\"Error: missing value for short flag '-{sc}'.\");"); - sb.AppendLine("\t\t\t\t\t\t\treturn false;"); - sb.AppendLine("\t\t\t\t\t\t}"); - sb.AppendLine("\t\t\t\t\t\tif (!TryApplyShortFlag(sc, args[idx[0] + 1]))"); - sb.AppendLine("\t\t\t\t\t\t{"); - sb.AppendLine("\t\t\t\t\t\t\tif (ShouldDeferLeadingShortFlag(sc)) break;"); - sb.AppendLine("\t\t\t\t\t\t\treturn false;"); - sb.AppendLine("\t\t\t\t\t\t}"); - sb.AppendLine("\t\t\t\t\t\tidx[0] += 2;"); - sb.AppendLine("\t\t\t\t\t\tcontinue;"); - sb.AppendLine("\t\t\t\t\t}"); - sb.AppendLine("\t\t\t\t\tConsole.Error.WriteLine(\"Error: combined short flags (e.g. -abc) are not supported.\");"); - sb.AppendLine("\t\t\t\t\treturn false;"); - sb.AppendLine("\t\t\t\t}"); - sb.AppendLine("\t\t\t\tConsole.Error.WriteLine($\"Error: unexpected token '{a}'.\");"); - sb.AppendLine("\t\t\t\treturn false;"); - sb.AppendLine("\t\t\t}"); - if (storeTypeFq is not null && storeFieldName is not null && members.Length > 0) - EmitOptionsConstructAndStore(sb, storeTypeFq, members, storeFieldName, storeBestCtorParamOrder); - sb.AppendLine("\t\t\treturn true;"); - sb.AppendLine("\t\t}"); - sb.AppendLine(); - } - - /// - /// After parses flags into a flags dict, extract member values and - /// construct the options instance, then store it in . - /// Injected just before return true of the parse method. - /// - private static void EmitOptionsConstructAndStore( - StringBuilder sb, - string typeFq, - ImmutableArray members, - string storeFieldName, - ImmutableArray? bestCtorParamOrder) - { - var byName = members.ToDictionary(static m => m.SymbolName, StringComparer.OrdinalIgnoreCase); - - // For cross-assembly options types the property initializer syntax is not readable. - // Instantiate the type once to capture all C# runtime defaults. - var hasRtDefaults = members.Any(static m => m.UsesRuntimeDefault); - if (hasRtDefaults) - sb.AppendLine($"\t\t\tvar __rt_default = new {typeFq}();"); - - // Extract each member's value from the flags dict. - foreach (var m in members) - { - if (m.Kind != ParameterKind.Flag) - continue; - if (m.Special == BoolSpecialKind.Bool) - sb.AppendLine($"\t\t\tvar {m.LocalVarName} = flags.ContainsKey(\"{Escape(m.CliLongName)}\");"); - else if (m.Special == BoolSpecialKind.NullableBool) - { - sb.AppendLine($"\t\t\tbool? {m.LocalVarName} = null;"); - sb.AppendLine($"\t\t\tif (flags.TryGetValue(\"{Escape(m.CliLongName)}\", out var {m.LocalVarName}_yv))"); - sb.AppendLine($"\t\t\t\t{m.LocalVarName} = ParseNullableBool({m.LocalVarName}_yv, true);"); - sb.AppendLine($"\t\t\tif (flags.TryGetValue(\"no-{Escape(m.CliLongName)}\", out var {m.LocalVarName}_nv))"); - sb.AppendLine($"\t\t\t\t{m.LocalVarName} = ParseNullableBool({m.LocalVarName}_nv, false);"); - } - else - { - // Declare the local variable first (EmitParseAndAssign only assigns, does not declare). - // For cross-assembly runtime-default properties, seed from the pre-created instance. - var initializer = m.UsesRuntimeDefault && hasRtDefaults - ? $"__rt_default.{m.SymbolName}" - : GetCliInitializer(m); - sb.AppendLine($"\t\t\t{GetCSharpCliType(m)} {m.LocalVarName} = {initializer};"); - var canOmitFlag = !m.IsRequired || m.DefaultValueLiteral is not null; - if (canOmitFlag) - { - sb.AppendLine($"\t\t\tif (flags.TryGetValue(\"{Escape(m.CliLongName)}\", out var {m.LocalVarName}Text) && {m.LocalVarName}Text is not null)"); - sb.AppendLine("\t\t\t{"); - EmitParseAndAssign(sb, m, m.LocalVarName + "Text", m.LocalVarName, "return false", null); - sb.AppendLine("\t\t\t}"); - } - else - { - sb.AppendLine($"\t\t\tif (!flags.TryGetValue(\"{Escape(m.CliLongName)}\", out var {m.LocalVarName}Text) || {m.LocalVarName}Text is null)"); - sb.AppendLine("\t\t\t{"); - sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine($\"Error: missing required flag --{Escape(m.CliLongName)}.\");"); - sb.AppendLine("\t\t\t\treturn false;"); - sb.AppendLine("\t\t\t}"); - EmitParseAndAssign(sb, m, m.LocalVarName + "Text", m.LocalVarName, "return false", null); - } - } - } - - // Construct using the primary constructor if all members align, otherwise property assignment. - if (bestCtorParamOrder is { } ctorOrder && ctorOrder.Length == members.Length) - { - sb.Append($"\t\t\t{storeFieldName} = new {typeFq}("); - for (var i = 0; i < ctorOrder.Length; i++) - { - if (i > 0) sb.Append(", "); - sb.Append(byName[ctorOrder[i]].LocalVarName); - } - - sb.AppendLine(");"); - } - else - { - sb.AppendLine($"\t\t\t{storeFieldName} = new {typeFq}();"); - foreach (var m in members) - sb.AppendLine($"\t\t\t{storeFieldName}.{m.SymbolName} = {m.LocalVarName};"); - } - } - - private static void EmitIsMultiFlagPredicate(StringBuilder sb, CommandModel cmd) - { - var names = new List(); - foreach (var p in cmd.Parameters) - { - if (p is { IsCollection: true, Kind: ParameterKind.Flag } && p.CollectionSeparator is null) - names.Add(p.CliLongName); - } - - if (names.Count == 0) - { - sb.AppendLine("\t\t\tbool IsMultiFlag(string name) => false;"); - return; - } - - sb.AppendLine("\t\t\tbool IsMultiFlag(string name) => name switch"); - sb.AppendLine("\t\t\t{"); - foreach (var n in names) - sb.AppendLine($"\t\t\t\t\"{Escape(n)}\" => true,"); - - sb.AppendLine("\t\t\t\t_ => false"); - sb.AppendLine("\t\t\t};"); - } - - /// - /// Emits local variable reconstruction for each options type in the injection chain. - /// For every member: prefer the value from the command's flags dict (post-command flags), - /// fall back to the pre-parsed static field (pre-command flags). This ensures flags work in - /// either position: myapp --verbose cmd or myapp cmd --verbose. - /// - private static void EmitOptionsReconstructLocals( - StringBuilder sb, - ImmutableArray<(string TypeFq, string TypeMetadataName, ImmutableArray AllBaseTypeMetadataNames, string StaticFieldName, string LocalVarName, ImmutableArray FlatMembers, ImmutableArray? BestCtorParamOrder)> chain) - { - if (chain.IsDefaultOrEmpty) return; - - // Track which static provides the fallback for each CLI name (first in chain that declares it). - // Key = CliLongName, Value = "{staticFieldName}.{SymbolName}" - var fallbackMap = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var (_, _, _, staticField, _, flatMembers, _) in chain) - { - foreach (var m in flatMembers) - { - if (!fallbackMap.ContainsKey(m.CliLongName)) - fallbackMap[m.CliLongName] = staticField + "." + m.SymbolName; - } - } - - // Track which member vars have already been emitted (across chain entries, to avoid re-declaration). - var emittedTmpVars = new HashSet(StringComparer.OrdinalIgnoreCase); - - foreach (var (typeFq, _, _, _, localVar, flatMembers, bestCtorParamOrder) in chain) - { - if (flatMembers.IsEmpty) continue; - var byName = flatMembers.ToDictionary(static m => m.SymbolName, StringComparer.OrdinalIgnoreCase); - - // Extract each member: command-level flags take precedence over pre-parsed static value. - foreach (var m in flatMembers) - { - var fallback = fallbackMap.TryGetValue(m.CliLongName, out var fb) ? fb : "default"; - var tmpName = "__ropt_" + m.LocalVarName; - // Skip if already emitted by a parent type in the chain (inherited members appear in multiple flat lists). - if (!emittedTmpVars.Add(tmpName)) continue; - if (m.Special == BoolSpecialKind.Bool) - { - sb.AppendLine($"\t\t\tvar {tmpName} = flags.ContainsKey(\"{Escape(m.CliLongName)}\") || {fallback};"); - } - else if (m.Special == BoolSpecialKind.NullableBool) - { - sb.AppendLine($"\t\t\tbool? {tmpName} = {fallback};"); - sb.AppendLine($"\t\t\tif (flags.TryGetValue(\"{Escape(m.CliLongName)}\", out var {tmpName}_yv))"); - sb.AppendLine($"\t\t\t\t{tmpName} = ParseNullableBool({tmpName}_yv, true);"); - sb.AppendLine($"\t\t\tif (flags.TryGetValue(\"no-{Escape(m.CliLongName)}\", out var {tmpName}_nv))"); - sb.AppendLine($"\t\t\t\t{tmpName} = ParseNullableBool({tmpName}_nv, false);"); - } - else - { - // For value-typed flags: if found in command flags use that; else keep static fallback value. - sb.AppendLine($"\t\t\tflags.TryGetValue(\"{Escape(m.CliLongName)}\", out var {tmpName}Txt);"); - sb.AppendLine($"\t\t\tvar {tmpName} = {fallback};"); - if (m.ScalarKind == CliScalarKind.Primitive) - { - // Re-parse from text if present, keeping static value if not. - var parseExpr = m.TypeName switch - { - "int" => $"int.TryParse({tmpName}Txt, out var {tmpName}P) ? {tmpName}P : {tmpName}", - "int?" => - $"int.TryParse({tmpName}Txt, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var {tmpName}P) ? (int?){tmpName}P : {tmpName}", - "long" => $"long.TryParse({tmpName}Txt, out var {tmpName}P) ? {tmpName}P : {tmpName}", - "long?" => - $"long.TryParse({tmpName}Txt, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var {tmpName}P) ? (long?){tmpName}P : {tmpName}", - "double" => - $"double.TryParse({tmpName}Txt, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var {tmpName}P) ? {tmpName}P : {tmpName}", - "double?" => - $"double.TryParse({tmpName}Txt, System.Globalization.NumberStyles.Float | System.Globalization.NumberStyles.AllowThousands, System.Globalization.CultureInfo.InvariantCulture, out var {tmpName}P) ? (double?){tmpName}P : {tmpName}", - "float" => - $"float.TryParse({tmpName}Txt, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var {tmpName}P) ? {tmpName}P : {tmpName}", - "float?" => - $"float.TryParse({tmpName}Txt, System.Globalization.NumberStyles.Float | System.Globalization.NumberStyles.AllowThousands, System.Globalization.CultureInfo.InvariantCulture, out var {tmpName}P) ? (float?){tmpName}P : {tmpName}", - "decimal" => - $"decimal.TryParse({tmpName}Txt, System.Globalization.NumberStyles.Number, System.Globalization.CultureInfo.InvariantCulture, out var {tmpName}P) ? {tmpName}P : {tmpName}", - "decimal?" => - $"decimal.TryParse({tmpName}Txt, System.Globalization.NumberStyles.Number, System.Globalization.CultureInfo.InvariantCulture, out var {tmpName}P) ? (decimal?){tmpName}P : {tmpName}", - "DateTime" => - $"global::System.DateTime.TryParse({tmpName}Txt, System.Globalization.CultureInfo.InvariantCulture, global::System.Globalization.DateTimeStyles.RoundtripKind | global::System.Globalization.DateTimeStyles.AllowWhiteSpaces, out var {tmpName}P) ? {tmpName}P : {tmpName}", - "DateTime?" => - $"global::System.DateTime.TryParse({tmpName}Txt, System.Globalization.CultureInfo.InvariantCulture, global::System.Globalization.DateTimeStyles.RoundtripKind | global::System.Globalization.DateTimeStyles.AllowWhiteSpaces, out var {tmpName}P) ? (global::System.DateTime?){tmpName}P : {tmpName}", - "DateTimeOffset" => - $"global::System.DateTimeOffset.TryParse({tmpName}Txt, System.Globalization.CultureInfo.InvariantCulture, global::System.Globalization.DateTimeStyles.RoundtripKind | global::System.Globalization.DateTimeStyles.AllowWhiteSpaces, out var {tmpName}P) ? {tmpName}P : {tmpName}", - "DateTimeOffset?" => - $"global::System.DateTimeOffset.TryParse({tmpName}Txt, System.Globalization.CultureInfo.InvariantCulture, global::System.Globalization.DateTimeStyles.RoundtripKind | global::System.Globalization.DateTimeStyles.AllowWhiteSpaces, out var {tmpName}P) ? (global::System.DateTimeOffset?){tmpName}P : {tmpName}", - "TimeSpan" => - $"global::Nullean.Argh.ArghTimeSpan.TryParse({tmpName}Txt, out var {tmpName}P) ? {tmpName}P : {tmpName}", - "TimeSpan?" => - $"global::Nullean.Argh.ArghTimeSpan.TryParse({tmpName}Txt, out var {tmpName}P) ? (global::System.TimeSpan?){tmpName}P : {tmpName}", - "DateOnly" => - $"global::System.DateOnly.TryParse({tmpName}Txt, System.Globalization.CultureInfo.InvariantCulture, global::System.Globalization.DateTimeStyles.None, out var {tmpName}P) ? {tmpName}P : {tmpName}", - "DateOnly?" => - $"global::System.DateOnly.TryParse({tmpName}Txt, System.Globalization.CultureInfo.InvariantCulture, global::System.Globalization.DateTimeStyles.None, out var {tmpName}P) ? (global::System.DateOnly?){tmpName}P : {tmpName}", - "string" or "string?" => $"{tmpName}Txt ?? {tmpName}", - _ => $"{tmpName}Txt != null ? {tmpName}Txt : {tmpName}" - }; - if (m.TypeName is "string" or "string?") - sb.AppendLine($"\t\t\t{tmpName} = {parseExpr};"); - else - sb.AppendLine($"\t\t\tif ({tmpName}Txt != null) {tmpName} = {parseExpr};"); - } - else if (m.ScalarKind == CliScalarKind.Enum && m.EnumTypeFq is not null) - { - // Re-parse enum from command-trailing flags; null-guard required (TryGetValue out-var is string?). - // On invalid value: keep static fallback silently (no user-visible error; leading globals were already validated). - var evVar = "__ev_ropt_" + m.LocalVarName; - sb.AppendLine($"\t\t\tif ({tmpName}Txt is not null)"); - sb.AppendLine("\t\t\t{"); - sb.AppendLine($"\t\t\t\tif (global::System.Enum.TryParse<{m.EnumTypeFq}>({tmpName}Txt, true, out var {evVar}) && global::System.Enum.IsDefined(typeof({m.EnumTypeFq}), {evVar}))"); - sb.AppendLine($"\t\t\t\t\t{tmpName} = {evVar};"); - sb.AppendLine("\t\t\t}"); - } - else if (m.ScalarKind == CliScalarKind.FileInfo) - { - sb.AppendLine($"\t\t\tif ({tmpName}Txt is not null)"); - sb.AppendLine("\t\t\t{"); - if (m.ExpandUserProfileBeforeBind) - { - var expanded = "__path_ropt_" + m.LocalVarName; - sb.AppendLine($"\t\t\t\tvar {expanded} = global::Nullean.Argh.ArghPath.ExpandUserProfilePath({tmpName}Txt);"); - sb.AppendLine($"\t\t\t\t{tmpName} = new global::System.IO.FileInfo({expanded});"); - } - else - sb.AppendLine($"\t\t\t\t{tmpName} = new global::System.IO.FileInfo({tmpName}Txt);"); - sb.AppendLine("\t\t\t}"); - } - else if (m.ScalarKind == CliScalarKind.DirectoryInfo) - { - sb.AppendLine($"\t\t\tif ({tmpName}Txt is not null)"); - sb.AppendLine("\t\t\t{"); - if (m.ExpandUserProfileBeforeBind) - { - var expanded = "__path_ropt_" + m.LocalVarName; - sb.AppendLine($"\t\t\t\tvar {expanded} = global::Nullean.Argh.ArghPath.ExpandUserProfilePath({tmpName}Txt);"); - sb.AppendLine($"\t\t\t\t{tmpName} = new global::System.IO.DirectoryInfo({expanded});"); - } - else - sb.AppendLine($"\t\t\t\t{tmpName} = new global::System.IO.DirectoryInfo({tmpName}Txt);"); - sb.AppendLine("\t\t\t}"); - } - else if (m.ScalarKind == CliScalarKind.Uri) - { - var uriVar = "__uri_ropt_" + m.LocalVarName; - sb.AppendLine($"\t\t\tif ({tmpName}Txt is not null && global::System.Uri.TryCreate({tmpName}Txt, global::System.UriKind.RelativeOrAbsolute, out var {uriVar}))"); - sb.AppendLine($"\t\t\t\t{tmpName} = {uriVar};"); - } - else if (m.ScalarKind == CliScalarKind.CustomParser && m.ParserTypeFq is not null) - { - var parserVar = "__parser_ropt_" + m.LocalVarName; - var pvVar = "__pv_ropt_" + m.LocalVarName; - sb.AppendLine($"\t\t\tif ({tmpName}Txt is not null)"); - sb.AppendLine("\t\t\t{"); - sb.AppendLine($"\t\t\t\tvar {parserVar} = new {m.ParserTypeFq}();"); - sb.AppendLine($"\t\t\t\tif ({parserVar}.TryParse({tmpName}Txt, out var {pvVar}))"); - sb.AppendLine($"\t\t\t\t\t{tmpName} = {pvVar};"); - sb.AppendLine("\t\t\t}"); - } - } - } - - // Construct the local options instance using pre-computed constructor order. - if (bestCtorParamOrder is { } ctorOrder && ctorOrder.Length == flatMembers.Length) - { - sb.Append($"\t\t\tvar {localVar} = new {typeFq}("); - for (var i = 0; i < ctorOrder.Length; i++) - { - if (i > 0) sb.Append(", "); - sb.Append("__ropt_" + byName[ctorOrder[i]].LocalVarName); - } - sb.AppendLine(");"); - } - else - { - sb.AppendLine($"\t\t\tvar {localVar} = new {typeFq}();"); - foreach (var m in flatMembers) - sb.AppendLine($"\t\t\t{localVar}.{m.SymbolName} = __ropt_{m.LocalVarName};"); - } - } - } - - private static void EmitBindCollectionParameter(StringBuilder sb, ParameterModel p, bool multiFlagsAvailable, string failureExit = "return 2", string? helpMethodName = null, - string? flagHelpStdErrMethodName = null, string? parseFailureRunHint = null) - { - var flagKey = Escape(p.CliLongName); - var acc = p.LocalVarName + "_acc"; - var elemModel = ForElementParsing(p); - if (p.CollectionSeparator is string sep) - { - sb.AppendLine($"\t\t\tif (!flags.TryGetValue(\"{flagKey}\", out var {p.LocalVarName}Joined))"); - if (p.IsRequired) - { - sb.AppendLine("\t\t\t{"); - sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine($\"Error: missing required flag --{flagKey}.\");"); - EmitAfterCliParseErrorHelp(sb, p, "\t\t\t\t", helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); - sb.AppendLine($"\t\t\t\t{failureExit};"); - sb.AppendLine("\t\t\t}"); - } - else - { - sb.AppendLine($"\t\t\t\t{p.LocalVarName}Joined = null;"); - } - - sb.AppendLine($"\t\t\tif (!string.IsNullOrEmpty({p.LocalVarName}Joined))"); - sb.AppendLine("\t\t\t{"); - sb.AppendLine($"\t\t\t\tvar __sep_{p.LocalVarName} = \"{Escape(sep)}\";"); - sb.AppendLine($"\t\t\t\tforeach (var __part in {p.LocalVarName}Joined.Split(__sep_{p.LocalVarName}, StringSplitOptions.None))"); - sb.AppendLine("\t\t\t\t{"); - sb.AppendLine("\t\t\t\t\tif (string.IsNullOrEmpty(__part)) continue;"); - EmitParseFromString(sb, elemModel, "__part", "__ce_" + p.LocalVarName, indentExtra: "\t\t", outVarKeyword: true, failureExit: failureExit, helpMethodName: helpMethodName, flagHelpStdErrMethodName: flagHelpStdErrMethodName, parseFailureRunHint: parseFailureRunHint); - if (p.CollectionTargetIsReadOnlySet) - { - sb.AppendLine($"\t\t\t\t\tif (!{acc}.Add(__ce_{p.LocalVarName}))"); - sb.AppendLine("\t\t\t\t\t{"); - sb.AppendLine($"\t\t\t\t\t\tConsole.Error.WriteLine($\"Error: duplicate value '{{__ce_{p.LocalVarName}}}' for --{flagKey}.\");"); - sb.AppendLine($"\t\t\t\t\t\t{failureExit};"); - sb.AppendLine("\t\t\t\t\t}"); - } - else - { - sb.AppendLine($"\t\t\t\t\t{acc}.Add(__ce_{p.LocalVarName});"); - } - sb.AppendLine("\t\t\t\t}"); - sb.AppendLine("\t\t\t}"); - } - else - { - if (!multiFlagsAvailable) - return; - - sb.AppendLine($"\t\t\tif (!multiFlags.TryGetValue(\"{flagKey}\", out var __rawList_{p.LocalVarName}))"); - sb.AppendLine($"\t\t\t\t__rawList_{p.LocalVarName} = new List();"); - sb.AppendLine($"\t\t\tforeach (var __raw in __rawList_{p.LocalVarName})"); - sb.AppendLine("\t\t\t{"); - EmitParseFromString(sb, elemModel, "__raw", "__ce_" + p.LocalVarName, indentExtra: "\t", outVarKeyword: true, failureExit: failureExit, helpMethodName: helpMethodName, flagHelpStdErrMethodName: flagHelpStdErrMethodName, parseFailureRunHint: parseFailureRunHint); - if (p.CollectionTargetIsReadOnlySet) - { - sb.AppendLine($"\t\t\t\tif (!{acc}.Add(__ce_{p.LocalVarName}))"); - sb.AppendLine("\t\t\t\t{"); - sb.AppendLine($"\t\t\t\t\tConsole.Error.WriteLine($\"Error: duplicate value '{{__ce_{p.LocalVarName}}}' for --{flagKey}.\");"); - sb.AppendLine($"\t\t\t\t\t{failureExit};"); - sb.AppendLine("\t\t\t\t}"); - } - else - { - sb.AppendLine($"\t\t\t\t{acc}.Add(__ce_{p.LocalVarName});"); - } - sb.AppendLine("\t\t\t}"); - if (p.IsRequired) - { - sb.AppendLine($"\t\t\tif ({acc}.Count == 0)"); - sb.AppendLine("\t\t\t{"); - sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine($\"Error: missing required flag --{flagKey}.\");"); - EmitAfterCliParseErrorHelp(sb, p, "\t\t\t\t", helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); - sb.AppendLine($"\t\t\t\t{failureExit};"); - sb.AppendLine("\t\t\t}"); - } - } - - var declType = p.FullDeclaredTypeFq ?? "object"; - var useNullWhenUnset = !p.IsRequired && p.DeclaredNullableAnnotated; - if (useNullWhenUnset) - { - if (p.CollectionTargetIsArray) - sb.AppendLine($"\t\t\t{declType} {p.LocalVarName} = {acc}.Count == 0 ? null : {acc}.ToArray();"); - else - sb.AppendLine($"\t\t\t{declType} {p.LocalVarName} = {acc}.Count == 0 ? null : {acc};"); - } - else if (p.CollectionTargetIsArray) - sb.AppendLine($"\t\t\t{declType} {p.LocalVarName} = {acc}.ToArray();"); - else - sb.AppendLine($"\t\t\t{declType} {p.LocalVarName} = {acc};"); - } - - private static void EmitAsParametersConstruction(StringBuilder sb, CommandModel cmd) - { - if (cmd.HandlerParamTypes.IsDefaultOrEmpty) - return; - - foreach (var mp in cmd.HandlerParamTypes) - { - if (!mp.IsAsParameters) - continue; - - var group = cmd.Parameters - .Where(p => p.AsParametersOwnerParamName == mp.Name) - .OrderBy(p => p.AsParametersMemberOrder) - .ToArray(); - if (group.Length == 0) - continue; - - var typeFq = group[0].AsParametersTypeFq; - if (typeFq is null) - continue; - - var varName = AsParametersConstructedVarName(mp.Name); - var ctor = group.Where(p => !p.AsParametersUseInit).ToArray(); - var init = group.Where(p => p.AsParametersUseInit).ToArray(); - sb.Append($"\t\t\tvar {varName} = new {typeFq}("); - for (var i = 0; i < ctor.Length; i++) - { - if (i > 0) - sb.Append(", "); - sb.Append(ctor[i].Kind == ParameterKind.Injected ? "ct" : ctor[i].LocalVarName); - } - - sb.Append(")"); - if (init.Length > 0) - { - sb.AppendLine(); - sb.AppendLine("\t\t\t{"); - foreach (var ip in init) - { - var rhs = ip.Kind == ParameterKind.Injected ? "ct" : ip.LocalVarName; - sb.AppendLine($"\t\t\t\t{ip.AsParametersClrName} = {rhs},"); - } - - sb.AppendLine("\t\t\t};"); - } - else - { - sb.AppendLine(";"); - } - } - } - - private static void EmitAsParametersConstructionForDto(StringBuilder sb, CommandModel cmd) - { - var group = cmd.Parameters - .Where(static p => p.AsParametersOwnerParamName is not null) - .OrderBy(static p => p.AsParametersMemberOrder) - .ToArray(); - if (group.Length == 0) - { - sb.AppendLine("\t\t\treturn false;"); - return; - } - - var typeFq = group[0].AsParametersTypeFq; - if (typeFq is null) - { - sb.AppendLine("\t\t\treturn false;"); - return; - } - - var ctor = group.Where(static p => !p.AsParametersUseInit).ToArray(); - var init = group.Where(static p => p.AsParametersUseInit).ToArray(); - sb.Append("\t\t\tvar __dto = new ").Append(typeFq).Append("("); - for (var i = 0; i < ctor.Length; i++) - { - if (i > 0) - sb.Append(", "); - sb.Append(ctor[i].Kind == ParameterKind.Injected - ? "default(global::System.Threading.CancellationToken)" - : ctor[i].LocalVarName); - } - - sb.Append(")"); - if (init.Length > 0) - { - sb.AppendLine(); - sb.AppendLine("\t\t\t{"); - foreach (var ip in init) - { - var rhs = ip.Kind == ParameterKind.Injected - ? "default(global::System.Threading.CancellationToken)" - : ip.LocalVarName; - sb.AppendLine($"\t\t\t\t{ip.AsParametersClrName} = {rhs},"); - } - - sb.AppendLine("\t\t\t};"); - } - else - { - sb.AppendLine(";"); - } - - sb.AppendLine("\t\t\tvalue = __dto;"); - sb.AppendLine("\t\t\treturn true;"); - } - - private static void EmitOptionsDtoConstructionAndReturn(StringBuilder sb, string typeFq, ImmutableArray members, ImmutableArray? bestCtorParamOrder) - { - var byName = members.ToDictionary(static m => m.SymbolName, StringComparer.OrdinalIgnoreCase); - - if (bestCtorParamOrder is { } ctorOrder && ctorOrder.Length > 0 && ctorOrder.Length == members.Length) - { - sb.Append("\t\t\tvalue = new ").Append(typeFq).Append("("); - for (var i = 0; i < ctorOrder.Length; i++) - { - if (i > 0) - sb.Append(", "); - sb.Append(byName[ctorOrder[i]].LocalVarName); - } - - sb.AppendLine(");"); - sb.AppendLine("\t\t\treturn true;"); - return; - } - - sb.AppendLine($"\t\t\tvar __dto = new {typeFq}();"); - foreach (var m in members) - sb.AppendLine($"\t\t\t__dto.{m.SymbolName} = {m.LocalVarName};"); - - sb.AppendLine("\t\t\tvalue = __dto;"); - sb.AppendLine("\t\t\treturn true;"); - } - - private static string AsParametersConstructedVarName(string methodParameterName) => - "__as_" + Naming.SanitizeIdentifier(methodParameterName); - - private static void EmitValidationChecks( - StringBuilder sb, - CommandModel cmd, - string failureExit, - string? entryAssemblyName, - string? flagHelpStdErrMethodName = null) - { - foreach (var p in cmd.Parameters) - { - if (p.Kind == ParameterKind.Injected || p.Kind == ParameterKind.OptionsInjected) - continue; - if (p.Validations.IsDefaultOrEmpty) - continue; - - var cliName = p.CliLongName; - var varName = p.LocalVarName; - var isNullable = !p.IsRequired; - var isNullableValueType = isNullable && p.Special == BoolSpecialKind.None - && p.ScalarKind == CliScalarKind.Primitive && p.TypeName != "string" - && p.TypeName.EndsWith("?", StringComparison.Ordinal); - - // Build the run-hint line (baked in as a string literal) - string? runHint = null; - if (entryAssemblyName is not null && !string.IsNullOrEmpty(cmd.CommandName)) - { - var route = cmd.RoutePrefix.IsDefaultOrEmpty - ? "" - : string.Join(" ", cmd.RoutePrefix) + " "; - runHint = $"Run '{Escape(entryAssemblyName)} {Escape(route)}{Escape(cmd.CommandName)} --help' for usage."; - } - - if (p.IsCollection) - EmitCollectionFilesystemValidation(sb, p, cliName, varName, failureExit, flagHelpStdErrMethodName, runHint); - - foreach (var constraint in p.Validations) - { - // Filesystem-path-family constraints on collections are handled per-element above - // (varName is the whole list/array, not a single FileInfo/DirectoryInfo instance). - if (p.IsCollection && IsCollectionFilesystemConstraint(constraint)) - continue; - - switch (constraint) - { - case RangeConstraint r: - { - var guard = isNullableValueType ? $"{varName}.HasValue && (" : ""; - var closeGuard = isNullableValueType ? ")" : ""; - var access = isNullableValueType ? $"{varName}.Value" : varName; - sb.AppendLine($"\t\t\tif ({guard}{access} < {r.MinLiteral} || {access} > {r.MaxLiteral}{closeGuard})"); - sb.AppendLine("\t\t\t{"); - sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine(\"Error: --{Escape(cliName)}: value must be between {Escape(r.MinLiteral.Trim('"'))} and {Escape(r.MaxLiteral.Trim('"'))}.\");"); - EmitValidationErrorFooter(sb, p, cliName, "\t\t\t\t", flagHelpStdErrMethodName, runHint); - sb.AppendLine($"\t\t\t\t{failureExit};"); - sb.AppendLine("\t\t\t}"); - break; - } - case TimeSpanRangeConstraint tsr: - { - var tsMin = "__tsRangeMin_" + varName; - var tsMax = "__tsRangeMax_" + varName; - sb.AppendLine($"\t\t\tif (!global::Nullean.Argh.ArghTimeSpan.TryParse({tsr.MinLiteral}, out var {tsMin}) || !global::Nullean.Argh.ArghTimeSpan.TryParse({tsr.MaxLiteral}, out var {tsMax}))"); - sb.AppendLine("\t\t\t{"); - sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine(\"Error: --{Escape(cliName)}: invalid TimeSpanRange bounds.\");"); - EmitValidationErrorFooter(sb, p, cliName, "\t\t\t\t", flagHelpStdErrMethodName, runHint); - sb.AppendLine($"\t\t\t\t{failureExit};"); - sb.AppendLine("\t\t\t}"); - var guard = isNullableValueType ? $"{varName}.HasValue && (" : ""; - var closeGuard = isNullableValueType ? ")" : ""; - var access = isNullableValueType ? $"{varName}.Value" : varName; - sb.AppendLine($"\t\t\tif ({guard}{access} < {tsMin} || {access} > {tsMax}{closeGuard})"); - sb.AppendLine("\t\t\t{"); - sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine(\"Error: --{Escape(cliName)}: value must be between {Escape(tsr.MinLiteral.Trim('"'))} and {Escape(tsr.MaxLiteral.Trim('"'))}.\");"); - EmitValidationErrorFooter(sb, p, cliName, "\t\t\t\t", flagHelpStdErrMethodName, runHint); - sb.AppendLine($"\t\t\t\t{failureExit};"); - sb.AppendLine("\t\t\t}"); - break; - } - case CollectionCountConstraint cc: - { - var lenExpr = p.CollectionTargetIsArray ? $"{varName}.Length" : $"{varName}.Count"; - var nullGuard = !p.IsRequired && p.DeclaredNullableAnnotated ? $"{varName} != null && " : ""; - string ccCond; - string ccMsg; - if (cc.Min.HasValue && cc.Max.HasValue) - { - ccCond = $"{nullGuard}({lenExpr} < {cc.Min} || {lenExpr} > {cc.Max})"; - ccMsg = $"must have between {cc.Min} and {cc.Max} items."; - } - else if (cc.Min.HasValue) - { - ccCond = $"{nullGuard}{lenExpr} < {cc.Min}"; - ccMsg = $"must have at least {cc.Min} items."; - } - else - { - ccCond = $"{nullGuard}{lenExpr} > {cc.Max}"; - ccMsg = $"must have at most {cc.Max} items."; - } - var ccPrefix = p.Kind == ParameterKind.Positional ? $"<{Escape(cliName)}>" : $"--{Escape(cliName)}"; - sb.AppendLine($"\t\t\tif ({ccCond})"); - sb.AppendLine("\t\t\t{"); - sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine(\"Error: {ccPrefix}: {Escape(ccMsg)}\");"); - EmitValidationErrorFooter(sb, p, cliName, "\t\t\t\t", flagHelpStdErrMethodName, runHint); - sb.AppendLine($"\t\t\t\t{failureExit};"); - sb.AppendLine("\t\t\t}"); - break; - } - case StringLengthConstraint s: - { - // For required non-nullable strings: access with ! to avoid introducing a null-path - // in the condition (which would cause CS8604 at the handler call site). - // For optional strings: wrap with a null guard. - var sv = isNullable ? varName : varName + "!"; - var nullPrefix = isNullable ? $"{varName} != null && " : ""; - string cond; - string msg; - if (s.Min.HasValue && s.Max.HasValue) - { - cond = $"{nullPrefix}({sv}.Length < {s.Min} || {sv}.Length > {s.Max})"; - msg = $"value must be between {s.Min} and {s.Max} characters."; - } - else if (s.Min.HasValue) - { - cond = $"{nullPrefix}{sv}.Length < {s.Min}"; - msg = $"value must be at least {s.Min} characters."; - } - else - { - cond = $"{nullPrefix}{sv}.Length > {s.Max}"; - msg = $"value must be at most {s.Max} characters."; - } - sb.AppendLine($"\t\t\tif ({cond})"); - sb.AppendLine("\t\t\t{"); - sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine(\"Error: --{Escape(cliName)}: {Escape(msg)}\");"); - EmitValidationErrorFooter(sb, p, cliName, "\t\t\t\t", flagHelpStdErrMethodName, runHint); - sb.AppendLine($"\t\t\t\t{failureExit};"); - sb.AppendLine("\t\t\t}"); - break; - } - case RegexConstraint rx: - { - var rv = isNullable ? varName : varName + "!"; - var cond = isNullable - ? $"{varName} != null && !global::System.Text.RegularExpressions.Regex.IsMatch({rv}, @\"{EscapeVerbatimString(rx.Pattern)}\")" - : $"!global::System.Text.RegularExpressions.Regex.IsMatch({rv}, @\"{EscapeVerbatimString(rx.Pattern)}\")"; - sb.AppendLine($"\t\t\tif ({cond})"); - sb.AppendLine("\t\t\t{"); - sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine(\"Error: --{Escape(cliName)}: value does not match required pattern {Escape(rx.Pattern)}.\");"); - EmitValidationErrorFooter(sb, p, cliName, "\t\t\t\t", flagHelpStdErrMethodName, runHint); - sb.AppendLine($"\t\t\t\t{failureExit};"); - sb.AppendLine("\t\t\t}"); - break; - } - case AllowedValuesConstraint av: - { - var isStringType = p.TypeName == "string"; - string cond; - if (isStringType) - { - var avv = isNullable ? varName : varName + "!"; - var checks = av.Values - .Select(v => $"!string.Equals({avv}, {v}, global::System.StringComparison.Ordinal)") - .ToList(); - var nullGuard = isNullable ? $"{varName} != null && " : ""; - cond = $"{nullGuard}({string.Join(" && ", checks)})"; - } - else - { - var checks = av.Values.Select(v => $"{varName} != {v}").ToList(); - var nullGuard = isNullableValueType ? $"{varName}.HasValue && " : ""; - var access = isNullableValueType ? $"{varName}.Value" : varName; - cond = $"{nullGuard}({string.Join(" && ", checks.Select(c => c.Replace(varName, access)))})"; - } - var displayVals = string.Join(", ", av.Values.Select(v => v.Trim('"'))); - sb.AppendLine($"\t\t\tif ({cond})"); - sb.AppendLine("\t\t\t{"); - sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine(\"Error: --{Escape(cliName)}: value must be one of: {Escape(displayVals)}.\");"); - EmitValidationErrorFooter(sb, p, cliName, "\t\t\t\t", flagHelpStdErrMethodName, runHint); - sb.AppendLine($"\t\t\t\t{failureExit};"); - sb.AppendLine("\t\t\t}"); - break; - } - case DeniedValuesConstraint dv: - { - var isStringType = p.TypeName == "string"; - string cond; - if (isStringType) - { - var dvv = isNullable ? varName : varName + "!"; - var checks = dv.Values - .Select(v => $"string.Equals({dvv}, {v}, global::System.StringComparison.Ordinal)") - .ToList(); - var nullGuard = isNullable ? $"{varName} != null && " : ""; - cond = $"{nullGuard}({string.Join(" || ", checks)})"; - } - else - { - var checks = dv.Values.Select(v => $"{varName} == {v}").ToList(); - var nullGuard = isNullableValueType ? $"{varName}.HasValue && " : ""; - var access = isNullableValueType ? $"{varName}.Value" : varName; - cond = $"{nullGuard}({string.Join(" || ", checks.Select(c => c.Replace(varName, access)))})"; - } - var displayVals = string.Join(", ", dv.Values.Select(v => v.Trim('"'))); - sb.AppendLine($"\t\t\tif ({cond})"); - sb.AppendLine("\t\t\t{"); - sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine(\"Error: --{Escape(cliName)}: value must not be: {Escape(displayVals)}.\");"); - EmitValidationErrorFooter(sb, p, cliName, "\t\t\t\t", flagHelpStdErrMethodName, runHint); - sb.AppendLine($"\t\t\t\t{failureExit};"); - sb.AppendLine("\t\t\t}"); - break; - } - case EmailConstraint: - { - // Simple email check: at least one char, @, at least one char (DataAnnotations-compatible) - var ev = isNullable ? varName : varName + "!"; - var cond = isNullable - ? $"{varName} != null && ({ev}.IndexOf('@') < 1 || {ev}.IndexOf('@') == {ev}.Length - 1)" - : $"({ev}.IndexOf('@') < 1 || {ev}.IndexOf('@') == {ev}.Length - 1)"; - sb.AppendLine($"\t\t\tif ({cond})"); - sb.AppendLine("\t\t\t{"); - sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine(\"Error: --{Escape(cliName)}: value is not a valid email address.\");"); - EmitValidationErrorFooter(sb, p, cliName, "\t\t\t\t", flagHelpStdErrMethodName, runHint); - sb.AppendLine($"\t\t\t\t{failureExit};"); - sb.AppendLine("\t\t\t}"); - break; - } - case UrlConstraint: - { - // Validates absolute URL with http, https, or ftp scheme - sb.AppendLine($"\t\t\tif ({(isNullable ? $"{varName} != null && " : "")}!"); - sb.AppendLine($"\t\t\t\t(global::System.Uri.TryCreate({varName}, global::System.UriKind.Absolute, out var __urlCheck_{varName}) &&"); - sb.AppendLine($"\t\t\t\t (__urlCheck_{varName}.Scheme == \"http\" || __urlCheck_{varName}.Scheme == \"https\" || __urlCheck_{varName}.Scheme == \"ftp\")))"); - sb.AppendLine("\t\t\t{"); - sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine(\"Error: --{Escape(cliName)}: value is not a valid URL.\");"); - EmitValidationErrorFooter(sb, p, cliName, "\t\t\t\t", flagHelpStdErrMethodName, runHint); - sb.AppendLine($"\t\t\t\t{failureExit};"); - sb.AppendLine("\t\t\t}"); - break; - } - case UriSchemeConstraint us: - { - // varName is a Uri? or Uri instance (already parsed) - var access = isNullable ? $"{varName}!" : varName; - var schemeChecks = us.Schemes - .Select(s => $"{access}.Scheme == \"{Escape(s)}\"") - .ToList(); - var nullGuard = isNullable ? $"{varName} != null && " : ""; - var displaySchemes = string.Join(", ", us.Schemes); - sb.AppendLine($"\t\t\tif ({nullGuard}(!{access}.IsAbsoluteUri || !({string.Join(" || ", schemeChecks)})))"); - sb.AppendLine("\t\t\t{"); - sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine(\"Error: --{Escape(cliName)}: URI scheme must be one of: {Escape(displaySchemes)}.\");"); - EmitValidationErrorFooter(sb, p, cliName, "\t\t\t\t", flagHelpStdErrMethodName, runHint); - sb.AppendLine($"\t\t\t\t{failureExit};"); - sb.AppendLine("\t\t\t}"); - break; - } - case RejectSymbolicLinksConstraint: - { - var access = isNullable ? $"{varName}!" : varName; - var nullGuard = isNullable ? $"{varName} != null && " : ""; - sb.AppendLine($"\t\t\tif ({nullGuard}global::Nullean.Argh.ArghIO.PathIsSymbolicOrReparsePoint({access}.FullName))"); - sb.AppendLine("\t\t\t{"); - sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine(\"Error: --{Escape(cliName)}: path must not be a symbolic link or reparse point.\");"); - EmitValidationErrorFooter(sb, p, cliName, "\t\t\t\t", flagHelpStdErrMethodName, runHint); - sb.AppendLine($"\t\t\t\t{failureExit};"); - sb.AppendLine("\t\t\t}"); - break; - } - case ExistingPathConstraint: - { - var access = isNullable ? $"{varName}!" : varName; - var nullGuard = isNullable ? $"{varName} != null && " : ""; - if (p.ScalarKind == CliScalarKind.FileInfo) - { - sb.AppendLine($"\t\t\tif ({nullGuard}!global::System.IO.File.Exists({access}.FullName))"); - sb.AppendLine("\t\t\t{"); - sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine(\"Error: --{Escape(cliName)}: file does not exist.\");"); - EmitValidationErrorFooter(sb, p, cliName, "\t\t\t\t", flagHelpStdErrMethodName, runHint); - sb.AppendLine($"\t\t\t\t{failureExit};"); - sb.AppendLine("\t\t\t}"); - } - else - { - sb.AppendLine($"\t\t\tif ({nullGuard}!global::System.IO.Directory.Exists({access}.FullName))"); - sb.AppendLine("\t\t\t{"); - sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine(\"Error: --{Escape(cliName)}: directory does not exist.\");"); - EmitValidationErrorFooter(sb, p, cliName, "\t\t\t\t", flagHelpStdErrMethodName, runHint); - sb.AppendLine($"\t\t\t\t{failureExit};"); - sb.AppendLine("\t\t\t}"); - } - break; - } - case NonExistingPathConstraint: - { - var access = isNullable ? $"{varName}!" : varName; - var nullGuard = isNullable ? $"{varName} != null && " : ""; - sb.AppendLine($"\t\t\tif ({nullGuard}(global::System.IO.File.Exists({access}.FullName) || global::System.IO.Directory.Exists({access}.FullName)))"); - sb.AppendLine("\t\t\t{"); - if (p.ScalarKind == CliScalarKind.FileInfo) - sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine(\"Error: --{Escape(cliName)}: path already exists or is occupied by a directory.\");"); - else - sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine(\"Error: --{Escape(cliName)}: path already exists.\");"); - EmitValidationErrorFooter(sb, p, cliName, "\t\t\t\t", flagHelpStdErrMethodName, runHint); - sb.AppendLine($"\t\t\t\t{failureExit};"); - sb.AppendLine("\t\t\t}"); - break; - } - - case FileExtensionsConstraint fe: - { - // varName is a FileInfo? or FileInfo instance - var access = isNullable ? $"{varName}!" : varName; - var extChecks = fe.Extensions - .Select(ext => $"!string.Equals(global::System.IO.Path.GetExtension({access}.Name).TrimStart('.'), \"{Escape(ext)}\", global::System.StringComparison.OrdinalIgnoreCase)") - .ToList(); - var nullGuard = isNullable ? $"{varName} != null && " : ""; - var displayExts = string.Join(", ", fe.Extensions); - sb.AppendLine($"\t\t\tif ({nullGuard}({string.Join(" && ", extChecks)}))"); - sb.AppendLine("\t\t\t{"); - sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine(\"Error: --{Escape(cliName)}: extension must be one of: {Escape(displayExts)}.\");"); - EmitValidationErrorFooter(sb, p, cliName, "\t\t\t\t", flagHelpStdErrMethodName, runHint); - sb.AppendLine($"\t\t\t\t{failureExit};"); - sb.AppendLine("\t\t\t}"); - break; - } - } - } - } - } - - private static bool IsCollectionFilesystemConstraint(ValidationConstraint c) => - c is ExistingPathConstraint or NonExistingPathConstraint or RejectSymbolicLinksConstraint or FileExtensionsConstraint; - - /// - /// Collection-aware emission for the filesystem-path attribute family ([Existing], [NonExisting], - /// [RejectSymbolicLinks], [FileExtensions]) applied to a List<FileInfo>, - /// FileInfo[], DirectoryInfo[], etc. — including variadic [Argument] collections. - /// Unlike the scalar constraint switch above (which exits on the first violation), this loops over every - /// element and collects every failing item before printing one error block and exiting once, so a user - /// passing e.g. five files with two missing sees both, not just the first. - /// - private static void EmitCollectionFilesystemValidation( - StringBuilder sb, ParameterModel p, string cliName, string varName, - string failureExit, string? flagHelpStdErrMethodName, string? runHint) - { - if (p.Validations.IsDefaultOrEmpty) - return; - var fsConstraints = p.Validations.Where(IsCollectionFilesystemConstraint).ToList(); - if (fsConstraints.Count == 0) - return; - - var isDir = p.ElementScalarKind == CliScalarKind.DirectoryInfo; - var failuresVar = "__fsFailures_" + p.LocalVarName; - var itemVar = "__fsItem_" + p.LocalVarName; - var msgVar = "__fsMsg_" + p.LocalVarName; - var argToken = p.Kind == ParameterKind.Positional ? $"<{Escape(cliName)}>" : $"--{Escape(cliName)}"; - var nullGuard = !p.IsRequired && p.DeclaredNullableAnnotated; - const string outerIndent = "\t\t\t"; - var loopIndent = nullGuard ? outerIndent + "\t" : outerIndent; - var bodyIndent = loopIndent + "\t"; - - sb.AppendLine($"{outerIndent}var {failuresVar} = new List();"); - if (nullGuard) - { - sb.AppendLine($"{outerIndent}if ({varName} != null)"); - sb.AppendLine($"{outerIndent}{{"); - } - - sb.AppendLine($"{loopIndent}foreach (var {itemVar} in {varName})"); - sb.AppendLine($"{loopIndent}{{"); - - foreach (var c in fsConstraints) - { - switch (c) - { - case RejectSymbolicLinksConstraint: - // Runs before existence/extension checks; a rejected symlink skips further checks for that item. - sb.AppendLine($"{bodyIndent}if (global::Nullean.Argh.ArghIO.PathIsSymbolicOrReparsePoint({itemVar}.FullName))"); - sb.AppendLine($"{bodyIndent}{{"); - sb.AppendLine($"{bodyIndent}\t{failuresVar}.Add({itemVar}.FullName + \": path must not be a symbolic link or reparse point.\");"); - sb.AppendLine($"{bodyIndent}\tcontinue;"); - sb.AppendLine($"{bodyIndent}}}"); - break; - case ExistingPathConstraint: - if (isDir) - { - sb.AppendLine($"{bodyIndent}if (!global::System.IO.Directory.Exists({itemVar}.FullName))"); - sb.AppendLine($"{bodyIndent}\t{failuresVar}.Add({itemVar}.FullName + \": directory does not exist.\");"); - } - else - { - sb.AppendLine($"{bodyIndent}if (!global::System.IO.File.Exists({itemVar}.FullName))"); - sb.AppendLine($"{bodyIndent}\t{failuresVar}.Add({itemVar}.FullName + \": file does not exist.\");"); - } - break; - case NonExistingPathConstraint: - sb.AppendLine($"{bodyIndent}if (global::System.IO.File.Exists({itemVar}.FullName) || global::System.IO.Directory.Exists({itemVar}.FullName))"); - sb.AppendLine($"{bodyIndent}\t{failuresVar}.Add({itemVar}.FullName + \": path already exists.\");"); - break; - case FileExtensionsConstraint fe: - { - var extChecks = fe.Extensions - .Select(ext => $"!string.Equals(global::System.IO.Path.GetExtension({itemVar}.Name).TrimStart('.'), \"{Escape(ext)}\", global::System.StringComparison.OrdinalIgnoreCase)") - .ToList(); - var displayExts = string.Join(", ", fe.Extensions); - sb.AppendLine($"{bodyIndent}if ({string.Join(" && ", extChecks)})"); - sb.AppendLine($"{bodyIndent}\t{failuresVar}.Add({itemVar}.FullName + \": extension must be one of: {Escape(displayExts)}.\");"); - break; - } - } - } - - sb.AppendLine($"{loopIndent}}}"); - if (nullGuard) - sb.AppendLine($"{outerIndent}}}"); - - sb.AppendLine($"{outerIndent}if ({failuresVar}.Count > 0)"); - sb.AppendLine($"{outerIndent}{{"); - sb.AppendLine($"{outerIndent}\tforeach (var {msgVar} in {failuresVar})"); - sb.AppendLine($"{outerIndent}\t\tConsole.Error.WriteLine(\"Error: {argToken}: \" + {msgVar});"); - EmitValidationErrorFooter(sb, p, cliName, outerIndent + "\t", flagHelpStdErrMethodName, runHint); - sb.AppendLine($"{outerIndent}\t{failureExit};"); - sb.AppendLine($"{outerIndent}}}"); - } - - private static string EscapeVerbatimString(string s) => s.Replace("\"", "\"\""); - - private static void EmitCommandRunnerFuzzyFailHelper( - StringBuilder sb, - CommandModel cmd, - string? flagHelpStdErrMethodName, - string? parseFailureRunHint) - { - var flagParams = cmd.Parameters - .Where(static p => IsEmittedFlagLike(p.Kind)) - .ToList(); - - if (flagParams.Count > 0) - { - sb.Append("\t\t\tvar __flagFuzzyCands = new string[] { "); - var sortedNames = flagParams - .Select(static p => p.CliLongName) - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(static x => x, StringComparer.OrdinalIgnoreCase) - .ToList(); - for (var i = 0; i < sortedNames.Count; i++) - { - if (i > 0) - sb.Append(", "); - sb.Append('"').Append(Escape(sortedNames[i])).Append('"'); - } - sb.AppendLine(" };"); - } - - sb.AppendLine("\t\t\tint FailUnknownLongOption(string flagName)"); - sb.AppendLine("\t\t\t{"); - if (flagParams.Count > 0) - { - sb.AppendLine($"\t\t\t\tvar __matches = FuzzyMatch.FindClosest(flagName, __flagFuzzyCands, {FuzzyMaxDistance});"); - sb.AppendLine("\t\t\t\tif (__matches.Count == 0)"); - sb.AppendLine("\t\t\t\t{"); - } - sb.AppendLine("\t\t\t\t\tConsole.Error.WriteLine($\"Error: unknown option '--{flagName}'.\");"); - if (parseFailureRunHint is not null) - { - sb.AppendLine("\t\t\t\t\tConsole.Error.WriteLine();"); - sb.AppendLine($"\t\t\t\t\tConsole.Error.WriteLine(\"{Escape(parseFailureRunHint)}\");"); - } - sb.AppendLine("\t\t\t\t\treturn 2;"); - if (flagParams.Count > 0) - { - sb.AppendLine("\t\t\t\t}"); - sb.AppendLine("\t\t\t\tif (__matches.Count == 1)"); - sb.AppendLine("\t\t\t\t{"); - sb.AppendLine("\t\t\t\t\tvar __m = __matches[0];"); - sb.AppendLine("\t\t\t\t\tConsole.Error.WriteLine($\"Error: unknown option '--{flagName}'. Did you mean '--{__m}'?\");"); - if (flagHelpStdErrMethodName is not null) - { - sb.AppendLine("\t\t\t\t\tConsole.Error.WriteLine();"); - sb.AppendLine($"\t\t\t\t\t{flagHelpStdErrMethodName}(__m);"); - } - if (parseFailureRunHint is not null) - { - sb.AppendLine("\t\t\t\t\tConsole.Error.WriteLine();"); - sb.AppendLine($"\t\t\t\t\tConsole.Error.WriteLine(\"{Escape(parseFailureRunHint)}\");"); - } - sb.AppendLine("\t\t\t\t\treturn 2;"); - sb.AppendLine("\t\t\t\t}"); - sb.AppendLine("\t\t\t\tConsole.Error.WriteLine($\"Error: unknown option '--{flagName}'. Did you mean one of these?\");"); - sb.AppendLine("\t\t\t\tConsole.Error.WriteLine();"); - sb.AppendLine("\t\t\t\tforeach (var __m in __matches)"); - sb.AppendLine("\t\t\t\t{"); - if (flagHelpStdErrMethodName is not null) - sb.AppendLine($"\t\t\t\t\t{flagHelpStdErrMethodName}(__m);"); - sb.AppendLine("\t\t\t\t}"); - if (parseFailureRunHint is not null) - { - sb.AppendLine("\t\t\t\tConsole.Error.WriteLine();"); - sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine(\"{Escape(parseFailureRunHint)}\");"); - } - sb.AppendLine("\t\t\t\treturn 2;"); - } - sb.AppendLine("\t\t\t}"); - sb.AppendLine(); - } - - private static void EmitCommandRunner( - StringBuilder sb, - CommandModel cmd, - ImmutableArray globalMiddleware, - bool emitDtoTryParse = false, - bool dtoLenient = false, - string? dtoMethodName = null, - string? dtoResultTypeFq = null, - string? dtoOptionsTypeFq = null, - ImmutableArray? dtoOptionsBestCtorParamOrder = null, - ImmutableArray<(string TypeFq, string TypeMetadataName, ImmutableArray AllBaseTypeMetadataNames, string StaticFieldName, string LocalVarName, ImmutableArray FlatMembers, ImmutableArray? BestCtorParamOrder)> injectedOptions = default, - string? entryAssemblyName = null) - { - var anyRepeatedCollection = cmd.Parameters.Any(static p => - p is { IsCollection: true, Kind: ParameterKind.Flag } && p.CollectionSeparator is null); - - var failureExit = emitDtoTryParse ? "return false" : "return 2"; - var helpMethodName = emitDtoTryParse ? null : HelpPrinterMethodForCommand(cmd); - var flagHelpStdErrMethodName = emitDtoTryParse || cmd.IsRootDefault ? null : $"PrintHelp_{cmd.RunMethodName}_Flag_ToStdErr"; - string? parseFailureRunHint = null; - if (!emitDtoTryParse && entryAssemblyName is not null && !string.IsNullOrEmpty(cmd.CommandName)) - { - var routeForHint = cmd.RoutePrefix.IsDefaultOrEmpty ? "" : string.Join(" ", cmd.RoutePrefix) + " "; - parseFailureRunHint = $"Run '{Escape(entryAssemblyName)} {Escape(routeForHint)}{Escape(cmd.CommandName)} --help' for usage."; - } - - if (emitDtoTryParse) - { - if (dtoMethodName is null || dtoResultTypeFq is null) - throw new InvalidOperationException("DTO try-parse requires method name and result type."); - - sb.AppendLine($"\t\tinternal static bool {dtoMethodName}(string[] args, out {dtoResultTypeFq}? value)"); - sb.AppendLine("\t\t{"); - sb.AppendLine("\t\t\tvalue = null;"); - } - else - { - sb.AppendLine($"\t\tprivate static async Task {cmd.RunMethodName}(string[] args, CancellationToken ct)"); - sb.AppendLine("\t\t{"); - sb.AppendLine("\t\t\tfor (var i = 0; i < args.Length; i++)"); - sb.AppendLine("\t\t\t{"); - sb.AppendLine("\t\t\t\tif (args[i] == \"--help\" || args[i] == \"-h\")"); - sb.AppendLine("\t\t\t\t{"); - sb.AppendLine($"\t\t\t\t\t{helpMethodName}();"); - sb.AppendLine("\t\t\t\t\treturn 0;"); - sb.AppendLine("\t\t\t\t}"); - sb.AppendLine("\t\t\t}"); - sb.AppendLine(); - } - - EmitCliValueDeclarations(sb, cmd, dtoOptionsTypeFq); - - sb.AppendLine("\t\t\tvar flags = new Dictionary(StringComparer.OrdinalIgnoreCase);"); - if (anyRepeatedCollection) - { - sb.AppendLine("\t\t\tvar multiFlags = new Dictionary>(StringComparer.OrdinalIgnoreCase);"); - EmitIsMultiFlagPredicate(sb, cmd); - sb.AppendLine("\t\t\tvoid SetFlag(string name, string? value)"); - sb.AppendLine("\t\t\t{"); - sb.AppendLine("\t\t\t\tif (IsMultiFlag(name))"); - sb.AppendLine("\t\t\t\t{"); - sb.AppendLine("\t\t\t\t\tif (value is null) return;"); - sb.AppendLine("\t\t\t\t\tif (!multiFlags.TryGetValue(name, out var list)) { list = new List(); multiFlags[name] = list; }"); - sb.AppendLine("\t\t\t\t\tlist.Add(value);"); - sb.AppendLine("\t\t\t\t\treturn;"); - sb.AppendLine("\t\t\t\t}"); - sb.AppendLine("\t\t\t\tflags[name] = value;"); - sb.AppendLine("\t\t\t}"); - } - - sb.AppendLine("\t\t\tvar positionals = new List();"); - EmitBoolSwitchNames(sb, cmd); - EmitCanonFlagNameMethod(sb, cmd); - EmitShortFlagMethods(sb, cmd, multiFlagsAvailable: anyRepeatedCollection, - parseFailureRunHint: emitDtoTryParse ? null : parseFailureRunHint); - EmitKnownNonBoolFlagNames(sb, cmd); - if (!emitDtoTryParse) - EmitCommandRunnerFuzzyFailHelper(sb, cmd, flagHelpStdErrMethodName, parseFailureRunHint); - sb.AppendLine("\t\t\tfor (var i = 0; i < args.Length;)"); - sb.AppendLine("\t\t\t{"); - sb.AppendLine("\t\t\t\tvar a = args[i];"); - sb.AppendLine("\t\t\t\tif (a.StartsWith(\"--\", StringComparison.Ordinal))"); - sb.AppendLine("\t\t\t\t{"); - sb.AppendLine("\t\t\t\t\tvar eq = a.IndexOf('=');"); - sb.AppendLine("\t\t\t\t\tif (eq >= 0)"); - sb.AppendLine("\t\t\t\t\t{"); - sb.AppendLine("\t\t\t\t\t\tvar flagName = CanonFlagName(a.Substring(2, eq - 2));"); - sb.AppendLine("\t\t\t\t\t\tvar flagValue = a.Substring(eq + 1);"); - if (emitDtoTryParse && !dtoLenient) - { - sb.AppendLine("\t\t\t\t\t\tif (!IsBoolSwitchName(flagName) && !IsKnownNonBoolFlagName(flagName))"); - sb.AppendLine("\t\t\t\t\t\t{"); - sb.AppendLine("\t\t\t\t\t\t\tConsole.Error.WriteLine($\"Error: unknown option '--{flagName}'.\");"); - sb.AppendLine($"\t\t\t\t\t\t\t{failureExit};"); - sb.AppendLine("\t\t\t\t\t\t}"); - } - else if (!emitDtoTryParse) - { - sb.AppendLine("\t\t\t\t\t\tif (!IsBoolSwitchName(flagName) && !IsKnownNonBoolFlagName(flagName))"); - sb.AppendLine("\t\t\t\t\t\t\treturn FailUnknownLongOption(flagName);"); - } - if (anyRepeatedCollection) - { - sb.AppendLine("\t\t\t\t\t\tSetFlag(flagName, flagValue);"); - } - else - { - sb.AppendLine("\t\t\t\t\t\tflags[flagName] = flagValue;"); - } - - sb.AppendLine("\t\t\t\t\t\ti++;"); - sb.AppendLine("\t\t\t\t\t}"); - sb.AppendLine("\t\t\t\t\telse"); - sb.AppendLine("\t\t\t\t\t{"); - sb.AppendLine("\t\t\t\t\t\tvar flagName = CanonFlagName(a.Substring(2));"); - sb.AppendLine("\t\t\t\t\t\tif (IsBoolSwitchName(flagName))"); - sb.AppendLine("\t\t\t\t\t\t{"); - sb.AppendLine("\t\t\t\t\t\t\tflags[flagName] = IsBoolSwitchNoName(flagName) ? null : \"true\";"); - sb.AppendLine("\t\t\t\t\t\t\ti++;"); - sb.AppendLine("\t\t\t\t\t\t}"); - sb.AppendLine("\t\t\t\t\t\telse"); - sb.AppendLine("\t\t\t\t\t\t{"); - if (emitDtoTryParse) - { - if (dtoLenient) - { - sb.AppendLine("\t\t\t\t\t\t\tif (!IsKnownNonBoolFlagName(flagName))"); - sb.AppendLine("\t\t\t\t\t\t\t{"); - sb.AppendLine("\t\t\t\t\t\t\t\tif (i + 1 < args.Length && !args[i + 1].StartsWith(\"-\", StringComparison.Ordinal))"); - sb.AppendLine("\t\t\t\t\t\t\t\t\ti += 2;"); - sb.AppendLine("\t\t\t\t\t\t\t\telse"); - sb.AppendLine("\t\t\t\t\t\t\t\t\ti++;"); - sb.AppendLine("\t\t\t\t\t\t\t\tcontinue;"); - sb.AppendLine("\t\t\t\t\t\t\t}"); - } - else - { - sb.AppendLine("\t\t\t\t\t\t\tif (!IsKnownNonBoolFlagName(flagName))"); - sb.AppendLine("\t\t\t\t\t\t\t{"); - sb.AppendLine("\t\t\t\t\t\t\t\tConsole.Error.WriteLine($\"Error: unknown option '--{flagName}'.\");"); - sb.AppendLine($"\t\t\t\t\t\t\t\t{failureExit};"); - sb.AppendLine("\t\t\t\t\t\t\t}"); - } - } - else - { - sb.AppendLine("\t\t\t\t\t\t\tif (!IsKnownNonBoolFlagName(flagName))"); - sb.AppendLine("\t\t\t\t\t\t\t\treturn FailUnknownLongOption(flagName);"); - } - sb.AppendLine("\t\t\t\t\t\t\tif (i + 1 >= args.Length)"); - sb.AppendLine("\t\t\t\t\t\t\t{"); - sb.AppendLine("\t\t\t\t\t\t\t\tConsole.Error.WriteLine($\"Error: missing value for flag --{flagName}.\");"); - if (flagHelpStdErrMethodName is not null) - { - sb.AppendLine("\t\t\t\t\t\t\t\tConsole.Error.WriteLine();"); - sb.AppendLine($"\t\t\t\t\t\t\t\t{flagHelpStdErrMethodName}(flagName);"); - sb.AppendLine("\t\t\t\t\t\t\t\tConsole.Error.WriteLine();"); - if (parseFailureRunHint is not null) - sb.AppendLine($"\t\t\t\t\t\t\t\tConsole.Error.WriteLine(\"{parseFailureRunHint}\");"); - } - else if (helpMethodName is not null) - sb.AppendLine($"\t\t\t\t\t\t\t\t{helpMethodName}();"); - sb.AppendLine($"\t\t\t\t\t\t\t\t{failureExit};"); - sb.AppendLine("\t\t\t\t\t\t\t}"); - if (anyRepeatedCollection) - { - sb.AppendLine("\t\t\t\t\t\t\tSetFlag(flagName, args[i + 1]);"); - } - else - { - sb.AppendLine("\t\t\t\t\t\t\tflags[flagName] = args[i + 1];"); - } - - sb.AppendLine("\t\t\t\t\t\t\ti += 2;"); - sb.AppendLine("\t\t\t\t\t\t}"); - sb.AppendLine("\t\t\t\t\t}"); - sb.AppendLine("\t\t\t\t\tcontinue;"); - sb.AppendLine("\t\t\t\t}"); - sb.AppendLine("\t\t\t\tif (a.Length >= 2 && a[0] == '-' && a[1] != '-')"); - sb.AppendLine("\t\t\t\t{"); - sb.AppendLine("\t\t\t\t\tvar eqs = a.IndexOf('=');"); - sb.AppendLine("\t\t\t\t\tif (eqs >= 0)"); - sb.AppendLine("\t\t\t\t\t{"); - sb.AppendLine("\t\t\t\t\t\tvar shortKey = a.Substring(1, eqs - 1);"); - sb.AppendLine("\t\t\t\t\t\tif (shortKey.Length != 1)"); - sb.AppendLine("\t\t\t\t\t\t{"); - sb.AppendLine("\t\t\t\t\t\t\tConsole.Error.WriteLine(\"Error: short options must be a single letter (e.g. -e=value).\");"); - if (helpMethodName is not null) - sb.AppendLine($"\t\t\t\t\t\t\t{helpMethodName}();"); - sb.AppendLine($"\t\t\t\t\t\t\t{failureExit};"); - sb.AppendLine("\t\t\t\t\t\t}"); - if (emitDtoTryParse && dtoLenient) - sb.AppendLine("\t\t\t\t\t\tTryApplyShortFlag(shortKey[0], a.Substring(eqs + 1));"); - else - { - sb.AppendLine("\t\t\t\t\t\tif (!TryApplyShortFlag(shortKey[0], a.Substring(eqs + 1)))"); - sb.AppendLine($"\t\t\t\t\t\t\t{failureExit};"); - } - sb.AppendLine("\t\t\t\t\t\ti++;"); - sb.AppendLine("\t\t\t\t\t\tcontinue;"); - sb.AppendLine("\t\t\t\t\t}"); - sb.AppendLine("\t\t\t\t\tif (a.Length == 2)"); - sb.AppendLine("\t\t\t\t\t{"); - sb.AppendLine("\t\t\t\t\t\tvar sc = a[1];"); - sb.AppendLine("\t\t\t\t\t\tif (IsShortBoolChar(sc))"); - sb.AppendLine("\t\t\t\t\t\t{"); - sb.AppendLine("\t\t\t\t\t\t\tif (!TryApplyShortFlag(sc, \"true\"))"); - sb.AppendLine($"\t\t\t\t\t\t\t\t{failureExit};"); - sb.AppendLine("\t\t\t\t\t\t\ti++;"); - sb.AppendLine("\t\t\t\t\t\t\tcontinue;"); - sb.AppendLine("\t\t\t\t\t\t}"); - if (emitDtoTryParse && dtoLenient) - { - // lenient: skip unknown value-taking short flags using same heuristic as long flags - sb.AppendLine("\t\t\t\t\t\tif (i + 1 < args.Length && !args[i + 1].StartsWith(\"-\", StringComparison.Ordinal))"); - sb.AppendLine("\t\t\t\t\t\t{"); - sb.AppendLine("\t\t\t\t\t\t\tTryApplyShortFlag(sc, args[i + 1]);"); - sb.AppendLine("\t\t\t\t\t\t\ti += 2;"); - sb.AppendLine("\t\t\t\t\t\t}"); - sb.AppendLine("\t\t\t\t\t\telse"); - sb.AppendLine("\t\t\t\t\t\t{"); - sb.AppendLine("\t\t\t\t\t\t\tTryApplyShortFlag(sc, \"true\");"); - sb.AppendLine("\t\t\t\t\t\t\ti++;"); - sb.AppendLine("\t\t\t\t\t\t}"); - sb.AppendLine("\t\t\t\t\t\tcontinue;"); - } - else - { - sb.AppendLine("\t\t\t\t\t\tif (i + 1 >= args.Length)"); - sb.AppendLine("\t\t\t\t\t\t{"); - sb.AppendLine("\t\t\t\t\t\t\tConsole.Error.WriteLine($\"Error: missing value for short flag '-{sc}'.\");"); - if (helpMethodName is not null) - sb.AppendLine($"\t\t\t\t\t\t\t{helpMethodName}();"); - sb.AppendLine($"\t\t\t\t\t\t\t{failureExit};"); - sb.AppendLine("\t\t\t\t\t\t}"); - sb.AppendLine("\t\t\t\t\t\tif (!TryApplyShortFlag(sc, args[i + 1]))"); - sb.AppendLine($"\t\t\t\t\t\t\t{failureExit};"); - sb.AppendLine("\t\t\t\t\t\ti += 2;"); - sb.AppendLine("\t\t\t\t\t\tcontinue;"); - } - sb.AppendLine("\t\t\t\t\t}"); - sb.AppendLine("\t\t\t\t\tConsole.Error.WriteLine(\"Error: combined short flags (e.g. -abc) are not supported.\");"); - if (helpMethodName is not null) - sb.AppendLine($"\t\t\t\t\t{helpMethodName}();"); - sb.AppendLine($"\t\t\t\t\t{failureExit};"); - sb.AppendLine("\t\t\t\t}"); - sb.AppendLine("\t\t\t\tpositionals.Add(a);"); - sb.AppendLine("\t\t\t\ti++;"); - sb.AppendLine("\t\t\t}"); - sb.AppendLine(); - - foreach (var p in cmd.Parameters) - { - if (p.Kind == ParameterKind.Injected || p.Kind == ParameterKind.OptionsInjected) - continue; - - if (p.Kind == ParameterKind.Positional) - continue; - - if (p.Special == BoolSpecialKind.Bool) - { - sb.AppendLine($"\t\t\tvar {p.LocalVarName} = flags.ContainsKey(\"{Escape(p.CliLongName)}\");"); - continue; - } - - if (p.Special == BoolSpecialKind.NullableBool) - { - sb.AppendLine($"\t\t\tbool? {p.LocalVarName} = null;"); - sb.AppendLine($"\t\t\tif (flags.TryGetValue(\"{Escape(p.CliLongName)}\", out var {p.LocalVarName}_yesVal))"); - sb.AppendLine($"\t\t\t\t{p.LocalVarName} = ParseNullableBool({p.LocalVarName}_yesVal, true);"); - sb.AppendLine($"\t\t\tif (flags.TryGetValue(\"no-{Escape(p.CliLongName)}\", out var {p.LocalVarName}_noVal))"); - sb.AppendLine($"\t\t\t\t{p.LocalVarName} = ParseNullableBool({p.LocalVarName}_noVal, false);"); - continue; - } - - if (p.IsCollection && p.Kind == ParameterKind.Flag) - { - EmitBindCollectionParameter(sb, p, anyRepeatedCollection, failureExit, helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); - continue; - } - - var flagKey = Escape(p.CliLongName); - sb.AppendLine($"\t\t\tif (!flags.TryGetValue(\"{flagKey}\", out var {p.LocalVarName}Text))"); - if (p.IsRequired) - { - sb.AppendLine("\t\t\t{"); - sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine($\"Error: missing required flag --{flagKey}.\");"); - EmitAfterCliParseErrorHelp(sb, p, "\t\t\t\t", helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); - sb.AppendLine($"\t\t\t\t{failureExit};"); - sb.AppendLine("\t\t\t}"); - } - else - sb.AppendLine($"\t\t\t\t{p.LocalVarName}Text = null;"); - - EmitParseAndAssign(sb, p, p.LocalVarName + "Text", p.LocalVarName, failureExit, helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); - } - - var posIndex = 0; - foreach (var p in cmd.Parameters) - { - if (p.Kind != ParameterKind.Positional) - continue; - - if (p.IsVariadic) - { - EmitVariadicPositionalParse(sb, p, posIndex, failureExit, helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); - posIndex++; - continue; - } - - if (p.IsRequired) - { - sb.AppendLine($"\t\t\tif (positionals.Count <= {posIndex})"); - sb.AppendLine("\t\t\t{"); - sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine(\"Error: missing required argument <{Escape(p.CliLongName)}>.\");"); - if (helpMethodName is not null) - sb.AppendLine($"\t\t\t\t{helpMethodName}();"); - sb.AppendLine($"\t\t\t\t{failureExit};"); - sb.AppendLine("\t\t\t}"); - sb.AppendLine("\t\t\telse"); - sb.AppendLine("\t\t\t{"); - EmitParseFromString(sb, p, $"positionals[{posIndex}]", p.LocalVarName, indentExtra: "\t", failureExit: failureExit, helpMethodName: helpMethodName, flagHelpStdErrMethodName: flagHelpStdErrMethodName, parseFailureRunHint: parseFailureRunHint); - sb.AppendLine("\t\t\t}"); - } - else - { - var fallback = p.DefaultValueLiteral ?? "default!"; - sb.AppendLine($"\t\t\tif (positionals.Count <= {posIndex})"); - sb.AppendLine($"\t\t\t\t{p.LocalVarName} = {fallback};"); - sb.AppendLine("\t\t\telse"); - sb.AppendLine("\t\t\t{"); - EmitParseFromString(sb, p, $"positionals[{posIndex}]", p.LocalVarName, indentExtra: "\t", failureExit: failureExit, helpMethodName: helpMethodName, flagHelpStdErrMethodName: flagHelpStdErrMethodName, parseFailureRunHint: parseFailureRunHint); - sb.AppendLine("\t\t\t}"); - } - - posIndex++; - } - - if (emitDtoTryParse) - { - EmitValidationChecks(sb, cmd, failureExit, entryAssemblyName: null); - if (dtoOptionsTypeFq is not null) - EmitOptionsDtoConstructionAndReturn(sb, dtoOptionsTypeFq, cmd.Parameters, dtoOptionsBestCtorParamOrder); - else - EmitAsParametersConstructionForDto(sb, cmd); - - sb.AppendLine("\t\t}"); - sb.AppendLine(); - return; - } - - EmitAsParametersConstruction(sb, cmd); - - EmitValidationChecks(sb, cmd, failureExit, entryAssemblyName, flagHelpStdErrMethodName); - - // Reconstruct options instances merging command-level flags with pre-parsed statics. - EmitOptionsReconstructLocals(sb, injectedOptions); - - if (cmd.RequiresInstance) - { - // Try to construct from options-injected ctor parameters before falling back to DI or parameterless ctor. - string? optionsCtorArgs = null; - if (!injectedOptions.IsDefaultOrEmpty && !cmd.ContainingTypeCtorParams.IsDefaultOrEmpty) - { - var ctorParams = cmd.ContainingTypeCtorParams; - if (ctorParams.Length > 0) - { - var ctorArgs = new List(); - var allResolved = true; - foreach (var (_, cpMetaName) in ctorParams) - { - // Exact match first, then most-derived (from end of chain); use LocalVarName (reconstructed) - string? bestLocal = null; - foreach (var o in injectedOptions) - if (o.TypeMetadataName == cpMetaName) { bestLocal = o.LocalVarName; break; } - if (bestLocal is null) - for (var _i = injectedOptions.Length - 1; _i >= 0; _i--) - if (injectedOptions[_i].AllBaseTypeMetadataNames.Contains(cpMetaName)) { bestLocal = injectedOptions[_i].LocalVarName; break; } - if (bestLocal is null) { allResolved = false; break; } - ctorArgs.Add(bestLocal); - } - if (allResolved) - optionsCtorArgs = string.Join(", ", ctorArgs); - } - } - - if (optionsCtorArgs is not null) - { - sb.AppendLine( - $"\t\t\tvar __cmdHandler = (ArghServices.ServiceProvider?.GetService(typeof({cmd.ContainingTypeFq})) as {cmd.ContainingTypeFq}) ?? new {cmd.ContainingTypeFq}({optionsCtorArgs});"); - } - else if (cmd.ContainingTypeHasParameterlessCtor) - { - sb.AppendLine( - $"\t\t\tvar __cmdHandler = (ArghServices.ServiceProvider?.GetService(typeof({cmd.ContainingTypeFq})) as {cmd.ContainingTypeFq}) ?? new {cmd.ContainingTypeFq}();"); - } - else - { - sb.AppendLine( - $"\t\t\tvar __cmdHandler = (ArghServices.ServiceProvider?.GetService(typeof({cmd.ContainingTypeFq})) as {cmd.ContainingTypeFq}) ?? throw new global::System.InvalidOperationException(\"Register the command type in DI for hosted execution, or add a public parameterless constructor for standalone CLI.\");"); - } - - sb.AppendLine(); - } - - sb.AppendLine(); - var useMiddleware = globalMiddleware.Length > 0 || cmd.CommandMiddlewareData.Length > 0; - if (!useMiddleware) - { - sb.Append("\t\t\t"); - EmitInvocation(sb, cmd, injectedOptions: injectedOptions); - sb.AppendLine(); - } - else - { - EmitCommandPathLiteral(sb, cmd); - sb.AppendLine("\t\t\tvar ctx = new CommandContext(commandPath, args, ct);"); - sb.AppendLine("\t\t\tCommandMiddlewareDelegate next = async c =>"); - sb.AppendLine("\t\t\t{"); - EmitInvocation(sb, cmd, "c.CancellationToken", "c", "\t\t\t\t", injectedOptions: injectedOptions); - sb.AppendLine("\t\t\t};"); - var cap = 0; - for (var i = cmd.CommandMiddlewareData.Length - 1; i >= 0; i--) - { - var (fq, middlewareParamless) = cmd.CommandMiddlewareData[i]; - var name = "__cap" + cap++; - sb.AppendLine($"\t\t\tvar {name} = next;"); - sb.AppendLine($"\t\t\tnext = async c => await {DiResolveOrNew(fq, middlewareParamless)}.InvokeAsync(c, {name});"); - } - - for (var i = globalMiddleware.Length - 1; i >= 0; i--) - { - var gFq = globalMiddleware[i].TypeFq; - var gParamless = globalMiddleware[i].HasParameterlessCtor; - var name = "__cap" + cap++; - sb.AppendLine($"\t\t\tvar {name} = next;"); - sb.AppendLine($"\t\t\tnext = async c => await {DiResolveOrNew(gFq, gParamless)}.InvokeAsync(c, {name});"); - } - - sb.AppendLine("\t\t\tawait next(ctx).ConfigureAwait(false);"); - sb.AppendLine("\t\t\treturn ctx.ExitCode;"); - } - - sb.AppendLine("\t\t}"); - sb.AppendLine(); - } - - private static void EmitCommandPathLiteral(StringBuilder sb, CommandModel cmd) - { - sb.Append("\t\t\tvar commandPath = new string[] { "); - for (var i = 0; i < cmd.RoutePrefix.Length; i++) - { - if (i > 0) - sb.Append(", "); - sb.Append('"').Append(Escape(cmd.RoutePrefix[i])).Append('"'); - } - - if (cmd.RoutePrefix.Length > 0) - sb.Append(", "); - sb.Append('"').Append(Escape(cmd.CommandName)).Append('"'); - sb.AppendLine(" };"); - } - - private static void EmitCliValueDeclarations(StringBuilder sb, CommandModel cmd, string? rtDefaultTypeFq = null) - { - // For cross-assembly options types, seed non-nullable properties from a runtime instance. - // rtDefaultTypeFq covers UseGlobalOptions / UseNamespaceOptions DTO paths. - // AsParametersTypeFq covers [AsParameters] init-property paths from cross-assembly types. - var hasRtDefaults = rtDefaultTypeFq is not null && cmd.Parameters.Any(static p => p.UsesRuntimeDefault); - if (hasRtDefaults) - sb.AppendLine($"\t\t\tvar __rt_default = new {rtDefaultTypeFq}();"); - - // Emit one runtime-default instance per unique cross-assembly [AsParameters] type. - var asParamsRtTypes = new Dictionary(StringComparer.Ordinal); - foreach (var p in cmd.Parameters) - { - if (!p.UsesRuntimeDefault || p.AsParametersTypeFq is null) - continue; - if (asParamsRtTypes.ContainsKey(p.AsParametersTypeFq)) - continue; - var suffix = DtoMethodSuffix(p.AsParametersTypeFq); - asParamsRtTypes[p.AsParametersTypeFq] = suffix; - sb.AppendLine($"\t\t\tvar __rt_default_{suffix} = new {p.AsParametersTypeFq}();"); - } - - foreach (var p in cmd.Parameters) - { - if (p.Kind == ParameterKind.Injected || p.Kind == ParameterKind.OptionsInjected) - continue; - - if (p.Special == BoolSpecialKind.Bool || p.Special == BoolSpecialKind.NullableBool) - continue; - - if (p.IsCollection && p.Kind == ParameterKind.Flag) - { - var elemFq = GetElementCSharpFq(p); - var accType = p.CollectionTargetIsReadOnlySet - ? "global::System.Collections.Generic.HashSet" - : "global::System.Collections.Generic.List"; - sb.AppendLine( - $"\t\t\tvar {p.LocalVarName}_acc = new {accType}<{elemFq}>();"); - continue; - } - - string initializer; - if (p.UsesRuntimeDefault) - { - if (hasRtDefaults && rtDefaultTypeFq is not null) - initializer = $"__rt_default.{p.SymbolName}"; - else if (p.AsParametersTypeFq is not null && asParamsRtTypes.TryGetValue(p.AsParametersTypeFq, out var asParamsSuffix)) - initializer = $"__rt_default_{asParamsSuffix}.{p.SymbolName}"; - else - initializer = GetCliInitializer(p); - } - else - initializer = GetCliInitializer(p); - - sb.AppendLine($"\t\t\t{GetCSharpCliType(p)} {p.LocalVarName} = {initializer};"); - } - } - - private static string GetElementCSharpFq(ParameterModel p) - { - switch (p.ElementScalarKind) - { - case CliScalarKind.Enum when p.ElementEnumTypeFq is not null: - return p.ElementEnumTypeFq; - case CliScalarKind.FileInfo: - return "global::System.IO.FileInfo"; - case CliScalarKind.DirectoryInfo: - return "global::System.IO.DirectoryInfo"; - case CliScalarKind.Uri: - return "global::System.Uri"; - case CliScalarKind.CustomParser when p.ElementCustomValueTypeFq is not null: - return p.ElementCustomValueTypeFq; - default: - break; - } - - return p.ElementTypeName switch - { - "string" => "string", - "int" => "int", - "long" => "long", - "float" => "float", - "double" => "double", - "decimal" => "decimal", - "bool" => "bool", - "DateTime" => "global::System.DateTime", - "DateTimeOffset" => "global::System.DateTimeOffset", - "TimeSpan" => "global::System.TimeSpan", - "DateOnly" => "global::System.DateOnly", - "DateTime?" => "global::System.DateTime?", - "DateTimeOffset?" => "global::System.DateTimeOffset?", - "TimeSpan?" => "global::System.TimeSpan?", - "DateOnly?" => "global::System.DateOnly?", - _ => "string" - }; - } - - private static ParameterModel ForElementParsing(ParameterModel p) => - p with - { - ScalarKind = p.ElementScalarKind, - TypeName = p.ElementTypeName, - EnumTypeFq = p.ElementEnumTypeFq, - EnumMemberNames = p.ElementEnumMemberNames, - EnumMemberCliNames = p.ElementEnumMemberCliNames, - ParserTypeFq = p.ElementParserTypeFq, - CustomValueTypeFq = p.ElementCustomValueTypeFq, - Special = BoolSpecialKind.None, - IsCollection = false, - IsRequired = true - }; - - private static void EmitVariadicPositionalParse( - StringBuilder sb, - ParameterModel p, - int startIndex, - string failureExit, - string? helpMethodName, - string? flagHelpStdErrMethodName, - string? parseFailureRunHint) - { - var argName = Escape(p.CliLongName); - var countVar = "__varCount_" + p.LocalVarName; - var arrVar = "__arr_" + p.LocalVarName; - var elemModel = ForElementParsing(p); - var elemCsharpType = GetCSharpCliType(elemModel); - - // For [Argument] params T[] with no [MinLength], zero items is valid (C# params convention). - // If the user added [MinLength(n)], CollectionCountConstraint validation below enforces at-least-n. - // However if IsRequired is true (non-nullable, no default), we require at least 1. - if (p.IsRequired) - { - sb.AppendLine($"\t\t\tif (positionals.Count <= {startIndex})"); - sb.AppendLine("\t\t\t{"); - sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine(\"Error: missing required argument <{argName}...>.\");"); - if (helpMethodName is not null) - sb.AppendLine($"\t\t\t\t{helpMethodName}();"); - sb.AppendLine($"\t\t\t\t{failureExit};"); - sb.AppendLine("\t\t\t}"); - } - - sb.AppendLine($"\t\t\tvar {countVar} = positionals.Count > {startIndex} ? positionals.Count - {startIndex} : 0;"); - sb.AppendLine($"\t\t\tvar {arrVar} = new {elemCsharpType}[{countVar}];"); - sb.AppendLine($"\t\t\tfor (var __vi_{p.LocalVarName} = 0; __vi_{p.LocalVarName} < {countVar}; __vi_{p.LocalVarName}++)"); - sb.AppendLine("\t\t\t{"); - - if (p.ElementScalarKind == CliScalarKind.Primitive && p.ElementTypeName == "string") - { - sb.AppendLine($"\t\t\t\t{arrVar}[__vi_{p.LocalVarName}] = positionals[{startIndex} + __vi_{p.LocalVarName}];"); - } - else - { - EmitParseFromString(sb, elemModel, - $"positionals[{startIndex} + __vi_{p.LocalVarName}]", - $"{arrVar}[__vi_{p.LocalVarName}]", - indentExtra: "\t", - outVarKeyword: false, - failureExit: failureExit, - helpMethodName: helpMethodName, - flagHelpStdErrMethodName: flagHelpStdErrMethodName, - parseFailureRunHint: parseFailureRunHint); - } - - sb.AppendLine("\t\t\t}"); - sb.AppendLine($"\t\t\t{p.LocalVarName} = {arrVar};"); - } - - private static string GetCSharpCliType(ParameterModel p) - { - if (p.ScalarKind == CliScalarKind.Collection && p.FullDeclaredTypeFq is not null) - return p.FullDeclaredTypeFq; - - switch (p.ScalarKind) - { - case CliScalarKind.Enum when p.EnumTypeFq is not null: - // Optional on the CLI but backed by a non-nullable enum + default (e.g. options properties): keep a non-nullable temp. - // Also keep non-nullable for cross-assembly runtime-default properties (no null initial value). - // IsNullableAnnotated guards against NRT nullable enums (e.g. MyEnum?) on cross-assembly types. - if ((p.IsRequired || p.DefaultValueLiteral is not null || p.UsesRuntimeDefault) && !p.IsNullableAnnotated) - return p.EnumTypeFq; - return p.EnumTypeFq + "?"; - case CliScalarKind.FileInfo: - return (p.IsRequired || p.UsesRuntimeDefault) && !p.IsNullableAnnotated ? "global::System.IO.FileInfo" : "global::System.IO.FileInfo?"; - case CliScalarKind.DirectoryInfo: - return (p.IsRequired || p.UsesRuntimeDefault) && !p.IsNullableAnnotated ? "global::System.IO.DirectoryInfo" : "global::System.IO.DirectoryInfo?"; - case CliScalarKind.Uri: - return (p.IsRequired || p.UsesRuntimeDefault) && !p.IsNullableAnnotated ? "global::System.Uri" : "global::System.Uri?"; - case CliScalarKind.CustomParser when p.CustomValueTypeFq is not null: - return (p.IsRequired || p.UsesRuntimeDefault) && !p.IsNullableAnnotated ? p.CustomValueTypeFq : p.CustomValueTypeFq + "?"; - default: - break; - } - - if (p.TypeName == "string") - return (p.IsRequired || p.UsesRuntimeDefault) && !p.IsNullableAnnotated ? "string" : "string?"; - - return p.TypeName switch - { - "int" => "int", - "int?" => "int?", - "long" => "long", - "long?" => "long?", - "float" => "float", - "float?" => "float?", - "double" => "double", - "double?" => "double?", - "decimal" => "decimal", - "decimal?" => "decimal?", - "bool" => "bool", - "bool?" => "bool?", - "DateTime" => "global::System.DateTime", - "DateTime?" => "global::System.DateTime?", - "DateTimeOffset" => "global::System.DateTimeOffset", - "DateTimeOffset?" => "global::System.DateTimeOffset?", - "TimeSpan" => "global::System.TimeSpan", - "TimeSpan?" => "global::System.TimeSpan?", - "DateOnly" => "global::System.DateOnly", - "DateOnly?" => "global::System.DateOnly?", - _ => "string?" - }; - } - - private static string GetCliInitializer(ParameterModel p) - { - if (p.DefaultValueLiteral is not null) - return p.DefaultValueLiteral; - - if (p.ScalarKind == CliScalarKind.Collection) - return "null!"; - - if (!p.IsRequired && p.ScalarKind is CliScalarKind.Enum or CliScalarKind.FileInfo or CliScalarKind.DirectoryInfo - or CliScalarKind.Uri or CliScalarKind.CustomParser) - return "null"; - - if (!p.IsRequired && p.TypeName.EndsWith("?", StringComparison.Ordinal)) - return "null"; - - if (p.TypeName == "string") - return p.IsRequired ? "null!" : "null"; - - return "default!"; - } - - /// - /// Global/namespace options flattened into a command via use - /// but must still participate in long-name aliases, short-option - /// binding, and bare bool switch recognition the same as . - /// - private static bool IsEmittedFlagLike(ParameterKind kind) => - kind is ParameterKind.Flag or ParameterKind.OptionsInjected; - - private static void EmitBoolSwitchNames(StringBuilder sb, CommandModel cmd, bool suppressNoNameHelper = false) - { - var names = new List(); - var noNames = new List(); - foreach (var p in cmd.Parameters) - { - if (!IsEmittedFlagLike(p.Kind)) - continue; - if (p.Special == BoolSpecialKind.Bool) - names.Add(p.CliLongName); - if (p.Special == BoolSpecialKind.NullableBool) - { - names.Add(p.CliLongName); - noNames.Add("no-" + p.CliLongName); - } - } - - if (names.Count == 0 && noNames.Count == 0) - { - sb.AppendLine("\t\t\tbool IsBoolSwitchName(string name) => false;"); - if (!suppressNoNameHelper) - sb.AppendLine("\t\t\tbool IsBoolSwitchNoName(string name) => false;"); - return; - } - - var boolSwitchNameCases = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var n in names) - boolSwitchNameCases.Add(n); - foreach (var n in noNames) - boolSwitchNameCases.Add(n); - - sb.AppendLine("\t\t\tbool IsBoolSwitchName(string name) => name switch"); - sb.AppendLine("\t\t\t{"); - foreach (var n in boolSwitchNameCases.OrderBy(static x => x, StringComparer.OrdinalIgnoreCase)) - sb.AppendLine($"\t\t\t\t\"{Escape(n)}\" => true,"); - - sb.AppendLine("\t\t\t\t_ => false"); - sb.AppendLine("\t\t\t};"); - - if (suppressNoNameHelper) - return; - if (noNames.Count == 0) - { - sb.AppendLine("\t\t\tbool IsBoolSwitchNoName(string name) => false;"); - } - else - { - var noNameCases = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var n in noNames) - noNameCases.Add(n); - sb.AppendLine("\t\t\tbool IsBoolSwitchNoName(string name) => name switch"); - sb.AppendLine("\t\t\t{"); - foreach (var n in noNameCases.OrderBy(static x => x, StringComparer.OrdinalIgnoreCase)) - sb.AppendLine($"\t\t\t\t\"{Escape(n)}\" => true,"); - sb.AppendLine("\t\t\t\t_ => false"); - sb.AppendLine("\t\t\t};"); - } - } - - private static void EmitKnownNonBoolFlagNames(StringBuilder sb, CommandModel cmd) - { - var names = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var p in cmd.Parameters) - { - if (!IsEmittedFlagLike(p.Kind)) - continue; - if (p.Special == BoolSpecialKind.Bool || p.Special == BoolSpecialKind.NullableBool) - continue; - names.Add(p.CliLongName); - foreach (var al in p.Aliases) - names.Add(al); - } - - if (names.Count == 0) - { - sb.AppendLine("\t\t\tbool IsKnownNonBoolFlagName(string name) => false;"); - return; - } - - sb.AppendLine("\t\t\tbool IsKnownNonBoolFlagName(string name) => name switch"); - sb.AppendLine("\t\t\t{"); - foreach (var n in names.OrderBy(static x => x, StringComparer.OrdinalIgnoreCase)) - sb.AppendLine($"\t\t\t\t\"{Escape(n)}\" => true,"); - sb.AppendLine("\t\t\t\t_ => false"); - sb.AppendLine("\t\t\t};"); - } - - private static void EmitCanonFlagNameMethod(StringBuilder sb, CommandModel cmd) - { - var cases = new List<(string from, string to)>(); - foreach (var p in cmd.Parameters) - { - if (!IsEmittedFlagLike(p.Kind)) - continue; - foreach (var al in p.Aliases) - { - if (string.Equals(al, p.CliLongName, StringComparison.OrdinalIgnoreCase)) - continue; - cases.Add((al, p.CliLongName)); - } - - if (p.Special == BoolSpecialKind.NullableBool) - { - foreach (var al in p.Aliases) - { - if (string.Equals(al, p.CliLongName, StringComparison.OrdinalIgnoreCase)) - continue; - cases.Add(("no-" + al, "no-" + p.CliLongName)); - } - } - } - - if (cases.Count == 0) - { - sb.AppendLine("\t\t\tstring CanonFlagName(string raw) => raw;"); - return; - } - - sb.AppendLine("\t\t\tstring CanonFlagName(string raw) => raw switch"); - sb.AppendLine("\t\t\t{"); - foreach ((var from, var to) in cases) - sb.AppendLine($"\t\t\t\t\"{Escape(from)}\" => \"{Escape(to)}\","); - - sb.AppendLine("\t\t\t\t_ => raw"); - sb.AppendLine("\t\t\t};"); - } - - private static void EmitShortFlagMethods(StringBuilder sb, CommandModel cmd, bool multiFlagsAvailable = true, string? parseFailureRunHint = null) - { - var shortCases = new List<(char c, string Primary, bool IsBool, bool IsRepeatableCollection)>(); - foreach (var p in cmd.Parameters) - { - if (!IsEmittedFlagLike(p.Kind)) - continue; - if (p.ShortOpt is not char ch) - continue; - // IsRepeatableCollection only applies when multiFlags is available in the emitted context. - var isRepeatableCollection = multiFlagsAvailable && p.IsCollection && p.CollectionSeparator is null; - shortCases.Add((ch, p.CliLongName, p.Special == BoolSpecialKind.Bool, isRepeatableCollection)); - } - - if (shortCases.Count == 0) - { - sb.AppendLine("\t\t\tbool TryApplyShortFlag(char c, string val)"); - sb.AppendLine("\t\t\t{"); - sb.AppendLine("\t\t\t\tConsole.Error.WriteLine($\"Error: unknown short option '-{c}'.\");"); - if (parseFailureRunHint is not null) - { - sb.AppendLine("\t\t\t\tConsole.Error.WriteLine();"); - sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine(\"{Escape(parseFailureRunHint)}\");"); - } - sb.AppendLine("\t\t\t\treturn false;"); - sb.AppendLine("\t\t\t}"); - sb.AppendLine("\t\t\tbool IsShortBoolChar(char c) => false;"); - return; - } - - sb.AppendLine("\t\t\tbool TryApplyShortFlag(char c, string val)"); - sb.AppendLine("\t\t\t{"); - sb.AppendLine("\t\t\t\tswitch (c)"); - sb.AppendLine("\t\t\t\t{"); - foreach ((var c, var primary, _, var isRepeatableCol) in shortCases) - { - var esc = Escape(primary); - sb.AppendLine($"\t\t\t\t\tcase '{c}':"); - if (isRepeatableCol) - { - // Repeatable flag: append to multiFlags so short opt and long opt collect into the same list. - sb.AppendLine($"\t\t\t\t\t\tif (!multiFlags.TryGetValue(\"{esc}\", out var __scList_{esc.Replace("-", "_")})) {{ __scList_{esc.Replace("-", "_")} = new List(); multiFlags[\"{esc}\"] = __scList_{esc.Replace("-", "_")}; }}"); - sb.AppendLine($"\t\t\t\t\t\t__scList_{esc.Replace("-", "_")}.Add(val);"); - } - else - { - sb.AppendLine($"\t\t\t\t\t\tflags[\"{esc}\"] = val;"); - } - sb.AppendLine("\t\t\t\t\t\treturn true;"); - } - - sb.AppendLine("\t\t\t\t\tdefault:"); - sb.AppendLine("\t\t\t\t\t\tConsole.Error.WriteLine($\"Error: unknown short option '-{c}'.\");"); - if (parseFailureRunHint is not null) - { - sb.AppendLine("\t\t\t\t\t\tConsole.Error.WriteLine();"); - sb.AppendLine($"\t\t\t\t\t\tConsole.Error.WriteLine(\"{Escape(parseFailureRunHint)}\");"); - } - sb.AppendLine("\t\t\t\t\t\treturn false;"); - sb.AppendLine("\t\t\t\t}"); - sb.AppendLine("\t\t\t}"); - - var anyBool = shortCases.Exists(static x => x.IsBool); - if (!anyBool) - { - sb.AppendLine("\t\t\tbool IsShortBoolChar(char c) => false;"); - return; - } - - sb.AppendLine("\t\t\tbool IsShortBoolChar(char c) => c switch"); - sb.AppendLine("\t\t\t{"); - foreach ((var c, _, var isBool, _) in shortCases) - { - if (isBool) - sb.AppendLine($"\t\t\t\t'{c}' => true,"); - } - - sb.AppendLine("\t\t\t\t_ => false"); - sb.AppendLine("\t\t\t};"); - } - - private static void EmitParseAndAssign(StringBuilder sb, ParameterModel p, string rawExpr, string targetVar, string failureExit = "return 2", string? helpMethodName = null, - string? flagHelpStdErrMethodName = null, string? parseFailureRunHint = null) - { - if (!p.IsRequired && p.DefaultValueLiteral is not null) - { - sb.AppendLine($"\t\t\tif ({rawExpr} is null)"); - sb.AppendLine($"\t\t\t\t{targetVar} = {p.DefaultValueLiteral};"); - sb.AppendLine("\t\t\telse"); - sb.AppendLine("\t\t\t{"); - EmitParseFromString(sb, p, rawExpr, targetVar, indentExtra: "\t", outVarKeyword: false, failureExit: failureExit, helpMethodName: helpMethodName, flagHelpStdErrMethodName: flagHelpStdErrMethodName, parseFailureRunHint: parseFailureRunHint); - sb.AppendLine("\t\t\t}"); - } - else if (!p.IsRequired && p.DefaultValueLiteral is null) - { - // Optional parameter with no explicit default: only parse when a value was actually provided. - // Guards against passing null into type-specific parsers (e.g. Enum.TryParse) when the flag is absent. - sb.AppendLine($"\t\t\tif ({rawExpr} is not null)"); - sb.AppendLine("\t\t\t{"); - EmitParseFromString(sb, p, rawExpr, targetVar, indentExtra: "\t", outVarKeyword: false, failureExit: failureExit, helpMethodName: helpMethodName, flagHelpStdErrMethodName: flagHelpStdErrMethodName, parseFailureRunHint: parseFailureRunHint); - sb.AppendLine("\t\t\t}"); - } - else - EmitParseFromString(sb, p, rawExpr, targetVar, failureExit: failureExit, helpMethodName: helpMethodName, flagHelpStdErrMethodName: flagHelpStdErrMethodName, parseFailureRunHint: parseFailureRunHint); - } - - private static void EmitParseFromString(StringBuilder sb, ParameterModel p, string rawExpr, string targetVar, string indentExtra = "", - bool outVarKeyword = false, string failureExit = "return 2", string? helpMethodName = null, string? flagHelpStdErrMethodName = null, string? parseFailureRunHint = null) - { - var ind = "\t\t\t" + indentExtra; - var e = Escape(p.CliLongName); - string Out(string name) => outVarKeyword ? "out var " + name : "out " + name; - - if (p.ScalarKind == CliScalarKind.Enum && p.EnumTypeFq is not null && !p.EnumMemberNames.IsDefaultOrEmpty) - { - var evVar = "__ev_" + p.LocalVarName; - var evParsed = "__evp_" + p.LocalVarName; - sb.AppendLine($"{ind}var {evParsed} = false;"); - sb.AppendLine($"{ind}{p.EnumTypeFq} {evVar} = default;"); - sb.AppendLine($"{ind}switch (({rawExpr} ?? \"\").ToLowerInvariant())"); - sb.AppendLine($"{ind}{{"); - for (var i = 0; i < p.EnumMemberNames.Length; i++) - { - var memberName = p.EnumMemberNames[i]; - var cliName = ResolveEnumMemberCliName(p.EnumMemberCliNames, i, memberName); - sb.AppendLine($"{ind}\tcase \"{Escape(cliName.ToLowerInvariant())}\": {evVar} = {p.EnumTypeFq}.{memberName}; {evParsed} = true; break;"); - } - sb.AppendLine($"{ind}}}"); - sb.AppendLine($"{ind}if (!{evParsed})"); - sb.AppendLine($"{ind}{{"); - sb.AppendLine($"{ind}\tConsole.Error.WriteLine($\"Error: invalid value for --{e}: '{{{rawExpr}}}'.\");"); - EmitAfterCliParseErrorHelp(sb, p, $"{ind}\t", helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); - sb.AppendLine($"{ind}\t{failureExit};"); - sb.AppendLine($"{ind}}}"); - if (outVarKeyword) - sb.AppendLine($"{ind}var {targetVar} = {evVar};"); - else - sb.AppendLine($"{ind}{targetVar} = {evVar};"); - return; - } - - if (p.ScalarKind == CliScalarKind.FileInfo) - { - // Optional FileInfo? must omit new FileInfo when the flag was not provided (null), not pass null into the ctor (ArgumentNullException). - if (!p.IsRequired) - { - var csharpNullableFi = GetCSharpCliType(p); - var tmpFi = "__nullableFileInfo_" + Naming.SanitizeIdentifier(p.LocalVarName); - sb.AppendLine($"{ind}{csharpNullableFi} {tmpFi} = null;"); - sb.AppendLine($"{ind}if (!string.IsNullOrWhiteSpace({rawExpr}))"); - sb.AppendLine($"{ind}{{"); - var innerFi = ind + "\t"; - string pathSrcOpt = $"{rawExpr}!"; - if (p.ExpandUserProfileBeforeBind) - { - var expandedOpt = "__path_" + Naming.SanitizeIdentifier(p.LocalVarName); - sb.AppendLine($"{innerFi}var {expandedOpt} = global::Nullean.Argh.ArghPath.ExpandUserProfilePath({rawExpr}!);"); - pathSrcOpt = expandedOpt; - } - - sb.AppendLine($"{innerFi}{tmpFi} = new global::System.IO.FileInfo({pathSrcOpt});"); - sb.AppendLine($"{ind}}}"); - if (outVarKeyword) - sb.AppendLine($"{ind}var {targetVar} = {tmpFi};"); - else - sb.AppendLine($"{ind}{targetVar} = {tmpFi};"); - return; - } - - string pathSrc = $"{rawExpr}!"; - if (p.ExpandUserProfileBeforeBind) - { - var expandedName = "__path_" + Naming.SanitizeIdentifier(p.LocalVarName); - sb.AppendLine($"{ind}var {expandedName} = global::Nullean.Argh.ArghPath.ExpandUserProfilePath({rawExpr}!);"); - pathSrc = expandedName; - } - - if (outVarKeyword) - sb.AppendLine($"{ind}var {targetVar} = new global::System.IO.FileInfo({pathSrc});"); - else - sb.AppendLine($"{ind}{targetVar} = new global::System.IO.FileInfo({pathSrc});"); - return; - } - - if (p.ScalarKind == CliScalarKind.DirectoryInfo) - { - // Optional DirectoryInfo? must omit new DirectoryInfo when the flag was not provided (null), not pass null into the ctor (ArgumentNullException). - if (!p.IsRequired) - { - var csharpNullableDi = GetCSharpCliType(p); - var tmpDi = "__nullableDirectoryInfo_" + Naming.SanitizeIdentifier(p.LocalVarName); - sb.AppendLine($"{ind}{csharpNullableDi} {tmpDi} = null;"); - sb.AppendLine($"{ind}if (!string.IsNullOrWhiteSpace({rawExpr}))"); - sb.AppendLine($"{ind}{{"); - var innerDi = ind + "\t"; - string pathSrcDirOpt = $"{rawExpr}!"; - if (p.ExpandUserProfileBeforeBind) - { - var expandedOptDir = "__dir_" + Naming.SanitizeIdentifier(p.LocalVarName); - sb.AppendLine($"{innerDi}var {expandedOptDir} = global::Nullean.Argh.ArghPath.ExpandUserProfilePath({rawExpr}!);"); - pathSrcDirOpt = expandedOptDir; - } - - sb.AppendLine($"{innerDi}{tmpDi} = new global::System.IO.DirectoryInfo({pathSrcDirOpt});"); - sb.AppendLine($"{ind}}}"); - if (outVarKeyword) - sb.AppendLine($"{ind}var {targetVar} = {tmpDi};"); - else - sb.AppendLine($"{ind}{targetVar} = {tmpDi};"); - return; - } - - string pathSrcDir = $"{rawExpr}!"; - if (p.ExpandUserProfileBeforeBind) - { - var expandedDir = "__dir_" + Naming.SanitizeIdentifier(p.LocalVarName); - sb.AppendLine($"{ind}var {expandedDir} = global::Nullean.Argh.ArghPath.ExpandUserProfilePath({rawExpr}!);"); - pathSrcDir = expandedDir; - } - - if (outVarKeyword) - sb.AppendLine($"{ind}var {targetVar} = new global::System.IO.DirectoryInfo({pathSrcDir});"); - else - sb.AppendLine($"{ind}{targetVar} = new global::System.IO.DirectoryInfo({pathSrcDir});"); - return; - } - - if (p.ScalarKind == CliScalarKind.Uri) - { - // Optional Uri? must treat omitted flags as null (raw text is null), not run Uri.TryCreate on null/whitespace. - if (!p.IsRequired) - { - var csharpNullableUri = GetCSharpCliType(p); - var tmpUri = "__nullableUriParsed_" + Naming.SanitizeIdentifier(p.LocalVarName); - sb.AppendLine($"{ind}{csharpNullableUri} {tmpUri} = null;"); - sb.AppendLine($"{ind}if (!string.IsNullOrWhiteSpace({rawExpr}))"); - sb.AppendLine($"{ind}{{"); - sb.AppendLine( - $"{ind}\tif (!global::System.Uri.TryCreate({rawExpr}, global::System.UriKind.RelativeOrAbsolute, out var __uri))"); - sb.AppendLine($"{ind}\t{{"); - sb.AppendLine($"{ind}\t\tConsole.Error.WriteLine($\"Error: invalid URI for --{e}: '{{{rawExpr}}}'.\");"); - EmitAfterCliParseErrorHelp(sb, p, $"{ind}\t\t", helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); - sb.AppendLine($"{ind}\t\t{failureExit};"); - sb.AppendLine($"{ind}\t}}"); - sb.AppendLine($"{ind}\t{tmpUri} = __uri;"); - sb.AppendLine($"{ind}}}"); - if (outVarKeyword) - sb.AppendLine($"{ind}var {targetVar} = {tmpUri};"); - else - sb.AppendLine($"{ind}{targetVar} = {tmpUri};"); - return; - } - - sb.AppendLine($"{ind}if (!global::System.Uri.TryCreate({rawExpr}, global::System.UriKind.RelativeOrAbsolute, out var __uri))"); - sb.AppendLine($"{ind}{{"); - sb.AppendLine($"{ind}\tConsole.Error.WriteLine($\"Error: invalid URI for --{e}: '{{{rawExpr}}}'.\");"); - EmitAfterCliParseErrorHelp(sb, p, $"{ind}\t", helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); - sb.AppendLine($"{ind}\t{failureExit};"); - sb.AppendLine($"{ind}}}"); - if (outVarKeyword) - sb.AppendLine($"{ind}var {targetVar} = __uri;"); - else - sb.AppendLine($"{ind}{targetVar} = __uri;"); - return; - } - - if (p.ScalarKind == CliScalarKind.CustomParser && p.ParserTypeFq is not null && p.CustomValueTypeFq is not null) - { - sb.AppendLine($"{ind}var __parser = new {p.ParserTypeFq}();"); - sb.AppendLine($"{ind}if (!__parser.TryParse({rawExpr}!, out var __pv))"); - sb.AppendLine($"{ind}{{"); - sb.AppendLine($"{ind}\tConsole.Error.WriteLine($\"Error: invalid value for --{e}.\");"); - EmitAfterCliParseErrorHelp(sb, p, $"{ind}\t", helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); - sb.AppendLine($"{ind}\t{failureExit};"); - sb.AppendLine($"{ind}}}"); - if (outVarKeyword) - sb.AppendLine($"{ind}var {targetVar} = __pv;"); - else - sb.AppendLine($"{ind}{targetVar} = __pv;"); - return; - } - - if (p.Special == BoolSpecialKind.None && p.TypeName is "int?" or "long?" or "float?" or "double?" or "decimal?") - { - EmitNullableNumericParseFromString(sb, p, rawExpr, targetVar, ind, outVarKeyword, failureExit, helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); - return; - } - - if (p.Special == BoolSpecialKind.None && p.TypeName is "DateTime?" or "DateTimeOffset?" or "TimeSpan?" or "DateOnly?") - { - EmitNullableTemporalParseFromString(sb, p, rawExpr, targetVar, ind, outVarKeyword, failureExit, helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); - return; - } - - switch (p.Special) - { - case BoolSpecialKind.None when p.TypeName == "string": - { - if (outVarKeyword) - sb.AppendLine($"{ind}var {targetVar} = {rawExpr};"); - else - { - var nonNull = p.IsRequired ? "!" : ""; - sb.AppendLine($"{ind}{targetVar} = {rawExpr}{nonNull};"); - } - - break; - } - case BoolSpecialKind.None when p.TypeName == "int": - sb.AppendLine( - $"{ind}if (!int.TryParse({rawExpr}, NumberStyles.Integer, CultureInfo.InvariantCulture, {Out(targetVar)}))"); - sb.AppendLine($"{ind}{{"); - sb.AppendLine($"{ind}\tConsole.Error.WriteLine($\"Error: invalid int for --{e}: '{{{rawExpr}}}'.\");"); - EmitAfterCliParseErrorHelp(sb, p, $"{ind}\t", helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); - sb.AppendLine($"{ind}\t{failureExit};"); - sb.AppendLine($"{ind}}}"); - break; - case BoolSpecialKind.None when p.TypeName == "long": - sb.AppendLine( - $"{ind}if (!long.TryParse({rawExpr}, NumberStyles.Integer, CultureInfo.InvariantCulture, {Out(targetVar)}))"); - sb.AppendLine($"{ind}{{"); - sb.AppendLine($"{ind}\tConsole.Error.WriteLine($\"Error: invalid long for --{e}: '{{{rawExpr}}}'.\");"); - EmitAfterCliParseErrorHelp(sb, p, $"{ind}\t", helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); - sb.AppendLine($"{ind}\t{failureExit};"); - sb.AppendLine($"{ind}}}"); - break; - case BoolSpecialKind.None when p.TypeName == "float": - sb.AppendLine( - $"{ind}if (!float.TryParse({rawExpr}, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, {Out(targetVar)}))"); - sb.AppendLine($"{ind}{{"); - sb.AppendLine($"{ind}\tConsole.Error.WriteLine($\"Error: invalid float for --{e}: '{{{rawExpr}}}'.\");"); - EmitAfterCliParseErrorHelp(sb, p, $"{ind}\t", helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); - sb.AppendLine($"{ind}\t{failureExit};"); - sb.AppendLine($"{ind}}}"); - break; - case BoolSpecialKind.None when p.TypeName == "double": - sb.AppendLine( - $"{ind}if (!double.TryParse({rawExpr}, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, {Out(targetVar)}))"); - sb.AppendLine($"{ind}{{"); - sb.AppendLine($"{ind}\tConsole.Error.WriteLine($\"Error: invalid double for --{e}: '{{{rawExpr}}}'.\");"); - EmitAfterCliParseErrorHelp(sb, p, $"{ind}\t", helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); - sb.AppendLine($"{ind}\t{failureExit};"); - sb.AppendLine($"{ind}}}"); - break; - case BoolSpecialKind.None when p.TypeName == "decimal": - sb.AppendLine( - $"{ind}if (!decimal.TryParse({rawExpr}, NumberStyles.Number, CultureInfo.InvariantCulture, {Out(targetVar)}))"); - sb.AppendLine($"{ind}{{"); - sb.AppendLine($"{ind}\tConsole.Error.WriteLine($\"Error: invalid decimal for --{e}: '{{{rawExpr}}}'.\");"); - EmitAfterCliParseErrorHelp(sb, p, $"{ind}\t", helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); - sb.AppendLine($"{ind}\t{failureExit};"); - sb.AppendLine($"{ind}}}"); - break; - case BoolSpecialKind.None when p.TypeName == "DateTime": - { - var tmp = "__dt_" + p.LocalVarName; - sb.AppendLine( - $"{ind}if (!global::System.DateTime.TryParse({rawExpr}, CultureInfo.InvariantCulture, global::System.Globalization.DateTimeStyles.RoundtripKind | global::System.Globalization.DateTimeStyles.AllowWhiteSpaces, out var {tmp}))"); - sb.AppendLine($"{ind}{{"); - sb.AppendLine($"{ind}\tConsole.Error.WriteLine($\"Error: invalid DateTime for --{e}: '{{{rawExpr}}}'.\");"); - EmitAfterCliParseErrorHelp(sb, p, $"{ind}\t", helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); - sb.AppendLine($"{ind}\t{failureExit};"); - sb.AppendLine($"{ind}}}"); - if (outVarKeyword) - sb.AppendLine($"{ind}var {targetVar} = {tmp};"); - else - sb.AppendLine($"{ind}{targetVar} = {tmp};"); - break; - } - case BoolSpecialKind.None when p.TypeName == "DateTimeOffset": - { - var tmp = "__dto_" + p.LocalVarName; - sb.AppendLine( - $"{ind}if (!global::System.DateTimeOffset.TryParse({rawExpr}, CultureInfo.InvariantCulture, global::System.Globalization.DateTimeStyles.RoundtripKind | global::System.Globalization.DateTimeStyles.AllowWhiteSpaces, out var {tmp}))"); - sb.AppendLine($"{ind}{{"); - sb.AppendLine($"{ind}\tConsole.Error.WriteLine($\"Error: invalid DateTimeOffset for --{e}: '{{{rawExpr}}}'.\");"); - EmitAfterCliParseErrorHelp(sb, p, $"{ind}\t", helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); - sb.AppendLine($"{ind}\t{failureExit};"); - sb.AppendLine($"{ind}}}"); - if (outVarKeyword) - sb.AppendLine($"{ind}var {targetVar} = {tmp};"); - else - sb.AppendLine($"{ind}{targetVar} = {tmp};"); - break; - } - case BoolSpecialKind.None when p.TypeName == "TimeSpan": - { - var tmp = "__ts_" + p.LocalVarName; - sb.AppendLine($"{ind}if (!global::Nullean.Argh.ArghTimeSpan.TryParse({rawExpr}, out var {tmp}))"); - sb.AppendLine($"{ind}{{"); - sb.AppendLine($"{ind}\tConsole.Error.WriteLine($\"Error: invalid TimeSpan for --{e}: '{{{rawExpr}}}'.\");"); - EmitAfterCliParseErrorHelp(sb, p, $"{ind}\t", helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); - sb.AppendLine($"{ind}\t{failureExit};"); - sb.AppendLine($"{ind}}}"); - if (outVarKeyword) - sb.AppendLine($"{ind}var {targetVar} = {tmp};"); - else - sb.AppendLine($"{ind}{targetVar} = {tmp};"); - break; - } - case BoolSpecialKind.None when p.TypeName == "DateOnly": - { - var tmp = "__do_" + p.LocalVarName; - sb.AppendLine( - $"{ind}if (!global::System.DateOnly.TryParse({rawExpr}, CultureInfo.InvariantCulture, global::System.Globalization.DateTimeStyles.None, out var {tmp}))"); - sb.AppendLine($"{ind}{{"); - sb.AppendLine($"{ind}\tConsole.Error.WriteLine($\"Error: invalid DateOnly for --{e}: '{{{rawExpr}}}'.\");"); - EmitAfterCliParseErrorHelp(sb, p, $"{ind}\t", helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); - sb.AppendLine($"{ind}\t{failureExit};"); - sb.AppendLine($"{ind}}}"); - if (outVarKeyword) - sb.AppendLine($"{ind}var {targetVar} = {tmp};"); - else - sb.AppendLine($"{ind}{targetVar} = {tmp};"); - break; - } - case BoolSpecialKind.None when p.TypeName == "bool": - if (outVarKeyword) - sb.AppendLine( - $"{ind}var {targetVar} = bool.TryParse({rawExpr}, out var tmpBool) ? tmpBool : true;"); - else - sb.AppendLine($"{ind}{targetVar} = bool.TryParse({rawExpr}, out var tmpBool) ? tmpBool : true;"); - break; - default: - if (outVarKeyword) - sb.AppendLine($"{ind}var {targetVar} = {rawExpr};"); - else - sb.AppendLine($"{ind}{targetVar} = {rawExpr}; // fallback"); - break; - } - } - - private static void EmitNullableNumericParseFromString(StringBuilder sb, ParameterModel p, string rawExpr, string targetVar, - string ind, bool outVarKeyword, string failureExit, string? helpMethodName, string? flagHelpStdErrMethodName = null, string? parseFailureRunHint = null) - { - var e = Escape(p.CliLongName); - var tmpVar = "__nullableNumericParsed_" + p.LocalVarName; - var parsedOut = "__nv_" + p.LocalVarName; - var csharpNullable = GetCSharpCliType(p); - - sb.AppendLine($"{ind}{csharpNullable} {tmpVar} = null;"); - sb.AppendLine($"{ind}if ({rawExpr} is not null)"); - sb.AppendLine($"{ind}{{"); - - switch (p.TypeName) - { - case "int?": - sb.AppendLine( - $"{ind}\tif (!int.TryParse({rawExpr}, NumberStyles.Integer, CultureInfo.InvariantCulture, out var {parsedOut}))"); - break; - case "long?": - sb.AppendLine( - $"{ind}\tif (!long.TryParse({rawExpr}, NumberStyles.Integer, CultureInfo.InvariantCulture, out var {parsedOut}))"); - break; - case "float?": - sb.AppendLine( - $"{ind}\tif (!float.TryParse({rawExpr}, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var {parsedOut}))"); - break; - case "double?": - sb.AppendLine( - $"{ind}\tif (!double.TryParse({rawExpr}, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var {parsedOut}))"); - break; - case "decimal?": - sb.AppendLine( - $"{ind}\tif (!decimal.TryParse({rawExpr}, NumberStyles.Number, CultureInfo.InvariantCulture, out var {parsedOut}))"); - break; - default: - throw new InvalidOperationException($"Unexpected nullable numeric type '{p.TypeName}'."); - } - - sb.AppendLine($"{ind}\t{{"); - sb.AppendLine($"{ind}\t\tConsole.Error.WriteLine($\"Error: invalid {p.TypeName.TrimEnd('?')} for --{e}: '{{{rawExpr}}}'.\");"); - EmitAfterCliParseErrorHelp(sb, p, $"{ind}\t\t", helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); - sb.AppendLine($"{ind}\t\t{failureExit};"); - sb.AppendLine($"{ind}\t}}"); - sb.AppendLine($"{ind}\t{tmpVar} = {parsedOut};"); - sb.AppendLine($"{ind}}}"); - - if (outVarKeyword) - sb.AppendLine($"{ind}var {targetVar} = {tmpVar};"); - else - sb.AppendLine($"{ind}{targetVar} = {tmpVar};"); - } - - private static void EmitNullableTemporalParseFromString(StringBuilder sb, ParameterModel p, string rawExpr, string targetVar, - string ind, bool outVarKeyword, string failureExit, string? helpMethodName, string? flagHelpStdErrMethodName = null, string? parseFailureRunHint = null) - { - var e = Escape(p.CliLongName); - var tmpVar = "__nullableTemporalParsed_" + p.LocalVarName; - var parsedOut = "__nt_" + p.LocalVarName; - var csharpNullable = GetCSharpCliType(p); - - sb.AppendLine($"{ind}{csharpNullable} {tmpVar} = null;"); - sb.AppendLine($"{ind}if ({rawExpr} is not null)"); - sb.AppendLine($"{ind}{{"); - - switch (p.TypeName) - { - case "DateTime?": - sb.AppendLine( - $"{ind}\tif (!global::System.DateTime.TryParse({rawExpr}, CultureInfo.InvariantCulture, global::System.Globalization.DateTimeStyles.RoundtripKind | global::System.Globalization.DateTimeStyles.AllowWhiteSpaces, out var {parsedOut}))"); - break; - case "DateTimeOffset?": - sb.AppendLine( - $"{ind}\tif (!global::System.DateTimeOffset.TryParse({rawExpr}, CultureInfo.InvariantCulture, global::System.Globalization.DateTimeStyles.RoundtripKind | global::System.Globalization.DateTimeStyles.AllowWhiteSpaces, out var {parsedOut}))"); - break; - case "TimeSpan?": - sb.AppendLine($"{ind}\tif (!global::Nullean.Argh.ArghTimeSpan.TryParse({rawExpr}, out var {parsedOut}))"); - break; - case "DateOnly?": - sb.AppendLine( - $"{ind}\tif (!global::System.DateOnly.TryParse({rawExpr}, CultureInfo.InvariantCulture, global::System.Globalization.DateTimeStyles.None, out var {parsedOut}))"); - break; - default: - throw new InvalidOperationException($"Unexpected nullable temporal type '{p.TypeName}'."); - } - - sb.AppendLine($"{ind}\t{{"); - sb.AppendLine($"{ind}\t\tConsole.Error.WriteLine($\"Error: invalid {p.TypeName.TrimEnd('?')} for --{e}: '{{{rawExpr}}}'.\");"); - EmitAfterCliParseErrorHelp(sb, p, $"{ind}\t\t", helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); - sb.AppendLine($"{ind}\t\t{failureExit};"); - sb.AppendLine($"{ind}\t}}"); - sb.AppendLine($"{ind}\t{tmpVar} = {parsedOut};"); - sb.AppendLine($"{ind}}}"); - - if (outVarKeyword) - sb.AppendLine($"{ind}var {targetVar} = {tmpVar};"); - else - sb.AppendLine($"{ind}{targetVar} = {tmpVar};"); - } - - private static void EmitInvocation( - StringBuilder sb, - CommandModel cmd, - string ctExpr = "ct", - string? commandContextVar = null, - string lineIndent = "\t\t\t", - ImmutableArray<(string TypeFq, string TypeMetadataName, ImmutableArray AllBaseTypeMetadataNames, string StaticFieldName, string LocalVarName, ImmutableArray FlatMembers, ImmutableArray? BestCtorParamOrder)> injectedOptions = default) - { - // Lambda commands: invoke through ArghApp.GetRegisteredLambda with a cast - if (cmd.IsLambda && !string.IsNullOrEmpty(cmd.LambdaStorageKey)) - { - EmitLambdaInvocation(sb, cmd, ctExpr, commandContextVar, lineIndent); - return; - } - - var args = new List(); - if (cmd.HandlerParamTypes.IsDefaultOrEmpty) - { - foreach (var p in cmd.Parameters) - { - if (p.Kind == ParameterKind.Injected) - args.Add(ctExpr); - else if (p.Kind != ParameterKind.OptionsInjected) - // OptionsInjected entries are flag-recognition markers added by FixOptionsParamsInCommands; - // they are not method arguments — the options instance is passed as a reconstructed local. - args.Add(p.LocalVarName); - } - } - else - { - foreach (var mp in cmd.HandlerParamTypes) - { - if (mp.IsInjectedParam) - { - args.Add(ctExpr); - continue; - } - - if (mp.IsAsParameters) - { - args.Add(AsParametersConstructedVarName(mp.Name)); - continue; - } - - // Options-type parameters are injected as locally-reconstructed instances that merge - // command-level flags (post-command) with pre-parsed static values (pre-command). - if (!injectedOptions.IsDefaultOrEmpty) - { - string? localVar = null; - foreach (var o in injectedOptions) - if (o.TypeMetadataName == mp.TypeMetadataName) { localVar = o.LocalVarName; break; } - if (localVar is null) - for (var _i = injectedOptions.Length - 1; _i >= 0; _i--) - if (injectedOptions[_i].AllBaseTypeMetadataNames.Contains(mp.TypeMetadataName)) { localVar = injectedOptions[_i].LocalVarName; break; } - - if (localVar is not null) - { - args.Add(localVar); - continue; - } - } - - foreach (var p in cmd.Parameters) - { - if (p.AsParametersOwnerParamName is not null) - continue; - if (p.SymbolName != mp.Name) - continue; - args.Add(p.LocalVarName); - break; - } - } - } - - var argList = string.Join(", ", args); - var call = cmd.RequiresInstance - ? $"__cmdHandler.{cmd.MethodName}({argList})" - : $"{cmd.ContainingTypeFq}.{cmd.MethodName}({argList})"; - - var ret0 = commandContextVar is null - ? $"{lineIndent}return 0;" - : $"{lineIndent}{commandContextVar}.ExitCode = 0;\n{lineIndent}return;"; - - var retFq = cmd.ReturnTypeFq; - // Empty string means no return type info (shouldn't happen for method handlers). - // Note: retFq comes from SymbolDisplayFormat.FullyQualifiedFormat, which renders special - // types using their C# keyword ("void", "int") rather than "global::System.Void"/"global::System.Int32" — - // keep these checks in that form (see also the "int" checks inside Task/ValueTask below). - if (retFq == "" || retFq == "void") - { - sb.AppendLine($"{lineIndent}{call};"); - sb.AppendLine(ret0); - return; - } - - if (retFq == "int") - { - if (commandContextVar is null) - sb.AppendLine($"{lineIndent}return {call};"); - else - { - sb.AppendLine($"{lineIndent}{commandContextVar}.ExitCode = {call};"); - sb.AppendLine($"{lineIndent}return;"); - } - - return; - } - - if (retFq == "global::System.Threading.Tasks.Task") - { - sb.AppendLine($"{lineIndent}await {call}.ConfigureAwait(false);"); - sb.AppendLine(ret0); - return; - } - - if (retFq == "global::System.Threading.Tasks.Task") - { - if (commandContextVar is null) - sb.AppendLine($"{lineIndent}return await {call}.ConfigureAwait(false);"); - else - { - sb.AppendLine($"{lineIndent}{commandContextVar}.ExitCode = await {call}.ConfigureAwait(false);"); - sb.AppendLine($"{lineIndent}return;"); - } - return; - } - - if (retFq.StartsWith("global::System.Threading.Tasks.Task<", StringComparison.Ordinal)) - { - sb.AppendLine($"{lineIndent}await {call}.ConfigureAwait(false);"); - sb.AppendLine(ret0); - return; - } - - if (retFq == "global::System.Threading.Tasks.ValueTask") - { - sb.AppendLine($"{lineIndent}await {call}.ConfigureAwait(false);"); - sb.AppendLine(ret0); - return; - } - - if (retFq == "global::System.Threading.Tasks.ValueTask") - { - if (commandContextVar is null) - sb.AppendLine($"{lineIndent}return await {call}.ConfigureAwait(false);"); - else - { - sb.AppendLine($"{lineIndent}{commandContextVar}.ExitCode = await {call}.ConfigureAwait(false);"); - sb.AppendLine($"{lineIndent}return;"); - } - return; - } - - if (retFq.StartsWith("global::System.Threading.Tasks.ValueTask<", StringComparison.Ordinal)) - { - sb.AppendLine($"{lineIndent}await {call}.ConfigureAwait(false);"); - sb.AppendLine(ret0); - return; - } - - sb.AppendLine($"{lineIndent}{call};"); - sb.AppendLine(ret0); - } - - private static void EmitLambdaInvocation( - StringBuilder sb, - CommandModel cmd, - string ctExpr, - string? commandContextVar, - string lineIndent) - { - var lambdaArgs = new List(); - foreach (var p in cmd.Parameters) - { - if (p.Kind == ParameterKind.Injected) - lambdaArgs.Add(ctExpr); - else - lambdaArgs.Add(p.LocalVarName); - } - var lambdaArgList = string.Join(", ", lambdaArgs); - var castType = string.IsNullOrEmpty(cmd.LambdaDelegateFq) || cmd.LambdaDelegateFq == "global::System.Delegate" - ? "global::System.Delegate" - : cmd.LambdaDelegateFq; - - var lambdaRet0 = commandContextVar is null - ? $"{lineIndent}return 0;" - : $"{lineIndent}{commandContextVar}.ExitCode = 0;\n{lineIndent}return;"; - - var lambdaRetFq = cmd.ReturnTypeFq; - var lambdaIsTaskOfInt = lambdaRetFq == "global::System.Threading.Tasks.Task" - || lambdaRetFq == "global::System.Threading.Tasks.ValueTask"; - - if (castType == "global::System.Delegate") - { - // Fallback: use DynamicInvoke - sb.AppendLine($"{lineIndent}var __lambdaDelegate = ArghApp.GetRegisteredLambda(\"{Escape(cmd.LambdaStorageKey)}\");"); - sb.AppendLine($"{lineIndent}__lambdaDelegate?.DynamicInvoke({lambdaArgList});"); - sb.AppendLine(lambdaRet0); - } - else - { - sb.AppendLine($"{lineIndent}var __lambdaDelegate = (({castType})ArghApp.GetRegisteredLambda(\"{Escape(cmd.LambdaStorageKey)}\")!);"); - if (lambdaRetFq == "global::System.Threading.Tasks.Task" || - (lambdaRetFq.StartsWith("global::System.Threading.Tasks.Task<", System.StringComparison.Ordinal) && !lambdaIsTaskOfInt)) - { - sb.AppendLine($"{lineIndent}await __lambdaDelegate({lambdaArgList}).ConfigureAwait(false);"); - sb.AppendLine(lambdaRet0); - } - else if (lambdaIsTaskOfInt) - { - if (commandContextVar is null) - sb.AppendLine($"{lineIndent}return await __lambdaDelegate({lambdaArgList}).ConfigureAwait(false);"); - else - { - sb.AppendLine($"{lineIndent}{commandContextVar}.ExitCode = await __lambdaDelegate({lambdaArgList}).ConfigureAwait(false);"); - sb.AppendLine($"{lineIndent}return;"); - } - } - else if (lambdaRetFq == "int") - { - if (commandContextVar is null) - sb.AppendLine($"{lineIndent}return __lambdaDelegate({lambdaArgList});"); - else - { - sb.AppendLine($"{lineIndent}{commandContextVar}.ExitCode = __lambdaDelegate({lambdaArgList});"); - sb.AppendLine($"{lineIndent}return;"); - } - } - else - { - sb.AppendLine($"{lineIndent}__lambdaDelegate({lambdaArgList});"); - sb.AppendLine(lambdaRet0); - } - } - } - - private static IEnumerable EnumerateFlagMembers(OptionsTypeModel? model) - { - if (model is null) - yield break; - - foreach (var p in model.Members) - { - if (p.Kind == ParameterKind.Flag) - yield return p; - } - } - - private static void AddCliKeys(IEnumerable flags, HashSet keys) - { - foreach (var p in flags) - { - keys.Add(p.CliLongName); - foreach (var a in p.Aliases) - { - if (!string.IsNullOrEmpty(a)) - keys.Add(a); - } - } - } - - private static List<(string Segment, OptionsTypeModel Model)> GetCommandNamespaceOptionChain(AppEmitModel app, ImmutableArray routePrefix) - { - var list = new List<(string, OptionsTypeModel)>(); - var current = app.Root; - foreach (var seg in routePrefix) - { - RegistryNode.NamedCommandNamespaceChild? found = null; - foreach (var c in current.Children) - { - if (string.Equals(c.Segment, seg, StringComparison.OrdinalIgnoreCase)) - { - found = c; - break; - } - } - - if (found is null) - break; - - current = found.Node; - if (current.CommandNamespaceOptionsModel is { Members: { Length: > 0 } } gom) - list.Add((seg, gom)); - } - - return list; - } - - private static bool CommandFlagMatchesScopedKeys(ParameterModel p, HashSet scopedKeys) - { - if (scopedKeys.Contains(p.CliLongName)) - return true; - - foreach (var a in p.Aliases) - { - if (!string.IsNullOrEmpty(a) && scopedKeys.Contains(a)) - return true; - } - - return false; - } - - - /// - /// Diagnoses misuse of the filesystem-path attribute family. lets - /// collection call sites pass the *element* kind (e.g. FileInfo for List<FileInfo>) so these - /// attributes are correctly recognized on collections of FileInfo/DirectoryInfo, not just scalars. - /// - private static void ReportFilesystemPathAttributeIssues( - ISymbol host, - CliScalarKind scalarKind, - string declaredName, - DiagnosticAccumulator? acc, - SourceProductionContext? ctx, - Location? fallbackLocation, - CliScalarKind? filesystemScalarKind = null) - { - Location loc = host.Locations.FirstOrDefault() ?? fallbackLocation ?? Location.None; - var fsKind = filesystemScalarKind ?? scalarKind; - - static void ReportFilesystemDiag(DiagnosticAccumulator? a, SourceProductionContext? c, DiagnosticDescriptor d, Location location, - string arg0) - { - if (c.HasValue) - c.Value.ReportDiagnostic(Diagnostic.Create(d, location, arg0)); - else if (a is not null) - a.Add(d, location, arg0); - } - - static void ReportFilesystemDiagTwo(DiagnosticAccumulator? a, SourceProductionContext? c, DiagnosticDescriptor d, Location location, - string arg0, string arg1) - { - if (c.HasValue) - c.Value.ReportDiagnostic(Diagnostic.Create(d, location, arg0, arg1)); - else if (a is not null) - a.Add(d, location, arg0, arg1); - } - - var hasExisting = false; - var hasNonExisting = false; - var hasExpandProfile = false; - var hasRejectSymlinks = false; - var hasFileExtensions = false; - - foreach (var attr in host.GetAttributes()) - { - var fqn = attr.AttributeClass?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) ?? ""; - switch (fqn) - { - case "global::Nullean.Argh.ExistingAttribute": - hasExisting = true; - break; - case "global::Nullean.Argh.NonExistingAttribute": - hasNonExisting = true; - break; - case "global::Nullean.Argh.ExpandUserProfileAttribute": - hasExpandProfile = true; - break; - case "global::Nullean.Argh.RejectSymbolicLinksAttribute": - hasRejectSymlinks = true; - break; - case "global::System.ComponentModel.DataAnnotations.FileExtensionsAttribute": - hasFileExtensions = true; - break; - } - } - - if (hasExisting && hasNonExisting) - ReportFilesystemDiag(acc, ctx, PathExistenceAttributesConflict, loc, declaredName); - - var isFileInfo = fsKind == CliScalarKind.FileInfo; - var isDirInfo = fsKind == CliScalarKind.DirectoryInfo; - var isFileOrDir = isFileInfo || isDirInfo; - - if (hasExisting && !isFileOrDir) - ReportFilesystemDiagTwo(acc, ctx, FilesystemPathAttributeTypeMismatch, loc, declaredName, - "[Existing] only applies to FileInfo, FileInfo?, DirectoryInfo, DirectoryInfo?, or a collection of FileInfo/DirectoryInfo parameters and properties."); - - if (hasNonExisting && !isFileOrDir) - ReportFilesystemDiagTwo(acc, ctx, FilesystemPathAttributeTypeMismatch, loc, declaredName, - "[NonExisting] only applies to FileInfo, FileInfo?, DirectoryInfo, DirectoryInfo?, or a collection of FileInfo/DirectoryInfo parameters and properties."); - - if (hasExpandProfile && !isFileOrDir) - ReportFilesystemDiagTwo(acc, ctx, FilesystemPathAttributeTypeMismatch, loc, declaredName, - "[ExpandUserProfile] only applies to FileInfo, DirectoryInfo, or a collection of FileInfo/DirectoryInfo parameters and properties."); - - if (hasRejectSymlinks && !isFileOrDir) - ReportFilesystemDiagTwo(acc, ctx, FilesystemPathAttributeTypeMismatch, loc, declaredName, - "[RejectSymbolicLinks] only applies to FileInfo, DirectoryInfo, or a collection of FileInfo/DirectoryInfo parameters and properties."); - - if (hasFileExtensions && !isFileInfo) - ReportFilesystemDiagTwo(acc, ctx, FilesystemPathAttributeTypeMismatch, loc, declaredName, - "[FileExtensions] only applies to FileInfo, FileInfo?, or a collection of FileInfo parameters and properties."); - } - - private static bool TryReadExpandUserProfileBeforeBind(ISymbol host, CliScalarKind scalarKind) - { - foreach (var attr in host.GetAttributes()) - { - if (attr.AttributeClass?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == - "global::Nullean.Argh.ExpandUserProfileAttribute") - { - return scalarKind is CliScalarKind.FileInfo or CliScalarKind.DirectoryInfo; - } - } - - return false; - } - - - /// - /// Reads DataAnnotations/Argh validation attributes off . - /// - /// - /// Kind used to gate the filesystem-path family ([Existing], [NonExisting], [RejectSymbolicLinks], [FileExtensions]). - /// For scalar parameters this equals (the default when null). For collection - /// parameters (List<FileInfo>, DirectoryInfo[], ...) callers pass the *element* kind here so - /// these attributes are recognized per-item while stays - /// for the other (non filesystem-family) constraint decisions such as [Url] vs Uri-scheme. - /// - private static ImmutableArray ReadValidationConstraints(ISymbol attributeHost, CliScalarKind scalarKind, - string primitiveTypeName, bool isCollection = false, CliScalarKind? filesystemScalarKind = null) - { - var fsKind = filesystemScalarKind ?? scalarKind; - var builder = ImmutableArray.CreateBuilder(); - foreach (var attr in attributeHost.GetAttributes()) - { - var fqn = attr.AttributeClass?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) ?? ""; - switch (fqn) - { - case "global::System.ComponentModel.DataAnnotations.RangeAttribute": - if (attr.ConstructorArguments.Length >= 2) - builder.Add(new RangeConstraint(attr.ConstructorArguments[0].ToCSharpString(), attr.ConstructorArguments[1].ToCSharpString())); - break; - case "global::Nullean.Argh.TimeSpanRangeAttribute": - if (primitiveTypeName is "TimeSpan" or "TimeSpan?" && - attr.ConstructorArguments.Length >= 2) - builder.Add(new TimeSpanRangeConstraint( - attr.ConstructorArguments[0].ToCSharpString(), - attr.ConstructorArguments[1].ToCSharpString())); - break; - case "global::System.ComponentModel.DataAnnotations.StringLengthAttribute": - if (attr.ConstructorArguments.Length >= 1) - { - var max = (int?)(int?)attr.ConstructorArguments[0].Value; - int? min = null; - foreach (var n in attr.NamedArguments) - if (n.Key == "MinimumLength") min = (int?)n.Value.Value; - if (isCollection) - builder.Add(new CollectionCountConstraint(min, max)); - else - builder.Add(new StringLengthConstraint(min, max)); - } - break; - case "global::System.ComponentModel.DataAnnotations.MinLengthAttribute": - if (attr.ConstructorArguments.Length >= 1) - { - if (isCollection) - builder.Add(new CollectionCountConstraint((int?)attr.ConstructorArguments[0].Value, null)); - else - builder.Add(new StringLengthConstraint((int?)attr.ConstructorArguments[0].Value, null)); - } - break; - case "global::System.ComponentModel.DataAnnotations.MaxLengthAttribute": - if (attr.ConstructorArguments.Length >= 1) - { - if (isCollection) - builder.Add(new CollectionCountConstraint(null, (int?)attr.ConstructorArguments[0].Value)); - else - builder.Add(new StringLengthConstraint(null, (int?)attr.ConstructorArguments[0].Value)); - } - break; - case "global::System.ComponentModel.DataAnnotations.LengthAttribute": - if (attr.ConstructorArguments.Length >= 2) - { - if (isCollection) - builder.Add(new CollectionCountConstraint((int?)attr.ConstructorArguments[0].Value, (int?)attr.ConstructorArguments[1].Value)); - else - builder.Add(new StringLengthConstraint((int?)attr.ConstructorArguments[0].Value, (int?)attr.ConstructorArguments[1].Value)); - } - break; - case "global::System.ComponentModel.DataAnnotations.RegularExpressionAttribute": - if (attr.ConstructorArguments.Length >= 1 && attr.ConstructorArguments[0].Value is string pat) - builder.Add(new RegexConstraint(pat)); - break; - case "global::System.ComponentModel.DataAnnotations.AllowedValuesAttribute": - if (attr.ConstructorArguments.Length >= 1 && attr.ConstructorArguments[0].Kind == TypedConstantKind.Array) - { - var vals = attr.ConstructorArguments[0].Values.Select(v => v.ToCSharpString()).ToImmutableArray(); - if (!vals.IsEmpty) builder.Add(new AllowedValuesConstraint(vals)); - } - break; - case "global::System.ComponentModel.DataAnnotations.DeniedValuesAttribute": - if (attr.ConstructorArguments.Length >= 1 && attr.ConstructorArguments[0].Kind == TypedConstantKind.Array) - { - var vals = attr.ConstructorArguments[0].Values.Select(v => v.ToCSharpString()).ToImmutableArray(); - if (!vals.IsEmpty) builder.Add(new DeniedValuesConstraint(vals)); - } - break; - case "global::System.ComponentModel.DataAnnotations.EmailAddressAttribute": - builder.Add(new EmailConstraint()); - break; - case "global::System.ComponentModel.DataAnnotations.UrlAttribute": - if (scalarKind == CliScalarKind.Uri) - builder.Add(new UriSchemeConstraint(ImmutableArray.Create("http", "https"))); - else - builder.Add(new UrlConstraint()); - break; - case "global::System.ComponentModel.DataAnnotations.FileExtensionsAttribute": - { - if (fsKind != CliScalarKind.FileInfo) - break; - string? extsStr = null; - foreach (var n in attr.NamedArguments) - if (n.Key == "Extensions") extsStr = n.Value.Value as string; - extsStr ??= "png,jpg,jpeg,gif"; - var exts = extsStr.Split(',').Select(e => e.Trim().TrimStart('.')).ToImmutableArray(); - builder.Add(new FileExtensionsConstraint(exts)); - break; - } - case "global::Nullean.Argh.UriSchemeAttribute": - if (attr.ConstructorArguments.Length >= 1 && attr.ConstructorArguments[0].Kind == TypedConstantKind.Array) - { - var schemes = attr.ConstructorArguments[0].Values - .Select(v => v.Value as string).Where(s => s is not null).Select(s => s!) - .ToImmutableArray(); - if (!schemes.IsEmpty) builder.Add(new UriSchemeConstraint(schemes)); - } - break; - case "global::Nullean.Argh.ExistingAttribute": - if (fsKind is CliScalarKind.FileInfo or CliScalarKind.DirectoryInfo) - builder.Add(new ExistingPathConstraint()); - break; - case "global::Nullean.Argh.NonExistingAttribute": - if (fsKind is CliScalarKind.FileInfo or CliScalarKind.DirectoryInfo) - builder.Add(new NonExistingPathConstraint()); - break; - case "global::Nullean.Argh.RejectSymbolicLinksAttribute": - if (fsKind is CliScalarKind.FileInfo or CliScalarKind.DirectoryInfo) - builder.Add(new RejectSymbolicLinksConstraint()); - break; - } - } - return OrderPathValidations(builder.ToImmutable()); - } - - private static ImmutableArray OrderPathValidations(ImmutableArray validations) - { - if (validations.IsDefaultOrEmpty) - return validations; - - var hasReject = false; - foreach (var c in validations) - { - if (c is RejectSymbolicLinksConstraint) - { - hasReject = true; - break; - } - } - - if (!hasReject) - return validations; - - var b = ImmutableArray.CreateBuilder(validations.Length); - foreach (var c in validations) - { - if (c is RejectSymbolicLinksConstraint) - b.Add(c); - } - - foreach (var c in validations) - { - if (c is not RejectSymbolicLinksConstraint) - b.Add(c); - } - - return b.ToImmutable(); - } - - private static string ResolveEnumMemberCliName(ImmutableArray cliNames, int index, string memberName) - => !cliNames.IsDefaultOrEmpty ? cliNames[index] : memberName.ToLowerInvariant(); - - private static string? BuildValidationLine(ParameterModel p) - { - var tokens = new List(); - - if (p.ScalarKind == CliScalarKind.Enum && !p.EnumMemberNames.IsDefaultOrEmpty) - { - tokens.Add("One of: <" + string.Join("|", p.EnumMemberNames.Select((m, i) => ResolveEnumMemberCliName(p.EnumMemberCliNames, i, m))) + ">"); - if (p.EnumMemberDocs is { Count: > 0 } docs) - { - var memberDescParts = new List(); - for (var i = 0; i < p.EnumMemberNames.Length; i++) - { - var member = p.EnumMemberNames[i]; - var cliName = ResolveEnumMemberCliName(p.EnumMemberCliNames, i, member); - if (docs.TryGetValue(member, out var memberDoc) && !string.IsNullOrWhiteSpace(memberDoc)) - memberDescParts.Add($"{cliName}: {memberDoc.Trim()}"); - } - if (memberDescParts.Count > 0) - tokens.Add("(" + string.Join("; ", memberDescParts) + ")"); - } - } - - if (p.IsCollection && p.ElementScalarKind == CliScalarKind.Enum && !p.ElementEnumMemberNames.IsDefaultOrEmpty) - { - var label = p.CollectionTargetIsReadOnlySet ? "Combination of:" : "One or more of:"; - tokens.Add(label + " <" + string.Join("|", p.ElementEnumMemberNames.Select((m, i) => ResolveEnumMemberCliName(p.ElementEnumMemberCliNames, i, m))) + ">"); - if (p.ElementEnumMemberDocs is { Count: > 0 } elemDocs) - { - var memberDescParts = new List(); - for (var i = 0; i < p.ElementEnumMemberNames.Length; i++) - { - var member = p.ElementEnumMemberNames[i]; - var cliName = ResolveEnumMemberCliName(p.ElementEnumMemberCliNames, i, member); - if (elemDocs.TryGetValue(member, out var memberDoc) && !string.IsNullOrWhiteSpace(memberDoc)) - memberDescParts.Add($"{cliName}: {memberDoc.Trim()}"); - } - if (memberDescParts.Count > 0) - tokens.Add("(" + string.Join("; ", memberDescParts) + ")"); - } - } - - if (!p.Validations.IsDefaultOrEmpty) - { - foreach (var v in p.Validations) - { - switch (v) - { - case RangeConstraint r: - tokens.Add($"[range: {r.MinLiteral.Trim('"')}–{r.MaxLiteral.Trim('"')}]"); - break; - case CollectionCountConstraint cc when cc.Min.HasValue && cc.Max.HasValue: - tokens.Add($"[count: {cc.Min}–{cc.Max}]"); - break; - case CollectionCountConstraint cc when cc.Min.HasValue: - tokens.Add($"[min-count: {cc.Min}]"); - break; - case CollectionCountConstraint cc when cc.Max.HasValue: - tokens.Add($"[max-count: {cc.Max}]"); - break; - case StringLengthConstraint s when s.Min.HasValue && s.Max.HasValue: - tokens.Add($"[length: {s.Min}–{s.Max}]"); - break; - case StringLengthConstraint s when s.Min.HasValue: - tokens.Add($"[min-length: {s.Min}]"); - break; - case StringLengthConstraint s when s.Max.HasValue: - tokens.Add($"[max-length: {s.Max}]"); - break; - case RegexConstraint rx: - tokens.Add($"[pattern: {rx.Pattern}]"); - break; - case AllowedValuesConstraint av: - tokens.Add("[allowed: " + string.Join("|", av.Values.Select(val => val.Trim('"'))) + "]"); - break; - case DeniedValuesConstraint dv: - tokens.Add("[denied: " + string.Join("|", dv.Values.Select(val => val.Trim('"'))) + "]"); - break; - case EmailConstraint: - tokens.Add("[email]"); - break; - case UrlConstraint: - tokens.Add("[url]"); - break; - case UriSchemeConstraint us: - tokens.Add("[schemes: " + string.Join("|", us.Schemes) + "]"); - break; - case FileExtensionsConstraint fe: - tokens.Add("[extensions: " + string.Join("|", fe.Extensions) + "]"); - break; - case ExistingPathConstraint: - tokens.Add("[existing]"); - break; - case NonExistingPathConstraint: - tokens.Add("[unused path]"); - break; - case RejectSymbolicLinksConstraint: - tokens.Add("[no symlinks]"); - break; - case TimeSpanRangeConstraint ts: - tokens.Add($"[time-span-range: {ts.MinLiteral.Trim('"')}–{ts.MaxLiteral.Trim('"')}]"); - break; - } - } - } - - if (p.ExpandUserProfileBeforeBind) - tokens.Add("[expand ~ profile]"); - - return tokens.Count > 0 ? string.Join(" ", tokens) : null; - } - - private static bool HelpUsesEnumChoiceContinuationLayout(ParameterModel p) => - p.ScalarKind == CliScalarKind.Enum - || (p.IsCollection && p.ElementScalarKind == CliScalarKind.Enum && !p.ElementEnumMemberNames.IsDefaultOrEmpty); - - private static void EmitHelpOptionRows(StringBuilder sb, IReadOnlyList rows, int maxOptWidth) - { - var continuationIndent = new string(' ', maxOptWidth + 4); - foreach (var p in rows) - { - var left = HelpLayout.FormatOptionLeftCell(p).PadRight(maxOptWidth); - var desc = BuildDescriptionSuffix(p, forPositional: false); - var validationLine = BuildValidationLine(p); - var validationOnNewLine = validationLine != null && HelpUsesEnumChoiceContinuationLayout(p); - - if (validationLine is null) - { - sb.AppendLine($"\t\t\tConsole.Out.WriteLine($\" {{CliHelpFormatting.Accent(\"{Escape(left)}\")}} {EscapeForHelpInterpolation(desc)}\");"); - } - else if (validationOnNewLine) - { - sb.AppendLine($"\t\t\tConsole.Out.WriteLine($\" {{CliHelpFormatting.Accent(\"{Escape(left)}\")}} {EscapeForHelpInterpolation(desc)}\");"); - sb.AppendLine($"\t\t\tConsole.Out.WriteLine($\"{continuationIndent}{{CliHelpFormatting.DocRemarksLine(\"{Escape(validationLine)}\")}}\");"); - } - else if (string.IsNullOrEmpty(desc)) - { - sb.AppendLine($"\t\t\tConsole.Out.WriteLine($\" {{CliHelpFormatting.Accent(\"{Escape(left)}\")}} {{CliHelpFormatting.DocRemarksLine(\"{Escape(validationLine)}\")}}\");"); - } - else - { - sb.AppendLine($"\t\t\tConsole.Out.WriteLine($\" {{CliHelpFormatting.Accent(\"{Escape(left)}\")}} {EscapeForHelpInterpolation(desc)} {{CliHelpFormatting.DocRemarksLine(\"{Escape(validationLine)}\")}}\");"); - } - } - } - - /// Same layout as but to stderr (parse errors). - private static void EmitHelpOptionRowsStdErr(StringBuilder sb, ParameterModel p, int maxOptWidth, string lineIndent) - { - var continuationIndent = new string(' ', maxOptWidth + 4); - var left = HelpLayout.FormatOptionLeftCell(p).PadRight(maxOptWidth); - var desc = BuildDescriptionSuffix(p, forPositional: false); - var validationLine = BuildValidationLine(p); - var validationOnNewLine = validationLine != null && HelpUsesEnumChoiceContinuationLayout(p); - - if (validationLine is null) - { - sb.AppendLine( - $"{lineIndent}Console.Error.WriteLine($\" {{CliHelpFormatting.Accent(\"{Escape(left)}\")}} {EscapeForHelpInterpolation(desc)}\");"); - } - else if (validationOnNewLine) - { - sb.AppendLine( - $"{lineIndent}Console.Error.WriteLine($\" {{CliHelpFormatting.Accent(\"{Escape(left)}\")}} {EscapeForHelpInterpolation(desc)}\");"); - sb.AppendLine( - $"{lineIndent}Console.Error.WriteLine($\"{continuationIndent}{{CliHelpFormatting.DocRemarksLine(\"{Escape(validationLine)}\")}}\");"); - } - else if (string.IsNullOrEmpty(desc)) - { - sb.AppendLine( - $"{lineIndent}Console.Error.WriteLine($\" {{CliHelpFormatting.Accent(\"{Escape(left)}\")}} {{CliHelpFormatting.DocRemarksLine(\"{Escape(validationLine)}\")}}\");"); - } - else - { - sb.AppendLine( - $"{lineIndent}Console.Error.WriteLine($\" {{CliHelpFormatting.Accent(\"{Escape(left)}\")}} {EscapeForHelpInterpolation(desc)} {{CliHelpFormatting.DocRemarksLine(\"{Escape(validationLine)}\")}}\");"); - } - } - - private static void EmitOptionsTryParseFlagHelpPrinter( - StringBuilder sb, - string parseMethodName, - List flagMembers, - int maxOptWidth) - { - if (flagMembers.Count == 0) - return; - - var methodName = parseMethodName + "_FlagHelp_ToStdErr"; - sb.AppendLine($"\t\tprivate static void {methodName}(string canonFlagName)"); - sb.AppendLine("\t\t{"); - sb.AppendLine("\t\t\tswitch (canonFlagName)"); - sb.AppendLine("\t\t\t{"); - foreach (var p in flagMembers.OrderBy(static x => x.CliLongName, StringComparer.OrdinalIgnoreCase)) - { - sb.AppendLine($"\t\t\t\tcase \"{Escape(p.CliLongName)}\":"); - EmitHelpOptionRowsStdErr(sb, p, maxOptWidth, "\t\t\t\t\t"); - sb.AppendLine("\t\t\t\t\tbreak;"); - } - - sb.AppendLine("\t\t\t\tdefault:"); - sb.AppendLine("\t\t\t\t\tbreak;"); - sb.AppendLine("\t\t\t}"); - sb.AppendLine("\t\t}"); - sb.AppendLine(); - } - - /// After a CLI parse/validation error on stderr: optional flag rows (matching --help), then optional run hint. - private static void EmitAfterCliParseErrorHelp( - StringBuilder sb, - ParameterModel p, - string lineIndent, - string? helpMethodName, - string? flagHelpStdErrMethodName, - string? parseFailureRunHint) - { - if (p.Kind == ParameterKind.Flag && flagHelpStdErrMethodName is not null) - { - sb.AppendLine($"{lineIndent}Console.Error.WriteLine();"); - sb.AppendLine($"{lineIndent}{flagHelpStdErrMethodName}(\"{Escape(p.CliLongName)}\");"); - sb.AppendLine($"{lineIndent}Console.Error.WriteLine();"); - if (parseFailureRunHint is not null) - sb.AppendLine($"{lineIndent}Console.Error.WriteLine(\"{parseFailureRunHint}\");"); - } - else if (helpMethodName is not null) - sb.AppendLine($"{lineIndent}{helpMethodName}();"); - } - - /// After a validation-check error: optional flag rows on stderr, then optional run hint. - private static void EmitValidationErrorFooter( - StringBuilder sb, - ParameterModel p, - string cliName, - string indent, - string? flagHelpStdErrMethodName, - string? runHint) - { - if (p.Kind == ParameterKind.Flag && flagHelpStdErrMethodName is not null) - { - sb.AppendLine($"{indent}Console.Error.WriteLine();"); - sb.AppendLine($"{indent}{flagHelpStdErrMethodName}(\"{Escape(cliName)}\");"); - sb.AppendLine($"{indent}Console.Error.WriteLine();"); - } - - if (runHint is not null) - sb.AppendLine($"{indent}Console.Error.WriteLine(\"{runHint}\");"); - } - - private static void EmitCommandHelpPrinter(StringBuilder sb, CommandModel cmd, AppEmitModel app, string entryAssemblyName) - { - if (cmd.IsRootDefault) - return; - - var routeUsage = cmd.RoutePrefix.IsDefaultOrEmpty - ? "" - : string.Join(" ", cmd.RoutePrefix) + " "; - - var globalFlagMembers = EnumerateFlagMembers(app.GlobalOptionsModel).ToList(); - List<(string Segment, List Rows)> namespaceOptionSections = new(); - var namespaceOptionChain = GetCommandNamespaceOptionChain(app, cmd.RoutePrefix); - var suppressedForNamespaceDisplay = new HashSet(StringComparer.OrdinalIgnoreCase); - AddCliKeys(globalFlagMembers, suppressedForNamespaceDisplay); - foreach ((var seg, var gom) in namespaceOptionChain) - { - var allInNamespace = EnumerateFlagMembers(gom).ToList(); - var rows = allInNamespace.Where(p => !suppressedForNamespaceDisplay.Contains(p.CliLongName)).ToList(); - AddCliKeys(allInNamespace, suppressedForNamespaceDisplay); - if (rows.Count > 0) - namespaceOptionSections.Add((seg, rows)); - } - - var scopedKeys = new HashSet(StringComparer.OrdinalIgnoreCase); - AddCliKeys(globalFlagMembers, scopedKeys); - foreach ((_, var gom) in namespaceOptionChain) - AddCliKeys(EnumerateFlagMembers(gom), scopedKeys); - - var commandOnlyFlags = cmd.Parameters - .Where(p => p.Kind == ParameterKind.Flag && !CommandFlagMatchesScopedKeys(p, scopedKeys)) - .ToList(); - - var widthCandidates = new List { "-h, --help".Length }; - widthCandidates.AddRange(globalFlagMembers.Select(p => HelpLayout.FormatOptionLeftCell(p).Length)); - foreach ((_, var rows) in namespaceOptionSections) - widthCandidates.AddRange(rows.Select(p => HelpLayout.FormatOptionLeftCell(p).Length)); - - widthCandidates.AddRange(commandOnlyFlags.Select(p => HelpLayout.FormatOptionLeftCell(p).Length)); - var maxOptWidth = Math.Min(widthCandidates.Max(), 40); - maxOptWidth = Math.Max(maxOptWidth, "-h, --help".Length); - - sb.AppendLine($"\t\tprivate static void PrintHelp_{cmd.RunMethodName}()"); - sb.AppendLine("\t\t{"); - sb.AppendLine( - $"\t\t\tConsole.Out.WriteLine(CliHelpFormatting.Section(\"Usage: \") + CliHelpFormatting.Accent(\"{Escape(entryAssemblyName)}\") + \" {Escape(routeUsage)}{Escape(cmd.CommandName)} {Escape(cmd.UsageHints)}\");"); - - sb.AppendLine("\t\t\tConsole.Out.WriteLine();"); - - EmitCommandHelpDocPrologue(sb, "\t\t\t", cmd.SummaryInnerXml, cmd.SummaryOneLiner, false); - if (!string.IsNullOrWhiteSpace(cmd.SummaryOneLiner) || !string.IsNullOrWhiteSpace(cmd.SummaryInnerXml)) - sb.AppendLine("\t\t\tConsole.Out.WriteLine();"); - - var hasArgs = false; - foreach (var p in cmd.Parameters) - { - if (p.Kind == ParameterKind.Positional) - hasArgs = true; - } - - if (hasArgs) - { - var maxArgWidth = cmd.Parameters - .Where(p => p.Kind == ParameterKind.Positional) - .Select(p => - { - if (p.IsVariadic) - return (p.IsRequired ? $"<{p.CliLongName}...>" : $"[<{p.CliLongName}...>]").Length; - return (p.IsRequired ? $"<{p.CliLongName}>" : $"[<{p.CliLongName}>]").Length; - }) - .DefaultIfEmpty(0).Max(); - maxArgWidth = Math.Min(maxArgWidth, 40); - - sb.AppendLine("\t\t\tConsole.Out.WriteLine(CliHelpFormatting.Section(\"Arguments:\"));"); - foreach (var p in cmd.Parameters) - { - if (p.Kind != ParameterKind.Positional) - continue; - - var nameCell = p.IsVariadic - ? (p.IsRequired ? $"<{p.CliLongName}...>" : $"[<{p.CliLongName}...>]") - : (p.IsRequired ? $"<{p.CliLongName}>" : $"[<{p.CliLongName}>]"); - var nameCellPadded = nameCell.PadRight(maxArgWidth); - var desc = BuildDescriptionSuffix(p, forPositional: true); - var argValidationLine = BuildValidationLine(p); - if (argValidationLine is null) - { - sb.AppendLine($"\t\t\tConsole.Out.WriteLine($\" {{CliHelpFormatting.Placeholder(\"{Escape(nameCellPadded)}\")}} {EscapeForHelpInterpolation(desc)}\");"); - } - else if (string.IsNullOrEmpty(desc)) - { - sb.AppendLine($"\t\t\tConsole.Out.WriteLine($\" {{CliHelpFormatting.Placeholder(\"{Escape(nameCellPadded)}\")}} {{CliHelpFormatting.DocRemarksLine(\"{Escape(argValidationLine)}\")}}\");"); - } - else - { - sb.AppendLine($"\t\t\tConsole.Out.WriteLine($\" {{CliHelpFormatting.Placeholder(\"{Escape(nameCellPadded)}\")}} {EscapeForHelpInterpolation(desc)} {{CliHelpFormatting.DocRemarksLine(\"{Escape(argValidationLine)}\")}}\");"); - } - } - - sb.AppendLine("\t\t\tConsole.Out.WriteLine();"); - } - - sb.AppendLine("\t\t\tConsole.Out.WriteLine(CliHelpFormatting.Section(\"Global options:\"));"); - sb.AppendLine( - $"\t\t\tConsole.Out.WriteLine(\" \" + CliHelpFormatting.Placeholder(\"{Escape("-h, --help".PadRight(maxOptWidth))}\") + \" Show help.\");"); - if (globalFlagMembers.Count > 0) - EmitHelpOptionRows(sb, globalFlagMembers, maxOptWidth); - - sb.AppendLine("\t\t\tConsole.Out.WriteLine();"); - - foreach ((var segment, var gRows) in namespaceOptionSections) - { - sb.AppendLine($"\t\t\tConsole.Out.WriteLine(CliHelpFormatting.Section(\"'{Escape(segment)}' options:\"));"); - EmitHelpOptionRows(sb, gRows, maxOptWidth); - sb.AppendLine("\t\t\tConsole.Out.WriteLine();"); - } - - if (commandOnlyFlags.Count > 0) - { - sb.AppendLine("\t\t\tConsole.Out.WriteLine(CliHelpFormatting.Section(\"Options:\"));"); - EmitHelpOptionRows(sb, commandOnlyFlags, maxOptWidth); - } - - var remarksXml = TransformRemarksInnerXmlForHelp(cmd.RemarksInnerXml, cmd, app.AllCommands, entryAssemblyName); - var hasRemarks = !string.IsNullOrWhiteSpace(cmd.RemarksRendered) || !string.IsNullOrWhiteSpace(remarksXml); - if (hasRemarks) - { - sb.AppendLine("\t\t\tConsole.Out.WriteLine();"); - EmitNotesSection(sb, "\t\t\t", remarksXml, cmd.RemarksRendered); - } - - if (!string.IsNullOrWhiteSpace(cmd.ExamplesRendered)) - { - sb.AppendLine("\t\t\tConsole.Out.WriteLine();"); - sb.AppendLine("\t\t\tConsole.Out.WriteLine(CliHelpFormatting.Section(\"Examples:\"));"); - foreach (var line in cmd.ExamplesRendered.Split('\n')) - { - var trimmed = line.TrimEnd('\r'); - if (trimmed.Length == 0) - sb.AppendLine("\t\t\tConsole.Out.WriteLine();"); - else - sb.AppendLine($"\t\t\tConsole.Out.WriteLine(\" {Escape(trimmed)}\");"); - } - } - - sb.AppendLine("\t\t}"); - sb.AppendLine(); - } - - private static void EmitCommandFlagHelpToStdErrMethod(StringBuilder sb, CommandModel cmd, AppEmitModel app) - { - if (cmd.IsRootDefault) - return; - - var globalFlagMembers = EnumerateFlagMembers(app.GlobalOptionsModel).ToList(); - List<(string Segment, List Rows)> namespaceOptionSections = new(); - var namespaceOptionChain = GetCommandNamespaceOptionChain(app, cmd.RoutePrefix); - var suppressedForNamespaceDisplay = new HashSet(StringComparer.OrdinalIgnoreCase); - AddCliKeys(globalFlagMembers, suppressedForNamespaceDisplay); - foreach ((var seg, var gom) in namespaceOptionChain) - { - var allInNamespace = EnumerateFlagMembers(gom).ToList(); - var rows = allInNamespace.Where(p => !suppressedForNamespaceDisplay.Contains(p.CliLongName)).ToList(); - AddCliKeys(allInNamespace, suppressedForNamespaceDisplay); - if (rows.Count > 0) - namespaceOptionSections.Add((seg, rows)); - } - - var scopedKeys = new HashSet(StringComparer.OrdinalIgnoreCase); - AddCliKeys(globalFlagMembers, scopedKeys); - foreach ((_, var gom) in namespaceOptionChain) - AddCliKeys(EnumerateFlagMembers(gom), scopedKeys); - - var commandOnlyFlags = cmd.Parameters - .Where(p => p.Kind == ParameterKind.Flag && !CommandFlagMatchesScopedKeys(p, scopedKeys)) - .ToList(); - - var widthCandidates = new List { "-h, --help".Length }; - widthCandidates.AddRange(globalFlagMembers.Select(p => HelpLayout.FormatOptionLeftCell(p).Length)); - foreach ((_, var rows) in namespaceOptionSections) - widthCandidates.AddRange(rows.Select(p => HelpLayout.FormatOptionLeftCell(p).Length)); - - widthCandidates.AddRange(commandOnlyFlags.Select(p => HelpLayout.FormatOptionLeftCell(p).Length)); - var maxOptWidth = Math.Min(widthCandidates.Max(), 40); - maxOptWidth = Math.Max(maxOptWidth, "-h, --help".Length); - - var byCanon = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var p in globalFlagMembers) - byCanon[p.CliLongName] = p; - foreach ((_, var rows) in namespaceOptionSections) - foreach (var p in rows) - byCanon[p.CliLongName] = p; - foreach (var p in commandOnlyFlags) - byCanon[p.CliLongName] = p; - - sb.AppendLine($"\t\tprivate static void PrintHelp_{cmd.RunMethodName}_Flag_ToStdErr(string canonFlagName)"); - sb.AppendLine("\t\t{"); - if (byCanon.Count > 0) - { - sb.AppendLine("\t\t\tswitch (canonFlagName)"); - sb.AppendLine("\t\t\t{"); - foreach (var p in byCanon.Values.OrderBy(static x => x.CliLongName, StringComparer.OrdinalIgnoreCase)) - { - sb.AppendLine($"\t\t\t\tcase \"{Escape(p.CliLongName)}\":"); - EmitHelpOptionRowsStdErr(sb, p, maxOptWidth, "\t\t\t\t\t"); - sb.AppendLine("\t\t\t\t\tbreak;"); - } - - sb.AppendLine("\t\t\t\tdefault:"); - sb.AppendLine("\t\t\t\t\tbreak;"); - sb.AppendLine("\t\t\t}"); - } - - sb.AppendLine("\t\t}"); - sb.AppendLine(); - } - - private static string BuildDescriptionSuffix(ParameterModel p, bool forPositional) - { - var parts = new List(); - - if (!forPositional && p.Kind == ParameterKind.Flag && p.Special == BoolSpecialKind.None && p.IsRequired) - parts.Add("[required]"); - - if (!forPositional && p is { IsCollection: true, Kind: ParameterKind.Flag }) - parts.Add(p.CollectionSeparator is null ? "[repeatable]" : "[separated]"); - - if (forPositional && p.IsVariadic) - parts.Add("[variadic]"); - - if (!string.IsNullOrWhiteSpace(p.Description)) - parts.Add(p.Description.Trim()); - - if (p.Special == BoolSpecialKind.None) - { - if (p.DefaultValueLiteral is not null) - parts.Add($"[default: {FormatDefaultForHelp(p)}]"); - } - - return string.Join(" ", parts.Where(s => !string.IsNullOrWhiteSpace(s))); - } - - private static string FormatDefaultForHelp(ParameterModel p) - { - if (p.DefaultValueLiteral is null) - return ""; - - if (p.ScalarKind == CliScalarKind.Enum && !p.EnumMemberNames.IsDefaultOrEmpty) - { - var lit = p.DefaultValueLiteral.Trim(); - for (var i = 0; i < p.EnumMemberNames.Length; i++) - { - var member = p.EnumMemberNames[i]; - if (string.Equals(lit, member, StringComparison.Ordinal) || lit.EndsWith("." + member, StringComparison.Ordinal)) - return ResolveEnumMemberCliName(p.EnumMemberCliNames, i, member); - } - } - - return p.TypeName switch - { - "string" => p.DefaultValueLiteral.Trim('"'), - _ => p.DefaultValueLiteral - }; - } - - /// - /// Emits: if (string.Equals({varName}, "{value}", StringComparison.OrdinalIgnoreCase)) { {body} } - /// - private static void EmitOrdinalIgnoreCaseIf( - StringBuilder sb, - string indent, - string varName, - string value, - Action body) - { - sb.AppendLine($"{indent}if (string.Equals({varName}, \"{Escape(value)}\", StringComparison.OrdinalIgnoreCase))"); - sb.AppendLine($"{indent}{{"); - body(sb); - sb.AppendLine($"{indent}}}"); - } - - private static string Escape(string s) => s.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\r", "\\r").Replace("\n", "\\n"); - - /// Doubles { and } so text can be embedded in generated C# $"…" without forming interpolation holes. - private static string EscapeInterpolationBraces(string s) => - s.Replace("{", "{{").Replace("}", "}}"); - - private static string EscapeForHelpInterpolation(string s) => EscapeInterpolationBraces(Escape(s)); - - private static string EscapeDocXml(string s) => s.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\r", "\\r").Replace("\n", "\\n"); - - /// - /// Remarks XML only: <paramref name="x"/> for a CLI flag becomes <c>--long-name</c>; - /// <see cref="M:…"/> for another command handler becomes <c>entryAsm route cmd usage-hints</c> (same tail as the emitted Usage line after the assembly name). - /// - private static string? TransformRemarksInnerXmlForHelp( - string? innerXml, - CommandModel forCommand, - ImmutableArray allCommands, - string entryAssemblyName) - { - if (string.IsNullOrWhiteSpace(innerXml)) - return innerXml; - - var crefToCommand = new Dictionary(StringComparer.Ordinal); - foreach (var c in allCommands) - { - if (c.IsLambda || string.IsNullOrEmpty(c.HandlerDocCommentId)) - continue; - if (crefToCommand.ContainsKey(c.HandlerDocCommentId)) - continue; - crefToCommand[c.HandlerDocCommentId] = c; - } - - var flagBySymbol = new Dictionary(StringComparer.Ordinal); - foreach (var p in forCommand.Parameters) - { - if (p.Kind == ParameterKind.Flag) - flagBySymbol[p.SymbolName] = p; - } - - XElement root; - try - { - root = XElement.Parse("" + innerXml + "", LoadOptions.PreserveWhitespace); - } - catch - { - return innerXml; - } - - foreach (var e in root.Descendants().ToList()) - { - if (e.Name.LocalName == "paramref") - { - var nameAttr = e.Attribute("name")?.Value; - if (!string.IsNullOrEmpty(nameAttr) && - flagBySymbol.TryGetValue(nameAttr!, out var pm)) - e.ReplaceWith(new XElement("c", "--" + pm.CliLongName)); - continue; - } - - if (e.Name.LocalName != "see") - continue; - - if (e.Attribute("langword") is not null || e.Attribute("href") is not null) - continue; - - var crefAttr = e.Attribute("cref")?.Value; - if (string.IsNullOrEmpty(crefAttr)) - continue; - - CommandModel? cmd = null; - if (crefToCommand.TryGetValue(crefAttr!, out var byId)) - cmd = byId; - else - { - foreach (var c in allCommands) - { - if (c.IsLambda || string.IsNullOrEmpty(c.HandlerDocCommentId)) - continue; - if (!DocumentationCrefMatchesDocId(crefAttr!, c.HandlerDocCommentId)) - continue; - cmd = c; - break; - } - } - - if (cmd is not null) - e.ReplaceWith(new XElement("c", BuildCommandUsageSynopsisTail(cmd, entryAssemblyName))); - } - - return string.Concat(root.Nodes().Select(n => n.ToString())); - } - - private static string BuildCommandUsageSynopsisTail(CommandModel cmd, string entryAssemblyName) - { - var routeUsage = cmd.RoutePrefix.IsDefaultOrEmpty - ? "" - : string.Join(" ", cmd.RoutePrefix) + " "; - return $"{entryAssemblyName} {routeUsage}{cmd.CommandName} {cmd.UsageHints}".TrimEnd(); - } - - /// - /// vs XML cref: compiler XML may use the full M:… id or a short form (e.g. Type.Method). - /// - private static bool DocumentationCrefMatchesMethod(string cref, IMethodSymbol method) - { - if (string.IsNullOrEmpty(cref)) - return false; - - cref = cref.Replace("global::", ""); - - if (method.GetDocumentationCommentId() is not { Length: > 0 } fullId) - return false; - - return DocumentationCrefMatchesDocId(cref, fullId); - } - - /// String-based version of that takes the pre-extracted doc comment id. - private static bool DocumentationCrefMatchesDocId(string cref, string fullId) - { - if (string.IsNullOrEmpty(cref) || string.IsNullOrEmpty(fullId)) - return false; - - cref = cref.Replace("global::", ""); - fullId = fullId.Replace("global::", ""); - - if (string.Equals(cref, fullId, StringComparison.Ordinal)) - return true; - - if (!fullId.StartsWith("M:", StringComparison.Ordinal) || fullId.Length < 3) - return false; - - var sigParen = fullId.IndexOf('(', 2); - var qualifiedMember = sigParen >= 2 ? fullId.Substring(2, sigParen - 2) : fullId.Substring(2); - - if (string.Equals(cref, qualifiedMember, StringComparison.Ordinal)) - return true; - - // e.g. cref "CliRegistrationModule.DocLambdaEcho" or "Demo" for "…DocsCommands.Demo(…)". - if (qualifiedMember.EndsWith(cref, StringComparison.Ordinal)) - return true; - - return false; - } - - private sealed record CommandModel( - ImmutableArray RoutePrefix, - string CommandName, - string RunMethodName, - string ContainingTypeFq, - string MethodName, - bool RequiresInstance, - bool ContainingTypeHasParameterlessCtor, - string ReturnTypeFq, - bool ReturnIsAsync, - bool ReturnIsVoid, - ImmutableArray Parameters, - bool HandlerHasNoOptionsInjection, - ImmutableArray HandlerParamTypes, - SourceSpanInfo HandlerSpanInfo, - ImmutableArray<(string Name, string TypeMetadataName)> ContainingTypeCtorParams, - string HandlerDocCommentId, - string SummaryOneLiner, - string RemarksRendered, - string SummaryInnerXml, - string RemarksInnerXml, - string ExamplesRendered, - string UsageHints, - ImmutableArray<(string Fq, bool HasParameterlessCtor)> CommandMiddlewareData, - bool IsRootDefault = false, - bool IsLambda = false, - string LambdaStorageKey = "", - string LambdaDelegateFq = "", - bool IsIntrinsic = false, - ImmutableArray CommandAliases = default, - bool IsHidden = false, - bool IsDeprecated = false, - string? DeprecationMessage = null, - CommandIntentData? Intent = null, - CommandOutputData? Output = null) - { - public static CommandModel FromRootMethod( - IMethodSymbol method, - CSharpParseOptions parseOptions, - ImmutableArray routePrefix, - SourceProductionContext context, - Location diagnosticLocation, - Compilation? compilation = null) - { - var parameters = BuildParameterModels(method, parseOptions, context, diagnosticLocation, compilation); - ReportDuplicateCliNames(context, diagnosticLocation, parameters); - ReportBoolNegationSwitchConflicts(context, diagnosticLocation, parameters, method); - ValidateExpandedParameterLayout(context, diagnosticLocation, parameters); - ValidateVariadicPositionalIsLast(context, diagnosticLocation, parameters); - foreach (var p in parameters) - { - if (p.IsCollection && p.Kind == ParameterKind.Positional && !p.IsVariadic) - context.ReportDiagnostic(Diagnostic.Create(CollectionPositionalNotSupported, diagnosticLocation)); - if (p.IsVariadic && !p.CollectionTargetIsArray) - context.ReportDiagnostic(Diagnostic.Create(VariadicCollectionMustBeArray, diagnosticLocation)); - if (p.CollectionTargetIsReadOnlySet && !p.ElementIsValueType) - context.ReportDiagnostic(Diagnostic.Create(ReadOnlySetInvalidElementType, diagnosticLocation, p.ElementTypeName)); - } - - var docs = MergeMethodDocumentationFromTrivia( - method, - Documentation.ParseMethod(method.GetDocumentationCommentXml(), parseOptions), - parseOptions); - - var withDocs = ApplyParamDocumentation(parameters, method, docs.ParamDocsRaw); - withDocs = ApplyCollectionSeparatorsFromDocumentation(withDocs, method, docs.ParamSeparators); - var usage = UsageSynopsis.Build(withDocs); - var runName = BuildRootDefaultRunMethodName(routePrefix); - var containingFq = method.ContainingType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - var hasParamlessCtor = method.ContainingType is INamedTypeSymbol namedCt && - HasPublicParameterlessCtor(namedCt); - var (retFq, retIsAsync, retIsVoid, handlerNoInj, handlerParams, handlerLoc, ctorParams, mwData, docId) = - ExtractHandlerAnalysis(method); - return new CommandModel( - routePrefix, - RootDefaultInternalCommandName, - runName, - containingFq, - method.Name, - !method.IsStatic, - hasParamlessCtor, - retFq, - retIsAsync, - retIsVoid, - withDocs, - handlerNoInj, - handlerParams, - handlerLoc, - ctorParams, - docId, - docs.SummaryOneLiner, - docs.RemarksRendered, - docs.SummaryInnerXml, - docs.RemarksInnerXml, - docs.ExamplesRendered, - usage, - mwData, - IsRootDefault: true); - } - - public static CommandModel FromMethod( - string commandName, - IMethodSymbol method, - CSharpParseOptions parseOptions, - ImmutableArray routePrefix, - SourceProductionContext context, - Location diagnosticLocation, - Compilation? compilation = null) - { - var parameters = BuildParameterModels(method, parseOptions, context, diagnosticLocation, compilation); - ReportDuplicateCliNames(context, diagnosticLocation, parameters); - ReportBoolNegationSwitchConflicts(context, diagnosticLocation, parameters, method); - ValidateExpandedParameterLayout(context, diagnosticLocation, parameters); - ValidateVariadicPositionalIsLast(context, diagnosticLocation, parameters); - foreach (var p in parameters) - { - if (p.IsCollection && p.Kind == ParameterKind.Positional && !p.IsVariadic) - context.ReportDiagnostic(Diagnostic.Create(CollectionPositionalNotSupported, diagnosticLocation)); - if (p.IsVariadic && !p.CollectionTargetIsArray) - context.ReportDiagnostic(Diagnostic.Create(VariadicCollectionMustBeArray, diagnosticLocation)); - if (p.CollectionTargetIsReadOnlySet && !p.ElementIsValueType) - context.ReportDiagnostic(Diagnostic.Create(ReadOnlySetInvalidElementType, diagnosticLocation, p.ElementTypeName)); - } - - var docs = MergeMethodDocumentationFromTrivia( - method, - Documentation.ParseMethod(method.GetDocumentationCommentXml(), parseOptions), - parseOptions); - var withDocs = ApplyParamDocumentation(parameters, method, docs.ParamDocsRaw); - withDocs = ApplyCollectionSeparatorsFromDocumentation(withDocs, method, docs.ParamSeparators); - var usage = UsageSynopsis.Build(withDocs); - var runName = BuildRunMethodName(routePrefix, commandName); - var containingFq = method.ContainingType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - var hasParamlessCtor = method.ContainingType is INamedTypeSymbol namedCt && - HasPublicParameterlessCtor(namedCt); - var (retFq, retIsAsync, retIsVoid, handlerNoInj, handlerParams, handlerLoc, ctorParams, mwData, docId) = - ExtractHandlerAnalysis(method); - var (isObs, obsMsg) = TryGetObsoleteAttribute(method); - return new CommandModel( - routePrefix, - commandName, - runName, - containingFq, - method.Name, - !method.IsStatic, - hasParamlessCtor, - retFq, - retIsAsync, - retIsVoid, - withDocs, - handlerNoInj, - handlerParams, - handlerLoc, - ctorParams, - docId, - docs.SummaryOneLiner, - docs.RemarksRendered, - docs.SummaryInnerXml, - docs.RemarksInnerXml, - docs.ExamplesRendered, - usage, - mwData, - IsIntrinsic: HasCommandIntrinsicAttribute(method), - CommandAliases: TryGetCommandAliasesFromAttribute(method), - IsHidden: HasHiddenAttribute(method), - IsDeprecated: isObs, - DeprecationMessage: obsMsg, - Intent: TryGetCommandIntentData(method), - Output: BuildCommandOutputFromParameters(withDocs)); - } - - /// Overload for the per-invocation Select step — uses instead of SourceProductionContext. - public static CommandModel FromRootMethod( - IMethodSymbol method, - CSharpParseOptions parseOptions, - ImmutableArray routePrefix, - DiagnosticAccumulator acc, - Location diagnosticLocation, - Compilation? compilation = null) - { - var parameters = BuildParameterModels(method, parseOptions, acc, diagnosticLocation, compilation); - ReportDuplicateCliNamesAcc(acc, diagnosticLocation, parameters); - ReportBoolNegationSwitchConflictsAcc(acc, diagnosticLocation, parameters, method); - ValidateExpandedParameterLayoutAcc(acc, diagnosticLocation, parameters); - ValidateVariadicPositionalIsLastAcc(acc, diagnosticLocation, parameters); - foreach (var p in parameters) - { - if (p.IsCollection && p.Kind == ParameterKind.Positional && !p.IsVariadic) - acc.Add(CollectionPositionalNotSupported, diagnosticLocation); - if (p.IsVariadic && !p.CollectionTargetIsArray) - acc.Add(VariadicCollectionMustBeArray, diagnosticLocation); - if (p.CollectionTargetIsReadOnlySet && !p.ElementIsValueType) - acc.Add(ReadOnlySetInvalidElementType, diagnosticLocation, p.ElementTypeName); - } - var docs = MergeMethodDocumentationFromTrivia(method, Documentation.ParseMethod(method.GetDocumentationCommentXml(), parseOptions), parseOptions); - var withDocs = ApplyParamDocumentation(parameters, method, docs.ParamDocsRaw); - withDocs = ApplyCollectionSeparatorsFromDocumentation(withDocs, method, docs.ParamSeparators); - var usage = UsageSynopsis.Build(withDocs); - var runName = BuildRootDefaultRunMethodName(routePrefix); - var containingFq = method.ContainingType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - var hasParamlessCtor = method.ContainingType is INamedTypeSymbol namedCt && HasPublicParameterlessCtor(namedCt); - var (retFq, retIsAsync, retIsVoid, handlerNoInj, handlerParams, handlerLoc, ctorParams, mwData, docId) = ExtractHandlerAnalysis(method); - return new CommandModel(routePrefix, RootDefaultInternalCommandName, runName, containingFq, method.Name, !method.IsStatic, hasParamlessCtor, retFq, retIsAsync, retIsVoid, withDocs, handlerNoInj, handlerParams, handlerLoc, ctorParams, docId, docs.SummaryOneLiner, docs.RemarksRendered, docs.SummaryInnerXml, docs.RemarksInnerXml, docs.ExamplesRendered, usage, mwData, IsRootDefault: true); - } - - /// Overload for the per-invocation Select step — uses instead of SourceProductionContext. - public static CommandModel FromMethod( - string commandName, - IMethodSymbol method, - CSharpParseOptions parseOptions, - ImmutableArray routePrefix, - DiagnosticAccumulator acc, - Location diagnosticLocation, - Compilation? compilation = null) - { - var parameters = BuildParameterModels(method, parseOptions, acc, diagnosticLocation, compilation); - ReportDuplicateCliNamesAcc(acc, diagnosticLocation, parameters); - ReportBoolNegationSwitchConflictsAcc(acc, diagnosticLocation, parameters, method); - ValidateExpandedParameterLayoutAcc(acc, diagnosticLocation, parameters); - ValidateVariadicPositionalIsLastAcc(acc, diagnosticLocation, parameters); - foreach (var p in parameters) - { - if (p.IsCollection && p.Kind == ParameterKind.Positional && !p.IsVariadic) - acc.Add(CollectionPositionalNotSupported, diagnosticLocation); - if (p.IsVariadic && !p.CollectionTargetIsArray) - acc.Add(VariadicCollectionMustBeArray, diagnosticLocation); - if (p.CollectionTargetIsReadOnlySet && !p.ElementIsValueType) - acc.Add(ReadOnlySetInvalidElementType, diagnosticLocation, p.ElementTypeName); - } - var docs = MergeMethodDocumentationFromTrivia(method, Documentation.ParseMethod(method.GetDocumentationCommentXml(), parseOptions), parseOptions); - var withDocs = ApplyParamDocumentation(parameters, method, docs.ParamDocsRaw); - withDocs = ApplyCollectionSeparatorsFromDocumentation(withDocs, method, docs.ParamSeparators); - var usage = UsageSynopsis.Build(withDocs); - var runName = BuildRunMethodName(routePrefix, commandName); - var containingFq = method.ContainingType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - var hasParamlessCtor = method.ContainingType is INamedTypeSymbol namedCt && HasPublicParameterlessCtor(namedCt); - var (retFq, retIsAsync, retIsVoid, handlerNoInj, handlerParams, handlerLoc, ctorParams, mwData, docId) = ExtractHandlerAnalysis(method); - var (isDeprecated, deprecationMsg) = TryGetObsoleteAttribute(method); - return new CommandModel(routePrefix, commandName, runName, containingFq, method.Name, !method.IsStatic, hasParamlessCtor, retFq, retIsAsync, retIsVoid, withDocs, handlerNoInj, handlerParams, handlerLoc, ctorParams, docId, docs.SummaryOneLiner, docs.RemarksRendered, docs.SummaryInnerXml, docs.RemarksInnerXml, docs.ExamplesRendered, usage, mwData, IsIntrinsic: HasCommandIntrinsicAttribute(method), CommandAliases: TryGetCommandAliasesFromAttribute(method), IsHidden: HasHiddenAttribute(method), IsDeprecated: isDeprecated, DeprecationMessage: deprecationMsg, Intent: TryGetCommandIntentData(method), Output: BuildCommandOutputFromParameters(withDocs)); - } - - private static ImmutableArray BuildParameterModels( - IMethodSymbol method, - CSharpParseOptions parseOptions, - DiagnosticAccumulator acc, - Location diagnosticLocation, - Compilation? compilation = null) - { - var builder = ImmutableArray.CreateBuilder(); - foreach (var p in method.Parameters) - { - if (IsInjected(p)) - { - builder.Add(ParameterModel.From(p)); - continue; - } - if (HasAsParametersAttribute(p)) - { - if (p.Type is not INamedTypeSymbol namedType || namedType.TypeKind == TypeKind.Error) - continue; - var prefix = GetAsParametersPrefix(p); - foreach (var pm in FlattenAsParametersTypeAcc(acc, diagnosticLocation, p, namedType, prefix, compilation, parseOptions)) - builder.Add(pm); - continue; - } - builder.Add(ParameterModel.From(p, null, acc, diagnosticLocation)); - } - return builder.ToImmutable(); - } - - private static ImmutableArray BuildParameterModels( - IMethodSymbol method, - CSharpParseOptions parseOptions, - SourceProductionContext context, - Location diagnosticLocation, - Compilation? compilation = null) - { - var builder = ImmutableArray.CreateBuilder(); - foreach (var p in method.Parameters) - { - if (IsInjected(p)) - { - builder.Add(ParameterModel.From(p)); - continue; - } - - if (HasAsParametersAttribute(p)) - { - if (p.Type is not INamedTypeSymbol namedType || namedType.TypeKind == TypeKind.Error) - continue; - - var prefix = GetAsParametersPrefix(p); - foreach (var pm in FlattenAsParametersType(context, diagnosticLocation, p, namedType, prefix, compilation, parseOptions)) - builder.Add(pm); - continue; - } - - builder.Add(ParameterModel.From(p, context, null, diagnosticLocation)); - } - - return builder.ToImmutable(); - } - - private static ImmutableArray ApplyCollectionSeparatorsFromDocumentation( - ImmutableArray parameters, - IMethodSymbol method, - ImmutableDictionary paramSeparators) - { - if (paramSeparators.IsEmpty) - return parameters; - - var b = ImmutableArray.CreateBuilder(parameters.Length); - foreach (var p in parameters) - { - if (!p.IsCollection || p.CollectionSeparator is not null) - { - b.Add(p); - continue; - } - - if (paramSeparators.TryGetValue(p.SymbolName, out var sep) && !string.IsNullOrWhiteSpace(sep)) - b.Add(p with { CollectionSeparator = sep }); - else - b.Add(p); - } - - return b.ToImmutable(); - } - - private static ImmutableArray CollectCommandMiddleware(IMethodSymbol method) - { - var b = ImmutableArray.CreateBuilder(); - foreach (var attr in method.GetAttributes()) - { - var ac = attr.AttributeClass; - if (ac is null || ac.Name != "MiddlewareAttribute" || ac.TypeArguments.Length != 1) - continue; - if (ac.TypeArguments[0] is INamedTypeSymbol ft && ft.TypeKind != TypeKind.Error) - b.Add(ft); - } - - return b.ToImmutable(); - } - - /// Returns the CSharp-error-message display string for a type — used as a stable, symbol-free metadata key. - private static string GetMetadataName(ITypeSymbol t) => - t.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat); - - private static ( - string ReturnTypeFq, - bool ReturnIsAsync, - bool ReturnIsVoid, - bool HasNoOptionsInjection, - ImmutableArray HandlerParamTypes, - SourceSpanInfo HandlerSpanInfo, - ImmutableArray<(string Name, string TypeMetadataName)> ContainingTypeCtorParams, - ImmutableArray<(string Fq, bool HasParameterlessCtor)> MiddlewareData, - string DocCommentId - ) ExtractHandlerAnalysis(IMethodSymbol method) - { - // Return type - var retFq = method.ReturnType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - // FullyQualifiedFormat renders special types via their C# keyword ("void"), not "global::System.Void". - var retIsVoid = retFq is "void" - or "global::System.Threading.Tasks.Task" - or "global::System.Threading.Tasks.ValueTask"; - var retIsAsync = retFq is "global::System.Threading.Tasks.Task" - or "global::System.Threading.Tasks.ValueTask" - || (method.ReturnType is INamedTypeSymbol named && named.IsGenericType && - (named.ConstructedFrom.Name is "Task" or "ValueTask") && - named.ConstructedFrom.ContainingNamespace?.ToDisplayString() == "System.Threading.Tasks"); - - // Parameters - var paramBuilder = ImmutableArray.CreateBuilder(method.Parameters.Length); - foreach (var p in method.Parameters) - { - var isInj = IsInjected(p); - var isAsParam = HasAsParametersAttribute(p); - var asParamPrefix = isAsParam ? GetAsParametersPrefix(p) : null; - string? asParamTypeFq = null; - ImmutableArray? asParamBestCtor = null; - var asParamIsPublic = true; - var asParamIsGeneric = false; - if (isAsParam && p.Type is INamedTypeSymbol asNt && asNt.TypeKind != TypeKind.Error) - { - asParamTypeFq = asNt.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - asParamIsPublic = asNt.DeclaredAccessibility == Accessibility.Public; - asParamIsGeneric = asNt.TypeParameters.Length > 0; - // Pre-compute the best ctor param order for DTO construction in emit. - var membersForCtor = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var member in asNt.GetMembers()) - { - if (member is IPropertySymbol prop && prop.DeclaredAccessibility == Accessibility.Public && !prop.IsStatic && !prop.IsIndexer && prop.GetMethod is not null && prop.SetMethod is not null) - membersForCtor.Add(prop.Name); - else if (member is IFieldSymbol field && field.DeclaredAccessibility == Accessibility.Public && !field.IsStatic) - membersForCtor.Add(field.Name); - } - // Walk primary ctor or most-parameterized public ctor - IMethodSymbol? bestCtor = null; - foreach (var ctor in asNt.InstanceConstructors) - { - if (ctor.DeclaredAccessibility != Accessibility.Public) continue; - if (ctor.Parameters.Length == 0) continue; - if (!ctor.Parameters.All(cp => membersForCtor.Contains(cp.Name))) continue; - if (bestCtor is null || ctor.Parameters.Length > bestCtor.Parameters.Length) - bestCtor = ctor; - } - if (bestCtor is not null) - { - var ctorB = ImmutableArray.CreateBuilder(bestCtor.Parameters.Length); - foreach (var cp in bestCtor.Parameters) - ctorB.Add(cp.Name); - asParamBestCtor = ctorB.MoveToImmutable(); - } - } - var paramBaseNames = p.Type is INamedTypeSymbol paramNt - ? CollectBaseTypeMetadataNames(paramNt) - : ImmutableArray.Empty; - paramBuilder.Add(new HandlerParam(p.Name, GetMetadataName(p.Type), paramBaseNames, isInj, isAsParam, asParamPrefix, asParamTypeFq, asParamIsPublic, asParamIsGeneric, asParamBestCtor)); - } - - // Handler location - var loc = method.Locations.FirstOrDefault() ?? Location.None; - - // Primary constructor parameters of containing type - var ctorParams = ImmutableArray<(string, string)>.Empty; - var primaryCtor = TryGetPrimaryConstructor(method.ContainingType); - if (primaryCtor is not null && primaryCtor.Parameters.Length > 0) - { - var ctorBuilder = ImmutableArray.CreateBuilder<(string, string)>(primaryCtor.Parameters.Length); - foreach (var cp in primaryCtor.Parameters) - ctorBuilder.Add((cp.Name, GetMetadataName(cp.Type))); - ctorParams = ctorBuilder.ToImmutable(); - } - - // Middleware data - var rawMiddleware = CollectCommandMiddleware(method); - var mwBuilder = ImmutableArray.CreateBuilder<(string, bool)>(rawMiddleware.Length); - foreach (var mw in rawMiddleware) - mwBuilder.Add((mw.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), HasPublicParameterlessCtor(mw))); - var middlewareData = mwBuilder.ToImmutable(); - - var docId = method.GetDocumentationCommentId() ?? ""; - return ( - ReturnTypeFq: retFq, - ReturnIsAsync: retIsAsync, - ReturnIsVoid: retIsVoid, - HasNoOptionsInjection: HasNoOptionsInjection(method), - HandlerParamTypes: paramBuilder.ToImmutable(), - HandlerSpanInfo: SourceSpanInfo.From(loc), - ContainingTypeCtorParams: ctorParams, - MiddlewareData: middlewareData, - DocCommentId: docId - ); - } - - private static string BuildRunMethodName(ImmutableArray routePrefix, string commandName) - { - if (routePrefix.IsDefaultOrEmpty) - return "Run_" + Naming.SanitizeIdentifier(commandName); - - var sb = new StringBuilder(); - sb.Append("Run"); - foreach (var seg in routePrefix) - { - sb.Append('_'); - sb.Append(Naming.SanitizeIdentifier(seg)); - } - - sb.Append('_'); - sb.Append(Naming.SanitizeIdentifier(commandName)); - return sb.ToString(); - } - - /// Visible to for lambda root commands (same naming as ). - internal static string BuildRootDefaultRunMethodName(ImmutableArray routePrefix) => - BuildRunMethodName(routePrefix, "RootDefault"); - - /// Public helper used by the analyzed-invocation pipeline to re-compute run method names when prefixing. - internal static string BuildRunMethodNameStatic(ImmutableArray routePrefix, string commandName) => - BuildRunMethodName(routePrefix, commandName); - - private static ImmutableArray ApplyParamDocumentation( - ImmutableArray parameters, - IMethodSymbol method, - ImmutableDictionary paramDocsRaw) - { - if (paramDocsRaw.IsEmpty) - return parameters; - - var map = new Dictionary(); - foreach (var p in parameters) - map[p.SymbolName] = p; - - foreach (var ps in method.Parameters) - { - if (!map.TryGetValue(ps.Name, out var existing)) - continue; - if (!paramDocsRaw.TryGetValue(ps.Name, out var raw) || string.IsNullOrWhiteSpace(raw)) - continue; - - if (existing.Kind == ParameterKind.Positional) - { - map[ps.Name] = existing with { Description = raw.Trim() }; - continue; - } - - var doc = ParamDocParser.Parse(raw); - map[ps.Name] = existing with - { - CliLongName = doc.ExplicitLongName ?? existing.CliLongName, - Description = doc.Description, - ShortOpt = doc.ShortOpt, - Aliases = doc.Aliases - }; - } - - var rebuilt = ImmutableArray.CreateBuilder(parameters.Length); - foreach (var p in parameters) - rebuilt.Add(map[p.SymbolName]); - - return rebuilt.ToImmutable(); - } - } - - /// Parse options property/field <summary> lines that may start with -x, --long, … synopsis prefixes (same rules as handler docs). - private static ParamDoc ParseOptionsFlagDocumentation(string? summaryLine) - { - if (string.IsNullOrWhiteSpace(summaryLine)) - return new ParamDoc(null, ImmutableArray.Empty, ""); - return ParamDocParser.Parse(summaryLine!.Trim()); - } - - // ── Validation constraint types ───────────────────────────────────────────── - // All fields are value types, strings, or ImmutableArray so these can be - // cached inside AnalyzedInvocation records in the Roslyn incremental pipeline. - - private abstract record ValidationConstraint; - private sealed record CollectionCountConstraint(int? Min, int? Max) : ValidationConstraint; - private sealed record RangeConstraint(string MinLiteral, string MaxLiteral) : ValidationConstraint; - private sealed record TimeSpanRangeConstraint(string MinLiteral, string MaxLiteral) : ValidationConstraint; - private sealed record StringLengthConstraint(int? Min, int? Max) : ValidationConstraint; - private sealed record RegexConstraint(string Pattern) : ValidationConstraint; - private sealed record AllowedValuesConstraint(ImmutableArray Values) : ValidationConstraint; - private sealed record DeniedValuesConstraint(ImmutableArray Values) : ValidationConstraint; - private sealed record EmailConstraint : ValidationConstraint; - private sealed record UrlConstraint : ValidationConstraint; - private sealed record UriSchemeConstraint(ImmutableArray Schemes) : ValidationConstraint; - private sealed record FileExtensionsConstraint(ImmutableArray Extensions) : ValidationConstraint; - private sealed record ExistingPathConstraint : ValidationConstraint; - private sealed record NonExistingPathConstraint : ValidationConstraint; - private sealed record RejectSymbolicLinksConstraint : ValidationConstraint; - - private sealed record ParameterModel( - string SymbolName, - string LocalVarName, - string CliLongName, - ParameterKind Kind, - BoolSpecialKind Special, - CliScalarKind ScalarKind, - string TypeName, - string? EnumTypeFq, - ImmutableArray EnumMemberNames, - string? ParserTypeFq, - string? CustomValueTypeFq, - bool IsRequired, - string? DefaultValueLiteral, - string Description, - char? ShortOpt, - ImmutableArray Aliases, - bool IsCollection = false, - string? CollectionSeparator = null, - CliScalarKind ElementScalarKind = CliScalarKind.Primitive, - string ElementTypeName = "string", - string? ElementEnumTypeFq = null, - ImmutableArray ElementEnumMemberNames = default, - ImmutableArray EnumMemberCliNames = default, - ImmutableArray ElementEnumMemberCliNames = default, - string? ElementParserTypeFq = null, - string? ElementCustomValueTypeFq = null, - string? FullDeclaredTypeFq = null, - string? AsParametersOwnerParamName = null, - int AsParametersMemberOrder = -1, - string? AsParametersTypeFq = null, - bool AsParametersUseInit = false, - string? AsParametersClrName = null, - bool CollectionTargetIsArray = false, - bool CollectionTargetIsReadOnlySet = false, - /// True when the declared collection type uses NRT annotation (e.g. IReadOnlySet<int>?). Optional params with this shape default to null when no values were parsed. - bool DeclaredNullableAnnotated = false, - bool ElementIsValueType = false, - ImmutableDictionary? EnumMemberDocs = null, - ImmutableDictionary? ElementEnumMemberDocs = null, - bool ExpandUserProfileBeforeBind = false, - ImmutableArray Validations = default, - bool IsHidden = false, - bool IsVariadic = false, - /// - /// True when the property is from a cross-assembly type (DeclaringSyntaxReferences empty) and has no - /// detectable static default. The emit uses new T().PropName at runtime for the initial value. - /// - bool UsesRuntimeDefault = false, - /// - /// True when the source property/parameter is a nullable reference type (NRT, e.g. string?, - /// FileInfo?) as opposed to a value-type Nullable<T> (e.g. int?). - /// Used by to emit string? even when - /// is true, preventing CS8600 when the runtime default for a nullable property is null. - /// - bool IsNullableAnnotated = false, - bool IsConfirmationSkip = false, - bool IsDryRun = false, - bool IsCommandOutput = false, - ImmutableArray CommandOutputExplicitFormats = default, - bool IsDeprecated = false, - string? DeprecationMessage = null) - { - // ── shared helpers ────────────────────────────────────────────────────── - - private static ImmutableDictionary? TryGetEnumDocs(ITypeSymbol type) - { - var t = type; - if (t is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nul) - t = nul.TypeArguments[0]; - return t is INamedTypeSymbol en ? GetEnumMemberDocs(en) : null; - } - - private static void ClassifyScalarUnified( - ITypeSymbol type, - ISymbol attributeHost, - BoolSpecialKind bs, - bool isSeparateType, - out CliScalarKind sk, - out string typeName, - out string? enumFq, - out ImmutableArray enumMembers, - out string? parserFq, - out string? customValueTypeFq) - { - if (isSeparateType) - ClassifyScalarForType(type, attributeHost, bs, out sk, out typeName, out enumFq, out enumMembers, out parserFq, out customValueTypeFq); - else - ClassifyScalar((IParameterSymbol)attributeHost, bs, out sk, out typeName, out enumFq, out enumMembers, out parserFq, out customValueTypeFq); - } - - private static ParameterModel BuildCollectionParameterModel( - ITypeSymbol collectionType, - ITypeSymbol elementType, - ISymbol attributeHost, - ParameterKind kind, - string cliLongName, - string localVarName, - string symbolName, - bool isSeparateType, - string? defaultLiteral, - string description, - AsParametersMeta? asParams, - char? flagShortOpt = null, - ImmutableArray synopsisAliasesFromSummary = default, - bool isVariadic = false, - SourceProductionContext? reportCtx = null, - DiagnosticAccumulator? reportAcc = null, - Location? reportFallbackLocation = null) - { - ClassifyScalarForType(elementType, attributeHost, BoolSpecialKind.None, - out var elemSk, out var elemTn, out var eFq, out var eMem, out var pFq, out var cFq); - var eCliMem = elemSk == CliScalarKind.Enum ? TryGetEnumCliNames(elementType) : default; - var elemEnumDocs = elemSk == CliScalarKind.Enum ? TryGetEnumDocs(elementType) : null; - if (reportCtx is not null || reportAcc is not null) - ReportFilesystemPathAttributeIssues(attributeHost, CliScalarKind.Collection, symbolName, reportAcc, reportCtx, - reportFallbackLocation, filesystemScalarKind: elemSk); - var sep = TryGetCollectionSeparatorFromAttribute(attributeHost); - var required = isSeparateType - ? ComputeRequiredForOptionsType(collectionType, BoolSpecialKind.None) - : ComputeRequired((IParameterSymbol)attributeHost, BoolSpecialKind.None); - var defFq = (collectionType as INamedTypeSymbol)?.OriginalDefinition - .ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) ?? ""; - var synopsisAliasesResolved = synopsisAliasesFromSummary.IsDefault - ? ImmutableArray.Empty - : synopsisAliasesFromSummary; - var fq = collectionType.ToDisplayString(FullyQualifiedFormatWithNullableRefAnnotations); - var declaredNullableAnnotated = collectionType.NullableAnnotation == NullableAnnotation.Annotated; - var collValidations = ReadValidationConstraints(attributeHost, CliScalarKind.Collection, "values", isCollection: true, - filesystemScalarKind: elemSk); - var expandProfileElem = TryReadExpandUserProfileBeforeBind(attributeHost, elemSk); - // Variadic positionals always allow zero items by C# params convention. - // Minimum count enforcement is handled via CollectionCountConstraint ([MinLength]). - if (isVariadic) required = false; - var (isOutputColl, outputFormatsColl) = TryGetCommandOutputAttribute(attributeHost); - var (isDeprecatedColl, deprecationMsgColl) = TryGetObsoleteAttribute(attributeHost); - return new ParameterModel( - symbolName, - localVarName, - cliLongName, - kind, - BoolSpecialKind.None, - CliScalarKind.Collection, - "values", - null, - ImmutableArray.Empty, - null, - null, - required, - defaultLiteral, - description, - flagShortOpt, - synopsisAliasesResolved, - IsCollection: true, - CollectionSeparator: sep, - ElementScalarKind: elemSk, - ElementTypeName: elemTn, - ElementEnumTypeFq: eFq, - ElementEnumMemberNames: eMem, - ElementEnumMemberCliNames: eCliMem, - ElementParserTypeFq: pFq, - ElementCustomValueTypeFq: cFq, - FullDeclaredTypeFq: fq, - CollectionTargetIsArray: collectionType is IArrayTypeSymbol, - CollectionTargetIsReadOnlySet: defFq == "global::System.Collections.Generic.IReadOnlySet", - DeclaredNullableAnnotated: declaredNullableAnnotated, - ElementIsValueType: elementType.IsValueType, - ElementEnumMemberDocs: elemEnumDocs, - AsParametersOwnerParamName: asParams?.OwnerParamName, - AsParametersMemberOrder: asParams?.MemberOrder ?? -1, - AsParametersTypeFq: asParams?.TypeFq, - AsParametersUseInit: asParams?.UseInit ?? false, - AsParametersClrName: asParams?.ClrName, - ExpandUserProfileBeforeBind: expandProfileElem, - Validations: collValidations, - IsHidden: HasHiddenAttribute(attributeHost), - IsVariadic: isVariadic, - IsConfirmationSkip: HasConfirmationSkipAttribute(attributeHost), - IsDryRun: HasDryRunAttribute(attributeHost), - IsCommandOutput: isOutputColl, - CommandOutputExplicitFormats: outputFormatsColl, - IsDeprecated: isDeprecatedColl, - DeprecationMessage: deprecationMsgColl); - } - - // ── five factory methods ───────────────────────────────────────────── - - public static ParameterModel From(IParameterSymbol p, SourceProductionContext? reportCtx = null, DiagnosticAccumulator? reportAcc = null, - Location? reportFallbackLocation = null) - { - var isArg = HasArgumentAttribute(p); - - if (IsInjectedStatic(p)) - return new ParameterModel( - p.Name, - SafeLocalName(p.Name), - Naming.ToCliLongName(p.Name), - ParameterKind.Injected, - BoolSpecialKind.None, - CliScalarKind.Primitive, - "CancellationToken", - null, - ImmutableArray.Empty, - null, - null, - false, - null, - "", - null, - ImmutableArray.Empty); - - var kind = isArg ? ParameterKind.Positional : ParameterKind.Flag; - var bs = ClassifyBool(p.Type); - if (TryUnwrapCollectionType(p.Type, out var elemType) && bs == BoolSpecialKind.None - && TryParserTypeFqFromSymbol(p) is null) - { - var isVariadic = isArg && p.Type is IArrayTypeSymbol; - var defLitColl = TryGetDefaultLiteral(p, BoolSpecialKind.None); - return BuildCollectionParameterModel(p.Type, elemType, p, kind, - Naming.ToCliLongName(p.Name), SafeLocalName(p.Name), p.Name, - isSeparateType: false, defLitColl, "", asParams: null, isVariadic: isVariadic, - reportCtx: reportCtx, reportAcc: reportAcc, reportFallbackLocation: reportFallbackLocation); - } - - ClassifyScalarUnified(p.Type, p, bs, isSeparateType: false, - out var sk, out var typeName, out var enumFq, out var enumMembers, out var parserFq, out var customValFq); - if (reportCtx is not null || reportAcc is not null) - ReportFilesystemPathAttributeIssues(p, sk, p.Name, reportAcc, reportCtx, reportFallbackLocation); - - var required = ComputeRequired(p, bs); - var defLit = TryGetDefaultLiteral(p, bs); - var enumDocs = sk == CliScalarKind.Enum ? TryGetEnumDocs(p.Type) : null; - var enumCliNames = sk == CliScalarKind.Enum ? TryGetEnumCliNames(p.Type) : default; - var validations = ReadValidationConstraints(p, sk, typeName); - var expandProf = TryReadExpandUserProfileBeforeBind(p, sk); - var (isOutputP, outputFormatsP) = TryGetCommandOutputAttribute(p); - var (isDeprecatedP, deprecationMsgP) = TryGetObsoleteAttribute(p); - return new ParameterModel( - p.Name, - SafeLocalName(p.Name), - Naming.ToCliLongName(p.Name), - kind, - bs, - sk, - typeName, - enumFq, - enumMembers, - parserFq, - customValFq, - required, - defLit, - "", - null, - ImmutableArray.Empty, - EnumMemberCliNames: enumCliNames, - EnumMemberDocs: enumDocs, - ExpandUserProfileBeforeBind: expandProf, - Validations: validations, - IsHidden: HasHiddenAttribute(p), - IsConfirmationSkip: HasConfirmationSkipAttribute(p), - IsDryRun: HasDryRunAttribute(p), - IsCommandOutput: isOutputP, - CommandOutputExplicitFormats: outputFormatsP, - IsDeprecated: isDeprecatedP, - DeprecationMessage: deprecationMsgP); - } - - public static ParameterModel FromOptionsProperty(IPropertySymbol prop, Compilation? compilation = null, string? defaultValueLiteral = null) - { - var rawSummary = Documentation.GetPropertySummaryLine(prop, compilation, TryExtractFullDocumentationFromPropertyTrivia(prop)); - var doc = ParseOptionsFlagDocumentation(rawSummary); - var derivedLongNameProp = Naming.ToCliLongName(prop.Name); - var effectiveLongNameProp = doc.ExplicitLongName ?? derivedLongNameProp; - var bs = ClassifyBool(prop.Type); - if (TryUnwrapCollectionType(prop.Type, out var elemType) && bs == BoolSpecialKind.None - && TryParserTypeFqFromSymbol(prop) is null) - { - return BuildCollectionParameterModel(prop.Type, elemType, prop, ParameterKind.Flag, - effectiveLongNameProp, SafeLocalName(prop.Name), prop.Name, - isSeparateType: true, defaultLiteral: null, doc.Description, asParams: null, - flagShortOpt: doc.ShortOpt, synopsisAliasesFromSummary: doc.Aliases); - } - - ClassifyScalarUnified(prop.Type, prop, bs, isSeparateType: true, - out var sk, out var typeName, out var enumFq, out var enumMembers, out var parserFq, out var customValFq); - // A property initializer supplies a CLI default: the flag is not required on the command line. - // For cross-assembly types, DeclaringSyntaxReferences is empty so we can't read the initializer - // expression from syntax. Mark the property as using a runtime default instead of "required". - var isCrossAssemblyDefault = defaultValueLiteral is null && prop.DeclaringSyntaxReferences.IsEmpty; - var required = !isCrossAssemblyDefault && ComputeRequiredForOptionsType(prop.Type, bs) && defaultValueLiteral is null; - var enumDocs = sk == CliScalarKind.Enum ? TryGetEnumDocs(prop.Type) : null; - var enumCliNames = sk == CliScalarKind.Enum ? TryGetEnumCliNames(prop.Type) : default; - var validations = ReadValidationConstraints(prop, sk, typeName); - var defLit = QualifyOptionsEnumDefaultLiteral(defaultValueLiteral, sk, enumFq, enumMembers); - var expandProf = TryReadExpandUserProfileBeforeBind(prop, sk); - return new ParameterModel( - prop.Name, - SafeLocalName(prop.Name), - effectiveLongNameProp, - ParameterKind.Flag, - bs, - sk, - typeName, - enumFq, - enumMembers, - parserFq, - customValFq, - required, - defLit, - doc.Description, - doc.ShortOpt, - doc.Aliases, - EnumMemberCliNames: enumCliNames, - EnumMemberDocs: enumDocs, - ExpandUserProfileBeforeBind: expandProf, - Validations: validations, - IsHidden: HasHiddenAttribute(prop), - UsesRuntimeDefault: isCrossAssemblyDefault, - IsNullableAnnotated: prop.Type.NullableAnnotation == NullableAnnotation.Annotated - || prop.Type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T }, - IsConfirmationSkip: HasConfirmationSkipAttribute(prop), - IsDryRun: HasDryRunAttribute(prop), - IsCommandOutput: TryGetCommandOutputAttribute(prop).IsOutput, - CommandOutputExplicitFormats: TryGetCommandOutputAttribute(prop).ExplicitFormats, - IsDeprecated: TryGetObsoleteAttribute(prop).IsDeprecated, - DeprecationMessage: TryGetObsoleteAttribute(prop).Message); - } - - public static ParameterModel FromOptionsField(IFieldSymbol field, Compilation? compilation = null, string? defaultValueLiteral = null) - { - var rawSummary = Documentation.GetFieldSummaryLine(field, compilation, TryExtractFullDocumentationFromFieldTrivia(field)); - var doc = ParseOptionsFlagDocumentation(rawSummary); - var derivedLongNameField = Naming.ToCliLongName(field.Name); - var effectiveLongNameField = doc.ExplicitLongName ?? derivedLongNameField; - var bs = ClassifyBool(field.Type); - if (TryUnwrapCollectionType(field.Type, out var elemType) && bs == BoolSpecialKind.None - && TryParserTypeFqFromSymbol(field) is null) - { - return BuildCollectionParameterModel(field.Type, elemType, field, ParameterKind.Flag, - effectiveLongNameField, SafeLocalName(field.Name), field.Name, - isSeparateType: true, defaultLiteral: null, doc.Description, asParams: null, - flagShortOpt: doc.ShortOpt, synopsisAliasesFromSummary: doc.Aliases); - } - - ClassifyScalarUnified(field.Type, field, bs, isSeparateType: true, - out var sk, out var typeName, out var enumFq, out var enumMembers, out var parserFq, out var customValFq); - var isCrossAssemblyDefault = defaultValueLiteral is null && field.DeclaringSyntaxReferences.IsEmpty; - var required = !isCrossAssemblyDefault && ComputeRequiredForOptionsType(field.Type, bs) && defaultValueLiteral is null; - var enumCliNames = sk == CliScalarKind.Enum ? TryGetEnumCliNames(field.Type) : default; - var validations = ReadValidationConstraints(field, sk, typeName); - var defLit = QualifyOptionsEnumDefaultLiteral(defaultValueLiteral, sk, enumFq, enumMembers); - var expandProf = TryReadExpandUserProfileBeforeBind(field, sk); - return new ParameterModel( - field.Name, - SafeLocalName(field.Name), - effectiveLongNameField, - ParameterKind.Flag, - bs, - sk, - typeName, - enumFq, - enumMembers, - parserFq, - customValFq, - required, - defLit, - doc.Description, - doc.ShortOpt, - doc.Aliases, - EnumMemberCliNames: enumCliNames, - ExpandUserProfileBeforeBind: expandProf, - Validations: validations, - IsHidden: HasHiddenAttribute(field), - UsesRuntimeDefault: isCrossAssemblyDefault, - IsNullableAnnotated: field.Type.NullableAnnotation == NullableAnnotation.Annotated - || field.Type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T }); - } - public static ParameterModel FromAsParametersCtorParameter( - string methodParamName, - string typeFq, - INamedTypeSymbol containingType, - IParameterSymbol cp, - string namePrefix, - int memberOrder, - Compilation? compilation, - CSharpParseOptions parseOptions, - SourceProductionContext? reportCtx = null, - DiagnosticAccumulator? reportAcc = null, - Location? reportFallbackLocation = null) - { - if (IsInjectedType(cp.Type)) - { - var injCli = namePrefix + Naming.ToCliLongName(cp.Name); - var injLocal = SafeLocalName(methodParamName + "_" + cp.Name); - return new ParameterModel( - cp.Name, - injLocal, - injCli, - ParameterKind.Injected, - BoolSpecialKind.None, - CliScalarKind.Primitive, - "CancellationToken", - null, - ImmutableArray.Empty, - null, - null, - false, - null, - "", - null, - ImmutableArray.Empty, - AsParametersOwnerParamName: methodParamName, - AsParametersMemberOrder: memberOrder, - AsParametersTypeFq: typeFq, - AsParametersUseInit: false, - AsParametersClrName: cp.Name); - } - - var isArg = HasArgumentAttribute(cp); - var kind = isArg ? ParameterKind.Positional : ParameterKind.Flag; - var bs = ClassifyBool(cp.Type); - var cli = namePrefix + Naming.ToCliLongName(cp.Name); - var local = SafeLocalName(methodParamName + "_" + cp.Name); - var desc = Documentation.GetParamDocFromType(containingType, cp.Name, compilation, TryExtractFullDocumentationFromTypeTrivia(containingType)); - if (string.IsNullOrWhiteSpace(desc)) - { - var pxml = cp.GetDocumentationCommentXml(); - if (string.IsNullOrWhiteSpace(pxml)) - pxml = Documentation.GetDocumentationXmlFromMetadataReference(cp, compilation); - if (!string.IsNullOrWhiteSpace(pxml)) - { - desc = Documentation.GetParamDocFromXmlFragment(pxml, cp.Name); - if (string.IsNullOrWhiteSpace(desc)) - desc = Documentation.GetTypeSummaryLine(pxml); - } - } - - if (string.IsNullOrWhiteSpace(desc)) - desc = Documentation.GetTypeSummaryLine(TryExtractDocumentationFromParameterTrivia(cp)); - var meta = new AsParametersMeta(methodParamName, memberOrder, typeFq, UseInit: false, cp.Name); - if (TryUnwrapCollectionType(cp.Type, out var elemType) && bs == BoolSpecialKind.None - && TryParserTypeFqFromSymbol(cp) is null) - { - var isVariadicCp = isArg && cp.Type is IArrayTypeSymbol; - var defLitColl = TryGetDefaultLiteral(cp, BoolSpecialKind.None); - return BuildCollectionParameterModel(cp.Type, elemType, cp, kind, cli, local, cp.Name, - isSeparateType: false, defLitColl, desc, meta, isVariadic: isVariadicCp, - reportCtx: reportCtx, reportAcc: reportAcc, - reportFallbackLocation: cp.Locations.FirstOrDefault() ?? reportFallbackLocation); - } - - ClassifyScalarUnified(cp.Type, cp, bs, isSeparateType: false, - out var sk, out var typeName, out var enumFq, out var enumMembers, out var parserFq, out var customValFq); - if (reportCtx is not null || reportAcc is not null) - ReportFilesystemPathAttributeIssues(cp, sk, cp.Name, reportAcc, reportCtx, - cp.Locations.FirstOrDefault() ?? reportFallbackLocation); - - var required = ComputeRequired(cp, bs); - var defLit = TryGetDefaultLiteral(cp, bs); - var enumCliNames = sk == CliScalarKind.Enum ? TryGetEnumCliNames(cp.Type) : default; - var validations = ReadValidationConstraints(cp, sk, typeName); - var expandProf = TryReadExpandUserProfileBeforeBind(cp, sk); - var (isDeprecatedCp, deprecationMsgCp) = TryGetObsoleteAttribute(cp); - var (isOutputCp, outputFormatsCp) = TryGetCommandOutputAttribute(cp); - return new ParameterModel( - cp.Name, - local, - cli, - kind, - bs, - sk, - typeName, - enumFq, - enumMembers, - parserFq, - customValFq, - required, - defLit, - desc, - null, - ImmutableArray.Empty, - EnumMemberCliNames: enumCliNames, - AsParametersOwnerParamName: methodParamName, - AsParametersMemberOrder: memberOrder, - AsParametersTypeFq: typeFq, - AsParametersUseInit: false, - AsParametersClrName: cp.Name, - ExpandUserProfileBeforeBind: expandProf, - Validations: validations, - IsConfirmationSkip: HasConfirmationSkipAttribute(cp), - IsDryRun: HasDryRunAttribute(cp), - IsCommandOutput: isOutputCp, - CommandOutputExplicitFormats: outputFormatsCp, - IsDeprecated: isDeprecatedCp, - DeprecationMessage: deprecationMsgCp); - } - - public static ParameterModel FromAsParametersInitProperty( - string methodParamName, - string typeFq, - IPropertySymbol prop, - string namePrefix, - int memberOrder, - Compilation? compilation, - CSharpParseOptions parseOptions, - SourceProductionContext? reportCtx = null, - DiagnosticAccumulator? reportAcc = null, - Location? reportFallbackLocation = null) - { - if (IsInjectedType(prop.Type)) - { - var injCli = namePrefix + Naming.ToCliLongName(prop.Name); - var injLocal = SafeLocalName(methodParamName + "_" + prop.Name); - return new ParameterModel( - prop.Name, - injLocal, - injCli, - ParameterKind.Injected, - BoolSpecialKind.None, - CliScalarKind.Primitive, - "CancellationToken", - null, - ImmutableArray.Empty, - null, - null, - false, - null, - "", - null, - ImmutableArray.Empty, - AsParametersOwnerParamName: methodParamName, - AsParametersMemberOrder: memberOrder, - AsParametersTypeFq: typeFq, - AsParametersUseInit: true, - AsParametersClrName: prop.Name); - } - - var isArg = HasArgumentAttribute(prop); - var kind = isArg ? ParameterKind.Positional : ParameterKind.Flag; - var bs = ClassifyBool(prop.Type); - var local = SafeLocalName(methodParamName + "_" + prop.Name); - var rawSummary = Documentation.GetPropertySummaryLine(prop, compilation, TryExtractFullDocumentationFromPropertyTrivia(prop)); - var doc = ParseOptionsFlagDocumentation(rawSummary); - var derivedCli = namePrefix + Naming.ToCliLongName(prop.Name); - var cli = doc.ExplicitLongName is not null ? namePrefix + doc.ExplicitLongName : derivedCli; - var meta = new AsParametersMeta(methodParamName, memberOrder, typeFq, UseInit: true, prop.Name); - if (TryUnwrapCollectionType(prop.Type, out var elemType) && bs == BoolSpecialKind.None - && TryParserTypeFqFromSymbol(prop) is null) - { - var isVariadicProp = isArg && prop.Type is IArrayTypeSymbol; - return BuildCollectionParameterModel(prop.Type, elemType, prop, kind, cli, local, prop.Name, - isSeparateType: true, defaultLiteral: null, doc.Description, meta, - flagShortOpt: doc.ShortOpt, synopsisAliasesFromSummary: doc.Aliases, isVariadic: isVariadicProp, - reportCtx: reportCtx, reportAcc: reportAcc, - reportFallbackLocation: prop.Locations.FirstOrDefault() ?? reportFallbackLocation); - } - - ClassifyScalarUnified(prop.Type, prop, bs, isSeparateType: true, - out var sk, out var typeName, out var enumFq, out var enumMembers, out var parserFq, out var customValFq); - if (reportCtx is not null || reportAcc is not null) - ReportFilesystemPathAttributeIssues(prop, sk, prop.Name, reportAcc, reportCtx, - prop.Locations.FirstOrDefault() ?? reportFallbackLocation); - - var defaultValueLiteral = compilation is not null ? TryGetOptionsPropertyDefaultLiteral(prop, compilation) : null; - // Cross-assembly [AsParameters] types: syntax refs empty, can't read initializer. - var isCrossAssemblyDefault = defaultValueLiteral is null && prop.DeclaringSyntaxReferences.IsEmpty; - var required = !isCrossAssemblyDefault && ComputeRequiredForOptionsType(prop.Type, bs) && defaultValueLiteral is null; - var defLit = QualifyOptionsEnumDefaultLiteral(defaultValueLiteral, sk, enumFq, enumMembers); - var enumCliNames = sk == CliScalarKind.Enum ? TryGetEnumCliNames(prop.Type) : default; - var validations = ReadValidationConstraints(prop, sk, typeName); - var expandProf = TryReadExpandUserProfileBeforeBind(prop, sk); - var (isDeprecatedProp, deprecationMsgProp) = TryGetObsoleteAttribute(prop); - var (isOutputProp, outputFormatsProp) = TryGetCommandOutputAttribute(prop); - return new ParameterModel( - prop.Name, - local, - cli, - kind, - bs, - sk, - typeName, - enumFq, - enumMembers, - parserFq, - customValFq, - required, - defLit, - doc.Description, - doc.ShortOpt, - doc.Aliases, - EnumMemberCliNames: enumCliNames, - AsParametersOwnerParamName: methodParamName, - AsParametersMemberOrder: memberOrder, - AsParametersTypeFq: typeFq, - AsParametersUseInit: true, - AsParametersClrName: prop.Name, - ExpandUserProfileBeforeBind: expandProf, - Validations: validations, - UsesRuntimeDefault: isCrossAssemblyDefault, - IsNullableAnnotated: prop.Type.NullableAnnotation == NullableAnnotation.Annotated - || prop.Type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T }, - IsConfirmationSkip: HasConfirmationSkipAttribute(prop), - IsDryRun: HasDryRunAttribute(prop), - IsCommandOutput: isOutputProp, - CommandOutputExplicitFormats: outputFormatsProp, - IsDeprecated: isDeprecatedProp, - DeprecationMessage: deprecationMsgProp); - } - - private static bool ComputeRequiredForOptionsType(ITypeSymbol type, BoolSpecialKind bs) - { - if (bs == BoolSpecialKind.Bool) - return false; - - if (type.NullableAnnotation == NullableAnnotation.Annotated) - return false; - - if (type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T }) - return false; - - if (type.IsReferenceType && type.NullableAnnotation != NullableAnnotation.Annotated) - return true; - - return type.IsValueType && type.NullableAnnotation != NullableAnnotation.Annotated; - } - - private static void ClassifyScalarForType( - ITypeSymbol type, - ISymbol attributeHost, - BoolSpecialKind bs, - out CliScalarKind kind, - out string primitiveName, - out string? enumFq, - out ImmutableArray enumMembers, - out string? parserFq, - out string? customValueFq) - { - enumFq = null; - enumMembers = ImmutableArray.Empty; - parserFq = null; - customValueFq = null; - if (bs != BoolSpecialKind.None) - { - kind = CliScalarKind.Primitive; - primitiveName = GetSimpleTypeName(type); - return; - } - - parserFq = TryParserTypeFqFromSymbol(attributeHost); - if (parserFq is not null) - { - kind = CliScalarKind.CustomParser; - primitiveName = "custom"; - customValueFq = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - return; - } - - var t = type; - if (t is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nn) - t = nn.TypeArguments[0]; - - if (t.TypeKind == TypeKind.Enum && t is INamedTypeSymbol en) - { - kind = CliScalarKind.Enum; - enumFq = en.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - enumMembers = GetEnumMemberNames(en); - primitiveName = "enum"; - return; - } - - if (t is INamedTypeSymbol named) - { - var fq = named.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - if (fq == "global::System.IO.FileInfo") - { - kind = CliScalarKind.FileInfo; - primitiveName = "FileInfo"; - return; - } - - if (fq == "global::System.IO.DirectoryInfo") - { - kind = CliScalarKind.DirectoryInfo; - primitiveName = "DirectoryInfo"; - return; - } - - if (fq == "global::System.Uri") - { - kind = CliScalarKind.Uri; - primitiveName = "Uri"; - return; - } - } - - kind = CliScalarKind.Primitive; - primitiveName = GetSimpleTypeName(type); - } - - private static string? TryParserTypeFqFromSymbol(ISymbol symbol) - { - foreach (var attr in symbol.GetAttributes()) - { - if (attr.AttributeClass?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) != - "global::Nullean.Argh.ArgumentParserAttribute") - continue; - - if (attr.ConstructorArguments.Length > 0 && - attr.ConstructorArguments[0].Value is INamedTypeSymbol parser) - return parser.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - } - - return null; - } - - private static void ClassifyScalar( - IParameterSymbol p, - BoolSpecialKind bs, - out CliScalarKind kind, - out string primitiveName, - out string? enumFq, - out ImmutableArray enumMembers, - out string? parserFq, - out string? customValueFq) - { - enumFq = null; - enumMembers = ImmutableArray.Empty; - parserFq = null; - customValueFq = null; - if (bs != BoolSpecialKind.None) - { - kind = CliScalarKind.Primitive; - primitiveName = GetSimpleTypeName(p.Type); - return; - } - - parserFq = TryParserTypeFqFromSymbol(p); - if (parserFq is not null) - { - kind = CliScalarKind.CustomParser; - primitiveName = "custom"; - customValueFq = p.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - return; - } - - var t = p.Type; - if (t is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nn) - t = nn.TypeArguments[0]; - - if (t.TypeKind == TypeKind.Enum && t is INamedTypeSymbol en) - { - kind = CliScalarKind.Enum; - enumFq = en.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - enumMembers = GetEnumMemberNames(en); - primitiveName = "enum"; - return; - } - - if (t is INamedTypeSymbol named) - { - var fq = named.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - if (fq == "global::System.IO.FileInfo") - { - kind = CliScalarKind.FileInfo; - primitiveName = "FileInfo"; - return; - } - - if (fq == "global::System.IO.DirectoryInfo") - { - kind = CliScalarKind.DirectoryInfo; - primitiveName = "DirectoryInfo"; - return; - } - - if (fq == "global::System.Uri") - { - kind = CliScalarKind.Uri; - primitiveName = "Uri"; - return; - } - } - - kind = CliScalarKind.Primitive; - primitiveName = GetSimpleTypeName(p.Type); - } - - private static ImmutableArray GetEnumMemberNames(INamedTypeSymbol enumType) - { - var b = ImmutableArray.CreateBuilder(); - foreach (var m in enumType.GetMembers()) - { - if (m is IFieldSymbol { HasConstantValue: true, IsImplicitlyDeclared: false }) - b.Add(m.Name); - } - - return b.ToImmutable(); - } - - private static ImmutableArray GetEnumMemberCliNames(INamedTypeSymbol enumType) - { - var hasAny = false; - var b = ImmutableArray.CreateBuilder(); - foreach (var m in enumType.GetMembers()) - { - if (m is not IFieldSymbol { HasConstantValue: true, IsImplicitlyDeclared: false }) - continue; - string? cliName = null; - foreach (var attr in m.GetAttributes()) - { - if (attr.AttributeClass?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == "global::Nullean.Argh.EnumValueAttribute" - && attr.ConstructorArguments.Length > 0 - && attr.ConstructorArguments[0].Value is string v) - { - cliName = v; - hasAny = true; - break; - } - } - b.Add(cliName ?? m.Name.ToLowerInvariant()); - } - return hasAny ? b.ToImmutable() : default; - } - - private static ImmutableArray TryGetEnumCliNames(ITypeSymbol type) - { - var t = type; - if (t is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nn) - t = nn.TypeArguments[0]; - return t is INamedTypeSymbol { TypeKind: TypeKind.Enum } en ? GetEnumMemberCliNames(en) : default; - } - - private static ImmutableDictionary GetEnumMemberDocs(INamedTypeSymbol enumType) - { - var b = ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); - foreach (var m in enumType.GetMembers()) - { - if (m is not IFieldSymbol { HasConstantValue: true, IsImplicitlyDeclared: false } field) - continue; - var xml = field.GetDocumentationCommentXml(); - if (string.IsNullOrWhiteSpace(xml)) - continue; - try - { - var doc = System.Xml.Linq.XDocument.Parse("" + xml + "", System.Xml.Linq.LoadOptions.PreserveWhitespace); - var summary = Documentation.FlattenBlockPublic(doc.Root?.Element("summary")).Replace("\r\n", "\n").Trim(); - if (!string.IsNullOrWhiteSpace(summary)) - b[field.Name] = summary; - } - catch { } - } - return b.ToImmutable(); - } - - private static bool IsInjectedStatic(IParameterSymbol p) => IsInjectedType(p.Type); - - private static BoolSpecialKind ClassifyBool(ITypeSymbol type) - { - if (type.SpecialType == SpecialType.System_Boolean) - return BoolSpecialKind.Bool; - - if (type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } named && - named.TypeArguments[0].SpecialType == SpecialType.System_Boolean) - return BoolSpecialKind.NullableBool; - - return BoolSpecialKind.None; - } - - private static string GetSimpleTypeName(ITypeSymbol type) - { - if (type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nn) - { - var inner = GetSimpleTypeName(nn.TypeArguments[0]); - if (inner == "bool") - return "bool?"; - return inner + "?"; - } - - if (type.SpecialType == SpecialType.System_String) - return "string"; - if (type.SpecialType == SpecialType.System_Int32) - return "int"; - if (type.SpecialType == SpecialType.System_Int64) - return "long"; - if (type.SpecialType == SpecialType.System_Single) - return "float"; - if (type.SpecialType == SpecialType.System_Double) - return "double"; - if (type.SpecialType == SpecialType.System_Decimal) - return "decimal"; - if (type.SpecialType == SpecialType.System_Boolean) - return "bool"; - - if (type is INamedTypeSymbol named) - { - var fq = named.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - switch (fq) - { - case "global::System.DateTime": - return "DateTime"; - case "global::System.DateTimeOffset": - return "DateTimeOffset"; - case "global::System.TimeSpan": - return "TimeSpan"; - case "global::System.DateOnly": - return "DateOnly"; - } - } - - return "string"; - } - - private static bool ComputeRequired(IParameterSymbol p, BoolSpecialKind bs) - { - if (bs == BoolSpecialKind.Bool) - return false; - - if (p.HasExplicitDefaultValue) - return false; - - if (p.Type.NullableAnnotation == NullableAnnotation.Annotated) - return false; - - if (p.Type.IsReferenceType && p.Type.NullableAnnotation == NullableAnnotation.Annotated) - return false; - - if (p.Type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T }) - return false; - - if (p.Type.IsReferenceType && p.Type.NullableAnnotation != NullableAnnotation.Annotated) - return true; - - return p.Type.IsValueType && !p.HasExplicitDefaultValue && p.Type.NullableAnnotation != NullableAnnotation.Annotated; - } - - private static string? TryGetDefaultLiteral(IParameterSymbol p, BoolSpecialKind bs) - { - if (bs == BoolSpecialKind.Bool) - return "false"; - - if (!p.HasExplicitDefaultValue) - return null; - - var v = p.ExplicitDefaultValue; - if (v is null) - return p.Type.IsReferenceType ? "null" : "default"; - - return v switch - { - string s => SymbolDisplay.FormatPrimitive(s, quoteStrings: true, useHexadecimalNumbers: false), - char ch => SymbolDisplay.FormatPrimitive(ch, quoteStrings: true, useHexadecimalNumbers: false), - bool b => b ? "true" : "false", - IFormattable => Convert.ToString(v, CultureInfo.InvariantCulture) ?? "default", - _ => "default" - }; - } - - private static string SafeLocalName(string name) - { - var k = Naming.ToCliLongName(name).Replace("-", "_"); - if (k.Length == 0) - return "arg"; - if (!char.IsLetter(k[0]) && k[0] != '_') - return "v_" + k; - if (CSharpKeywords.Contains(k)) - return "@" + k; - return k; - } - - private static readonly HashSet CSharpKeywords = new HashSet(StringComparer.Ordinal) - { - "abstract", "as", "base", "bool", "break", "byte", "case", "catch", "char", "checked", - "class", "const", "continue", "decimal", "default", "delegate", "do", "double", "else", - "enum", "event", "explicit", "extern", "false", "finally", "fixed", "float", "for", - "foreach", "goto", "if", "implicit", "in", "int", "interface", "internal", "is", "lock", - "long", "namespace", "new", "null", "object", "operator", "out", "override", "params", - "private", "protected", "public", "readonly", "ref", "return", "sbyte", "sealed", "short", - "sizeof", "stackalloc", "static", "string", "struct", "switch", "this", "throw", "true", - "try", "typeof", "uint", "ulong", "unchecked", "unsafe", "ushort", "using", "virtual", - "void", "volatile", "while" - }; - } - - private enum ParameterKind - { - Flag, - Positional, - Injected, - /// - /// A flattened member from a global or namespace options type injected into this command. - /// Participates in bool-switch / short-opt / canon-name detection so the flag is parsed correctly, - /// but is skipped by value-declaration and binding emission (the value is obtained from a - /// locally-reconstructed options instance instead). - /// - OptionsInjected - } - - private enum CliScalarKind - { - Primitive, - Enum, - FileInfo, - DirectoryInfo, - Uri, - CustomParser, - Collection - } - - private enum BoolSpecialKind - { - None, - Bool, - NullableBool - } - - private static class Naming - { - public static string ToCommandName(string name) => ToKebabCase(StripCommandSuffixes(name)); - - public static string ToCliLongName(string name) => ToKebabCase(name); - - public static string ToTypeSegmentName(string typeName) => ToKebabCase(StripCommandSuffixes(typeName)); - - public static string SanitizeIdentifier(string commandName) - { - var sb = new StringBuilder(); - foreach (var c in commandName) - { - if (char.IsLetterOrDigit(c)) - sb.Append(c); - else - sb.Append('_'); - } - - return sb.Length == 0 ? "cmd" : sb.ToString(); - } - - private static string StripCommandSuffixes(string typeName) - { - string[] suffixes = ["Commands", "Command", "Handlers", "Handler"]; - foreach (var s in suffixes) - { - if (typeName.EndsWith(s, StringComparison.Ordinal) && typeName.Length > s.Length) - return typeName.Substring(0, typeName.Length - s.Length); - } - - return typeName; - } - - private static string ToKebabCase(string name) - { - if (string.IsNullOrEmpty(name)) - return name; - - var sb = new StringBuilder(); - for (var i = 0; i < name.Length; i++) - { - var c = name[i]; - if (char.IsUpper(c)) - { - if (i > 0 && (char.IsLower(name[i - 1]) || (i + 1 < name.Length && char.IsLower(name[i + 1])))) - sb.Append('-'); - sb.Append(char.ToLowerInvariant(c)); - } - else - sb.Append(c); - } - - return sb.ToString(); - } - } - - private readonly record struct ParamDoc(char? ShortOpt, ImmutableArray Aliases, string Description, string? ExplicitLongName = null); - - private static class ParamDocParser - { - public static ParamDoc Parse(string text) - { - text = text.Trim(); - if (text.Length == 0) - return new ParamDoc(null, ImmutableArray.Empty, ""); - - var parts = text.Split(','); - char? shortOpt = null; - string? explicitLongName = null; - var aliases = ImmutableArray.CreateBuilder(); - var i = 0; - for (; i < parts.Length; i++) - { - var seg = parts[i].Trim(); - if (seg.Length == 0) - { - i++; - break; - } - - if (LooksLikeShortFlag(seg)) - { - if (shortOpt is null) - shortOpt = seg[1]; - continue; - } - - if (LooksLikeLongFlag(seg)) - { - // First --long-name becomes the primary CLI name (overrides the derived name). - // Subsequent --long-names become aliases. - if (explicitLongName is null) - explicitLongName = seg.Substring(2); - else - aliases.Add(seg.Substring(2)); - continue; - } - - break; - } - - var desc = i >= parts.Length ? "" : string.Join(",", parts, i, parts.Length - i).Trim(); - return new ParamDoc(shortOpt, aliases.ToImmutable(), desc, explicitLongName); - } - - private static bool LooksLikeShortFlag(string seg) => - seg.Length == 2 && seg[0] == '-' && seg[1] != '-' && (char.IsLetterOrDigit(seg[1])); - - private static bool LooksLikeLongFlag(string seg) => - seg.Length > 2 && seg.StartsWith("--", StringComparison.Ordinal); - } - - private static class HelpLayout - { - public static string FormatOptionLeftCell(ParameterModel p) - { - if (p.Special == BoolSpecialKind.Bool) - { - if (p.ShortOpt is char c) - return "-" + c + ", " + "--" + p.CliLongName; - return "--" + p.CliLongName; - } - - if (p.Special == BoolSpecialKind.NullableBool) - { - if (p.ShortOpt is char nc) - return "-" + nc + ", " + "--[no-]" + p.CliLongName; - return "--[no-]" + p.CliLongName; - } - - var th = TypeHint(p); - var sb = new StringBuilder(); - if (p.ShortOpt is char ch) - { - sb.Append('-').Append(ch).Append(", "); - } - - foreach (var a in p.Aliases) - { - if (string.Equals(a, p.CliLongName, StringComparison.OrdinalIgnoreCase)) - continue; - sb.Append("--").Append(a).Append(", "); - } - - sb.Append("--").Append(p.CliLongName); - if (p.Special == BoolSpecialKind.None) - sb.Append(' ').Append(th); - - return sb.ToString(); - } - - public static string TypeHint(ParameterModel p) - { - if (InferValidationDerivedTypeHint(p) is string vh) - return vh; - - switch (p.ScalarKind) - { - case CliScalarKind.Collection: - return ""; - case CliScalarKind.Enum: - return ""; - case CliScalarKind.FileInfo: - return ""; - case CliScalarKind.DirectoryInfo: - return ""; - case CliScalarKind.Uri: - return ""; - case CliScalarKind.CustomParser: - return ""; - default: - break; - } - - return p.TypeName switch - { - "string" => "", - "int" => "", - "long" => "", - "float" => "", - "double" => "", - "decimal" => "", - "bool" => "", - "bool?" => "", - "DateTime" or "DateTime?" => "", - "DateTimeOffset" or "DateTimeOffset?" => "", - "TimeSpan" or "TimeSpan?" => "", - "DateOnly" or "DateOnly?" => "", - _ => "" - }; - } - - private static string? InferValidationDerivedTypeHint(ParameterModel p) - { - if (p.Validations.IsDefaultOrEmpty || p.ScalarKind == CliScalarKind.Collection) - return null; - - // Nullable reference/value does not switch placeholders: email is only for CLR string bindings; url for string/Uri scheme rules. - if (p.Validations.Any(static v => v is EmailConstraint)) - { - if (HasClrSemanticStringBinding(p)) - return ""; - } - - if (p.Validations.Any(static v => v is UrlConstraint or UriSchemeConstraint)) - return ""; - - return null; - } - - private static bool HasClrSemanticStringBinding(ParameterModel p) => - p.ScalarKind == CliScalarKind.Primitive && IsClrStringParameterTypeName(p.TypeName); - - private static bool IsClrStringParameterTypeName(string? typeName) => - typeName is not null && (typeName == "string" || typeName == "string?"); - } - - private readonly record struct MethodDocumentation( - string SummaryOneLiner, - string RemarksRendered, - string ExamplesRendered, - string SummaryInnerXml, - string RemarksInnerXml, - ImmutableDictionary ParamDocsRaw, - ImmutableDictionary ParamSeparators); - - private static class Documentation - { - public static MethodDocumentation ParseMethod(string? xml, CSharpParseOptions parseOptions) - { - if (string.IsNullOrWhiteSpace(xml)) - return new MethodDocumentation("", "", "", "", "", ImmutableDictionary.Empty, - ImmutableDictionary.Empty); - - try - { - var doc = XDocument.Parse("" + xml + "", LoadOptions.PreserveWhitespace); - var root = doc.Root; - if (root is null) - return new MethodDocumentation("", "", "", "", "", ImmutableDictionary.Empty, - ImmutableDictionary.Empty); - - var summary = WhitespaceCollapsePattern.Replace(FlattenBlock(root.Element("summary")).Replace("\r\n", "\n"), " ").Trim(); - var remarks = FlattenBlock(root.Element("remarks")).Replace("\r\n", "\n").Trim(); - var summaryInner = GetElementInnerXml(root.Element("summary")); - var remarksInner = GetElementInnerXml(root.Element("remarks")); - var examples = string.Join("\n\n", root.Elements("example") - .Select(e => FlattenBlock(e).Replace("\r\n", "\n").Trim()) - .Where(s => !string.IsNullOrWhiteSpace(s))); - var paramMap = - ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); - var sepMap = - ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); - foreach (var pe in root.Elements("param")) - { - var name = pe.Attribute("name")?.Value; - if (string.IsNullOrEmpty(name)) - continue; - - var sepEl = pe.Elements().FirstOrDefault(e => e.Name.LocalName == "separator"); - if (sepEl is not null && !string.IsNullOrEmpty(sepEl.Value)) - sepMap[name!] = sepEl.Value.Trim(); - - paramMap[name!] = FlattenParam(pe); - } - - return new MethodDocumentation(summary, remarks, examples, summaryInner, remarksInner, paramMap.ToImmutable(), sepMap.ToImmutable()); - } - catch - { - return new MethodDocumentation("", "", "", "", "", ImmutableDictionary.Empty, - ImmutableDictionary.Empty); - } - } - - private static string GetElementInnerXml(XElement? el) - { - if (el is null) - return ""; - return string.Concat(el.Nodes().Select(n => n.ToString())); - } - - public static string GetParamDocFromType(INamedTypeSymbol type, string parameterName, Compilation? compilation = null, string? fallbackXml = null) - { - var xml = type.GetDocumentationCommentXml(); - if (string.IsNullOrWhiteSpace(xml)) - xml = GetDocumentationXmlFromMetadataReference(type, compilation); - if (string.IsNullOrWhiteSpace(xml)) - xml = fallbackXml; - return GetParamDocFromXmlFragment(xml, parameterName); - } - - /// Extracts <param name="…"> text from documentation XML (handles <member>-wrapped compiler output). - public static string GetParamDocFromXmlFragment(string? xml, string parameterName) - { - if (string.IsNullOrWhiteSpace(xml)) - return ""; - try - { - var doc = XDocument.Parse("" + xml + "", LoadOptions.PreserveWhitespace); - var root = doc.Root; - if (root is null) - return ""; - foreach (var pe in root.Descendants()) - { - if (!string.Equals(pe.Name.LocalName, "param", StringComparison.Ordinal)) - continue; - if (string.Equals(pe.Attribute("name")?.Value, parameterName, StringComparison.Ordinal)) - return FlattenParam(pe); - } - } - catch - { - // ignore - } - - return ""; - } - - public static string GetPropertySummaryLine(IPropertySymbol prop, Compilation? compilation = null, string? fallbackXml = null) - { - var xml = prop.GetDocumentationCommentXml(); - if (string.IsNullOrWhiteSpace(xml)) - xml = GetDocumentationXmlFromMetadataReference(prop, compilation); - if (string.IsNullOrWhiteSpace(xml)) - xml = fallbackXml; - // Compiler / GetDocumentationCommentXml often wraps content in ; use the same - // summary resolution as types (descendant ) so help text is not dropped. - return GetTypeSummaryLine(xml); - } - - public static string GetFieldSummaryLine(IFieldSymbol field, Compilation? compilation = null, string? fallbackXml = null) - { - var xml = field.GetDocumentationCommentXml(); - if (string.IsNullOrWhiteSpace(xml)) - xml = GetDocumentationXmlFromMetadataReference(field, compilation); - if (string.IsNullOrWhiteSpace(xml)) - xml = fallbackXml; - return GetTypeSummaryLine(xml); - } - - public static string GetDocumentationXmlFromMetadataReference(ISymbol symbol, Compilation? compilation, string? artifactsPath = null) - { - if (compilation is null) - return ""; - var docId = symbol.GetDocumentationCommentId(); - if (string.IsNullOrWhiteSpace(docId)) - return ""; - var containingAssembly = symbol.ContainingAssembly; - if (containingAssembly is null) - return ""; - -#pragma warning disable RS1035 // Required to load companion XML docs for metadata references. - foreach (var reference in compilation.References) - { - if (compilation.GetAssemblyOrModuleSymbol(reference) is not IAssemblySymbol referenceAssembly) - continue; - if (!SymbolEqualityComparer.Default.Equals(referenceAssembly, containingAssembly)) - continue; - var referenceDisplay = reference.Display; - if (string.IsNullOrWhiteSpace(referenceDisplay)) - continue; - foreach (var xmlPath in GetXmlDocumentationCandidates(referenceDisplay!, containingAssembly.Name, artifactsPath)) - { - if (!global::System.IO.File.Exists(xmlPath)) - continue; - try - { - var doc = XDocument.Load(xmlPath, LoadOptions.PreserveWhitespace); - var member = doc.Root? - .Element("members")? - .Elements("member") - .FirstOrDefault(m => string.Equals(m.Attribute("name")?.Value, docId, StringComparison.Ordinal)); - if (member is not null) - return string.Concat(member.Nodes().Select(n => n.ToString())); - } - catch - { - // ignore malformed external XML docs - } - } - } -#pragma warning restore RS1035 - - return ""; - } - - private static IEnumerable GetXmlDocumentationCandidates(string referencePath, string assemblyName, string? artifactsPath = null) - { - var yielded = new HashSet(StringComparer.OrdinalIgnoreCase); - static string NormalizePathSeparators(string p) => p.Replace('\\', '/'); - - var direct = global::System.IO.Path.ChangeExtension(referencePath, ".xml"); - if (!string.IsNullOrWhiteSpace(direct) && yielded.Add(direct)) - yield return direct; - - var referenceDir = global::System.IO.Path.GetDirectoryName(referencePath); - if (!string.IsNullOrWhiteSpace(referenceDir)) - { - var byAssemblyName = global::System.IO.Path.Combine(referenceDir!, assemblyName + ".xml"); - if (yielded.Add(byAssemblyName)) - yield return byAssemblyName; - var leaf = global::System.IO.Path.GetFileName(referenceDir); - if (string.Equals(leaf, "ref", StringComparison.OrdinalIgnoreCase) || - string.Equals(leaf, "refint", StringComparison.OrdinalIgnoreCase)) - { - var parent = global::System.IO.Path.GetDirectoryName(referenceDir!); - if (!string.IsNullOrWhiteSpace(parent)) - { - var sibling = global::System.IO.Path.Combine(parent, assemblyName + ".xml"); - if (yielded.Add(sibling)) - yield return sibling; - } - } - } - - var normalized = NormalizePathSeparators(referencePath); - var objMarker = "/obj/"; - var idxObj = normalized.IndexOf(objMarker, StringComparison.OrdinalIgnoreCase); - if (idxObj >= 0) - { - var binPath = normalized.Substring(0, idxObj) + "/bin/" + normalized.Substring(idxObj + objMarker.Length); - binPath = binPath.Replace("/refint/", "/").Replace("/ref/", "/"); - var platformPath = binPath.Replace('/', global::System.IO.Path.DirectorySeparatorChar); - var binXml = global::System.IO.Path.ChangeExtension(platformPath, ".xml"); - if (yielded.Add(binXml)) - yield return binXml; - } - - // When is known, build the canonical bin/{Project}/{Pivot}/{Assembly}.xml - // path even when the reference points to a ref/, refint/, or obj/ subdirectory. - if (!string.IsNullOrWhiteSpace(artifactsPath)) - { - var normalizedArtifacts = NormalizePathSeparators(artifactsPath!.TrimEnd('/', '\\')); - if (normalized.Length > normalizedArtifacts.Length && - normalized[normalizedArtifacts.Length] == '/' && - normalized.StartsWith(normalizedArtifacts, StringComparison.OrdinalIgnoreCase)) - { - var relPath = normalized.Substring(normalizedArtifacts.Length + 1); - if (relPath.StartsWith("obj/", StringComparison.OrdinalIgnoreCase)) - relPath = "bin/" + relPath.Substring(4); - relPath = ReplaceOrdinalIgnoreCase(ReplaceOrdinalIgnoreCase(relPath, "/refint/", "/"), "/ref/", "/"); - var platformPath = (normalizedArtifacts + "/" + relPath) - .Replace('/', global::System.IO.Path.DirectorySeparatorChar); - var artifactXml = global::System.IO.Path.ChangeExtension(platformPath, ".xml"); - if (yielded.Add(artifactXml)) - yield return artifactXml; - } - } - } - - private static string ReplaceOrdinalIgnoreCase(string input, string oldValue, string newValue) - { - var idx = input.IndexOf(oldValue, StringComparison.OrdinalIgnoreCase); - if (idx < 0) return input; - return input.Substring(0, idx) + newValue + input.Substring(idx + oldValue.Length); - } - - /// First line of <summary> for a type symbol (handles <member>-wrapped XML from Roslyn). - public static string GetTypeSummaryLine(string? xml) - { - if (string.IsNullOrWhiteSpace(xml)) - return ""; - try - { - var doc = XDocument.Parse("" + xml + "", LoadOptions.PreserveWhitespace); - var root = doc.Root; - if (root is null) - return ""; - var sum = root.Element("summary"); - if (sum is null) - { - foreach (var e in root.Descendants()) - { - if (e.Name.LocalName == "summary") - { - sum = e; - break; - } - } - } - - if (sum is null) - return ""; - return FlattenBlock(sum).Replace("\r\n", "\n").Trim(); - } - catch - { - return ""; - } - } - - /// Inner XML of <summary> and <remarks> for a type symbol. - public static (string SummaryInnerXml, string RemarksInnerXml) GetTypeDocumentation(string? xml) - { - if (string.IsNullOrWhiteSpace(xml)) - return ("", ""); - try - { - var doc = XDocument.Parse("" + xml + "", LoadOptions.PreserveWhitespace); - var root = doc.Root; - if (root is null) - return ("", ""); - // Roslyn wraps type XML in a element - var search = root.Element("member") ?? root; - return (GetElementInnerXml(search.Element("summary")), GetElementInnerXml(search.Element("remarks"))); - } - catch - { - return ("", ""); - } - } - - private static string FlattenParam(XElement param) - { - var sb = new StringBuilder(); - foreach (var n in param.Nodes()) - { - if (n is XElement e && e.Name.LocalName == "separator") - continue; - FlattenNodes(new[] { n }, sb); - } - - return sb.ToString().Trim(); - } - - public static string FlattenBlockPublic(XElement? element) => FlattenBlock(element); - - private static string FlattenBlock(XElement? element) - { - if (element is null) - return ""; - - var sb = new StringBuilder(); - FlattenNodes(element.Nodes(), sb); - return sb.ToString(); - } - - private static void FlattenNodes(IEnumerable nodes, StringBuilder sb) - { - foreach (var n in nodes) - { - switch (n) - { - case XText t: - sb.Append(t.Value); - break; - case XElement e when e.Name.LocalName == "para": - if (sb.Length > 0) - sb.AppendLine(); - FlattenNodes(e.Nodes(), sb); - break; - case XElement e when e.Name.LocalName == "code": - sb.AppendLine(); - foreach (var c in e.Nodes()) - { - if (c is XText tx) - sb.Append(" ").AppendLine(tx.Value.TrimEnd()); - } - - break; - case XElement e when e.Name.LocalName == "list": - if (sb.Length > 0) - sb.AppendLine(); - foreach (var item in e.Elements().Where(x => x.Name.LocalName == "item")) - { - sb.Append(" - "); - var desc = item.Element("description"); - if (desc is not null) - FlattenNodes(desc.Nodes(), sb); - else - FlattenNodes(item.Nodes(), sb); - sb.AppendLine(); - } - - break; - case XElement e when e.Name.LocalName == "c": - sb.Append(e.Value.Trim()); - break; - case XElement e when e.Name.LocalName == "paramref": - { - var pn = e.Attribute("name")?.Value; - if (!string.IsNullOrEmpty(pn)) - sb.Append(pn); - break; - } - case XElement e when e.Name.LocalName == "typeparamref": - { - var tn = e.Attribute("name")?.Value; - if (!string.IsNullOrEmpty(tn)) - sb.Append(tn); - break; - } - case XElement e when e.Name.LocalName == "see": - AppendSeeForListing(e, sb); - break; - case XElement e: - FlattenNodes(e.Nodes(), sb); - break; - } - } - } - - private static void AppendSeeForListing(XElement e, StringBuilder sb) - { - var lang = e.Attribute("langword")?.Value; - if (!string.IsNullOrEmpty(lang)) - { - sb.Append(lang); - return; - } - - var href = e.Attribute("href")?.Value; - if (!string.IsNullOrEmpty(href)) - { - var vis = string.IsNullOrWhiteSpace(e.Value) ? href! : e.Value.Trim(); - sb.Append(vis); - return; - } - - var cref = e.Attribute("cref")?.Value; - if (!string.IsNullOrEmpty(cref)) - { - var vis = string.IsNullOrWhiteSpace(e.Value) ? CrefShortNameForListing(cref!) : e.Value.Trim(); - sb.Append(vis); - return; - } - - FlattenNodes(e.Nodes(), sb); - } - - private static string CrefShortNameForListing(string cref) - { - if (string.IsNullOrEmpty(cref)) - return ""; - var colon = cref.IndexOf(':'); - var tail = colon >= 0 ? cref.Substring(colon + 1) : cref; - var dot = tail.LastIndexOf('.'); - var name = dot >= 0 ? tail.Substring(dot + 1) : tail; - var paren = name.IndexOf('('); - if (paren >= 0) - name = name.Substring(0, paren); - return name; - } - } - - private static class UsageSynopsis - { - /// Minimal usage tail: required flags and positionals explicitly; optional switches and flags fold into a single [options]. - public static string Build(ImmutableArray parameters) - { - var parts = new List(); - var needsOptions = false; - - foreach (var p in parameters) - { - if (p.Kind == ParameterKind.Injected || p.Kind == ParameterKind.OptionsInjected) - continue; - - if (p.Kind == ParameterKind.Positional) - { - string seg; - if (p.IsVariadic) - seg = p.IsRequired ? $"<{p.CliLongName}...>" : $"[<{p.CliLongName}...>]"; - else - seg = p.IsRequired ? $"<{p.CliLongName}>" : $"[<{p.CliLongName}>]"; - parts.Add(seg); - continue; - } - - if (p.Kind != ParameterKind.Flag) - continue; - - if (p.Special == BoolSpecialKind.Bool) - { - needsOptions = true; - continue; - } - - if (p.Special == BoolSpecialKind.NullableBool) - { - needsOptions = true; - continue; - } - - if (p.IsCollection) - { - if (p.IsRequired) - { - var typeHint = HelpLayout.TypeHint(p); - parts.Add($"--{p.CliLongName} {typeHint}"); - } - else - { - needsOptions = true; - } - - continue; - } - - var typeHintScalar = HelpLayout.TypeHint(p); - if (p.IsRequired) - parts.Add($"--{p.CliLongName} {typeHintScalar}"); - else - needsOptions = true; - } - - if (needsOptions) - parts.Add("[options]"); - - return string.Join(" ", parts); - } - } } diff --git a/tests/Nullean.Argh.Tests.CliHost/Nullean.Argh.Tests.CliHost.csproj b/tests/Nullean.Argh.Tests.CliHost/Nullean.Argh.Tests.CliHost.csproj index 978f819..e67abc4 100644 --- a/tests/Nullean.Argh.Tests.CliHost/Nullean.Argh.Tests.CliHost.csproj +++ b/tests/Nullean.Argh.Tests.CliHost/Nullean.Argh.Tests.CliHost.csproj @@ -3,7 +3,7 @@ Exe net10.0 - Nullean.Argh.Tests.CliHost +Nullean.Argh.Tests.CliHost enable enable false