Merge pull request #2891 from SillyTavern/if-command-default-arguments `/if` and `/while` now allow "right" and "rule" to be optional
Signed| @@ -12,7 +12,7 @@ import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCom | ||
| 12 | 12 | import { SlashCommandEnumValue, enumTypes } from './slash-commands/SlashCommandEnumValue.js'; |
| 13 | 13 | import { PARSER_FLAG, SlashCommandParser } from './slash-commands/SlashCommandParser.js'; |
| 14 | 14 | import { SlashCommandScope } from './slash-commands/SlashCommandScope.js'; |
| 15 | 15 | import { isFalseBoolean, convertValueType, isTrueBoolean } from './utils.js'; |
| 16 | 16 | |
| 17 | 17 | /** @typedef {import('./slash-commands/SlashCommandParser.js').NamedArguments} NamedArguments */ |
| 18 | 18 | /** @typedef {import('./slash-commands/SlashCommand.js').UnnamedArguments} UnnamedArguments */ |
| @@ -488,7 +488,7 @@ function existsGlobalVariable(name) { | ||
| 488 | 488 | /** |
| 489 | 489 | * Parses boolean operands from command arguments. |
| 490 | 490 | * @param {object} args Command arguments |
| 491 | 491 | * @returns {{a: string | number, b: string | number?, rule: string}} Boolean operands |
| 492 | 492 | */ |
| 493 | 493 | export function parseBooleanOperands(args) { |
| 494 | 494 | // Resolution order: numeric literal, local variable, global variable, string literal |
| @@ -497,6 +497,9 @@ export function parseBooleanOperands(args) { | ||
| 497 | 497 | */ |
| 498 | 498 | function getOperand(operand) { |
| 499 | 499 | if (operand === undefined) { |
| 500 | + return undefined; | |
| 501 | + } | |
| 502 | + if (operand === '') { | |
| 500 | 503 | return ''; |
| 501 | 504 | } |
| 502 | 505 | |
| @@ -525,8 +528,8 @@ export function parseBooleanOperands(args) { | ||
| 525 | 528 | return stringLiteral || ''; |
| 526 | 529 | } |
| 527 | 530 | |
| 528 | 531 | const left = getOperand(args.a ||?? args.left ||?? args.first ||?? args.x); |
| 529 | 532 | const right = getOperand(args.b ||?? args.right ||?? args.second ||?? args.y); |
| 530 | 533 | const rule = args.rule; |
| 531 | 534 | |
| 532 | 535 | return { a: left, b: right, rule }; |
| @@ -534,84 +537,79 @@ export function parseBooleanOperands(args) { | ||
| 534 | 537 | |
| 535 | 538 | /** |
| 536 | 539 | * Evaluates a boolean comparison rule. |
| 537 | - * @param {string} rule Boolean comparison rule | |
| 540 | + * | |
| 541 | + * @param {string?} rule Boolean comparison rule | |
| 538 | 542 | * @param {string|number} a The left operand |
| 539 | 543 | * @param {string|number?} b The right operand |
| 540 | 544 | * @returns {boolean} True if the rule yields true, false otherwise |
| 541 | 545 | */ |
| 542 | 546 | export function evalBoolean(rule, a, b) { |
| 543 | 547 | if (!rulea === undefined) { |
| 544 | - toastr.warning('The rule must be specified for the boolean comparison.', 'Invalid command'); | |
| 548 | + throw new Error('Left operand is not provided'); | |
| 545 | - throw new Error('Invalid command.'); | |
| 549 | + } | |
| 550 | + | |
| 551 | + // If right-hand side was not provided, whe just check if the left side is truthy | |
| 552 | + if (b === undefined) { | |
| 553 | + switch (rule) { | |
| 554 | + case undefined: | |
| 555 | + case 'not': { | |
| 556 | + const resultOnTruthy = rule !== 'not'; | |
| 557 | + if (isTrueBoolean(String(a))) return resultOnTruthy; | |
| 558 | + if (isFalseBoolean(String(a))) return !resultOnTruthy; | |
| 559 | + return a ? resultOnTruthy : !resultOnTruthy; | |
| 560 | + } | |
| 561 | + default: | |
| 562 | + throw new Error(`Unknown boolean comparison rule for truthy check. If right operand is not provided, the rule must not provided or be 'not'. Provided: ${rule}`); | |
| 563 | + } | |
| 546 | 564 | } |
| 547 | 565 | |
| 548 | - let result = false; | |
| 566 | + // If no rule was provided, we are implicitly using 'eq', as defined for the slash commands | |
| 567 | + rule ??= 'eq'; | |
| 568 | + | |
| 549 | 569 | if (typeof a === 'number' && typeof b === 'number') { |
| 550 | 570 | // only do numeric comparison if both operands are numbers |
| 551 | 571 | const aNumber = Number(a); |
| 552 | 572 | const bNumber = Number(b); |
| 553 | 573 | |
| 554 | 574 | switch (rule) { |
| 555 | - case 'not': | |
| 556 | - result = !aNumber; | |
| 557 | - break; | |
| 558 | 575 | case 'gt': |
| 559 | 576 | result =return aNumber > bNumber; |
| 560 | - break; | |
| 561 | 577 | case 'gte': |
| 562 | 578 | result =return aNumber >= bNumber; |
| 563 | - break; | |
| 564 | 579 | case 'lt': |
| 565 | 580 | result =return aNumber < bNumber; |
| 566 | - break; | |
| 567 | 581 | case 'lte': |
| 568 | 582 | result =return aNumber <= bNumber; |
| 569 | - break; | |
| 570 | 583 | case 'eq': |
| 571 | 584 | result =return aNumber === bNumber; |
| 572 | - break; | |
| 573 | 585 | case 'neq': |
| 574 | 586 | result =return aNumber !== bNumber; |
| 575 | - break; | |
| 576 | - default: | |
| 577 | - toastr.error('Unknown boolean comparison rule for type number.', 'Invalid command'); | |
| 578 | - throw new Error('Invalid command.'); | |
| 579 | - } | |
| 580 | - } else { | |
| 581 | - // otherwise do case-insensitive string comparsion, stringify non-strings | |
| 582 | - let aString; | |
| 583 | - let bString; | |
| 584 | - if (typeof a == 'string') { | |
| 585 | - aString = a.toLowerCase(); | |
| 586 | - } else { | |
| 587 | - aString = JSON.stringify(a).toLowerCase(); | |
| 588 | - } | |
| 589 | - if (typeof b == 'string') { | |
| 590 | - bString = b.toLowerCase(); | |
| 591 | - } else { | |
| 592 | - bString = JSON.stringify(b).toLowerCase(); | |
| 593 | - } | |
| 594 | - | |
| 595 | - switch (rule) { | |
| 596 | 587 | case 'in': |
| 597 | - result = aString.includes(bString); | |
| 598 | - break; | |
| 599 | 588 | case 'nin': |
| 600 | - result = !aString.includes(bString); | |
| 589 | + // Fall through to string comparison. Otherwise you could not check if 12345 contains 45 for example. | |
| 601 | - break; | |
| 590 | + console.debug(`Boolean comparison rule '${rule}' is not supported for type number. Falling back to string comparison.`); | |
| 602 | - case 'eq': | |
| 603 | - result = aString === bString; | |
| 604 | - break; | |
| 605 | - case 'neq': | |
| 606 | - result = aString !== bString; | |
| 607 | 591 | break; |
| 608 | 592 | default: |
| 609 | - toastr.error('Unknown boolean comparison rule for type string.', 'Invalid /if command'); | |
| 593 | + throw new Error(`Unknown boolean comparison rule for type number. Accepted: gt, gte, lt, lte, eq, neq. Provided: ${rule}`); | |
| 610 | - throw new Error('Invalid command.'); | |
| 611 | 594 | } |
| 612 | 595 | } |
| 613 | 596 | |
| 614 | - return result; | |
| 597 | + // otherwise do case-insensitive string comparsion, stringify non-strings | |
| 598 | + let aString = (typeof a === 'string') ? a.toLowerCase() : JSON.stringify(a).toLowerCase(); | |
| 599 | + let bString = (typeof b === 'string') ? b.toLowerCase() : JSON.stringify(b).toLowerCase(); | |
| 600 | + | |
| 601 | + switch (rule) { | |
| 602 | + case 'in': | |
| 603 | + return aString.includes(bString); | |
| 604 | + case 'nin': | |
| 605 | + return !aString.includes(bString); | |
| 606 | + case 'eq': | |
| 607 | + return aString === bString; | |
| 608 | + case 'neq': | |
| 609 | + return aString !== bString; | |
| 610 | + default: | |
| 611 | + throw new Error(`Unknown boolean comparison rule for type number. Accepted: in, nin, eq, neq. Provided: ${rule}`); | |
| 612 | + } | |
| 615 | 613 | } |
| 616 | 614 | |
| 617 | 615 | /** |
| @@ -1317,32 +1315,36 @@ export function registerVariableCommands() { | ||
| 1317 | 1315 | typeList: [ARGUMENT_TYPE.VARIABLE_NAME, ARGUMENT_TYPE.STRING, ARGUMENT_TYPE.NUMBER], |
| 1318 | 1316 | isRequired: true, |
| 1319 | 1317 | enumProvider: commonEnumProviders.variables('all'), |
| 1320 | - forceEnum: false, | |
| 1321 | 1318 | }), |
| 1322 | 1319 | SlashCommandNamedArgument.fromProps({ |
| 1323 | 1320 | name: 'right', |
| 1324 | 1321 | description: 'right operand', |
| 1325 | 1322 | typeList: [ARGUMENT_TYPE.VARIABLE_NAME, ARGUMENT_TYPE.STRING, ARGUMENT_TYPE.NUMBER], |
| 1326 | - isRequired: true, | |
| 1327 | 1323 | enumProvider: commonEnumProviders.variables('all'), |
| 1328 | - forceEnum: false, | |
| 1329 | 1324 | }), |
| 1330 | 1325 | new SlashCommandNamedArgument.fromProps({ |
| 1331 | - 'rule', 'comparison rule', [ARGUMENT_TYPE.STRING], true, false, null, [ | |
| 1326 | + name: 'rule', | |
| 1332 | - new SlashCommandEnumValue('gt', 'a > b'), | |
| 1327 | + description: 'comparison rule', | |
| 1333 | - new SlashCommandEnumValue('gte', 'a >= b'), | |
| 1328 | + typeList: [ARGUMENT_TYPE.STRING], | |
| 1334 | - new SlashCommandEnumValue('lt', 'a < b'), | |
| 1329 | + defaultValue: 'eq', | |
| 1335 | - new SlashCommandEnumValue('lte', 'a <= b'), | |
| 1330 | + enumList: [ | |
| 1336 | 1331 | new SlashCommandEnumValue('eq', 'a == b (strings & numbers)'), |
| 1337 | 1332 | new SlashCommandEnumValue('neq', 'a !== b (strings & numbers)'), |
| 1338 | 1333 | new SlashCommandEnumValue('notin', '!a includes b (strings & numbers as strings)'), |
| 1339 | 1334 | new SlashCommandEnumValue('innin', 'a not includes b (strings & numbers as strings)'), |
| 1340 | 1335 | new SlashCommandEnumValue('ningt', 'a not includes> b (numbers)'), |
| 1341 | - ], | |
| 1336 | + new SlashCommandEnumValue('gte', 'a >= b (numbers)'), | |
| 1342 | - ), | |
| 1337 | + new SlashCommandEnumValue('lt', 'a < b (numbers)'), | |
| 1343 | - new SlashCommandNamedArgument( | |
| 1338 | + new SlashCommandEnumValue('lte', 'a <= b (numbers)'), | |
| 1344 | - 'else', 'command to execute if not true', [ARGUMENT_TYPE.CLOSURE, ARGUMENT_TYPE.SUBCOMMAND], false, | |
| 1339 | + new SlashCommandEnumValue('not', '!a (truthy)'), | |
| 1345 | - ), | |
| 1340 | + ], | |
| 1341 | + forceEnum: true, | |
| 1342 | + }), | |
| 1343 | + SlashCommandNamedArgument.fromProps({ | |
| 1344 | + name: 'else', | |
| 1345 | + description: 'command to execute if not true', | |
| 1346 | + typeList: [ARGUMENT_TYPE.CLOSURE, ARGUMENT_TYPE.SUBCOMMAND], | |
| 1347 | + }), | |
| 1346 | 1348 | ], |
| 1347 | 1349 | unnamedArgumentList: [ |
| 1348 | 1350 | new SlashCommandArgument( |
| @@ -1360,17 +1362,25 @@ export function registerVariableCommands() { | ||
| 1360 | 1362 | Numeric values and string literals for left and right operands supported. |
| 1361 | 1363 | </div> |
| 1362 | 1364 | <div> |
| 1365 | + If the rule is not provided, it defaults to <code>eq</code>. | |
| 1366 | + </div> | |
| 1367 | + <div> | |
| 1368 | + If no right operand is provided, it defaults to checking the <code>left</code> value to be truthy. | |
| 1369 | + A non-empty string or non-zero number is considered truthy, as is the value <code>true</code> or <code>on</code>.<br /> | |
| 1370 | + Only acceptable rules for no provided right operand are <code>not</code>, and no provided rule - which default to returning whether it is not or is truthy. | |
| 1371 | + </div> | |
| 1372 | + <div> | |
| 1363 | 1373 | <strong>Available rules:</strong> |
| 1364 | 1374 | <ul> |
| 1365 | 1375 | <li>gt<code>eq</code> => a >== b <small>(strings & numbers)</small></li> |
| 1366 | 1376 | <li>gte<code>neq</code> => a >!== b <small>(strings & numbers)</small></li> |
| 1367 | 1377 | <li>lt<code>in</code> => a <includes b <small>(strings & numbers as strings)</small></li> |
| 1368 | - <li>lte => a <= b</li> | |
| 1378 | + <li><code>nin</code> => a not includes b <small>(strings & numbers as strings)</small></li> | |
| 1369 | 1379 | <li>eq<code>gt</code> => a ==> b <small>(numbers)</small></li> |
| 1370 | 1380 | <li>neq<code>gte</code> => a !>= b <small>(numbers)</small></li> |
| 1371 | 1381 | <li>not<code>lt</code> => !a < b <small>(numbers)</small></li> |
| 1372 | 1382 | <li>in (strings)<code>lte</code> => a includes<= b <small>(numbers)</small></li> |
| 1373 | - <li>nin (strings) => a not includes b</li> | |
| 1383 | + <li><code>not</code> => !a <small>(truthy)</small></li> | |
| 1374 | 1384 | </ul> |
| 1375 | 1385 | </div> |
| 1376 | 1386 | <div> |
| @@ -1380,6 +1390,17 @@ export function registerVariableCommands() { | ||
| 1380 | 1390 | <pre><code class="language-stscript">/if left=score right=10 rule=gte "/speak You win"</code></pre> |
| 1381 | 1391 | triggers a /speak command if the value of "score" is greater or equals 10. |
| 1382 | 1392 | </li> |
| 1393 | + <li> | |
| 1394 | + <pre><code class="language-stscript">/if left={{lastMessage}} rule=in right=surprise {: /echo SURPISE! :}</code></pre> | |
| 1395 | + executes a subcommand defined as a closure if the given value contains a specified word. | |
| 1396 | + <li> | |
| 1397 | + <pre><code class="language-stscript">/if left=myContent {: /echo My content had some content. :}</code></pre> | |
| 1398 | + executes the defined subcommand, if the provided value of left is truthy (contains some kind of contant that is not empty or false) | |
| 1399 | + </li> | |
| 1400 | + <li> | |
| 1401 | + <pre><code class="language-stscript">/if left=tree right={{getvar::object}} {: /echo The object is a tree! :}</code></pre> | |
| 1402 | + executes the defined subcommand, if the left and right values are equals. | |
| 1403 | + </li> | |
| 1383 | 1404 | </ul> |
| 1384 | 1405 | </div> |
| 1385 | 1406 | `, |
| @@ -1395,32 +1416,38 @@ export function registerVariableCommands() { | ||
| 1395 | 1416 | typeList: [ARGUMENT_TYPE.VARIABLE_NAME, ARGUMENT_TYPE.STRING, ARGUMENT_TYPE.NUMBER], |
| 1396 | 1417 | isRequired: true, |
| 1397 | 1418 | enumProvider: commonEnumProviders.variables('all'), |
| 1398 | - forceEnum: false, | |
| 1399 | 1419 | }), |
| 1400 | 1420 | SlashCommandNamedArgument.fromProps({ |
| 1401 | 1421 | name: 'right', |
| 1402 | 1422 | description: 'right operand', |
| 1403 | 1423 | typeList: [ARGUMENT_TYPE.VARIABLE_NAME, ARGUMENT_TYPE.STRING, ARGUMENT_TYPE.NUMBER], |
| 1404 | - isRequired: true, | |
| 1405 | 1424 | enumProvider: commonEnumProviders.variables('all'), |
| 1406 | - forceEnum: false, | |
| 1407 | 1425 | }), |
| 1408 | 1426 | new SlashCommandNamedArgument.fromProps({ |
| 1409 | - 'rule', 'comparison rule', [ARGUMENT_TYPE.STRING], true, false, null, [ | |
| 1427 | + name: 'rule', | |
| 1410 | - new SlashCommandEnumValue('gt', 'a > b'), | |
| 1428 | + description: 'comparison rule', | |
| 1411 | - new SlashCommandEnumValue('gte', 'a >= b'), | |
| 1429 | + typeList: [ARGUMENT_TYPE.STRING], | |
| 1412 | - new SlashCommandEnumValue('lt', 'a < b'), | |
| 1430 | + defaultValue: 'eq', | |
| 1413 | - new SlashCommandEnumValue('lte', 'a <= b'), | |
| 1431 | + enumList: [ | |
| 1414 | 1432 | new SlashCommandEnumValue('eq', 'a == b (strings & numbers)'), |
| 1415 | 1433 | new SlashCommandEnumValue('neq', 'a !== b (strings & numbers)'), |
| 1416 | 1434 | new SlashCommandEnumValue('notin', '!a includes b (strings & numbers as strings)'), |
| 1417 | 1435 | new SlashCommandEnumValue('innin', 'a not includes b (strings & numbers as strings)'), |
| 1418 | 1436 | new SlashCommandEnumValue('ningt', 'a not includes> b (numbers)'), |
| 1419 | - ], | |
| 1437 | + new SlashCommandEnumValue('gte', 'a >= b (numbers)'), | |
| 1420 | - ), | |
| 1438 | + new SlashCommandEnumValue('lt', 'a < b (numbers)'), | |
| 1421 | - new SlashCommandNamedArgument( | |
| 1439 | + new SlashCommandEnumValue('lte', 'a <= b (numbers)'), | |
| 1422 | - 'guard', 'disable loop iteration limit', [ARGUMENT_TYPE.STRING], false, false, null, commonEnumProviders.boolean('onOff')(), | |
| 1440 | + new SlashCommandEnumValue('not', '!a (truthy)'), | |
| 1423 | - ), | |
| 1441 | + ], | |
| 1442 | + forceEnum: true, | |
| 1443 | + }), | |
| 1444 | + SlashCommandNamedArgument.fromProps({ | |
| 1445 | + name: 'guard', | |
| 1446 | + description: 'disable loop iteration limit', | |
| 1447 | + typeList: [ARGUMENT_TYPE.STRING], | |
| 1448 | + defaultValue: 'off', | |
| 1449 | + enumList: commonEnumProviders.boolean('onOff')(), | |
| 1450 | + }), | |
| 1424 | 1451 | ], |
| 1425 | 1452 | unnamedArgumentList: [ |
| 1426 | 1453 | new SlashCommandArgument( |
| @@ -1439,15 +1466,15 @@ export function registerVariableCommands() { | ||
| 1439 | 1466 | <div> |
| 1440 | 1467 | <strong>Available rules:</strong> |
| 1441 | 1468 | <ul> |
| 1442 | 1469 | <li>gt<code>eq</code> => a >== b <small>(strings & numbers)</small></li> |
| 1443 | 1470 | <li>gte<code>neq</code> => a >!== b <small>(strings & numbers)</small></li> |
| 1444 | 1471 | <li>lt<code>in</code> => a <includes b <small>(strings & numbers as strings)</small></li> |
| 1445 | - <li>lte => a <= b</li> | |
| 1472 | + <li><code>nin</code> => a not includes b <small>(strings & numbers as strings)</small></li> | |
| 1446 | 1473 | <li>eq<code>gt</code> => a ==> b <small>(numbers)</small></li> |
| 1447 | 1474 | <li>neq<code>gte</code> => a !>= b <small>(numbers)</small></li> |
| 1448 | 1475 | <li>not<code>lt</code> => !a < b <small>(numbers)</small></li> |
| 1449 | 1476 | <li>in (strings)<code>lte</code> => a includes<= b <small>(numbers)</small></li> |
| 1450 | - <li>nin (strings) => a not includes b</li> | |
| 1477 | + <li><code>not</code> => !a <small>(truthy)</small></li> | |
| 1451 | 1478 | </ul> |
| 1452 | 1479 | </div> |
| 1453 | 1480 | <div> |
| @@ -1457,7 +1484,11 @@ export function registerVariableCommands() { | ||
| 1457 | 1484 | <pre><code class="language-stscript">/setvar key=i 0 | /while left=i right=10 rule=lte "/addvar key=i 1"</code></pre> |
| 1458 | 1485 | adds 1 to the value of "i" until it reaches 10. |
| 1459 | 1486 | </li> |
| 1460 | - </ul> | |
| 1487 | + <li> | |
| 1488 | + <pre><code class="language-stscript">/while left={{getvar::currentword}} {: /setvar key=currentword {: /do-something-and-return :}() | /echo The current work is "{{getvar::currentword}}" :}</code></pre> | |
| 1489 | + executes the defined subcommand as long as the "currentword" variable is truthy (has any content that is not false/empty) | |
| 1490 | + </ul> | |
| 1491 | + </li> | |
| 1461 | 1492 | </div> |
| 1462 | 1493 | <div> |
| 1463 | 1494 | Loops are limited to 100 iterations by default, pass <code>guard=off</code> to disable. |
| @@ -1572,7 +1603,7 @@ export function registerVariableCommands() { | ||
| 1572 | 1603 | typeList: [ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.VARIABLE_NAME], |
| 1573 | 1604 | isRequired: true, |
| 1574 | 1605 | acceptsMultiple: true, |
| 1575 | 1606 | enumProvider: (executor, scope) => { |
| 1576 | 1607 | const vars = commonEnumProviders.variables('all')(executor, scope); |
| 1577 | 1608 | vars.push( |
| 1578 | 1609 | new SlashCommandEnumValue( |
| @@ -1580,16 +1611,16 @@ export function registerVariableCommands() { | ||
| 1580 | 1611 | null, |
| 1581 | 1612 | enumTypes.variable, |
| 1582 | 1613 | enumIcons.variable, |
| 1583 | 1614 | (input) => /^\w*$/.test(input), |
| 1584 | 1615 | (input) => input, |
| 1585 | 1616 | ), |
| 1586 | 1617 | new SlashCommandEnumValue( |
| 1587 | 1618 | 'any number', |
| 1588 | 1619 | null, |
| 1589 | 1620 | enumTypes.number, |
| 1590 | 1621 | enumIcons.number, |
| 1591 | 1622 | (input) => input == '' || !Number.isNaN(Number(input)), |
| 1592 | 1623 | (input) => input, |
| 1593 | 1624 | ), |
| 1594 | 1625 | ); |
| 1595 | 1626 | return vars; |