Allow custom help providers (#1259)

Allow custom help providers

* Version option will show in help even with a default command

* Reserve `-v` and `--version` as special Spectre.Console command line arguments (nb. breaking change for Spectre.Console users who have a default command with a settings class that uses either of these switches).

* Help writer correctly determines if trailing commands exist and whether to display them as optional or mandatory in the usage statement.

* Ability to control the number of indirect commands to display in the help text when the command itself doesn't have any examples of its own. Defaults to 5 (for backward compatibility) but can be set to any integer or zero to disable completely.

* Significant increase in unit test coverage for the help writer.

* Minor grammatical improvements to website documentation.
This commit is contained in:
Frank Ray
2023-09-08 08:51:33 +01:00
committed by GitHub
parent 813a53cdfa
commit 131b37fff8
70 changed files with 1646 additions and 330 deletions

View File

@ -8,85 +8,87 @@ internal sealed class CommandExecutor
{
_registrar = registrar ?? throw new ArgumentNullException(nameof(registrar));
_registrar.Register(typeof(DefaultPairDeconstructor), typeof(DefaultPairDeconstructor));
}
}
public async Task<int> Execute(IConfiguration configuration, IEnumerable<string> args)
{
if (configuration == null)
{
throw new ArgumentNullException(nameof(configuration));
}
_registrar.RegisterInstance(typeof(IConfiguration), configuration);
_registrar.RegisterLazy(typeof(IAnsiConsole), () => configuration.Settings.Console.GetConsole());
}
args ??= new List<string>();
_registrar.RegisterInstance(typeof(IConfiguration), configuration);
_registrar.RegisterLazy(typeof(IAnsiConsole), () => configuration.Settings.Console.GetConsole());
// Register the help provider
var defaultHelpProvider = new HelpProvider(configuration.Settings);
_registrar.RegisterInstance(typeof(IHelpProvider), defaultHelpProvider);
// Create the command model.
var model = CommandModelBuilder.Build(configuration);
_registrar.RegisterInstance(typeof(CommandModel), model);
_registrar.RegisterDependencies(model);
// No default command?
if (model.DefaultCommand == null)
{
// Got at least one argument?
var firstArgument = args.FirstOrDefault();
if (firstArgument != null)
{
// Asking for version? Kind of a hack, but it's alright.
// We should probably make this a bit better in the future.
if (firstArgument.Equals("--version", StringComparison.OrdinalIgnoreCase) ||
firstArgument.Equals("-v", StringComparison.OrdinalIgnoreCase))
{
var console = configuration.Settings.Console.GetConsole();
console.WriteLine(ResolveApplicationVersion(configuration));
return 0;
}
}
}
_registrar.RegisterDependencies(model);
// Asking for version? Kind of a hack, but it's alright.
// We should probably make this a bit better in the future.
if (args.Contains("-v") || args.Contains("--version"))
{
var console = configuration.Settings.Console.GetConsole();
console.WriteLine(ResolveApplicationVersion(configuration));
return 0;
}
// Parse and map the model against the arguments.
var parsedResult = ParseCommandLineArguments(model, configuration.Settings, args);
// Currently the root?
if (parsedResult?.Tree == null)
{
// Display help.
configuration.Settings.Console.SafeRender(HelpWriter.Write(model, configuration.Settings.ShowOptionDefaultValues));
return 0;
}
// Get the command to execute.
var leaf = parsedResult.Tree.GetLeafCommand();
if (leaf.Command.IsBranch || leaf.ShowHelp)
{
// Branches can't be executed. Show help.
configuration.Settings.Console.SafeRender(HelpWriter.WriteCommand(model, leaf.Command, configuration.Settings.ShowOptionDefaultValues));
return leaf.ShowHelp ? 0 : 1;
}
// Is this the default and is it called without arguments when there are required arguments?
if (leaf.Command.IsDefaultCommand && args.Count() == 0 && leaf.Command.Parameters.Any(p => p.Required))
{
// Display help for default command.
configuration.Settings.Console.SafeRender(HelpWriter.WriteCommand(model, leaf.Command, configuration.Settings.ShowOptionDefaultValues));
return 1;
}
// Register the arguments with the container.
var parsedResult = ParseCommandLineArguments(model, configuration.Settings, args);
// Register the arguments with the container.
_registrar.RegisterInstance(typeof(CommandTreeParserResult), parsedResult);
_registrar.RegisterInstance(typeof(IRemainingArguments), parsedResult.Remaining);
// Create the resolver and the context.
// Create the resolver.
using (var resolver = new TypeResolverAdapter(_registrar.Build()))
{
{
// Get the registered help provider, falling back to the default provider
// registered above if no custom implementations have been registered.
var helpProvider = resolver.Resolve(typeof(IHelpProvider)) as IHelpProvider ?? defaultHelpProvider;
// Currently the root?
if (parsedResult?.Tree == null)
{
// Display help.
configuration.Settings.Console.SafeRender(helpProvider.Write(model, null));
return 0;
}
// Get the command to execute.
var leaf = parsedResult.Tree.GetLeafCommand();
if (leaf.Command.IsBranch || leaf.ShowHelp)
{
// Branches can't be executed. Show help.
configuration.Settings.Console.SafeRender(helpProvider.Write(model, leaf.Command));
return leaf.ShowHelp ? 0 : 1;
}
// Is this the default and is it called without arguments when there are required arguments?
if (leaf.Command.IsDefaultCommand && args.Count() == 0 && leaf.Command.Parameters.Any(p => p.Required))
{
// Display help for default command.
configuration.Settings.Console.SafeRender(helpProvider.Write(model, leaf.Command));
return 1;
}
// Create the content.
var context = new CommandContext(parsedResult.Remaining, leaf.Command.Name, leaf.Command.Data);
// Execute the command tree.
return await Execute(leaf, parsedResult.Tree, context, resolver, configuration).ConfigureAwait(false);
}
}
}
private CommandTreeParserResult? ParseCommandLineArguments(CommandModel model, CommandAppSettings settings, IEnumerable<string> args)
#pragma warning disable CS8603 // Possible null reference return.
private CommandTreeParserResult ParseCommandLineArguments(CommandModel model, CommandAppSettings settings, IEnumerable<string> args)
{
var parser = new CommandTreeParser(model, settings.CaseSensitivity, settings.ParsingMode, settings.ConvertFlagsToRemainingArguments);
@ -113,7 +115,8 @@ internal sealed class CommandExecutor
return parsedResult;
}
#pragma warning restore CS8603 // Possible null reference return.
private static string ResolveApplicationVersion(IConfiguration configuration)
{
return

View File

@ -35,11 +35,11 @@ internal sealed class ComponentRegistry : IDisposable
foreach (var type in new HashSet<Type>(registration.RegistrationTypes))
{
if (!_registrations.ContainsKey(type))
{
_registrations.Add(type, new HashSet<ComponentRegistration>());
{
// Only add each registration type once.
_registrations.Add(type, new HashSet<ComponentRegistration>());
_registrations[type].Add(registration);
}
_registrations[type].Add(registration);
}
}

View File

@ -4,7 +4,8 @@ internal sealed class CommandAppSettings : ICommandAppSettings
{
public string? ApplicationName { get; set; }
public string? ApplicationVersion { get; set; }
public bool ShowOptionDefaultValues { get; set; }
public int MaximumIndirectExamples { get; set; }
public bool ShowOptionDefaultValues { get; set; }
public IAnsiConsole? Console { get; set; }
public ICommandInterceptor? Interceptor { get; set; }
public ITypeRegistrarFrontend Registrar { get; set; }
@ -24,7 +25,8 @@ internal sealed class CommandAppSettings : ICommandAppSettings
{
Registrar = new TypeRegistrar(registrar);
CaseSensitivity = CaseSensitivity.All;
ShowOptionDefaultValues = true;
ShowOptionDefaultValues = true;
MaximumIndirectExamples = 5;
}
public bool IsTrue(Func<CommandAppSettings, bool> func, string environmentVariableName)

View File

@ -19,6 +19,19 @@ internal sealed class Configurator : IUnsafeConfigurator, IConfigurator, IConfig
Settings = new CommandAppSettings(registrar);
Examples = new List<string[]>();
}
public void SetHelpProvider(IHelpProvider helpProvider)
{
// Register the help provider
_registrar.RegisterInstance(typeof(IHelpProvider), helpProvider);
}
public void SetHelpProvider<T>()
where T : IHelpProvider
{
// Register the help provider
_registrar.Register(typeof(IHelpProvider), typeof(T));
}
public void AddExample(params string[] args)
{

View File

@ -17,7 +17,7 @@ internal sealed class Configurator<TSettings> : IUnsafeBranchConfigurator, IConf
_command.Description = description;
}
public void AddExample(string[] args)
public void AddExample(params string[] args)
{
_command.Examples.Add(args);
}

View File

@ -1,428 +0,0 @@
namespace Spectre.Console.Cli;
internal static class HelpWriter
{
private sealed class HelpArgument
{
public string Name { get; }
public int Position { get; set; }
public bool Required { get; }
public string? Description { get; }
public HelpArgument(string name, int position, bool required, string? description)
{
Name = name;
Position = position;
Required = required;
Description = description;
}
public static IReadOnlyList<HelpArgument> Get(CommandInfo? command)
{
var arguments = new List<HelpArgument>();
arguments.AddRange(command?.Parameters?.OfType<CommandArgument>()?.Select(
x => new HelpArgument(x.Value, x.Position, x.Required, x.Description))
?? Array.Empty<HelpArgument>());
return arguments;
}
}
private sealed class HelpOption
{
public string? Short { get; }
public string? Long { get; }
public string? Value { get; }
public bool? ValueIsOptional { get; }
public string? Description { get; }
public object? DefaultValue { get; }
public HelpOption(string? @short, string? @long, string? @value, bool? valueIsOptional, string? description, object? defaultValue)
{
Short = @short;
Long = @long;
Value = value;
ValueIsOptional = valueIsOptional;
Description = description;
DefaultValue = defaultValue;
}
public static IReadOnlyList<HelpOption> Get(CommandModel model, CommandInfo? command)
{
var parameters = new List<HelpOption>();
parameters.Add(new HelpOption("h", "help", null, null, "Prints help information", null));
// At the root and no default command?
if (command == null && model?.DefaultCommand == null)
{
parameters.Add(new HelpOption("v", "version", null, null, "Prints version information", null));
}
parameters.AddRange(command?.Parameters.OfType<CommandOption>().Where(o => !o.IsHidden).Select(o =>
new HelpOption(
o.ShortNames.FirstOrDefault(), o.LongNames.FirstOrDefault(),
o.ValueName, o.ValueIsOptional, o.Description,
o.ParameterKind == ParameterKind.Flag && o.DefaultValue?.Value is false ? null : o.DefaultValue?.Value))
?? Array.Empty<HelpOption>());
return parameters;
}
}
public static IEnumerable<IRenderable> Write(CommandModel model, bool writeOptionsDefaultValues)
{
return WriteCommand(model, null, writeOptionsDefaultValues);
}
public static IEnumerable<IRenderable> WriteCommand(CommandModel model, CommandInfo? command, bool writeOptionsDefaultValues)
{
var container = command as ICommandContainer ?? model;
var isDefaultCommand = command?.IsDefaultCommand ?? false;
var result = new List<IRenderable>();
result.AddRange(GetDescription(command));
result.AddRange(GetUsage(model, command));
result.AddRange(GetExamples(model, command));
result.AddRange(GetArguments(command));
result.AddRange(GetOptions(model, command, writeOptionsDefaultValues));
result.AddRange(GetCommands(model, container, isDefaultCommand));
return result;
}
private static IEnumerable<IRenderable> GetDescription(CommandInfo? command)
{
if (command?.Description == null)
{
yield break;
}
var composer = new Composer();
composer.Style("yellow", "DESCRIPTION:").LineBreak();
composer.Text(command.Description).LineBreak();
yield return composer.LineBreak();
}
private static IEnumerable<IRenderable> GetUsage(CommandModel model, CommandInfo? command)
{
var composer = new Composer();
composer.Style("yellow", "USAGE:").LineBreak();
composer.Tab().Text(model.GetApplicationName());
var parameters = new List<string>();
if (command == null)
{
parameters.Add("[grey][[OPTIONS]][/]");
parameters.Add("[aqua]<COMMAND>[/]");
}
else
{
foreach (var current in command.Flatten())
{
var isCurrent = current == command;
if (!current.IsDefaultCommand)
{
if (isCurrent)
{
parameters.Add($"[underline]{current.Name.EscapeMarkup()}[/]");
}
else
{
parameters.Add($"{current.Name.EscapeMarkup()}");
}
}
if (current.Parameters.OfType<CommandArgument>().Any())
{
if (isCurrent)
{
foreach (var argument in current.Parameters.OfType<CommandArgument>()
.Where(a => a.Required).OrderBy(a => a.Position).ToArray())
{
parameters.Add($"[aqua]<{argument.Value.EscapeMarkup()}>[/]");
}
}
var optionalArguments = current.Parameters.OfType<CommandArgument>().Where(x => !x.Required).ToArray();
if (optionalArguments.Length > 0 || !isCurrent)
{
foreach (var optionalArgument in optionalArguments)
{
parameters.Add($"[silver][[{optionalArgument.Value.EscapeMarkup()}]][/]");
}
}
}
if (isCurrent)
{
parameters.Add("[grey][[OPTIONS]][/]");
}
}
if (command.IsBranch)
{
parameters.Add("[aqua]<COMMAND>[/]");
}
}
composer.Join(" ", parameters);
composer.LineBreak();
return new[]
{
composer,
};
}
private static IEnumerable<IRenderable> GetExamples(CommandModel model, CommandInfo? command)
{
var maxExamples = int.MaxValue;
var examples = command?.Examples ?? model.Examples ?? new List<string[]>();
if (examples.Count == 0)
{
// Since we're not checking direct examples,
// make sure that we limit the number of examples.
maxExamples = 5;
// Get the current root command.
var root = command ?? (ICommandContainer)model;
var queue = new Queue<ICommandContainer>(new[] { root });
// Traverse the command tree and look for examples.
// As soon as a node contains commands, bail.
while (queue.Count > 0)
{
var current = queue.Dequeue();
foreach (var cmd in current.Commands.Where(x => !x.IsHidden))
{
if (cmd.Examples.Count > 0)
{
examples.AddRange(cmd.Examples);
}
queue.Enqueue(cmd);
}
if (examples.Count >= maxExamples)
{
break;
}
}
}
if (examples.Count > 0)
{
var composer = new Composer();
composer.LineBreak();
composer.Style("yellow", "EXAMPLES:").LineBreak();
for (var index = 0; index < Math.Min(maxExamples, examples.Count); index++)
{
var args = string.Join(" ", examples[index]);
composer.Tab().Text(model.GetApplicationName()).Space().Style("grey", args);
composer.LineBreak();
}
return new[] { composer };
}
return Array.Empty<IRenderable>();
}
private static IEnumerable<IRenderable> GetArguments(CommandInfo? command)
{
var arguments = HelpArgument.Get(command);
if (arguments.Count == 0)
{
return Array.Empty<IRenderable>();
}
var result = new List<IRenderable>
{
new Markup(Environment.NewLine),
new Markup("[yellow]ARGUMENTS:[/]"),
new Markup(Environment.NewLine),
};
var grid = new Grid();
grid.AddColumn(new GridColumn { Padding = new Padding(4, 4), NoWrap = true });
grid.AddColumn(new GridColumn { Padding = new Padding(0, 0) });
foreach (var argument in arguments.Where(x => x.Required).OrderBy(x => x.Position))
{
grid.AddRow(
$"[silver]<{argument.Name.EscapeMarkup()}>[/]",
argument.Description?.TrimEnd('.') ?? " ");
}
foreach (var argument in arguments.Where(x => !x.Required).OrderBy(x => x.Position))
{
grid.AddRow(
$"[grey][[{argument.Name.EscapeMarkup()}]][/]",
argument.Description?.TrimEnd('.') ?? " ");
}
result.Add(grid);
return result;
}
private static IEnumerable<IRenderable> GetOptions(CommandModel model, CommandInfo? command, bool writeDefaultValues)
{
// Collect all options into a single structure.
var parameters = HelpOption.Get(model, command);
if (parameters.Count == 0)
{
return Array.Empty<IRenderable>();
}
var result = new List<IRenderable>
{
new Markup(Environment.NewLine),
new Markup("[yellow]OPTIONS:[/]"),
new Markup(Environment.NewLine),
};
var helpOptions = parameters.ToArray();
var defaultValueColumn = writeDefaultValues && helpOptions.Any(e => e.DefaultValue != null);
var grid = new Grid();
grid.AddColumn(new GridColumn { Padding = new Padding(4, 4), NoWrap = true });
if (defaultValueColumn)
{
grid.AddColumn(new GridColumn { Padding = new Padding(0, 0, 4, 0) });
}
grid.AddColumn(new GridColumn { Padding = new Padding(0, 0) });
static string GetOptionParts(HelpOption option)
{
var builder = new StringBuilder();
if (option.Short != null)
{
builder.Append('-').Append(option.Short.EscapeMarkup());
if (option.Long != null)
{
builder.Append(", ");
}
}
else
{
builder.Append(" ");
if (option.Long != null)
{
builder.Append(" ");
}
}
if (option.Long != null)
{
builder.Append("--").Append(option.Long.EscapeMarkup());
}
if (option.Value != null)
{
builder.Append(' ');
if (option.ValueIsOptional ?? false)
{
builder.Append("[grey][[").Append(option.Value.EscapeMarkup()).Append("]][/]");
}
else
{
builder.Append("[silver]<").Append(option.Value.EscapeMarkup()).Append(">[/]");
}
}
return builder.ToString();
}
if (defaultValueColumn)
{
grid.AddRow(" ", "[lime]DEFAULT[/]", " ");
}
foreach (var option in helpOptions)
{
var columns = new List<string> { GetOptionParts(option) };
if (defaultValueColumn)
{
static string Bold(object obj) => $"[bold]{obj.ToString().EscapeMarkup()}[/]";
var defaultValue = option.DefaultValue switch
{
null => " ",
"" => " ",
Array { Length: 0 } => " ",
Array array => string.Join(", ", array.Cast<object>().Select(Bold)),
_ => Bold(option.DefaultValue),
};
columns.Add(defaultValue);
}
columns.Add(option.Description?.TrimEnd('.') ?? " ");
grid.AddRow(columns.ToArray());
}
result.Add(grid);
return result;
}
private static IEnumerable<IRenderable> GetCommands(
CommandModel model,
ICommandContainer command,
bool isDefaultCommand)
{
var commands = isDefaultCommand ? model.Commands : command.Commands;
commands = commands.Where(x => !x.IsHidden).ToList();
if (commands.Count == 0)
{
return Array.Empty<IRenderable>();
}
var result = new List<IRenderable>
{
new Markup(Environment.NewLine),
new Markup("[yellow]COMMANDS:[/]"),
new Markup(Environment.NewLine),
};
var grid = new Grid();
grid.AddColumn(new GridColumn { Padding = new Padding(4, 4), NoWrap = true });
grid.AddColumn(new GridColumn { Padding = new Padding(0, 0) });
foreach (var child in commands)
{
var arguments = new Composer();
arguments.Style("silver", child.Name.EscapeMarkup());
arguments.Space();
foreach (var argument in HelpArgument.Get(child).Where(a => a.Required))
{
arguments.Style("silver", $"<{argument.Name.EscapeMarkup()}>");
arguments.Space();
}
if (model.TrimTrailingPeriod)
{
grid.AddRow(
arguments.ToString().TrimEnd(),
child.Description?.TrimEnd('.') ?? " ");
}
else
{
grid.AddRow(
arguments.ToString().TrimEnd(),
child.Description ?? " ");
}
}
result.Add(grid);
return result;
}
}

View File

@ -1,6 +1,6 @@
namespace Spectre.Console.Cli;
internal sealed class CommandArgument : CommandParameter
internal sealed class CommandArgument : CommandParameter, ICommandArgument
{
public string Value { get; }
public int Position { get; set; }

View File

@ -1,6 +1,6 @@
namespace Spectre.Console.Cli;
internal sealed class CommandInfo : ICommandContainer
internal sealed class CommandInfo : ICommandContainer, ICommandInfo
{
public string Name { get; }
public HashSet<string> Aliases { get; }
@ -20,8 +20,14 @@ internal sealed class CommandInfo : ICommandContainer
// only branches can have a default command
public CommandInfo? DefaultCommand => IsBranch ? Children.FirstOrDefault(c => c.IsDefaultCommand) : null;
public bool IsHidden { get; }
public bool IsHidden { get; }
IReadOnlyList<ICommandInfo> Help.ICommandContainer.Commands => Children.Cast<ICommandInfo>().ToList();
ICommandInfo? Help.ICommandContainer.DefaultCommand => DefaultCommand;
IReadOnlyList<ICommandParameter> ICommandInfo.Parameters => Parameters.Cast<ICommandParameter>().ToList();
ICommandInfo? ICommandInfo.Parent => Parent;
IReadOnlyList<string[]> Help.ICommandContainer.Examples => (IReadOnlyList<string[]>)Examples;
public CommandInfo(CommandInfo? parent, ConfiguredCommand prototype)
{
Parent = parent;
@ -48,19 +54,5 @@ internal sealed class CommandInfo : ICommandContainer
Description = description.Description;
}
}
}
public List<CommandInfo> Flatten()
{
var result = new Stack<CommandInfo>();
var current = this;
while (current != null)
{
result.Push(current);
current = current.Parent;
}
return result.ToList();
}
}
}

