Files
plezy/scripts/generate_hid_key_labels.dart
T

209 lines
6.2 KiB
Dart

/// Generates lib/data/hid_key_labels.dart from the curated offline HID catalog.
///
/// Usage:
/// dart run scripts/generate_hid_key_labels.dart [input.json] [output.dart]
library;
import 'dart:convert';
import 'dart:io';
const defaultHidKeyLabelsInput = 'scripts/data/hid_key_labels.json';
const defaultHidKeyLabelsOutput = 'lib/data/hid_key_labels.dart';
final class HidKeyLabel {
const HidKeyLabel({required this.id, required this.label});
final String id;
final String label;
int get numericId => int.parse(id, radix: 16);
}
final class HidKeyGroup {
const HidKeyGroup({required this.name, required this.keys});
final String name;
final List<HidKeyLabel> keys;
}
final class HidKeyCatalog {
const HidKeyCatalog({required this.groups});
final List<HidKeyGroup> groups;
}
typedef AtomicFileWriter = Future<void> Function(String path, String contents);
HidKeyCatalog parseHidKeyLabelsCatalog(String source) {
final Object? decoded;
try {
decoded = jsonDecode(source);
} on FormatException catch (error) {
throw FormatException('Invalid HID catalog JSON: ${error.message}');
}
final root = _expectMap(decoded, 'catalog');
if (root['schemaVersion'] != 1) {
throw const FormatException('HID catalog schemaVersion must be 1');
}
final rawGroups = _expectList(root['groups'], 'groups');
if (rawGroups.isEmpty) {
throw const FormatException('HID catalog groups must not be empty');
}
final groups = <HidKeyGroup>[];
final groupNames = <String>{};
final ids = <String>{};
var previousId = -1;
for (var groupIndex = 0; groupIndex < rawGroups.length; groupIndex++) {
final rawGroup = _expectMap(rawGroups[groupIndex], 'groups[$groupIndex]');
final name = _expectNonemptyString(rawGroup['name'], 'groups[$groupIndex].name');
if (!groupNames.add(name)) {
throw FormatException('Duplicate HID group name: $name');
}
final rawKeys = _expectList(rawGroup['keys'], 'groups[$groupIndex].keys');
if (rawKeys.isEmpty) {
throw FormatException('HID group "$name" must contain at least one key');
}
final keys = <HidKeyLabel>[];
for (var keyIndex = 0; keyIndex < rawKeys.length; keyIndex++) {
final path = 'groups[$groupIndex].keys[$keyIndex]';
final rawKey = _expectMap(rawKeys[keyIndex], path);
final id = _expectString(rawKey['id'], '$path.id');
if (!RegExp(r'^[0-9a-f]{8}$').hasMatch(id)) {
throw FormatException('$path.id must be exactly eight lowercase hexadecimal digits');
}
if (!ids.add(id)) {
throw FormatException('Duplicate HID key ID: $id');
}
final numericId = int.parse(id, radix: 16);
if (numericId <= previousId) {
throw FormatException('HID key IDs must be in strictly increasing numeric order: $id');
}
previousId = numericId;
final label = _expectNonemptyString(rawKey['label'], '$path.label');
keys.add(HidKeyLabel(id: id, label: label));
}
groups.add(HidKeyGroup(name: name, keys: List.unmodifiable(keys)));
}
return HidKeyCatalog(groups: List.unmodifiable(groups));
}
String renderHidKeyLabels(HidKeyCatalog catalog) {
final output = StringBuffer()
..writeln(
'// Generated by dart run scripts/generate_hid_key_labels.dart from '
'scripts/data/hid_key_labels.json; do not edit by hand.',
)
..writeln()
..writeln('/// Human-readable labels for physical keyboard keys, keyed by USB HID usage code.')
..writeln('const hidKeyLabels = <int, String>{');
for (final group in catalog.groups) {
output.writeln(' // ${group.name}');
for (final key in group.keys) {
output.writeln(' 0x${key.id}: ${_dartString(key.label)},');
}
}
output.writeln('};');
return output.toString();
}
Future<void> writeFileAtomically(String path, String contents) async {
final output = File(path);
await output.parent.create(recursive: true);
final temporary = File('$path.tmp.$pid.${DateTime.now().microsecondsSinceEpoch}');
try {
await temporary.writeAsString(contents, flush: true);
await temporary.rename(path);
} finally {
if (await temporary.exists()) {
await temporary.delete();
}
}
}
Future<void> generateHidKeyLabels(
String inputPath,
String outputPath, {
AtomicFileWriter writer = writeFileAtomically,
}) async {
final source = await File(inputPath).readAsString();
final catalog = parseHidKeyLabelsCatalog(source);
final rendered = renderHidKeyLabels(catalog);
await writer(outputPath, rendered);
}
Future<void> main(List<String> arguments) async {
if (arguments.length > 2) {
stderr.writeln('Usage: dart run scripts/generate_hid_key_labels.dart [input.json] [output.dart]');
exitCode = 64;
return;
}
final inputPath = arguments.isEmpty ? defaultHidKeyLabelsInput : arguments[0];
final outputPath = arguments.length < 2 ? defaultHidKeyLabelsOutput : arguments[1];
await generateHidKeyLabels(inputPath, outputPath);
}
Map<String, Object?> _expectMap(Object? value, String path) {
if (value is! Map<String, Object?>) {
throw FormatException('$path must be a JSON object');
}
return value;
}
List<Object?> _expectList(Object? value, String path) {
if (value is! List<Object?>) {
throw FormatException('$path must be a JSON array');
}
return value;
}
String _expectString(Object? value, String path) {
if (value is! String) {
throw FormatException('$path must be a string');
}
return value;
}
String _expectNonemptyString(Object? value, String path) {
final string = _expectString(value, path);
if (string.trim().isEmpty) {
throw FormatException('$path must not be empty');
}
return string;
}
String _dartString(String value) {
if (value == r'\') return r"r'\'";
if (value.contains("'")) {
final escaped = value
.replaceAll(r'\', r'\\')
.replaceAll('"', r'\"')
.replaceAll(r'$', r'\$')
.replaceAll('\r', r'\r')
.replaceAll('\n', r'\n')
.replaceAll('\t', r'\t');
return '"$escaped"';
}
final escaped = value
.replaceAll(r'\', r'\\')
.replaceAll("'", r"\'")
.replaceAll(r'$', r'\$')
.replaceAll('\r', r'\r')
.replaceAll('\n', r'\n')
.replaceAll('\t', r'\t');
return "'$escaped'";
}