JAL-629 Add some shortnames to args. Add Opt.OUTPUT and allow --output=*.ext to be...
[jalview.git] / src / jalview / bin / argparser / Arg.java
1 package jalview.bin.argparser;
2
3 import java.util.ArrayList;
4 import java.util.Arrays;
5 import java.util.EnumSet;
6 import java.util.Iterator;
7 import java.util.List;
8 import java.util.Locale;
9 import java.util.stream.Collectors;
10
11 import jalview.bin.Cache;
12 import jalview.util.ChannelProperties;
13
14 public enum Arg
15 {
16
17   // Initialising arguments (BOOTSTRAP)
18   HELP("h", "Display this help statement", Opt.UNARY, Opt.BOOTSTRAP),
19   VERSION("v",
20           "Display the version of "
21                   + ChannelProperties.getProperty("app_name"),
22           Opt.UNARY, Opt.BOOTSTRAP),
23   HEADLESS(
24           "Run Jalview in headless mode. No GUI interface will be created and Jalview will quit after all arguments have been processed.",
25           Opt.UNARY, Opt.BOOTSTRAP),
26   JABAWS("Set a different URL to connect to a JABAWS server.", Opt.STRING,
27           Opt.BOOTSTRAP),
28   NEWS("Show (or don't show) the news feed.", true, Opt.BOOLEAN,
29           Opt.BOOTSTRAP),
30   SPLASH("Show (or don't show) the About Jalview splash screen.", true,
31           Opt.BOOLEAN, Opt.BOOTSTRAP),
32   QUESTIONNAIRE(
33           "Show (or don't show) the questionnaire if one is available.",
34           true, Opt.BOOLEAN, Opt.BOOTSTRAP),
35   USAGESTATS("Send (or don't send) initial launch usage stats.", true,
36           Opt.BOOLEAN, Opt.BOOTSTRAP),
37   WEBSERVICEDISCOVERY(
38           "Attempt (or don't attempt) to connect to JABAWS web services.",
39           true, Opt.BOOLEAN, Opt.BOOTSTRAP),
40   PROPS("Use a file as the preferences file instead of the usual ~/"
41           + ChannelProperties.getProperty("preferences.filename")
42           + " file.", Opt.STRING, Opt.BOOTSTRAP),
43   DEBUG("d", "Start Jalview in debug log level.", Opt.BOOLEAN,
44           Opt.BOOTSTRAP),
45   TRACE("Start Jalview in trace log level.", Opt.BOOLEAN, Opt.BOOTSTRAP,
46           Opt.SECRET),
47   QUIET("q",
48           "Stop all output to STDOUT (after the Java Virtual Machine has started). Use ‑‑quiet a second time to stop all output to STDERR.",
49           Opt.UNARY, Opt.MULTI, Opt.BOOTSTRAP),
50   INITSUBSTITUTIONS(
51           "Set ‑‑substitutions to be initially enabled (or initially disabled).",
52           true, Opt.BOOLEAN, Opt.BOOTSTRAP, Opt.NOACTION),
53
54   // Opening an alignment
55   OPEN("Opens one or more alignment files or URLs in new alignment windows.",
56           Opt.STRING, Opt.LINKED, Opt.INCREMENTDEFAULTCOUNTER, Opt.MULTI,
57           Opt.GLOB, Opt.ALLOWSUBSTITUTIONS, Opt.INPUT),
58   APPEND("Appends one or more alignment files or URLs to the open alignment window (or opens a new alignment if none already open).",
59           Opt.STRING, Opt.LINKED, Opt.MULTI, Opt.GLOB,
60           Opt.ALLOWSUBSTITUTIONS, Opt.INPUT),
61   TITLE("Specifies the title for the open alignment window as string.",
62           Opt.STRING, Opt.LINKED),
63   COLOUR("Applies the colour scheme to the open alignment window. Valid values are:\n"
64           + "clustal,\n" + "blosum62,\n" + "pc-identity,\n" + "zappo,\n"
65           + "taylor,\n" + "gecos-flower,\n" + "gecos-blossom,\n"
66           + "gecos-sunset,\n" + "gecos-ocean,\n" + "hydrophobic,\n"
67           + "helix-propensity,\n" + "strand-propensity,\n"
68           + "turn-propensity,\n" + "buried-index,\n" + "nucleotide,\n"
69           + "nucleotide-ambiguity,\n" + "purine-pyrimidine,\n"
70           + "rna-helices,\n" + "t-coffee-scores,\n" + "sequence-id.",
71           Opt.STRING, Opt.LINKED, Opt.ALLOWALL),
72   FEATURES("Add a feature file or URL to the open alignment.", Opt.STRING,
73           Opt.LINKED, Opt.MULTI, Opt.ALLOWSUBSTITUTIONS),
74   TREE("Add a tree file or URL to the open alignment.", Opt.STRING,
75           Opt.LINKED, Opt.MULTI, Opt.ALLOWSUBSTITUTIONS),
76   SORTBYTREE(
77           "Enforces sorting (or not sorting) the open alignment in the order of an attached phylogenetic tree.",
78           true, Opt.LINKED, Opt.BOOLEAN, Opt.ALLOWALL),
79   ANNOTATIONS("Add an annotations file or URL to the open alignment.",
80           Opt.STRING, Opt.LINKED, Opt.MULTI, Opt.ALLOWSUBSTITUTIONS),
81   SHOWANNOTATIONS(
82           "Enforces showing (or not showing) alignment annotations.",
83           Opt.BOOLEAN, Opt.LINKED, Opt.ALLOWALL),
84   WRAP("Enforces wrapped (or not wrapped) alignment formatting.",
85           Opt.BOOLEAN, Opt.LINKED, Opt.ALLOWALL),
86   NOSTRUCTURE(
87           "Do not open or process any 3D structure in the ‑‑open or ‑‑append files.",
88           Opt.UNARY, Opt.LINKED, Opt.ALLOWALL),
89
90   // Adding a 3D structure
91   STRUCTURE(
92           "Load a structure file or URL associated with a sequence in the open alignment.\n"
93                   + "The sequence to be associated with can be specified with a following --seqid argument, or the subval modifier seqid=ID can be used. A subval INDEX can also be used to specify the INDEX-th sequence in the open alignment.",
94           Opt.STRING, Opt.LINKED, Opt.MULTI, Opt.ALLOWSUBSTITUTIONS),
95   SEQID("Specify the sequence name for the preceding --structure to be associated with.",
96           Opt.STRING, Opt.LINKED, Opt.MULTI, Opt.ALLOWSUBSTITUTIONS),
97   PAEMATRIX("Add a PAE json matrix file to the preceding --structure.",
98           Opt.STRING, Opt.LINKED, Opt.MULTI, Opt.ALLOWSUBSTITUTIONS),
99   TEMPFAC("Set the type of temperature factor. Possible values are:\n"
100           + "default,\n" + "plddt.", Opt.STRING, Opt.LINKED),
101   STRUCTUREVIEWER(
102           "Set the structure viewer to use to open the 3d structure file specified in previous --structure to name. Possible values of name are:\n"
103                   + "none,\n" + "jmol,\n" + "chimera,\n" + "chimerax,\n"
104                   + "pymol.",
105           Opt.STRING, Opt.LINKED, Opt.MULTI),
106   NOTEMPFAC(
107           "Do not show the temperature factor annotation for the preceding --structure.",
108           Opt.UNARY, Opt.LINKED, Opt.ALLOWALL, Opt.SECRET), // keep this secret
109                                                             // until it
110   // works!
111   SHOWSSANNOTATIONS(null, Opt.BOOLEAN, Opt.LINKED, Opt.ALLOWALL),
112
113   // Outputting files
114   IMAGE("Output an image of the open alignment window. Format is specified by the subval modifier, a following --type argument or guessed from the file extension. Valid formats/extensions are:\n"
115           + "svg,\n" + "png,\n" + "eps,\n" + "html,\n" + "biojs.",
116           Opt.STRING, Opt.LINKED, Opt.ALLOWSUBSTITUTIONS, Opt.ALLOWALL,
117           Opt.REQUIREINPUT, Opt.OUTPUT),
118   TYPE("Set the image format for the preceding --image to name. Valid values for name are: svg,\n"
119           + "png,\n" + "eps,\n" + "html,\n" + "biojs.", Opt.STRING,
120           Opt.LINKED, Opt.ALLOWALL),
121   TEXTRENDERER(
122           "Sets whether text in a vector image format (SVG, HTML, EPS) should be rendered as text or vector line-art. Possible values for name are:\n"
123                   + "text,\n" + "lineart.",
124           Opt.STRING, Opt.LINKED, Opt.ALLOWALL),
125   SCALE("Sets a scaling for bitmap image format (PNG). Should be given as a floating point number. If used in conjunction with --width and --height then the smallest scaling will be used (scale, width and height provide bounds for the image).",
126           Opt.STRING, Opt.LINKED, Opt.ALLOWALL),
127   WIDTH("Sets a width for bitmap image format (PNG) with the height maintaining the aspect ratio. Should be given as a positive integer. If used in conjunction with --scale and --height then the smallest scaling will be used (scale, width and height provide bounds for the image).",
128           Opt.STRING, Opt.LINKED, Opt.ALLOWALL),
129   HEIGHT("Sets a height for bitmap image format (PNG) with the width maintaining the aspect ratio. Should be given as a positive integer. If used in conjunction with --scale and --width then the smallest scaling will be used (scale, width and height provide bounds for the image).",
130           Opt.STRING, Opt.LINKED, Opt.ALLOWALL),
131   OUTPUT("Export the open alignment to file filename. The format name is specified by the subval modifier format=name, a following --format name argument or guessed from the file extension. Valid format names (and file extensions) are:\n"
132           + "fasta (fa, fasta, mfa, fastq),\n" + "pfam (pfam),\n"
133           + "stockholm (sto, stk),\n" + "pir (pir),\n" + "blc (blc),\n"
134           + "amsa (amsa),\n" + "json (json),\n" + "pileup (pileup),\n"
135           + "msf (msf),\n" + "clustal (aln),\n" + "phylip (phy),\n"
136           + "jalview (jvp, jar).", Opt.STRING, Opt.LINKED,
137           Opt.ALLOWSUBSTITUTIONS, Opt.ALLOWALL, Opt.REQUIREINPUT,
138           Opt.OUTPUT),
139   FORMAT("Sets the format for the preceding --output file. Valid formats are:\n"
140           + "fasta,\n" + "pfam,\n" + "stockholm,\n" + "pir,\n" + "blc,\n"
141           + "amsa,\n" + "json,\n" + "pileup,\n" + "msf,\n" + "clustal,\n"
142           + "phylip,\n" + "jalview.", Opt.STRING, Opt.LINKED, Opt.ALLOWALL),
143   GROOVY("Process a groovy script in the file for the open alignment.",
144           Opt.STRING, Opt.LINKED, Opt.MULTI, Opt.ALLOWSUBSTITUTIONS,
145           Opt.ALLOWALL),
146   BACKUPS("Enable (or disable) writing backup files when saving an ‑‑output file. This applies to the current open alignment.  To apply to all ‑‑output and ‑‑image files, use after ‑‑all.",
147           true, Opt.BOOLEAN, Opt.LINKED, Opt.ALLOWALL),
148   OVERWRITE(
149           "Enable (or disable) overwriting of output files without backups enabled. This applies to the current open alignment.  To apply to all ‑‑output and ‑‑image files, use after ‑‑all.",
150           Opt.BOOLEAN, Opt.LINKED, Opt.ALLOWALL),
151   CLOSE("Close the current open alignment window. This occurs after other output arguments. This applies to the current open alignment.  To apply to all ‑‑output and ‑‑image files, use after ‑‑all.",
152           Opt.UNARY, Opt.LINKED, Opt.ALLOWALL),
153
154   // controlling flow of arguments
155   NEW("Move on to a new alignment window. This will ensure --append will start a new alignment window and other linked arguments will apply to the new alignment window.",
156           Opt.UNARY, Opt.MULTI, Opt.NOACTION, Opt.INCREMENTDEFAULTCOUNTER),
157   SUBSTITUTIONS(
158           "The following argument values allow (or don't allow) subsituting filename parts. This is initially true. Valid substitutions are {basename} - the filename-without-extension of the currently --opened file (or first --appended file),\n"
159                   + "{dirname}, - the directory (folder) name of the currently --opened file (or first --appended file),\n"
160                   + "{argfilebasename} - the filename-without-extension of the current --argfile,\n"
161                   + "{argfiledirname} - the directory (folder) name of the current --argfile,\n"
162                   + "{n} - the value of the index counter (starting at 0).\n"
163                   + "{++n} - increase and substitute the value of the index counter,\n"
164                   + "{} - the value of the current alignment window default index.",
165           true, Opt.BOOLEAN, Opt.MULTI, Opt.NOACTION),
166   ARGFILE("Open one or more files filename and read, line-by-line, as arguments to Jalview.\n"
167           + "Values in an argfile should be given with an equals sign (\"=\") separator with no spaces.\n"
168           + "Note that if you use one or more --argfile arguments then all other non-initialising arguments will be ignored.",
169           Opt.STRING, Opt.MULTI, Opt.BOOTSTRAP, Opt.GLOB,
170           Opt.ALLOWSUBSTITUTIONS),
171   NPP("n++",
172           "Increase the index counter used in argument value substitutions.",
173           Opt.UNARY, Opt.MULTI, Opt.NOACTION),
174   ALL("Apply the following output arguments to all sets of linked arguments.",
175           Opt.BOOLEAN, Opt.MULTI, Opt.NOACTION),
176   QUIT("After all files have been opened, appended and output, quit Jalview. In ‑‑headless mode this already happens.",
177           Opt.UNARY),
178
179   // secret options
180   TESTOUTPUT(
181           "Allow specific stdout information.  For testing purposes only.",
182           Opt.UNARY, Opt.BOOTSTRAP, Opt.SECRET), // do not show this to the user
183   SETPROP("Set an individual Java System property.", Opt.STRING, Opt.MULTI,
184           Opt.BOOTSTRAP, Opt.SECRET), // not in use yet
185   NIL("This argument does nothing on its own, but can be used with linkedIds.",
186           Opt.UNARY, Opt.LINKED, Opt.MULTI, Opt.NOACTION, Opt.SECRET),
187
188   // private options (inserted during arg processing)
189   SETARGFILE(
190           "Sets the current value of the argfilename.  Inserted before argfilecontents.",
191           Opt.UNARY, Opt.LINKED, Opt.STRING, Opt.MULTI, Opt.PRIVATE,
192           Opt.NOACTION),
193   UNSETARGFILE(
194           "Unsets the current value of the argfilename.  Inserted after argfile contents.",
195           Opt.UNARY, Opt.LINKED, Opt.MULTI, Opt.PRIVATE, Opt.NOACTION),
196
197   // these last two have no purpose in the normal Jalview application but are
198   // used by jalview.bin.Launcher to set memory settings. They are not used by
199   // argparser but are here for Usage statement reasons.
200   JVMMEMPC(
201           "Only available with standalone executable jar or jalview.bin.Launcher.\n"
202                   + "Limit maximum heap size (memory) to PERCENT% of total physical memory detected. This defaults to 90 if total physical memory can be detected.\n"
203                   + "The equals sign (\"=\") separator must be used with no spaces.",
204           Opt.NOACTION, Opt.BOOTSTRAP, Opt.STRING),
205   JVMMEMMAX(
206           "Only available with standalone executable jar or jalview.bin.Launcher.\n"
207                   + "Limit maximum heap size (memory) to MAXMEMORY. MAXMEMORY can be specified in bytes, kilobytes(k), megabytes(m), gigabytes(g) or if you're lucky enough, terabytes(t). This defaults to 32g if total physical memory can be detected, or to 8g if total physical memory cannot be detected.\n"
208                   + "The equals sign (\"=\") separator must be used with no spaces.",
209           Opt.NOACTION, Opt.BOOTSTRAP, Opt.STRING),
210
211   ;
212
213   public static enum Opt
214   {
215     BOOLEAN, // This Arg can be specified as --arg or --noarg to give true or
216              // false. A default can be given with setOptions(bool, Opt....).
217              // Use ArgParser.isSet(Arg) to see if this arg was not specified.
218     STRING, // This Arg can accept a value either through --arg=value or --arg
219             // value.
220     UNARY, // This Arg is a boolean value, true if present, false if not. Like
221            // BOOLEAN but without the --noarg option.
222     MULTI, // This Arg can be specified multiple times. Multiple values are
223            // stored in the ArgValuesMap (along with their positional index) for
224            // each linkedId.
225     LINKED, // This Arg can be linked to others through a --arg[linkedId] or
226             // --arg[linkedId]=value. If no linkedId is specified then the
227             // current default linkedId will be used.
228     NODUPLICATEVALUES, // This Arg can only have one value (per linkedId). The
229                        // first value will be used and subsequent values ignored
230                        // with a warning.
231     BOOTSTRAP, // This Arg value(s) can be determined at an earlier stage than
232                // non-BOOTSTRAP Args. Substitutions do not happen in BOOTSTRAP
233                // Args and they cannot be linked or contain SubVals. See
234                // jalview.bin.argparser.BootstrapArgs.
235     GLOB, // This Arg can expand wildcard filename "globs" (e.g.
236           // path/*/filename*). If the Arg value is given as --arg filename*
237           // then the shell will have expanded the glob already, but if
238           // specified as --arg=filename* then the Java glob expansion method
239           // will be used (see FileUtils.getFilenamesFromGlob()). Note that this
240           // might be different from the shell expansion rules.
241     NOACTION, // This Arg does not perform a data task, usually used to control
242               // flow in ArgParser.parse(args).
243     ALLOWSUBSTITUTIONS, // This Arg allows substitutions in its linkedId,
244                         // SubVals and values.
245     PRIVATE, // This Arg is used internally, and cannot be specified by the
246              // user.
247     SECRET, // This Arg is used by development processes and although it can be
248             // set by the user, it is not displayed to the user.
249     ALLOWALL, // This Arg can use the '*' linkedId to apply to all known
250               // linkedIds
251     INCREMENTDEFAULTCOUNTER, // If an Arg has this option and the default
252                              // linkedId is used, the defaultLinkedIdCounter is
253                              // incremented *first*.
254     INPUT, // This Arg counts as an input for REQUIREINPUT
255     REQUIREINPUT, // This Arg can only be applied via --all if there is an
256                   // input (i.e. --open or --append)
257     OUTPUT, // This Arg provides an output filename. With Opt.ALLOWALL *.ext is
258             // shorthand for --all --output={basename}.ext
259   }
260
261   private final String[] argNames;
262
263   private Opt[] argOptions;
264
265   private boolean defaultBoolValue = false;
266
267   private String description = null;
268
269   private Arg(String description, Opt... options)
270   {
271     this(null, description, false, options);
272   }
273
274   private Arg(String description, boolean defaultBoolean, Opt... options)
275   {
276     this(null, description, defaultBoolean, options);
277   }
278
279   private Arg(String alternativeName, String description, Opt... options)
280   {
281     this(alternativeName, description, false, options);
282   }
283
284   private Arg(String alternativeName, String description,
285           boolean defaultBoolean, Opt... options)
286   {
287     this.argNames = alternativeName != null
288             ? new String[]
289             { this.getName(), alternativeName }
290             : new String[]
291             { this.getName() };
292     this.description = description;
293     this.defaultBoolValue = defaultBoolean;
294     this.setOptions(options);
295   }
296
297   public String argString()
298   {
299     return argString(false);
300   }
301
302   public String negateArgString()
303   {
304     return argString(true);
305   }
306
307   private String argString(boolean negate)
308   {
309     StringBuilder sb = new StringBuilder(ArgParser.DOUBLEDASH);
310     if (negate && hasOption(Opt.BOOLEAN))
311       sb.append(ArgParser.NEGATESTRING);
312     sb.append(getName());
313     return sb.toString();
314   }
315
316   public String toLongString()
317   {
318     StringBuilder sb = new StringBuilder();
319     sb.append(this.getClass().getName()).append('.').append(this.name());
320     sb.append('(');
321     if (getNames().length > 0)
322       sb.append('"');
323     sb.append(String.join("\", \"", getNames()));
324     if (getNames().length > 0)
325       sb.append('"');
326     sb.append(")\n");
327     sb.append("\nOpt: ");
328     // map List<Opt> to List<String> for the String.join
329     List<String> optList = Arrays.asList(argOptions).stream()
330             .map(opt -> opt.name()).collect(Collectors.toList());
331     sb.append(String.join(", ", optList));
332     sb.append("\n");
333     return sb.toString();
334   }
335
336   public String[] getNames()
337   {
338     return argNames;
339   }
340
341   public String getName()
342   {
343     return this.name().toLowerCase(Locale.ROOT).replace('_', '-');
344   }
345
346   @Override
347   public final String toString()
348   {
349     return getName();
350   }
351
352   public boolean hasOption(Opt o)
353   {
354     if (argOptions == null)
355       return false;
356     for (Opt option : argOptions)
357     {
358       if (o == option)
359         return true;
360     }
361     return false;
362   }
363
364   protected void setOptions(Opt... options)
365   {
366     this.argOptions = options;
367   }
368
369   protected boolean getDefaultBoolValue()
370   {
371     return defaultBoolValue;
372   }
373
374   protected String getDescription()
375   {
376     return description;
377   }
378
379   public static String booleanArgString(Arg a)
380   {
381     StringBuilder sb = new StringBuilder(a.argString());
382     if (a.hasOption(Opt.BOOLEAN))
383     {
384       sb.append('/');
385       sb.append(a.negateArgString());
386     }
387     return sb.toString();
388   }
389
390   public static final String usage()
391   {
392     StringBuilder sb = new StringBuilder();
393
394     sb.append(ChannelProperties.getProperty("app_name"));
395     String version = Cache.getDefault("VERSION", null);
396     if (version != null)
397     {
398       sb.append(" version ");
399       sb.append(Cache.getDefault("VERSION", "unknown"));
400     }
401     sb.append(System.lineSeparator());
402     sb.append("Usage: jalview [files...] [args]");
403     sb.append(System.lineSeparator());
404     sb.append(System.lineSeparator());
405
406     int maxArgLength = 0;
407     for (Arg a : EnumSet.allOf(Arg.class))
408     {
409       if (a.hasOption(Opt.PRIVATE) || a.hasOption(Opt.SECRET))
410         continue;
411
412       StringBuilder argSb = new StringBuilder();
413       argSb.append(a.hasOption(Opt.BOOLEAN) ? booleanArgString(a)
414               : a.argString());
415       if (a.hasOption(Opt.STRING))
416         argSb.append("=value");
417       if (argSb.length() > maxArgLength)
418         maxArgLength = argSb.length();
419     }
420
421     // might want to sort these
422     for (Arg a : EnumSet.allOf(Arg.class))
423     {
424       if (a.hasOption(Opt.PRIVATE) || a.hasOption(Opt.SECRET))
425         continue;
426       StringBuilder argSb = new StringBuilder();
427       argSb.append(a.hasOption(Opt.BOOLEAN) ? booleanArgString(a)
428               : a.argString());
429       if (a.hasOption(Opt.STRING))
430         argSb.append("=value");
431       Iterator<String> descLines = null;
432       if (a.getDescription() != null)
433       {
434         descLines = Arrays.stream(a.getDescription().split("\\n"))
435                 .iterator();
436       }
437       sb.append(String.format("%-" + maxArgLength + "s", argSb.toString()));
438       boolean first = true;
439       if (descLines != null)
440       {
441         while (descLines.hasNext())
442         {
443           if (first)
444             sb.append(" - ");
445           else
446             sb.append(" ".repeat(maxArgLength + 3));
447           sb.append(descLines.next());
448           sb.append(System.lineSeparator());
449           first = false;
450         }
451       }
452
453       List<String> options = new ArrayList<>();
454
455       if (a.hasOption(Opt.BOOLEAN))
456       {
457         options.add("default " + (a.getDefaultBoolValue() ? a.argString()
458                 : a.negateArgString()));
459       }
460
461       if (a.hasOption(Opt.MULTI))
462       {
463         options.add("multiple");
464       }
465
466       if (a.hasOption(Opt.LINKED))
467       {
468         options.add("can be linked");
469       }
470
471       if (a.hasOption(Opt.GLOB))
472       {
473         options.add("allows file globs");
474       }
475
476       if (a.hasOption(Opt.ALLOWSUBSTITUTIONS))
477       {
478         options.add("allows substitutions");
479       }
480
481       if (a.hasOption(Opt.ALLOWALL))
482       {
483         options.add("can be applied to all linked arguments");
484       }
485
486       if (a.hasOption(Opt.PRIVATE))
487       {
488         options.add("for internal use only");
489       }
490
491       if (a.hasOption(Opt.SECRET))
492       {
493         options.add("for development use only");
494       }
495
496       if (options.size() > 0)
497       {
498         if (first)
499           sb.append(" - ");
500         else
501           sb.append(" ".repeat(maxArgLength + 3));
502         sb.append("(");
503         sb.append(String.join("; ", options));
504         sb.append(')');
505         sb.append(System.lineSeparator());
506       }
507       sb.append(System.lineSeparator());
508     }
509     return sb.toString();
510   }
511 }