355 lines
10 KiB
C
355 lines
10 KiB
C
|
|
/**
|
||
|
|
* @file format.c
|
||
|
|
* @brief Implements `PRINT USING` field formatting.
|
||
|
|
*
|
||
|
|
* One field per call, with whatever literal text surrounds it copied through.
|
||
|
|
* The work splits three ways: find the field, measure it, then fill it.
|
||
|
|
*/
|
||
|
|
|
||
|
|
#include <inttypes.h>
|
||
|
|
#include <math.h>
|
||
|
|
#include <stdio.h>
|
||
|
|
#include <string.h>
|
||
|
|
|
||
|
|
#include <akerror.h>
|
||
|
|
|
||
|
|
#include <akbasic/error.h>
|
||
|
|
#include <akbasic/format.h>
|
||
|
|
|
||
|
|
/** @brief The PUDEF defaults, in PUDEF's own order. */
|
||
|
|
static const char PUDEF_DEFAULTS[AKBASIC_PUDEF_CHARS] = { ' ', ',', '.', '$' };
|
||
|
|
|
||
|
|
/** @brief True for a character that can only appear inside a numeric field. */
|
||
|
|
static bool is_numeric_field_char(char c)
|
||
|
|
{
|
||
|
|
return (c == '#' || c == ',' || c == '.');
|
||
|
|
}
|
||
|
|
|
||
|
|
/** @brief True for a character that can only appear inside a string field. */
|
||
|
|
static bool is_string_field_char(char c)
|
||
|
|
{
|
||
|
|
return (c == '=' || c == '>');
|
||
|
|
}
|
||
|
|
|
||
|
|
akerr_ErrorContext *akbasic_format_state_init(akbasic_FormatState *obj)
|
||
|
|
{
|
||
|
|
PREPARE_ERROR(errctx);
|
||
|
|
|
||
|
|
FAIL_ZERO_RETURN(errctx, (obj != NULL), AKERR_NULLPOINTER, "NULL format state in init");
|
||
|
|
memcpy(obj->chars, PUDEF_DEFAULTS, sizeof(obj->chars));
|
||
|
|
SUCCEED_RETURN(errctx);
|
||
|
|
}
|
||
|
|
|
||
|
|
akerr_ErrorContext *akbasic_format_pudef(akbasic_FormatState *obj, const char *chars)
|
||
|
|
{
|
||
|
|
PREPARE_ERROR(errctx);
|
||
|
|
int i = 0;
|
||
|
|
|
||
|
|
FAIL_ZERO_RETURN(errctx, (obj != NULL && chars != NULL), AKERR_NULLPOINTER,
|
||
|
|
"NULL argument in pudef");
|
||
|
|
/*
|
||
|
|
* Only as many as were given. `PUDEF "*"` redefines the leading blank and
|
||
|
|
* leaves the comma, the point and the dollar alone, which is BASIC 7.0's
|
||
|
|
* rule and is what makes the one-character form useful.
|
||
|
|
*/
|
||
|
|
for ( i = 0; i < AKBASIC_PUDEF_CHARS && chars[i] != '\0'; i++ ) {
|
||
|
|
obj->chars[i] = chars[i];
|
||
|
|
}
|
||
|
|
SUCCEED_RETURN(errctx);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @brief Locate the first field in a format string.
|
||
|
|
*
|
||
|
|
* @param format The format string.
|
||
|
|
* @param start Output: index of the field's first character.
|
||
|
|
* @param length Output: how many characters the field spans.
|
||
|
|
* @param numeric Output: true for a numeric field, false for a string field.
|
||
|
|
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||
|
|
* @throws AKBASIC_ERR_SYNTAX When there is no field.
|
||
|
|
*/
|
||
|
|
static akerr_ErrorContext *find_field(const char *format, size_t *start, size_t *length, bool *numeric)
|
||
|
|
{
|
||
|
|
PREPARE_ERROR(errctx);
|
||
|
|
size_t i = 0;
|
||
|
|
|
||
|
|
for ( i = 0; format[i] != '\0'; i++ ) {
|
||
|
|
if ( format[i] == '#' || format[i] == '.' ) {
|
||
|
|
*numeric = true;
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
if ( is_string_field_char(format[i]) ) {
|
||
|
|
*numeric = false;
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
FAIL_ZERO_RETURN(errctx, (format[i] != '\0'), AKBASIC_ERR_SYNTAX,
|
||
|
|
"PRINT USING format \"%s\" contains no field", format);
|
||
|
|
|
||
|
|
/*
|
||
|
|
* A `$`, `+` or `-` immediately before the field belongs to it. Scanning
|
||
|
|
* backwards is how a leading sign or currency sign is told apart from
|
||
|
|
* literal text: `"COST $###"` has a field of `$###`, and `"A + B ###"` does
|
||
|
|
* not, because the `+` is not adjacent.
|
||
|
|
*/
|
||
|
|
*start = i;
|
||
|
|
while ( *start > 0 && (format[*start - 1] == '$' || format[*start - 1] == '+' ||
|
||
|
|
format[*start - 1] == '-') ) {
|
||
|
|
*start -= 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
for ( ; format[i] != '\0'; i++ ) {
|
||
|
|
if ( *numeric && is_numeric_field_char(format[i]) ) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
if ( !(*numeric) && is_string_field_char(format[i]) ) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
/* A trailing sign belongs to the field too. */
|
||
|
|
if ( format[i] == '+' || format[i] == '-' ) {
|
||
|
|
i += 1;
|
||
|
|
}
|
||
|
|
*length = i - *start;
|
||
|
|
SUCCEED_RETURN(errctx);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** @brief Measure a numeric field: digits before and after the point, and its decorations. */
|
||
|
|
static void measure_numeric(const char *field, size_t length, int *before, int *after,
|
||
|
|
bool *commas, bool *dollar, bool *leadsign, bool *trailsign)
|
||
|
|
{
|
||
|
|
size_t i = 0;
|
||
|
|
bool seenpoint = false;
|
||
|
|
|
||
|
|
*before = 0;
|
||
|
|
*after = 0;
|
||
|
|
*commas = false;
|
||
|
|
*dollar = false;
|
||
|
|
*leadsign = false;
|
||
|
|
*trailsign = false;
|
||
|
|
|
||
|
|
for ( i = 0; i < length; i++ ) {
|
||
|
|
switch ( field[i] ) {
|
||
|
|
case '#':
|
||
|
|
if ( seenpoint ) {
|
||
|
|
*after += 1;
|
||
|
|
} else {
|
||
|
|
*before += 1;
|
||
|
|
}
|
||
|
|
break;
|
||
|
|
case '.':
|
||
|
|
seenpoint = true;
|
||
|
|
break;
|
||
|
|
case ',':
|
||
|
|
*commas = true;
|
||
|
|
break;
|
||
|
|
case '$':
|
||
|
|
*dollar = true;
|
||
|
|
break;
|
||
|
|
case '+':
|
||
|
|
case '-':
|
||
|
|
if ( i == 0 ) {
|
||
|
|
*leadsign = true;
|
||
|
|
} else {
|
||
|
|
*trailsign = true;
|
||
|
|
}
|
||
|
|
break;
|
||
|
|
default:
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** @brief Fill @p dest with @p width copies of `*`, the overflow marker. */
|
||
|
|
static void overflow(char *dest, size_t width)
|
||
|
|
{
|
||
|
|
memset(dest, '*', width);
|
||
|
|
dest[width] = '\0';
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @brief Render a number into a measured numeric field.
|
||
|
|
*
|
||
|
|
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||
|
|
*/
|
||
|
|
static akerr_ErrorContext *render_numeric(akbasic_FormatState *obj, const char *field, size_t length,
|
||
|
|
double number, char *dest, size_t len)
|
||
|
|
{
|
||
|
|
PREPARE_ERROR(errctx);
|
||
|
|
char digits[AKBASIC_MAX_STRING_LENGTH];
|
||
|
|
char grouped[AKBASIC_MAX_STRING_LENGTH];
|
||
|
|
char *point = NULL;
|
||
|
|
size_t used = 0;
|
||
|
|
size_t i = 0;
|
||
|
|
size_t intlen = 0;
|
||
|
|
int before = 0;
|
||
|
|
int after = 0;
|
||
|
|
int group = 0;
|
||
|
|
bool commas = false;
|
||
|
|
bool dollar = false;
|
||
|
|
bool leadsign = false;
|
||
|
|
bool trailsign = false;
|
||
|
|
bool negative = (number < 0.0);
|
||
|
|
|
||
|
|
measure_numeric(field, length, &before, &after, &commas, &dollar, &leadsign, &trailsign);
|
||
|
|
FAIL_ZERO_RETURN(errctx, (length + 1 <= len), AKBASIC_ERR_BOUNDS,
|
||
|
|
"PRINT USING field of %zu characters does not fit", length);
|
||
|
|
|
||
|
|
snprintf(digits, sizeof(digits), "%.*f", after, (negative ? -number : number));
|
||
|
|
|
||
|
|
point = strchr(digits, '.');
|
||
|
|
intlen = (point != NULL ? (size_t)(point - digits) : strlen(digits));
|
||
|
|
|
||
|
|
/* Group the integer part, if the field asked for separators. */
|
||
|
|
used = 0;
|
||
|
|
if ( commas ) {
|
||
|
|
for ( i = 0; i < intlen; i++ ) {
|
||
|
|
if ( i > 0 && ((intlen - i) % 3) == 0 ) {
|
||
|
|
grouped[used] = obj->chars[AKBASIC_PUDEF_COMMA];
|
||
|
|
used += 1;
|
||
|
|
}
|
||
|
|
grouped[used] = digits[i];
|
||
|
|
used += 1;
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
memcpy(grouped, digits, intlen);
|
||
|
|
used = intlen;
|
||
|
|
}
|
||
|
|
grouped[used] = '\0';
|
||
|
|
group = (int)used;
|
||
|
|
|
||
|
|
/*
|
||
|
|
* Does it fit? The field's `#` positions before the point are the budget,
|
||
|
|
* and a separator or a sign that the field asked for costs nothing extra
|
||
|
|
* because it was written into the field. Overflow fills with `*` rather than
|
||
|
|
* printing wider than asked, which would misalign every later column.
|
||
|
|
*/
|
||
|
|
if ( (int)intlen > before ) {
|
||
|
|
overflow(dest, length);
|
||
|
|
SUCCEED_RETURN(errctx);
|
||
|
|
}
|
||
|
|
|
||
|
|
used = 0;
|
||
|
|
if ( leadsign ) {
|
||
|
|
dest[used] = (negative ? '-' : '+');
|
||
|
|
used += 1;
|
||
|
|
}
|
||
|
|
if ( dollar ) {
|
||
|
|
dest[used] = obj->chars[AKBASIC_PUDEF_DOLLAR];
|
||
|
|
used += 1;
|
||
|
|
}
|
||
|
|
/*
|
||
|
|
* Right-justify inside the digit positions, padding with the PUDEF blank.
|
||
|
|
* The width to pad to counts the separators the field will actually use, so
|
||
|
|
* `###,###` holding 1234 pads to the same column every time.
|
||
|
|
*/
|
||
|
|
{
|
||
|
|
int width = before + (commas ? (before - 1) / 3 : 0);
|
||
|
|
|
||
|
|
for ( i = (size_t)group; (int)i < width; i++ ) {
|
||
|
|
dest[used] = obj->chars[AKBASIC_PUDEF_BLANK];
|
||
|
|
used += 1;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
memcpy(dest + used, grouped, (size_t)group);
|
||
|
|
used += (size_t)group;
|
||
|
|
|
||
|
|
if ( after > 0 ) {
|
||
|
|
dest[used] = obj->chars[AKBASIC_PUDEF_POINT];
|
||
|
|
used += 1;
|
||
|
|
memcpy(dest + used, (point != NULL ? point + 1 : ""), (size_t)after);
|
||
|
|
used += (size_t)after;
|
||
|
|
}
|
||
|
|
if ( trailsign ) {
|
||
|
|
dest[used] = (negative ? '-' : '+');
|
||
|
|
used += 1;
|
||
|
|
}
|
||
|
|
/*
|
||
|
|
* A negative number in a field with no sign position still has to say so.
|
||
|
|
* BASIC 7.0 overflows the field in that case rather than dropping the minus,
|
||
|
|
* because a printed -5 that reads as 5 is worse than a row of stars.
|
||
|
|
*/
|
||
|
|
if ( negative && !leadsign && !trailsign ) {
|
||
|
|
overflow(dest, length);
|
||
|
|
SUCCEED_RETURN(errctx);
|
||
|
|
}
|
||
|
|
dest[used] = '\0';
|
||
|
|
SUCCEED_RETURN(errctx);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** @brief Render a string into a measured string field: `=` centres, `>` right-justifies. */
|
||
|
|
static akerr_ErrorContext *render_string(const char *field, size_t length, const char *text,
|
||
|
|
char *dest, size_t len)
|
||
|
|
{
|
||
|
|
PREPARE_ERROR(errctx);
|
||
|
|
size_t textlen = strlen(text);
|
||
|
|
size_t pad = 0;
|
||
|
|
|
||
|
|
FAIL_ZERO_RETURN(errctx, (length + 1 <= len), AKBASIC_ERR_BOUNDS,
|
||
|
|
"PRINT USING field of %zu characters does not fit", length);
|
||
|
|
if ( textlen > length ) {
|
||
|
|
/* Truncated, not starred: BASIC 7.0 cuts a string to its field. */
|
||
|
|
memcpy(dest, text, length);
|
||
|
|
dest[length] = '\0';
|
||
|
|
SUCCEED_RETURN(errctx);
|
||
|
|
}
|
||
|
|
|
||
|
|
memset(dest, ' ', length);
|
||
|
|
dest[length] = '\0';
|
||
|
|
if ( field[0] == '=' ) {
|
||
|
|
pad = (length - textlen) / 2;
|
||
|
|
} else {
|
||
|
|
pad = length - textlen;
|
||
|
|
}
|
||
|
|
memcpy(dest + pad, text, textlen);
|
||
|
|
SUCCEED_RETURN(errctx);
|
||
|
|
}
|
||
|
|
|
||
|
|
akerr_ErrorContext *akbasic_format_using(akbasic_FormatState *obj, const char *format, akbasic_Value *value, char *dest, size_t len)
|
||
|
|
{
|
||
|
|
PREPARE_ERROR(errctx);
|
||
|
|
char rendered[AKBASIC_MAX_STRING_LENGTH];
|
||
|
|
size_t start = 0;
|
||
|
|
size_t length = 0;
|
||
|
|
size_t used = 0;
|
||
|
|
bool numeric = false;
|
||
|
|
|
||
|
|
FAIL_ZERO_RETURN(errctx, (obj != NULL && format != NULL && value != NULL && dest != NULL),
|
||
|
|
AKERR_NULLPOINTER, "NULL argument in format_using");
|
||
|
|
FAIL_ZERO_RETURN(errctx, (len > 0), AKBASIC_ERR_BOUNDS, "Zero-length destination");
|
||
|
|
|
||
|
|
PASS(errctx, find_field(format, &start, &length, &numeric));
|
||
|
|
|
||
|
|
if ( numeric ) {
|
||
|
|
double number = 0.0;
|
||
|
|
|
||
|
|
FAIL_NONZERO_RETURN(errctx, (value->valuetype == AKBASIC_TYPE_STRING), AKBASIC_ERR_TYPE,
|
||
|
|
"PRINT USING: a numeric field cannot print a string");
|
||
|
|
if ( value->valuetype == AKBASIC_TYPE_FLOAT ) {
|
||
|
|
number = value->floatval;
|
||
|
|
} else if ( value->valuetype == AKBASIC_TYPE_BOOLEAN ) {
|
||
|
|
number = (double)value->boolvalue;
|
||
|
|
} else {
|
||
|
|
number = (double)value->intval;
|
||
|
|
}
|
||
|
|
PASS(errctx, render_numeric(obj, format + start, length, number,
|
||
|
|
rendered, sizeof(rendered)));
|
||
|
|
} else {
|
||
|
|
FAIL_NONZERO_RETURN(errctx, (value->valuetype != AKBASIC_TYPE_STRING), AKBASIC_ERR_TYPE,
|
||
|
|
"PRINT USING: a string field cannot print a number");
|
||
|
|
PASS(errctx, render_string(format + start, length, value->stringval,
|
||
|
|
rendered, sizeof(rendered)));
|
||
|
|
}
|
||
|
|
|
||
|
|
/* Literal text before the field, the field, then literal text after it. */
|
||
|
|
used = strlen(rendered) + strlen(format) - length;
|
||
|
|
FAIL_ZERO_RETURN(errctx, (used + 1 <= len), AKBASIC_ERR_BOUNDS,
|
||
|
|
"PRINT USING result of %zu characters does not fit", used);
|
||
|
|
memcpy(dest, format, start);
|
||
|
|
memcpy(dest + start, rendered, strlen(rendered));
|
||
|
|
snprintf(dest + start + strlen(rendered), len - start - strlen(rendered),
|
||
|
|
"%s", format + start + length);
|
||
|
|
SUCCEED_RETURN(errctx);
|
||
|
|
}
|