• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

PeculiarVentures / pvtsutils / 25105967637

29 Apr 2026 11:21AM UTC coverage: 92.053% (+1.1%) from 90.909%
25105967637

push

github

web-flow
Merge pull request #17 from PeculiarVentures:v2-dev

Release @peculiar/utils v2

293 of 374 branches covered (78.34%)

556 of 604 new or added lines in 17 files covered. (92.05%)

556 of 604 relevant lines covered (92.05%)

17.8 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

89.84
/src/encoding/hex.ts
1
import type { BufferSourceLike } from "../bytes/index.js";
2
import { toUint8Array } from "../bytes/index.js";
3
import type { ParsedBytes } from "../converters/types.js";
4

5
const HEX_CHARACTER_REGEX = /^[0-9a-f]$/i;
4✔
6
const COMMON_SEPARATORS = [" ", "\t", "\n", "\r", ":", "-", "."] as const;
4✔
7

8
/** Hex casing modes used by format-aware helpers. */
9
export type HexCase = "lower" | "upper";
10

11
/** Options that control hexadecimal decoding. */
12
export interface HexDecodeOptions {
13
  allowPrefix?: boolean;
14
  allowOddLength?: boolean;
15
  separators?: readonly string[] | "common" | "none";
16
  strict?: boolean;
17
}
18

19
/** Grouping information for formatted hexadecimal output. */
20
export interface HexGroupFormat {
21
  size: number;
22
  separator: string;
23
}
24

25
/** Line wrapping information for formatted hexadecimal output. */
26
export interface HexLineFormat {
27
  bytesPerLine: number;
28
  separator?: "\n" | "\r\n";
29
}
30

31
/** Options that control hexadecimal encoding. */
32
export interface HexEncodeOptions {
33
  case?: HexCase;
34
  prefix?: "" | "0x";
35
  group?: HexGroupFormat;
36
  line?: HexLineFormat;
37
}
38

39
/** Preserved formatting metadata for parsed hexadecimal text. */
40
export interface HexFormat {
41
  case: HexCase;
42
  prefix: "" | "0x";
43
  group?: HexGroupFormat;
44
  line?: {
45
    bytesPerLine: number;
46
    separator: "\n" | "\r\n";
47
  };
48
}
49

50
function resolveSeparators(options: HexDecodeOptions): readonly string[] {
51
  if (options.separators === "none") {
29✔
52
    return [];
1✔
53
  }
54
  if (!options.separators || options.separators === "common") {
28!
55
    return COMMON_SEPARATORS;
28✔
56
  }
NEW
57
  return options.separators;
×
58
}
59

60
function validateSeparator(separator: string): void {
61
  if (!separator) {
196!
NEW
62
    throw new TypeError("Hex separators must be non-empty strings");
×
63
  }
64
}
65

66
function matchSeparator(text: string, index: number, separators: readonly string[]): string | undefined {
67
  for (const separator of separators) {
43✔
68
    if (text.startsWith(separator, index)) {
223✔
69
      return separator;
37✔
70
    }
71
  }
72
  return undefined;
6✔
73
}
74

75
function detectCase(text: string): HexCase {
76
  const hasUpper = /[A-F]/.test(text);
4✔
77
  const hasLower = /[a-f]/.test(text);
4✔
78
  return hasUpper && !hasLower ? "upper" : "lower";
4!
79
}
80

81
function detectLineSeparator(text: string): "\n" | "\r\n" | undefined {
82
  const match = /\r\n|\n/.exec(text);
4✔
83
  if (!match) {
4✔
84
    return undefined;
3✔
85
  }
86
  return match[0] === "\r\n" ? "\r\n" : "\n";
1!
87
}
88

89
function compactForDetection(text: string): string {
90
  return text.replace(/[^0-9a-f]/gi, "");
2✔
91
}
92

