/** * @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 #include #include #include #include #include #include #include /** @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"); PASS(errctx, aksl_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. * * Returns a context rather than `void` because the fill can fail and there was * nowhere to say so. Both call sites are already inside `render_numeric`, which * returns one. See libakstdlib #38. */ static akerr_ErrorContext *overflow(char *dest, size_t width) { PREPARE_ERROR(errctx); PASS(errctx, aksl_memset(dest, '*', width)); dest[width] = '\0'; SUCCEED_RETURN(errctx); } /** * @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); int written = 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); PASS(errctx, aksl_snprintf(&written, digits, sizeof(digits), "%.*f", after, (negative ? -number : number))); PASS(errctx, aksl_strchr(digits, '.', &point)); if ( point != NULL ) { intlen = (size_t)(point - digits); } else { PASS(errctx, aksl_strlen(digits, &intlen)); } /* 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 { PASS(errctx, aksl_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 ) { PASS(errctx, 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; } } PASS(errctx, aksl_memcpy(dest + used, grouped, (size_t)group)); used += (size_t)group; if ( after > 0 ) { dest[used] = obj->chars[AKBASIC_PUDEF_POINT]; used += 1; PASS(errctx, aksl_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 ) { PASS(errctx, 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 = 0; size_t pad = 0; PASS(errctx, aksl_strlen(text, &textlen)); 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. */ PASS(errctx, aksl_memcpy(dest, text, length)); dest[length] = '\0'; SUCCEED_RETURN(errctx); } PASS(errctx, aksl_memset(dest, ' ', length)); dest[length] = '\0'; if ( field[0] == '=' ) { pad = (length - textlen) / 2; } else { pad = length - textlen; } PASS(errctx, aksl_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; size_t renderedlen = 0; size_t formatlen = 0; int written = 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. */ PASS(errctx, aksl_strlen(rendered, &renderedlen)); PASS(errctx, aksl_strlen(format, &formatlen)); used = renderedlen + formatlen - length; FAIL_ZERO_RETURN(errctx, (used + 1 <= len), AKBASIC_ERR_BOUNDS, "PRINT USING result of %zu characters does not fit", used); PASS(errctx, aksl_memcpy(dest, format, start)); PASS(errctx, aksl_memcpy(dest + start, rendered, renderedlen)); /* The check above already proved the tail fits, so this cannot truncate. */ PASS(errctx, aksl_snprintf(&written, dest + start + renderedlen, len - start - renderedlen, "%s", format + start + length)); SUCCEED_RETURN(errctx); }