Add parse command to CLI with multiple output formats

Extend CLI with comprehensive file parsing capabilities:

New parse command:
- Parse binary files using XScript definitions
- Auto-detect file type or force specific type with -t option
- Output formats: JSON, tree, or table view

Output options:
- JSON (-f json): Machine-readable structured output
- Tree (-f tree): Hierarchical view with colored output
- Table (-f table): Compact tabular format

Additional features:
- Verbose mode (-v) to show hidden fields
- Warnings-only mode (-w) to show blank/zero values
- Nested data expansion in tree view
- Hex formatting for addresses and hashes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
njohnson 2026-01-12 20:55:00 -05:00
parent d7488c5fa9
commit 37bde14174

View File

@ -5,6 +5,7 @@
// xscript-cli check-dir <directory> - Check all .xscript files in directory
// xscript-cli list-types <file.xscript> - List all types defined in file
// xscript-cli info <file.xscript> <type> - Show info about a specific type
// xscript-cli parse <binary-file> - Parse file and show UI fields
#include <QCoreApplication>
#include <QCommandLineParser>
@ -13,10 +14,15 @@
#include <QDir>
#include <QDirIterator>
#include <QTextStream>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
#include <functional>
#include "lexer.h"
#include "parser.h"
#include "typeregistry.h"
#include "dsluischema.h"
static QTextStream out(stdout);
static QTextStream err(stderr);
@ -295,6 +301,595 @@ int cmdInfo(const QString& filePath, const QString& typeName) {
return 0;
}
// ============================================================================
// Parse command - Parse binary files and output UI field diagnostics
// ============================================================================
struct ParseOptions {
QString definitionsPath;
QString typeName; // Force specific type (skip auto-detect)
QString format = "json"; // json, table, tree
bool verbose = false;
bool warningsOnly = false;
};
bool isBlankValue(const QVariant& value) {
if (value.isNull()) return true;
if (value.typeId() == QMetaType::QString && value.toString().isEmpty()) return true;
if (value.typeId() == QMetaType::QByteArray && value.toByteArray().isEmpty()) return true;
if (value.typeId() == QMetaType::QVariantList && value.toList().isEmpty()) return true;
return false;
}
bool isZeroValue(const QVariant& value) {
bool ok;
if (value.typeId() == QMetaType::Int || value.typeId() == QMetaType::LongLong ||
value.typeId() == QMetaType::UInt || value.typeId() == QMetaType::ULongLong) {
return value.toLongLong(&ok) == 0 && ok;
}
return false;
}
QString getFieldTypeString(UiFieldKind kind) {
switch (kind) {
case UiFieldKind::ScalarU8: return "u8";
case UiFieldKind::ScalarI8: return "i8";
case UiFieldKind::ScalarU16: return "u16";
case UiFieldKind::ScalarI16: return "i16";
case UiFieldKind::ScalarU32: return "u32";
case UiFieldKind::ScalarI32: return "i32";
case UiFieldKind::ScalarU64: return "u64";
case UiFieldKind::ScalarI64: return "i64";
case UiFieldKind::Bool: return "bool";
case UiFieldKind::GenericValue: return "value";
}
return "unknown";
}
QString formatValue(const QVariant& value, int maxLen = 80) {
if (value.isNull()) return "(null)";
switch (value.typeId()) {
case QMetaType::QString:
return value.toString().left(maxLen);
case QMetaType::QByteArray: {
QByteArray ba = value.toByteArray();
if (ba.size() <= 16) {
return QString("bytes[%1]: %2").arg(ba.size()).arg(QString(ba.toHex(' ')));
}
return QString("bytes[%1]").arg(ba.size());
}
case QMetaType::QVariantList: {
QVariantList list = value.toList();
return QString("list[%1]").arg(list.size());
}
case QMetaType::QVariantMap: {
QVariantMap map = value.toMap();
return QString("object{%1}").arg(map.size());
}
case QMetaType::Bool:
return value.toBool() ? "true" : "false";
case QMetaType::Int:
case QMetaType::LongLong:
return QString::number(value.toLongLong());
case QMetaType::UInt:
case QMetaType::ULongLong: {
qulonglong v = value.toULongLong();
if (v > 0xFFFF) {
return QString("0x%1 (%2)").arg(v, 0, 16).arg(v);
}
return QString::number(v);
}
case QMetaType::Double:
case QMetaType::Float:
return QString::number(value.toDouble(), 'g', 6);
default:
return value.toString().left(maxLen);
}
}
QJsonValue variantToJson(const QVariant& value) {
if (value.isNull()) return QJsonValue::Null;
switch (value.typeId()) {
case QMetaType::Bool:
return value.toBool();
case QMetaType::Int:
case QMetaType::LongLong:
return value.toLongLong();
case QMetaType::UInt:
case QMetaType::ULongLong:
return value.toLongLong(); // JSON doesn't have unsigned
case QMetaType::Double:
case QMetaType::Float:
return value.toDouble();
case QMetaType::QString:
return value.toString();
case QMetaType::QByteArray: {
QByteArray ba = value.toByteArray();
if (ba.size() <= 64) {
return QString("0x") + QString(ba.toHex());
}
return QString("bytes[%1]").arg(ba.size());
}
case QMetaType::QVariantList: {
QJsonArray arr;
for (const QVariant& item : value.toList()) {
arr.append(variantToJson(item));
}
return arr;
}
case QMetaType::QVariantMap: {
QJsonObject obj;
QVariantMap map = value.toMap();
for (auto it = map.begin(); it != map.end(); ++it) {
obj[it.key()] = variantToJson(it.value());
}
return obj;
}
default:
return value.toString();
}
}
void outputJson(const QString& filePath, const QString& typeName, const QString& displayName,
const QVariantMap& vars, const QMap<QString, UiField>& schema,
const ParseOptions& opts) {
QJsonObject root;
root["file"] = QFileInfo(filePath).fileName();
root["detected_type"] = typeName;
if (!displayName.isEmpty()) {
root["display_name"] = displayName;
}
QJsonArray fields;
QJsonArray warnings;
// Get runtime UI metadata for display names
QVariantMap uiMeta = vars.value("_ui_meta").toMap();
for (auto it = schema.begin(); it != schema.end(); ++it) {
const QString& fieldName = it.key();
const UiField& field = it.value();
if (fieldName.startsWith("_")) continue; // Skip internal fields
if (!vars.contains(fieldName) && !opts.verbose) continue;
QVariant value = vars.value(fieldName);
bool hasBlank = isBlankValue(value);
bool hasZero = isZeroValue(value);
if (opts.warningsOnly && !hasBlank && !hasZero) continue;
QJsonObject fieldObj;
fieldObj["name"] = fieldName;
// Use runtime display name if available, fallback to schema
QString display = field.displayName;
if (uiMeta.contains(fieldName)) {
QVariantMap meta = uiMeta.value(fieldName).toMap();
if (meta.contains("display") && !meta.value("display").toString().isEmpty()) {
display = meta.value("display").toString();
}
}
fieldObj["display"] = display;
fieldObj["value"] = variantToJson(value);
fieldObj["type"] = getFieldTypeString(field.kind);
fieldObj["visible"] = field.visible;
if (!field.readOnly) {
fieldObj["editable"] = true;
}
if (hasBlank) {
fieldObj["warning"] = "blank";
warnings.append(QString("%1 has blank/empty value").arg(fieldName));
} else if (hasZero) {
fieldObj["warning"] = "zero";
warnings.append(QString("%1 has zero value").arg(fieldName));
}
fields.append(fieldObj);
}
// In verbose mode, also show non-UI fields
if (opts.verbose) {
for (auto it = vars.begin(); it != vars.end(); ++it) {
const QString& fieldName = it.key();
if (fieldName.startsWith("_")) continue;
if (schema.contains(fieldName)) continue; // Already added
QVariant value = it.value();
bool hasBlank = isBlankValue(value);
bool hasZero = isZeroValue(value);
if (opts.warningsOnly && !hasBlank && !hasZero) continue;
QJsonObject fieldObj;
fieldObj["name"] = fieldName;
fieldObj["display"] = fieldName;
fieldObj["value"] = variantToJson(value);
fieldObj["type"] = "hidden";
fieldObj["visible"] = false;
if (hasBlank) {
fieldObj["warning"] = "blank";
} else if (hasZero) {
fieldObj["warning"] = "zero";
}
fields.append(fieldObj);
}
}
root["fields"] = fields;
root["field_count"] = fields.size();
if (!warnings.isEmpty()) {
root["warnings"] = warnings;
}
QJsonDocument doc(root);
out << doc.toJson(QJsonDocument::Indented);
}
void outputTreeNode(const QString& name, const QVariant& value, int depth, bool showInternal) {
QString indent = QString(" ").repeated(depth);
QString prefix = depth > 0 ? "|-- " : "";
// Skip internal fields unless verbose
if (!showInternal && name.startsWith("_")) return;
switch (value.typeId()) {
case QMetaType::QVariantMap: {
QVariantMap map = value.toMap();
// Match GUI naming logic: _name takes priority, then _display, then key
QString displayName;
if (map.contains("_name") && !map.value("_name").toString().isEmpty()) {
displayName = map.value("_name").toString();
} else if (map.contains("_display") && !map.value("_display").toString().isEmpty()) {
displayName = map.value("_display").toString();
} else {
displayName = name;
}
out << indent << prefix << Color::Cyan << displayName << Color::Reset << " {" << map.size() << "}\n";
// Sort keys for consistent output
QStringList keys = map.keys();
keys.sort();
for (const QString& key : keys) {
outputTreeNode(key, map.value(key), depth + 1, showInternal);
}
break;
}
case QMetaType::QVariantList: {
QVariantList list = value.toList();
out << indent << prefix << Color::Yellow << name << Color::Reset << " [" << list.size() << "]\n";
int idx = 0;
for (const QVariant& item : list) {
QString itemName = QString("[%1]").arg(idx);
outputTreeNode(itemName, item, depth + 1, showInternal);
idx++;
}
break;
}
case QMetaType::QByteArray: {
QByteArray ba = value.toByteArray();
out << indent << prefix << name << ": " << Color::Blue << "bytes[" << ba.size() << "]" << Color::Reset << "\n";
break;
}
default:
out << indent << prefix << name << ": " << formatValue(value, 100) << "\n";
break;
}
}
void outputTree(const QString& filePath, const QString& typeName, const QString& displayName,
const QVariantMap& vars, const QMap<QString, UiField>& schema,
const ParseOptions& opts) {
out << Color::Bold << "File: " << Color::Reset << QFileInfo(filePath).fileName() << "\n";
out << Color::Bold << "Type: " << Color::Reset << typeName;
if (!displayName.isEmpty()) {
out << " (" << displayName << ")";
}
out << "\n";
out << QString("-").repeated(60) << "\n";
// Output tree structure
QStringList keys = vars.keys();
keys.sort();
for (const QString& key : keys) {
outputTreeNode(key, vars.value(key), 0, opts.verbose);
}
out << QString("-").repeated(60) << "\n";
// Count total nodes for summary
std::function<int(const QVariant&)> countNodes = [&](const QVariant& v) -> int {
int count = 1;
if (v.typeId() == QMetaType::QVariantMap) {
QVariantMap map = v.toMap();
for (auto it = map.begin(); it != map.end(); ++it) {
count += countNodes(it.value());
}
} else if (v.typeId() == QMetaType::QVariantList) {
for (const QVariant& item : v.toList()) {
count += countNodes(item);
}
}
return count;
};
int totalNodes = 0;
for (auto it = vars.begin(); it != vars.end(); ++it) {
totalNodes += countNodes(it.value());
}
out << "Total nodes: " << totalNodes << "\n";
}
void outputTable(const QString& filePath, const QString& typeName, const QString& displayName,
const QVariantMap& vars, const QMap<QString, UiField>& schema,
const ParseOptions& opts) {
out << Color::Bold << "File: " << Color::Reset << QFileInfo(filePath).fileName() << "\n";
out << Color::Bold << "Type: " << Color::Reset << typeName;
if (!displayName.isEmpty()) {
out << " (" << displayName << ")";
}
out << "\n\n";
// Get runtime UI metadata
QVariantMap uiMeta = vars.value("_ui_meta").toMap();
// Calculate column widths
int nameWidth = 12;
int displayWidth = 14;
int valueWidth = 30;
int typeWidth = 8;
// Build rows
struct Row {
QString name;
QString display;
QString value;
QString type;
QString warning;
};
QVector<Row> rows;
for (auto it = schema.begin(); it != schema.end(); ++it) {
const QString& fieldName = it.key();
const UiField& field = it.value();
if (fieldName.startsWith("_")) continue;
if (!vars.contains(fieldName) && !opts.verbose) continue;
QVariant value = vars.value(fieldName);
bool hasBlank = isBlankValue(value);
bool hasZero = isZeroValue(value);
if (opts.warningsOnly && !hasBlank && !hasZero) continue;
Row row;
row.name = fieldName;
QString display = field.displayName;
if (uiMeta.contains(fieldName)) {
QVariantMap meta = uiMeta.value(fieldName).toMap();
if (meta.contains("display") && !meta.value("display").toString().isEmpty()) {
display = meta.value("display").toString();
}
}
row.display = display;
row.value = formatValue(value, 50);
row.type = getFieldTypeString(field.kind);
if (hasBlank) row.warning = "BLANK";
else if (hasZero) row.warning = "ZERO";
nameWidth = qMax(nameWidth, row.name.length() + 2);
displayWidth = qMax(displayWidth, row.display.length() + 2);
valueWidth = qMax(valueWidth, qMin(row.value.length() + 2, 52));
typeWidth = qMax(typeWidth, row.type.length() + 2);
rows.append(row);
}
// In verbose mode, add hidden fields
if (opts.verbose) {
for (auto it = vars.begin(); it != vars.end(); ++it) {
const QString& fieldName = it.key();
if (fieldName.startsWith("_")) continue;
if (schema.contains(fieldName)) continue;
QVariant value = it.value();
bool hasBlank = isBlankValue(value);
bool hasZero = isZeroValue(value);
if (opts.warningsOnly && !hasBlank && !hasZero) continue;
Row row;
row.name = fieldName;
row.display = "(hidden)";
row.value = formatValue(value, 50);
row.type = "hidden";
if (hasBlank) row.warning = "BLANK";
else if (hasZero) row.warning = "ZERO";
nameWidth = qMax(nameWidth, row.name.length() + 2);
valueWidth = qMax(valueWidth, qMin(row.value.length() + 2, 52));
rows.append(row);
}
}
if (rows.isEmpty()) {
out << "(no fields to display)\n";
return;
}
out << Color::Bold << "UI Fields:" << Color::Reset << "\n";
// Header
out << QString("+%1+%2+%3+%4+%5+\n")
.arg(QString("-").repeated(nameWidth))
.arg(QString("-").repeated(displayWidth))
.arg(QString("-").repeated(valueWidth))
.arg(QString("-").repeated(typeWidth))
.arg(QString("-").repeated(9));
out << QString("| %1| %2| %3| %4| %5|\n")
.arg("Name", -nameWidth + 1)
.arg("Display", -displayWidth + 1)
.arg("Value", -valueWidth + 1)
.arg("Type", -typeWidth + 1)
.arg("Warning", -8);
out << QString("+%1+%2+%3+%4+%5+\n")
.arg(QString("-").repeated(nameWidth))
.arg(QString("-").repeated(displayWidth))
.arg(QString("-").repeated(valueWidth))
.arg(QString("-").repeated(typeWidth))
.arg(QString("-").repeated(9));
// Rows
for (const Row& row : rows) {
QString valueStr = row.value;
if (valueStr.length() > valueWidth - 1) {
valueStr = valueStr.left(valueWidth - 4) + "...";
}
QString warningStr = row.warning;
if (!warningStr.isEmpty()) {
warningStr = Color::Yellow + warningStr + Color::Reset;
}
out << QString("| %1| %2| %3| %4| %5|\n")
.arg(row.name, -nameWidth + 1)
.arg(row.display, -displayWidth + 1)
.arg(valueStr, -valueWidth + 1)
.arg(row.type, -typeWidth + 1)
.arg(row.warning, -8);
}
out << QString("+%1+%2+%3+%4+%5+\n")
.arg(QString("-").repeated(nameWidth))
.arg(QString("-").repeated(displayWidth))
.arg(QString("-").repeated(valueWidth))
.arg(QString("-").repeated(typeWidth))
.arg(QString("-").repeated(9));
out << "\nTotal fields: " << rows.size() << "\n";
}
int cmdParse(const QString& filePath, const ParseOptions& opts) {
// Determine definitions path
QString defsPath = opts.definitionsPath;
if (defsPath.isEmpty()) {
// Try common locations
QStringList searchPaths = {
QCoreApplication::applicationDirPath() + "/definitions",
QCoreApplication::applicationDirPath() + "/../definitions",
QCoreApplication::applicationDirPath() + "/../../definitions",
QDir::currentPath() + "/definitions",
"definitions"
};
for (const QString& path : searchPaths) {
if (QDir(path).exists()) {
defsPath = path;
break;
}
}
}
if (defsPath.isEmpty() || !QDir(defsPath).exists()) {
err << Color::Red << "Error: Could not find definitions directory" << Color::Reset << "\n";
err << "Use --definitions=<path> to specify the path\n";
return 1;
}
// Load all XScript definitions
TypeRegistry registry;
int loadedCount = 0;
QDirIterator it(defsPath, {"*.xscript"}, QDir::Files, QDirIterator::Subdirectories);
while (it.hasNext()) {
QString scriptPath = it.next();
QFile scriptFile(scriptPath);
if (scriptFile.open(QIODevice::ReadOnly | QIODevice::Text)) {
try {
registry.ingestScript(QString::fromUtf8(scriptFile.readAll()), scriptPath);
loadedCount++;
} catch (const std::exception& e) {
err << Color::Yellow << "Warning: Failed to load " << scriptPath << ": "
<< e.what() << Color::Reset << "\n";
}
}
}
if (loadedCount == 0) {
err << Color::Red << "Error: No XScript definitions loaded from " << defsPath << Color::Reset << "\n";
return 1;
}
// Open binary file
QFile file(filePath);
if (!file.open(QIODevice::ReadOnly)) {
err << Color::Red << "Error: Could not open file: " << filePath << Color::Reset << "\n";
return 1;
}
// Determine type
QString typeName = opts.typeName;
if (typeName.isEmpty()) {
typeName = registry.chooseType(&file, filePath);
file.seek(0); // Reset after type detection
}
if (typeName.isEmpty()) {
err << Color::Red << "Error: Could not detect file type" << Color::Reset << "\n";
err << "Use --type=<typename> to specify the type manually\n";
return 1;
}
if (!registry.hasType(typeName)) {
err << Color::Red << "Error: Unknown type '" << typeName << "'" << Color::Reset << "\n";
return 1;
}
// Parse the file
QVariantMap vars;
try {
vars = registry.parse(typeName, &file, filePath);
} catch (const std::exception& e) {
err << Color::Red << "Error parsing file: " << e.what() << Color::Reset << "\n";
return 1;
}
// Get UI schema for this type
const TypeDef& td = registry.module().types[typeName];
QMap<QString, UiField> schema = buildUiSchemaForType(td, &registry.module());
QString displayName = td.display;
// Output results
if (opts.format == "json") {
outputJson(filePath, typeName, displayName, vars, schema, opts);
} else if (opts.format == "table") {
outputTable(filePath, typeName, displayName, vars, schema, opts);
} else if (opts.format == "tree") {
outputTree(filePath, typeName, displayName, vars, schema, opts);
} else {
err << Color::Red << "Error: Unknown format '" << opts.format << "'" << Color::Reset << "\n";
err << "Valid formats: json, table, tree\n";
return 1;
}
return 0;
}
int main(int argc, char *argv[]) {
QCoreApplication app(argc, argv);
QCoreApplication::setApplicationName("xscript-cli");
@ -305,9 +900,22 @@ int main(int argc, char *argv[]) {
parser.addHelpOption();
parser.addVersionOption();
parser.addPositionalArgument("command", "Command to run: check, check-dir, list-types, info");
parser.addPositionalArgument("command", "Command to run: check, check-dir, list-types, info, parse");
parser.addPositionalArgument("args", "Command arguments", "[args...]");
// Parse command options
QCommandLineOption verboseOpt({"v", "verbose"}, "Show all fields including hidden ones");
QCommandLineOption formatOpt({"f", "format"}, "Output format: json (default), table, tree", "format", "json");
QCommandLineOption definitionsOpt({"d", "definitions"}, "Path to definitions directory", "path");
QCommandLineOption typeOpt({"t", "type"}, "Force specific type (skip auto-detect)", "typename");
QCommandLineOption warningsOpt({"w", "warnings-only"}, "Only show fields with blank/zero values");
parser.addOption(verboseOpt);
parser.addOption(formatOpt);
parser.addOption(definitionsOpt);
parser.addOption(typeOpt);
parser.addOption(warningsOpt);
parser.process(app);
const QStringList args = parser.positionalArguments();
@ -319,6 +927,19 @@ int main(int argc, char *argv[]) {
err << " xscript-cli check-dir <directory> - Check all .xscript files\n";
err << " xscript-cli list-types <file.xscript> - List all types in file\n";
err << " xscript-cli info <file.xscript> <type> - Show type info\n";
err << " xscript-cli parse <binary-file> - Parse file and show UI fields\n";
err << "\n";
err << "Parse command options:\n";
err << " -v, --verbose Show all fields including hidden ones\n";
err << " -f, --format=<fmt> Output format: json (default), table, tree\n";
err << " -d, --definitions=<path> Path to definitions directory\n";
err << " -t, --type=<typename> Force specific type (skip auto-detect)\n";
err << " -w, --warnings-only Only show fields with blank/zero values\n";
err << "\n";
err << "Formats:\n";
err << " json JSON output with field metadata\n";
err << " table Tabular output of UI fields\n";
err << " tree Hierarchical tree view of entire structure\n";
return 1;
}
@ -352,9 +973,24 @@ int main(int argc, char *argv[]) {
}
return cmdInfo(args[1], args[2]);
}
else if (command == "parse") {
if (args.size() < 2) {
err << "Error: parse requires a binary file path\n";
return 1;
}
ParseOptions opts;
opts.verbose = parser.isSet(verboseOpt);
opts.format = parser.value(formatOpt);
opts.definitionsPath = parser.value(definitionsOpt);
opts.typeName = parser.value(typeOpt);
opts.warningsOnly = parser.isSet(warningsOpt);
return cmdParse(args[1], opts);
}
else {
err << "Error: Unknown command '" << command << "'\n";
err << "Valid commands: check, check-dir, list-types, info\n";
err << "Valid commands: check, check-dir, list-types, info, parse\n";
return 1;
}
}