93
function detectGroup(text: string): HexGroupFormat | undefined {
94
  const segments = text.match(/[0-9A-Fa-f]+|[^0-9A-Fa-f]+/g) ?? [];
4!
95
  if (segments.length < 3) {
4!
NEW
96
    return undefined;
×
97
  }
98

99
  const hexSegments = segments.filter((_, index) => index % 2 === 0);
26✔
100
  const separators = segments.filter((_, index) => index % 2 === 1);
26✔
101
  const separator = separators[0];
4✔
102
  if (!separator || separators.some((item) => item !== separator)) {
11!
NEW
103
    return undefined;
×
104
  }
105
  if (hexSegments.some((segment) => segment.length === 0 || segment.length % 2 !== 0)) {
15!
NEW
106
    return undefined;
×
107
  }
108

109
  const firstLength = hexSegments[0]?.length ?? 0;
4!
110
  if (!firstLength) {
4!
NEW
111
    return undefined;
×
112
  }
113
  if (hexSegments.slice(0, -1).some((segment) => segment.length !== firstLength)) {
11!
NEW
114
    return undefined;
×
115
  }
116
  if ((hexSegments[hexSegments.length - 1]?.length ?? 0) > firstLength) {
4!
NEW
117
    return undefined;
×
118
  }
119

120
  return {
4✔
121
    size: firstLength / 2,
122
    separator,
123
  };
124
}
125

126
function detectFormat(text: string): HexFormat {
127
  const trimmed = text.trim();
4✔
128
  const prefix = /^0x/i.test(trimmed) ? "0x" : "";
4✔
129
  const body = prefix ? trimmed.slice(2) : trimmed;
4✔
130
  const lineSeparator = detectLineSeparator(body);
4✔
131
  const lines = body.split(/\r\n|\n/).filter((line) => line.length > 0);
5✔
132
  const sampleLine = lines[0]?.trim() ?? "";
4!
133
  const group = detectGroup(sampleLine);
4✔
134
  const format: HexFormat = {
4✔
135
    case: detectCase(trimmed),
136
    prefix,
137
  };
138

139
  if (group) {
4!
140
    format.group = group;
4✔
141
  }
142

143
  if (lineSeparator && lines.length > 1) {
4✔
144
    const firstLineBytes = compactForDetection(lines[0] ?? "").length / 2;
1!
145
    if (firstLineBytes > 0 && lines.slice(0, -1).every((line) => compactForDetection(line).length / 2 === firstLineBytes)) {
1!
146
      format.line = {
1✔
147
        bytesPerLine: firstLineBytes,
148
        separator: lineSeparator,
149
      };
150
    }
151
  }
152

153
  return format;
4✔
154
}
155

156
function normalizeText(text: string, options: HexDecodeOptions): string {
157
  const allowPrefix = options.allowPrefix ?? true;
29✔
158
  const separators = [...resolveSeparators(options)].sort((left, right) => right.length - left.length);
168✔
159
  for (const separator of separators) {
29✔
160
    validateSeparator(separator);
196✔
161
  }
162

163
  let working = text.trim();
29✔
164
  if (/^0x/i.test(working)) {
29✔
165
    if (!allowPrefix) {
5✔
166
      throw new TypeError("Hexadecimal text must not include a 0x prefix");
1✔
167
    }
168
    working = working.slice(2);
4✔
169
  }
170

171
  let normalized = "";
28✔
172
  let lastTokenWasSeparator = false;
28✔
173

174
  for (let index = 0; index < working.length;) {
28✔
175
    const character = working[index] ?? "";
200!
176
    if (HEX_CHARACTER_REGEX.test(character)) {
200✔
177
      normalized += character;
157✔
178
      lastTokenWasSeparator = false;
157✔
179
      index += 1;
157✔
180
      continue;
157✔
181
    }
182

183
    const separator = matchSeparator(working, index, separators);
43✔
184
    if (!separator) {
43✔
185
      throw new TypeError("Input is not valid hexadecimal text");
6✔
186
    }
187
    if (options.strict && (lastTokenWasSeparator || normalized.length === 0)) {
37!
NEW
188
      throw new TypeError("Hexadecimal text contains misplaced separators");
×
189
    }
190
    lastTokenWasSeparator = true;
37✔
191
    index += separator.length;
37✔
192
  }
193

194
  if (options.strict && lastTokenWasSeparator && normalized.length > 0) {
22!
NEW
195
    throw new TypeError("Hexadecimal text must not end with a separator");
×
196
  }
197

198
  if (normalized.length % 2 !== 0) {
22✔
199
    if (!options.allowOddLength) {
5✔
200
      throw new TypeError("Hexadecimal text must contain an even number of characters");
2✔
201
    }
202
    normalized = `0${normalized}`;
3✔
203
  }
204

205
  return normalized.toLowerCase();
20✔
206
}
207

