@gmod/tabix 3.4.1 → 3.4.3

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.
@@ -14,6 +14,7 @@ import type { GenericFilehandle } from 'generic-filehandle2'
14
14
 
15
15
  const TAB = 9
16
16
  const NEWLINE = 10
17
+ const CARRIAGE_RETURN = 13
17
18
  const SEMICOLON = 59
18
19
 
19
20
  type GetLinesCallback = (
@@ -59,6 +60,56 @@ function resolveFilehandle(
59
60
  throw new TypeError('must provide either filehandle, path, or url')
60
61
  }
61
62
 
63
+ // An explicitly supplied index always wins over the `<path>.tbi` /
64
+ // `<url>.tbi` names derived from the data file, whichever kind it is.
65
+ function resolveIndex({
66
+ tbiFilehandle,
67
+ csiFilehandle,
68
+ tbiPath,
69
+ csiPath,
70
+ tbiUrl,
71
+ csiUrl,
72
+ path,
73
+ url,
74
+ }: {
75
+ tbiFilehandle?: GenericFilehandle
76
+ csiFilehandle?: GenericFilehandle
77
+ tbiPath?: string
78
+ csiPath?: string
79
+ tbiUrl?: string
80
+ csiUrl?: string
81
+ path?: string
82
+ url?: string
83
+ }) {
84
+ if (tbiFilehandle) {
85
+ return new TBI({ filehandle: tbiFilehandle })
86
+ }
87
+ if (csiFilehandle) {
88
+ return new CSI({ filehandle: csiFilehandle })
89
+ }
90
+ if (tbiPath) {
91
+ return new TBI({ filehandle: new LocalFile(tbiPath) })
92
+ }
93
+ if (csiPath) {
94
+ return new CSI({ filehandle: new LocalFile(csiPath) })
95
+ }
96
+ if (csiUrl) {
97
+ return new CSI({ filehandle: new RemoteFile(csiUrl) })
98
+ }
99
+ if (tbiUrl) {
100
+ return new TBI({ filehandle: new RemoteFile(tbiUrl) })
101
+ }
102
+ if (path) {
103
+ return new TBI({ filehandle: new LocalFile(`${path}.tbi`) })
104
+ }
105
+ if (url) {
106
+ return new TBI({ filehandle: new RemoteFile(`${url}.tbi`) })
107
+ }
108
+ throw new TypeError(
109
+ 'must provide one of tbiFilehandle, tbiPath, csiFilehandle, csiPath, tbiUrl, csiUrl',
110
+ )
111
+ }
112
+
62
113
  function calculateFileOffset(
63
114
  cpositions: number[],
64
115
  dpositions: number[],
@@ -172,28 +223,16 @@ export default class TabixIndexedFile {
172
223
  chunkCacheSize?: number
173
224
  }) {
174
225
  this.filehandle = resolveFilehandle(filehandle, path, url)
175
-
176
- if (tbiFilehandle) {
177
- this.index = new TBI({ filehandle: tbiFilehandle })
178
- } else if (csiFilehandle) {
179
- this.index = new CSI({ filehandle: csiFilehandle })
180
- } else if (tbiPath) {
181
- this.index = new TBI({ filehandle: new LocalFile(tbiPath) })
182
- } else if (csiPath) {
183
- this.index = new CSI({ filehandle: new LocalFile(csiPath) })
184
- } else if (path) {
185
- this.index = new TBI({ filehandle: new LocalFile(`${path}.tbi`) })
186
- } else if (csiUrl) {
187
- this.index = new CSI({ filehandle: new RemoteFile(csiUrl) })
188
- } else if (tbiUrl) {
189
- this.index = new TBI({ filehandle: new RemoteFile(tbiUrl) })
190
- } else if (url) {
191
- this.index = new TBI({ filehandle: new RemoteFile(`${url}.tbi`) })
192
- } else {
193
- throw new TypeError(
194
- 'must provide one of tbiFilehandle, tbiPath, csiFilehandle, csiPath, tbiUrl, csiUrl',
195
- )
196
- }
226
+ this.index = resolveIndex({
227
+ tbiFilehandle,
228
+ csiFilehandle,
229
+ tbiPath,
230
+ csiPath,
231
+ tbiUrl,
232
+ csiUrl,
233
+ path,
234
+ url,
235
+ })
197
236
 
198
237
  this.chunkCache = new AbortablePromiseCache<Chunk, ReadChunk>({
199
238
  cache: new LRU({ maxSize: Math.floor(chunkCacheSize / (1 << 16)) }),
@@ -318,15 +357,14 @@ export default class TabixIndexedFile {
318
357
  continue
319
358
  }
320
359
 
321
- // find tab positions
360
+ // find tab positions. Columns past the end of the line all get `n`
361
+ // rather than breaking out, which would leave stale offsets from the
362
+ // previous line in the tail of the array.
322
363
  tabs[0] = blockStart - 1
323
364
  for (let i = 0; i < maxColumn; i++) {
324
- const tabPos = buffer.indexOf(TAB, tabs[i]! + 1)
325
- if (tabPos === -1 || tabPos >= n) {
326
- tabs[i + 1] = n
327
- break
328
- }
329
- tabs[i + 1] = tabPos
365
+ const prev = tabs[i]!
366
+ const tabPos = prev < n ? buffer.indexOf(TAB, prev + 1) : -1
367
+ tabs[i + 1] = tabPos === -1 || tabPos >= n ? n : tabPos
330
368
  }
331
369
 
332
370
  // compare ref name bytes directly
@@ -380,7 +418,9 @@ export default class TabixIndexedFile {
380
418
  }
381
419
 
382
420
  if (endCoordinate > start) {
383
- const line = decoder.decode(buffer.subarray(blockStart, n))
421
+ // trim a CRLF terminator, matching htslib's line reader
422
+ const lineEnd = buffer[n - 1] === CARRIAGE_RETURN ? n - 1 : n
423
+ const line = decoder.decode(buffer.subarray(blockStart, lineEnd))
384
424
  callback(
385
425
  line,
386
426
  calculateFileOffset(
package/src/tbi.ts CHANGED
@@ -3,6 +3,7 @@ import { unzip } from '@gmod/bgzf-filehandle'
3
3
  import Chunk from './chunk.ts'
4
4
  import IndexFile from './indexFile.ts'
5
5
  import {
6
+ clampChunkEnds,
6
7
  findFirstData,
7
8
  memoizeByRefId,
8
9
  optimizeChunks,
@@ -16,21 +17,31 @@ import type VirtualOffset from './virtualOffset.ts'
16
17
 
17
18
  const TBI_MAGIC = 21_578_324 // TBI\1
18
19
 
20
+ // TBI is always the fixed depth-5, 14-bit-leaf binning scheme
21
+ const TBI_DEPTH = 5
22
+ const TBI_MAX_REF_LENGTH = 2 ** (14 + TBI_DEPTH * 3)
23
+
19
24
  /**
20
25
  * calculate the list of bins that may overlap with region [beg,end)
21
26
  * (zero-based half-open)
22
27
  */
23
28
  function reg2bins(beg: number, end: number) {
24
- beg += 1 // < convert to 1-based closed
25
- end -= 1
26
- return [
27
- [0, 0],
28
- [1 + (beg >> 26), 1 + (end >> 26)],
29
- [9 + (beg >> 23), 9 + (end >> 23)],
30
- [73 + (beg >> 20), 73 + (end >> 20)],
31
- [585 + (beg >> 17), 585 + (end >> 17)],
32
- [4681 + (beg >> 14), 4681 + (end >> 14)],
33
- ] as const
29
+ // Clamp into the binning scheme's range. Past 2**31 the shifts below
30
+ // truncate to int32 and go negative, which would silently yield no bins.
31
+ const b = Math.min(Math.max(beg, 0), TBI_MAX_REF_LENGTH)
32
+ const e = Math.min(end, TBI_MAX_REF_LENGTH) - 1
33
+ const bins: (readonly [number, number])[] = []
34
+ if (b <= e) {
35
+ bins.push(
36
+ [0, 0],
37
+ [1 + (b >> 26), 1 + (e >> 26)],
38
+ [9 + (b >> 23), 9 + (e >> 23)],
39
+ [73 + (b >> 20), 73 + (e >> 20)],
40
+ [585 + (b >> 17), 585 + (e >> 17)],
41
+ [4681 + (b >> 14), 4681 + (e >> 14)],
42
+ )
43
+ }
44
+ return bins
34
45
  }
35
46
 
36
47
  export default class TabixIndex extends IndexFile {
@@ -41,16 +52,18 @@ export default class TabixIndex extends IndexFile {
41
52
  onProgress: opts.onProgress,
42
53
  })
43
54
  const bytes = (await unzip(buf)) as Uint8Array
44
- const dataView = new DataView(bytes.buffer)
55
+ const dataView = new DataView(
56
+ bytes.buffer,
57
+ bytes.byteOffset,
58
+ bytes.byteLength,
59
+ )
45
60
 
46
61
  if (dataView.getUint32(0, true) !== TBI_MAGIC) {
47
62
  throw new Error('Not a TBI file')
48
63
  }
49
64
 
50
65
  const refCount = dataView.getUint32(4, true)
51
- const depth = 5
52
- const maxBinNumber = ((1 << ((depth + 1) * 3)) - 1) / 7
53
- const maxRefLength = 2 ** (14 + depth * 3)
66
+ const maxBinNumber = ((1 << ((TBI_DEPTH + 1) * 3)) - 1) / 7
54
67
 
55
68
  // TBI header layout matches CSI aux data; parseAuxData handles both
56
69
  const {
@@ -147,6 +160,10 @@ export default class TabixIndex extends IndexFile {
147
160
  linearIndex[k] = fromBytes(bytes, pos)
148
161
  pos += 8
149
162
  }
163
+ clampChunkEnds(
164
+ Object.values(binIndex).flat(),
165
+ linearIndex.map(v => v.blockPosition),
166
+ )
150
167
  return { binIndex, linearIndex, stats }
151
168
  }
152
169
 
@@ -154,7 +171,7 @@ export default class TabixIndex extends IndexFile {
154
171
  indices: memoizeByRefId(getIndices),
155
172
  metaChar,
156
173
  maxBinNumber,
157
- maxRefLength,
174
+ maxRefLength: TBI_MAX_REF_LENGTH,
158
175
  skipLines,
159
176
  firstDataLine,
160
177
  columnNumbers,
@@ -172,10 +189,6 @@ export default class TabixIndex extends IndexFile {
172
189
  max: number,
173
190
  opts: Options = {},
174
191
  ) {
175
- if (min < 0) {
176
- min = 0
177
- }
178
-
179
192
  const indexData = await this.parse(opts)
180
193
  const refId = indexData.refNameToId[refName]
181
194
  if (refId === undefined) {
@@ -186,13 +199,12 @@ export default class TabixIndex extends IndexFile {
186
199
  return []
187
200
  }
188
201
 
189
- const linearIndex = ba.linearIndex ?? []
190
- // min >= 2**31 overflows int32 and produces a negative >> 14 result —
191
- // query is well beyond the indexed range
192
- if (linearIndex.length > 0 && min >= 2 ** 31) {
202
+ if (min > TBI_MAX_REF_LENGTH) {
193
203
  console.warn('querying outside of possible tabix range')
194
204
  }
195
-
205
+ // clamp before the >> 14 below, which would truncate to int32 and go
206
+ // negative past 2**31
207
+ const beg = Math.min(Math.max(min, 0), TBI_MAX_REF_LENGTH)
196
208
  const overlappingBins = reg2bins(min, max) // List of bin #s that overlap min, max
197
209
  const chunks: Chunk[] = []
198
210
 
@@ -211,7 +223,8 @@ export default class TabixIndex extends IndexFile {
211
223
  // The linear index is monotonically non-decreasing, so the minimum virtual
212
224
  // offset for chunks that could overlap [min, ...) is at index minLin.
213
225
  // SYNC: ~/src/gmod/bam-js/src/bai.ts getLowestChunk
214
- const lowest = linearIndex[Math.min(min >> 14, linearIndex.length - 1)]
226
+ const linearIndex = ba.linearIndex
227
+ const lowest = linearIndex?.[Math.min(beg >> 14, linearIndex.length - 1)]
215
228
 
216
229
  return optimizeChunks(chunks, lowest)
217
230
  }
package/src/util.ts CHANGED
@@ -65,6 +65,7 @@ export function optimizeChunks(chunks: Chunk[], lowest?: VirtualOffset) {
65
65
  lastChunk.minv,
66
66
  chunk.maxv,
67
67
  lastChunk.bin,
68
+ chunk.endPosition,
68
69
  )
69
70
  lastMaxBlock = chunkMaxBlock
70
71
  }
@@ -78,6 +79,45 @@ export function optimizeChunks(chunks: Chunk[], lowest?: VirtualOffset) {
78
79
  return mergedChunks
79
80
  }
80
81
 
82
+ // Tighten each chunk's endPosition (default: a full max-size BGZF block past
83
+ // maxv) down to the next known block boundary. Every chunk min/maxv blockPosition
84
+ // and every extraBoundary (e.g. linear-index entries) is a real BGZF block start;
85
+ // since blocks don't overlap, the first boundary strictly greater than a chunk's
86
+ // maxv.blockPosition is an upper bound on where that final block ends — always at
87
+ // least the true block end, so the clamped fetch still contains the whole block.
88
+ // Shrinks both the byte estimate and the actual fetch with no extra I/O.
89
+ export function clampChunkEnds(
90
+ chunks: Chunk[],
91
+ extraBoundaries: number[] = [],
92
+ ) {
93
+ // Array#sort with a comparator beats a Float64Array numeric sort here:
94
+ // extraBoundaries (the linear index) arrives already sorted and TimSort
95
+ // exploits the existing run, which introsort cannot.
96
+ const boundaries = [...extraBoundaries]
97
+ for (const c of chunks) {
98
+ boundaries.push(c.minv.blockPosition, c.maxv.blockPosition)
99
+ }
100
+ boundaries.sort((a, b) => a - b)
101
+
102
+ for (const c of chunks) {
103
+ const max = c.maxv.blockPosition
104
+ // first boundary strictly greater than max
105
+ let lo = 0
106
+ let hi = boundaries.length
107
+ while (lo < hi) {
108
+ const mid = (lo + hi) >>> 1
109
+ if (boundaries[mid]! > max) {
110
+ hi = mid
111
+ } else {
112
+ lo = mid + 1
113
+ }
114
+ }
115
+ if (lo < boundaries.length) {
116
+ c.endPosition = Math.min(c.endPosition, boundaries[lo]!)
117
+ }
118
+ }
119
+ }
120
+
81
121
  export function findFirstData(
82
122
  currentFdl: VirtualOffset | undefined,
83
123
  virtualOffset: VirtualOffset,
@@ -116,7 +156,11 @@ const tabixFormats: Record<number, string> = {
116
156
  }
117
157
 
118
158
  export function parseAuxData(bytes: Uint8Array, offset: number) {
119
- const dataView = new DataView(bytes.buffer)
159
+ const dataView = new DataView(
160
+ bytes.buffer,
161
+ bytes.byteOffset,
162
+ bytes.byteLength,
163
+ )
120
164
  const formatFlags = dataView.getInt32(offset, true)
121
165
  const coordinateType =
122
166
  formatFlags & 0x1_00_00 ? 'zero-based-half-open' : '1-based-closed'