/*
 * ckAsc.c -- check text files for non-ASCII and suspicious characters
 *
 * ---
 * Copyright (c) 2026 Carl L. Wuebker & Claude.ai Sonnet 4.6
 *
 * Permission is hereby granted, free of charge, to any person obtaining a
 * copy of this software and associated documentation files (the "Software"),
 * to deal in the Software without restriction, including without limitation
 * the rights to use, copy, modify, merge, publish, distribute, sublicense,
 * and/or sell copies of the Software, and to permit persons to whom the
 * Software is furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
 * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
 * IN THE SOFTWARE.

 * Note that Claude.ai Sonnet 4.6, guided by my requests, testing & feedback,
 * wrote the code.  I've minimally tested the code, so there still may be bugs.
 * 16Mar26: Posted at https://wuebker.com
 * ---
 *
 * Compile:
 *   gcc -Wall -Wextra -O2 -o ckAsc ckAsc.c
 *
 * Usage:
 *   ckAsc       [-u] file [file ...]
 *
 * Default mode -- reports:
 *   - ASCII control characters (0x00-0x1F) EXCEPT \t (0x09), \r (0x0D), \n (0x0A)
 *   - DEL (0x7F)
 *   - Multi-byte UTF-8 sequences (visible Unicode gets a generic label;
 *     invisible/formatting/suspicious codepoints get a specific description)
 *   - Isolated high bytes (0x80-0xFF) that are not valid UTF-8
 *
 * With -u -- reports only:
 *   - DEL (0x7F)
 *   - Multi-byte UTF-8 sequences (same classification as above)
 *   - Isolated high bytes that are not valid UTF-8
 *   (i.e. suppresses the ASCII control-character reporting)
 *
 * Output columns:
 *   file:line:col  U+XXXX  <description>  [raw bytes: XX XX ...]
 *   file:line:col  0xXX    <description>  [raw bytes: XX]
 */

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

/* ------------------------------------------------------------------ */
/* UTF-8 decoder                                                      */
/* Returns codepoint >= 0 on success, -1 on invalid/incomplete seq.   */
/* *bytes_used is set to the number of bytes consumed (1..4).         */
/* ------------------------------------------------------------------ */
static long decode_utf8(const unsigned char *buf, int avail, int *bytes_used)
{
    unsigned char b0 = buf[0];

    if (b0 < 0x80) {                          /* 1-byte (ASCII) */
        *bytes_used = 1;
        return (long)b0;
    }
    if (b0 < 0xC0) {                          /* bare continuation byte */
        *bytes_used = 1;
        return -1;
    }
    if (b0 < 0xE0) {                          /* 2-byte sequence */
        if (avail < 2 || (buf[1] & 0xC0) != 0x80) { *bytes_used = 1; return -1; }
        *bytes_used = 2;
        long cp = ((long)(b0 & 0x1F) << 6) | (buf[1] & 0x3F);
        return (cp >= 0x80) ? cp : -1;        /* reject overlong */
    }
    if (b0 < 0xF0) {                          /* 3-byte sequence */
        if (avail < 3 || (buf[1] & 0xC0) != 0x80
                      || (buf[2] & 0xC0) != 0x80) { *bytes_used = 1; return -1; }
        *bytes_used = 3;
        long cp = ((long)(b0 & 0x0F) << 12)
                | ((long)(buf[1] & 0x3F) <<  6)
                |  (long)(buf[2] & 0x3F);
        return (cp >= 0x800) ? cp : -1;
    }
    if (b0 < 0xF8) {                          /* 4-byte sequence */
        if (avail < 4 || (buf[1] & 0xC0) != 0x80
                      || (buf[2] & 0xC0) != 0x80
                      || (buf[3] & 0xC0) != 0x80) { *bytes_used = 1; return -1; }
        *bytes_used = 4;
        long cp = ((long)(b0 & 0x07) << 18)
                | ((long)(buf[1] & 0x3F) << 12)
                | ((long)(buf[2] & 0x3F) <<  6)
                |  (long)(buf[3] & 0x3F);
        return (cp >= 0x10000 && cp <= 0x10FFFF) ? cp : -1;
    }

    *bytes_used = 1;
    return -1;   /* 0xF8-0xFF are never valid UTF-8 */
}

/* ------------------------------------------------------------------ */
/* Invisible / suspicious Unicode classifier                           */
/* Returns a specific description string, or NULL for ordinary visible */
/* ------------------------------------------------------------------ */
static const char *invisible_unicode_desc(long cp)
{
    /* Named single codepoints */
    switch (cp) {
        case 0x00AD: return "SOFT HYPHEN";
        case 0x034F: return "COMBINING GRAPHEME JOINER";
        case 0x061C: return "ARABIC LETTER MARK";
        case 0x115F: return "HANGUL CHOSEONG FILLER";
        case 0x1160: return "HANGUL JUNGSEONG FILLER";
        case 0x17B4: return "KHMER VOWEL INHERENT AQ (invisible)";
        case 0x17B5: return "KHMER VOWEL INHERENT AA (invisible)";
        case 0x180B: return "MONGOLIAN FREE VARIATION SELECTOR ONE";
        case 0x180C: return "MONGOLIAN FREE VARIATION SELECTOR TWO";
        case 0x180D: return "MONGOLIAN FREE VARIATION SELECTOR THREE";
        case 0x180E: return "MONGOLIAN VOWEL SEPARATOR";
        case 0x200B: return "ZERO WIDTH SPACE";
        case 0x200C: return "ZERO WIDTH NON-JOINER";
        case 0x200D: return "ZERO WIDTH JOINER";
        case 0x200E: return "LEFT-TO-RIGHT MARK";
        case 0x200F: return "RIGHT-TO-LEFT MARK";
        case 0x202A: return "LEFT-TO-RIGHT EMBEDDING";
        case 0x202B: return "RIGHT-TO-LEFT EMBEDDING";
        case 0x202C: return "POP DIRECTIONAL FORMATTING";
        case 0x202D: return "LEFT-TO-RIGHT OVERRIDE";
        case 0x202E: return "RIGHT-TO-LEFT OVERRIDE";
        case 0x2060: return "WORD JOINER";
        case 0x2061: return "FUNCTION APPLICATION (invisible math)";
        case 0x2062: return "INVISIBLE TIMES";
        case 0x2063: return "INVISIBLE SEPARATOR";
        case 0x2064: return "INVISIBLE PLUS";
        case 0x2065: return "RESERVED (invisible)";
        case 0x206A: return "INHIBIT SYMMETRIC SWAPPING (deprecated)";
        case 0x206B: return "ACTIVATE SYMMETRIC SWAPPING (deprecated)";
        case 0x206C: return "INHIBIT ARABIC FORM SHAPING (deprecated)";
        case 0x206D: return "ACTIVATE ARABIC FORM SHAPING (deprecated)";
        case 0x206E: return "NATIONAL DIGIT SHAPES (deprecated)";
        case 0x206F: return "NOMINAL DIGIT SHAPES (deprecated)";
        case 0x2800: return "BRAILLE PATTERN BLANK";
        case 0x3164: return "HANGUL FILLER";
        case 0xFEFF: return "ZERO WIDTH NO-BREAK SPACE / BOM";
        case 0xFFA0: return "HALFWIDTH HANGUL FILLER";
        case 0xFFF9: return "INTERLINEAR ANNOTATION ANCHOR";
        case 0xFFFA: return "INTERLINEAR ANNOTATION SEPARATOR";
        case 0xFFFB: return "INTERLINEAR ANNOTATION TERMINATOR";
        case 0x1BCA0: return "SHORTHAND FORMAT LETTER OVERLAP";
        case 0x1BCA1: return "SHORTHAND FORMAT CONTINUING OVERLAP";
        case 0x1BCA2: return "SHORTHAND FORMAT DOWN STEP";
        case 0x1BCA3: return "SHORTHAND FORMAT UP STEP";
        case 0x1D173: return "MUSICAL SYMBOL BEGIN BEAM";
        case 0x1D174: return "MUSICAL SYMBOL END BEAM";
        case 0x1D175: return "MUSICAL SYMBOL BEGIN TIE";
        case 0x1D176: return "MUSICAL SYMBOL END TIE";
        case 0x1D177: return "MUSICAL SYMBOL BEGIN SLUR";
        case 0x1D178: return "MUSICAL SYMBOL END SLUR";
        case 0x1D179: return "MUSICAL SYMBOL BEGIN PHRASE";
        case 0x1D17A: return "MUSICAL SYMBOL END PHRASE";
    }

    /* Ranges */
    if (cp >= 0x2066 && cp <= 0x2069) return "BIDI ISOLATE CONTROL";
    if (cp >= 0xFE00 && cp <= 0xFE0F) return "VARIATION SELECTOR";
    if (cp >= 0xE0000 && cp <= 0xE007F) return "TAG CHARACTER (steganography/Trojan-Source risk)";
    if (cp >= 0xE0100 && cp <= 0xE01EF) return "VARIATION SELECTOR SUPPLEMENT";

    return NULL;   /* ordinary visible codepoint */
}

/* ------------------------------------------------------------------ */
/* ASCII control character description (for default mode)              */
/* Returns NULL for the exempted characters: \t \r \n                 */
/* ------------------------------------------------------------------ */
static const char *ascii_ctrl_desc(unsigned char b)
{
    switch (b) {
        case 0x00: return "NUL";
        case 0x01: return "SOH (Start of Heading)";
        case 0x02: return "STX (Start of Text)";
        case 0x03: return "ETX (End of Text)";
        case 0x04: return "EOT (End of Transmission)";
        case 0x05: return "ENQ (Enquiry)";
        case 0x06: return "ACK (Acknowledge)";
        case 0x07: return "BEL (Bell)";
        case 0x08: return "BS  (Backspace)";
        /* 0x09 \t -- exempt */
        case 0x0A: return NULL;   /* \n -- exempt */
        case 0x0B: return "VT  (Vertical Tab)";
        case 0x0C: return "FF  (Form Feed)";
        case 0x0D: return NULL;   /* \r -- exempt */
        case 0x0E: return "SO  (Shift Out)";
        case 0x0F: return "SI  (Shift In)";
        case 0x10: return "DLE (Data Link Escape)";
        case 0x11: return "DC1 (Device Control 1 / XON)";
        case 0x12: return "DC2 (Device Control 2)";
        case 0x13: return "DC3 (Device Control 3 / XOFF)";
        case 0x14: return "DC4 (Device Control 4)";
        case 0x15: return "NAK (Negative Acknowledge)";
        case 0x16: return "SYN (Synchronous Idle)";
        case 0x17: return "ETB (End of Transmission Block)";
        case 0x18: return "CAN (Cancel)";
        case 0x19: return "EM  (End of Medium)";
        case 0x1A: return "SUB (Substitute / EOF on Windows)";
        case 0x1B: return "ESC (Escape)";
        case 0x1C: return "FS  (File Separator)";
        case 0x1D: return "GS  (Group Separator)";
        case 0x1E: return "RS  (Record Separator)";
        case 0x1F: return "US  (Unit Separator)";
        default:   return NULL;
    }
}

/* ------------------------------------------------------------------ */
/* Format raw bytes as "XX XX ..." into a static buffer               */
/* ------------------------------------------------------------------ */
static const char *fmt_bytes(const unsigned char *buf, int n)
{
    static char out[64];
    int pos = 0;
    for (int i = 0; i < n && pos < (int)sizeof(out) - 4; i++) {
        if (i) out[pos++] = ' ';
        pos += sprintf(out + pos, "%02X", buf[i]);
    }
    out[pos] = '\0';
    return out;
}