208
function groupPairs(pairs: readonly string[], group?: HexGroupFormat): string {
209
  if (!group) {
17✔
210
    return pairs.join("");
7✔
211
  }
212
  if (!Number.isInteger(group.size) || group.size < 1) {
10!
NEW
213
    throw new RangeError("Hex group size must be a positive integer");
×
214
  }
215

216
  const chunks: string[] = [];
10✔
217
  for (let index = 0; index < pairs.length; index += group.size) {
10✔
218
    chunks.push(pairs.slice(index, index + group.size).join(""));
28✔
219
  }
220
  return chunks.join(group.separator);
10✔
221
}
222

223
/** Removes separators and normalizes hexadecimal text. */
224
export function normalize(text: string, options: HexDecodeOptions = {}): string {
29✔
225
  return normalizeText(text, options);
29✔
226
}
227

228
/** Checks whether a value is normalized hexadecimal text. */
229
export function is(text: unknown, options: HexDecodeOptions = {}): text is string {
6✔
230
  if (typeof text !== "string") {
6!
NEW
231
    return false;
×
232
  }
233
  try {
6✔
234
    normalize(text, options);
6✔
235
    return true;
6✔
236
  } catch {
237
    return false;
3✔
238
  }
239
}
240

241
/** Encodes buffer data as formatted hexadecimal text. */
242
export function encode(data: BufferSourceLike, options: HexEncodeOptions = {}): string {
15✔
243
  const bytes = toUint8Array(data);
15✔
244
  const casing = options.case ?? "lower";
15✔
245
  const pairs = Array.from(bytes, (byte) => {
15✔
246
    const text = byte.toString(16).padStart(2, "0");
58✔
247
    return casing === "upper" ? text.toUpperCase() : text;
58✔
248
  });
249

250
  let body = "";
15✔
251
  if (options.line) {
15✔
252
    const bytesPerLine = options.line.bytesPerLine;
2✔
253
    if (!Number.isInteger(bytesPerLine) || bytesPerLine < 1) {
2!
NEW
254
      throw new RangeError("Hex bytesPerLine must be a positive integer");
×
255
    }
256
    const separator = options.line.separator ?? "\n";
2✔
257
    const lines: string[] = [];
2✔
258
    for (let index = 0; index < pairs.length; index += bytesPerLine) {
2✔
259
      lines.push(groupPairs(pairs.slice(index, index + bytesPerLine), options.group));
4✔
260
    }
261
    body = lines.join(separator);
2✔
262
  } else {
263
    body = groupPairs(pairs, options.group);
13✔
264
  }
265

266
  return `${options.prefix ?? ""}${body}`;
15✔
267
}
268

269
/** Decodes hexadecimal text into bytes. */
270
export function decode(text: string, options: HexDecodeOptions = {}): Uint8Array {
17✔
271
  const normalized = normalize(text, options);
17✔
272

273
  const result = new Uint8Array(normalized.length / 2);
17✔
274
  for (let i = 0; i < normalized.length; i += 2) {
17✔
275
    result[i / 2] = Number.parseInt(normalized.slice(i, i + 2), 16);
40✔
276
  }
277

278
  return result;
11✔
279
}
280

281
/** Parses hexadecimal text into bytes plus detected formatting metadata. */
282
export function parse(text: string, options: HexDecodeOptions = {}): ParsedBytes<HexFormat> {
4✔
283
  const normalized = normalize(text, options);
4✔
284
  return {
4✔
285
    bytes: decode(normalized),
286
    format: detectFormat(text),
287
    normalized,
288
  };
289
}
290

291
/** Formats bytes using preserved hexadecimal formatting metadata. */
292
export function format(data: BufferSourceLike, value: HexFormat): string {
293
  return encode(data, value);
3✔
294
}
295

296
/** Reusable hexadecimal formatting presets. */
297
export const formats = {
4✔
298
  compact: Object.freeze({} as HexEncodeOptions),
299
  upper: Object.freeze({ case: "upper" } as HexEncodeOptions),
300
  colon: Object.freeze({ group: { size: 1, separator: ":" } } as HexEncodeOptions),
301
  colonUpper: Object.freeze({ case: "upper", group: { size: 1, separator: ":" } } as HexEncodeOptions),
302
  groupsOf4: Object.freeze({ group: { size: 4, separator: " " } } as HexEncodeOptions),
303
  prefixed: Object.freeze({ prefix: "0x" } as HexEncodeOptions),
304
} as const;
305

306
/** Hexadecimal codec helpers. */
307
export const hex = { encode, decode, format, formats, is, normalize, parse } as const;
4✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc