Current section
26 Versions
Jump to
Current section
26 Versions
Compare versions
76
files changed
+5122
additions
-5566
deletions
| @@ -3,167 +3,359 @@ | |
| 3 3 | import HologramInterpreterError from "./errors/interpreter_error.mjs"; |
| 4 4 | import Interpreter from "./interpreter.mjs"; |
| 5 5 | import Type from "./type.mjs"; |
| 6 | - import Utils from "./utils.mjs"; |
| 7 6 | |
| 8 7 | export default class Bitstring { |
| 9 | - static buildSignedBigIntFromBitArray(bitArray) { |
| 10 | - if (bitArray.length === 0) { |
| 11 | - return 0n; |
| 8 | + static #decoder = new TextDecoder("utf-8", {fatal: true}); |
| 9 | + static #encoder = new TextEncoder("utf-8"); |
| 10 | + |
| 11 | + static calculateBitCount(bitstring) { |
| 12 | + if (bitstring.bytes !== null) { |
| 13 | + const completeByteCount = |
| 14 | + bitstring.leftoverBitCount === 0 |
| 15 | + ? bitstring.bytes.length |
| 16 | + : bitstring.bytes.length - 1; |
| 17 | + |
| 18 | + return 8 * completeByteCount + bitstring.leftoverBitCount; |
| 12 19 | } |
| 13 20 | |
| 14 | - const signBit = bitArray[0]; |
| 15 | - |
| 16 | - const value = bitArray.slice(1).reduce((acc, bit, index) => { |
| 17 | - return acc | BigInt(bit << (bitArray.length - index - 2)); |
| 18 | - }, 0n); |
| 19 | - |
| 20 | - return signBit === 1 ? -BigInt(2 ** (bitArray.length - 1)) + value : value; |
| 21 | + return 8 * $.calculateTextByteCount(bitstring.text); |
| 21 22 | } |
| 22 23 | |
| 23 | - static buildUnsignedBigIntFromBitArray(bitArray) { |
| 24 | - return bitArray.reduce((acc, bit, index) => { |
| 25 | - return acc | BigInt(bit << (bitArray.length - 1 - index)); |
| 26 | - }, 0n); |
| 24 | + static calculateSegmentBitCount(segment) { |
| 25 | + const size = $.resolveSegmentSize(segment); |
| 26 | + const unit = $.resolveSegmentUnit(segment); |
| 27 | + |
| 28 | + return size * unit; |
| 27 29 | } |
| 28 30 | |
| 29 | - // TODO: test |
| 30 | - static buildValueFromBitstringChunk(segment, bitArray, offset) { |
| 31 | - switch (segment.type) { |
| 32 | - case "float": |
| 33 | - return Bitstring.#buildFloatFromBitstringChunk( |
| 34 | - segment, |
| 35 | - bitArray, |
| 36 | - offset, |
| 37 | - ); |
| 38 | - |
| 39 | - case "integer": |
| 40 | - return Bitstring.#buildIntegerFromBitstringChunk( |
| 41 | - segment, |
| 42 | - bitArray, |
| 43 | - offset, |
| 44 | - ); |
| 45 | - |
| 46 | - case "utf8": |
| 47 | - return Bitstring.fetchNextCodePointFromUtf8BitstringChunk( |
| 48 | - bitArray, |
| 49 | - offset, |
| 50 | - ); |
| 51 | - |
| 52 | - default: |
| 53 | - throw new HologramInterpreterError( |
| 54 | - `building ${segment.type} value from a bitstring segment is not yet implemented in Hologram`, |
| 55 | - ); |
| 56 | - } |
| 31 | + static calculateTextByteCount(text) { |
| 32 | + return $.#encoder.encode(text).length; |
| 57 33 | } |
| 58 34 | |
| 59 | - // See: https://en.wikipedia.org/wiki/UTF-8#Encoding |
| 60 | - static fetchNextCodePointFromUtf8BitstringChunk(bitArray, offset) { |
| 61 | - const numRemainingBits = bitArray.length - offset; |
| 62 | - |
| 63 | - let numBytes; |
| 64 | - |
| 65 | - // 0xxxxxxx |
| 66 | - if (numRemainingBits >= 8 && bitArray[offset] === 0) { |
| 67 | - numBytes = 1; |
| 68 | - } else if ( |
| 69 | - // 110xxxxx, 10xxxxxx |
| 70 | - numRemainingBits >= 16 && |
| 71 | - bitArray[offset] === 1 && |
| 72 | - bitArray[offset + 1] === 1 && |
| 73 | - bitArray[offset + 2] === 0 && |
| 74 | - bitArray[offset + 8] == 1 && |
| 75 | - bitArray[offset + 9] == 0 |
| 76 | - ) { |
| 77 | - numBytes = 2; |
| 78 | - } else if ( |
| 79 | - //1110xxxx, 10xxxxxx, 10xxxxxx |
| 80 | - numRemainingBits >= 24 && |
| 81 | - bitArray[offset] === 1 && |
| 82 | - bitArray[offset + 1] === 1 && |
| 83 | - bitArray[offset + 2] === 1 && |
| 84 | - bitArray[offset + 3] === 0 && |
| 85 | - bitArray[offset + 8] == 1 && |
| 86 | - bitArray[offset + 9] == 0 && |
| 87 | - bitArray[offset + 16] == 1 && |
| 88 | - bitArray[offset + 17] == 0 |
| 89 | - ) { |
| 90 | - numBytes = 3; |
| 91 | - } else if ( |
| 92 | - // 11110xxx, 10xxxxxx, 10xxxxxx, 10xxxxxx |
| 93 | - numRemainingBits >= 32 && |
| 94 | - bitArray[offset] === 1 && |
| 95 | - bitArray[offset + 1] === 1 && |
| 96 | - bitArray[offset + 2] === 1 && |
| 97 | - bitArray[offset + 3] === 1 && |
| 98 | - bitArray[offset + 4] === 0 && |
| 99 | - bitArray[offset + 8] == 1 && |
| 100 | - bitArray[offset + 9] == 0 && |
| 101 | - bitArray[offset + 16] == 1 && |
| 102 | - bitArray[offset + 17] == 0 && |
| 103 | - bitArray[offset + 24] == 1 && |
| 104 | - bitArray[offset + 25] == 0 |
| 105 | - ) { |
| 106 | - numBytes = 4; |
| 107 | - } else { |
| 108 | - return false; |
| 35 | + static concat(bitstrings) { |
| 36 | + // Fast path: if a single bitstring is given return it as is |
| 37 | + if (bitstrings.length === 1) { |
| 38 | + return bitstrings[0]; |
| 109 39 | } |
| 110 40 | |
| 111 | - if (numBytes > 1 && offset + numBytes * 8 > bitArray.length) { |
| 112 | - return false; |
| 41 | + // Fast path: if all bitstrings are text-based join them |
| 42 | + // Notice: this also covers the case when an empty array is given (an empty bitstring is returned) |
| 43 | + if (bitstrings.every((bs) => bs.text !== null)) { |
| 44 | + const text = bitstrings.map((bs) => bs.text).join(""); |
| 45 | + return $.fromText(text); |
| 113 46 | } |
| 114 47 | |
| 115 | - const chunks = []; |
| 48 | + bitstrings.forEach((bs) => $.maybeSetBytesFromText(bs)); |
| 116 49 | |
| 117 | - switch (numBytes) { |
| 118 | - case 1: |
| 119 | - chunks[0] = bitArray.slice(offset + 1, offset + 8); |
| 120 | - break; |
| 121 | - |
| 122 | - case 2: |
| 123 | - chunks[0] = bitArray.slice(offset + 3, offset + 8); |
| 124 | - chunks[1] = bitArray.slice(offset + 8 + 2, offset + 2 * 8); |
| 125 | - break; |
| 126 | - |
| 127 | - case 3: |
| 128 | - chunks[0] = bitArray.slice(offset + 4, offset + 8); |
| 129 | - chunks[1] = bitArray.slice(offset + 8 + 2, offset + 2 * 8); |
| 130 | - chunks[2] = bitArray.slice(offset + 2 * 8 + 2, offset + 3 * 8); |
| 131 | - break; |
| 132 | - |
| 133 | - case 4: |
| 134 | - chunks[0] = bitArray.slice(offset + 5, offset + 8); |
| 135 | - chunks[1] = bitArray.slice(offset + 8 + 2, offset + 8 + 8); |
| 136 | - chunks[2] = bitArray.slice(offset + 2 * 8 + 2, offset + 3 * 8); |
| 137 | - chunks[3] = bitArray.slice(offset + 3 * 8 + 2, offset + 4 * 8); |
| 138 | - break; |
| 50 | + // Fast path: no bitstrings with leftover bits |
| 51 | + if (bitstrings.every((bs) => bs.leftoverBitCount === 0)) { |
| 52 | + return $.#concatBitstringsWithoutLeftoverBits(bitstrings); |
| 139 53 | } |
| 140 54 | |
| 141 | - const codePointBitCount = chunks.reduce( |
| 142 | - (acc, chunk) => acc + chunk.length, |
| 55 | + // Complex case: handle leftover bits |
| 56 | + |
| 57 | + const totalBitCount = bitstrings.reduce( |
| 58 | + (acc, bs) => acc + $.calculateBitCount(bs), |
| 143 59 | 0, |
| 144 60 | ); |
| 145 61 | |
| 146 | - const codePointBitArray = new Uint8Array(codePointBitCount); |
| 147 | - let codePointOffset = 0; |
| 62 | + const resultByteCount = Math.ceil(totalBitCount / 8); |
| 63 | + const resultLeftoverBitCount = totalBitCount % 8; |
| 64 | + const resultBytes = new Uint8Array(resultByteCount); |
| 148 65 | |
| 149 | - for (const chunk of chunks) { |
| 150 | - codePointBitArray.set(chunk, codePointOffset); |
| 151 | - codePointOffset += chunk.length; |
| 66 | + let bitOffset = 0; |
| 67 | + |
| 68 | + for (let i = 0; i < bitstrings.length; i++) { |
| 69 | + const bs = bitstrings[i]; |
| 70 | + const bsBitCount = $.calculateBitCount(bs); |
| 71 | + |
| 72 | + if (bsBitCount === 0) continue; |
| 73 | + |
| 74 | + const byteOffset = bitOffset >>> 3; // Integer division by 8 |
| 75 | + |
| 76 | + if (bitOffset % 8 === 0) { |
| 77 | + // If we're at a byte boundary, we can use a fast path |
| 78 | + $.#appendBitstringAtByteBoundary(resultBytes, byteOffset, bs); |
| 79 | + } else { |
| 80 | + // We're not at a byte boundary - need to shift bits |
| 81 | + $.#appendBitstringNotAtByteBoundary( |
| 82 | + resultBytes, |
| 83 | + byteOffset, |
| 84 | + bitOffset, |
| 85 | + bs, |
| 86 | + ); |
| 87 | + } |
| 88 | + |
| 89 | + bitOffset += bsBitCount; |
| 152 90 | } |
| 153 91 | |
| 154 | - const codePoint = |
| 155 | - Bitstring.buildUnsignedBigIntFromBitArray(codePointBitArray); |
| 156 | - |
| 157 | - return [Type.integer(codePoint), numBytes * 8]; |
| 92 | + return { |
| 93 | + type: "bitstring", |
| 94 | + text: null, |
| 95 | + bytes: resultBytes, |
| 96 | + leftoverBitCount: resultLeftoverBitCount, |
| 97 | + hex: null, |
| 98 | + }; |
| 158 99 | } |
| 159 100 | |
| 160 | - static from(segments) { |
| 161 | - const bitArrays = segments.map((segment, index) => { |
| 162 | - Bitstring.validateSegment(segment, index + 1); |
| 163 | - return Bitstring.#buildBitArray(segment, index + 1); |
| 101 | + // TODO: support utf8, utf16, utf32 modifiers |
| 102 | + static decodeSegmentChunk(segment, chunk) { |
| 103 | + let endianness, signedness; |
| 104 | + |
| 105 | + switch (segment.type) { |
| 106 | + case "binary": |
| 107 | + case "bitstring": |
| 108 | + return chunk; |
| 109 | + |
| 110 | + case "float": |
| 111 | + endianness = segment.endianness || "big"; |
| 112 | + return $.toFloat(chunk, endianness); |
| 113 | + |
| 114 | + case "integer": |
| 115 | + signedness = segment.signedness || "unsigned"; |
| 116 | + endianness = segment.endianness || "big"; |
| 117 | + return $.toInteger(chunk, signedness, endianness); |
| 118 | + |
| 119 | + default: |
| 120 | + throw new HologramInterpreterError( |
| 121 | + `${segment.type} segment type modifier is not yet implemented in Hologram`, |
| 122 | + ); |
| 123 | + } |
| 124 | + } |
| 125 | + |
| 126 | + static fromBits(bits) { |
| 127 | + const bitCount = bits.length; |
| 128 | + const byteCount = Math.ceil(bitCount / 8); |
| 129 | + const leftoverBitCount = bitCount % 8; |
| 130 | + const bytes = new Uint8Array(byteCount); |
| 131 | + |
| 132 | + // Process 8 bytes at a time when possible |
| 133 | + let byteIndex = 0; |
| 134 | + let bitIndex = 0; |
| 135 | + |
| 136 | + // Fast path for byte-aligned chunks |
| 137 | + while (bitIndex + 8 <= bitCount) { |
| 138 | + bytes[byteIndex++] = |
| 139 | + (bits[bitIndex] << 7) | |
| 140 | + (bits[bitIndex + 1] << 6) | |
| 141 | + (bits[bitIndex + 2] << 5) | |
| 142 | + (bits[bitIndex + 3] << 4) | |
| 143 | + (bits[bitIndex + 4] << 3) | |
| 144 | + (bits[bitIndex + 5] << 2) | |
| 145 | + (bits[bitIndex + 6] << 1) | |
| 146 | + bits[bitIndex + 7]; |
| 147 | + bitIndex += 8; |
| 148 | + } |
| 149 | + |
| 150 | + // Handle remaining bits if any |
| 151 | + if (bitIndex < bitCount) { |
| 152 | + let lastByte = 0; |
| 153 | + |
| 154 | + for (let j = 0; j < leftoverBitCount; j++) { |
| 155 | + if (bits[bitIndex + j]) { |
| 156 | + lastByte |= 1 << (7 - j); |
| 157 | + } |
| 158 | + } |
| 159 | + bytes[byteIndex] = lastByte; |
| 160 | + } |
| 161 | + |
| 162 | + return { |
| 163 | + type: "bitstring", |
| 164 | + text: null, |
| 165 | + bytes, |
| 166 | + leftoverBitCount, |
| 167 | + hex: null, |
| 168 | + }; |
| 169 | + } |
| 170 | + |
| 171 | + static fromBytes(bytes) { |
| 172 | + const uint8Bytes = |
| 173 | + bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); |
| 174 | + |
| 175 | + return { |
| 176 | + type: "bitstring", |
| 177 | + text: null, |
| 178 | + bytes: uint8Bytes, |
| 179 | + leftoverBitCount: 0, |
| 180 | + hex: null, |
| 181 | + }; |
| 182 | + } |
| 183 | + |
| 184 | + static fromSegments(segments) { |
| 185 | + const bitstrings = segments.map((segment) => { |
| 186 | + switch (segment.value.type) { |
| 187 | + case "bitstring": |
| 188 | + return $.fromSegmentWithBitstringValue(segment); |
| 189 | + |
| 190 | + case "float": |
| 191 | + return $.fromSegmentWithFloatValue(segment); |
| 192 | + |
| 193 | + case "integer": |
| 194 | + return $.fromSegmentWithIntegerValue(segment); |
| 195 | + |
| 196 | + case "string": |
| 197 | + return $.fromSegmentWithStringValue(segment); |
| 198 | + } |
| 164 199 | }); |
| 165 200 | |
| 166 | - return {type: "bitstring", bits: Utils.concatUint8Arrays(bitArrays)}; |
| 201 | + return $.concat(bitstrings); |
| 202 | + } |
| 203 | + |
| 204 | + static fromSegmentWithBitstringValue(segment) { |
| 205 | + // Fast path: if no size specified, use the entire bitstring |
| 206 | + if (segment.size === null) { |
| 207 | + return segment.value; |
| 208 | + } |
| 209 | + |
| 210 | + // For bitstrings "unit" is always 1, so we can use just "size" |
| 211 | + return $.takeChunk(segment.value, 0, Number(segment.size.value)); |
| 212 | + } |
| 213 | + |
| 214 | + static fromSegmentWithFloatValue(segment) { |
| 215 | + let value; |
| 216 | + |
| 217 | + if (segment.value.type === "float") { |
| 218 | + value = segment.value.value; |
| 219 | + } else { |
| 220 | + // integer |
| 221 | + value = Number(segment.value.value); |
| 222 | + } |
| 223 | + |
| 224 | + const isLittleEndian = $.#isLittleEndian(segment); |
| 225 | + |
| 226 | + const bitCount = $.calculateSegmentBitCount(segment); |
| 227 | + const byteCount = bitCount / 8; |
| 228 | + const buffer = new ArrayBuffer(byteCount); |
| 229 | + const dataView = new DataView(buffer); |
| 230 | + |
| 231 | + if (bitCount === 64) { |
| 232 | + dataView.setFloat64(0, value, isLittleEndian); |
| 233 | + } else if (bitCount === 32) { |
| 234 | + dataView.setFloat32(0, value, isLittleEndian); |
| 235 | + } else { |
| 236 | + // DataView.setFloat16() has limited availability in browsers |
| 237 | + $.#setFloat16(dataView, value, isLittleEndian); |
| 238 | + } |
| 239 | + |
| 240 | + return { |
| 241 | + type: "bitstring", |
| 242 | + text: null, |
| 243 | + bytes: new Uint8Array(buffer), |
| 244 | + leftoverBitCount: 0, |
| 245 | + hex: null, |
| 246 | + }; |
| 247 | + } |
| 248 | + |
| 249 | + // TODO: support utf16 and utf32 type modifiers |
| 250 | + static fromSegmentWithIntegerValue(segment) { |
| 251 | + const value = segment.value.value; |
| 252 | + |
| 253 | + if (segment.type === "utf8") { |
| 254 | + return { |
| 255 | + type: "bitstring", |
| 256 | + text: String.fromCodePoint(Number(value)), |
| 257 | + bytes: null, |
| 258 | + leftoverBitCount: 0, |
| 259 | + hex: null, |
| 260 | + }; |
| 261 | + } |
| 262 | + |
| 263 | + if ( |
| 264 | + value >= BigInt(Number.MIN_SAFE_INTEGER) && |
| 265 | + value <= BigInt(Number.MAX_SAFE_INTEGER) |
| 266 | + ) { |
| 267 | + return $.#fromSegmentWithIntegerWithinNumberRangeValue(segment); |
| 268 | + } |
| 269 | + |
| 270 | + return $.#fromSegmentWithIntegerOutsideNumberRangeValue(segment); |
| 271 | + } |
| 272 | + |
| 273 | + static fromSegmentWithStringValue(segment) { |
| 274 | + const valueStr = segment.value.value; |
| 275 | + |
| 276 | + // TODO: this won't work correctly for utf16 and utf32 |
| 277 | + // Fast path: if no size specified, use the entire string |
| 278 | + if (segment.size === null) { |
| 279 | + return { |
| 280 | + type: "bitstring", |
| 281 | + text: valueStr, |
| 282 | + bytes: null, |
| 283 | + leftoverBitCount: 0, |
| 284 | + hex: null, |
| 285 | + }; |
| 286 | + } |
| 287 | + |
| 288 | + // Calculate the bit count from size and unit (do this before encoding for potential early returns) |
| 289 | + const bitCount = $.calculateSegmentBitCount(segment); |
| 290 | + const completeBytes = Math.floor(bitCount / 8); |
| 291 | + const leftoverBits = bitCount % 8; |
| 292 | + |
| 293 | + const byteLength = $.calculateTextByteCount(valueStr); |
| 294 | + |
| 295 | + // If we know we need the complete string and no leftover bits, avoid encoding |
| 296 | + if (completeBytes === byteLength && leftoverBits === 0) { |
| 297 | + return { |
| 298 | + type: "bitstring", |
| 299 | + text: valueStr, |
| 300 | + bytes: null, |
| 301 | + leftoverBitCount: 0, |
| 302 | + hex: null, |
| 303 | + }; |
| 304 | + } |
| 305 | + |
| 306 | + const sourceBytes = $.#encoder.encode(valueStr); |
| 307 | + |
| 308 | + // Fast path: if we need all complete bytes but no leftover bits |
| 309 | + if (leftoverBits === 0) { |
| 310 | + // We can use a subarray view of the original bytes to avoid copying |
| 311 | + return { |
| 312 | + type: "bitstring", |
| 313 | + text: null, |
| 314 | + bytes: sourceBytes.subarray(0, completeBytes), |
| 315 | + leftoverBitCount: 0, |
| 316 | + hex: null, |
| 317 | + }; |
| 318 | + } |
| 319 | + |
| 320 | + const totalBytes = completeBytes + 1; |
| 321 | + const bytes = new Uint8Array(totalBytes); |
| 322 | + |
| 323 | + // Micro-optimization: If we only need a few bytes, avoid the set() operation overhead |
| 324 | + if (completeBytes <= 4) { |
| 325 | + // Manual copy for small arrays - can be faster than set() due to function call overhead |
| 326 | + for (let i = 0; i < completeBytes; i++) { |
| 327 | + bytes[i] = sourceBytes[i]; |
| 328 | + } |
| 329 | + } else { |
| 330 | + // Use set() for larger arrays - more efficient for bulk operations |
| 331 | + bytes.set(sourceBytes.subarray(0, completeBytes)); |
| 332 | + } |
| 333 | + |
| 334 | + // We take the leftmost (most significant bits) as the leftover bits |
| 335 | + const mask = 0xff << (8 - leftoverBits); |
| 336 | + bytes[completeBytes] = sourceBytes[completeBytes] & mask; |
| 337 | + |
| 338 | + return { |
| 339 | + type: "bitstring", |
| 340 | + text: null, |
| 341 | + bytes, |
| 342 | + leftoverBitCount: leftoverBits, |
| 343 | + hex: null, |
| 344 | + }; |
| 345 | + } |
| 346 | + |
| 347 | + static fromText(text) { |
| 348 | + return { |
| 349 | + type: "bitstring", |
| 350 | + text, |
| 351 | + bytes: null, |
| 352 | + leftoverBitCount: 0, |
| 353 | + hex: null, |
| 354 | + }; |
| 355 | + } |
| 356 | + |
| 357 | + static isEmpty(bitstring) { |
| 358 | + return bitstring.text === "" || bitstring.bytes?.length === 0; |
| 167 359 | } |
| 168 360 | |
| 169 361 | // See: String.printable?/2 |
| @@ -183,7 +375,17 @@ export default class Bitstring { | |
| 183 375 | // ?\e = 27 |
| 184 376 | // ?\d = 127 |
| 185 377 | // ?\a = 7 |
| 186 | - if ([10, 13, 9, 11, 8, 12, 27, 127, 7].includes(codePoint)) { |
| 378 | + if ( |
| 379 | + codePoint === 10 || |
| 380 | + codePoint === 13 || |
| 381 | + codePoint === 9 || |
| 382 | + codePoint === 11 || |
| 383 | + codePoint === 8 || |
| 384 | + codePoint === 12 || |
| 385 | + codePoint === 27 || |
| 386 | + codePoint === 127 || |
| 387 | + codePoint === 7 |
| 388 | + ) { |
| 187 389 | return true; |
| 188 390 | } |
| 189 391 | |
| @@ -206,31 +408,20 @@ export default class Bitstring { | |
| 206 408 | } |
| 207 409 | |
| 208 410 | static isPrintableText(bitstring) { |
| 209 | - if (!Type.isBinary(bitstring)) { |
| 411 | + if (bitstring.leftoverBitCount !== 0) { |
| 210 412 | return false; |
| 211 413 | } |
| 212 414 | |
| 213 | - let offset = 0; |
| 415 | + $.maybeSetTextFromBytes(bitstring); |
| 214 416 | |
| 215 | - while (offset < bitstring.bits.length) { |
| 216 | - const codePointInfo = Bitstring.fetchNextCodePointFromUtf8BitstringChunk( |
| 217 | - bitstring.bits, |
| 218 | - offset, |
| 219 | - ); |
| 417 | + if (bitstring.text === false) { |
| 418 | + return false; |
| 419 | + } |
| 220 420 | |
| 221 | - if (!codePointInfo) { |
| 421 | + for (const char of bitstring.text) { |
| 422 | + if (!$.isPrintableCodePoint(char.codePointAt(0))) { |
| 222 423 | return false; |
| 223 424 | } |
| 224 | - |
| 225 | - if (!Bitstring.validateCodePoint(codePointInfo[0].value)) { |
| 226 | - return false; |
| 227 | - } |
| 228 | - |
| 229 | - if (!Bitstring.isPrintableCodePoint(codePointInfo[0].value)) { |
| 230 | - return false; |
| 231 | - } |
| 232 | - |
| 233 | - offset += codePointInfo[1]; |
| 234 425 | } |
| 235 426 | |
| 236 427 | return true; |
| @@ -241,94 +432,252 @@ export default class Bitstring { | |
| 241 432 | return false; |
| 242 433 | } |
| 243 434 | |
| 244 | - let offset = 0; |
| 245 | - |
| 246 | - while (offset < bitstring.bits.length) { |
| 247 | - const codePointInfo = Bitstring.fetchNextCodePointFromUtf8BitstringChunk( |
| 248 | - bitstring.bits, |
| 249 | - offset, |
| 250 | - ); |
| 251 | - |
| 252 | - if (!codePointInfo) { |
| 253 | - return false; |
| 254 | - } |
| 255 | - |
| 256 | - if (!Bitstring.validateCodePoint(codePointInfo[0].value)) { |
| 257 | - return false; |
| 258 | - } |
| 259 | - |
| 260 | - offset += codePointInfo[1]; |
| 261 | - } |
| 262 | - |
| 263 | - return true; |
| 435 | + $.maybeSetTextFromBytes(bitstring); |
| 436 | + return bitstring.text !== false; |
| 264 437 | } |
| 265 438 | |
| 266 | - // Using set() is much more performant than using spread operator, |
| 267 | - // see: https://jsben.ch/jze3P |
| 268 | - static merge(bitstrings) { |
| 269 | - const length = bitstrings.reduce( |
| 270 | - (acc, bitstring) => acc + bitstring.bits.length, |
| 271 | - 0, |
| 272 | - ); |
| 439 | + static maybeResolveHex(bitstring) { |
| 440 | + if (bitstring.hex === null) { |
| 441 | + $.maybeSetBytesFromText(bitstring); |
| 273 442 | |
| 274 | - const bits = new Uint8Array(length); |
| 275 | - let offset = 0; |
| 443 | + let hex = ""; |
| 444 | + const bytes = bitstring.bytes; |
| 276 445 | |
| 277 | - for (const bitstring of bitstrings) { |
| 278 | - bits.set(bitstring.bits, offset); |
| 279 | - offset += bitstring.bits.length; |
| 446 | + for (let i = 0; i < bytes.length; i++) { |
| 447 | + hex += bytes[i].toString(16).padStart(2, "0"); |
| 448 | + } |
| 449 | + |
| 450 | + bitstring.hex = hex; |
| 280 451 | } |
| 281 | - |
| 282 | - return Type.bitstring(bits); |
| 283 452 | } |
| 284 453 | |
| 454 | + static maybeSetBytesFromText(bitstring) { |
| 455 | + if (bitstring.bytes === null) { |
| 456 | + bitstring.bytes = $.#encoder.encode(bitstring.text); |
| 457 | + } |
| 458 | + } |
| 459 | + |
| 460 | + static maybeSetTextFromBytes(bitstring) { |
| 461 | + if (bitstring.text === null) { |
| 462 | + try { |
| 463 | + bitstring.text = $.#decoder.decode(bitstring.bytes); |
| 464 | + } catch { |
| 465 | + bitstring.text = false; |
| 466 | + } |
| 467 | + } |
| 468 | + } |
| 469 | + |
| 470 | + // TODO: support utf8, utf16 and utf32 type modifiers |
| 285 471 | static resolveSegmentSize(segment) { |
| 286 | - if (["float", "integer"].includes(segment.type) && segment.size !== null) { |
| 287 | - return segment.size.value; |
| 288 | - } |
| 289 | - |
| 290 | - switch (segment.type) { |
| 291 | - case "float": |
| 292 | - return 64n; |
| 293 | - |
| 294 | - case "integer": |
| 295 | - return 8n; |
| 296 | - |
| 297 | - default: |
| 298 | - throw new HologramInterpreterError( |
| 299 | - `resolving ${segment.type} segment size is not yet implemented in Hologram`, |
| 300 | - ); |
| 301 | - } |
| 302 | - } |
| 303 | - |
| 304 | - static resolveSegmentUnit(segment) { |
| 305 | - if ( |
| 306 | - ["binary", "float", "integer"].includes(segment.type) && |
| 307 | - segment.unit !== null |
| 308 | - ) { |
| 309 | - return segment.unit; |
| 472 | + if (segment.size !== null) { |
| 473 | + return Number(segment.size.value); |
| 310 474 | } |
| 311 475 | |
| 312 476 | switch (segment.type) { |
| 313 477 | case "binary": |
| 314 | - return 8n; |
| 478 | + if (segment.value.type === "string") { |
| 479 | + return $.calculateTextByteCount(segment.value.value); |
| 480 | + } |
| 481 | + |
| 482 | + // bitstring |
| 483 | + if (segment.value.text !== null) { |
| 484 | + return $.calculateTextByteCount(segment.value.text); |
| 485 | + } |
| 486 | + |
| 487 | + // bitstring |
| 488 | + return segment.value.bytes.length; |
| 315 489 | |
| 316 490 | case "float": |
| 491 | + return 64; |
| 492 | + |
| 317 493 | case "integer": |
| 318 | - return 1n; |
| 494 | + return 8; |
| 319 495 | |
| 320 496 | default: |
| 321 | - throw new HologramInterpreterError( |
| 322 | - `resolving ${segment.type} segment unit is not yet implemented in Hologram`, |
| 323 | - ); |
| 497 | + return null; |
| 324 498 | } |
| 325 499 | } |
| 326 500 | |
| 327 | - static toText(bitstring) { |
| 328 | - const byteArray = Bitstring.#convertBitArrayToByteArray(bitstring.bits); |
| 329 | - const decoder = new TextDecoder("utf-8"); |
| 501 | + static resolveSegmentUnit(segment) { |
| 502 | + if (segment.unit !== null && segment.size !== null) { |
| 503 | + return Number(segment.unit); |
| 504 | + } |
| 330 505 | |
| 331 | - return decoder.decode(byteArray); |
| 506 | + switch (segment.type) { |
| 507 | + case "binary": |
| 508 | + return 8; |
| 509 | + |
| 510 | + case "float": |
| 511 | + case "integer": |
| 512 | + return 1; |
| 513 | + |
| 514 | + default: |
| 515 | + return null; |
| 516 | + } |
| 517 | + } |
| 518 | + |
| 519 | + static serialize(bitstring) { |
| 520 | + if ($.isEmpty(bitstring)) { |
| 521 | + return "b"; |
| 522 | + } |
| 523 | + |
| 524 | + $.maybeResolveHex(bitstring); |
| 525 | + |
| 526 | + return `b${bitstring.leftoverBitCount}${bitstring.hex}`; |
| 527 | + } |
| 528 | + |
| 529 | + static takeChunk(bitstring, chunkOffset, chunkSize) { |
| 530 | + const bitstringBitCount = $.calculateBitCount(bitstring); |
| 531 | + |
| 532 | + // Early return for taking the entire bitstring |
| 533 | + if (chunkOffset === 0 && chunkSize === bitstringBitCount) { |
| 534 | + return bitstring; |
| 535 | + } |
| 536 | + |
| 537 | + $.maybeSetBytesFromText(bitstring); |
| 538 | + |
| 539 | + const startByteIndex = Math.floor(chunkOffset / 8); |
| 540 | + const startBitOffset = chunkOffset % 8; |
| 541 | + const resultByteCount = Math.ceil(chunkSize / 8); |
| 542 | + const resultLeftoverBits = chunkSize % 8; |
| 543 | + |
| 544 | + // Fast path: if byte-aligned and no leftover bits |
| 545 | + if (startBitOffset === 0 && resultLeftoverBits === 0) { |
| 546 | + return { |
| 547 | + type: "bitstring", |
| 548 | + text: null, |
| 549 | + bytes: bitstring.bytes.subarray( |
| 550 | + startByteIndex, |
| 551 | + startByteIndex + resultByteCount, |
| 552 | + ), |
| 553 | + leftoverBitCount: 0, |
| 554 | + hex: null, |
| 555 | + }; |
| 556 | + } |
| 557 | + |
| 558 | + const resultBytes = new Uint8Array(resultByteCount); |
| 559 | + |
| 560 | + if (startBitOffset === 0) { |
| 561 | + // Byte-aligned with leftover bits |
| 562 | + |
| 563 | + resultBytes.set( |
| 564 | + bitstring.bytes.subarray( |
| 565 | + startByteIndex, |
| 566 | + startByteIndex + resultByteCount, |
| 567 | + ), |
| 568 | + ); |
| 569 | + |
| 570 | + resultBytes[resultByteCount - 1] &= 0xff << (8 - resultLeftoverBits); |
| 571 | + } else { |
| 572 | + // Non-byte-aligned |
| 573 | + |
| 574 | + const rightShift = 8 - startBitOffset; |
| 575 | + const leftShift = startBitOffset; |
| 576 | + const bytes = bitstring.bytes; // Local reference to array performance optimization |
| 577 | + |
| 578 | + for (let i = 0; i < resultByteCount; i++) { |
| 579 | + const firstByte = bytes[startByteIndex + i]; |
| 580 | + const secondByte = bytes[startByteIndex + i + 1]; |
| 581 | + |
| 582 | + resultBytes[i] = |
| 583 | + ((firstByte << leftShift) | (secondByte >>> rightShift)) & 0xff; |
| 584 | + } |
| 585 | + |
| 586 | + // Mask out extra bits in the last byte if we have leftover bits |
| 587 | + if (resultLeftoverBits > 0) { |
| 588 | + resultBytes[resultByteCount - 1] &= 0xff << (8 - resultLeftoverBits); |
| 589 | + } |
| 590 | + } |
| 591 | + |
| 592 | + return { |
| 593 | + type: "bitstring", |
| 594 | + text: null, |
| 595 | + bytes: resultBytes, |
| 596 | + leftoverBitCount: resultLeftoverBits, |
| 597 | + hex: null, |
| 598 | + }; |
| 599 | + } |
| 600 | + |
| 601 | + static toCodepoints(bitstring) { |
| 602 | + $.maybeSetTextFromBytes(bitstring); |
| 603 | + |
| 604 | + return Type.list( |
| 605 | + Array.from(bitstring.text, (char) => Type.integer(char.codePointAt(0))), |
| 606 | + ); |
| 607 | + } |
| 608 | + |
| 609 | + static toFloat(bitstring, endianness) { |
| 610 | + $.maybeSetBytesFromText(bitstring); |
| 611 | + |
| 612 | + const bytes = bitstring.bytes; |
| 613 | + const byteCount = bytes.length; |
| 614 | + const isLittleEndian = endianness === "little"; |
| 615 | + |
| 616 | + let result; |
| 617 | + |
| 618 | + if (byteCount === 8) { |
| 619 | + // 64-bit float |
| 620 | + const buffer = new ArrayBuffer(8); |
| 621 | + const view = new Uint8Array(buffer); |
| 622 | + view.set(bytes); |
| 623 | + result = new DataView(buffer).getFloat64(0, isLittleEndian); |
| 624 | + } else if (byteCount === 4) { |
| 625 | + // 32-bit float |
| 626 | + const buffer = new ArrayBuffer(4); |
| 627 | + const view = new Uint8Array(buffer); |
| 628 | + view.set(bytes); |
| 629 | + result = new DataView(buffer).getFloat32(0, isLittleEndian); |
| 630 | + } else { |
| 631 | + // 16-bit float - needs manual conversion as JavaScript doesn't natively support Float16 |
| 632 | + result = $.#decodeFloat16(bytes, isLittleEndian); |
| 633 | + } |
| 634 | + |
| 635 | + return Type.float(result); |
| 636 | + } |
| 637 | + |
| 638 | + static toInteger(bitstring, signedness, endianness) { |
| 639 | + $.maybeSetBytesFromText(bitstring); |
| 640 | + |
| 641 | + const bytes = bitstring.bytes; |
| 642 | + const byteCount = bytes.length; |
| 643 | + |
| 644 | + if (byteCount === 0) { |
| 645 | + return Type.integer(0n); |
| 646 | + } |
| 647 | + |
| 648 | + const leftoverBitCount = bitstring.leftoverBitCount; |
| 649 | + const isSigned = signedness === "signed"; |
| 650 | + |
| 651 | + // Fast path for single byte with no leftover bits |
| 652 | + if (byteCount === 1 && leftoverBitCount === 0) { |
| 653 | + const value = bytes[0]; |
| 654 | + |
| 655 | + return Type.integer( |
| 656 | + isSigned && value & 0x80 ? BigInt(value - 256) : BigInt(value), |
| 657 | + ); |
| 658 | + } |
| 659 | + |
| 660 | + const isLittleEndian = endianness === "little"; |
| 661 | + |
| 662 | + if (leftoverBitCount === 0) { |
| 663 | + return $.#toIntegerFromBitstringWithoutLeftoverBits( |
| 664 | + bitstring, |
| 665 | + isSigned, |
| 666 | + isLittleEndian, |
| 667 | + ); |
| 668 | + } |
| 669 | + |
| 670 | + return $.#toIntegerFromBitstringWithLeftoverBits( |
| 671 | + bitstring, |
| 672 | + isSigned, |
| 673 | + isLittleEndian, |
| 674 | + ); |
| 675 | + } |
| 676 | + |
| 677 | + static toText(bitstring) { |
| 678 | + $.maybeSetTextFromBytes(bitstring); |
| 679 | + |
| 680 | + return bitstring.text; |
| 332 681 | } |
| 333 682 | |
| 334 683 | static validateCodePoint(codePoint) { |
| @@ -351,254 +700,417 @@ export default class Bitstring { | |
| 351 700 | static validateSegment(segment, index) { |
| 352 701 | switch (segment.type) { |
| 353 702 | case "binary": |
| 354 | - return Bitstring.#validateBinarySegment(segment, index); |
| 703 | + return $.#validateSegmentWithBinaryType(segment, index); |
| 355 704 | |
| 356 705 | case "bitstring": |
| 357 | - return Bitstring.#validateBitstringSegment(segment, index); |
| 706 | + return $.#validateSegmentWithBitstringType(segment, index); |
| 358 707 | |
| 359 708 | case "float": |
| 360 | - return Bitstring.#validateFloatSegment(segment, index); |
| 709 | + return $.#validateSegmentWithFloatType(segment, index); |
| 361 710 | |
| 362 711 | case "integer": |
| 363 | - return Bitstring.#validateIntegerSegment(segment, index); |
| 712 | + return $.#validateSegmentWithIntegerType(segment, index); |
| 364 713 | |
| 365 714 | case "utf8": |
| 366 715 | case "utf16": |
| 367 716 | case "utf32": |
| 368 | - return Bitstring.#validateUtfSegment(segment, index); |
| 717 | + return $.#validateSegmentWithUtfType(segment, index); |
| 369 718 | } |
| 370 719 | } |
| 371 720 | |
| 372 | - static #buildBitArray(segment, index) { |
| 373 | - switch (segment.value.type) { |
| 374 | - case "bitstring": |
| 375 | - return Bitstring.#buildBitArrayFromBitstring(segment); |
| 721 | + static #appendBitstringAtByteBoundary(resultBytes, byteOffset, bitstring) { |
| 722 | + const bytes = bitstring.bytes; |
| 723 | + const leftoverBitCount = bitstring.leftoverBitCount; |
| 376 724 | |
| 377 | - case "float": |
| 378 | - return Bitstring.#buildBitArrayFromFloat(segment); |
| 725 | + if (leftoverBitCount === 0) { |
| 726 | + // If no leftover bits in this bitstring, copy directly |
| 727 | + resultBytes.set(bytes, byteOffset); |
| 728 | + } else { |
| 729 | + const totalByteCount = bytes.length; |
| 730 | + const completeByteCount = totalByteCount - 1; |
| 379 731 | |
| 380 | - case "integer": |
| 381 | - return Bitstring.#buildBitArrayFromInteger(segment, index); |
| 732 | + if (completeByteCount > 0) { |
| 733 | + resultBytes.set(bytes.subarray(0, completeByteCount), byteOffset); |
| 734 | + } |
| 382 735 | |
| 383 | - case "string": |
| 384 | - return Bitstring.#buildBitArrayFromString(segment); |
| 736 | + // Apply a mask to only include the leftover bits in the last byte |
| 737 | + const lastByte = bytes[totalByteCount - 1]; |
| 738 | + const lastByteOffset = byteOffset + completeByteCount; |
| 739 | + const leftoverBitsMask = 0xff << (8 - leftoverBitCount); |
| 740 | + const maskedLastByte = lastByte & leftoverBitsMask; |
| 741 | + |
| 742 | + // Place leftover bits in the correct position |
| 743 | + resultBytes[lastByteOffset] = maskedLastByte; |
| 385 744 | } |
| 386 745 | } |
| 387 746 | |
| 388 | - static #buildBitArrayFromBitstring(segment) { |
| 389 | - return new Uint8Array(segment.value.bits); |
| 390 | - } |
| 747 | + static #appendBitstringNotAtByteBoundary( |
| 748 | + resultBytes, |
| 749 | + byteOffset, |
| 750 | + bitOffset, |
| 751 | + bitstring, |
| 752 | + ) { |
| 753 | + const bytes = bitstring.bytes; |
| 754 | + const leftoverBitCount = bitstring.leftoverBitCount; |
| 755 | + const bitPositionInByte = bitOffset & 7; // Modulo 8 (position within byte) |
| 756 | + const bitsToShiftRight = bitPositionInByte; // How many bits to shift right when adding to current byte |
| 757 | + const bitsToShiftLeft = 8 - bitsToShiftRight; // How many bits to shift left when adding to next byte |
| 391 758 | |
| 392 | - static #buildBitArrayFromFloat(segment) { |
| 393 | - const value = segment.value.value; |
| 394 | - const size = Bitstring.resolveSegmentSize(segment); |
| 759 | + // Calculate how many complete bytes we have in the source bitstring |
| 760 | + const completeByteCount = |
| 761 | + leftoverBitCount === 0 ? bytes.length : bytes.length - 1; |
| 395 762 | |
| 396 | - const bitArrays = Array.from(Bitstring.#getBytesFromFloat(value, size)).map( |
| 397 | - (byte) => Bitstring.#convertDataToBitArray(BigInt(byte), 8n, 1n), |
| 398 | - ); |
| 763 | + // Process all complete bytes in the source bitstring |
| 764 | + for (let i = 0; i < completeByteCount; i++) { |
| 765 | + const currentByte = bytes[i]; |
| 399 766 | |
| 400 | - return Utils.concatUint8Arrays(bitArrays); |
| 401 | - } |
| 767 | + // Add high bits to current byte (may already have bits) |
| 768 | + resultBytes[byteOffset + i] |= currentByte >>> bitsToShiftRight; |
| 402 769 | |
| 403 | - static #buildBitArrayFromInteger(segment, index) { |
| 404 | - if (segment.type === "float") { |
| 405 | - const segmentWithValueCastedToFloat = { |
| 406 | - ...segment, |
| 407 | - value: Type.float(Number(segment.value.value)), |
| 408 | - }; |
| 409 | - return Bitstring.#buildBitArrayFromFloat(segmentWithValueCastedToFloat); |
| 410 | - } |
| 411 | - |
| 412 | - // Max Unicode code point value is 1,114,112 |
| 413 | - if (["utf8", "utf16", "utf32"].includes(segment.type)) { |
| 414 | - try { |
| 415 | - const str = String.fromCodePoint(Number(segment.value.value)); |
| 416 | - |
| 417 | - const segmentWithValueCastedToString = { |
| 418 | - ...segment, |
| 419 | - value: Type.string(str), |
| 420 | - }; |
| 421 | - |
| 422 | - return Bitstring.#buildBitArrayFromString( |
| 423 | - segmentWithValueCastedToString, |
| 424 | - ); |
| 425 | - } catch { |
| 426 | - Bitstring.#raiseInvalidUnicodeCodePointError(segment, index); |
| 770 | + // Add low bits to next byte (if we're not byte-aligned) |
| 771 | + if (bitsToShiftRight > 0) { |
| 772 | + resultBytes[byteOffset + i + 1] = |
| 773 | + (currentByte << bitsToShiftLeft) & 0xff; |
| 427 774 | } |
| 428 775 | } |
| 429 776 | |
| 430 | - const value = segment.value.value; |
| 431 | - const size = Bitstring.resolveSegmentSize(segment); |
| 432 | - const unit = Bitstring.resolveSegmentUnit(segment); |
| 777 | + // Handle last byte with leftover bits if any |
| 778 | + if (leftoverBitCount > 0) { |
| 779 | + const lastByte = bytes[completeByteCount]; |
| 433 780 | |
| 434 | - return Bitstring.#convertDataToBitArray(value, size, unit); |
| 781 | + // Create a mask to extract only the valid leftover bits from the most significant bits |
| 782 | + const leftoverBitsMask = 0xff << (8 - leftoverBitCount); |
| 783 | + const maskedLastByte = lastByte & leftoverBitsMask; |
| 784 | + |
| 785 | + // Add high bits of the last byte to current position |
| 786 | + resultBytes[byteOffset + completeByteCount] |= |
| 787 | + maskedLastByte >>> bitsToShiftRight; |
| 788 | + |
| 789 | + // Add low bits of the last byte to next position if we're not byte-aligned |
| 790 | + if (bitsToShiftRight > 0) { |
| 791 | + resultBytes[byteOffset + completeByteCount + 1] |= |
| 792 | + (maskedLastByte << bitsToShiftLeft) & 0xff; |
| 793 | + } |
| 794 | + } |
| 435 795 | } |
| 436 796 | |
| 437 | - static #buildBitArrayFromString(segment) { |
| 438 | - const value = segment.value.value; |
| 797 | + static #concatBitstringsWithoutLeftoverBits(bitstrings) { |
| 798 | + const totalByteCount = bitstrings.reduce((acc, bs) => { |
| 799 | + return acc + bs.bytes.length; |
| 800 | + }, 0); |
| 439 801 | |
| 440 | - const bitArrays = Array.from( |
| 441 | - Bitstring.#getBytesFromString(value, segment.type), |
| 442 | - ).map((byte) => Bitstring.#convertDataToBitArray(BigInt(byte), 8n, 1n)); |
| 802 | + const resultBytes = new Uint8Array(totalByteCount); |
| 803 | + let offset = 0; |
| 443 804 | |
| 444 | - if (segment.size !== null) { |
| 445 | - const unit = Bitstring.resolveSegmentUnit({...segment, type: "binary"}); |
| 446 | - const numBits = segment.size.value * unit; |
| 805 | + for (let i = 0; i < bitstrings.length; i++) { |
| 806 | + const bs = bitstrings[i]; |
| 807 | + resultBytes.set(bs.bytes, offset); |
| 808 | + offset += bs.bytes.length; |
| 809 | + } |
| 447 810 | |
| 448 | - return Utils.concatUint8Arrays(bitArrays).subarray(0, Number(numBits)); |
| 811 | + return { |
| 812 | + type: "bitstring", |
| 813 | + text: null, |
| 814 | + bytes: resultBytes, |
| 815 | + leftoverBitCount: 0, |
| 816 | + hex: null, |
| 817 | + }; |
| 818 | + } |
| 819 | + |
| 820 | + static #decodeFloat16(bytes, isLittleEndian) { |
| 821 | + const byte1 = isLittleEndian ? bytes[1] : bytes[0]; |
| 822 | + const byte2 = isLittleEndian ? bytes[0] : bytes[1]; |
| 823 | + |
| 824 | + const sign = byte1 & 0x80 ? -1 : 1; |
| 825 | + const exponent = (byte1 & 0x7c) >> 2; |
| 826 | + const fraction = ((byte1 & 0x03) << 8) | byte2; |
| 827 | + |
| 828 | + // Handle special cases |
| 829 | + if (exponent === 0) { |
| 830 | + if (fraction === 0) { |
| 831 | + return sign * 0; // Signed zero |
| 832 | + } |
| 833 | + |
| 834 | + // Denormalized number |
| 835 | + return sign * Math.pow(2, -14) * (fraction / 1024); |
| 836 | + } |
| 837 | + |
| 838 | + // Normalized number |
| 839 | + return sign * Math.pow(2, exponent - 15) * (1 + fraction / 1024); |
| 840 | + } |
| 841 | + |
| 842 | + static #fromSegmentWithIntegerWithinNumberRangeValue(segment) { |
| 843 | + const numberValue = Number(segment.value.value); |
| 844 | + const isLittleEndian = $.#isLittleEndian(segment); |
| 845 | + |
| 846 | + const bitCount = $.calculateSegmentBitCount(segment); |
| 847 | + const completeBytes = Math.floor(bitCount / 8); |
| 848 | + const leftoverBits = bitCount % 8; |
| 849 | + const totalBytes = completeBytes + (leftoverBits > 0 ? 1 : 0); |
| 850 | + |
| 851 | + const buffer = new ArrayBuffer(totalBytes); |
| 852 | + const bytesArray = new Uint8Array(buffer); |
| 853 | + const dataView = new DataView(buffer); |
| 854 | + |
| 855 | + // Fast path for standard bit counts |
| 856 | + if (bitCount === 8) { |
| 857 | + dataView.setUint8(0, numberValue & 0xff); |
| 858 | + |
| 859 | + return { |
| 860 | + type: "bitstring", |
| 861 | + text: null, |
| 862 | + bytes: bytesArray, |
| 863 | + leftoverBitCount: leftoverBits, |
| 864 | + hex: null, |
| 865 | + }; |
| 866 | + } else if (bitCount === 16 && completeBytes === 2) { |
| 867 | + dataView.setUint16(0, numberValue & 0xffff, isLittleEndian); |
| 868 | + |
| 869 | + return { |
| 870 | + type: "bitstring", |
| 871 | + text: null, |
| 872 | + bytes: bytesArray, |
| 873 | + leftoverBitCount: leftoverBits, |
| 874 | + hex: null, |
| 875 | + }; |
| 876 | + } else if (bitCount === 32 && completeBytes === 4) { |
| 877 | + dataView.setUint32(0, numberValue & 0xffffffff, isLittleEndian); |
| 878 | + |
| 879 | + return { |
| 880 | + type: "bitstring", |
| 881 | + text: null, |
| 882 | + bytes: bytesArray, |
| 883 | + leftoverBitCount: leftoverBits, |
| 884 | + hex: null, |
| 885 | + }; |
| 886 | + } |
| 887 | + |
| 888 | + // Hybrid approach: bitwise operations for small integers, division for larger ones |
| 889 | + const usesBitwiseOps = Math.abs(numberValue) < 0x100000000; // 2^32 |
| 890 | + |
| 891 | + if (isLittleEndian) { |
| 892 | + // Little endian: LSB first |
| 893 | + |
| 894 | + let remainingValue = numberValue; |
| 895 | + |
| 896 | + for (let i = 0; i < completeBytes; i++) { |
| 897 | + if (usesBitwiseOps) { |
| 898 | + bytesArray[i] = remainingValue & 0xff; |
| 899 | + remainingValue = remainingValue >>> 8; |
| 900 | + } else { |
| 901 | + bytesArray[i] = remainingValue % 256; |
| 902 | + remainingValue = Math.floor(remainingValue / 256); |
| 903 | + } |
| 904 | + } |
| 905 | + |
| 906 | + // Handle leftover bits |
| 907 | + if (leftoverBits > 0) { |
| 908 | + const shiftAmount = 8 - leftoverBits; |
| 909 | + |
| 910 | + bytesArray[completeBytes] = |
| 911 | + (remainingValue & ((1 << leftoverBits) - 1)) << shiftAmount; |
| 912 | + } |
| 449 913 | } else { |
| 450 | - return Utils.concatUint8Arrays(bitArrays); |
| 451 | - } |
| 452 | - } |
| 914 | + // Big endian: MSB first |
| 453 915 | |
| 454 | - static #buildFloatFromBitstringChunk(segment, bitArray, offset) { |
| 455 | - let size = Bitstring.resolveSegmentSize(segment); |
| 916 | + if (usesBitwiseOps) { |
| 917 | + // Fast bit shifting approach for small integers |
| 456 918 | |
| 457 | - if (![16n, 32n, 64n].includes(size)) { |
| 458 | - size = 64n; |
| 459 | - } |
| 919 | + const totalBitsInInteger = 32; |
| 460 920 | |
| 461 | - if (size === 16n) { |
| 462 | - throw new HologramInterpreterError( |
| 463 | - "16-bit float bitstring segments are not yet implemented in Hologram", |
| 464 | - ); |
| 465 | - } |
| 921 | + // Calculate shift to align with MSB |
| 466 922 | |
| 467 | - const unit = Bitstring.resolveSegmentUnit(segment); |
| 468 | - const segmentLen = Number(size * unit); |
| 923 | + const initialShift = totalBitsInInteger - bitCount; |
| 469 924 | |
| 470 | - if (offset + segmentLen > bitArray.length) { |
| 471 | - return false; |
| 472 | - } |
| 925 | + let shiftedValue = |
| 926 | + initialShift > 0 ? numberValue << initialShift : numberValue; |
| 473 927 | |
| 474 | - const chunk = bitArray.slice(offset, offset + segmentLen); |
| 475 | - const bytesArray = Bitstring.#convertBitArrayToByteArray(chunk); |
| 476 | - const dataView = new DataView(bytesArray.buffer); |
| 928 | + // For complete bytes |
| 929 | + for (let i = 0; i < completeBytes; i++) { |
| 930 | + bytesArray[i] = (shiftedValue >>> (totalBitsInInteger - 8)) & 0xff; |
| 931 | + shiftedValue = shiftedValue << 8; |
| 932 | + } |
| 477 933 | |
| 478 | - const value = |
| 479 | - size === 64n |
| 480 | - ? dataView.getFloat64(0, false) |
| 481 | - : dataView.getFloat32(0, false); |
| 934 | + // Handle leftover bits |
| 935 | + if (leftoverBits > 0) { |
| 936 | + bytesArray[completeBytes] = |
| 937 | + (shiftedValue >>> (totalBitsInInteger - 8)) & 0xff; |
| 938 | + } |
| 939 | + } else { |
| 940 | + // Division approach for larger integers |
| 482 941 | |
| 483 | - return [Type.float(value), segmentLen]; |
| 484 | - } |
| 942 | + let remainingValue = numberValue; |
| 485 943 | |
| 486 | - static #buildIntegerFromBitstringChunk(segment, bitArray, offset) { |
| 487 | - const size = Bitstring.resolveSegmentSize(segment); |
| 488 | - const unit = Bitstring.resolveSegmentUnit(segment); |
| 489 | - const segmentLen = Number(size * unit); |
| 944 | + // For big-endian, we need to start with the most significant bits |
| 945 | + // Calculate bytes from most significant to least significant |
| 946 | + for (let i = 0; i < completeBytes; i++) { |
| 947 | + // Calculate how many bits we still need to shift |
| 948 | + const bitsToShift = (completeBytes - i - 1) * 8 + leftoverBits; |
| 490 949 | |
| 491 | - if (offset + segmentLen > bitArray.length) { |
| 492 | - return false; |
| 493 | - } |
| 950 | + // Create a divisor based on that shift |
| 951 | + const divisor = Math.pow(2, bitsToShift); |
| 494 952 | |
| 495 | - const bitArrayChunk = bitArray.slice(offset, offset + segmentLen); |
| 496 | - let value; |
| 953 | + // Extract the current byte |
| 954 | + const byteValue = Math.floor(remainingValue / divisor); |
| 955 | + bytesArray[i] = byteValue & 0xff; |
| 497 956 | |
| 498 | - if (Bitstring.#resolveSegmentSignedness(segment) === "signed") { |
| 499 | - value = Bitstring.buildSignedBigIntFromBitArray(bitArrayChunk); |
| 500 | - } else { |
| 501 | - value = Bitstring.buildUnsignedBigIntFromBitArray(bitArrayChunk); |
| 502 | - } |
| 957 | + // Remove the processed bits |
| 958 | + remainingValue = remainingValue % divisor; |
| 959 | + } |
| 503 960 | |
| 504 | - return [Type.integer(value), segmentLen]; |
| 505 | - } |
| 506 | - |
| 507 | - static #convertBitArrayToByteArray(bitArray) { |
| 508 | - if (bitArray.length % 8 !== 0) { |
| 509 | - throw new HologramInterpreterError( |
| 510 | - `number of bits must be divisible by 8, got ${bitArray.length} bits`, |
| 511 | - ); |
| 512 | - } |
| 513 | - |
| 514 | - const numBytes = bitArray.length / 8; |
| 515 | - const byteArray = new Uint8Array(numBytes); |
| 516 | - |
| 517 | - for (let i = 0; i < numBytes; ++i) { |
| 518 | - for (let j = 0; j < 8; ++j) { |
| 519 | - if (bitArray[i * 8 + j] === 1) { |
| 520 | - byteArray[i] = Bitstring.#putNumberBit(byteArray[i], 7 - j); |
| 961 | + // Handle leftover bits |
| 962 | + if (leftoverBits > 0) { |
| 963 | + // For leftover bits, we shift them to the most significant bits of the last byte |
| 964 | + bytesArray[completeBytes] = |
| 965 | + (remainingValue << (8 - leftoverBits)) & 0xff; |
| 521 966 | } |
| 522 967 | } |
| 523 968 | } |
| 524 969 | |
| 525 | - return byteArray; |
| 970 | + return { |
| 971 | + type: "bitstring", |
| 972 | + text: null, |
| 973 | + bytes: bytesArray, |
| 974 | + leftoverBitCount: leftoverBits, |
| 975 | + hex: null, |
| 976 | + }; |
| 526 977 | } |
| 527 978 | |
| 528 | - static #convertDataToBitArray(data, size, unit) { |
| 529 | - // clamp to size number of bits |
| 530 | - const numBits = size * unit; |
| 531 | - const bitmask = 2n ** numBits - 1n; |
| 532 | - const clampedData = data & bitmask; |
| 979 | + static #fromSegmentWithIntegerOutsideNumberRangeValue(segment) { |
| 980 | + const value = segment.value.value; |
| 981 | + const isLittleEndian = $.#isLittleEndian(segment); |
| 533 982 | |
| 534 | - const bitArr = []; |
| 983 | + const bitCount = $.calculateSegmentBitCount(segment); |
| 984 | + const completeBytes = Math.floor(bitCount / 8); |
| 985 | + const leftoverBits = bitCount % 8; |
| 986 | + const totalBytes = completeBytes + (leftoverBits > 0 ? 1 : 0); |
| 535 987 | |
| 536 | - for (let i = numBits; i >= 1n; --i) { |
| 537 | - bitArr[numBits - i] = Bitstring.#getBit(clampedData, i - 1n); |
| 988 | + const buffer = new ArrayBuffer(totalBytes); |
| 989 | + const bytesArray = new Uint8Array(buffer); |
| 990 | + |
| 991 | + // Special fast path for 64-bit BigInts (common case) |
| 992 | + if (bitCount === 64 && completeBytes === 8 && leftoverBits === 0) { |
| 993 | + const byteMask = 0xffn; |
| 994 | + |
| 995 | + if (isLittleEndian) { |
| 996 | + // Little endian: LSB first |
| 997 | + for (let i = 0; i < 8; i++) { |
| 998 | + bytesArray[i] = Number((value >> BigInt(i * 8)) & byteMask); |
| 999 | + } |
| 1000 | + } else { |
| 1001 | + // Big endian: MSB first |
| 1002 | + for (let i = 0; i < 8; i++) { |
| 1003 | + bytesArray[i] = Number((value >> BigInt(56 - i * 8)) & byteMask); |
| 1004 | + } |
| 1005 | + } |
| 1006 | + |
| 1007 | + return { |
| 1008 | + type: "bitstring", |
| 1009 | + text: null, |
| 1010 | + bytes: bytesArray, |
| 1011 | + leftoverBitCount: leftoverBits, |
| 1012 | + hex: null, |
| 1013 | + }; |
| 538 1014 | } |
| 539 1015 | |
| 540 | - return new Uint8Array(bitArr); |
| 541 | - } |
| 1016 | + // Fast path for byte-aligned BigInts (no leftover bits) |
| 1017 | + if (leftoverBits === 0) { |
| 1018 | + const byteMask = 0xffn; |
| 542 1019 | |
| 543 | - static #encodeUtf16(str, endianness) { |
| 544 | - const byteArray = new Uint8Array(str.length * 2); |
| 545 | - const view = new DataView(byteArray.buffer); |
| 1020 | + if (isLittleEndian) { |
| 1021 | + // Little endian: LSB first |
| 546 1022 | |
| 547 | - str |
| 548 | - .split("") |
| 549 | - .forEach((char, index) => |
| 550 | - view.setUint16(index * 2, char.charCodeAt(0), endianness === "little"), |
| 551 | - ); |
| 1023 | + let remainingValue = value; |
| 552 1024 | |
| 553 | - return byteArray; |
| 554 | - } |
| 1025 | + for (let i = 0; i < completeBytes; i++) { |
| 1026 | + bytesArray[i] = Number(remainingValue & byteMask); |
| 1027 | + remainingValue = remainingValue >> 8n; |
| 1028 | + } |
| 1029 | + } else { |
| 1030 | + // Big endian: MSB first |
| 555 1031 | |
| 556 | - static #getBit(value, position) { |
| 557 | - return (value & (1n << position)) === 0n ? 0 : 1; |
| 558 | - } |
| 1032 | + const totalBits = BigInt(completeBytes * 8); |
| 559 1033 | |
| 560 | - static #getBytesFromFloat(float, size) { |
| 561 | - let floatArr; |
| 1034 | + for (let i = 0; i < completeBytes; i++) { |
| 1035 | + const shift = totalBits - BigInt((i + 1) * 8); |
| 1036 | + bytesArray[i] = Number((value >> shift) & byteMask); |
| 1037 | + } |
| 1038 | + } |
| 562 1039 | |
| 563 | - switch (size) { |
| 564 | - case 64n: |
| 565 | - floatArr = new Float64Array([float]); |
| 566 | - break; |
| 567 | - |
| 568 | - case 32n: |
| 569 | - floatArr = new Float32Array([float]); |
| 570 | - break; |
| 571 | - |
| 572 | - case 16n: |
| 573 | - // This case is not possible at the moment, since an error would be raised earlier. |
| 1040 | + return { |
| 1041 | + type: "bitstring", |
| 1042 | + text: null, |
| 1043 | + bytes: bytesArray, |
| 1044 | + leftoverBitCount: leftoverBits, |
| 1045 | + hex: null, |
| 1046 | + }; |
| 574 1047 | } |
| 575 1048 | |
| 576 | - return new Uint8Array(floatArr.buffer).reverse(); |
| 577 | - } |
| 1049 | + // Handle cases with leftover bits (not byte-aligned) |
| 1050 | + const byteMask = 0xffn; |
| 578 1051 | |
| 579 | - static #getBytesFromString(str, encoding) { |
| 580 | - switch (encoding) { |
| 581 | - case "binary": |
| 582 | - case "bitstring": |
| 583 | - case "utf8": |
| 584 | - return new TextEncoder().encode(str); |
| 1052 | + if (isLittleEndian) { |
| 1053 | + // Little endian: LSB first |
| 585 1054 | |
| 586 | - case "utf16": |
| 587 | - return Bitstring.#encodeUtf16(str, "big"); |
| 1055 | + let remainingValue = value; |
| 1056 | + |
| 1057 | + // Process complete bytes |
| 1058 | + for (let i = 0; i < completeBytes; i++) { |
| 1059 | + bytesArray[i] = Number(remainingValue & byteMask); |
| 1060 | + remainingValue = remainingValue >> 8n; |
| 1061 | + } |
| 1062 | + |
| 1063 | + // Handle leftover bits - shift to the most significant bits of the byte |
| 1064 | + if (leftoverBits > 0) { |
| 1065 | + const leftoverMask = (1n << BigInt(leftoverBits)) - 1n; |
| 1066 | + const shiftAmount = BigInt(8 - leftoverBits); |
| 1067 | + |
| 1068 | + bytesArray[completeBytes] = Number( |
| 1069 | + (remainingValue & leftoverMask) << shiftAmount, |
| 1070 | + ); |
| 1071 | + } |
| 1072 | + } else { |
| 1073 | + // Big endian: MSB first |
| 1074 | + |
| 1075 | + // Calculate total bits needed |
| 1076 | + const totalBits = BigInt(completeBytes * 8 + leftoverBits); |
| 1077 | + |
| 1078 | + let remainingValue = value; |
| 1079 | + |
| 1080 | + // Process complete bytes |
| 1081 | + for (let i = 0; i < completeBytes; i++) { |
| 1082 | + // Calculate how many bits we still need to shift right to get the current byte |
| 1083 | + const shift = totalBits - BigInt((i + 1) * 8); |
| 1084 | + |
| 1085 | + bytesArray[i] = Number((remainingValue >> shift) & byteMask); |
| 1086 | + } |
| 1087 | + |
| 1088 | + // Handle leftover bits |
| 1089 | + if (leftoverBits > 0) { |
| 1090 | + // For leftover bits, we need to: |
| 1091 | + // 1. Get the remaining value (last bits) |
| 1092 | + // 2. Shift it left to align with MSB of the last byte |
| 1093 | + |
| 1094 | + const remainingBits = |
| 1095 | + remainingValue & ((1n << BigInt(leftoverBits)) - 1n); |
| 1096 | + |
| 1097 | + bytesArray[completeBytes] = Number( |
| 1098 | + remainingBits << BigInt(8 - leftoverBits), |
| 1099 | + ); |
| 1100 | + } |
| 588 1101 | } |
| 1102 | + |
| 1103 | + return { |
| 1104 | + type: "bitstring", |
| 1105 | + text: null, |
| 1106 | + bytes: bytesArray, |
| 1107 | + leftoverBitCount: leftoverBits, |
| 1108 | + hex: null, |
| 1109 | + }; |
| 589 1110 | } |
| 590 1111 | |
| 591 | - static #putNumberBit(value, position) { |
| 592 | - return value | (1 << position); |
| 593 | - } |
| 594 | - |
| 595 | - static #raiseInvalidUnicodeCodePointError(segment, index) { |
| 596 | - Bitstring.#raiseTypeMismatchError( |
| 597 | - index, |
| 598 | - segment.type, |
| 599 | - "a non-negative integer encodable as " + segment.type, |
| 600 | - segment.value, |
| 601 | - ); |
| 1112 | + static #isLittleEndian(segment) { |
| 1113 | + return segment.endianness === "little"; |
| 602 1114 | } |
| 603 1115 | |
| 604 1116 | static #raiseTypeMismatchError( |
| @@ -613,19 +1125,183 @@ export default class Bitstring { | |
| 613 1125 | Interpreter.raiseArgumentError(message); |
| 614 1126 | } |
| 615 1127 | |
| 616 | - static #resolveSegmentSignedness(segment) { |
| 617 | - if (segment.signedness !== null) { |
| 618 | - return segment.signedness; |
| 1128 | + static #setFloat16(dataView, value, isLittleEndian) { |
| 1129 | + // Handle zeros |
| 1130 | + if (value === 0) { |
| 1131 | + const highByte = Object.is(value, -0) ? 0x80 : 0; |
| 1132 | + const lowByte = 0; |
| 1133 | + |
| 1134 | + if (isLittleEndian) { |
| 1135 | + dataView.setUint8(0, lowByte); |
| 1136 | + dataView.setUint8(1, highByte); |
| 1137 | + } else { |
| 1138 | + dataView.setUint8(0, highByte); |
| 1139 | + dataView.setUint8(1, lowByte); |
| 1140 | + } |
| 1141 | + |
| 1142 | + return; |
| 619 1143 | } |
| 620 1144 | |
| 621 | - return "unsigned"; |
| 1145 | + // Extract sign and absolute value |
| 1146 | + const absValue = Math.abs(value); |
| 1147 | + const signByte = value < 0 ? 0x80 : 0; |
| 1148 | + |
| 1149 | + // Calculate exponent |
| 1150 | + const exp = Math.floor(Math.log2(absValue)); |
| 1151 | + const biasedExp = exp + 15; |
| 1152 | + |
| 1153 | + // Calculate normalized fraction (remove hidden bit and scale to 10 bits) |
| 1154 | + // Use precise multiplication to avoid rounding errors |
| 1155 | + const significand = absValue * Math.pow(2, -exp); |
| 1156 | + const fraction = Math.round((significand - 1) * 0x400); |
| 1157 | + |
| 1158 | + // Combine high byte: sign + 5 bits of exponent + top 2 bits of fraction |
| 1159 | + const highByte = |
| 1160 | + signByte | ((biasedExp & 0x1f) << 2) | ((fraction >> 8) & 0x03); |
| 1161 | + |
| 1162 | + // Low byte: bottom 8 bits of fraction |
| 1163 | + const lowByte = fraction & 0xff; |
| 1164 | + |
| 1165 | + // Set bytes in proper order |
| 1166 | + if (isLittleEndian) { |
| 1167 | + dataView.setUint8(0, lowByte); |
| 1168 | + dataView.setUint8(1, highByte); |
| 1169 | + } else { |
| 1170 | + dataView.setUint8(0, highByte); |
| 1171 | + dataView.setUint8(1, lowByte); |
| 1172 | + } |
| 622 1173 | } |
| 623 1174 | |
| 624 | - static #validateBinarySegment(segment, index) { |
| 625 | - if ( |
| 626 | - segment.value.type === "bitstring" && |
| 627 | - segment.value.bits.length % 8 !== 0 |
| 628 | - ) { |
| 1175 | + static #toIntegerFromBitstringWithLeftoverBits( |
| 1176 | + bitstring, |
| 1177 | + isSigned, |
| 1178 | + isLittleEndian, |
| 1179 | + ) { |
| 1180 | + const bytes = bitstring.bytes; |
| 1181 | + const byteCount = bytes.length; |
| 1182 | + const leftoverBitCount = bitstring.leftoverBitCount; |
| 1183 | + |
| 1184 | + let result = 0n; |
| 1185 | + |
| 1186 | + // Little endian: LSB first |
| 1187 | + // Big endian: MSB first |
| 1188 | + |
| 1189 | + // Process complete bytes first |
| 1190 | + if (isLittleEndian) { |
| 1191 | + for (let i = 0; i < byteCount - 1; i++) { |
| 1192 | + result |= BigInt(bytes[i]) << BigInt(i * 8); |
| 1193 | + } |
| 1194 | + } else { |
| 1195 | + for (let i = 0; i < byteCount - 1; i++) { |
| 1196 | + result = (result << 8n) | BigInt(bytes[i]); |
| 1197 | + } |
| 1198 | + } |
| 1199 | + |
| 1200 | + // Handle the last byte with leftover bits |
| 1201 | + const lastByte = bytes[byteCount - 1]; |
| 1202 | + const mask = 0xff << (8 - leftoverBitCount); |
| 1203 | + const leftoverValue = lastByte & mask; |
| 1204 | + |
| 1205 | + // Right-shift the leftover bits to align them properly |
| 1206 | + const shiftedValue = leftoverValue >>> (8 - leftoverBitCount); |
| 1207 | + |
| 1208 | + // Place leftover bits in the correct position |
| 1209 | + if (isLittleEndian) { |
| 1210 | + result |= BigInt(shiftedValue) << BigInt((byteCount - 1) * 8); |
| 1211 | + } else { |
| 1212 | + result = (result << BigInt(leftoverBitCount)) | BigInt(shiftedValue); |
| 1213 | + } |
| 1214 | + |
| 1215 | + if (isSigned) { |
| 1216 | + const bitCount = $.calculateBitCount(bitstring); |
| 1217 | + const signBit = 1n << BigInt(bitCount - 1); |
| 1218 | + |
| 1219 | + if ((result & signBit) !== 0n) { |
| 1220 | + result = result - (1n << BigInt(bitCount)); |
| 1221 | + } |
| 1222 | + } |
| 1223 | + |
| 1224 | + return Type.integer(result); |
| 1225 | + } |
| 1226 | + |
| 1227 | + static #toIntegerFromBitstringWithoutLeftoverBits( |
| 1228 | + bitstring, |
| 1229 | + isSigned, |
| 1230 | + isLittleEndian, |
| 1231 | + ) { |
| 1232 | + const bytes = bitstring.bytes; |
| 1233 | + const byteCount = bytes.length; |
| 1234 | + |
| 1235 | + // Use DataView for standard sizes (1, 2, 4 bytes) |
| 1236 | + let buffer, dataView, result; |
| 1237 | + |
| 1238 | + switch (byteCount) { |
| 1239 | + case 1: |
| 1240 | + result = BigInt(bytes[0]); |
| 1241 | + break; |
| 1242 | + |
| 1243 | + case 2: |
| 1244 | + buffer = new ArrayBuffer(2); |
| 1245 | + dataView = new DataView(buffer); |
| 1246 | + |
| 1247 | + dataView.setUint8(0, bytes[0]); |
| 1248 | + dataView.setUint8(1, bytes[1]); |
| 1249 | + |
| 1250 | + result = isSigned |
| 1251 | + ? BigInt(dataView.getInt16(0, isLittleEndian)) |
| 1252 | + : BigInt(dataView.getUint16(0, isLittleEndian)); |
| 1253 | + |
| 1254 | + break; |
| 1255 | + |
| 1256 | + case 4: |
| 1257 | + buffer = new ArrayBuffer(4); |
| 1258 | + dataView = new DataView(buffer); |
| 1259 | + |
| 1260 | + dataView.setUint8(0, bytes[0]); |
| 1261 | + dataView.setUint8(1, bytes[1]); |
| 1262 | + dataView.setUint8(2, bytes[2]); |
| 1263 | + dataView.setUint8(3, bytes[3]); |
| 1264 | + |
| 1265 | + result = isSigned |
| 1266 | + ? BigInt(dataView.getInt32(0, isLittleEndian)) |
| 1267 | + : BigInt(dataView.getUint32(0, isLittleEndian)); |
| 1268 | + |
| 1269 | + break; |
| 1270 | + |
| 1271 | + default: |
| 1272 | + result = 0n; |
| 1273 | + |
| 1274 | + if (isLittleEndian) { |
| 1275 | + for (let i = 0; i < byteCount; i++) { |
| 1276 | + result |= BigInt(bytes[i]) << BigInt(i * 8); |
| 1277 | + } |
| 1278 | + } else { |
| 1279 | + for (let i = 0; i < byteCount; i++) { |
| 1280 | + result = (result << 8n) | BigInt(bytes[i]); |
| 1281 | + } |
| 1282 | + } |
| 1283 | + |
| 1284 | + if (isSigned) { |
| 1285 | + const bitCount = $.calculateBitCount(bitstring); |
| 1286 | + const signBit = 1n << BigInt(bitCount - 1); |
| 1287 | + |
| 1288 | + if ((result & signBit) !== 0n) { |
| 1289 | + result = result - (1n << BigInt(bitCount)); |
| 1290 | + } |
| 1291 | + } |
| 1292 | + } |
| 1293 | + |
| 1294 | + return Type.integer(result); |
| 1295 | + } |
| 1296 | + |
| 1297 | + static #validateSegmentWithBinaryType(segment, index) { |
| 1298 | + const valueType = segment.value.type; |
| 1299 | + |
| 1300 | + if (valueType !== "bitstring" && valueType !== "string") { |
| 1301 | + $.#raiseTypeMismatchError(index, "binary", "a binary", segment.value); |
| 1302 | + } |
| 1303 | + |
| 1304 | + if (valueType === "bitstring" && segment.value.leftoverBitCount !== 0) { |
| 629 1305 | const inspectedValue = Interpreter.inspect(segment.value); |
| 630 1306 | |
| 631 1307 | Interpreter.raiseArgumentError( |
| @@ -633,45 +1309,32 @@ export default class Bitstring { | |
| 633 1309 | ); |
| 634 1310 | } |
| 635 1311 | |
| 636 | - if (["float", "integer"].includes(segment.value.type)) { |
| 637 | - Bitstring.#raiseTypeMismatchError( |
| 638 | - index, |
| 639 | - "binary", |
| 640 | - "a binary", |
| 641 | - segment.value, |
| 642 | - ); |
| 1312 | + return true; |
| 1313 | + } |
| 1314 | + |
| 1315 | + static #validateSegmentWithBitstringType(segment, index) { |
| 1316 | + const valueType = segment.value.type; |
| 1317 | + |
| 1318 | + if (valueType === "float" || valueType === "integer") { |
| 1319 | + $.#raiseTypeMismatchError(index, "binary", "a binary", segment.value); |
| 1320 | + } |
| 1321 | + |
| 1322 | + if (segment.size !== null || segment.signedness !== null) { |
| 1323 | + $.#raiseTypeMismatchError(index, "integer", "an integer", segment.value); |
| 643 1324 | } |
| 644 1325 | |
| 645 1326 | return true; |
| 646 1327 | } |
| 647 1328 | |
| 648 | - static #validateBitstringSegment(segment, index) { |
| 649 | - if (["float", "integer"].includes(segment.value.type)) { |
| 650 | - Bitstring.#raiseTypeMismatchError( |
| 651 | - index, |
| 652 | - "binary", |
| 653 | - "a binary", |
| 654 | - segment.value, |
| 655 | - ); |
| 656 | - } |
| 1329 | + static #validateSegmentWithFloatType(segment, index) { |
| 1330 | + const valueType = segment.value.type; |
| 657 1331 | |
| 658 | - if (segment.signedness !== null || segment.size !== null) { |
| 659 | - Bitstring.#raiseTypeMismatchError( |
| 660 | - index, |
| 661 | - "integer", |
| 662 | - "an integer", |
| 663 | - segment.value, |
| 664 | - ); |
| 665 | - } |
| 666 | - |
| 667 | - return true; |
| 668 | - } |
| 669 | - |
| 670 | - static #validateFloatSegment(segment, index) { |
| 671 1332 | if ( |
| 672 | - !["float", "integer", "variable_pattern"].includes(segment.value.type) |
| 1333 | + valueType !== "float" && |
| 1334 | + valueType !== "integer" && |
| 1335 | + valueType !== "variable_pattern" |
| 673 1336 | ) { |
| 674 | - Bitstring.#raiseTypeMismatchError( |
| 1337 | + $.#raiseTypeMismatchError( |
| 675 1338 | index, |
| 676 1339 | "float", |
| 677 1340 | "a float or an integer", |
| @@ -679,50 +1342,36 @@ export default class Bitstring { | |
| 679 1342 | ); |
| 680 1343 | } |
| 681 1344 | |
| 682 | - if (segment.size === null && segment.unit !== null) { |
| 1345 | + if (!(segment.size !== null) && segment.unit !== null) { |
| 683 1346 | Interpreter.raiseCompileError( |
| 684 1347 | "integer and float types require a size specifier if the unit specifier is given", |
| 685 1348 | ); |
| 686 1349 | } |
| 687 1350 | |
| 688 | - const size = Bitstring.resolveSegmentSize(segment); |
| 689 | - const unit = Bitstring.resolveSegmentUnit(segment); |
| 690 | - const numBits = size * unit; |
| 1351 | + const bitCount = $.calculateSegmentBitCount(segment); |
| 691 1352 | |
| 692 | - if (![16n, 32n, 64n].includes(numBits)) { |
| 693 | - Bitstring.#raiseTypeMismatchError( |
| 694 | - index, |
| 695 | - "integer", |
| 696 | - "an integer", |
| 697 | - segment.value, |
| 698 | - ); |
| 699 | - } |
| 700 | - |
| 701 | - if (numBits !== 64n && numBits !== 32n) { |
| 702 | - throw new HologramInterpreterError( |
| 703 | - `${numBits}-bit float bitstring segments are not yet implemented in Hologram`, |
| 704 | - ); |
| 1353 | + if (bitCount !== 16 && bitCount !== 32 && bitCount !== 64) { |
| 1354 | + $.#raiseTypeMismatchError(index, "integer", "an integer", segment.value); |
| 705 1355 | } |
| 706 1356 | |
| 707 1357 | return true; |
| 708 1358 | } |
| 709 1359 | |
| 710 | - static #validateIntegerSegment(segment, index) { |
| 711 | - if (!["integer", "variable_pattern"].includes(segment.value.type)) { |
| 712 | - Bitstring.#raiseTypeMismatchError( |
| 713 | - index, |
| 714 | - "integer", |
| 715 | - "an integer", |
| 716 | - segment.value, |
| 717 | - ); |
| 1360 | + static #validateSegmentWithIntegerType(segment, index) { |
| 1361 | + const valueType = segment.value.type; |
| 1362 | + |
| 1363 | + if (valueType !== "integer" && valueType !== "variable_pattern") { |
| 1364 | + $.#raiseTypeMismatchError(index, "integer", "an integer", segment.value); |
| 718 1365 | } |
| 719 1366 | |
| 720 1367 | return true; |
| 721 1368 | } |
| 722 1369 | |
| 723 | - static #validateUtfSegment(segment, index) { |
| 724 | - if (["bitstring", "float"].includes(segment.value.type)) { |
| 725 | - Bitstring.#raiseTypeMismatchError( |
| 1370 | + static #validateSegmentWithUtfType(segment, index) { |
| 1371 | + const valueType = segment.value.type; |
| 1372 | + |
| 1373 | + if (valueType === "bitstring" || valueType === "float") { |
| 1374 | + $.#raiseTypeMismatchError( |
| 726 1375 | index, |
| 727 1376 | segment.type, |
| 728 1377 | "a non-negative integer encodable as " + segment.type, |
| @@ -731,18 +1380,15 @@ export default class Bitstring { | |
| 731 1380 | } |
| 732 1381 | |
| 733 1382 | if ( |
| 734 | - segment.signedness !== null || |
| 735 1383 | segment.size !== null || |
| 736 | - segment.unit !== null |
| 1384 | + segment.unit !== null || |
| 1385 | + segment.signedness !== null |
| 737 1386 | ) { |
| 738 | - Bitstring.#raiseTypeMismatchError( |
| 739 | - index, |
| 740 | - "integer", |
| 741 | - "an integer", |
| 742 | - segment.value, |
| 743 | - ); |
| 1387 | + $.#raiseTypeMismatchError(index, "integer", "an integer", segment.value); |
| 744 1388 | } |
| 745 1389 | |
| 746 1390 | return true; |
| 747 1391 | } |
| 748 1392 | } |
| 1393 | + |
| 1394 | + const $ = Bitstring; |
| @@ -1,100 +1,171 @@ | |
| 1 1 | "use strict"; |
| 2 2 | |
| 3 | + import Bitstring from "./bitstring.mjs"; |
| 4 | + import ComponentRegistry from "./component_registry.mjs"; |
| 3 5 | import Config from "./config.mjs"; |
| 4 | - import GlobalRegistry from "./global_registry.mjs"; |
| 6 | + import Connection from "./connection.mjs"; |
| 7 | + import Hologram from "./hologram.mjs"; |
| 5 8 | import HologramRuntimeError from "./errors/runtime_error.mjs"; |
| 9 | + import HttpTransport from "./http_transport.mjs"; |
| 10 | + import Interpreter from "./interpreter.mjs"; |
| 6 11 | import Serializer from "./serializer.mjs"; |
| 7 | - import Utils from "./utils.mjs"; |
| 12 | + import Type from "./type.mjs"; |
| 8 13 | |
| 9 | - import {Socket} from "phoenix"; |
| 10 | - |
| 11 | - // TODO: test |
| 12 14 | export default class Client { |
| 13 | - static #channel = null; |
| 15 | + // Deps: [:maps.get/2] |
| 16 | + static buildCommandPayload(command) { |
| 17 | + const target = Erlang_Maps["get/2"](Type.atom("target"), command); |
| 14 18 | |
| 15 | - // Made public to make tests easier |
| 16 | - static socket = null; |
| 17 | - |
| 18 | - static connect() { |
| 19 | - Utils.runAsyncTask(() => { |
| 20 | - Client.socket = new Socket("/hologram", { |
| 21 | - encode: Client.encoder, |
| 22 | - longPollFallbackMs: window.location.host.startsWith("localhost") |
| 23 | - ? undefined |
| 24 | - : 3000, |
| 25 | - }); |
| 26 | - |
| 27 | - Client.socket.connect(); |
| 28 | - |
| 29 | - Client.#channel = Client.socket.channel("hologram"); |
| 30 | - |
| 31 | - Client.#channel |
| 32 | - .join() |
| 33 | - .receive("ok", (_resp) => { |
| 34 | - console.debug("Hologram: connected to a server"); |
| 35 | - GlobalRegistry.set("connected?", true); |
| 36 | - }) |
| 37 | - .receive("error", (_resp) => { |
| 38 | - GlobalRegistry.set("connected?", false); |
| 39 | - throw new HologramRuntimeError("unable to connect to a server"); |
| 40 | - }) |
| 41 | - .receive("timeout", (_resp) => { |
| 42 | - GlobalRegistry.set("connected?", false); |
| 43 | - throw new HologramRuntimeError("unable to connect to a server"); |
| 44 | - }); |
| 45 | - |
| 46 | - Client.#channel.on("reload", (_payload) => document.location.reload()); |
| 47 | - }); |
| 48 | - } |
| 49 | - |
| 50 | - static encoder(msg, callback) { |
| 51 | - let encoded; |
| 52 | - |
| 53 | - if (msg.topic === "hologram") { |
| 54 | - const serializedPayload = Serializer.serialize(msg.payload, false, true); |
| 55 | - encoded = `["${msg.join_ref}","${msg.ref}","${msg.topic}","${msg.event}",${serializedPayload}]`; |
| 56 | - } else { |
| 57 | - encoded = JSON.stringify([ |
| 58 | - msg.join_ref, |
| 59 | - msg.ref, |
| 60 | - msg.topic, |
| 61 | - msg.event, |
| 62 | - msg.payload, |
| 63 | - ]); |
| 19 | + if (!ComponentRegistry.isCidRegistered(target)) { |
| 20 | + const message = `invalid command target, there is no component with CID: ${Interpreter.inspect(target)}`; |
| 21 | + throw new HologramRuntimeError(message); |
| 64 22 | } |
| 65 23 | |
| 66 | - return callback(encoded); |
| 24 | + const module = ComponentRegistry.getComponentModule(target); |
| 25 | + |
| 26 | + return Type.map([ |
| 27 | + [Type.atom("module"), module], |
| 28 | + [Type.atom("name"), Erlang_Maps["get/2"](Type.atom("name"), command)], |
| 29 | + [Type.atom("params"), Erlang_Maps["get/2"](Type.atom("params"), command)], |
| 30 | + [Type.atom("target"), target], |
| 31 | + ]); |
| 67 32 | } |
| 68 33 | |
| 69 | - static fetchPage(toParam, successCallback, failureCallback) { |
| 70 | - return Utils.runAsyncTask(() => { |
| 71 | - Client.#channel |
| 72 | - .push("page", toParam, Config.fetchPageTimeoutMs) |
| 73 | - .receive("ok", successCallback) |
| 74 | - .receive("error", failureCallback) |
| 75 | - .receive("timeout", failureCallback); |
| 34 | + static buildPageQueryString(params) { |
| 35 | + if (Type.isList(params)) { |
| 36 | + params = Type.map( |
| 37 | + params.data.map((param) => [param.data[0], param.data[1]]), |
| 38 | + ); |
| 39 | + } |
| 40 | + |
| 41 | + let queryParts = []; |
| 42 | + |
| 43 | + Object.values(params.data).forEach((param) => { |
| 44 | + const key = param[0]; |
| 45 | + |
| 46 | + if (key.type !== "atom") { |
| 47 | + throw new HologramRuntimeError( |
| 48 | + `invalid param key type (only atom type is allowed), got: ${Interpreter.inspect(key)}`, |
| 49 | + ); |
| 50 | + } |
| 51 | + |
| 52 | + const value = param[1]; |
| 53 | + |
| 54 | + if ( |
| 55 | + value.type !== "atom" && |
| 56 | + value.type !== "float" && |
| 57 | + value.type !== "integer" && |
| 58 | + !Type.isBinary(value) |
| 59 | + ) { |
| 60 | + throw new HologramRuntimeError( |
| 61 | + `invalid param value type (only atom, float, integer and string types are allowed), got: ${Interpreter.inspect(value)}`, |
| 62 | + ); |
| 63 | + } |
| 64 | + |
| 65 | + queryParts.push( |
| 66 | + `${key.value}=${Type.isBitstring(value) ? Bitstring.toText(value) : value.value.toString()}`, |
| 67 | + ); |
| 76 68 | }); |
| 69 | + |
| 70 | + return queryParts.length > 0 ? `?${queryParts.join("&")}` : ""; |
| 77 71 | } |
| 78 72 | |
| 79 | - static fetchPageBundlePath(pageModule, successCallback, failureCallback) { |
| 80 | - return Utils.runAsyncTask(() => { |
| 81 | - Client.#channel |
| 82 | - .push("page_bundle_path", pageModule, Config.clientFetchTimeoutMs) |
| 83 | - .receive("ok", successCallback) |
| 84 | - .receive("error", failureCallback) |
| 85 | - .receive("timeout", failureCallback); |
| 86 | - }); |
| 73 | + static connect(sendImmediatePing) { |
| 74 | + Connection.connect(); |
| 75 | + HttpTransport.restartPing(sendImmediatePing); |
| 87 76 | } |
| 88 77 | |
| 78 | + static async fetchPage(toParam, onSuccess) { |
| 79 | + let pageModule, queryString; |
| 80 | + |
| 81 | + if (Type.isAlias(toParam)) { |
| 82 | + pageModule = toParam; |
| 83 | + queryString = ""; |
| 84 | + } else { |
| 85 | + pageModule = toParam.data[0]; |
| 86 | + queryString = $.buildPageQueryString(toParam.data[1]); |
| 87 | + } |
| 88 | + |
| 89 | + try { |
| 90 | + const pageModuleName = Interpreter.moduleExName(pageModule); |
| 91 | + const url = `/hologram/page/${pageModuleName}${queryString}`; |
| 92 | + const response = await fetch(url); |
| 93 | + |
| 94 | + if (!response.ok) { |
| 95 | + $.#handleFetchPageError(response.status); |
| 96 | + } |
| 97 | + |
| 98 | + const html = await response.text(); |
| 99 | + onSuccess(html); |
| 100 | + } catch (error) { |
| 101 | + if (error instanceof HologramRuntimeError) { |
| 102 | + throw error; |
| 103 | + } |
| 104 | + |
| 105 | + $.#handleFetchPageError(error); |
| 106 | + } |
| 107 | + } |
| 108 | + |
| 109 | + // Covered in feature tests |
| 110 | + static fetchPageBundlePath(pageModule, onSuccess, onFail) { |
| 111 | + const opts = { |
| 112 | + onSuccess, |
| 113 | + onError: onFail, |
| 114 | + onTimeout: onFail, |
| 115 | + timeout: Config.clientFetchTimeoutMs, |
| 116 | + }; |
| 117 | + |
| 118 | + return Connection.sendRequest("page_bundle_path", pageModule, opts); |
| 119 | + } |
| 120 | + |
| 121 | + // Covered in feature tests |
| 89 122 | static isConnected() { |
| 90 | - return Client.socket === null ? false : Client.socket.isConnected(); |
| 123 | + return Connection.isConnected(); |
| 91 124 | } |
| 92 125 | |
| 93 | - static sendCommand(payload, successCallback, failureCallback) { |
| 94 | - Client.#channel |
| 95 | - .push("command", payload) |
| 96 | - .receive("ok", successCallback) |
| 97 | - .receive("error", failureCallback) |
| 98 | - .receive("timeout", failureCallback); |
| 126 | + static async sendCommand(command) { |
| 127 | + const opts = { |
| 128 | + method: "POST", |
| 129 | + headers: { |
| 130 | + "Content-Type": "application/json", |
| 131 | + }, |
| 132 | + body: Serializer.serialize($.buildCommandPayload(command), "server"), |
| 133 | + }; |
| 134 | + |
| 135 | + try { |
| 136 | + const response = await fetch("/hologram/command", opts); |
| 137 | + |
| 138 | + if (!response.ok) { |
| 139 | + $.#failCommand(response.status); |
| 140 | + } |
| 141 | + |
| 142 | + const [status, result] = await response.json(); |
| 143 | + |
| 144 | + if (status === 0) { |
| 145 | + $.#failCommand(result); |
| 146 | + } |
| 147 | + |
| 148 | + const nextAction = Interpreter.evaluateJavaScriptExpression(result); |
| 149 | + |
| 150 | + if (!Type.isNil(nextAction)) { |
| 151 | + Hologram.executeAction(nextAction); |
| 152 | + } |
| 153 | + } catch (error) { |
| 154 | + if (error instanceof HologramRuntimeError) { |
| 155 | + throw error; |
| 156 | + } |
| 157 | + |
| 158 | + $.#failCommand(error); |
| 159 | + } |
| 160 | + } |
| 161 | + |
| 162 | + static #failCommand(message) { |
| 163 | + throw new HologramRuntimeError(`command failed: ${message}`); |
| 164 | + } |
| 165 | + |
| 166 | + static #handleFetchPageError(message) { |
| 167 | + throw new HologramRuntimeError(`page fetch failed: ${message}`); |
| 99 168 | } |
| 100 169 | } |
| 170 | + |
| 171 | + const $ = Client; |
| @@ -19,6 +19,11 @@ export default class CommandQueue { | |
| 19 19 | ++CommandQueue.items[id].failCount; |
| 20 20 | } |
| 21 21 | |
| 22 | + static failAndThrowError(id, message) { |
| 23 | + CommandQueue.fail(id); |
| 24 | + throw new HologramRuntimeError(`command failed: ${message}`); |
| 25 | + } |
| 26 | + |
| 22 27 | // Made public to make tests easier |
| 23 28 | static getNextPending() { |
| 24 29 | // The traversal order for string keys is ascending chronological (so we get FIFO behaviour) |
| @@ -41,21 +46,27 @@ export default class CommandQueue { | |
| 41 46 | item.status = "sending"; |
| 42 47 | |
| 43 48 | const successCallback = ((currentItem) => { |
| 44 | - return (resp) => { |
| 45 | - CommandQueue.remove(currentItem.id); |
| 49 | + return (responsePayload) => { |
| 50 | + const [status, result] = responsePayload; |
| 46 51 | |
| 47 | - const nextAction = Interpreter.evaluateJavaScriptExpression(resp); |
| 52 | + if (status === 1) { |
| 53 | + CommandQueue.remove(currentItem.id); |
| 48 54 | |
| 49 | - if (!Type.isNil(nextAction)) { |
| 50 | - Hologram.executeAction(nextAction); |
| 55 | + const nextAction = |
| 56 | + Interpreter.evaluateJavaScriptExpression(result); |
| 57 | + |
| 58 | + if (!Type.isNil(nextAction)) { |
| 59 | + Hologram.executeAction(nextAction); |
| 60 | + } |
| 61 | + } else { |
| 62 | + $.failAndThrowError(currentItem.id, result); |
| 51 63 | } |
| 52 64 | }; |
| 53 65 | })(item); |
| 54 66 | |
| 55 67 | const failureCallback = ((currentItem) => { |
| 56 | - return (resp) => { |
| 57 | - CommandQueue.fail(currentItem.id); |
| 58 | - throw new HologramRuntimeError(`command failed: ${resp}`); |
| 68 | + return (responsePayload) => { |
| 69 | + $.failAndThrowError(currentItem.id, responsePayload); |
| 59 70 | }; |
| 60 71 | })(item); |
| 61 72 | |
| @@ -108,3 +119,5 @@ export default class CommandQueue { | |
| 108 119 | ]); |
| 109 120 | } |
| 110 121 | } |
| 122 | + |
| 123 | + const $ = CommandQueue; |
| @@ -0,0 +1,299 @@ | |
| 1 | + "use strict"; |
| 2 | + |
| 3 | + import GlobalRegistry from "./global_registry.mjs"; |
| 4 | + import LiveReload from "./live_reload.mjs"; |
| 5 | + import Serializer from "./serializer.mjs"; |
| 6 | + |
| 7 | + export default class Connection { |
| 8 | + // 1 second |
| 9 | + static BASE_RECONNECT_DELAY = 1_000; |
| 10 | + |
| 11 | + // 10 seconds |
| 12 | + static CONNECTION_TIMEOUT = 10_000; |
| 13 | + |
| 14 | + // 32 seconds |
| 15 | + static MAX_RECONNECT_DELAY = 32_000; |
| 16 | + |
| 17 | + // 30 seconds |
| 18 | + static PING_INTERVAL = 30_000; |
| 19 | + |
| 20 | + // 5 seconds |
| 21 | + static PONG_TIMEOUT = 5_000; |
| 22 | + |
| 23 | + // 60 seconds |
| 24 | + static REQUEST_TIMEOUT = 60_000; |
| 25 | + |
| 26 | + static WEBSOCKET_PATH = "/hologram/websocket"; |
| 27 | + |
| 28 | + static connectionTimer = null; |
| 29 | + static pendingRequests = new Map(); |
| 30 | + static pingTimer = null; |
| 31 | + static pongTimer = null; |
| 32 | + static reconnectAttempts = 0; |
| 33 | + static reconnectTimer = null; |
| 34 | + static websocket = null; |
| 35 | + |
| 36 | + // disconnected, connecting, connected, error |
| 37 | + static status = "disconnected"; |
| 38 | + |
| 39 | + static clearConnectionTimer() { |
| 40 | + if ($.connectionTimer) { |
| 41 | + clearTimeout($.connectionTimer); |
| 42 | + $.connectionTimer = null; |
| 43 | + } |
| 44 | + } |
| 45 | + |
| 46 | + static clearPendingRequests(triggerErrorCallbacks) { |
| 47 | + for (const [_correlationId, request] of $.pendingRequests.entries()) { |
| 48 | + clearTimeout(request.timerId); |
| 49 | + |
| 50 | + if (triggerErrorCallbacks) { |
| 51 | + request.onError(); |
| 52 | + } |
| 53 | + } |
| 54 | + |
| 55 | + $.pendingRequests.clear(); |
| 56 | + } |
| 57 | + |
| 58 | + static clearPingTimer() { |
| 59 | + if ($.pingTimer) { |
| 60 | + clearInterval($.pingTimer); |
| 61 | + $.pingTimer = null; |
| 62 | + } |
| 63 | + } |
| 64 | + |
| 65 | + static clearPongTimer() { |
| 66 | + if ($.pongTimer) { |
| 67 | + clearTimeout($.pongTimer); |
| 68 | + $.pongTimer = null; |
| 69 | + } |
| 70 | + } |
| 71 | + |
| 72 | + static clearReconnectTimer() { |
| 73 | + if ($.reconnectTimer) { |
| 74 | + clearTimeout($.reconnectTimer); |
| 75 | + $.reconnectTimer = null; |
| 76 | + } |
| 77 | + } |
| 78 | + |
| 79 | + static connect() { |
| 80 | + if ($.status === "connected" || $.status === "connecting") return; |
| 81 | + |
| 82 | + $.status = "connecting"; |
| 83 | + $.clearReconnectTimer(); |
| 84 | + |
| 85 | + try { |
| 86 | + $.websocket = new WebSocket($.WEBSOCKET_PATH); |
| 87 | + |
| 88 | + $.websocket.onopen = $.handleOpen; |
| 89 | + $.websocket.onclose = $.handleClose; |
| 90 | + $.websocket.onerror = $.handleError; |
| 91 | + $.websocket.onmessage = $.handleMessage; |
| 92 | + |
| 93 | + $.connectionTimer = setTimeout(() => { |
| 94 | + if ($.status === "connecting") { |
| 95 | + $.websocket.close(); |
| 96 | + $.handleConnectionTimeout(); |
| 97 | + } |
| 98 | + }, $.CONNECTION_TIMEOUT); |
| 99 | + } catch (error) { |
| 100 | + $.handleError(error); |
| 101 | + } |
| 102 | + } |
| 103 | + |
| 104 | + static encodeMessage(type, payload, correlationId) { |
| 105 | + if (payload === null && correlationId === null) return `"${type}"`; |
| 106 | + |
| 107 | + if (correlationId) { |
| 108 | + return `["${type}",${Serializer.serialize(payload, "server")},"${correlationId}"]`; |
| 109 | + } |
| 110 | + |
| 111 | + return `["${type}",${Serializer.serialize(payload, "server")}]`; |
| 112 | + } |
| 113 | + |
| 114 | + static handleClose(event) { |
| 115 | + console.warn("Hologram: disconnected from server", event); |
| 116 | + |
| 117 | + $.status = "disconnected"; |
| 118 | + GlobalRegistry.set("connected?", false); |
| 119 | + |
| 120 | + $.clearConnectionTimer(); |
| 121 | + $.clearPingTimer(); |
| 122 | + $.clearPongTimer(); |
| 123 | + $.clearPendingRequests(true); |
| 124 | + |
| 125 | + $.reconnect(); |
| 126 | + } |
| 127 | + |
| 128 | + static handleConnectionTimeout() { |
| 129 | + console.error("Hologram: server connection timeout"); |
| 130 | + |
| 131 | + $.status = "error"; |
| 132 | + |
| 133 | + $.reconnect(); |
| 134 | + } |
| 135 | + |
| 136 | + static handleError(event) { |
| 137 | + console.error("Hologram: server connection error", event); |
| 138 | + |
| 139 | + $.status = "error"; |
| 140 | + GlobalRegistry.set("connected?", false); |
| 141 | + |
| 142 | + $.clearConnectionTimer(); |
| 143 | + |
| 144 | + $.reconnect(); |
| 145 | + } |
| 146 | + |
| 147 | + static handleMessage(event) { |
| 148 | + const encodedMessage = event.data; |
| 149 | + |
| 150 | + if (encodedMessage === '"pong"') { |
| 151 | + $.clearPongTimer(); |
| 152 | + return; |
| 153 | + } |
| 154 | + |
| 155 | + if (encodedMessage === '"reload"') { |
| 156 | + document.location.reload(); |
| 157 | + return; |
| 158 | + } |
| 159 | + |
| 160 | + const decodedMessage = JSON.parse(encodedMessage); |
| 161 | + |
| 162 | + if (decodedMessage.length === 3) { |
| 163 | + // Currently, the only supported message type that has a correlation ID is "reply" |
| 164 | + const [_type, payload, correlationId] = decodedMessage; |
| 165 | + |
| 166 | + if ($.pendingRequests.has(correlationId)) { |
| 167 | + const request = $.pendingRequests.get(correlationId); |
| 168 | + |
| 169 | + clearTimeout(request.timerId); |
| 170 | + $.pendingRequests.delete(correlationId); |
| 171 | + |
| 172 | + request.onSuccess(payload); |
| 173 | + } |
| 174 | + |
| 175 | + return; |
| 176 | + } |
| 177 | + |
| 178 | + // Currently, the only supported message type that has a payload, |
| 179 | + // but doesn't have a correlation ID is "compilation_error" |
| 180 | + const [_type, payload] = decodedMessage; |
| 181 | + LiveReload.showErrorOverlay(payload); |
| 182 | + } |
| 183 | + |
| 184 | + static handleOpen(_event) { |
| 185 | + console.log("Hologram: connected to server"); |
| 186 | + |
| 187 | + $.status = "connected"; |
| 188 | + GlobalRegistry.set("connected?", true); |
| 189 | + |
| 190 | + $.reconnectAttempts = 0; |
| 191 | + $.clearConnectionTimer(); |
| 192 | + |
| 193 | + $.startPing(); |
| 194 | + } |
| 195 | + |
| 196 | + static isConnected() { |
| 197 | + return $.status === "connected"; |
| 198 | + } |
| 199 | + |
| 200 | + static reconnect() { |
| 201 | + $.reconnectAttempts++; |
| 202 | + |
| 203 | + const delay = Math.min( |
| 204 | + $.BASE_RECONNECT_DELAY * Math.pow(2, $.reconnectAttempts - 1), |
| 205 | + $.MAX_RECONNECT_DELAY, |
| 206 | + ); |
| 207 | + |
| 208 | + console.log( |
| 209 | + `Hologram: reconnecting in ${delay} ms (attempt ${$.reconnectAttempts})`, |
| 210 | + ); |
| 211 | + |
| 212 | + $.reconnectTimer = setTimeout(() => { |
| 213 | + $.connect(); |
| 214 | + }, delay); |
| 215 | + } |
| 216 | + |
| 217 | + static sendMessage(type, payload = null, correlationId = null) { |
| 218 | + if ($.status === "connected") { |
| 219 | + const encodedMessage = $.encodeMessage(type, payload, correlationId); |
| 220 | + |
| 221 | + try { |
| 222 | + $.websocket.send(encodedMessage); |
| 223 | + return true; |
| 224 | + // eslint-disable-next-line no-empty |
| 225 | + } catch {} |
| 226 | + } |
| 227 | + |
| 228 | + console.error( |
| 229 | + "Hologram: failed to send message to server", |
| 230 | + type, |
| 231 | + payload, |
| 232 | + correlationId, |
| 233 | + ); |
| 234 | + |
| 235 | + return false; |
| 236 | + } |
| 237 | + |
| 238 | + static sendRequest( |
| 239 | + type, |
| 240 | + payload = null, |
| 241 | + {onSuccess, onError, onTimeout, timeout = $.REQUEST_TIMEOUT} = {}, |
| 242 | + ) { |
| 243 | + return new Promise((resolve, reject) => { |
| 244 | + const correlationId = crypto.randomUUID(); |
| 245 | + |
| 246 | + const timerId = setTimeout(() => { |
| 247 | + $.pendingRequests.delete(correlationId); |
| 248 | + if (onTimeout) onTimeout(); |
| 249 | + reject(new Error("Request timeout")); |
| 250 | + }, timeout); |
| 251 | + |
| 252 | + $.pendingRequests.set(correlationId, { |
| 253 | + onSuccess: (responsePayload) => { |
| 254 | + if (onSuccess) onSuccess(responsePayload); |
| 255 | + resolve(responsePayload); |
| 256 | + }, |
| 257 | + onError: () => { |
| 258 | + if (onError) onError(); |
| 259 | + reject(new Error("Request failed")); |
| 260 | + }, |
| 261 | + onTimeout: () => { |
| 262 | + if (onTimeout) onTimeout(); |
| 263 | + reject(new Error("Request timeout")); |
| 264 | + }, |
| 265 | + timerId, |
| 266 | + }); |
| 267 | + |
| 268 | + if (!$.sendMessage(type, payload, correlationId)) { |
| 269 | + $.pendingRequests.delete(correlationId); |
| 270 | + clearTimeout(timerId); |
| 271 | + if (onError) onError(); |
| 272 | + reject(new Error("Failed to send message")); |
| 273 | + } |
| 274 | + }); |
| 275 | + } |
| 276 | + |
| 277 | + static sendPing() { |
| 278 | + if ($.status === "connected") { |
| 279 | + $.sendMessage("ping"); |
| 280 | + |
| 281 | + $.pongTimer = setTimeout(() => { |
| 282 | + console.warn("Hologram: pong timeout"); |
| 283 | + $.websocket.close(); |
| 284 | + }, $.PONG_TIMEOUT); |
| 285 | + } |
| 286 | + } |
| 287 | + |
| 288 | + static startPing() { |
| 289 | + $.clearPingTimer(); |
| 290 | + |
| 291 | + $.pingTimer = setInterval(() => { |
| 292 | + if ($.status === "connected") { |
| 293 | + $.sendPing(); |
| 294 | + } |
| 295 | + }, $.PING_INTERVAL); |
| 296 | + } |
| 297 | + } |
| 298 | + |
| 299 | + const $ = Connection; |
| @@ -1,48 +1,114 @@ | |
| 1 1 | "use strict"; |
| 2 2 | |
| 3 | + import Bitstring from "./bitstring.mjs"; |
| 3 4 | import Interpreter from "./interpreter.mjs"; |
| 5 | + import Serializer from "./serializer.mjs"; |
| 4 6 | import Type from "./type.mjs"; |
| 5 7 | |
| 6 8 | export default class Deserializer { |
| 7 | - static deserialize(serialized, isVersioned = true) { |
| 8 | - const deserialized = JSON.parse(serialized, (_key, value) => { |
| 9 | - if (typeof value === "string") { |
| 10 | - if (value.startsWith("__atom__:")) { |
| 11 | - return Type.atom(value.slice(9)); |
| 12 | - } |
| 9 | + static deserialize(serialized) { |
| 10 | + return JSON.parse(serialized, (_key, value) => { |
| 11 | + return typeof value === "string" |
| 12 | + ? $.#maybeDeserializeFromString(value) |
| 13 | + : $.#maybeDeserializeFromObject(value); |
| 14 | + })[1]; |
| 15 | + } |
| 13 16 | |
| 14 | - if (value.startsWith("__bigint__:")) { |
| 15 | - return BigInt(value.slice(11)); |
| 16 | - } |
| 17 | + static #deserializeBoxedBitstring(serialized) { |
| 18 | + if (serialized === "b") { |
| 19 | + return Type.bitstring(""); |
| 20 | + } |
| 17 21 | |
| 18 | - if (value.startsWith("__binary__:")) { |
| 19 | - return Type.bitstring(value.slice(11)); |
| 20 | - } |
| 22 | + const hex = serialized.slice(2); |
| 23 | + const hexLength = hex.length; |
| 24 | + const bytes = new Uint8Array(hexLength >> 1); |
| 21 25 | |
| 22 | - if (value.startsWith("__float__:")) { |
| 23 | - return Type.float(Number(value.slice(10))); |
| 24 | - } |
| 26 | + // Use separate j index variable to avoid division in each iteration |
| 27 | + for (let i = 0, j = 0; i < hexLength; i += 2, j++) { |
| 28 | + bytes[j] = parseInt(hex.slice(i, i + 2), 16); |
| 29 | + } |
| 25 30 | |
| 26 | - if (value.startsWith("__function__:")) { |
| 27 | - return Interpreter.evaluateJavaScriptExpression(value.slice(13)); |
| 28 | - } |
| 31 | + const bitstring = Bitstring.fromBytes(bytes); |
| 32 | + bitstring.leftoverBitCount = parseInt(serialized[1]); |
| 29 33 | |
| 30 | - if (value.startsWith("__integer__:")) { |
| 31 | - return Type.integer(BigInt(value.slice(12))); |
| 32 | - } |
| 33 | - } |
| 34 | + return bitstring; |
| 35 | + } |
| 34 36 | |
| 35 | - if (value?.type === "bitstring") { |
| 36 | - return Type.bitstring(value.bits); |
| 37 | - } |
| 37 | + static #deserializeBoxedFunctionCapture(serialized) { |
| 38 | + const parts = serialized.split(Serializer.DELIMITER); |
| 39 | + const context = Interpreter.buildContext(); |
| 38 40 | |
| 39 | - if (value?.type === "map") { |
| 40 | - return Type.map(value.data); |
| 41 | - } |
| 41 | + return Type.functionCapture( |
| 42 | + parts[0], |
| 43 | + parts[1], |
| 44 | + parseInt(parts[2]), |
| 45 | + [], |
| 46 | + context, |
| 47 | + ); |
| 48 | + } |
| 42 49 | |
| 43 | - return value; |
| 44 | - }); |
| 50 | + static #deserializeBoxedIdentifier(identifierType, serialized) { |
| 51 | + const parts = serialized.split(Serializer.DELIMITER); |
| 45 52 | |
| 46 | - return isVersioned ? deserialized[1] : deserialized; |
| 53 | + return Type[identifierType]( |
| 54 | + parts[0], |
| 55 | + parts[1].split(",").map((segment) => parseInt(segment)), |
| 56 | + parts[2], |
| 57 | + ); |
| 58 | + } |
| 59 | + |
| 60 | + static #maybeDeserializeFromObject(obj) { |
| 61 | + switch (obj?.t) { |
| 62 | + case "l": |
| 63 | + return Type.list(obj.d); |
| 64 | + |
| 65 | + case "m": |
| 66 | + return Type.map(obj.d); |
| 67 | + |
| 68 | + case "t": |
| 69 | + return Type.tuple(obj.d); |
| 70 | + } |
| 71 | + |
| 72 | + return obj; |
| 73 | + } |
| 74 | + |
| 75 | + static #maybeDeserializeFromString(serialized) { |
| 76 | + const data = serialized.slice(1); |
| 77 | + |
| 78 | + switch (serialized[0]) { |
| 79 | + case "a": |
| 80 | + return Type.atom(data); |
| 81 | + |
| 82 | + case "b": |
| 83 | + return $.#deserializeBoxedBitstring(serialized); |
| 84 | + |
| 85 | + case "c": |
| 86 | + return $.#deserializeBoxedFunctionCapture(data); |
| 87 | + |
| 88 | + case "f": |
| 89 | + return Type.float(Number(data)); |
| 90 | + |
| 91 | + case "i": |
| 92 | + return Type.integer(BigInt(data)); |
| 93 | + |
| 94 | + case "o": |
| 95 | + return $.#deserializeBoxedIdentifier("port", data); |
| 96 | + |
| 97 | + case "p": |
| 98 | + return $.#deserializeBoxedIdentifier("pid", data); |
| 99 | + |
| 100 | + case "r": |
| 101 | + return $.#deserializeBoxedIdentifier("reference", data); |
| 102 | + |
| 103 | + case "s": |
| 104 | + return data; |
| 105 | + |
| 106 | + case "u": |
| 107 | + return Interpreter.evaluateJavaScriptExpression(data); |
| 108 | + } |
| 109 | + |
| 110 | + return serialized; |
| 47 111 | } |
| 48 112 | } |
| 113 | + |
| 114 | + const $ = Deserializer; |
Loading more files…