deno/std/fmt/printf.ts

688 lines
19 KiB
TypeScript

// Copyright 2018-2020 the Deno authors. All rights reserved. MIT license.
//
// This implementation is inspired by POSIX and Golang but does not port
// implementation code.
enum State {
PASSTHROUGH,
PERCENT,
POSITIONAL,
PRECISION,
WIDTH,
}
enum WorP {
WIDTH,
PRECISION,
}
class Flags {
plus?: boolean;
dash?: boolean;
sharp?: boolean;
space?: boolean;
zero?: boolean;
lessthan?: boolean;
width = -1;
precision = -1;
}
const min = Math.min;
const UNICODE_REPLACEMENT_CHARACTER = "\ufffd";
const DEFAULT_PRECISION = 6;
const FLOAT_REGEXP = /(-?)(\d)\.?(\d*)e([+-])(\d+)/;
enum F {
sign = 1,
mantissa,
fractional,
esign,
exponent,
}
class Printf {
format: string;
args: unknown[];
i: number;
state: State = State.PASSTHROUGH;
verb = "";
buf = "";
argNum = 0;
flags: Flags = new Flags();
haveSeen: boolean[];
// barf, store precision and width errors for later processing ...
tmpError?: string;
constructor(format: string, ...args: unknown[]) {
this.format = format;
this.args = args;
this.haveSeen = new Array(args.length);
this.i = 0;
}
doPrintf(): string {
for (; this.i < this.format.length; ++this.i) {
const c = this.format[this.i];
switch (this.state) {
case State.PASSTHROUGH:
if (c === "%") {
this.state = State.PERCENT;
} else {
this.buf += c;
}
break;
case State.PERCENT:
if (c === "%") {
this.buf += c;
this.state = State.PASSTHROUGH;
} else {
this.handleFormat();
}
break;
default:
throw Error("Should be unreachable, certainly a bug in the lib.");
}
}
// check for unhandled args
let extras = false;
let err = "%!(EXTRA";
for (let i = 0; i !== this.haveSeen.length; ++i) {
if (!this.haveSeen[i]) {
extras = true;
err += ` '${Deno.inspect(this.args[i])}'`;
}
}
err += ")";
if (extras) {
this.buf += err;
}
return this.buf;
}
// %[<positional>]<flag>...<verb>
handleFormat(): void {
this.flags = new Flags();
const flags = this.flags;
for (; this.i < this.format.length; ++this.i) {
const c = this.format[this.i];
switch (this.state) {
case State.PERCENT:
switch (c) {
case "[":
this.handlePositional();
this.state = State.POSITIONAL;
break;
case "+":
flags.plus = true;
break;
case "<":
flags.lessthan = true;
break;
case "-":
flags.dash = true;
flags.zero = false; // only left pad zeros, dash takes precedence
break;
case "#":
flags.sharp = true;
break;
case " ":
flags.space = true;
break;
case "0":
// only left pad zeros, dash takes precedence
flags.zero = !flags.dash;
break;
default:
if (("1" <= c && c <= "9") || c === "." || c === "*") {
if (c === ".") {
this.flags.precision = 0;
this.state = State.PRECISION;
this.i++;
} else {
this.state = State.WIDTH;
}
this.handleWidthAndPrecision(flags);
} else {
this.handleVerb();
return; // always end in verb
}
} // switch c
break;
case State.POSITIONAL: // either a verb or * only verb for now, TODO
if (c === "*") {
const worp = this.flags.precision === -1
? WorP.WIDTH
: WorP.PRECISION;
this.handleWidthOrPrecisionRef(worp);
this.state = State.PERCENT;
break;
} else {
this.handleVerb();
return; // always end in verb
}
default:
throw new Error(`Should not be here ${this.state}, library bug!`);
} // switch state
}
}
handleWidthOrPrecisionRef(wOrP: WorP): void {
if (this.argNum >= this.args.length) {
// handle Positional should have already taken care of it...
return;
}
const arg = this.args[this.argNum];
this.haveSeen[this.argNum] = true;
if (typeof arg === "number") {
switch (wOrP) {
case WorP.WIDTH:
this.flags.width = arg;
break;
default:
this.flags.precision = arg;
}
} else {
const tmp = wOrP === WorP.WIDTH ? "WIDTH" : "PREC";
this.tmpError = `%!(BAD ${tmp} '${this.args[this.argNum]}')`;
}
this.argNum++;
}
handleWidthAndPrecision(flags: Flags): void {
const fmt = this.format;
for (; this.i !== this.format.length; ++this.i) {
const c = fmt[this.i];
switch (this.state) {
case State.WIDTH:
switch (c) {
case ".":
// initialize precision, %9.f -> precision=0
this.flags.precision = 0;
this.state = State.PRECISION;
break;
case "*":
this.handleWidthOrPrecisionRef(WorP.WIDTH);
// force . or flag at this point
break;
default:
const val = parseInt(c);
// most likely parseInt does something stupid that makes
// it unusable for this scenario ...
// if we encounter a non (number|*|.) we're done with prec & wid
if (isNaN(val)) {
this.i--;
this.state = State.PERCENT;
return;
}
flags.width = flags.width == -1 ? 0 : flags.width;
flags.width *= 10;
flags.width += val;
} // switch c
break;
case State.PRECISION: {
if (c === "*") {
this.handleWidthOrPrecisionRef(WorP.PRECISION);
break;
}
const val = parseInt(c);
if (isNaN(val)) {
// one too far, rewind
this.i--;
this.state = State.PERCENT;
return;
}
flags.precision *= 10;
flags.precision += val;
break;
}
default:
throw new Error("can't be here. bug.");
} // switch state
}
}
handlePositional(): void {
if (this.format[this.i] !== "[") {
// sanity only
throw new Error("Can't happen? Bug.");
}
let positional = 0;
const format = this.format;
this.i++;
let err = false;
for (; this.i !== this.format.length; ++this.i) {
if (format[this.i] === "]") {
break;
}
positional *= 10;
const val = parseInt(format[this.i]);
if (isNaN(val)) {
//throw new Error(
// `invalid character in positional: ${format}[${format[this.i]}]`
//);
this.tmpError = "%!(BAD INDEX)";
err = true;
}
positional += val;
}
if (positional - 1 >= this.args.length) {
this.tmpError = "%!(BAD INDEX)";
err = true;
}
this.argNum = err ? this.argNum : positional - 1;
return;
}
handleLessThan(): string {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const arg = this.args[this.argNum] as any;
if ((arg || {}).constructor.name !== "Array") {
throw new Error(`arg ${arg} is not an array. Todo better error handling`);
}
let str = "[ ";
for (let i = 0; i !== arg.length; ++i) {
if (i !== 0) str += ", ";
str += this._handleVerb(arg[i]);
}
return str + " ]";
}
handleVerb(): void {
const verb = this.format[this.i];
this.verb = verb;
if (this.tmpError) {
this.buf += this.tmpError;
this.tmpError = undefined;
if (this.argNum < this.haveSeen.length) {
this.haveSeen[this.argNum] = true; // keep track of used args
}
} else if (this.args.length <= this.argNum) {
this.buf += `%!(MISSING '${verb}')`;
} else {
const arg = this.args[this.argNum]; // check out of range
this.haveSeen[this.argNum] = true; // keep track of used args
if (this.flags.lessthan) {
this.buf += this.handleLessThan();
} else {
this.buf += this._handleVerb(arg);
}
}
this.argNum++; // if there is a further positional, it will reset.
this.state = State.PASSTHROUGH;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
_handleVerb(arg: any): string {
switch (this.verb) {
case "t":
return this.pad(arg.toString());
break;
case "b":
return this.fmtNumber(arg as number, 2);
break;
case "c":
return this.fmtNumberCodePoint(arg as number);
break;
case "d":
return this.fmtNumber(arg as number, 10);
break;
case "o":
return this.fmtNumber(arg as number, 8);
break;
case "x":
return this.fmtHex(arg);
break;
case "X":
return this.fmtHex(arg, true);
break;
case "e":
return this.fmtFloatE(arg as number);
break;
case "E":
return this.fmtFloatE(arg as number, true);
break;
case "f":
case "F":
return this.fmtFloatF(arg as number);
break;
case "g":
return this.fmtFloatG(arg as number);
break;
case "G":
return this.fmtFloatG(arg as number, true);
break;
case "s":
return this.fmtString(arg as string);
break;
case "T":
return this.fmtString(typeof arg);
break;
case "v":
return this.fmtV(arg);
break;
case "j":
return this.fmtJ(arg);
break;
default:
return `%!(BAD VERB '${this.verb}')`;
}
}
pad(s: string): string {
const padding = this.flags.zero ? "0" : " ";
if (this.flags.dash) {
return s.padEnd(this.flags.width, padding);
}
return s.padStart(this.flags.width, padding);
}
padNum(nStr: string, neg: boolean): string {
let sign: string;
if (neg) {
sign = "-";
} else if (this.flags.plus || this.flags.space) {
sign = this.flags.plus ? "+" : " ";
} else {
sign = "";
}
const zero = this.flags.zero;
if (!zero) {
// sign comes in front of padding when padding w/ zero,
// in from of value if padding with spaces.
nStr = sign + nStr;
}
const pad = zero ? "0" : " ";
const len = zero ? this.flags.width - sign.length : this.flags.width;
if (this.flags.dash) {
nStr = nStr.padEnd(len, pad);
} else {
nStr = nStr.padStart(len, pad);
}
if (zero) {
// see above
nStr = sign + nStr;
}
return nStr;
}
fmtNumber(n: number, radix: number, upcase = false): string {
let num = Math.abs(n).toString(radix);
const prec = this.flags.precision;
if (prec !== -1) {
this.flags.zero = false;
num = n === 0 && prec === 0 ? "" : num;
while (num.length < prec) {
num = "0" + num;
}
}
let prefix = "";
if (this.flags.sharp) {
switch (radix) {
case 2:
prefix += "0b";
break;
case 8:
// don't annotate octal 0 with 0...
prefix += num.startsWith("0") ? "" : "0";
break;
case 16:
prefix += "0x";
break;
default:
throw new Error("cannot handle base: " + radix);
}
}
// don't add prefix in front of value truncated by precision=0, val=0
num = num.length === 0 ? num : prefix + num;
if (upcase) {
num = num.toUpperCase();
}
return this.padNum(num, n < 0);
}
fmtNumberCodePoint(n: number): string {
let s = "";
try {
s = String.fromCodePoint(n);
} catch (RangeError) {
s = UNICODE_REPLACEMENT_CHARACTER;
}
return this.pad(s);
}
fmtFloatSpecial(n: number): string {
// formatting of NaN and Inf are pants-on-head
// stupid and more or less arbitrary.
if (isNaN(n)) {
this.flags.zero = false;
return this.padNum("NaN", false);
}
if (n === Number.POSITIVE_INFINITY) {
this.flags.zero = false;
this.flags.plus = true;
return this.padNum("Inf", false);
}
if (n === Number.NEGATIVE_INFINITY) {
this.flags.zero = false;
return this.padNum("Inf", true);
}
return "";
}
roundFractionToPrecision(fractional: string, precision: number): string {
if (fractional.length > precision) {
fractional = "1" + fractional; // prepend a 1 in case of leading 0
let tmp = parseInt(fractional.substr(0, precision + 2)) / 10;
tmp = Math.round(tmp);
fractional = Math.floor(tmp).toString();
fractional = fractional.substr(1); // remove extra 1
} else {
while (fractional.length < precision) {
fractional += "0";
}
}
return fractional;
}
fmtFloatE(n: number, upcase = false): string {
const special = this.fmtFloatSpecial(n);
if (special !== "") {
return special;
}
const m = n.toExponential().match(FLOAT_REGEXP);
if (!m) {
throw Error("can't happen, bug");
}
let fractional = m[F.fractional];
const precision = this.flags.precision !== -1
? this.flags.precision
: DEFAULT_PRECISION;
fractional = this.roundFractionToPrecision(fractional, precision);
let e = m[F.exponent];
// scientific notation output with exponent padded to minlen 2
e = e.length == 1 ? "0" + e : e;
const val = `${m[F.mantissa]}.${fractional}${upcase ? "E" : "e"}${
m[F.esign]
}${e}`;
return this.padNum(val, n < 0);
}
fmtFloatF(n: number): string {
const special = this.fmtFloatSpecial(n);
if (special !== "") {
return special;
}
// stupid helper that turns a number into a (potentially)
// VERY long string.
function expandNumber(n: number): string {
if (Number.isSafeInteger(n)) {
return n.toString() + ".";
}
const t = n.toExponential().split("e");
let m = t[0].replace(".", "");
const e = parseInt(t[1]);
if (e < 0) {
let nStr = "0.";
for (let i = 0; i !== Math.abs(e) - 1; ++i) {
nStr += "0";
}
return (nStr += m);
} else {
const splIdx = e + 1;
while (m.length < splIdx) {
m += "0";
}
return m.substr(0, splIdx) + "." + m.substr(splIdx);
}
}
// avoiding sign makes padding easier
const val = expandNumber(Math.abs(n)) as string;
const arr = val.split(".");
const dig = arr[0];
let fractional = arr[1];
const precision = this.flags.precision !== -1
? this.flags.precision
: DEFAULT_PRECISION;
fractional = this.roundFractionToPrecision(fractional, precision);
return this.padNum(`${dig}.${fractional}`, n < 0);
}
fmtFloatG(n: number, upcase = false): string {
const special = this.fmtFloatSpecial(n);
if (special !== "") {
return special;
}
// The double argument representing a floating-point number shall be
// converted in the style f or e (or in the style F or E in
// the case of a G conversion specifier), depending on the
// value converted and the precision. Let P equal the
// precision if non-zero, 6 if the precision is omitted, or 1
// if the precision is zero. Then, if a conversion with style E would
// have an exponent of X:
// - If P > X>=-4, the conversion shall be with style f (or F )
// and precision P -( X+1).
// - Otherwise, the conversion shall be with style e (or E )
// and precision P -1.
// Finally, unless the '#' flag is used, any trailing zeros shall be
// removed from the fractional portion of the result and the
// decimal-point character shall be removed if there is no
// fractional portion remaining.
// A double argument representing an infinity or NaN shall be
// converted in the style of an f or F conversion specifier.
// https://pubs.opengroup.org/onlinepubs/9699919799/functions/fprintf.html
let P = this.flags.precision !== -1
? this.flags.precision
: DEFAULT_PRECISION;
P = P === 0 ? 1 : P;
const m = n.toExponential().match(FLOAT_REGEXP);
if (!m) {
throw Error("can't happen");
}
const X = parseInt(m[F.exponent]) * (m[F.esign] === "-" ? -1 : 1);
let nStr = "";
if (P > X && X >= -4) {
this.flags.precision = P - (X + 1);
nStr = this.fmtFloatF(n);
if (!this.flags.sharp) {
nStr = nStr.replace(/\.?0*$/, "");
}
} else {
this.flags.precision = P - 1;
nStr = this.fmtFloatE(n);
if (!this.flags.sharp) {
nStr = nStr.replace(/\.?0*e/, upcase ? "E" : "e");
}
}
return nStr;
}
fmtString(s: string): string {
if (this.flags.precision !== -1) {
s = s.substr(0, this.flags.precision);
}
return this.pad(s);
}
fmtHex(val: string | number, upper = false): string {
// allow others types ?
switch (typeof val) {
case "number":
return this.fmtNumber(val as number, 16, upper);
case "string": {
const sharp = this.flags.sharp && val.length !== 0;
let hex = sharp ? "0x" : "";
const prec = this.flags.precision;
const end = prec !== -1 ? min(prec, val.length) : val.length;
for (let i = 0; i !== end; ++i) {
if (i !== 0 && this.flags.space) {
hex += sharp ? " 0x" : " ";
}
// TODO: for now only taking into account the
// lower half of the codePoint, ie. as if a string
// is a list of 8bit values instead of UCS2 runes
const c = (val.charCodeAt(i) & 0xff).toString(16);
hex += c.length === 1 ? `0${c}` : c;
}
if (upper) {
hex = hex.toUpperCase();
}
return this.pad(hex);
}
default:
throw new Error(
"currently only number and string are implemented for hex",
);
}
}
fmtV(val: Record<string, unknown>): string {
if (this.flags.sharp) {
const options = this.flags.precision !== -1
? { depth: this.flags.precision }
: {};
return this.pad(Deno.inspect(val, options));
} else {
const p = this.flags.precision;
return p === -1 ? val.toString() : val.toString().substr(0, p);
}
}
fmtJ(val: unknown): string {
return JSON.stringify(val);
}
}
export function sprintf(format: string, ...args: unknown[]): string {
const printf = new Printf(format, ...args);
return printf.doPrintf();
}
export function printf(format: string, ...args: unknown[]): void {
const s = sprintf(format, ...args);
Deno.stdout.writeSync(new TextEncoder().encode(s));
}