| 1 | /** |
| 2 | * GitHub Actions expression type coercion rules |
| 3 | * (https://docs.github.com/en/actions/learn-github-actions/expressions#operators). |
| 4 | */ |
| 5 | import { parseNumberLiteral } from "./lexer.js"; |
| 6 | |
| 7 | export type Kind = "null" | "boolean" | "number" | "string" | "array" | "object"; |
| 8 | |
| 9 | export function kindOf(v: unknown): Kind { |
| 10 | if (v === null || v === undefined) return "null"; |
| 11 | switch (typeof v) { |
| 12 | case "boolean": |
| 13 | return "boolean"; |
| 14 | case "number": |
| 15 | case "bigint": |
| 16 | return "number"; |
| 17 | case "string": |
| 18 | return "string"; |
| 19 | default: |
| 20 | return Array.isArray(v) ? "array" : "object"; |
| 21 | } |
| 22 | } |
| 23 | |
| 24 | export function isTruthy(v: unknown): boolean { |
| 25 | switch (kindOf(v)) { |
| 26 | case "null": |
| 27 | return false; |
| 28 | case "boolean": |
| 29 | return v as boolean; |
| 30 | case "number": { |
| 31 | const n = Number(v); |
| 32 | return n !== 0 && !Number.isNaN(n); |
| 33 | } |
| 34 | case "string": |
| 35 | return (v as string).length > 0; |
| 36 | default: |
| 37 | return true; |
| 38 | } |
| 39 | } |
| 40 | |
| 41 | const NUMERIC_STRING = /^[+-]?(?:0[xX][0-9a-fA-F]+|0[oO][0-7]+|(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?|Infinity)$/; |
| 42 | |
| 43 | /** null -> 0, true -> 1, false -> 0, '' -> 0, numeric strings parsed, everything else NaN. */ |
| 44 | export function toNumber(v: unknown): number { |
| 45 | switch (kindOf(v)) { |
| 46 | case "null": |
| 47 | return 0; |
| 48 | case "boolean": |
| 49 | return v ? 1 : 0; |
| 50 | case "number": |
| 51 | return Number(v); |
| 52 | case "string": { |
| 53 | const s = (v as string).trim(); |
| 54 | if (s === "") return 0; |
| 55 | if (!NUMERIC_STRING.test(s)) return NaN; |
| 56 | return parseNumberLiteral(s); |
| 57 | } |
| 58 | default: |
| 59 | return NaN; |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | /** Formats a number the way GitHub prints it (integers without decimals, up to 15 significant digits). */ |
| 64 | export function formatNumber(n: number): string { |
| 65 | if (Number.isNaN(n)) return "NaN"; |
| 66 | if (n === Infinity) return "Infinity"; |
| 67 | if (n === -Infinity) return "-Infinity"; |
| 68 | if (Object.is(n, -0)) return "0"; |
| 69 | if (Number.isInteger(n) && Math.abs(n) < 1e21) return n.toFixed(0); |
| 70 | return String(parseFloat(n.toPrecision(15))); |
| 71 | } |
| 72 | |
| 73 | /** String coercion used by functions (contains, format, join...). Arrays/objects become 'Array'/'Object'. */ |
| 74 | export function toStr(v: unknown): string { |
| 75 | switch (kindOf(v)) { |
| 76 | case "null": |
| 77 | return ""; |
| 78 | case "boolean": |
| 79 | return v ? "true" : "false"; |
| 80 | case "number": |
| 81 | return formatNumber(Number(v)); |
| 82 | case "string": |
| 83 | return v as string; |
| 84 | case "array": |
| 85 | return "Array"; |
| 86 | default: |
| 87 | return "Object"; |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | /** String coercion used when interpolating `${{ }}` into a template: objects/arrays become JSON. */ |
| 92 | export function toTemplateString(v: unknown): string { |
| 93 | const k = kindOf(v); |
| 94 | if (k === "array" || k === "object") return toJSONString(v); |
| 95 | return toStr(v); |
| 96 | } |
| 97 | |
| 98 | export function toJSONString(v: unknown): string { |
| 99 | return JSON.stringify(v === undefined ? null : v, (_k, val: unknown) => (typeof val === "bigint" ? Number(val) : val), 2) ?? "null"; |
| 100 | } |
| 101 | |
| 102 | const fold = (s: string) => s.toUpperCase(); |
| 103 | |
| 104 | /** GitHub loose equality (`==`). */ |
| 105 | export function looseEquals(a: unknown, b: unknown): boolean { |
| 106 | const ka = kindOf(a); |
| 107 | const kb = kindOf(b); |
| 108 | if (ka !== kb) { |
| 109 | const na = toNumber(a); |
| 110 | const nb = toNumber(b); |
| 111 | return na === nb; |
| 112 | } |
| 113 | switch (ka) { |
| 114 | case "null": |
| 115 | return true; |
| 116 | case "number": |
| 117 | return Number(a) === Number(b); |
| 118 | case "string": |
| 119 | return fold(a as string) === fold(b as string); |
| 120 | case "boolean": |
| 121 | return a === b; |
| 122 | default: |
| 123 | return a === b; |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | /** Returns <0, 0, >0, or NaN when the values are not comparable. */ |
| 128 | export function looseCompare(a: unknown, b: unknown): number { |
| 129 | const ka = kindOf(a); |
| 130 | const kb = kindOf(b); |
| 131 | if (ka !== kb || ka === "null" || ka === "boolean" || ka === "number") { |
| 132 | const na = toNumber(a); |
| 133 | const nb = toNumber(b); |
| 134 | if (Number.isNaN(na) || Number.isNaN(nb)) return NaN; |
| 135 | return na === nb ? 0 : na < nb ? -1 : 1; |
| 136 | } |
| 137 | if (ka === "string") { |
| 138 | const sa = fold(a as string); |
| 139 | const sb = fold(b as string); |
| 140 | return sa === sb ? 0 : sa < sb ? -1 : 1; |
| 141 | } |
| 142 | return NaN; |
| 143 | } |