diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..0445299 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,19 @@ +name: CI + +on: + push: + branches: [ "main" ] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Install dependencies + run: npm install + - name: Run tests + run: npm test diff --git a/_tests/parser.test.js b/_tests/parser.test.js new file mode 100644 index 0000000..2a5064a --- /dev/null +++ b/_tests/parser.test.js @@ -0,0 +1,53 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { parseDescriptor, stripComments } from '../parser.js'; + +const defaultOpts = { separator: ',', prefix: '0x', comment: '//' }; +const simpleDescriptorBytes = [0x05, 0x01, 0x09, 0x02, 0xA1, 0x01, 0x81, 0x02, 0xC0]; +const simpleDescriptorOutput = [ + '0x05,0x01, // Usage Page (Generic Desktop Page)', + '0x09,0x02, // Usage (Mouse)', + '0xA1,0x01, // Collection (Application)', + '0x81,0x02, // Input (Data,Variable,Absolute,No Wrap,Linear,Preferred State,No Null position,Bit Field)', + '0xC0, // End Collection', +].join('\n') + '\n'; + +test('stripComments removes C-style, C++-style and hash comments', () => { + const input = `0x01, 0x02 // comment\n# another\n/* block\ncomment */\n0x03`; + const result = stripComments(input); + + assert.ok(!result.includes('//')); + assert.ok(!result.includes('# another')); + assert.ok(!result.includes('/*')); + + const tokens = result + .split(/\s+/) + .filter(Boolean); + assert.deepStrictEqual(tokens, ['0x01,', '0x02', '0x03']); +}); + +test('parseDescriptor formats a simple descriptor with indentation', () => { + const output = parseDescriptor(simpleDescriptorBytes, defaultOpts); + assert.strictEqual(output, simpleDescriptorOutput); +}); + +test('parseDescriptor falls back to default formatting options', () => { + const output = parseDescriptor(simpleDescriptorBytes); + assert.strictEqual(output, simpleDescriptorOutput); +}); + +test('parseDescriptor reports parsing errors in the output', () => { + const originalError = console.error; + let loggedMessage = ''; + console.error = (...args) => { + loggedMessage = args.join(' '); + }; + + try { + const output = parseDescriptor([0xC1, 0x00], defaultOpts); + assert.strictEqual(output, '// ERROR: End Collection: (0-byte arg) expected\n'); + assert.match(loggedMessage, /Error processing byte/); + } finally { + console.error = originalError; + } +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..fd50237 --- /dev/null +++ b/package.json @@ -0,0 +1,8 @@ +{ + "name": "hidparser", + "version": "1.0.0", + "type": "module", + "scripts": { + "test": "node --test $(find _tests -name '*.js' -print)" + } +} diff --git a/parser-long.js b/parser-long.js index 9a03e3e..88d0b3f 100644 --- a/parser-long.js +++ b/parser-long.js @@ -9,7 +9,7 @@ function handleLongItem(bytes, i, opts) { return { text: "", comment: "ERROR: Unexpected end of data", advance: 1, error: true }; } - const line = joinHex([b, bDataSize, bLongItemTag], opts); + const line = joinHex(null, [b, bDataSize, bLongItemTag], opts); return { text: line, comment: "Long Item", advance: 3, error: false }; } diff --git a/parser-short.js b/parser-short.js index 0594f44..e3a6e1d 100644 --- a/parser-short.js +++ b/parser-short.js @@ -3020,7 +3020,7 @@ function handleMainItem(bTag, bSize, bytes, i, opts, indent) { throw new Error(`End Collection: (0-byte arg) expected`); } const comment = "End Collection"; - const line = joinHex(b, null, opts, 0) + ","; + const line = joinHex(b, null, opts, 0); return { text: line, comment, advance: 1, indentChange: -1 }; } case 0x8: { // Input @@ -3130,7 +3130,7 @@ function handleGlobalItem(bTag, bSize, bytes, i, opts) { throw new Error(`Push: (0-byte arg) expected`); } const comment = "Push"; - const line = joinHex([b], null, opts, 0) + ","; + const line = joinHex(b, null, opts, 0); return { text: line, comment, advance: 1, indentChange: 0 }; } case 0xB0: { // Pop (0 bytes) @@ -3138,7 +3138,7 @@ function handleGlobalItem(bTag, bSize, bytes, i, opts) { throw new Error(`Pop: (0-byte arg) expected`); } const comment = "Pop"; - const line = joinHex([b], null, opts, 0) + ","; + const line = joinHex(b, null, opts, 0); return { text: line, comment, advance: 1, indentChange: 0 }; } default: { @@ -3232,7 +3232,7 @@ function handleShortItem(bytes, i, opts, indent, usagePage) { return handleLocalItem(bTag, bSize, bytes, i, opts, usagePage); default: const comment = `Unknown (${toHex(b, opts)})`; - const line = joinHex([b], opts) + ","; + const line = joinHex(b, null, opts, 0); return { text: line, comment, advance: 1, indentChange: 0, error: true }; } } diff --git a/parser.js b/parser.js index eb20a73..c79b398 100644 --- a/parser.js +++ b/parser.js @@ -1,9 +1,15 @@ import { handleShortItem } from "./parser-short.js"; import { handleLongItem } from "./parser-long.js"; - // hid1_11.pdf 6.2.2 Report Descriptor -function parseDescriptor(bytes, opts) { +const DEFAULT_PARSE_OPTIONS = { + separator: ",", + prefix: "0x", + comment: "//", +}; + +export function parseDescriptor(bytes, opts = {}) { + const options = { ...DEFAULT_PARSE_OPTIONS, ...opts }; let output = ""; let indent = 0; let usagePage = null; @@ -15,23 +21,23 @@ function parseDescriptor(bytes, opts) { try { // Long item if (b == 0xFE) { - result = handleLongItem(bytes, i, opts); + result = handleLongItem(bytes, i, options); } else { - result = handleShortItem(bytes, i, opts, indent, usagePage); + result = handleShortItem(bytes, i, options, indent, usagePage); } } catch (error) { console.error("Error processing byte:", b, error); - output += `${opts.comment} ERROR: ${error.message}\n`; + output += `${options.comment} ERROR: ${error.message}\n`; break; } if (result.usagePage) { usagePage = result.usagePage; } - + const spaces = " ".repeat(indent * 2); const pad = " ".repeat(Math.max(0, maxLineLength - result.text.length)); - output += `${result.text}${pad}${opts.comment} ${spaces}${result.comment}\n`; + output += `${result.text}${pad}${options.comment} ${spaces}${result.comment}\n`; indent += result.indentChange; i += result.advance; } @@ -39,24 +45,40 @@ function parseDescriptor(bytes, opts) { return output; } -function stripComments(input) { +export function stripComments(input) { input = input.replace(/\/\*[\s\S]*?\*\//g, ""); input = input.replace(/\/\/.*$/gm, ""); input = input.replace(/#.*$/gm, ""); return input; } -document.getElementById("parseBtn").addEventListener("click", () => { - let input = document.getElementById("descriptorInput").value.trim(); - input = stripComments(input); +if (typeof document !== "undefined") { + const parseBtn = document.getElementById("parseBtn"); + if (parseBtn) { + parseBtn.addEventListener("click", () => { + const inputField = document.getElementById("descriptorInput"); + if (!inputField) { + return; + } + + let input = inputField.value.trim(); + input = stripComments(input); - const bytes = input.split(/[\s,]+/).map(b => parseInt(b, 16)).filter(n => !isNaN(n)); - const opts = { - separator: document.getElementById("separator").value || ",", - prefix: document.getElementById("prefix").value || "0x", - comment: document.getElementById("commentSymbol").value || "//", - }; + const bytes = input + .split(/[\s,]+/) + .map(b => parseInt(b, 16)) + .filter(n => !isNaN(n)); + const opts = { + separator: (document.getElementById("separator")?.value) || ",", + prefix: (document.getElementById("prefix")?.value) || "0x", + comment: (document.getElementById("commentSymbol")?.value) || "//", + }; - const parsed = parseDescriptor(bytes, opts); - document.getElementById("output").textContent = parsed; -}); + const parsed = parseDescriptor(bytes, opts); + const outputEl = document.getElementById("output"); + if (outputEl) { + outputEl.textContent = parsed; + } + }); + } +} diff --git a/utils.js b/utils.js index 61f2e9c..2cab445 100644 --- a/utils.js +++ b/utils.js @@ -1,21 +1,44 @@ +const DEFAULT_OPTIONS = { + prefix: "0x", + separator: " ", +}; + +function normalizeOptions(opts = {}) { + return { + ...DEFAULT_OPTIONS, + ...opts, + }; +} + export function toHex(val, opts) { - const prefix = opts.prefix || "0x"; + const { prefix } = normalizeOptions(opts); return prefix + (val >>> 0).toString(16).padStart(2, "0").toUpperCase(); } -export function joinHex(tag, data, opts, nBytes) { - const sep = opts.separator || " "; +export function joinHex(tag, data, opts, nBytes = 0) { + const options = normalizeOptions(opts); const result = []; - - if(tag !== null) - result.push(toHex(tag & 0xFF, opts)); - - for (let i = 0; i < nBytes; i++) { - const byte = (data >>> (i * 8)) & 0xFF; - result.push(toHex(byte, opts)); + + if (Array.isArray(tag)) { + for (const value of tag) { + result.push(toHex(value & 0xFF, options)); + } + } else if (tag !== null && tag !== undefined) { + result.push(toHex(tag & 0xFF, options)); + } + + if (Array.isArray(data)) { + for (const value of data) { + result.push(toHex(value & 0xFF, options)); + } + } else if (nBytes && data != null) { + for (let i = 0; i < nBytes; i++) { + const byte = (data >>> (i * 8)) & 0xFF; + result.push(toHex(byte, options)); + } } - return result.join(sep) + sep; + return result.join(options.separator) + options.separator; } export function readIntLE(bytes, offset, n, signed = true) {