nodejs/node · #66064

buffer: add Buffer.stringLength()

mcollina · merged Sep 19, 20265 files · 340 + / 0
benchmark/buffers/buffer-stringlength.jsadded28 + / 0
@@ -0,0 +1,28 @@+'use strict';++const common = require('../common.js');+const { Buffer } = require('node:buffer');+const assert = require('node:assert');++const bench = common.createBenchmark(main, {+  n: [1e6],+  encoding: ['utf8', 'latin1', 'base64'],+  len: [32, 4096, 1048576],+  input: ['ascii', 'multibyte', 'invalid'],+});++function main({ n, encoding, len, input }) {+  let buf;+  if (input === 'ascii') {+    buf = Buffer.alloc(len, 'a');+  } else {+    buf = Buffer.alloc(len - (len % 3), '€');+    if (input === 'invalid') buf = Buffer.concat([buf, Buffer.from([0xE2, 0x82])]);+  }+  const expected = buf.toString(encoding).length;+  bench.start();+  for (let i = 0; i < n; ++i) {+    assert.strictEqual(Buffer.stringLength(buf, encoding), expected);+  }+  bench.end(n);+}
doc/api/buffer.md54 + / 0
@@ -1015,6 +1015,59 @@ console.log(`${str}: ${str.length} characters, ` + When `string` is a {Buffer|DataView|TypedArray|ArrayBuffer|SharedArrayBuffer}, the byte length as reported by `.byteLength` is returned. +### Static method: `Buffer.stringLength(input[, encoding])`++<!-- YAML+added: REPLACEME+-->++* `input` {Buffer | ArrayBuffer | TypedArray} The bytes that would be decoded.+* `encoding` {string} The character encoding `input` would be decoded with.+  **Default:** `'utf8'`.+* Returns: {integer}++Returns the length, in UTF-16 code units, of the string that+`buf.toString(encoding)` would produce for the same bytes, without decoding+them. This is the counterpart of [`Buffer.byteLength()`][], which returns the+number of bytes a string would encode to.++For `'utf8'`, invalid byte sequences are counted as they would be decoded:+each maximal invalid subsequence becomes one `U+FFFD` replacement character.+For every other encoding the result is computed from `input.byteLength` alone.++A detached `ArrayBuffer`, or a `TypedArray` backed by one, is treated as empty.++The result is not capped: compare it with+[`buffer.constants.MAX_STRING_LENGTH`][] before decoding to know whether the+decode can succeed at all. A string of `n` code units occupies between `n` and+`2 * n` bytes of memory.++```mjs+import { Buffer, constants } from 'node:buffer';++const buf = Buffer.from('€ 100', 'utf8');++console.log(Buffer.stringLength(buf));+// Prints: 5+console.log(Buffer.stringLength(buf, 'hex'));+// Prints: 14+console.log(Buffer.stringLength(buf) <= constants.MAX_STRING_LENGTH);+// Prints: true+```++```cjs+const { Buffer, constants } = require('node:buffer');++const buf = Buffer.from('€ 100', 'utf8');++console.log(Buffer.stringLength(buf));+// Prints: 5+console.log(Buffer.stringLength(buf, 'hex'));+// Prints: 14+console.log(Buffer.stringLength(buf) <= constants.MAX_STRING_LENGTH);+// Prints: true+```+ ### Static method: `Buffer.compare(buf1, buf2)`  <!-- YAML@@ -5715,6 +5768,7 @@ or after startup, if the alignment has to hold at run time. [`Buffer.alloc()`]: #static-method-bufferallocsize-fill-encoding [`Buffer.allocUnsafe()`]: #static-method-bufferallocunsafesize-alignment [`Buffer.allocUnsafeSlow()`]: #static-method-bufferallocunsafeslowsize-alignment+[`Buffer.byteLength()`]: #static-method-bufferbytelengthstring-encoding [`Buffer.concat()`]: #static-method-bufferconcatlist-totallength [`Buffer.copyBytesFrom()`]: #static-method-buffercopybytesfromview-offset-length [`Buffer.from(array)`]: #static-method-bufferfromarray
lib/buffer.js29 + / 0
@@ -26,6 +26,7 @@ const {   ArrayBufferIsView,   ArrayIsArray,   ArrayPrototypeForEach,+  MathCeil,   MathFloor,   MathMin,   MathTrunc,@@ -62,6 +63,7 @@ const {   fill: bindingFill,   isAscii: bindingIsAscii,   isUtf8: bindingIsUtf8,+  stringLengthUtf8: bindingStringLengthUtf8,   indexOfBuffer,   indexOfNumber,   indexOfString,@@ -937,6 +939,7 @@ function byteLength(string, encoding) { }  Buffer.byteLength = byteLength;+Buffer.stringLength = stringLength;  // For backwards compatibility. ObjectDefineProperty(Buffer.prototype, 'parent', {@@ -1494,6 +1497,32 @@ function isAscii(input) {   throw new ERR_INVALID_ARG_TYPE('input', ['ArrayBuffer', 'Buffer', 'TypedArray'], input); } +function stringLength(input, encoding = 'utf8') {+  if (!isTypedArray(input) && !isAnyArrayBuffer(input)) {+    throw new ERR_INVALID_ARG_TYPE('input', ['ArrayBuffer', 'Buffer', 'TypedArray'], input);+  }+  validateString(encoding, 'encoding');+  const ops = getEncodingOps(encoding);+  if (ops === undefined) {+    throw new ERR_UNKNOWN_ENCODING(encoding);+  }+  const length = input.byteLength;+  switch (ops.encodingVal) {+    case encodingsMap.utf8:+      return length === 0 ? 0 : bindingStringLengthUtf8(input);+    case encodingsMap.utf16le:+      return MathFloor(length / 2);+    case encodingsMap.hex:+      return length * 2;+    case encodingsMap.base64:+      return MathCeil(length / 3) * 4;+    case encodingsMap.base64url:+      return MathCeil(length * 4 / 3);+    default:  // latin1, ascii+      return length;+  }+}+ module.exports = {   Buffer,   transcode,
src/node_buffer.cc95 + / 0
@@ -1409,6 +1409,94 @@ static bool FastIsAscii(Local<Value> receiver,  static CFunction fast_is_ascii(CFunction::Make(FastIsAscii)); +// Number of UTF-16 code units produced by decoding [p, end) as UTF-8 with+// WHATWG "maximal subpart" U+FFFD replacement, matching the fallback that+// StringBytes::Encode takes for invalid input (v8::String::NewFromUtf8).+static size_t Utf16LengthFromInvalidUtf8(const uint8_t* p, const uint8_t* end) {+  size_t units = 0;+  while (p < end) {+    const uint8_t lead = *p;+    if (lead < 0x80) {+      p++;+      units++;+      continue;+    }+    size_t len;+    uint8_t lo = 0x80;+    uint8_t hi = 0xBF;+    if (lead >= 0xC2 && lead <= 0xDF) {+      len = 2;+    } else if (lead >= 0xE0 && lead <= 0xEF) {+      len = 3;+      if (lead == 0xE0) lo = 0xA0;+      if (lead == 0xED) hi = 0x9F;+    } else if (lead >= 0xF0 && lead <= 0xF4) {+      len = 4;+      if (lead == 0xF0) lo = 0x90;+      if (lead == 0xF4) hi = 0x8F;+    } else {+      // Invalid lead byte: one replacement character.+      p++;+      units++;+      continue;+    }+    size_t i = 1;+    for (; i < len && p + i < end; i++) {+      const uint8_t c = p[i];+      if (i == 1 ? (c < lo || c > hi) : (c < 0x80 || c > 0xBF)) break;+    }+    if (i == len) {+      p += len;+      units += (len == 4) ? 2 : 1;+    } else {+      // The lead byte plus the valid continuation bytes seen so far form the+      // maximal subpart and become one replacement character; the byte that+      // failed is decoded again on the next iteration.+      p += i;+      units++;+    }+  }+  return units;+}++static double StringLengthUtf8Impl(Local<Value> value) {+  ArrayBufferViewContents<uint8_t> abv(value);+  const uint8_t* data = abv.data();+  const size_t length = abv.length();+  if (length == 0) return 0;+  const simdutf::result r = simdutf::validate_utf8_with_errors(+      reinterpret_cast<const char*>(data), length);+  if (r.error == simdutf::error_code::SUCCESS) {+    return static_cast<double>(simdutf::utf16_length_from_utf8(+        reinterpret_cast<const char*>(data), length));+  }+  // r.count is the offset of the first invalid sequence; everything before it+  // is valid UTF-8.+  const size_t valid = simdutf::utf16_length_from_utf8(+      reinterpret_cast<const char*>(data), r.count);+  return static_cast<double>(+      valid + Utf16LengthFromInvalidUtf8(data + r.count, data + length));+}++static void StringLengthUtf8(const FunctionCallbackInfo<Value>& args) {+  CHECK_EQ(args.Length(), 1);+  CHECK(args[0]->IsTypedArray() || args[0]->IsArrayBuffer() ||+        args[0]->IsSharedArrayBuffer());++  args.GetReturnValue().Set(StringLengthUtf8Impl(args[0]));+}++static double FastStringLengthUtf8(Local<Value> receiver,+                                   Local<Value> value,+                                   // NOLINTNEXTLINE(runtime/references)+                                   FastApiCallbackOptions& options) {+  TRACK_V8_FAST_API_CALL("buffer.stringLengthUtf8");+  HandleScope scope(options.isolate);+  return StringLengthUtf8Impl(value);+}++static CFunction fast_string_length_utf8(CFunction::Make(FastStringLengthUtf8));+ void SetBufferPrototype(const FunctionCallbackInfo<Value>& args) {   Realm* realm = Realm::GetCurrent(args); @@ -1839,6 +1927,11 @@ void Initialize(Local<Object> target,   SetFastMethodNoSideEffect(context, target, "isUtf8", IsUtf8, &fast_is_utf8);   SetFastMethodNoSideEffect(       context, target, "isAscii", IsAscii, &fast_is_ascii);+  SetFastMethodNoSideEffect(context,+                            target,+                            "stringLengthUtf8",+                            StringLengthUtf8,+                            &fast_string_length_utf8);    target       ->Set(context,@@ -1914,6 +2007,8 @@ void RegisterExternalReferences(ExternalReferenceRegistry* registry) {   registry->Register(fast_is_utf8);   registry->Register(IsAscii);   registry->Register(fast_is_ascii);+  registry->Register(StringLengthUtf8);+  registry->Register(fast_string_length_utf8);    registry->Register(StringSlice<ASCII>);   registry->Register(StringSlice<BASE64>);
test/parallel/test-buffer-stringlength.jsadded134 + / 0
@@ -0,0 +1,134 @@+'use strict';++require('../common');+const assert = require('assert');+const { Buffer } = require('buffer');++const encodings = ['utf8', 'utf-8', 'UTF8', 'ucs2', 'utf16le', 'UTF-16LE',+                   'latin1', 'binary', 'ascii', 'hex', 'base64', 'base64url'];++function check(buf, encoding = 'utf8') {+  const expected = buf.toString(encoding).length;+  assert.strictEqual(Buffer.stringLength(buf, encoding), expected,+                     `${encoding}: ${buf.toString('hex').slice(0, 64)}`);+}++// Fixed-width encodings on a range of lengths, including odd ones.+for (let len = 0; len <= 20; len++) {+  const buf = Buffer.alloc(len, 0xE9);+  for (const encoding of encodings) check(buf, encoding);+}++// Default encoding is utf8.+assert.strictEqual(Buffer.stringLength(Buffer.from('€ 100')), 5);+assert.strictEqual(Buffer.stringLength(Buffer.from('😀')), 2);+assert.strictEqual(Buffer.stringLength(Buffer.alloc(0)), 0);++// Every kind of input that toString-like APIs accept.+{+  const source = Buffer.from('héllo wörld €');+  const ab = new ArrayBuffer(source.length);+  new Uint8Array(ab).set(source);+  const sab = new SharedArrayBuffer(source.length);+  new Uint8Array(sab).set(source);+  const expected = source.toString().length;+  assert.strictEqual(Buffer.stringLength(ab), expected);+  assert.strictEqual(Buffer.stringLength(sab), expected);+  assert.strictEqual(Buffer.stringLength(new Uint8Array(ab)), expected);+  assert.strictEqual(Buffer.stringLength(new Uint16Array(ab.slice(0, 16))),+                     Buffer.from(ab.slice(0, 16)).toString().length);+}++// Detached ArrayBuffers are empty.+{+  const ab = new ArrayBuffer(8);+  const view = new Uint8Array(ab);+  structuredClone(ab, { transfer: [ab] });+  assert.strictEqual(Buffer.stringLength(ab), 0);+  assert.strictEqual(Buffer.stringLength(view), 0);+}++// Invalid UTF-8 is counted the way toString() decodes it (one U+FFFD per+// maximal subpart). Cases taken from test/fixtures/wpt/encoding.+[+  [0xFF],+  [0xC0],+  [0xE0],+  [0xC0, 0x00],+  [0xC0, 0xC0],+  [0xE0, 0x00],+  [0xE0, 0xC0],+  [0xE0, 0x80, 0x00],+  [0xE0, 0x80, 0xC0],+  [0xFC, 0x80, 0x80, 0x80, 0x80, 0x80],+  [0xFE, 0x80, 0x80, 0x80, 0x80, 0x80],+  [0xC0, 0x80],+  [0xE0, 0x80, 0x80],+  [0xF0, 0x80, 0x80, 0x80],+  [0xF8, 0x80, 0x80, 0x80, 0x80],+  [0xFC, 0x80, 0x80, 0x80, 0x80, 0x80],+  [0xC1, 0xBF],+  [0xE0, 0x81, 0xBF],+  [0xF0, 0x80, 0x81, 0xBF],+  [0xF8, 0x80, 0x80, 0x81, 0xBF],+  [0xFC, 0x80, 0x80, 0x80, 0x81, 0xBF],+  [0xE0, 0x9F, 0xBF],+  [0xF0, 0x8F, 0xBF, 0xBF],+  [0xF8, 0x87, 0xBF, 0xBF, 0xBF],+  [0xFC, 0x83, 0xBF, 0xBF, 0xBF, 0xBF],+  [0xED, 0xA0, 0x80],+  [0xED, 0xBF, 0xBF],+  [0xF4, 0x90, 0x80, 0x80],+  [0xF0, 0x9F, 0x98],+  [0xE2, 0x82],+  [0xE2, 0x82, 0x41],+  [0xF0, 0x9F, 0x98, 0x80, 0xF0, 0x9F],+  [0x41, 0xF0, 0x9F, 0x98, 0x41],+].forEach((bytes) => {+  const invalid = Buffer.from(bytes);+  check(invalid);+  check(Buffer.concat([Buffer.from('abc'), invalid, Buffer.from('€😀')]));+  // Push the invalid sequence past the SIMD fast path's prefix.+  check(Buffer.concat([Buffer.alloc(100, 'é'), invalid, Buffer.alloc(100, 'x')]));+});++// Random byte soup, checked against the real decoder.+{+  let seed = 0x2545F491;+  const next = () => (seed = (seed * 1103515245 + 12345) >>> 0);+  for (let i = 0; i < 2000; i++) {+    const len = next() % 64;+    const buf = Buffer.alloc(len);+    for (let j = 0; j < len; j++) {+      // Bias towards UTF-8 structural bytes so multi-byte prefixes happen.+      const r = next() % 8;+      buf[j] = r < 3 ? 0x80 + (next() % 0x40) :+        r < 5 ? 0xC0 + (next() % 0x40) : next() % 0x100;+    }+    check(buf);+  }+}++// Large inputs (above the 1 MiB single-pass threshold of the decoder).+{+  const big = Buffer.alloc(3 * 2 ** 20, '€');+  check(big);+  check(Buffer.concat([big, Buffer.from([0xE2, 0x82])]));+  check(Buffer.alloc(2 ** 20 + 1, 'a'), 'base64');+  check(Buffer.alloc(2 ** 20 + 1, 'a'), 'base64url');+}++// Argument validation.+for (const input of [undefined, null, 'abc', 42, {}, [], new Blob([]),+                     new DataView(new ArrayBuffer(4))]) {+  assert.throws(() => Buffer.stringLength(input),+                { code: 'ERR_INVALID_ARG_TYPE' });+}+for (const encoding of ['nope', 'utf32', '']) {+  assert.throws(() => Buffer.stringLength(Buffer.alloc(1), encoding),+                { code: 'ERR_UNKNOWN_ENCODING' });+}+for (const encoding of [1, null, {}]) {+  assert.throws(() => Buffer.stringLength(Buffer.alloc(1), encoding),+                { code: 'ERR_INVALID_ARG_TYPE' });+}