BuildConsoleRpcCall pushed any bare token that "looked numeric" as a JSON
number, and lost the tokenizer's quoting decision — so a genuinely-string
argument that happened to be all digits (e.g. a label) was sent with the wrong
type. std::stoll also silently truncated "1e999" to 1 (stops at the first
non-digit) and std::stod could yield inf/subnormal.
- ParseConsoleCommandArgsTagged returns {text, quoted}; quoted tokens are sent
verbatim as JSON strings, never coerced. (ParseConsoleCommandArgs kept as a
text-only wrapper so existing callers/tests are unchanged.)
- Bare tokens become a number only when the WHOLE token parses to a finite
int/double; otherwise they're sent as a string.
Adds test coverage for quoted-numeric-string, bare-number, 1e999, and 123abc.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
315 lines
11 KiB
C++
315 lines
11 KiB
C++
#include "console_input_model.h"
|
|
|
|
#include <algorithm>
|
|
#include <cctype>
|
|
#include <cmath>
|
|
#include <sstream>
|
|
|
|
namespace dragonx {
|
|
namespace ui {
|
|
|
|
const std::vector<std::string>& ConsoleRpcCommandNames()
|
|
{
|
|
static const std::vector<std::string> commands = {
|
|
"help", "getinfo", "stop",
|
|
"getnetworkinfo", "getpeerinfo", "getconnectioncount",
|
|
"addnode", "setban", "listbanned", "clearbanned", "ping",
|
|
"getblockchaininfo", "getblockcount", "getbestblockhash",
|
|
"getblock", "getblockhash", "getblockheader", "getdifficulty",
|
|
"getrawmempool", "gettxout", "coinsupply", "getchaintips",
|
|
"getmininginfo", "setgenerate", "getgenerate",
|
|
"getnetworkhashps", "getblocksubsidy",
|
|
"getbalance", "z_gettotalbalance", "z_getbalances",
|
|
"getnewaddress", "z_getnewaddress",
|
|
"listaddresses", "z_listaddresses",
|
|
"sendtoaddress", "z_sendmany",
|
|
"listtransactions", "listunspent", "z_listunspent",
|
|
"z_getoperationstatus", "z_getoperationresult",
|
|
"getwalletinfo", "backupwallet",
|
|
"dumpprivkey", "importprivkey",
|
|
"z_exportkey", "z_importkey",
|
|
"signmessage", "settxfee",
|
|
"createrawtransaction", "decoderawtransaction",
|
|
"getrawtransaction", "sendrawtransaction", "signrawtransaction",
|
|
"validateaddress", "z_validateaddress", "estimatefee",
|
|
"clear"
|
|
};
|
|
return commands;
|
|
}
|
|
|
|
void AppendConsoleHistory(std::vector<std::string>& history,
|
|
const std::string& command,
|
|
std::size_t maxEntries)
|
|
{
|
|
if (command.empty()) return;
|
|
if (history.empty() || history.back() != command) {
|
|
history.push_back(command);
|
|
while (history.size() > maxEntries) {
|
|
history.erase(history.begin());
|
|
}
|
|
}
|
|
}
|
|
|
|
int NavigateConsoleHistoryIndex(int currentIndex, std::size_t historySize, bool up)
|
|
{
|
|
if (historySize == 0) return -1;
|
|
if (up) {
|
|
if (currentIndex < 0) return static_cast<int>(historySize) - 1;
|
|
if (currentIndex > 0) return currentIndex - 1;
|
|
return currentIndex;
|
|
}
|
|
if (currentIndex >= 0) {
|
|
int next = currentIndex + 1;
|
|
if (next >= static_cast<int>(historySize)) return -1;
|
|
return next;
|
|
}
|
|
return currentIndex;
|
|
}
|
|
|
|
std::string ConsoleHistoryEntry(const std::vector<std::string>& history, int historyIndex)
|
|
{
|
|
if (historyIndex < 0 || historyIndex >= static_cast<int>(history.size())) return {};
|
|
return history[static_cast<std::size_t>(historyIndex)];
|
|
}
|
|
|
|
ConsoleCompletionResult CompleteConsoleCommand(const std::string& input)
|
|
{
|
|
ConsoleCompletionResult result;
|
|
if (input.empty()) return result;
|
|
|
|
for (const auto& command : ConsoleRpcCommandNames()) {
|
|
if (command.compare(0, input.size(), input) == 0) {
|
|
result.matches.push_back(command);
|
|
}
|
|
}
|
|
|
|
if (result.matches.empty()) return result;
|
|
result.commonPrefix = result.matches.front();
|
|
for (std::size_t matchIndex = 1; matchIndex < result.matches.size(); ++matchIndex) {
|
|
const auto& match = result.matches[matchIndex];
|
|
std::size_t prefixLength = 0;
|
|
while (prefixLength < result.commonPrefix.size() &&
|
|
prefixLength < match.size() &&
|
|
result.commonPrefix[prefixLength] == match[prefixLength]) {
|
|
++prefixLength;
|
|
}
|
|
result.commonPrefix.resize(prefixLength);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
std::vector<std::string> FormatConsoleCompletionLines(const std::vector<std::string>& matches,
|
|
std::size_t maxLineLength)
|
|
{
|
|
std::vector<std::string> lines;
|
|
std::string line = " ";
|
|
for (std::size_t matchIndex = 0; matchIndex < matches.size(); ++matchIndex) {
|
|
if (matchIndex > 0) line += " ";
|
|
line += matches[matchIndex];
|
|
if (line.length() > maxLineLength) {
|
|
lines.push_back(line);
|
|
line = " ";
|
|
}
|
|
}
|
|
if (line.length() > 2) {
|
|
lines.push_back(line);
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
std::vector<ConsoleArg> ParseConsoleCommandArgsTagged(const std::string& command)
|
|
{
|
|
std::vector<ConsoleArg> args;
|
|
std::size_t index = 0;
|
|
while (index < command.size()) {
|
|
while (index < command.size() && (command[index] == ' ' || command[index] == '\t')) {
|
|
++index;
|
|
}
|
|
if (index >= command.size()) break;
|
|
|
|
ConsoleArg arg;
|
|
if (command[index] == '"' || command[index] == '\'') {
|
|
arg.quoted = true;
|
|
char quote = command[index++];
|
|
while (index < command.size() && command[index] != quote) {
|
|
arg.text += command[index++];
|
|
}
|
|
if (index < command.size()) ++index;
|
|
} else if (command[index] == '[' || command[index] == '{') {
|
|
char open = command[index];
|
|
char close = (open == '[') ? ']' : '}';
|
|
int depth = 0;
|
|
while (index < command.size()) {
|
|
if (command[index] == open) ++depth;
|
|
else if (command[index] == close) --depth;
|
|
arg.text += command[index++];
|
|
if (depth == 0) break;
|
|
}
|
|
} else {
|
|
while (index < command.size() && command[index] != ' ' && command[index] != '\t') {
|
|
arg.text += command[index++];
|
|
}
|
|
}
|
|
// A quoted empty string ("") is a real argument; only skip genuinely-empty unquoted tokens.
|
|
if (!arg.text.empty() || arg.quoted) args.push_back(std::move(arg));
|
|
}
|
|
return args;
|
|
}
|
|
|
|
std::vector<std::string> ParseConsoleCommandArgs(const std::string& command)
|
|
{
|
|
std::vector<std::string> args;
|
|
for (auto& a : ParseConsoleCommandArgsTagged(command)) args.push_back(std::move(a.text));
|
|
return args;
|
|
}
|
|
|
|
namespace {
|
|
// Interpret a bare token as a JSON number only when the WHOLE token is a valid, finite number;
|
|
// otherwise return it as a string. Guards against std::stoll silently truncating "1e9"/"123abc"
|
|
// (it stops at the first non-digit) and std::stod yielding inf/subnormal for out-of-range input.
|
|
nlohmann::json ParseConsoleNumberOrString(const std::string& s)
|
|
{
|
|
if (!s.empty()) {
|
|
try {
|
|
std::size_t pos = 0;
|
|
long long ll = std::stoll(s, &pos);
|
|
if (pos == s.size()) return ll;
|
|
} catch (...) {}
|
|
try {
|
|
std::size_t pos = 0;
|
|
double d = std::stod(s, &pos);
|
|
if (pos == s.size() && std::isfinite(d)) return d;
|
|
} catch (...) {}
|
|
}
|
|
return s;
|
|
}
|
|
} // namespace
|
|
|
|
ConsoleRpcCall BuildConsoleRpcCall(const std::string& command)
|
|
{
|
|
auto args = ParseConsoleCommandArgsTagged(command);
|
|
ConsoleRpcCall call;
|
|
if (args.empty()) return call;
|
|
|
|
call.valid = true;
|
|
call.method = args.front().text;
|
|
|
|
for (std::size_t argIndex = 1; argIndex < args.size(); ++argIndex) {
|
|
const ConsoleArg& a = args[argIndex];
|
|
const std::string& arg = a.text;
|
|
|
|
// A quoted token is always a string — never coerced — so a genuinely-string argument that
|
|
// happens to look numeric (e.g. an all-digit label) is sent as a string, not a number.
|
|
if (a.quoted) {
|
|
call.params.push_back(arg);
|
|
continue;
|
|
}
|
|
|
|
if (!arg.empty() && (arg[0] == '{' || arg[0] == '[')) {
|
|
auto parsed = nlohmann::json::parse(arg, nullptr, false);
|
|
if (!parsed.is_discarded()) {
|
|
call.params.push_back(parsed);
|
|
continue;
|
|
}
|
|
// Malformed JSON — fail the whole command instead of silently sending it as a string.
|
|
call.valid = false;
|
|
call.error = "Argument " + std::to_string(argIndex) + " is not valid JSON: " + arg;
|
|
return call;
|
|
}
|
|
|
|
if (arg == "true") {
|
|
call.params.push_back(true);
|
|
} else if (arg == "false") {
|
|
call.params.push_back(false);
|
|
} else {
|
|
call.params.push_back(ParseConsoleNumberOrString(arg));
|
|
}
|
|
}
|
|
|
|
return call;
|
|
}
|
|
|
|
std::vector<ConsoleResultLine> FormatConsoleRpcResultLines(const std::string& result,
|
|
bool isError)
|
|
{
|
|
if (isError) {
|
|
return {{"Error: " + result, ConsoleResultLineRole::Error}};
|
|
}
|
|
|
|
std::vector<ConsoleResultLine> lines;
|
|
std::string normalized = (result == "null") ? "(no result)" : result;
|
|
bool isJson = !normalized.empty() && (normalized[0] == '{' || normalized[0] == '[');
|
|
std::istringstream stream(normalized);
|
|
std::string line;
|
|
while (std::getline(stream, line)) {
|
|
ConsoleResultLineRole role = ConsoleResultLineRole::Result;
|
|
if (isJson && !line.empty()) {
|
|
std::string trimmed = line;
|
|
std::size_t first = trimmed.find_first_not_of(" \t");
|
|
if (first != std::string::npos) trimmed = trimmed.substr(first);
|
|
if (!trimmed.empty()) {
|
|
unsigned char firstChar = static_cast<unsigned char>(trimmed[0]);
|
|
if (trimmed[0] == '{' || trimmed[0] == '}' ||
|
|
trimmed[0] == '[' || trimmed[0] == ']') {
|
|
role = ConsoleResultLineRole::JsonBrace;
|
|
} else if (trimmed[0] == '"') {
|
|
if (trimmed.find("\": ") != std::string::npos ||
|
|
trimmed.find("\":") != std::string::npos) {
|
|
role = ConsoleResultLineRole::JsonKey;
|
|
} else {
|
|
role = ConsoleResultLineRole::JsonString;
|
|
}
|
|
} else if (std::isdigit(firstChar) || trimmed[0] == '-') {
|
|
role = ConsoleResultLineRole::JsonNumber;
|
|
} else if (trimmed == "true," || trimmed == "false," ||
|
|
trimmed == "true" || trimmed == "false" ||
|
|
trimmed == "null," || trimmed == "null") {
|
|
role = ConsoleResultLineRole::JsonNumber;
|
|
}
|
|
}
|
|
}
|
|
lines.push_back({line, role});
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
std::vector<int> ComputeConsoleFoldSpans(const std::vector<std::string>& lines)
|
|
{
|
|
std::vector<int> spans(lines.size(), 0);
|
|
|
|
auto leadingSpaces = [](const std::string& s) -> std::size_t {
|
|
std::size_t n = 0;
|
|
while (n < s.size() && s[n] == ' ') ++n;
|
|
return n;
|
|
};
|
|
auto lastNonSpace = [](const std::string& s) -> char {
|
|
for (std::size_t i = s.size(); i-- > 0; )
|
|
if (s[i] != ' ' && s[i] != '\t') return s[i];
|
|
return '\0';
|
|
};
|
|
auto firstNonSpace = [](const std::string& s) -> char {
|
|
for (char c : s)
|
|
if (c != ' ' && c != '\t') return c;
|
|
return '\0';
|
|
};
|
|
|
|
for (std::size_t i = 0; i < lines.size(); ++i) {
|
|
const char last = lastNonSpace(lines[i]);
|
|
if (last != '{' && last != '[') continue; // not a block opener
|
|
const std::size_t indent = leadingSpaces(lines[i]);
|
|
const char wantClose = (last == '{') ? '}' : ']';
|
|
// Matching closer = the next line at the same indentation whose first non-space char
|
|
// is the corresponding closing bracket (pretty-printed JSON has aligned brackets).
|
|
for (std::size_t j = i + 1; j < lines.size(); ++j) {
|
|
if (leadingSpaces(lines[j]) == indent && firstNonSpace(lines[j]) == wantClose) {
|
|
if (j - i >= 2) spans[i] = static_cast<int>(j - i); // >=1 inner line
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return spans;
|
|
}
|
|
|
|
} // namespace ui
|
|
} // namespace dragonx
|