View File

@ -1,14 +1,18 @@
namespace Spectre.Console.Cli;
internal sealed class CommandModel : ICommandContainer
internal sealed class CommandModel : ICommandContainer, ICommandModel
{
public string? ApplicationName { get; }
public ParsingMode ParsingMode { get; }
public IList<CommandInfo> Commands { get; }
public IList<string[]> Examples { get; }
public bool TrimTrailingPeriod { get; }
public CommandInfo? DefaultCommand => Commands.FirstOrDefault(c => c.IsDefaultCommand);
public CommandInfo? DefaultCommand => Commands.FirstOrDefault(c => c.IsDefaultCommand);
string ICommandModel.ApplicationName => GetApplicationName(ApplicationName);
IReadOnlyList<ICommandInfo> Help.ICommandContainer.Commands => Commands.Cast<ICommandInfo>().ToList();
ICommandInfo? Help.ICommandContainer.DefaultCommand => DefaultCommand;
IReadOnlyList<string[]> Help.ICommandContainer.Examples => (IReadOnlyList<string[]>)Examples;
public CommandModel(
CommandAppSettings settings,
@ -17,22 +21,32 @@ internal sealed class CommandModel : ICommandContainer
{
ApplicationName = settings.ApplicationName;
ParsingMode = settings.ParsingMode;
TrimTrailingPeriod = settings.TrimTrailingPeriod;
Commands = new List<CommandInfo>(commands ?? Array.Empty<CommandInfo>());
Examples = new List<string[]>(examples ?? Array.Empty<string[]>());
}
public string GetApplicationName()
}
/// <summary>
/// Gets the name of the application.
/// If the provided <paramref name="applicationName"/> is not null or empty,
/// it is returned. Otherwise the name of the current application
/// is determined based on the executable file's name.
/// </summary>
/// <param name="applicationName">The optional name of the application.</param>
/// <returns>
/// The name of the application, or a default value of "?" if no valid application name can be determined.
/// </returns>
private static string GetApplicationName(string? applicationName)
{
return
ApplicationName ??
applicationName ??
Path.GetFileName(GetApplicationFile()) ?? // null is propagated by GetFileName
"?";
}
private static string? GetApplicationFile()
{
var location = Assembly.GetEntryAssembly()?.Location;
var location = Assembly.GetEntryAssembly()?.Location;
if (string.IsNullOrWhiteSpace(location))
{
// this is special case for single file executable

View File

@ -1,6 +1,6 @@
namespace Spectre.Console.Cli;
internal sealed class CommandOption : CommandParameter
internal sealed class CommandOption : CommandParameter, ICommandOption
{
public IReadOnlyList<string> LongNames { get; }
public IReadOnlyList<string> ShortNames { get; }

View File

@ -1,6 +1,6 @@
namespace Spectre.Console.Cli;
internal abstract class CommandParameter : ICommandParameterInfo
internal abstract class CommandParameter : ICommandParameterInfo, ICommandParameter
{
public Guid Id { get; }
public Type ParameterType { get; }
@ -17,8 +17,10 @@ internal abstract class CommandParameter : ICommandParameterInfo
public string PropertyName => Property.Name;
public virtual bool WantRawValue => ParameterType.IsPairDeconstructable()
&& (PairDeconstructor != null || Converter == null);
&& (PairDeconstructor != null || Converter == null);
public bool IsFlag => ParameterKind == ParameterKind.Flag;
protected CommandParameter(
Type parameterType, ParameterKind parameterKind, PropertyInfo property,
string? description, TypeConverterAttribute? converter,