@jbrowse/launch-args 5.0.0-beta.17 → 5.0.0-beta.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/esm/launchPlan.js CHANGED
@@ -1,30 +1,75 @@
1
1
  import path from 'node:path';
2
- import { fileAdapter, fileTrack, isSequenceAdapter, sequenceAssembly, trackFlags, } from './fileInput.js';
2
+ import { buildComparative, comparativeRole, hasComparativeArgs, isSyntenyFlag, syntenyFlags, } from './comparative.js';
3
+ import { checkModifiers } from './displaySettings.js';
4
+ import { fileTrack, genomeAssembly, genomeFlags, isGenomeFlag, isTrackFlag, trackFlags, } from './fileInput.js';
3
5
  import { genomeConfigUrl } from './genome.js';
4
- import { FILE_FLAG, isDataFile, modifierValue, splitModifier, } from './parseArgv.js';
6
+ import { FILE_FLAG, isUrl, locValue, splitModifier } from './parseArgv.js';
7
+ import { viewSettings } from './slotPath.js';
8
+ /**
9
+ * The views jb2export's subcommand words name, which Desktop takes as its
10
+ * first argument too.
11
+ */
12
+ export const launchViewTypes = {
13
+ lgv: 'LinearGenomeView',
14
+ synteny: 'LinearSyntenyView',
15
+ dotplot: 'DotplotView',
16
+ };
17
+ export function launchViewType(word) {
18
+ return Object.hasOwn(launchViewTypes, word)
19
+ ? launchViewTypes[word]
20
+ : undefined;
21
+ }
22
+ // the app applies modifiers once the track is open, so a bad one fails here
23
+ function showTrack(trackId, modifiers) {
24
+ try {
25
+ checkModifiers(modifiers);
26
+ }
27
+ catch (e) {
28
+ throw new Error(`${trackId}: ${e instanceof Error ? e.message : e}`, {
29
+ cause: e,
30
+ });
31
+ }
32
+ return { trackId, modifiers };
33
+ }
5
34
  export const launchFlags = new Set([
6
35
  'genome',
7
36
  'config',
8
37
  'assembly',
9
38
  'loc',
39
+ 'view',
10
40
  'track',
11
- 'fasta',
12
- 'aliases',
13
- 'cytobands',
41
+ ...genomeFlags,
14
42
  FILE_FLAG,
15
43
  ...trackFlags,
44
+ ...syntenyFlags,
45
+ ]);
46
+ /**
47
+ * jb2export's flags that take only true or false, so a file after one is a
48
+ * file of its own. Desktop reads them to report one without taking the file.
49
+ */
50
+ export const exportBooleanFlags = new Set([
51
+ 'noRasterize',
52
+ 'defaultSession',
53
+ 'showGridlines',
54
+ 'refseq',
55
+ 'passOnly',
56
+ 'resume',
57
+ 'manifest',
58
+ 'dryRun',
16
59
  ]);
17
- // consumed while building the config, so they reach the view as config
18
- const BUILD_MODIFIERS = new Set(['name', 'index', 'aliases', 'cytobands']);
19
60
  const PATH_MODIFIERS = new Set(['index', 'aliases', 'cytobands']);
