@gmod/tabix 3.2.0 → 3.2.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gmod/tabix",
3
- "version": "3.2.0",
3
+ "version": "3.2.2",
4
4
  "description": "Read Tabix-indexed files, supports both .tbi and .csi indexes",
5
5
  "type": "module",
6
6
  "types": "./dist/index.d.ts",
@@ -31,6 +31,8 @@
31
31
  ],
32
32
  "scripts": {
33
33
  "test": "vitest",
34
+ "benchonly": "vitest bench",
35
+ "bench": "./scripts/build-both-branches.sh \"$BRANCH1\" \"$BRANCH2\" && vitest bench",
34
36
  "lint": "eslint --report-unused-disable-directives --max-warnings 0",
35
37
  "format": "prettier --write .",
36
38
  "docs": "documentation readme --shallow src/tabixIndexedFile.ts --section TabixIndexedFile",
@@ -53,24 +55,24 @@
53
55
  ],
54
56
  "dependencies": {
55
57
  "@gmod/abortable-promise-cache": "^3.0.1",
56
- "@gmod/bgzf-filehandle": "^6.0.3",
57
- "generic-filehandle2": "^2.0.1",
58
- "quick-lru": "^4.0.0"
58
+ "@gmod/bgzf-filehandle": "^6.0.9",
59
+ "@jbrowse/quick-lru": "^7.3.5",
60
+ "generic-filehandle2": "^2.0.16"
59
61
  },
60
62
  "devDependencies": {
61
63
  "@types/node": "^22.15.3",
62
- "@vitest/coverage-v8": "^4.0.6",
64
+ "@vitest/coverage-v8": "^4.0.16",
63
65
  "documentation": "^14.0.3",
64
- "eslint": "^9.9.0",
66
+ "eslint": "^9.39.2",
65
67
  "eslint-plugin-import": "^2.31.0",
66
68
  "eslint-plugin-unicorn": "^62.0.0",
67
69
  "prettier": "^3.3.3",
68
70
  "rimraf": "^6.0.1",
69
71
  "standard-changelog": "^7.0.1",
70
72
  "typescript": "^5.7.0",
71
- "typescript-eslint": "^8.0.1",
72
- "vitest": "^4.0.6",
73
- "webpack": "^5.93.0",
73
+ "typescript-eslint": "^8.50.1",
74
+ "vitest": "^4.0.16",
75
+ "webpack": "^5.104.1",
74
76
  "webpack-cli": "^6.0.1"
75
77
  },
