Files
openwrt/package/utils/cli/files/usr/share/ucode/cli/context.uc
Felix Fietkau 895b4e7caf cli: add support for partial completion with separator character
Useful for completing long lists of possible values with common prefix

Signed-off-by: Felix Fietkau <nbd@nbd.name>
2025-02-28 17:36:01 +01:00

709 lines
14 KiB
Ucode

// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2025 Felix Fietkau <nbd@nbd.name>
'use strict';
import * as callctx from "cli.context-call";
function prefix_match(prefix, str, icase)
{
if (icase) {
str = lc(str);
prefix = lc(prefix);
}
return substr(str, 0, length(prefix)) == prefix;
}
function context_clone()
{
let ret = { ...this };
ret.prompt = [ ...ret.prompt ];
ret.data = { ...ret.data };
ret.hooks = {};
return proto(ret, proto(this));
}
function context_entries()
{
return keys(this.node)
}
function context_help(entry)
{
if (entry)
return this.node[entry].help;
let ret = {};
for (let name, val in this.node)
ret[name] = val.help ?? "";
return ret;
}
function context_add_hook(type, cb)
{
this.hooks[type] ??= [];
push(this.hooks[type], cb);
}
function context_select_error(code, msg, ...args)
{
msg ??= "Unknown error";
msg = sprintf(msg, ...args);
let error = {
code, msg, args
};
this.errors ??= [];
push(this.errors, error);
}
function context_set(prompt, data)
{
if (prompt)
this.cur_prompt = prompt;
if (data)
this.data = { ...this.data, ...data };
return true;
}
const context_select_proto = {
add_hook: context_add_hook,
error: context_select_error,
set: context_set,
...callctx.callctx_error_proto,
};
function __context_select(ctx, name, args)
{
let entry = ctx.node[name];
if (!entry || !entry.select_node)
return;
let node = ctx.model.node[entry.select_node];
if (!node)
return;
let ret = proto(ctx.clone(), ctx.model.context_select_proto);
ret.cur_prompt = name;
ret.node = node;
try {
if (entry.select &&
!call(entry.select, entry, ctx.model.scope, ret, args))
ret.errors ??= [];
} catch (e) {
ctx.model.exception(e);
return;
}
push(ret.prompt, ret.cur_prompt);
ret.prev = ctx;
proto(ret, proto(ctx));
return ret;
}
function context_run_hooks(ctx, name)
{
try {
while (length(ctx.hooks[name]) > 0) {
let hook = ctx.hooks[name][0];
let ret = call(hook, ctx, ctx.model.scope);
if (!ret)
return false;
shift(ctx.hooks.exit);
}
} catch (e) {
ctx.model.exception(e);
return false;
}
return true;
}
function context_prev(ctx, skip_hooks)
{
if (!skip_hooks && !context_run_hooks(ctx, "exit"))
return;
return ctx.prev;
}
function context_top(ctx, skip_hooks)
{
while (ctx && ctx.prev)
ctx = context_prev(ctx, skip_hooks);
return ctx;
}
function prepare_spec(e, ctx, spec, argv)
{
if (type(spec) != "function")
return spec;
return call(spec, e, ctx.model.scope, ctx, argv);
}
function prepare_default(e, ctx, spec, argv, named_args)
{
if (type(spec) != "object" || type(spec.default) != "function")
return;
try {
spec.default = call(spec.default, e, ctx.model.scope, ctx, argv, named_args, spec);
} catch (e) {
model.exception(e);
}
}
function prepare_attr_spec(e, ctx, spec, argv, named_args)
{
if (type(spec) != "object")
return spec;
let t = ctx.model.types[spec.type];
if (t)
spec = { ...t, ...spec };
else
spec = { ...spec };
prepare_default(e, ctx, spec, argv, named_args, spec);
if (type(spec.value) == "function")
try {
spec.value = call(spec.value, e, ctx.model.scope, ctx, argv, named_args, spec);
} catch (e) {
ctx.model.exception(e);
spec.value = [];
}
return spec;
}
function parse_arg(ctx, name, spec, val)
{
let t;
if (val == null) {
ctx.invalid_argument("Missing argument %s", name);
return;
}
if (type(spec) == "object" && spec.type)
t = ctx.model.types[spec.type];
if (!t) {
ctx.invalid_argument("Invalid type in argument: %s", name);
return;
}
if (!t.parse)
return val;
return call(t.parse, spec, ctx.model.scope, ctx, name, val);
}
const context_defaults = {
up: [ "Return to previous node", context_prev ],
exit: [ "Return to previous node", context_prev ],
main: [ "Return to main node", context_top ],
};
const context_default_order = [ "up", "exit", "main" ];
function context_select(args, completion)
{
let ctx = this;
while (length(args) > completion ? 1 : 0) {
let name = args[0];
let entry = ctx.node[name];
if (!entry) {
let e = context_defaults[name];
if (!e)
return ctx;
shift(args);
ctx = e[1](ctx, completion);
if (!ctx)
return;
continue;
}
if (!entry.select_node)
return ctx;
let num_args = length(entry.args);
if (completion && num_args + 1 >= length(args))
return ctx;
shift(args);
let argv = [];
let parse_ctx = callctx.new(this.model, ctx);
if (num_args > 0) {
let cur_argv = slice(args, 0, num_args);
for (let i = 0; i < num_args; i++) {
let arg = shift(args);
let spec = entry.args[i];
spec = prepare_attr_spec(entry, ctx, spec, cur_argv, {});
if (arg != null)
arg = parse_arg(parse_ctx, spec.name, spec, arg);
if (arg != null)
push(argv, arg);
}
}
if (entry.no_subcommands && length(args) > 0)
parse_ctx.invalid_argument("command %s does not support subcommands", name);
if (length(parse_ctx.result.errors) > 0) {
ctx = ctx.clone();
ctx.errors = parse_ctx.result.errors;
return ctx;
}
ctx = __context_select(ctx, name, argv);
if (type(ctx) != "object" || ctx.errors)
break;
}
return ctx;
}
function complete_named_params(ctx, entry, obj, name, argv, named_params)
{
let data = [];
let empty = "";
if (substr(name, 0, 1) == "-") {
empty = "-";
name = substr(name, 1);
}
let defaults = {};
callctx.new(ctx.model, ctx).apply_defaults(obj, defaults);
for (let cur_name in sort(keys(obj))) {
let val = obj[cur_name];
if (!prefix_match(name, cur_name) || val.no_complete)
continue;
if (empty && !(val.allow_empty ?? entry.allow_empty))
continue;
if (!val.multiple && named_params[cur_name] != null)
continue;
if (type(val.available) == "function" &&
!call(val.available, val, ctx.model.scope, ctx, argv, named_params))
continue;
val = {
name: empty + cur_name,
...val,
};
push(data, val);
}
return {
type: "keywords",
name: "parameter",
help: "Parameter name",
value: data
};
}
function complete_param(e, ctx, cur, val, args, named_args)
{
cur = prepare_attr_spec(e, ctx, cur, args, named_args);
if (type(cur.value) == "object") {
let ret = [];
for (let key in sort(keys(cur.value)))
if (prefix_match(val, key, cur.ignore_case))
push(ret, {
name: key,
help: cur.value[key]
});
cur.value = ret;
return cur;
}
if (type(cur.value) == "array") {
cur.value = map(sort(filter(cur.value, (v) => prefix_match(val, v, cur.ignore_case))), (v) => ({ name: v }));
return cur;
}
let type_info = ctx.model.types[cur.type];
if (!type_info || !type_info.complete)
return cur;
cur.value = call(type_info.complete, cur, ctx.model.scope, ctx, val);
return cur;
}
function complete_arg_list(e, ctx, arg_info, args, base_args, named_args)
{
let cur_idx = length(args) - 1;
let cur = arg_info[cur_idx];
let val;
for (let i = 0; i <= cur_idx; i++)
val = shift(args);
let ret = complete_param(e, ctx, cur, val, base_args, named_args);
if (!cur.prefix_separator)
return ret;
let prefix_len = length(val);
let vals = [];
let match_prefix;
for (let cur_val in ret.value) {
let cur_str = cur_val.name;
let cur_suffix = substr(cur_str, prefix_len);
let idx = index(cur_suffix, cur.prefix_separator);
if (idx < 0) {
push(vals, cur_val);
continue;
}
let cur_prefix = substr(cur_str, 0, prefix_len + idx + 1);
if (cur_prefix == match_prefix)
continue;
match_prefix = cur_prefix;
push(vals, {
...cur_val,
name: cur_prefix,
incomplete: true
});
}
ret.value = vals;
return ret;
}
function handle_empty_param(entry, spec, name, argv, named_args)
{
if (substr(name, 0, 1) != "-")
return;
name = substr(name, 1);
let cur = spec[name];
if (!cur)
return;
if (cur.default == null &&
!(cur.allow_empty ?? entry.allow_empty))
return;
if (cur.required) {
cur = { ...cur };
prepare_default(e, ctx, cur, argv, named_args, cur);
named_args[name] = cur.default;
} else {
named_args[name] = null;
}
return true;
}
function default_complete(ctx, args)
{
let num_args = length(this.args);
let named_args = {};
let cur_args;
if (length(args) <= num_args)
return complete_arg_list(this, ctx, this.args, args, [ ...args ], named_args);
let spec = prepare_spec(this, ctx, this.named_args, args);
if (!spec)
return;
let base_args = slice(args, 0, num_args);
for (let i = 0; i < num_args; i++)
shift(args);
while (length(args) > 0) {
let name = args[0];
if (length(args) == 1)
return complete_named_params(ctx, this, spec, name, base_args, named_args);
shift(args);
let cur = spec[name];
if (!cur) {
if (handle_empty_param(this, spec, name, base_args, named_args))
continue;
return;
}
if (!cur.args) {
named_args[name] = true;
continue;
}
let val;
let cur_spec = cur.args;
if (type(cur_spec) != "array") {
cur_spec = [{
name,
help: cur.help,
...cur_spec
}];
named_args[name] = shift(args);
val = [ named_args[name] ];
} else {
let num_args = length(cur_spec);
let val = [];
for (let i = 0; i < num_args; i++)
push(val, shift(args));
named_args[name] = val;
}
if (!length(args))
return complete_arg_list(this, ctx, cur_spec, val, base_args, named_args);
}
}
function context_complete(args)
{
let ctx = this.select(args, true);
if (!ctx || ctx.errors)
return;
if (ctx != this) {
ctx = ctx.clone();
ctx.skip_default_complete = true;
}
if (length(args) > 1) {
let name = shift(args);
let entry = ctx.node[name];
if (!entry)
return;
try {
if (!entry.available || call(entry.available, entry, ctx.model.scope, ctx, args))
return call(entry.complete ?? default_complete, entry, ctx.model.scope, ctx, args);
} catch (e) {
this.model.exception(e);
}
return;
}
let name = shift(args) ?? "";
let prefix_len = length(name);
let data = [];
let default_data = {};
for (let cur_name in sort(keys(ctx.node))) {
let val = ctx.node[cur_name];
if (substr(cur_name, 0, prefix_len) != name)
continue;
if (val.available && !call(val.available, val, ctx.model.scope, ctx, args))
continue;
let cur = {
name: cur_name,
help: val.help,
category: val.select_node ? "Object" : "Action",
};
if (context_defaults[cur_name])
default_data[cur_name] = cur;
else
push(data, cur);
}
for (let cur_name in context_default_order) {
if (substr(cur_name, 0, prefix_len) != name)
continue;
let val = default_data[cur_name];
if (!val) {
if (!ctx.prev || ctx.skip_default_complete)
continue;
val = {
name: cur_name,
help: context_defaults[cur_name][0],
category: "Navigation",
};
}
push(data, val);
}
return {
type: "enum",
name: "command",
help: "Command",
value: data
};
}
function context_call(args)
{
let ctx = this.select(args);
if (!ctx || !length(args))
return;
let name = shift(args);
let entry = ctx.node[name];
if (!entry)
return;
if (!entry.call)
return;
let named_args = {};
let num_args = length(entry.args);
let cur_argv = slice(args, 0, num_args);
let argv = [];
let skip = {};
ctx = callctx.new(this.model, ctx);
ctx.entry = entry;
ctx.named_args = named_args;
for (let i = 0; i < num_args; i++) {
let arg = shift(args);
let spec = entry.args[i];
spec = prepare_attr_spec(entry, ctx, spec, cur_argv, named_args);
if (arg != null)
arg = parse_arg(ctx, spec.name, spec, arg);
if (spec.required && !length(arg)) {
if (spec.default)
arg = spec.default;
else
ctx.missing_argument("Missing argument %d: %s", i + 1, spec.name);
}
if (arg != null)
push(argv, arg);
}
let spec = prepare_spec(entry, ctx, entry.named_args, argv) ?? {};
let defaults = {};
ctx.apply_defaults(spec, defaults);
while (length(args) > 0) {
let name = shift(args);
let cur = spec[name];
try {
if (cur && type(cur.available) == "function" &&
!call(cur.available, cur, ctx.model.scope, ctx, argv, { ...defaults, ...named_args }))
cur = null;
} catch (e) {
ctx.model.exception(e);
continue;
}
if (!cur) {
if (handle_empty_param(entry, spec, name, argv, named_args))
continue;
ctx.invalid_argument("Invalid argument: %s", name);
return ctx.result;
}
if (!cur.args) {
named_args[name] = true;
continue;
}
let val;
let cur_spec = cur.args;
if (type(cur.args) == "array") {
val = [];
for (let spec in cur.args) {
spec = prepare_attr_spec(entry, ctx, spec, argv, named_args);
let cur = parse_arg(ctx, name, spec, shift(args));
if (cur == null)
return ctx.result;
push(val, cur);
}
} else {
let spec = prepare_attr_spec(entry, ctx, cur.args, argv, named_args);
val = parse_arg(ctx, name, spec, shift(args));
if (val == null)
return ctx.result;
}
if (cur.multiple) {
named_args[name] ??= [];
push(named_args[name], val);
} else {
named_args[name] = val;
}
}
for (let name, arg in spec) {
if (!arg.required || named_args[name] != null)
continue;
try {
if (type(arg.available) == "function" &&
!call(arg.available, arg, ctx.model.scope, ctx, argv, named_args))
continue;
} catch (e) {
ctx.model.exception(e);
continue;
}
let spec = { ...arg };
prepare_default(entry, ctx, spec, argv, named_args);
if (spec.default != null)
named_args[name] = spec.default;
else
ctx.missing_argument("Missing argument: %s", name);
}
if (length(ctx.result.errors) > 0)
return ctx.result;
if (entry.available && !call(entry.available, entry, ctx.model.scope, ctx))
return ctx.result;
try {
if (!entry.validate || call(entry.validate, entry, ctx.model.scope, ctx, argv, named_args))
call(entry.call, entry, ctx.model.scope, ctx, argv, named_args);
} catch (e) {
this.model.exception(e);
return;
}
return ctx.result;
}
const context_proto = {
clone: context_clone,
entries: context_entries,
help: context_help,
select: context_select,
call: context_call,
complete: context_complete,
add_hook: context_add_hook,
};
export function new(model) {
model.context_proto ??= {
model,
...context_proto
};
model.context_select_proto ??= {
model,
...context_select_proto
};
return proto({
prompt: [],
node: model.node.Root,
hooks: {},
data: {}
}, model.context_proto);
};