/* ------------------------------------------------------------------ */
/* Process one file                                                     */
/* ------------------------------------------------------------------ */
static int process_file(const char *path, int flag_u)
{
    FILE *fh = fopen(path, "rb");
    if (!fh) {
        perror(path);
        return 1;
    }

    fseek(fh, 0, SEEK_END);
    long fsize = ftell(fh);
    fseek(fh, 0, SEEK_SET);

    if (fsize < 0) {
        fprintf(stderr, "%s: cannot determine file size\n", path);
        fclose(fh);
        return 1;
    }

    unsigned char *data = malloc((size_t)fsize + 4);  /* +4 for lookahead sentinel */
    if (!data) {
        fprintf(stderr, "%s: out of memory\n", path);
        fclose(fh);
        return 1;
    }
    size_t nread = fread(data, 1, (size_t)fsize, fh);
    fclose(fh);
    memset(data + nread, 0, 4);

    int    found = 0;
    int    line  = 1;
    int    col   = 1;   /* 1-based character column */
    size_t i     = 0;

    while (i < nread) {
        unsigned char b0 = data[i];

        /* \n -- advance line counter, never report */
        if (b0 == 0x0A) {
            line++; col = 1; i++;
            continue;
        }
        /* \r -- skip silently (don't count as a character column) */
        if (b0 == 0x0D) {
            i++;
            continue;
        }

        /* ---- Decode UTF-8 ----------------------------------------- */
        int  used = 1;
        long cp   = decode_utf8(data + i, (int)(nread - i), &used);

        if (cp >= 0 && used == 1) {
            /* ---- Pure ASCII (0x00-0x7F, excluding \r and \n above) -- */

            if (b0 == 0x7F) {
                /* DEL -- always reported */
                printf("%s:%d:%d  0x7F: 7F  DEL (Delete)\n", path, line, col);
                found++;
            } else if (!flag_u) {
                /* ASCII control characters: only in default mode */
                const char *desc = ascii_ctrl_desc(b0);
                if (desc) {
                    printf("%s:%d:%d  0x%02X: %02X  %s\n",
                           path, line, col, b0, b0, desc);
                    found++;
                }
                /* Printable ASCII 0x20-0x7E: never reported */
            }
            /* space (0x20) and printable ASCII: fall through silently */

        } else if (cp >= 0 && used > 1) {
            /* ---- Valid multi-byte UTF-8 codepoint ------------------ */
            const char *inv = invisible_unicode_desc(cp);
            if (inv) {
                /* Invisible / suspicious: specific description */
                printf("%s:%d:%d  U+%04lX: %s  %s\n",
                       path, line, col, cp,
                       fmt_bytes(data + i, used), inv);
            } else {
                printf("%s:%d:%d  U+%04lX: %s\n",
                       path, line, col, cp,
                       fmt_bytes(data + i, used));
            }
            found++;

        } else {
            /* ---- Invalid UTF-8 / isolated high byte ---------------- */
            printf("%s:%d:%d  0x%02X: %02X  INVALID UTF-8 / HIGH BYTE\n",
                   path, line, col, b0, b0);
            found++;
            used = 1;
        }

        col += 1;
        i   += (size_t)used;
    }

    free(data);

    if (!found)
        printf("%s: no suspicious characters found\n", path);

    return 0;
}

/* ------------------------------------------------------------------ */
/* main                                                                 */
/* ------------------------------------------------------------------ */
int main(int argc, char *argv[])
{
    int flag_u     = 0;
    int first_file = 1;

    if (argc < 2) {
        fprintf(stderr,
            "Usage: ckAsc [-u] file [file ...]\n"
            "\n"
            "  Checks text files for non-ASCII and suspicious characters.\n"
            "\n"
            "  Default: reports ASCII control characters (0x00-0x1F, except\n"
            "           \\t \\r \\n), DEL (0x7F), multi-byte Unicode characters,\n"
            "           and isolated high bytes not valid in UTF-8.\n"
            "\n"
            "  -u    : report only DEL, Unicode, and invalid/high bytes;\n"
            "          suppress ASCII control character reporting.\n"
            "\n"
            "  Unicode codepoints are unlabelled unless they are invisible\n"
            "  or suspicious (zero-width spaces, bidi overrides, variation\n"
            "  selectors, tag characters, etc.) in which case a description\n"
            "  is appended after the hex bytes.\n"
            "\n"
            "  Output: file:line:col  U+XXXX: HH HH ...  [description]\n"
            "          file:line:col  0xXX: HH  description\n");
        return 1;
    }

    if (strcmp(argv[1], "-u") == 0) {
        flag_u     = 1;
        first_file = 2;
    }

    if (first_file >= argc) {
        fprintf(stderr, "ckAsc: no input files specified\n");
        return 1;
    }

    int rc = 0;
    for (int i = first_file; i < argc; i++)
        rc |= process_file(argv[i], flag_u);

    return rc;
}
