@gmod/tabix 1.5.1 → 1.5.4

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.
@@ -0,0 +1,79 @@
1
+ import AbortablePromiseCache from 'abortable-promise-cache'
2
+ import QuickLRU from 'quick-lru'
3
+ import { GenericFilehandle } from 'generic-filehandle'
4
+ import VirtualOffset from './virtualOffset'
5
+ import Chunk from './chunk'
6
+
7
+ export interface Options {
8
+ // support having some unknown parts of the options
9
+ [key: string]: unknown
10
+ signal?: AbortSignal
11
+ }
12
+
13
+ export default abstract class IndexFile {
14
+ public filehandle: GenericFilehandle
15
+ public renameRefSeq: (arg0: string) => string
16
+ private _parseCache: any
17
+
18
+ /**
19
+ * @param {filehandle} filehandle
20
+ * @param {function} [renameRefSeqs]
21
+ */
22
+ constructor({
23
+ filehandle,
24
+ renameRefSeqs = (n: string) => n,
25
+ }: {
26
+ filehandle: GenericFilehandle
27
+ renameRefSeqs?: (a: string) => string
28
+ }) {
29
+ this.filehandle = filehandle
30
+ this.renameRefSeq = renameRefSeqs
31
+ }
32
+
33
+ public abstract lineCount(refName: string, args: Options): Promise<number>
34
+
35
+ protected abstract _parse(opts: Options): Promise<{
36
+ refNameToId: { [key: string]: number }
37
+ refIdToName: string[]
38
+ }>
39
+
40
+ public async getMetadata(opts: Options = {}) {
41
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
42
+ const { indices, ...rest } = await this.parse(opts)
43
+ return rest
44
+ }
45
+
46
+ public abstract blocksForRange(
47
+ refName: string,
48
+ start: number,
49
+ end: number,
50
+ opts: Options,
51
+ ): Promise<Chunk[]>
52
+
53
+ _findFirstData(
54
+ currentFdl: VirtualOffset | undefined,
55
+ virtualOffset: VirtualOffset,
56
+ ) {
57
+ if (currentFdl) {
58
+ return currentFdl.compareTo(virtualOffset) > 0
59
+ ? virtualOffset
60
+ : currentFdl
61
+ } else {
62
+ return virtualOffset
63
+ }
64
+ }
65
+
66
+ async parse(opts: Options = {}) {
67
+ if (!this._parseCache) {
68
+ this._parseCache = new AbortablePromiseCache({
69
+ cache: new QuickLRU({ maxSize: 1 }),
70
+ fill: () => this._parse(opts),
71
+ })
72
+ }
73
+ return this._parseCache.get('index', null, undefined)
74
+ }
75
+
76
+ async hasRefSeq(seqId: number, opts: Options = {}) {
77
+ return !!((await this.parse(opts)).indices[seqId] || {}).binIndex
78
+ }
79
+ }
@@ -0,0 +1,519 @@
1
+ import AbortablePromiseCache from 'abortable-promise-cache'
2
+ import LRU from 'quick-lru'
3
+ import { GenericFilehandle, LocalFile } from 'generic-filehandle'
4
+ import { unzip, unzipChunkSlice } from '@gmod/bgzf-filehandle'
5
+ import { checkAbortSignal } from './util'
6
+ import IndexFile, { Options } from './indexFile'
7
+
8
+ import Chunk from './chunk'
9
+ import TBI from './tbi'
10
+ import CSI from './csi'
11
+
12
+ type GetLinesCallback = (line: string, fileOffset: number) => void
13
+
14
+ interface GetLinesOpts {
15
+ [key: string]: unknown
16
+ signal?: AbortSignal
17
+ lineCallback: GetLinesCallback
18
+ }
19
+
20
+ function timeout(time: number) {
21
+ return new Promise(resolve => {
22
+ setTimeout(resolve, time)
23
+ })
24
+ }
25
+ export default class TabixIndexedFile {
26
+ private filehandle: GenericFilehandle
27
+ private index: IndexFile
28
+ private chunkSizeLimit: number
29
+ private renameRefSeq: (n: string) => string
30
+ private chunkCache: any
31
+ /**
32
+ * @param {object} args
33
+ * @param {string} [args.path]
34
+ * @param {filehandle} [args.filehandle]
35
+ * @param {string} [args.tbiPath]
36
+ * @param {filehandle} [args.tbiFilehandle]
37
+ * @param {string} [args.csiPath]
38
+ * @param {filehandle} [args.csiFilehandle]
39
+ * @param {chunkSizeLimit} default 50MiB
40
+ * @param {function} [args.renameRefSeqs] optional function with sig `string => string` to transform
41
+ * reference sequence names for the purpose of indexing and querying. note that the data that is returned is
42
+ * not altered, just the names of the reference sequences that are used for querying.
43
+ * @param {number} [args.chunkCacheSize] maximum size in bytes of the chunk cache. default 5MB
44
+ * @param {number} [args.blockCacheSize] maximum size in bytes of the block cache. default 5MB
45
+ */
46
+ constructor({
47
+ path,
48
+ filehandle,
49
+ tbiPath,
50
+ tbiFilehandle,
51
+ csiPath,
52
+ csiFilehandle,
53
+ chunkSizeLimit = 50000000,
54
+ renameRefSeqs = n => n,
55
+ chunkCacheSize = 5 * 2 ** 20,
56
+ }: {
57
+ path?: string
58
+ filehandle?: GenericFilehandle
59
+ tbiPath?: string
60
+ tbiFilehandle?: GenericFilehandle
61
+ csiPath?: string
62
+ csiFilehandle?: GenericFilehandle
63
+ chunkSizeLimit?: number
64
+ renameRefSeqs?: (n: string) => string
65
+ chunkCacheSize?: number
66
+ }) {
67
+ if (filehandle) {
68
+ this.filehandle = filehandle
69
+ } else if (path) {
70
+ this.filehandle = new LocalFile(path)
71
+ } else {
72
+ throw new TypeError('must provide either filehandle or path')
73
+ }
74
+
75
+ if (tbiFilehandle) {
76
+ this.index = new TBI({
77
+ filehandle: tbiFilehandle,
78
+ renameRefSeqs,
79
+ })
80
+ } else if (csiFilehandle) {
81
+ this.index = new CSI({
82
+ filehandle: csiFilehandle,
83
+ renameRefSeqs,
84
+ })
85
+ } else if (tbiPath) {
86
+ this.index = new TBI({
87
+ filehandle: new LocalFile(tbiPath),
88
+ renameRefSeqs,
89
+ })
90
+ } else if (csiPath) {
91
+ this.index = new CSI({
92
+ filehandle: new LocalFile(csiPath),
93
+ renameRefSeqs,
94
+ })
95
+ } else if (path) {
96
+ this.index = new TBI({
97
+ filehandle: new LocalFile(`${path}.tbi`),
98
+ renameRefSeqs,
99
+ })
100
+ } else {
101
+ throw new TypeError(
102
+ 'must provide one of tbiFilehandle, tbiPath, csiFilehandle, or csiPath',
103
+ )
104
+ }
105
+
106
+ this.chunkSizeLimit = chunkSizeLimit
107
+ this.renameRefSeq = renameRefSeqs
108
+ this.chunkCache = new AbortablePromiseCache({
109
+ cache: new LRU({
110
+ maxSize: Math.floor(chunkCacheSize / (1 << 16)),
111
+ }),
112
+
113
+ fill: this.readChunk.bind(this),
114
+ })
115
+ }
116
+
117
+ /**
118
+ * @param {string} refName name of the reference sequence
119
+ * @param {number} start start of the region (in 0-based half-open coordinates)
120
+ * @param {number} end end of the region (in 0-based half-open coordinates)
121
+ * @param {function|object} lineCallback callback called for each line in the region. can also pass a object param containing obj.lineCallback, obj.signal, etc
122
+ * @returns {Promise} resolved when the whole read is finished, rejected on error
123
+ */
124
+ async getLines(
125
+ refName: string,
126
+ start: number,
127
+ end: number,
128
+ opts: GetLinesOpts | GetLinesCallback,
129
+ ) {
130
+ let signal: AbortSignal | undefined
131
+ let options: Options = {}
132
+ let callback: (line: string, lineOffset: number) => void
133
+ if (typeof opts === 'undefined') {
134
+ throw new TypeError('line callback must be provided')
135
+ }
136
+ if (typeof opts === 'function') {
137
+ callback = opts
138
+ } else {
139
+ options = opts
140
+ callback = opts.lineCallback
141
+ }
142
+ if (refName === undefined) {
143
+ throw new TypeError('must provide a reference sequence name')
144
+ }
145
+ if (!callback) {
146
+ throw new TypeError('line callback must be provided')
147
+ }
148
+
149
+ const metadata = await this.index.getMetadata(options)
150
+ checkAbortSignal(signal)
151
+ if (!start) {
152
+ start = 0
153
+ }
154
+ if (!end) {
155
+ end = metadata.maxRefLength
156
+ }
157
+ if (!(start <= end)) {
158
+ throw new TypeError(
159
+ 'invalid start and end coordinates. start must be less than or equal to end',
160
+ )
161
+ }
162
+ if (start === end) {
163
+ return
164
+ }
165
+
166
+ const chunks = await this.index.blocksForRange(refName, start, end, options)
167
+ checkAbortSignal(signal)
168
+
169
+ // check the chunks for any that are over the size limit. if
170
+ // any are, don't fetch any of them
171
+ for (let i = 0; i < chunks.length; i += 1) {
172
+ const size = chunks[i].fetchedSize()
173
+ if (size > this.chunkSizeLimit) {
174
+ throw new Error(
175
+ `Too much data. Chunk size ${size.toLocaleString()} bytes exceeds chunkSizeLimit of ${this.chunkSizeLimit.toLocaleString()}.`,
176
+ )
177
+ }
178
+ }
179
+
180
+ // now go through each chunk and parse and filter the lines out of it
181
+ let last = Date.now()
182
+ for (let chunkNum = 0; chunkNum < chunks.length; chunkNum += 1) {
183
+ let previousStartCoordinate: number | undefined
184
+ const c = chunks[chunkNum]
185
+ const { buffer, cpositions, dpositions } = await this.chunkCache.get(
186
+ c.toString(),
187
+ c,
188
+ signal,
189
+ )
190
+
191
+ const lines = (
192
+ typeof TextDecoder !== 'undefined'
193
+ ? new TextDecoder('utf-8').decode(buffer)
194
+ : buffer.toString()
195
+ ).split('\n')
196
+ lines.pop()
197
+
198
+ checkAbortSignal(signal)
199
+ let blockStart = c.minv.dataPosition
200
+ let pos
201
+
202
+ for (let i = 0; i < lines.length; i += 1) {
203
+ const line = lines[i]
204
+
205
+ for (pos = 0; blockStart >= dpositions[pos]; pos += 1) {}
206
+
207
+ // filter the line for whether it is within the requested range
208
+ const { startCoordinate, overlaps } = this.checkLine(
209
+ metadata,
210
+ refName,
211
+ start,
212
+ end,
213
+ line,
214
+ )
215
+
216
+ // do a small check just to make sure that the lines are really sorted by start coordinate
217
+ if (
218
+ previousStartCoordinate !== undefined &&
219
+ startCoordinate !== undefined &&
220
+ previousStartCoordinate > startCoordinate
221
+ ) {
222
+ throw new Error(
223
+ `Lines not sorted by start coordinate (${previousStartCoordinate} > ${startCoordinate}), this file is not usable with Tabix.`,
224
+ )
225
+ }
226
+ previousStartCoordinate = startCoordinate
227
+
228
+ if (overlaps) {
229
+ callback(
230
+ line.trim(),
231
+ // cpositions[pos] refers to actual file offset of a bgzip block boundaries
232
+ //
233
+ // we multiply by (1 <<8) in order to make sure each block has a "unique"
234
+ // address space so that data in that block could never overlap
235
+ //
236
+ // then the blockStart-dpositions is an uncompressed file offset from
237
+ // that bgzip block boundary, and since the cpositions are multiplied by
238
+ // (1 << 8) these uncompressed offsets get a unique space
239
+ cpositions[pos] * (1 << 8) + (blockStart - dpositions[pos]),
240
+ )
241
+ } else if (startCoordinate !== undefined && startCoordinate >= end) {
242
+ // the lines were overlapping the region, but now have stopped, so
243
+ // we must be at the end of the relevant data and we can stop
244
+ // processing data now
245
+ return
246
+ }
247
+ blockStart += line.length + 1
248
+
249
+ // yield if we have emitted beyond the yield limit
250
+ if (last - Date.now() > 500) {
251
+ last = Date.now()
252
+ checkAbortSignal(signal)
253
+ await timeout(1)
254
+ }
255
+ }
256
+ }
257
+ }
258
+
259
+ async getMetadata(opts: Options = {}) {
260
+ return this.index.getMetadata(opts)
261
+ }
262
+
263
+ /**
264
+ * get a buffer containing the "header" region of
265
+ * the file, which are the bytes up to the first
266
+ * non-meta line
267
+ *
268
+ * @returns {Promise} for a buffer
269
+ */
270
+ async getHeaderBuffer(opts: Options = {}) {
271
+ const { firstDataLine, metaChar, maxBlockSize } = await this.getMetadata(
272
+ opts,
273
+ )
274
+ checkAbortSignal(opts.signal)
275
+ const maxFetch =
276
+ firstDataLine && firstDataLine.blockPosition
277
+ ? firstDataLine.blockPosition + maxBlockSize
278
+ : maxBlockSize
279
+ // TODO: what if we don't have a firstDataLine, and the header
280
+ // actually takes up more than one block? this case is not covered here
281
+
282
+ let bytes = await this._readRegion(0, maxFetch, opts)
283
+ checkAbortSignal(opts.signal)
284
+ try {
285
+ bytes = await unzip(bytes)
286
+ } catch (e) {
287
+ console.error(e)
288
+ throw new Error(
289
+ //@ts-ignore
290
+ `error decompressing block ${e.code} at 0 (length ${maxFetch}) ${e}`,
291
+ )
292
+ }
293
+
294
+ // trim off lines after the last non-meta line
295
+ if (metaChar) {
296
+ // trim backward from the end
297
+ let lastNewline = -1
298
+ const newlineByte = '\n'.charCodeAt(0)
299
+ const metaByte = metaChar.charCodeAt(0)
300
+ for (let i = 0; i < bytes.length; i += 1) {
301
+ if (i === lastNewline + 1 && bytes[i] !== metaByte) {
302
+ break
303
+ }
304
+ if (bytes[i] === newlineByte) {
305
+ lastNewline = i
306
+ }
307
+ }
308
+ bytes = bytes.slice(0, lastNewline + 1)
309
+ }
310
+ return bytes
311
+ }
312
+
313
+ /**
314
+ * get a string containing the "header" region of the
315
+ * file, is the portion up to the first non-meta line
316
+ *
317
+ * @returns {Promise} for a string
318
+ */
319
+ async getHeader(opts: Options = {}) {
320
+ const bytes = await this.getHeaderBuffer(opts)
321
+ checkAbortSignal(opts.signal)
322
+ return bytes.toString('utf8')
323
+ }
324
+
325
+ /**
326
+ * get an array of reference sequence names, in the order in which
327
+ * they occur in the file.
328
+ *
329
+ * reference sequence renaming is not applied to these names.
330
+ *
331
+ * @returns {Promise} for an array of string sequence names
332
+ */
333
+ async getReferenceSequenceNames(opts: Options = {}) {
334
+ const metadata = await this.getMetadata(opts)
335
+ return metadata.refIdToName
336
+ }
337
+
338
+ /**
339
+ * @param {object} metadata metadata object from the parsed index,
340
+ * containing columnNumbers, metaChar, and format
341
+ * @param {string} regionRefName
342
+ * @param {number} regionStart region start coordinate (0-based-half-open)
343
+ * @param {number} regionEnd region end coordinate (0-based-half-open)
344
+ * @param {array[string]} line
345
+ * @returns {object} like `{startCoordinate, overlaps}`. overlaps is boolean,
346
+ * true if line is a data line that overlaps the given region
347
+ */
348
+ checkLine(
349
+ {
350
+ columnNumbers,
351
+ metaChar,
352
+ coordinateType,
353
+ format,
354
+ }: {
355
+ columnNumbers: { ref: number; start: number; end: number }
356
+ metaChar: string
357
+ coordinateType: string
358
+ format: string
359
+ },
360
+ regionRefName: string,
361
+ regionStart: number,
362
+ regionEnd: number,
363
+ line: string,
364
+ ) {
365
+ // skip meta lines
366
+ if (line.charAt(0) === metaChar) {
367
+ return { overlaps: false }
368
+ }
369
+
370
+ // check ref/start/end using column metadata from index
371
+ let { ref, start, end } = columnNumbers
372
+ if (!ref) {
373
+ ref = 0
374
+ }
375
+ if (!start) {
376
+ start = 0
377
+ }
378
+ if (!end) {
379
+ end = 0
380
+ }
381
+ if (format === 'VCF') {
382
+ end = 8
383
+ }
384
+ const maxColumn = Math.max(ref, start, end)
385
+
386
+ // this code is kind of complex, but it is fairly fast.
387
+ // basically, we want to avoid doing a split, because if the lines are really long
388
+ // that could lead to us allocating a bunch of extra memory, which is slow
389
+
390
+ let currentColumnNumber = 1 // cols are numbered starting at 1 in the index metadata
391
+ let currentColumnStart = 0
392
+ let refSeq = ''
393
+ let startCoordinate = -Infinity
394
+ for (let i = 0; i < line.length + 1; i += 1) {
395
+ if (line[i] === '\t' || i === line.length) {
396
+ if (currentColumnNumber === ref) {
397
+ if (
398
+ this.renameRefSeq(line.slice(currentColumnStart, i)) !==
399
+ regionRefName
400
+ ) {
401
+ return { overlaps: false }
402
+ }
403
+ } else if (currentColumnNumber === start) {
404
+ startCoordinate = parseInt(line.slice(currentColumnStart, i), 10)
405
+ // we convert to 0-based-half-open
406
+ if (coordinateType === '1-based-closed') {
407
+ startCoordinate -= 1
408
+ }
409
+ if (startCoordinate >= regionEnd) {
410
+ return { startCoordinate, overlaps: false }
411
+ }
412
+ if (end === 0 || end === start) {
413
+ // if we have no end, we assume the feature is 1 bp long
414
+ if (startCoordinate + 1 <= regionStart) {
415
+ return { startCoordinate, overlaps: false }
416
+ }
417
+ }
418
+ } else if (format === 'VCF' && currentColumnNumber === 4) {
419
+ refSeq = line.slice(currentColumnStart, i)
420
+ } else if (currentColumnNumber === end) {
421
+ let endCoordinate
422
+ // this will never match if there is no end column
423
+ if (format === 'VCF') {
424
+ endCoordinate = this._getVcfEnd(
425
+ startCoordinate,
426
+ refSeq,
427
+ line.slice(currentColumnStart, i),
428
+ )
429
+ } else {
430
+ endCoordinate = parseInt(line.slice(currentColumnStart, i), 10)
431
+ }
432
+ if (endCoordinate <= regionStart) {
433
+ return { overlaps: false }
434
+ }
435
+ }
436
+ currentColumnStart = i + 1
437
+ currentColumnNumber += 1
438
+ if (currentColumnNumber > maxColumn) {
439
+ break
440
+ }
441
+ }
442
+ }
443
+ return { startCoordinate, overlaps: true }
444
+ }
445
+
446
+ _getVcfEnd(startCoordinate: number, refSeq: string, info: any) {
447
+ let endCoordinate = startCoordinate + refSeq.length
448
+ // ignore TRA features as they specify CHR2 and END
449
+ // as being on a different chromosome
450
+ // if CHR2 is on the same chromosome, still ignore it
451
+ // because there should be another pairwise feature
452
+ // at the end of this one
453
+ const isTRA = info.indexOf('SVTYPE=TRA') !== -1
454
+ if (info[0] !== '.' && !isTRA) {
455
+ let prevChar = ';'
456
+ for (let j = 0; j < info.length; j += 1) {
457
+ if (prevChar === ';' && info.slice(j, j + 4) === 'END=') {
458
+ let valueEnd = info.indexOf(';', j)
459
+ if (valueEnd === -1) {
460
+ valueEnd = info.length
461
+ }
462
+ endCoordinate = parseInt(info.slice(j + 4, valueEnd), 10)
463
+ break
464
+ }
465
+ prevChar = info[j]
466
+ }
467
+ } else if (isTRA) {
468
+ return startCoordinate + 1
469
+ }
470
+ return endCoordinate
471
+ }
472
+
473
+ /**
474
+ * return the approximate number of data lines in the given reference sequence
475
+ * @param {string} refSeq reference sequence name
476
+ * @returns {Promise} for number of data lines present on that reference sequence
477
+ */
478
+ async lineCount(refName: string, opts: Options = {}) {
479
+ return this.index.lineCount(refName, opts)
480
+ }
481
+
482
+ async _readRegion(
483
+ position: number,
484
+ compressedSize: number,
485
+ opts: Options = {},
486
+ ) {
487
+ const { bytesRead, buffer } = await this.filehandle.read(
488
+ Buffer.alloc(compressedSize),
489
+ 0,
490
+ compressedSize,
491
+ position,
492
+ opts,
493
+ )
494
+
495
+ return bytesRead < compressedSize ? buffer.slice(0, bytesRead) : buffer
496
+ }
497
+
498
+ /**
499
+ * read and uncompress the data in a chunk (composed of one or more
500
+ * contiguous bgzip blocks) of the file
501
+ * @param {Chunk} chunk
502
+ * @returns {Promise} for a string chunk of the file
503
+ */
504
+ async readChunk(chunk: Chunk, opts: Options = {}) {
505
+ // fetch the uncompressed data, uncompress carefully a block at a time,
506
+ // and stop when done
507
+
508
+ const compressedData = await this._readRegion(
509
+ chunk.minv.blockPosition,
510
+ chunk.fetchedSize(),
511
+ opts,
512
+ )
513
+ try {
514
+ return unzipChunkSlice(compressedData, chunk)
515
+ } catch (e) {
516
+ throw new Error(`error decompressing chunk ${chunk.toString()} ${e}`)
517
+ }
518
+ }
519
+ }