76
78
  "publishConfig": {
@@ -1,17 +1,21 @@
1
1
  import AbortablePromiseCache from '@gmod/abortable-promise-cache'
2
2
  import { unzip, unzipChunkSlice } from '@gmod/bgzf-filehandle'
3
+ import LRU from '@jbrowse/quick-lru'
3
4
  import { LocalFile, RemoteFile } from 'generic-filehandle2'
4
- import LRU from 'quick-lru'
5
5
 
6
6
  import Chunk from './chunk.ts'
7
7
  import CSI from './csi.ts'
8
- import IndexFile, { IndexData, Options } from './indexFile.ts'
8
+ import IndexFile, { Options } from './indexFile.ts'
9
9
  import TBI from './tbi.ts'
10
- import { checkAbortSignal } from './util.ts'
11
10
 
12
11
  import type { GenericFilehandle } from 'generic-filehandle2'
13
12
 
14
- type GetLinesCallback = (line: string, fileOffset: number) => void
13
+ type GetLinesCallback = (
14
+ line: string,
15
+ fileOffset: number,
16
+ start: number,
17
+ end: number,
18
+ ) => void
15
19
 
16
20
  interface GetLinesOpts {
17
21
  [key: string]: unknown
@@ -29,6 +33,7 @@ export default class TabixIndexedFile {
29
33
  private filehandle: GenericFilehandle
30
34
  private index: IndexFile
31
35
  private renameRefSeq: (n: string) => string
36
+ private hasCustomRenameRefSeq: boolean
32
37
  private chunkCache: AbortablePromiseCache<Chunk, ReadChunk>
33
38
  public cache = new LRU<
34
39
  string,
@@ -73,7 +78,7 @@ export default class TabixIndexedFile {
73
78
  csiPath,
74
79
  csiUrl,
75
80
  csiFilehandle,
76
- renameRefSeqs = n => n,
81
+ renameRefSeqs: renameRefSeqsPre,
77
82
  chunkCacheSize = 5 * 2 ** 20,
78
83
  }: {
79
84
  path?: string
@@ -88,6 +93,7 @@ export default class TabixIndexedFile {
88
93
  renameRefSeqs?: (n: string) => string
89
94
  chunkCacheSize?: number
90
95
  }) {
96
+ const renameRefSeqs = renameRefSeqsPre ?? (arg => arg)
91
97
  if (filehandle) {
92
98
  this.filehandle = filehandle
93
99
  } else if (path) {
@@ -142,6 +148,7 @@ export default class TabixIndexedFile {
142
148
  }
143
149
 
144
150
  this.renameRefSeq = renameRefSeqs
151
+ this.hasCustomRenameRefSeq = renameRefSeqsPre !== undefined
145
152
  this.chunkCache = new AbortablePromiseCache<Chunk, ReadChunk>({
146
153
  cache: new LRU({ maxSize: Math.floor(chunkCacheSize / (1 << 16)) }),
147
154
  fill: (args: Chunk, signal?: AbortSignal) =>
@@ -185,7 +192,7 @@ export default class TabixIndexedFile {
185
192
  ) {
186
193
  let signal: AbortSignal | undefined
187
194
  let options: Options = {}
188
- let callback: (line: string, lineOffset: number) => void
195
+ let callback: GetLinesCallback
189
196
 
190
197
  if (typeof opts === 'function') {
191
198
  callback = opts
@@ -196,7 +203,6 @@ export default class TabixIndexedFile {
196
203
  }
197
204
 
198
205
  const metadata = await this.index.getMetadata(options)
199
- checkAbortSignal(signal)
200
206
  const start = s ?? 0
201
207
  const end = e ?? metadata.maxRefLength
202
208
  if (!(start <= end)) {
@@ -209,9 +215,24 @@ export default class TabixIndexedFile {
209
215
  }
210
216
 
211
217
  const chunks = await this.index.blocksForRange(refName, start, end, options)
212
- checkAbortSignal(signal)
213
218
  const decoder = new TextDecoder('utf8')
214
219
 
220
+ const isVCF = metadata.format === 'VCF'
221
+ const columnNumbersEffective = {
222
+ ref: metadata.columnNumbers.ref || 0,
223
+ start: metadata.columnNumbers.start || 0,
224
+ end: isVCF ? 8 : metadata.columnNumbers.end || 0,
225
+ }
226
+ const maxColumn = Math.max(
227
+ columnNumbersEffective.ref,
228
+ columnNumbersEffective.start,
229
+ columnNumbersEffective.end,
230
+ )
231
+ const metaCharCode = metadata.metaChar?.charCodeAt(0)
232
+ const coordinateOffset =
233
+ metadata.coordinateType === '1-based-closed' ? -1 : 0
234
+ const isIdentityRename = !this.hasCustomRenameRefSeq
235
+
215
236
  // now go through each chunk and parse and filter the lines out of it
216
237
  for (const c of chunks) {
217
238
  const { buffer, cpositions, dpositions } = await this.chunkCache.get(
@@ -220,7 +241,6 @@ export default class TabixIndexedFile {
220
241
  signal,
221
242
  )
222
243
 
223
- checkAbortSignal(signal)
224
244
  let blockStart = 0
225
245
  let pos = 0
226
246
 
@@ -251,15 +271,24 @@ export default class TabixIndexedFile {
251
271
  }
252
272
 
253
273
  // filter the line for whether it is within the requested range
254
- const { startCoordinate, overlaps } = this.checkLine(
255
- metadata,
274
+ const result = this.checkLine(
256
275
  refName,
257
276
  start,
258
277
  end,
259
278
  line,
279
+ columnNumbersEffective.ref,
280
+ columnNumbersEffective.start,
281
+ columnNumbersEffective.end,
282
+ maxColumn,
283
+ metaCharCode,
284
+ coordinateOffset,
285
+ isVCF,
286
+ isIdentityRename,
260
287
  )
261
288
 
262
- if (overlaps) {
289
+ if (result === null) {
290
+ return
291
+ } else if (result !== undefined) {
263
292
  callback(
264
293
  line,
265
294
  this.calculateFileOffset(
@@ -269,12 +298,9 @@ export default class TabixIndexedFile {
269
298
  blockStart,
270
299
  c.minv.dataPosition,
271
300
  ),
301
+ result.start,
302
+ result.end,
272
303
  )
273
- } else if (startCoordinate !== undefined && startCoordinate >= end) {
274
- // the lines were overlapping the region, but now have stopped, so we
275
- // must be at the end of the relevant data and we can stop processing
276
- // data now
277
- return
278
304
  }
279
305
  blockStart = n + 1
280
306
  }
@@ -296,15 +322,24 @@ export default class TabixIndexedFile {
296
322
  }
297
323
 
298
324
  // filter the line for whether it is within the requested range
299
- const { startCoordinate, overlaps } = this.checkLine(
300
- metadata,
325
+ const result = this.checkLine(
301
326
  refName,
302
327
  start,
303
328
  end,
304
329
  line,
330
+ columnNumbersEffective.ref,
331
+ columnNumbersEffective.start,
332
+ columnNumbersEffective.end,
333
+ maxColumn,
334
+ metaCharCode,
335
+ coordinateOffset,
336
+ isVCF,
337
+ isIdentityRename,
305
338
  )
306
339
 
307
- if (overlaps) {
340
+ if (result === null) {
341
+ return
342
+ } else if (result !== undefined) {
308
343
  callback(
309
344
  line,
310
345
  this.calculateFileOffset(
@@ -314,12 +349,9 @@ export default class TabixIndexedFile {
314
349
  blockStart,
315
350
  c.minv.dataPosition,
316
351
  ),
352
+ result.start,
353
+ result.end,
317
354
  )
318
- } else if (startCoordinate !== undefined && startCoordinate >= end) {
319
- // the lines were overlapping the region, but now have stopped, so we
320
- // must be at the end of the relevant data and we can stop processing
321
- // data now
322
- return
323
355
  }
324
356
  blockStart = n + 1
325
357
  }
@@ -339,8 +371,6 @@ export default class TabixIndexedFile {
339
371
  const { firstDataLine, metaChar, maxBlockSize } =
340
372
  await this.getMetadata(opts)
341
373
 
342
- checkAbortSignal(opts.signal)
343
-
344
374
  const maxFetch = (firstDataLine?.blockPosition || 0) + maxBlockSize
345
375
  // TODO: what if we don't have a firstDataLine, and the header actually
346
376
  // takes up more than one block? this case is not covered here
@@ -372,8 +402,6 @@ export default class TabixIndexedFile {
372
402
  /**
373
403
  * get a string containing the "header" region of the file, is the portion up
374
404
  * to the first non-meta line
375
- *
376
- * @returns {Promise} for a string
377
405
  */
378
406
  async getHeader(opts: Options = {}) {
379
407
  const decoder = new TextDecoder('utf8')
@@ -391,141 +419,152 @@ export default class TabixIndexedFile {
391
419
  }
392
420
 
393
421
  /**
394
- * @param {object} metadata metadata object from the parsed index, containing
395
- * columnNumbers, metaChar, and format
396
- *
397
422
  * @param {string} regionRefName
398
423
  *
399
424
  * @param {number} regionStart region start coordinate (0-based-half-open)
400
425
  *
401
426
  * @param {number} regionEnd region end coordinate (0-based-half-open)
402
427
  *
403
- * @param {array[string]} line
428
+ * @param {string} line
429
+ *
430
+ * @param {number} refColumn column number for ref
431
+ *
432
+ * @param {number} startColumn column number for start
433
+ *
434
+ * @param {number} endColumn column number for end
435
+ *
436
+ * @param {number} maxColumn pre-calculated max column
437
+ *
438
+ * @param {number} metaCharCode pre-calculated metaChar code
404
439
  *
405
- * @returns {object} like `{startCoordinate, overlaps}`. overlaps is boolean,
406
- * true if line is a data line that overlaps the given region
440
+ * @param {number} coordinateOffset 0 or -1 for coordinate adjustment
441
+ *
442
+ * @param {boolean} isVCF whether this is VCF format
443
+ *
444
+ * @param {boolean} isIdentityRename whether renameRefSeq is the identity function
445
+ *
446
+ * @returns {{ start: number, end: number } | null | undefined} coordinates if overlapping, null if should stop processing, undefined otherwise
407
447
  */
408
448
  checkLine(
409
- metadata: IndexData,
410
449
  regionRefName: string,
411
450
  regionStart: number,
412
451
  regionEnd: number,
413
452
  line: string,
414
- ) {
415
- const { columnNumbers, metaChar, coordinateType, format } = metadata
416
- // skip meta lines
417
- if (metaChar && line.startsWith(metaChar)) {
418
- return { overlaps: false }
453
+ refColumn: number,
454
+ startColumn: number,
455
+ endColumn: number,
456
+ maxColumn: number,
457
+ metaCharCode: number | undefined,
458
+ coordinateOffset: number,
459
+ isVCF: boolean,
460
+ isIdentityRename: boolean,
461
+ ): { start: number; end: number } | null | undefined {
462
+ if (metaCharCode !== undefined && line.charCodeAt(0) === metaCharCode) {
463
+ return
419
464
  }
420
465
 
421
- // check ref/start/end using column metadata from index
422
- let { ref, start, end } = columnNumbers
423
- if (!ref) {
424
- ref = 0
466
+ // Length-based fast path: split for short lines, indexOf for long lines
467
+ // Split is faster for short lines, but slow for long lines with big attribute columns
468
+ if (line.length < 500) {
469
+ const fields = line.split('\t')
470
+ const ref = fields[refColumn - 1]!
471
+ const refMatch = isIdentityRename
472
+ ? ref === regionRefName
473
+ : this.renameRefSeq(ref) === regionRefName
474
+ if (!refMatch) {
475
+ return
476
+ }
477
+
478
+ const startCoordinate = +fields[startColumn - 1]! + coordinateOffset
479
+ if (startCoordinate >= regionEnd) {
480
+ return null
481
+ }
482
+
483
+ let endCoordinate: number
484
+ if (endColumn === 0 || endColumn === startColumn) {
485
+ endCoordinate = startCoordinate + 1
486
+ } else if (isVCF) {
487
+ endCoordinate = this._getVcfEnd(
488
+ startCoordinate,
489
+ fields[3]!,
490
+ fields[endColumn - 1]!,
491
+ )
492
+ } else {
493
+ endCoordinate = +fields[endColumn - 1]!
494
+ }
495
+
496
+ if (endCoordinate <= regionStart) {
497
+ return
498
+ }
499
+ return { start: startCoordinate, end: endCoordinate }
425
500
  }
426
- if (!start) {
427
- start = 0
501
+
502
+ // Long lines - use indexOf chain (avoids parsing long attribute column)
503
+ let prev = -1
504
+ const tabs = [-1]
505
+ for (let i = 0; i < maxColumn; i++) {
506
+ const pos = line.indexOf('\t', prev + 1)
507
+ if (pos === -1) {
508
+ tabs.push(line.length)
509
+ break
510
+ }
511
+ tabs.push(pos)
512
+ prev = pos
428
513
  }
429
- if (!end) {
430
- end = 0
514
+
515
+ const ref = line.slice(tabs[refColumn - 1]! + 1, tabs[refColumn])
516
+ const refMatch = isIdentityRename
517
+ ? ref === regionRefName
518
+ : this.renameRefSeq(ref) === regionRefName
519
+ if (!refMatch) {
520
+ return
431
521
  }
432
- if (format === 'VCF') {
433
- end = 8
522
+
523
+ const startCoordinate =
524
+ +line.slice(tabs[startColumn - 1]! + 1, tabs[startColumn]) +
525
+ coordinateOffset
526
+ if (startCoordinate >= regionEnd) {
527
+ return null
434
528
  }
435
- const maxColumn = Math.max(ref, start, end)
436
-
437
- // this code is kind of complex, but it is fairly fast. basically, we want
438
- // to avoid doing a split, because if the lines are really long that could
439
- // lead to us allocating a bunch of extra memory, which is slow
440
-
441
- let currentColumnNumber = 1 // cols are numbered starting at 1 in the index metadata
442
- let currentColumnStart = 0
443
- let refSeq = ''
444
- let startCoordinate = -Infinity
445
- const l = line.length
446
- for (let i = 0; i < l + 1; i++) {
447
- if (line[i] === '\t' || i === l) {
448
- if (currentColumnNumber === ref) {
449
- if (
450
- this.renameRefSeq(line.slice(currentColumnStart, i)) !==
451
- regionRefName
452
- ) {
453
- return {
454
- overlaps: false,
455
- }
456
- }
457
- } else if (currentColumnNumber === start) {
458
- startCoordinate = Number.parseInt(
459
- line.slice(currentColumnStart, i),
460
- 10,
461
- )
462
- // we convert to 0-based-half-open
463
- if (coordinateType === '1-based-closed') {
464
- startCoordinate -= 1
465
- }
466
- if (startCoordinate >= regionEnd) {
467
- return {
468
- startCoordinate,
469
- overlaps: false,
470
- }
471
- }
472
- if (
473
- (end === 0 || end === start) && // if we have no end, we assume the feature is 1 bp long
474
- startCoordinate + 1 <= regionStart
475
- ) {
476
- return {
477
- startCoordinate,
478
- overlaps: false,
479
- }
480
- }
481
- } else if (format === 'VCF' && currentColumnNumber === 4) {
482
- refSeq = line.slice(currentColumnStart, i)
483
- } else if (currentColumnNumber === end) {
484
- // this will never match if there is no end column
485
- const endCoordinate =
486
- format === 'VCF'
487
- ? this._getVcfEnd(
488
- startCoordinate,
489
- refSeq,
490
- line.slice(currentColumnStart, i),
491
- )
492
- : Number.parseInt(line.slice(currentColumnStart, i), 10)
493
- if (endCoordinate <= regionStart) {
494
- return {
495
- overlaps: false,
496
- }
497
- }
498
- }
499
- if (currentColumnNumber === maxColumn) {
500
- break
501
- }
502
- currentColumnStart = i + 1
503
- currentColumnNumber += 1
504
- }
529
+
530
+ let endCoordinate: number
531
+ if (endColumn === 0 || endColumn === startColumn) {
532
+ endCoordinate = startCoordinate + 1
533
+ } else if (isVCF) {
534
+ endCoordinate = this._getVcfEnd(
535
+ startCoordinate,
536
+ line.slice(tabs[3]! + 1, tabs[4]),
537
+ line.slice(tabs[endColumn - 1]! + 1, tabs[endColumn]),
538
+ )
539
+ } else {
540
+ endCoordinate = +line.slice(tabs[endColumn - 1]! + 1, tabs[endColumn])
505
541
  }
506
- return {
507
- startCoordinate,
508
- overlaps: true,
542
+
543
+ if (endCoordinate <= regionStart) {
544
+ return
509
545
  }
546
+
547
+ return { start: startCoordinate, end: endCoordinate }
510
548
  }
511
549
 
512
550
  _getVcfEnd(startCoordinate: number, refSeq: string, info: any) {
513
551
  let endCoordinate = startCoordinate + refSeq.length
514
- // ignore TRA features as they specify CHR2 and END as being on a different
515
- // chromosome
516
- //
517
- // if CHR2 is on the same chromosome, still ignore it because there should
518
- // be another pairwise feature at the end of this one
519
552
  const isTRA = info.includes('SVTYPE=TRA')
520
- if (info[0] !== '.' && !isTRA) {
521
- const endRegex = /(?:^|;)END=([^;]+)/
522
- const match = endRegex.exec(info)
523
- if (match) {
524
- endCoordinate = Number.parseInt(match[1]!, 10)
525
- }
526
- } else if (isTRA) {
553
+ if (isTRA) {
527
554
  return startCoordinate + 1
528
555
  }
556
+
557
+ if (info[0] !== '.') {
558
+ const endIdx = info.indexOf('END=')
559
+ if (endIdx !== -1 && (endIdx === 0 || info[endIdx - 1] === ';')) {
560
+ const start = endIdx + 4
561
+ let end = info.indexOf(';', start)
562
+ if (end === -1) {
563
+ end = info.length
564
+ }
565
+ endCoordinate = Number.parseInt(info.slice(start, end), 10)
566
+ }
567
+ }
529
568
  return endCoordinate
530
569
  }
531
570
 
package/src/tbi.ts CHANGED
@@ -3,7 +3,7 @@ import { unzip } from '@gmod/bgzf-filehandle'
3
3
  import Chunk from './chunk.ts'
4
4
  import IndexFile, { Options } from './indexFile.ts'
5
5
  import { longFromBytesToUnsigned } from './long.ts'
6
- import { checkAbortSignal, optimizeChunks } from './util.ts'
6
+ import { optimizeChunks } from './util.ts'
7
7
  import VirtualOffset, { fromBytes } from './virtualOffset.ts'
8
8
 
9
9
  const TBI_MAGIC = 21578324 // TBI\1
@@ -44,7 +44,6 @@ export default class TabixIndex extends IndexFile {
44
44
  async _parse(opts: Options = {}) {
45
45
  const buf = await this.filehandle.readFile(opts)
46
46
  const bytes = await unzip(buf)
47
- checkAbortSignal(opts.signal)
48
47
  const dataView = new DataView(bytes.buffer)
49
48
 
50
49
  const magic = dataView.getUint32(0, true)
package/src/util.ts CHANGED
@@ -1,48 +1,6 @@
1
1
  import Chunk from './chunk.ts'
2
2
  import VirtualOffset from './virtualOffset.ts'
3
3
 
4
- class AbortError extends Error {
5
- public code: string | undefined
6
- }
7
- /**
8
- * Properly check if the given AbortSignal is aborted. Per the standard, if the
9
- * signal reads as aborted, this function throws either a DOMException
10
- * AbortError, or a regular error with a `code` attribute set to `ERR_ABORTED`.
11
- *
12
- * For convenience, passing `undefined` is a no-op
13
- *
14
- * @param {AbortSignal} [signal] an AbortSignal, or anything with an `aborted`
15
- * attribute
16
- *
17
- * @returns nothing
18
- */
19
- export function checkAbortSignal(signal?: AbortSignal) {
20
- if (!signal) {
21
- return
22
- }
23
-
24
- if (signal.aborted) {
25
- if (typeof DOMException === 'undefined') {
26
- const e = new AbortError('aborted')
27
- e.code = 'ERR_ABORTED'
28
- throw e
29
- } else {
30
- throw new DOMException('aborted', 'AbortError')
31
- }
32
- }
33
- }
34
-
35
- /**
36
- * Skips to the next tick, then runs `checkAbortSignal`.
37
- * Await this to inside an otherwise synchronous loop to
38
- * provide a place to break when an abort signal is received.
39
- * @param {AbortSignal} signal
40
- */
41
- export async function abortBreakPoint(signal?: AbortSignal) {
42
- await Promise.resolve()
43
- checkAbortSignal(signal)
44
- }
45
-
46
4
  export function canMergeBlocks(chunk1: Chunk, chunk2: Chunk) {
47
5
  return (
48
6
  chunk2.minv.blockPosition - chunk1.maxv.blockPosition < 65000 &&