File: nn.c
   1 /*
   2 The MIT License (MIT)
   3 
   4 Copyright (c) 2026 pacman64
   5 
   6 Permission is hereby granted, free of charge, to any person obtaining a copy of
   7 this software and associated documentation files (the "Software"), to deal
   8 in the Software without restriction, including without limitation the rights to
   9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
  10 of the Software, and to permit persons to whom the Software is furnished to do
  11 so, subject to the following conditions:
  12 
  13 The above copyright notice and this permission notice shall be included in all
  14 copies or substantial portions of the Software.
  15 
  16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  22 SOFTWARE.
  23 */
  24 
  25 /*
  26 You can build this command-line app by running
  27 
  28 cc -Wall -s -O2 -march=native -mtune=native -flto -o ./nn ./nn.c
  29 
  30 Building with COMPACT_OUTPUT defined makes `nn` output many fewer bytes, at
  31 the cost of using arguably worse colors. You can do that by running
  32 
  33 cc -s -O2 -march=native -mtune=native -flto -D COMPACT_OUTPUT -o ./nh ./nh.c
  34 */
  35 
  36 #include <stdbool.h>
  37 #include <stddef.h>
  38 #include <stdint.h>
  39 #include <stdio.h>
  40 #include <stdlib.h>
  41 #include <string.h>
  42 #include <unistd.h>
  43 
  44 #ifdef _WIN32
  45 #include <fcntl.h>
  46 #include <windows.h>
  47 #endif
  48 
  49 #ifdef RED_ERRORS
  50 #define ERROR_STYLE "\x1b[38;2;204;0;0m"
  51 #ifdef __APPLE__
  52 #define ERROR_STYLE "\x1b[31m"
  53 #endif
  54 #define RESET_STYLE "\x1b[0m"
  55 #else
  56 #define ERROR_STYLE
  57 #define RESET_STYLE
  58 #endif
  59 
  60 #define ERROR_LINE(MSG) (ERROR_STYLE MSG RESET_STYLE "\n")
  61 
  62 #define BAD_ALLOC 2
  63 
  64 // #define COMPACT_OUTPUT
  65 
  66 // EMIT_CONST emits string constants without their final null byte
  67 #define EMIT_CONST(w, x) fwrite(x, 1, sizeof(x) - 1, w)
  68 
  69 const char* info = ""
  70 "nn [options...] [filepaths...]\n"
  71 "\n"
  72 "\n"
  73 "Nice Numbers is an app which renders the plain text it's given to make long\n"
  74 "numbers much easier to read, by alternating 3-digit groups which are colored\n"
  75 "using ANSI-codes with unstyled ones.\n"
  76 "\n"
  77 "Unlike the common practice of inserting commas between 3-digit groups, this\n"
  78 "alternative doesn't widen the original text, keeping any alignments the same.\n"
  79 "\n"
  80 "All input is assumed to be UTF-8. When not given any filepaths, input is read\n"
  81 "from the standard input.\n"
  82 "\n"
  83 "\n"
  84 "Options, all of which can start with either 1 or 2 dashes:\n"
  85 "\n"
  86 "\n"
  87 "  -blue     use a blue-like color to alternate-style runs of digits\n"
  88 "  -bold     use a bold style/effect to alternate-style runs of digits\n"
  89 "  -gray     use a gray color to alternate-style runs of digits\n"
  90 "  -green    use a green color to alternate-style runs of digits\n"
  91 "  -inverse  invert/swap colors to alternate-style runs of digits\n"
  92 "  -orange   use an orange color to alternate-style runs of digits\n"
  93 "  -purple   use a purple color to alternate-style runs of digits\n"
  94 "  -red      use a red color to alternate-style runs of digits\n"
  95 "\n"
  96 "  -h          show this help message\n"
  97 "  -help       show this help message\n"
  98 "\n"
  99 "  -highlight  same as option -inverse\n"
 100 "  -hilite     same as option -inverse\n"
 101 "";
 102 
 103 // span is a region of bytes in memory
 104 typedef struct span {
 105     // ptr is the starting place of the region
 106     unsigned char* ptr;
 107 
 108     // len is how many bytes are in the region
 109     size_t len;
 110 } span;
 111 
 112 // advance updates a span so it starts after the number of bytes given
 113 static inline void advance(span* src, size_t n) {
 114     src->ptr += n;
 115     src->len -= n;
 116 }
 117 
 118 // slice is a growable region of bytes in memory
 119 typedef struct slice {
 120     // ptr is the starting place of the region
 121     unsigned char* ptr;
 122 
 123     // cap is how many bytes the memory region has available
 124     size_t cap;
 125 } slice;
 126 
 127 // find_digit returns the index of the first digit found, or a negative value
 128 // on failure
 129 static inline int64_t find_digit(span s) {
 130     for (size_t i = 0; i < s.len; i++) {
 131         const unsigned char b = s.ptr[i];
 132         if ('0' <= b && b <= '9') {
 133             return i;
 134         }
 135     }
 136     return -1;
 137 }
 138 
 139 // find_non_digit returns the index of the first non-digit found, or a negative
 140 // value on failure
 141 static inline int64_t find_non_digit(span s) {
 142     for (size_t i = 0; i < s.len; i++) {
 143         const unsigned char b = s.ptr[i];
 144         if (b < '0' || b > '9') {
 145             return i;
 146         }
 147     }
 148     return -1;
 149 }
 150 
 151 // restyle_digits renders a run of digits as alternating styled/unstyled runs
 152 // of 3 digits, which greatly improves readability, and is the only purpose
 153 // of this app; string is assumed to be all decimal digits
 154 void restyle_digits(FILE* w, span digits, span style) {
 155     if (digits.len < 4) {
 156         // digit sequence is short, so emit it as is
 157         fwrite(digits.ptr, 1, digits.len, w);
 158         return;
 159     }
 160 
 161     // separate leading 0..2 digits which don't align with the 3-digit groups
 162     size_t lead = digits.len % 3;
 163     // emit leading digits unstyled, if there are any
 164     fwrite(digits.ptr, 1, lead, w);
 165     // the rest is guaranteed to have a length which is a multiple of 3
 166     advance(&digits, lead);
 167 
 168     // start with the alternate style, unless there were no leading digits
 169     bool style_now = lead != 0;
 170 
 171     while (digits.len > 0) {
 172         if (style_now) {
 173             fwrite(style.ptr, 1, style.len, w);
 174             fwrite(digits.ptr, 1, 3, w);
 175             EMIT_CONST(w, "\x1b[0m");
 176         } else {
 177             fwrite(digits.ptr, 1, 3, w);
 178         }
 179 
 180         advance(&digits, 3);
 181         // alternate between styled and unstyled 3-digit groups
 182         style_now = !style_now;
 183     }
 184 }
 185 
 186 // restyle_line renders the line given, using ANSI-styles to make any long
 187 // numbers in it more legible
 188 void restyle_line(FILE* w, unsigned char* s, size_t len, span style) {
 189     span line;
 190     line.ptr = s;
 191     line.len = len;
 192 
 193     while (!feof(w) && line.len > 0) {
 194         int64_t i = find_digit(line);
 195         if (i < 0) {
 196             // no (more) digits for sure
 197             fwrite(line.ptr, 1, line.len, w);
 198             return;
 199         }
 200 
 201         // some ANSI-style sequences use 4-digit numbers, which are long
 202         // enough for this app to mangle
 203         bool is_ansi = i >= 2 && s[i - 2] == '\x1b' && s[i - 1] == '[';
 204 
 205         // emit line before current digit-run
 206         fwrite(line.ptr, 1, i, w);
 207 
 208         advance(&line, i);
 209 
 210         // see where the digit-run ends
 211         int64_t j = find_non_digit(line);
 212         if (j < 0) {
 213             // the digit-run goes until the end
 214             if (!is_ansi) {
 215                 restyle_digits(w, line, style);
 216             } else {
 217                 fwrite(line.ptr, 1, line.len, w);
 218             }
 219             return;
 220         }
 221 
 222         // emit styled digit-run... maybe
 223         if (!is_ansi) {
 224             span chunk;
 225             chunk.ptr = line.ptr;
 226             chunk.len = j;
 227             restyle_digits(w, chunk, style);
 228         } else {
 229             fwrite(line.ptr, 1, j, w);
 230         }
 231 
 232         // skip right past the end of the digit-run
 233         advance(&line, j);
 234     }
 235 }
 236 
 237 // default_digits_style makes it easy to change the built-in default style
 238 #ifdef COMPACT_OUTPUT
 239 unsigned char default_digits_style[] = "\x1b[38;5;248m";
 240 #else
 241 unsigned char default_digits_style[] = "\x1b[38;2;168;168;168m";
 242 #endif
 243 
 244 typedef struct handler_args {
 245     FILE* w;
 246     slice* line;
 247     span style;
 248 } handler_args;
 249 
 250 bool starts_with_bom(const unsigned char* p, size_t len) {
 251     return len >= 3 && p[0] == 0xef && p[1] == 0xbb && p[2] == 0xbf;
 252 }
 253 
 254 // handle_lines loops over input lines, restyling all digit-runs as more
 255 // readable `nice numbers`, fulfilling the app's purpose
 256 void handle_lines(handler_args args, FILE* src, bool live_lines) {
 257     FILE* w = args.w;
 258     slice* line = args.line;
 259 
 260     for (size_t i = 0; !feof(w); i++) {
 261         ssize_t len = getline((char**)&line->ptr, &line->cap, src);
 262         if (line->ptr == NULL) {
 263             fprintf(stderr, "\n");
 264             fprintf(stderr, ERROR_LINE("out of memory"));
 265             exit(BAD_ALLOC);
 266         }
 267 
 268         if (len < 0) {
 269             break;
 270         }
 271 
 272         unsigned char* ptr = line->ptr;
 273 
 274         // get rid of leading UTF-8 BOM (byte-order mark) if 1st line has it
 275         if (i == 0 && starts_with_bom(ptr, len)) {
 276             ptr += 3;
 277             len -= 3;
 278         }
 279 
 280         // replace trailing carriage-returns with line-feeds
 281         if (len >= 1 && ptr[len - 1] == '\r') {
 282             ptr[len - 1] = '\n';
 283         }
 284 
 285         // get rid of carriage-returns preceding line-feeds
 286         if (len >= 2 && ptr[len - 2] == '\r' && ptr[len - 1] == '\n') {
 287             ptr[len - 2] = '\n';
 288             len--;
 289         }
 290 
 291         restyle_line(w, ptr, len, args.style);
 292         if (len < 1 || ptr[len - 1] != '\n') {
 293             fputc('\n', w);
 294         }
 295     }
 296 
 297     if (!live_lines) {
 298         fflush(w);
 299     }
 300 }
 301 
 302 // handle_file handles data from the filename given; returns false only when
 303 // the file can't be opened
 304 bool handle_file(handler_args args, const char* path, bool live_lines) {
 305     FILE* f = fopen(path, "rb");
 306     if (f == NULL) {
 307         fprintf(stderr, ERROR_LINE("can't open file named '%s'"), path);
 308         return false;
 309     }
 310 
 311     handle_lines(args, f, live_lines);
 312     fclose(f);
 313     return true;
 314 }
 315 
 316 const char* style_names_aliases[] = {
 317     "b", "blue",
 318     "g", "green",
 319     "h", "inverse",
 320     "i", "inverse",
 321     "m", "magenta",
 322     "o", "orange",
 323     "p", "purple",
 324     "r", "red",
 325     "u", "underline",
 326 
 327     "hi", "inverse",
 328     "ma", "magenta",
 329     "or", "orange",
 330     "un", "underline",
 331 
 332     "inv", "inverse",
 333     "mag", "magenta",
 334 
 335     "grey", "gray",
 336     "highlight", "inverse",
 337     "highlighted", "inverse",
 338     "hilite", "inverse",
 339     "hilited", "inverse",
 340     "invert", "inverse",
 341     "inverted", "inverse",
 342     "underlined", "underline",
 343 
 344     "bb", "blueback",
 345     "gb", "greenback",
 346     "mb", "magentaback",
 347     "ob", "orangeback",
 348     "pb", "purpleback",
 349     "rb", "redback",
 350 
 351     "greyback", "grayback",
 352 };
 353 
 354 #ifdef COMPACT_OUTPUT
 355 char* styles[] = {
 356     "blue", "\x1b[38;5;26m",
 357     "bold", "\x1b[1m",
 358     "gray", "\x1b[38;5;248m",
 359     "green", "\x1b[38;5;29m",
 360     "inverse", "\x1b[7m",
 361     "magenta", "\x1b[38;5;165m",
 362     "orange", "\x1b[38;5;166m",
 363     "purple", "\x1b[38;5;99m",
 364     "red", "\x1b[38;5;1m",
 365     "underline", "\x1b[4m",
 366 
 367     "blueback", "\x1b[48;5;26m\x1b[38;5;15m",
 368     "grayback", "\x1b[48;5;248m\x1b[38;5;15m",
 369     "greenback", "\x1b[48;5;29m\x1b[38;5;15m",
 370     "magentaback", "\x1b[48;5;165m\x1b[38;5;15m",
 371     "orangeback", "\x1b[48;5;166m\x1b[38;5;15m",
 372     "purpleback", "\x1b[48;5;99m\x1b[38;5;15m",
 373     "redback", "\x1b[48;5;1m\x1b[38;5;15m",
 374 };
 375 #else
 376 char* styles[] = {
 377     "blue", "\x1b[38;2;0;95;215m",
 378     "bold", "\x1b[1m",
 379     "gray", "\x1b[38;2;168;168;168m",
 380     "green", "\x1b[38;2;0;135;95m",
 381     "inverse", "\x1b[7m",
 382     "magenta", "\x1b[38;2;215;0;255m",
 383     "orange", "\x1b[38;2;215;95;0m",
 384     "purple", "\x1b[38;2;135;95;255m",
 385     "red", "\x1b[38;2;204;0;0m",
 386     "underline", "\x1b[4m",
 387 
 388     "blueback", "\x1b[48;2;0;95;215m\x1b[38;2;238;238;238m",
 389     "grayback", "\x1b[48;2;168;168;168m\x1b[38;2;238;238;238m",
 390     "greenback", "\x1b[48;2;0;135;95m\x1b[38;2;238;238;238m",
 391     "magentaback", "\x1b[48;2;215;0;255m\x1b[38;2;238;238;238m",
 392     "orangeback", "\x1b[48;2;215;95;0m\x1b[38;2;238;238;238m",
 393     "purpleback", "\x1b[48;2;135;95;255m\x1b[38;2;238;238;238m",
 394     "redback", "\x1b[48;2;204;0;0m\x1b[38;2;238;238;238m",
 395 };
 396 #endif
 397 
 398 bool change_style(const char* arg, span* style) {
 399     // style-changing options must have 1 or 2 leading dashes
 400     if (arg[0] != '-') {
 401         return false;
 402     }
 403 
 404     // skip up to 2 leading dashes
 405     const char* s = arg + (arg[1] == '-' ? 2 : 1);
 406 
 407     // resolve style-name aliases
 408     const size_t n = sizeof(style_names_aliases) / sizeof(char*);
 409     for (size_t i = 0; i < n; i += 2) {
 410         if (strcmp(s, style_names_aliases[i]) == 0) {
 411             s = style_names_aliases[i + 1];
 412             break;
 413         }
 414     }
 415 
 416     // try to find ANSI-code for the style-name given
 417     for (size_t i = 0; i < sizeof(styles) / sizeof(char*); i += 2) {
 418         if (strcmp(s, styles[i]) == 0) {
 419             style->ptr = (unsigned char*)styles[i + 1];
 420             style->len = strlen(styles[i + 1]);
 421             return true;
 422         }
 423     }
 424 
 425     return false;
 426 }
 427 
 428 // run returns the number of errors
 429 int run(char** args, size_t nargs, FILE* w, bool live_lines) {
 430     size_t dashes = 0;
 431     for (int i = 0; i < nargs; i++) {
 432         if (strcmp(args[i], "-") == 0) {
 433             dashes++;
 434         }
 435     }
 436 
 437     if (dashes > 1) {
 438         const char* m = "can't use the standard input (dash) more than once";
 439         fprintf(stderr, ERROR_LINE("%s"), m);
 440         return 1;
 441     }
 442 
 443     size_t files = 0;
 444     size_t errors = 0;
 445 
 446     slice line;
 447     line.cap = 32 * 1024;
 448     line.ptr = malloc(line.cap);
 449 
 450     if (line.ptr == NULL) {
 451         fprintf(stderr, ERROR_LINE("out of memory"));
 452         exit(BAD_ALLOC);
 453     }
 454 
 455     handler_args ha;
 456     ha.w = w;
 457     ha.line = &line;
 458     ha.style.ptr = default_digits_style;
 459     ha.style.len = strlen((char*)default_digits_style);
 460 
 461     bool options = true;
 462 
 463     for (size_t i = 0; i < nargs && !feof(w); i++) {
 464         const char* arg = args[i];
 465 
 466         // `--` means no more options
 467         if (arg[0] == '-' && arg[1] == '-' && arg[2] == 0) {
 468             options = false;
 469             continue;
 470         }
 471 
 472         // `-` means standard input
 473         if (arg[0] == '-' && arg[1] == 0) {
 474             handle_lines(ha, stdin, live_lines);
 475             files++;
 476             continue;
 477         }
 478 
 479         if (options && arg[0] == '-') {
 480             if (!change_style(arg, &ha.style)) {
 481                 fprintf(stderr, ERROR_LINE("unsupported style named %s"), arg);
 482                 errors++;
 483             }
 484             continue;
 485         }
 486 
 487         if (!handle_file(ha, arg, live_lines)) {
 488             errors++;
 489         }
 490         files++;
 491     }
 492 
 493     // use stdin when not given any filepaths
 494     if (files == 0 && !feof(w)) {
 495         handle_lines(ha, stdin, live_lines);
 496     }
 497 
 498     free(line.ptr);
 499     return errors;
 500 }
 501 
 502 int main(int argc, char** argv) {
 503 #ifdef _WIN32
 504     setmode(fileno(stdin), O_BINARY);
 505     // ensure output lines end in LF instead of CRLF on windows
 506     setmode(fileno(stdout), O_BINARY);
 507     setmode(fileno(stderr), O_BINARY);
 508 #endif
 509 
 510     if (argc > 1) {
 511         if (
 512             strcmp(argv[1], "-h") == 0 ||
 513             strcmp(argv[1], "-help") == 0 ||
 514             strcmp(argv[1], "--h") == 0 ||
 515             strcmp(argv[1], "--help") == 0
 516         ) {
 517             fprintf(stdout, "%s", info);
 518             return 0;
 519         }
 520     }
 521 
 522     size_t nargs = argc - 1;
 523     char** args = argv + 1;
 524     bool buffered = false;
 525 
 526     if (nargs > 0) {
 527         if (
 528             strcmp(args[0], "-b") == 0 ||
 529             strcmp(args[0], "--b") == 0 ||
 530             strcmp(args[0], "-buffered") == 0 ||
 531             strcmp(args[0], "--buffered") == 0
 532         ) {
 533             buffered = true;
 534             nargs--;
 535             args++;
 536         }
 537     }
 538 
 539     if (nargs > 0 && strcmp(args[0], "--") == 0) {
 540         nargs--;
 541         args++;
 542     }
 543 
 544     const int fd = fileno(stdout);
 545     const bool live_lines = !buffered && lseek(fd, 0, SEEK_CUR) != 0;
 546     if (live_lines) {
 547         setvbuf(stdout, NULL, _IOLBF, 0);
 548     } else {
 549         setvbuf(stdout, NULL, _IOFBF, 0);
 550     }
 551     return run(args, nargs, stdout, live_lines) == 0 ? 0 : 1;
 552 }