20
- const URL_SCHEME = /^[a-z][a-z0-9+.-]*:\/\//i;
21
- function isTrackFlag(key) {
22
- return key === FILE_FLAG || trackFlags.includes(key);
61
+ function opensTrack(key) {
62
+ return key === FILE_FLAG || isTrackFlag(key);
23
63
  }
24
- function modifierName(mod) {
25
- return splitModifier(mod)?.[0] ?? '';
64
+ /** Whether a flag opens a data file, as a track, genome or comparison flag does. */
65
+ export function takesFile(key) {
66
+ return opensTrack(key) || isGenomeFlag(key) || isSyntenyFlag(key);
26
67
  }
27
- function uniqueId(used, base) {
68
+ /**
69
+ * `base`, or the first of `base-2`, `base-3`, ... that `used` lacks, which it
70
+ * adds to `used`.
71
+ */
72
+ export function uniqueId(used, base) {
28
73
  let id = base;
29
74
  for (let i = 2; used.has(id); i++) {
30
75
  id = `${base}-${i}`;
@@ -32,37 +77,26 @@ function uniqueId(used, base) {
32
77
  used.add(id);
33
78
  return id;
34
79
  }
80
+ function entryText([key, values]) {
81
+ return [...(key === FILE_FLAG ? [] : [`--${key}`]), ...values].join(' ');
82
+ }
35
83
  /**
36
84
  * The plan a command line describes, with every local path resolved against
37
85
  * `cwd`: the directory the command ran in, which is not the app's own once a
38
86
  * second launch hands its arguments to a running one.
87
+ *
88
+ * `viewType` is the view a subcommand word named. Without one, a second genome
89
+ * or a comparison file opens a synteny view, and anything else a linear one.
39
90
  */
40
- export function launchPlan(entries, cwd) {
41
- const abs = (file) => URL_SCHEME.test(file) ? file : path.resolve(cwd, file);
42
- const absMod = (mod) => PATH_MODIFIERS.has(modifierName(mod))
43
- ? `${modifierName(mod)}:${abs(mod.slice(mod.indexOf(':') + 1))}`
44
- : mod;
45
- const one = (key) => {
46
- const given = entries.filter(([k]) => k === key);
47
- if (given.length > 1) {
48
- throw new Error(`--${key} is given more than once`);
49
- }
50
- return given[0]?.[1][0];
91
+ export function launchPlan(entries, cwd, viewType) {
92
+ const abs = (file) => (isUrl(file) ? file : path.resolve(cwd, file));
93
+ const absMod = (mod) => {
94
+ const [name, value] = splitModifier(mod) ?? [];
95
+ return name && PATH_MODIFIERS.has(name) ? `${name}:${abs(value)}` : mod;
51
96
  };
52
- const genome = one('genome');
53
- const config = one('config');
54
- const fasta = one('fasta');
55
- if (genome && config) {
56
- throw new Error('pass --genome or --config, not both');
57
- }
58
- const configRef = genome
59
- ? genomeConfigUrl(genome)
60
- : config
61
- ? abs(config)
62
- : undefined;
63
97
  const resolved = entries.map(([key, values]) => {
64
98
  const [file, ...mods] = values;
65
- if (!isTrackFlag(key)) {
99
+ if (!takesFile(key)) {
66
100
  return [key, values];
67
101
  }
68
102
  else if (!file) {
@@ -72,12 +106,76 @@ export function launchPlan(entries, cwd) {
72
106
  return [key, [abs(file), ...mods.map(absMod)]];
73
107
  }
74
108
  });
75
- const fastaMods = entries.find(([key]) => key === 'fasta')?.[1].slice(1) ?? [];
76
- const genomeFile = fasta
77
- ? ['fasta', [abs(fasta), ...fastaMods.map(absMod)]]
78
- : resolved.find(([key, [file = '']]) => key === FILE_FLAG && isSequenceAdapter(fileAdapter(file)));
109
+ const type = viewType ??
110
+ (hasComparativeArgs(resolved) ? 'LinearSyntenyView' : 'LinearGenomeView');
111
+ return type === 'LinearGenomeView'
112
+ ? linearPlan(resolved, abs)
113
+ : comparativePlan(resolved, type);
114
+ }
115
+ function givenOnce(entries, key) {
116
+ const found = entries.filter(([k]) => k === key);
117
+ if (found.length > 1) {
118
+ throw new Error(`--${key} is given more than once`);
119
+ }
120
+ return found[0]?.[1];
121
+ }
122
+ const VIEW_NAMES = {
123
+ LinearSyntenyView: 'A synteny view',
124
+ DotplotView: 'A dotplot',
125
+ };
126
+ function comparativePlan(entries, type) {
127
+ const viewName = VIEW_NAMES[type];
128
+ if (entries.some(([key]) => key === 'config')) {
129
+ throw new Error(`${viewName} opens one row per genome, and --config names a config; pass --genome names or FASTA, 2bit or .chrom.sizes files`);
130
+ }
131
+ const data = buildComparative(entries);
132
+ const count = data.rows.length;
133
+ if (type === 'DotplotView' ? count !== 2 : count < 2) {
134
+ throw new Error(`${viewName} compares ${type === 'DotplotView' ? 'two genomes' : 'two genomes or more'}, and this command line names ${count}`);
135
+ }
136
+ const used = new Set();
137
+ for (const track of data.syntenyTracks) {
138
+ track.trackId = uniqueId(used, track.trackId);
139
+ }
140
+ return {
141
+ type,
142
+ assemblies: data.assemblies,
143
+ hosted: data.hosted.map(name => ({ name, config: genomeConfigUrl(name) })),
144
+ views: data.rows,
145
+ tracks: data.syntenyTracks,
146
+ view: viewSettings(givenOnce(entries, 'view') ?? []),
147
+ unapplied: entries
148
+ .filter(entry => !comparativeRole(entry) && entry[0] !== 'view')
149
+ .map(entryText),
150
+ };
151
+ }
152
+ function linearPlan(resolved, abs) {
153
+ const given = (key) => givenOnce(resolved, key);
154
+ const one = (key) => given(key)?.[0];
155
+ const genome = one('genome');
156
+ const config = one('config');
157
+ if (genome && config) {
158
+ throw new Error('pass --genome or --config, not both');
159
+ }
160
+ else if (config && isUrl(config) && !/^https?:\/\//i.test(config)) {
161
+ throw new Error(`--config ${config}: JBrowse fetches a config over http or https, or reads one from a file`);
162
+ }
163
+ const configRef = genome
164
+ ? genomeConfigUrl(genome)
165
+ : config
166
+ ? abs(config)
167
+ : undefined;
168
+ const roles = resolved.map(comparativeRole);
169
+ const comparison = resolved.find((_, i) => roles[i] === 'synteny');
170
+ const [genomeFile, otherGenome] = resolved.filter(([key], i) => roles[i] === 'genome' && key !== 'genome');
79
171
  const genomePath = genomeFile?.[1][0];
80
- if (genomePath && configRef) {
172
+ if (comparison) {
173
+ throw new Error(`${comparison[1][0]} compares two genomes, which a linear view does not show; open it in a synteny view`);
174
+ }
175
+ else if (otherGenome) {
176
+ throw new Error(`${otherGenome[1][0]} is a genome, and this view already has one; open one genome per view`);
177
+ }
178
+ else if (genomePath && configRef) {
81
179
  throw new Error(`${genomePath} is a genome, and ${genome ? '--genome' : '--config'} names one too; pass one or the other`);
82
180
  }
83
181
  else if (!genomePath && !configRef) {
@@ -86,55 +184,42 @@ export function launchPlan(entries, cwd) {
86
184
  else if (genomePath && one('assembly')) {
87
185
  throw new Error(`--assembly picks among the genomes of --genome or --config; ${genomePath} is its own`);
88
186
  }
89
- let assembly;
90
- if (genomeFile && genomePath) {
91
- const mods = genomeFile[1].slice(1);
92
- const aliases = one('aliases') ?? modifierValue(mods, 'aliases');
93
- const cytobands = one('cytobands') ?? modifierValue(mods, 'cytobands');
94
- const adapter = fileAdapter(genomePath, {
95
- adapterType: genomeFile[0] === 'fasta' && !isDataFile(genomePath)
96
- ? 'IndexedFastaAdapter'
97
- : undefined,
98
- });
99
- assembly = sequenceAssembly(adapter, genomePath, {
100
- aliases: aliases && abs(aliases),
101
- cytobands: cytobands && abs(cytobands),
102
- });
103
- }
187
+ const built = genomeFile && genomeAssembly(genomeFile[0], genomeFile[1]);
188
+ const assembly = built?.assembly;
189
+ const unapplied = built?.unused.map(mod => `${built.assembly.name} ${mod}`) ?? [];
104
190
  const assemblyName = assembly?.name ?? one('assembly');
105
191
  const used = new Set();
106
192
  const tracks = [];
107
193
  const show = [];
108
- const unapplied = [];
109
194
  for (const entry of resolved) {
110
195
  const [key, [first, ...mods]] = entry;
111
- if (!isTrackFlag(key) && key !== 'track') {
112
- unapplied.push(...mods
113
- .filter(mod => key !== 'fasta' || !BUILD_MODIFIERS.has(modifierName(mod)))
114
- .map(extra => `--${key} ${extra}`));
196
+ if (entry === genomeFile || key === 'loc' || key === 'view') {
197
+ continue;
115
198
  }
116
199
  else if (key === 'track') {
117
200
  if (!first) {
118
201
  throw new Error('--track requires a trackId');
119
202
  }
120
- show.push(first);
121
- unapplied.push(...mods.map(mod => `${first} ${mod}`));
203
+ show.push(showTrack(first, mods));
122
204
  }
123
- else if (isTrackFlag(key) && entry !== genomeFile) {
205
+ else if (opensTrack(key)) {
124
206
  const track = fileTrack(key, entry[1], assemblyName);
125
207
  track.trackId = uniqueId(used, track.trackId);
126
208
  tracks.push(track);
127
- show.push(track.trackId);
128
- unapplied.push(...mods
129
- .filter(mod => !BUILD_MODIFIERS.has(modifierName(mod)))
130
- .map(mod => `${track.trackId} ${mod}`));
209
+ show.push(showTrack(track.trackId, mods));
210
+ }
211
+ else {
212
+ unapplied.push(...mods.map(extra => `--${key} ${extra}`));
131
213
  }
132
214
  }
215
+ const loc = given('loc');
133
216
  return {
217
+ type: 'LinearGenomeView',
134
218
  config: configRef,
135
219
  assembly,
136
220
  assemblyName,
137
- loc: one('loc'),
221
+ loc: loc && locValue(loc),
222
+ view: viewSettings(given('view') ?? []),
138
223
  tracks,
139
224
  show,
140
225
  unapplied,
@@ -0,0 +1,10 @@
1
+ export declare function invalidModifierValue(prefix: string, val: string, expected: string): never;
2
+ export declare function parseNum(prefix: string, val: string, expected?: string): number;
3
+ export declare function parseStr(prefix: string, val: string, expected?: string): string;
4
+ export declare function parseEnum<T extends string>(prefix: string, val: string, allowed: readonly T[]): T;
5
+ export declare function parseBool(prefix: string, val: string): boolean;
6
+ /**
7
+ * A JSON modifier, reported with its token, since a bare SyntaxError from a
8
+ * shell-mangled brace names nothing.
9
+ */
10
+ export declare function parseJsonModifier(token: string): Record<string, unknown>;
@@ -0,0 +1,47 @@
1
+ // One rule for every modifier value, so the grammar reads the same whatever the
2
+ // track type: a value that isn't one of the things the modifier accepts is an
3
+ // error. jb2export writes a figure and exits, so a warning about a typo scrolls
4
+ // past and leaves a wrong image behind: `arcs:upp` used to render a plot with no
5
+ // arcs at all.
6
+ export function invalidModifierValue(prefix, val, expected) {
7
+ throw new Error(val === ''
8
+ ? `Missing ${prefix} value. Expected ${prefix}:<${expected}>.`
9
+ : `Invalid ${prefix} value "${val}". Expected ${expected}.`);
10
+ }
11
+ // A bare `height:` would otherwise become a silent 0 via `+''`. `expected`
12
+ // names the wider grammar for the modifiers that also accept keywords.
13
+ export function parseNum(prefix, val, expected = 'a number') {
14
+ const n = val.trim() === '' ? Number.NaN : Number(val);
15
+ return Number.isFinite(n) ? n : invalidModifierValue(prefix, val, expected);
16
+ }
17
+ // A modifier whose value is mandatory and free-form (a color, a tag, a display
18
+ // name): `color:` with nothing after the colon is a typo.
19
+ export function parseStr(prefix, val, expected = 'value') {
20
+ return val || invalidModifierValue(prefix, val, expected);
21
+ }
22
+ // A value from a fixed set, returned as the matched member so the caller keeps
23
+ // the narrow literal type.
24
+ export function parseEnum(prefix, val, allowed) {
25
+ return (allowed.find(a => a === val) ??
26
+ invalidModifierValue(prefix, val, allowed.join(', ')));
27
+ }
28
+ // Bare (`force`) or `:true` is on, `:false` is off, anything else is a typo.
29
+ export function parseBool(prefix, val) {
30
+ return val === '' || val === 'true'
31
+ ? true
32
+ : val === 'false'
33
+ ? false
34
+ : invalidModifierValue(prefix, val, 'true or false');
35
+ }
36
+ /**
37
+ * A JSON modifier, reported with its token, since a bare SyntaxError from a
38
+ * shell-mangled brace names nothing.
39
+ */
40
+ export function parseJsonModifier(token) {
41
+ try {
42
+ return JSON.parse(token);
43
+ }
44
+ catch (e) {
45
+ throw new Error(`Invalid JSON track option: ${token}`, { cause: e });
46
+ }
47
+ }
@@ -4,6 +4,8 @@ export type Entry = [string, string[]];
4
4
  * reads.bam`, a file typed by its name.
5
5
  */
6
6
  export declare const FILE_FLAG = "file";
7
+ /** Whether a command-line file is a URL (http, s3, ftp, …) rather than a path. */
8
+ export declare function isUrl(file: string): boolean;
7
9
  /**
8
10
  * A `name:value` or `path=value` modifier, or a JSON one. A URL and a Windows
9
11
  * path carry a colon too, which is why the name before it has to be more than
@@ -22,10 +24,11 @@ export declare function isDataFile(token: string): boolean;
22
24
  * `value`. A bare `-` is stdin, so it is a value.
23
25
  * - The token after a flag is its value, except after a flag in `booleans`,
24
26
  * which takes only `true` or `false`.
25
- * - Later tokens are the entry's modifiers (`height:100`, `color.field=tags.HP`,
26
- * `{"json":1}`), until a token that names a data file, which opens a
27
- * {@link FILE_FLAG} entry of its own. So `--loc chr1:1-100 reads.bam` keeps
28
- * both, and `reads.bam height:100` gives the file its modifier.
27
+ * - Later tokens are the entry's modifiers (`height:100`,
28
+ * `encoding.color.field=tags.HP`, `{"json":1}`), until a token that names a
29
+ * data file, which opens a {@link FILE_FLAG} entry of its own. So
30
+ * `--loc chr1:1-100 reads.bam` keeps both, and `reads.bam height:100` gives
31
+ * the file its modifier.
29
32
  * - A bare token before any flag that is not a data file throws; it used to be
30
33
  * dropped without a word.
31
34
  */
@@ -33,8 +36,35 @@ export declare function parseArgv(rawArgv: string[], { booleans }?: {
33
36
  booleans?: ReadonlySet<string>;
34
37
  }): Entry[];
35
38
  export declare function splitModifier(token: string): [string, string] | undefined;
39
+ /**
40
+ * The value of a `key:value` modifier in `tokens`. The last one given wins, as
41
+ * with the display modifiers, so a script overrides a shared list by
42
+ * appending to it.
43
+ */
36
44
  export declare function modifierValue(tokens: string[], key: string): string | undefined;
37
- export declare function standardizeArgv(args: Entry[], trackTypes: string[]): {
45
+ /** `--loc`'s tokens, which together name the regions of one view. */
46
+ export declare function locValue(values: string[]): string | undefined;
47
+ /**
48
+ * Words a flag's value is followed by, read off as one error: a flag that
49
+ * takes one value has no use for them.
50
+ */
51
+ export declare function extraValuesMessage(flag: string, extra: string[]): string;
52
+ /**
53
+ * The command line as option values, with the track entries apart, in order.
54
+ *
55
+ * - `--loc` takes all its tokens, so `--loc chr1:1-100 chr2:1-100` names the
56
+ * regions a quoted `--loc` does.
57
+ * - A flag in `repeatable` may repeat and so has no single value here; the
58
+ * caller reads it from the entries.
59
+ * - A flag in `withModifiers` takes modifiers after its value, which the
60
+ * caller reads from the entries too.
61
+ * - Any other flag given twice, or followed by a token it has no use for,
62
+ * throws.
63
+ */
64
+ export declare function standardizeArgv(args: Entry[], trackTypes: string[], { repeatable, withModifiers, }?: {
65
+ repeatable?: string[];
66
+ withModifiers?: string[];
67
+ }): {
38
68
  trackList: Entry[];
39
69
  [key: string]: unknown;
40
70
  };
package/esm/parseArgv.js CHANGED
@@ -5,6 +5,10 @@ import { fileNameOf, matchFormat } from '@jbrowse/add-track-core';
5
5
  */
6
6
  export const FILE_FLAG = 'file';
7
7
  const URL_SCHEME = /^[a-z][a-z0-9+.-]*:\/\//i;
8
+ /** Whether a command-line file is a URL (http, s3, ftp, …) rather than a path. */
9
+ export function isUrl(file) {
10
+ return URL_SCHEME.test(file);
11
+ }
8
12
  const DRIVE_LETTER = /^[a-z]:[\\/]/i;
9
13
  const MODIFIER = /^[a-z_][\w.-]*[:=]/i;
10
14
  /**
@@ -14,9 +18,7 @@ const MODIFIER = /^[a-z_][\w.-]*[:=]/i;
14
18
  */
15
19
  export function isModifier(token) {
16
20
  return (token.startsWith('{') ||
17
- (MODIFIER.test(token) &&
18
- !URL_SCHEME.test(token) &&
19
- !DRIVE_LETTER.test(token)));
21
+ (MODIFIER.test(token) && !isUrl(token) && !DRIVE_LETTER.test(token)));
20
22
  }
21
23
  /**
22
24
  * A file the shared format table recognizes by name, as a local path or a URL:
@@ -33,10 +35,11 @@ export function isDataFile(token) {
33
35
  * `value`. A bare `-` is stdin, so it is a value.
34
36
  * - The token after a flag is its value, except after a flag in `booleans`,
35
37
  * which takes only `true` or `false`.
36
- * - Later tokens are the entry's modifiers (`height:100`, `color.field=tags.HP`,
37
- * `{"json":1}`), until a token that names a data file, which opens a
38
- * {@link FILE_FLAG} entry of its own. So `--loc chr1:1-100 reads.bam` keeps
39
- * both, and `reads.bam height:100` gives the file its modifier.
38
+ * - Later tokens are the entry's modifiers (`height:100`,
39
+ * `encoding.color.field=tags.HP`, `{"json":1}`), until a token that names a
40
+ * data file, which opens a {@link FILE_FLAG} entry of its own. So
41
+ * `--loc chr1:1-100 reads.bam` keeps both, and `reads.bam height:100` gives
42
+ * the file its modifier.
40
43
  * - A bare token before any flag that is not a data file throws; it used to be
41
44
  * dropped without a word.
42
45
  */
@@ -73,35 +76,65 @@ export function parseArgv(rawArgv, { booleans = new Set() } = {}) {
73
76
  }
74
77
  return entries;
75
78
  }
76
- // Split a `key:value` modifier token at its FIRST colon, keeping any colons in
77
- // the value (URLs, locstrings — e.g. `aliases:https://x/a.txt`). Returns
78
- // undefined for a token with no colon or a leading colon. This is the
79
- // colon-preserving grammar the assembly-flag (`loc:`/`aliases:`) and track-file
80
- // (`index:`/`name:`) modifiers use; the display modifiers instead split every
81
- // colon (`color:tag:XS`) directly where they're parsed.
79
+ // `key:value` split at the first colon, so `aliases:https://x/a.txt` keeps its
80
+ // URL. The display modifiers split every colon instead (`color:tag:XS`).
82
81
  export function splitModifier(token) {
83
82
  const i = token.indexOf(':');
84
83
  return i > 0 ? [token.slice(0, i), token.slice(i + 1)] : undefined;
85
84
  }
86
- // The value of the first `key:value` modifier in `tokens` matching `key`.
85
+ /**
86
+ * The value of a `key:value` modifier in `tokens`. The last one given wins, as
87
+ * with the display modifiers, so a script overrides a shared list by
88
+ * appending to it.
89
+ */
87
90
  export function modifierValue(tokens, key) {
88
- for (const token of tokens) {
89
- const split = splitModifier(token);
90
- if (split?.[0] === key) {
91
- return split[1];
92
- }
93
- }
94
- return undefined;
91
+ return tokens.map(splitModifier).findLast(split => split?.[0] === key)?.[1];
92
+ }
93
+ /** `--loc`'s tokens, which together name the regions of one view. */
94
+ export function locValue(values) {
95
+ return values.join(' ').trim() || undefined;
95
96
  }
96
- export function standardizeArgv(args, trackTypes) {
97
+ /**
98
+ * Words a flag's value is followed by, read off as one error: a flag that
99
+ * takes one value has no use for them.
100
+ */
101
+ export function extraValuesMessage(flag, extra) {
102
+ return `--${flag} takes one value, so ${extra.map(e => `"${e}"`).join(' ')} ${extra.length > 1 ? 'apply' : 'applies'} to nothing`;
103
+ }
104
+ /**
105
+ * The command line as option values, with the track entries apart, in order.
106
+ *
107
+ * - `--loc` takes all its tokens, so `--loc chr1:1-100 chr2:1-100` names the
108
+ * regions a quoted `--loc` does.
109
+ * - A flag in `repeatable` may repeat and so has no single value here; the
110
+ * caller reads it from the entries.
111
+ * - A flag in `withModifiers` takes modifiers after its value, which the
112
+ * caller reads from the entries too.
113
+ * - Any other flag given twice, or followed by a token it has no use for,
114
+ * throws.
115
+ */
116
+ export function standardizeArgv(args, trackTypes, { repeatable = ['track'], withModifiers = ['track', 'view', 'fasta', 'chromSizes'], } = {}) {
97
117
  const trackList = [];
98
118
  const rest = {};
99
119
  for (const [key, vals] of args) {
100
120
  if (key === FILE_FLAG || trackTypes.includes(key)) {
101
121
  trackList.push([key, vals]);
102
122
  }
123
+ else if (repeatable.includes(key)) {
124
+ continue;
125
+ }
126
+ else if (Object.hasOwn(rest, key)) {
127
+ throw new Error(`--${key} is given more than once`);
128
+ }
129
+ else if (key === 'loc') {
130
+ rest[key] = locValue(vals) ?? true;
131
+ }
103
132
  else {
104
- rest[key] = vals[0] ?? true;
133
+ const [value, ...extra] = vals;
134
+ if (extra.length && !withModifiers.includes(key)) {
135
+ throw new Error(extraValuesMessage(key, extra));
136
+ }
137
+ rest[key] = value ?? true;
105
138
  }
106
139
  }
107
140
  return { trackList, ...rest };
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The value of a `path=value` modifier: `true`/`false`, a number, a
3
+ * comma-separated list (a trailing comma makes a list of one, and a `jexl:`
4
+ * item keeps the commas inside its own brackets and quotes), or the text as
5
+ * written.
6
+ */
7
+ export declare function slotValue(text: string): string | number | boolean | (string | number | boolean)[];
8
+ /** Whether a track option is a `path=value` slot write rather than a named modifier. */
9
+ export declare function isSlotPathOption(opt: string): boolean;
10
+ /**
11
+ * Whether a slot write names a member of a setting, `encoding.color.field=…`,
12
+ * rather than a whole setting, `height=…`.
13
+ */
14
+ export declare function isMemberWrite(opt: string): boolean;
15
+ /**
16
+ * Merge `patch` into `target`, object into object and anything else replacing
17
+ * what was there, so `encoding.color.field=…` and
18
+ * `encoding.color.scale.range=…` fill one object in either order.
19
+ */
20
+ export declare function mergeSettings(target: Record<string, unknown>, patch: Record<string, unknown>): Record<string, unknown>;
21
+ /** A `path=value` modifier read apart: the segments it walks, and what it sets. */
22
+ export interface SlotWrite {
23
+ opt: string;
24
+ segments: string[];
25
+ value: unknown;
26
+ }
27
+ /**
28
+ * `encoding.color.scale.range=tan,teal` and `marks.0.encoding.y=score`, read
29
+ * apart.
30
+ */
31
+ export declare function slotWrite(opt: string): SlotWrite;
32
+ /**
33
+ * Apply one `path=value` write to `target`, creating the objects and lists the
34
+ * path walks through and leaving everything beside them alone, so the writes of
35
+ * one command line compose whatever order they come in.
36
+ *
37
+ * Indexing a list is what carries the grammar: `marks`, the `transform` steps
38
+ * under a mark and an aggregate's `ops` are all lists of objects, and while a
39
+ * path stopped at the first of them a declared figure could only be written as
40
+ * raw JSON.
41
+ */
42
+ export declare function applySlotWrite(target: Record<string, unknown>, write: SlotWrite): Record<string, unknown>;
43
+ /**
44
+ * The settings `--view`'s tokens write onto the view, as a session spec's view
45
+ * object holds them: `mark.interpolate=bump`, `filter.0.field=length`, or a
46
+ * JSON object merged in whole.
47
+ */
48
+ export declare function viewSettings(tokens: string[]): Record<string, unknown>;