aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/pms_config.h16
-rw-r--r--src/pms_data.h36
-rw-r--r--src/pms_main.c297
-rw-r--r--src/pms_match.c142
-rw-r--r--src/pms_match.h20
-rw-r--r--src/pms_ref.c326
-rw-r--r--src/pms_ref.h37
-rw-r--r--src/pms_render.c306
-rw-r--r--src/pms_render.h10
-rw-r--r--src/pms_util.c89
-rw-r--r--src/pms_util.h27
11 files changed, 1306 insertions, 0 deletions
diff --git a/src/pms_config.h b/src/pms_config.h
new file mode 100644
index 0000000..8801d9d
--- /dev/null
+++ b/src/pms_config.h
@@ -0,0 +1,16 @@
+#ifndef PMS_CONFIG_H
+#define PMS_CONFIG_H
+
+#include <stdbool.h>
+
+typedef struct {
+ int before_context;
+ int after_context;
+ bool chapter_context;
+ bool show_notes;
+ bool highlighting;
+ bool pretty;
+ int maximum_line_length;
+} pms_config;
+
+#endif /* PMS_CONFIG_H */
diff --git a/src/pms_data.h b/src/pms_data.h
new file mode 100644
index 0000000..a9eefe5
--- /dev/null
+++ b/src/pms_data.h
@@ -0,0 +1,36 @@
+#ifndef PMS_DATA_H
+#define PMS_DATA_H
+
+typedef struct {
+ int number;
+ const char *name;
+ const char *abbr;
+ const char *aliases; /* comma-separated normalized aliases */
+} pms_book;
+
+typedef struct {
+ int book;
+ int chapter;
+ int verse;
+ const char *text;
+} pms_verse;
+
+typedef struct {
+ int book;
+ int chapter;
+ int verse_start;
+ int verse_end;
+ const char *label;
+ const char *text;
+} pms_note;
+
+extern pms_book pms_books[];
+extern int pms_books_length;
+
+extern pms_verse pms_verses[];
+extern int pms_verses_length;
+
+extern pms_note pms_notes[];
+extern int pms_notes_length;
+
+#endif /* PMS_DATA_H */
diff --git a/src/pms_main.c b/src/pms_main.c
new file mode 100644
index 0000000..1924b6a
--- /dev/null
+++ b/src/pms_main.c
@@ -0,0 +1,297 @@
+#include "pms_config.h"
+#include "pms_data.h"
+#include "pms_ref.h"
+#include "pms_render.h"
+#include <ctype.h>
+#include <errno.h>
+#include <signal.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/ioctl.h>
+#include <sys/wait.h>
+#include <unistd.h>
+
+static const char *usage_str =
+ "usage: pms [flags] [reference...]\n"
+ "\n"
+ "Bíblia Sagrada — Tradução e Comentários do Padre Manuel de Matos Soares (1932)\n"
+ "\n"
+ "Flags:\n"
+ " -A num show num verses of context after matching verses\n"
+ " -B num show num verses of context before matching verses\n"
+ " -C show matching verses in context of the chapter\n"
+ " -c show Padre Matos Soares's commentaries/footnotes for matching verses\n"
+ " -e highlighting of chapters and verse numbers\n"
+ " (default when output is a TTY)\n"
+ " -p output to less with chapter grouping, spacing, indentation,\n"
+ " and line wrapping\n"
+ " (default when output is a TTY)\n"
+ " -W no line wrap\n"
+ " -l list books\n"
+ " -h show help\n"
+ "\n"
+ "Reference:\n"
+ " <Book>\n"
+ " Individual book (e.g. \"Gn\", \"1Rs\", \"1Sm\", \"Mt\")\n"
+ " <Book>:<Chapter> or <Book> <Chapter>\n"
+ " Individual chapter of a book (e.g. \"Gn:1\", \"Sl 23\")\n"
+ " <Book>:<Chapter>:<Verse>[,<Verse>]...\n"
+ " Individual verse(s) of a specific chapter of a book (e.g. \"Jo 3:16\", \"Gn 1:1,3,5\")\n"
+ " <Book>:<Chapter>-<Chapter>\n"
+ " Range of chapters in a book (e.g. \"Gn 1-3\")\n"
+ " <Book>:<Chapter>:<Verse>-<Verse>\n"
+ " Range of verses in a book chapter (e.g. \"Gn 1:1-5\")\n"
+ " <Book>:<Chapter>:<Verse>-<Chapter>:<Verse>\n"
+ " Range of chapters and verses in a book (e.g. \"Gn 1:26-2:3\")\n"
+ "\n"
+ " /<Search>\n"
+ " All verses that match a pattern (case- and accent-insensitive)\n"
+ " <Book>/<Search>\n"
+ " All verses in a book that match a pattern\n"
+ " <Book>:<Chapter>/<Search>\n"
+ " All verses in a chapter of a book that match a pattern\n";
+
+static int get_terminal_width(void) {
+ struct winsize ws;
+ if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0 && ws.ws_col > 0) {
+ return (ws.ws_col < 80) ? ws.ws_col : 80;
+ }
+ return 80;
+}
+
+static FILE *open_pager(pid_t *pager_pid) {
+ *pager_pid = -1;
+ int pipefd[2];
+ if (pipe(pipefd) != 0) {
+ return stdout;
+ }
+
+ pid_t pid = fork();
+ if (pid < 0) {
+ close(pipefd[0]);
+ close(pipefd[1]);
+ return stdout;
+ }
+
+ if (pid == 0) {
+ /* Child: run pager */
+ close(pipefd[1]);
+ if (dup2(pipefd[0], STDIN_FILENO) < 0) {
+ _exit(1);
+ }
+ close(pipefd[0]);
+
+ const char *pager = getenv("PAGER");
+ if (pager && pager[0] != '\0') {
+ execlp(pager, pager, (char *)NULL);
+ }
+ execlp("less", "less", "-J", "-I", "-R", "-f", "-", (char *)NULL);
+ execlp("more", "more", (char *)NULL);
+ _exit(1);
+ }
+
+ /* Parent */
+ close(pipefd[0]);
+ *pager_pid = pid;
+ FILE *fp = fdopen(pipefd[1], "w");
+ if (!fp) {
+ close(pipefd[1]);
+ *pager_pid = -1;
+ return stdout;
+ }
+ return fp;
+}
+
+static void close_pager(FILE *fp, pid_t pager_pid) {
+ if (fp && fp != stdout) {
+ fclose(fp);
+ }
+ if (pager_pid > 0) {
+ int status = 0;
+ waitpid(pager_pid, &status, 0);
+ }
+}
+
+static int run_query(const char *query_str, const pms_config *config, bool use_pager) {
+ pms_ref *ref = pms_newref();
+ if (!ref) {
+ return 1;
+ }
+ if (pms_parse_ref(query_str, ref) != 0) {
+ fprintf(stderr, "pms: referência inválida: %s\n", query_str);
+ pms_freeref(ref);
+ return 1;
+ }
+
+ pid_t pager_pid = -1;
+ FILE *out = stdout;
+ if (use_pager) {
+ out = open_pager(&pager_pid);
+ }
+
+ pms_render(ref, config, out);
+
+ if (use_pager) {
+ close_pager(out, pager_pid);
+ }
+ pms_freeref(ref);
+ return 0;
+}
+
+int main(int argc, char *argv[]) {
+ bool is_tty = isatty(STDOUT_FILENO) != 0;
+ signal(SIGPIPE, SIG_IGN);
+
+ pms_config config = {
+ .before_context = 0,
+ .after_context = 0,
+ .chapter_context = false,
+ .show_notes = false,
+ .highlighting = is_tty,
+ .pretty = is_tty,
+ .maximum_line_length = get_terminal_width(),
+ };
+
+ bool list_books = false;
+ bool no_wrap = false;
+
+ int opt;
+ while ((opt = getopt(argc, argv, "A:B:CcepwWlhn")) != -1) {
+ switch (opt) {
+ case 'A': {
+ char *endptr = NULL;
+ errno = 0;
+ long val = strtol(optarg, &endptr, 10);
+ if (errno != 0 || endptr == optarg || *endptr != '\0' || val < 0) {
+ fprintf(stderr, "pms: número inválido para -A: %s\n\n%s", optarg, usage_str);
+ return 1;
+ }
+ config.after_context = (int)val;
+ break;
+ }
+ case 'B': {
+ char *endptr = NULL;
+ errno = 0;
+ long val = strtol(optarg, &endptr, 10);
+ if (errno != 0 || endptr == optarg || *endptr != '\0' || val < 0) {
+ fprintf(stderr, "pms: número inválido para -B: %s\n\n%s", optarg, usage_str);
+ return 1;
+ }
+ config.before_context = (int)val;
+ break;
+ }
+ case 'C':
+ config.chapter_context = true;
+ break;
+ case 'c':
+ case 'n':
+ config.show_notes = true;
+ break;
+ case 'e':
+ config.highlighting = true;
+ break;
+ case 'p':
+ config.pretty = true;
+ break;
+ case 'W':
+ case 'w':
+ no_wrap = true;
+ break;
+ case 'l':
+ list_books = true;
+ break;
+ case 'h':
+ printf("%s", usage_str);
+ return 0;
+ default:
+ fprintf(stderr, "\n%s", usage_str);
+ return 1;
+ }
+ }
+
+ if (no_wrap) {
+ config.maximum_line_length = 0;
+ }
+
+ if (list_books) {
+ for (int i = 0; i < pms_books_length; i++) {
+ printf("%s (%s)\n", pms_books[i].name, pms_books[i].abbr);
+ }
+ return 0;
+ }
+
+ int rem_argc = argc - optind;
+ char **rem_argv = argv + optind;
+
+ if (rem_argc == 0) {
+ /* Interactive REPL mode */
+ char line[1024];
+ while (true) {
+ if (isatty(STDIN_FILENO)) {
+ printf("pms> ");
+ fflush(stdout);
+ }
+ if (!fgets(line, sizeof(line), stdin)) {
+ if (isatty(STDIN_FILENO)) {
+ printf("\n");
+ }
+ break;
+ }
+ size_t len = strlen(line);
+ while (len > 0 && isspace((unsigned char)line[len - 1])) {
+ line[--len] = '\0';
+ }
+ char *p = line;
+ while (*p && isspace((unsigned char)*p)) {
+ p++;
+ }
+ if (*p == '\0') {
+ continue;
+ }
+ if (strcmp(p, "q") == 0 || strcmp(p, "quit") == 0 ||
+ strcmp(p, "exit") == 0 || strcmp(p, "sair") == 0) {
+ break;
+ }
+ if (strcmp(p, ":c") == 0 || strcmp(p, "-c") == 0) {
+ config.show_notes = !config.show_notes;
+ printf("Comentários: %s\n", config.show_notes ? "activados" : "desactivados");
+ continue;
+ }
+ if (strcmp(p, "-l") == 0 || strcmp(p, ":l") == 0) {
+ for (int i = 0; i < pms_books_length; i++) {
+ printf("%s (%s)\n", pms_books[i].name, pms_books[i].abbr);
+ }
+ continue;
+ }
+ if (strcmp(p, "-h") == 0 || strcmp(p, ":h") == 0 || strcmp(p, "help") == 0) {
+ printf("%s", usage_str);
+ continue;
+ }
+ run_query(p, &config, is_tty && config.pretty);
+ }
+ return 0;
+ }
+
+ /* Join remaining CLI arguments into a single reference string */
+ size_t total_len = 0;
+ for (int i = 0; i < rem_argc; i++) {
+ total_len += strlen(rem_argv[i]) + 2;
+ }
+ char *joined = malloc(total_len + 1);
+ if (!joined) {
+ return 1;
+ }
+ joined[0] = '\0';
+ for (int i = 0; i < rem_argc; i++) {
+ if (i > 0) {
+ strcat(joined, " ");
+ }
+ strcat(joined, rem_argv[i]);
+ }
+
+ int rc = run_query(joined, &config, is_tty && config.pretty);
+ free(joined);
+ return rc;
+}
diff --git a/src/pms_match.c b/src/pms_match.c
new file mode 100644
index 0000000..8140dd5
--- /dev/null
+++ b/src/pms_match.c
@@ -0,0 +1,142 @@
+#include "pms_match.h"
+#include "pms_data.h"
+#include "pms_util.h"
+#include <limits.h>
+#include <stdbool.h>
+#include <stddef.h>
+
+static bool verse_matches(const pms_ref *ref, const pms_verse *v) {
+ switch (ref->type) {
+ case PMS_REF_SEARCH: {
+ if (ref->book != 0 && (unsigned int)v->book != ref->book) {
+ return false;
+ }
+ if (ref->chapter != 0 && (unsigned int)v->chapter != ref->chapter) {
+ return false;
+ }
+ char folded[4096];
+ pms_fold_utf8(v->text, folded, sizeof(folded), NULL);
+ return regexec(&ref->search, folded, 0, NULL, 0) == 0;
+ }
+
+ case PMS_REF_EXACT:
+ return (unsigned int)v->book == ref->book &&
+ (ref->chapter == 0 || (unsigned int)v->chapter == ref->chapter) &&
+ (ref->verse == 0 || (unsigned int)v->verse == ref->verse);
+
+ case PMS_REF_EXACT_SET:
+ if ((unsigned int)v->book != ref->book || (unsigned int)v->chapter != ref->chapter) {
+ return false;
+ }
+ for (int i = 0; i < ref->verse_set_count; i++) {
+ if ((unsigned int)v->verse == ref->verse_set[i]) {
+ return true;
+ }
+ }
+ return false;
+
+ case PMS_REF_RANGE:
+ return (unsigned int)v->book == ref->book &&
+ ((ref->chapter_end == 0 && (unsigned int)v->chapter == ref->chapter) ||
+ ((unsigned int)v->chapter >= ref->chapter && (unsigned int)v->chapter <= ref->chapter_end)) &&
+ (ref->verse == 0 || (unsigned int)v->verse >= ref->verse) &&
+ (ref->verse_end == 0 || (unsigned int)v->verse <= ref->verse_end);
+
+ case PMS_REF_RANGE_EXT:
+ return (unsigned int)v->book == ref->book &&
+ (((unsigned int)v->chapter == ref->chapter && (unsigned int)v->verse >= ref->verse && ref->chapter != ref->chapter_end) ||
+ ((unsigned int)v->chapter > ref->chapter && (unsigned int)v->chapter < ref->chapter_end) ||
+ ((unsigned int)v->chapter == ref->chapter_end && (unsigned int)v->verse <= ref->verse_end && ref->chapter != ref->chapter_end) ||
+ (ref->chapter == ref->chapter_end && (unsigned int)v->chapter == ref->chapter &&
+ (unsigned int)v->verse >= ref->verse && (unsigned int)v->verse <= ref->verse_end));
+
+ default:
+ return false;
+ }
+}
+
+static pms_range chapter_bounds(int i, const pms_config *config) {
+ pms_range bounds = {.start = i, .end = i + 1};
+ int book = pms_verses[i].book;
+ int chapter = pms_verses[i].chapter;
+
+ int max_before = config->chapter_context ? INT_MAX : config->before_context;
+ for (int n = 0; n < max_before && bounds.start > 0; n++) {
+ if (pms_verses[bounds.start - 1].book == book &&
+ (!config->chapter_context || pms_verses[bounds.start - 1].chapter == chapter)) {
+ bounds.start--;
+ } else {
+ break;
+ }
+ }
+
+ int max_after = config->chapter_context ? INT_MAX : config->after_context;
+ for (int n = 0; n < max_after && bounds.end < pms_verses_length; n++) {
+ if (pms_verses[bounds.end].book == book &&
+ (!config->chapter_context || pms_verses[bounds.end].chapter == chapter)) {
+ bounds.end++;
+ } else {
+ break;
+ }
+ }
+
+ return bounds;
+}
+
+static int find_next_match(const pms_ref *ref, const pms_config *config, int i, pms_range *range) {
+ for (; i < pms_verses_length; i++) {
+ if (verse_matches(ref, &pms_verses[i])) {
+ *range = chapter_bounds(i, config);
+ return i;
+ }
+ }
+ return -1;
+}
+
+int pms_next_verse(const pms_ref *ref, const pms_config *config, pms_match_data *data) {
+ if (data->current >= pms_verses_length) {
+ return -1;
+ }
+
+ if (data->current == 0 && data->next_match == 0) {
+ data->matches[0] = (pms_range){-1, -1};
+ data->matches[1] = (pms_range){-1, -1};
+
+ data->next_match = find_next_match(ref, config, 0, &data->matches[0]);
+ if (data->next_match < 0) {
+ data->current = pms_verses_length;
+ return -1;
+ }
+ data->next_match = find_next_match(ref, config, data->next_match + 1, &data->matches[1]);
+ }
+
+ while (data->matches[1].start != -1 && data->matches[1].start <= data->matches[0].end) {
+ if (data->matches[1].end > data->matches[0].end) {
+ data->matches[0].end = data->matches[1].end;
+ }
+ data->matches[1] = (pms_range){-1, -1};
+ if (data->next_match >= 0) {
+ data->next_match = find_next_match(ref, config, data->next_match + 1, &data->matches[1]);
+ }
+ }
+
+ if (data->current < data->matches[0].start) {
+ data->current = data->matches[0].start;
+ }
+
+ if (data->current < data->matches[0].end) {
+ return data->current++;
+ }
+
+ if (data->matches[1].start != -1) {
+ data->matches[0] = data->matches[1];
+ data->matches[1] = (pms_range){-1, -1};
+ if (data->next_match >= 0) {
+ data->next_match = find_next_match(ref, config, data->next_match + 1, &data->matches[1]);
+ }
+ return pms_next_verse(ref, config, data);
+ }
+
+ data->current = pms_verses_length;
+ return -1;
+}
diff --git a/src/pms_match.h b/src/pms_match.h
new file mode 100644
index 0000000..89dec53
--- /dev/null
+++ b/src/pms_match.h
@@ -0,0 +1,20 @@
+#ifndef PMS_MATCH_H
+#define PMS_MATCH_H
+
+#include "pms_config.h"
+#include "pms_ref.h"
+
+typedef struct {
+ int start;
+ int end;
+} pms_range;
+
+typedef struct {
+ int current;
+ int next_match;
+ pms_range matches[2];
+} pms_match_data;
+
+int pms_next_verse(const pms_ref *ref, const pms_config *config, pms_match_data *data);
+
+#endif /* PMS_MATCH_H */
diff --git a/src/pms_ref.c b/src/pms_ref.c
new file mode 100644
index 0000000..45a4f16
--- /dev/null
+++ b/src/pms_ref.c
@@ -0,0 +1,326 @@
+#include "pms_ref.h"
+#include "pms_data.h"
+#include "pms_util.h"
+#include <ctype.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+pms_ref *pms_newref(void) {
+ return calloc(1, sizeof(pms_ref));
+}
+
+void pms_freeref(pms_ref *ref) {
+ if (!ref) {
+ return;
+ }
+ if (ref->search_str) {
+ free(ref->search_str);
+ regfree(&ref->search);
+ }
+ free(ref);
+}
+
+static bool alias_list_matches(const char *aliases, const char *key, bool prefix_ok) {
+ char buf[256];
+ strncpy(buf, aliases, sizeof(buf) - 1);
+ buf[sizeof(buf) - 1] = '\0';
+
+ char *saveptr = NULL;
+ char *tok = strtok_r(buf, ",", &saveptr);
+ size_t key_len = strlen(key);
+ while (tok) {
+ if (strcmp(tok, key) == 0) {
+ return true;
+ }
+ if (prefix_ok && key_len >= 2 && strncmp(tok, key, key_len) == 0) {
+ return true;
+ }
+ tok = strtok_r(NULL, ",", &saveptr);
+ }
+ return false;
+}
+
+int pms_book_from_name(const char *name) {
+ char key[128];
+ if (pms_normalize_book_key(name, key, sizeof(key)) == 0) {
+ return 0;
+ }
+ size_t key_len = strlen(key);
+
+ /* Pass 1: exact match on normalized abbr or name */
+ for (int i = 0; i < pms_books_length; i++) {
+ char norm_abbr[64], norm_name[128];
+ pms_normalize_book_key(pms_books[i].abbr, norm_abbr, sizeof(norm_abbr));
+ pms_normalize_book_key(pms_books[i].name, norm_name, sizeof(norm_name));
+ if (strcmp(key, norm_abbr) == 0 || strcmp(key, norm_name) == 0) {
+ return pms_books[i].number;
+ }
+ }
+
+ /* Pass 2: exact match on aliases */
+ for (int i = 0; i < pms_books_length; i++) {
+ if (alias_list_matches(pms_books[i].aliases, key, false)) {
+ return pms_books[i].number;
+ }
+ }
+
+ /* Pass 3: prefix match on normalized name, abbr, or aliases */
+ for (int i = 0; i < pms_books_length; i++) {
+ char norm_abbr[64], norm_name[128];
+ pms_normalize_book_key(pms_books[i].abbr, norm_abbr, sizeof(norm_abbr));
+ pms_normalize_book_key(pms_books[i].name, norm_name, sizeof(norm_name));
+ if (strncmp(norm_name, key, key_len) == 0 || strncmp(norm_abbr, key, key_len) == 0) {
+ return pms_books[i].number;
+ }
+ if (alias_list_matches(pms_books[i].aliases, key, true)) {
+ return pms_books[i].number;
+ }
+ }
+
+ /* Pass 4: substring match on normalized name (for "Mateus" -> "S. Mateus", etc.) */
+ if (!isdigit((unsigned char)key[0])) {
+ for (int i = 0; i < pms_books_length; i++) {
+ char norm_name[128];
+ pms_normalize_book_key(pms_books[i].name, norm_name, sizeof(norm_name));
+ if (!isdigit((unsigned char)norm_name[0]) && strstr(norm_name, key) != NULL) {
+ return pms_books[i].number;
+ }
+ }
+ }
+
+ return 0;
+}
+
+/*
+ * Split a string like "1 Reis 3:16-18" or "Gn1:1" or "S. João 3" into
+ * book_out ("1 Reis", "Gn", "S. João") and spec_out ("3:16-18", "1:1", "3").
+ */
+static void split_book_and_spec(const char *s, char *book_out, size_t book_sz, char *spec_out, size_t spec_sz) {
+ while (*s && isspace((unsigned char)*s)) {
+ s++;
+ }
+ const char *p = s;
+
+ /* Optional leading book number (1, 2, 3, 4 or Roman I, II, III, IV) */
+ if (isdigit((unsigned char)*p)) {
+ while (isdigit((unsigned char)*p)) {
+ p++;
+ }
+ while (*p && (isspace((unsigned char)*p) || *p == '.')) {
+ p++;
+ }
+ }
+
+ /* Consume book name characters (letters, spaces, dots, parentheses, hyphens in book names, UTF-8 bytes) */
+ while (*p) {
+ unsigned char c = (unsigned char)*p;
+ if (c == ':' || c == '/') {
+ break;
+ }
+ if (isdigit(c)) {
+ break;
+ }
+ p++;
+ }
+
+ size_t blen = (size_t)(p - s);
+ while (blen > 0 && (isspace((unsigned char)s[blen - 1]) || s[blen - 1] == ':')) {
+ blen--;
+ }
+ if (blen >= book_sz) {
+ blen = book_sz - 1;
+ }
+ memcpy(book_out, s, blen);
+ book_out[blen] = '\0';
+
+ while (*p && (isspace((unsigned char)*p) || *p == ':')) {
+ p++;
+ }
+ strncpy(spec_out, p, spec_sz - 1);
+ spec_out[spec_sz - 1] = '\0';
+ /* Trim trailing whitespace from spec_out */
+ size_t slen = strlen(spec_out);
+ while (slen > 0 && isspace((unsigned char)spec_out[slen - 1])) {
+ spec_out[--slen] = '\0';
+ }
+}
+
+static int parse_verse_set(const char *vspec, pms_ref *ref) {
+ char buf[256];
+ strncpy(buf, vspec, sizeof(buf) - 1);
+ buf[sizeof(buf) - 1] = '\0';
+
+ ref->verse_set_count = 0;
+ char *saveptr = NULL;
+ char *tok = strtok_r(buf, ",", &saveptr);
+ while (tok) {
+ while (*tok && isspace((unsigned char)*tok)) tok++;
+ if (*tok == '\0') {
+ tok = strtok_r(NULL, ",", &saveptr);
+ continue;
+ }
+ unsigned int v1 = 0, v2 = 0;
+ char extra = 0;
+ if (sscanf(tok, "%u - %u %c", &v1, &v2, &extra) == 2) {
+ if (v1 == 0 || v2 < v1) return -1;
+ for (unsigned int v = v1; v <= v2 && ref->verse_set_count < PMS_MAX_VERSE_SET; v++) {
+ ref->verse_set[ref->verse_set_count++] = v;
+ }
+ } else if (sscanf(tok, "%u %c", &v1, &extra) == 1) {
+ if (v1 == 0) return -1;
+ if (ref->verse_set_count < PMS_MAX_VERSE_SET) {
+ ref->verse_set[ref->verse_set_count++] = v1;
+ }
+ } else {
+ return -1;
+ }
+ tok = strtok_r(NULL, ",", &saveptr);
+ }
+ return ref->verse_set_count > 0 ? 0 : -1;
+}
+
+int pms_parse_ref(const char *input, pms_ref *ref) {
+ if (!input || !ref) {
+ return -1;
+ }
+ memset(ref, 0, sizeof(*ref));
+
+ while (*input && isspace((unsigned char)*input)) {
+ input++;
+ }
+ if (*input == '\0') {
+ return -1;
+ }
+
+ /* Check for '/' search syntax: /<pattern>, <Book>/<pattern>, <Book>:?<Chapter>/<pattern> */
+ const char *slash = strchr(input, '/');
+ if (slash != NULL) {
+ const char *pattern = slash + 1;
+ if (*pattern == '\0') {
+ return -1;
+ }
+ char prefix[256];
+ size_t plen = (size_t)(slash - input);
+ if (plen >= sizeof(prefix)) plen = sizeof(prefix) - 1;
+ memcpy(prefix, input, plen);
+ prefix[plen] = '\0';
+
+ if (plen > 0) {
+ char book_part[128], spec_part[128];
+ split_book_and_spec(prefix, book_part, sizeof(book_part), spec_part, sizeof(spec_part));
+ if (book_part[0] != '\0') {
+ int bnum = pms_book_from_name(book_part);
+ if (bnum == 0) {
+ return -1;
+ }
+ ref->book = (unsigned int)bnum;
+ }
+ if (spec_part[0] != '\0') {
+ unsigned int ch = 0;
+ char extra = 0;
+ if (sscanf(spec_part, "%u %c", &ch, &extra) != 1 || ch == 0) {
+ return -1;
+ }
+ ref->chapter = ch;
+ }
+ }
+
+ char folded_pat[512];
+ pms_fold_utf8(pattern, folded_pat, sizeof(folded_pat), NULL);
+ ref->search_str = strdup(folded_pat);
+ if (!ref->search_str) {
+ return -1;
+ }
+ if (regcomp(&ref->search, ref->search_str, REG_EXTENDED | REG_ICASE) != 0) {
+ free(ref->search_str);
+ ref->search_str = NULL;
+ return -1;
+ }
+ ref->type = PMS_REF_SEARCH;
+ return 0;
+ }
+
+ /* Standard passage reference */
+ char book_part[128], spec_part[128];
+ split_book_and_spec(input, book_part, sizeof(book_part), spec_part, sizeof(spec_part));
+ if (book_part[0] == '\0') {
+ return -1;
+ }
+ int bnum = pms_book_from_name(book_part);
+ if (bnum == 0) {
+ return -1;
+ }
+ ref->book = (unsigned int)bnum;
+
+ /* 1. <Book> only */
+ if (spec_part[0] == '\0') {
+ ref->type = PMS_REF_EXACT;
+ return 0;
+ }
+
+ unsigned int c1 = 0, c2 = 0, v1 = 0, v2 = 0;
+ char extra = 0;
+
+ /* 2. Cross-chapter range: <Chapter>:<Verse>-<Chapter>:<Verse> */
+ if (sscanf(spec_part, "%u : %u - %u : %u %c", &c1, &v1, &c2, &v2, &extra) == 4) {
+ if (c1 == 0 || v1 == 0 || c2 < c1 || v2 == 0) return -1;
+ ref->type = PMS_REF_RANGE_EXT;
+ ref->chapter = c1;
+ ref->verse = v1;
+ ref->chapter_end = c2;
+ ref->verse_end = v2;
+ return 0;
+ }
+
+ /* 3. Comma-separated verse set: <Chapter>:<V1>,<V2>... */
+ const char *colon = strchr(spec_part, ':');
+ if (colon != NULL && strchr(colon + 1, ',') != NULL) {
+ if (sscanf(spec_part, "%u :", &c1) != 1 || c1 == 0) {
+ return -1;
+ }
+ ref->chapter = c1;
+ ref->type = PMS_REF_EXACT_SET;
+ return parse_verse_set(colon + 1, ref);
+ }
+
+ /* 4. Single-chapter verse range: <Chapter>:<Verse>-<Verse> */
+ if (sscanf(spec_part, "%u : %u - %u %c", &c1, &v1, &v2, &extra) == 3 ||
+ sscanf(spec_part, "%u %u - %u %c", &c1, &v1, &v2, &extra) == 3) {
+ if (c1 == 0 || v1 == 0 || v2 < v1) return -1;
+ ref->type = PMS_REF_RANGE;
+ ref->chapter = c1;
+ ref->verse = v1;
+ ref->verse_end = v2;
+ return 0;
+ }
+
+ /* 5. Exact chapter and verse: <Chapter>:<Verse> or <Chapter> <Verse> */
+ if (sscanf(spec_part, "%u : %u %c", &c1, &v1, &extra) == 2 ||
+ sscanf(spec_part, "%u %u %c", &c1, &v1, &extra) == 2) {
+ if (c1 == 0 || v1 == 0) return -1;
+ ref->type = PMS_REF_EXACT;
+ ref->chapter = c1;
+ ref->verse = v1;
+ return 0;
+ }
+
+ /* 6. Chapter range: <Chapter>-<Chapter> */
+ if (sscanf(spec_part, "%u - %u %c", &c1, &c2, &extra) == 2) {
+ if (c1 == 0 || c2 < c1) return -1;
+ ref->type = PMS_REF_RANGE;
+ ref->chapter = c1;
+ ref->chapter_end = c2;
+ return 0;
+ }
+
+ /* 7. Whole chapter: <Chapter> */
+ if (sscanf(spec_part, "%u %c", &c1, &extra) == 1) {
+ if (c1 == 0) return -1;
+ ref->type = PMS_REF_EXACT;
+ ref->chapter = c1;
+ return 0;
+ }
+
+ return -1;
+}
diff --git a/src/pms_ref.h b/src/pms_ref.h
new file mode 100644
index 0000000..d0da38b
--- /dev/null
+++ b/src/pms_ref.h
@@ -0,0 +1,37 @@
+#ifndef PMS_REF_H
+#define PMS_REF_H
+
+#include <regex.h>
+#include <stdbool.h>
+
+#define PMS_REF_SEARCH 1
+#define PMS_REF_EXACT 2
+#define PMS_REF_EXACT_SET 3
+#define PMS_REF_RANGE 4
+#define PMS_REF_RANGE_EXT 5
+
+#define PMS_MAX_VERSE_SET 64
+
+typedef struct pms_ref {
+ int type;
+ unsigned int book;
+ unsigned int chapter;
+ unsigned int chapter_end;
+ unsigned int verse;
+ unsigned int verse_end;
+ unsigned int verse_set[PMS_MAX_VERSE_SET];
+ int verse_set_count;
+ char *search_str;
+ regex_t search;
+} pms_ref;
+
+pms_ref *pms_newref(void);
+void pms_freeref(pms_ref *ref);
+
+/* Match a book name, abbreviation, or modern alias to book number (1..73), or 0 if not found. */
+int pms_book_from_name(const char *name);
+
+/* Parse a reference string (e.g. "Gn 1:1-5", "1Rs:3:16", "/principio", "Jo 1 /luz") into ref. Returns 0 on success. */
+int pms_parse_ref(const char *input, pms_ref *ref);
+
+#endif /* PMS_REF_H */
diff --git a/src/pms_render.c b/src/pms_render.c
new file mode 100644
index 0000000..d433ced
--- /dev/null
+++ b/src/pms_render.c
@@ -0,0 +1,306 @@
+#include "pms_render.h"
+#include "pms_data.h"
+#include "pms_match.h"
+#include "pms_util.h"
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#define ESC_VERSE_START "\x1b[32m"
+#define ESC_VERSE_END "\x1b[0m"
+#define ESC_BOLD_START "\x1b[1m"
+#define ESC_BOLD_END "\x1b[0m"
+#define ESC_SEARCH_START "\x1b[7m"
+#define ESC_SEARCH_END "\x1b[27m"
+#define ESC_NOTE_START "\x1b[36m"
+#define ESC_NOTE_END "\x1b[0m"
+
+static const char *get_book_name(int book_num) {
+ for (int i = 0; i < pms_books_length; i++) {
+ if (pms_books[i].number == book_num) {
+ return pms_books[i].name;
+ }
+ }
+ return "";
+}
+
+/*
+ * Build a version of `text` with ANSI inverse-video highlights around regex matches
+ * when ref->type == PMS_REF_SEARCH and config->highlighting is true.
+ * Caller must NOT free the returned pointer if it equals `text`; we always return a newly
+ * allocated string that the caller frees.
+ */
+static char *highlight_search_matches(const char *text, const pms_ref *ref, const pms_config *config) {
+ if (!config->highlighting || ref->type != PMS_REF_SEARCH || !ref->search_str) {
+ return strdup(text);
+ }
+
+ size_t text_len = strlen(text);
+ char *folded = malloc(text_len + 2);
+ int *byte_map = malloc((text_len + 2) * sizeof(int));
+ if (!folded || !byte_map) {
+ free(folded);
+ free(byte_map);
+ return strdup(text);
+ }
+
+ size_t folded_len = pms_fold_utf8(text, folded, text_len + 2, byte_map);
+ size_t cap = text_len * 2 + 128;
+ char *out = malloc(cap);
+ if (!out) {
+ free(folded);
+ free(byte_map);
+ return strdup(text);
+ }
+
+ size_t out_pos = 0;
+ size_t f_pos = 0;
+ size_t orig_pos = 0;
+ regmatch_t m[1];
+
+ while (f_pos < folded_len && regexec(&ref->search, folded + f_pos, 1, m, f_pos > 0 ? REG_NOTBOL : 0) == 0) {
+ if (m[0].rm_so == m[0].rm_eo) {
+ break;
+ }
+ size_t match_f_start = f_pos + (size_t)m[0].rm_so;
+ size_t match_f_end = f_pos + (size_t)m[0].rm_eo;
+ size_t match_orig_start = (size_t)byte_map[match_f_start];
+ size_t match_orig_end = (size_t)byte_map[match_f_end];
+
+ size_t needed = (match_orig_end - orig_pos) + 32;
+ if (out_pos + needed >= cap) {
+ cap = (cap + needed) * 2;
+ char * resized = realloc(out, cap);
+ if (!resized) break;
+ out = resized;
+ }
+
+ if (match_orig_start > orig_pos) {
+ size_t pre = match_orig_start - orig_pos;
+ memcpy(out + out_pos, text + orig_pos, pre);
+ out_pos += pre;
+ }
+ memcpy(out + out_pos, ESC_SEARCH_START, strlen(ESC_SEARCH_START));
+ out_pos += strlen(ESC_SEARCH_START);
+
+ if (match_orig_end > match_orig_start) {
+ size_t mlen = match_orig_end - match_orig_start;
+ memcpy(out + out_pos, text + match_orig_start, mlen);
+ out_pos += mlen;
+ }
+ memcpy(out + out_pos, ESC_SEARCH_END, strlen(ESC_SEARCH_END));
+ out_pos += strlen(ESC_SEARCH_END);
+
+ orig_pos = match_orig_end;
+ f_pos = match_f_end;
+ }
+
+ if (orig_pos < text_len) {
+ size_t rem = text_len - orig_pos;
+ if (out_pos + rem + 2 >= cap) {
+ cap = out_pos + rem + 16;
+ char *resized = realloc(out, cap);
+ if (resized) out = resized;
+ }
+ memcpy(out + out_pos, text + orig_pos, rem);
+ out_pos += rem;
+ }
+ out[out_pos] = '\0';
+
+ free(folded);
+ free(byte_map);
+ return out;
+}
+
+/* Compute visible display column count of a token (ignoring ANSI SGR sequences \x1b[...m). */
+static int visible_utf8_cols(const char *s, size_t len) {
+ int cols = 0;
+ size_t i = 0;
+ while (i < len && s[i] != '\0') {
+ if (s[i] == '\x1b' && i + 1 < len && s[i + 1] == '[') {
+ i += 2;
+ while (i < len && s[i] != 'm' && s[i] != '\0') {
+ i++;
+ }
+ if (i < len && s[i] == 'm') {
+ i++;
+ }
+ continue;
+ }
+ if (((unsigned char)s[i] & 0xC0) != 0x80) {
+ cols++;
+ }
+ i++;
+ }
+ return cols;
+}
+
+static void print_wrapped(FILE *out, const char *text, int start_col, int indent_col, int max_cols) {
+ if (max_cols <= 0) {
+ fputs(text, out);
+ fputc('\n', out);
+ return;
+ }
+
+ int cur_col = start_col;
+ const char *p = text;
+ bool first_word = true;
+
+ while (*p) {
+ while (*p == ' ') p++;
+ if (*p == '\0') break;
+ const char *w_end = p;
+ while (*w_end && *w_end != ' ') w_end++;
+ size_t w_bytes = (size_t)(w_end - p);
+ int w_cols = visible_utf8_cols(p, w_bytes);
+
+ if (!first_word && cur_col + 1 + w_cols > max_cols && cur_col > indent_col) {
+ fputc('\n', out);
+ for (int i = 0; i < indent_col; i++) {
+ fputc(' ', out);
+ }
+ cur_col = indent_col;
+ fwrite(p, 1, w_bytes, out);
+ cur_col += w_cols;
+ } else {
+ if (!first_word) {
+ fputc(' ', out);
+ cur_col += 1;
+ }
+ fwrite(p, 1, w_bytes, out);
+ cur_col += w_cols;
+ }
+ first_word = false;
+ p = w_end;
+ }
+ fputc('\n', out);
+}
+
+static void render_verse_notes(const pms_verse *v, int next_book, int next_chapter, int next_verse,
+ const pms_config *config, FILE *out, bool *printed_notes) {
+ if (!config->show_notes) {
+ return;
+ }
+ const char *bname = get_book_name(v->book);
+ for (int k = 0; k < pms_notes_length; k++) {
+ if (printed_notes[k]) {
+ continue;
+ }
+ const pms_note *n = &pms_notes[k];
+ if (n->book != v->book || n->chapter != v->chapter) {
+ continue;
+ }
+ if (v->verse < n->verse_start || v->verse > n->verse_end) {
+ continue;
+ }
+ /* If note spans multiple verses (e.g. 5-7) and the next rendered verse is also in the span,
+ wait until the last rendered verse of the span so the note appears right after those verses. */
+ if (next_book == v->book && next_chapter == v->chapter &&
+ next_verse >= n->verse_start && next_verse <= n->verse_end) {
+ continue;
+ }
+
+ printed_notes[k] = true;
+ if (config->pretty) {
+ char prefix[128];
+ int p_cols = snprintf(prefix, sizeof(prefix), " [Nota v. %s] ", n->label);
+ if (config->highlighting) {
+ fprintf(out, " %s[Nota v. %s]%s ", ESC_NOTE_START, n->label, ESC_NOTE_END);
+ } else {
+ fputs(prefix, out);
+ }
+ print_wrapped(out, n->text, p_cols, 8, config->maximum_line_length);
+ } else {
+ if (config->highlighting) {
+ fprintf(out, "%s %d:%s (Nota)\t", bname, v->chapter, n->label);
+ } else {
+ fprintf(out, "%s %d:%s (Nota)\t", bname, v->chapter, n->label);
+ }
+ print_wrapped(out, n->text, 0, 0, config->maximum_line_length);
+ }
+ }
+}
+
+int pms_render(const pms_ref *ref, const pms_config *config, FILE *out) {
+ pms_match_data match_data = {0};
+ int prev_book = -1;
+ int prev_chapter = -1;
+ int prev_verse = -1;
+ int rendered_count = 0;
+
+ bool *printed_notes = NULL;
+ if (config->show_notes && pms_notes_length > 0) {
+ printed_notes = calloc((size_t)pms_notes_length, sizeof(bool));
+ }
+
+ int idx = pms_next_verse(ref, config, &match_data);
+ while (idx != -1) {
+ const pms_verse *v = &pms_verses[idx];
+ int next_idx = pms_next_verse(ref, config, &match_data);
+ int next_book = (next_idx != -1) ? pms_verses[next_idx].book : -1;
+ int next_chap = (next_idx != -1) ? pms_verses[next_idx].chapter : -1;
+ int next_vnum = (next_idx != -1) ? pms_verses[next_idx].verse : -1;
+
+ char *v_text = highlight_search_matches(v->text, ref, config);
+ const char *bname = get_book_name(v->book);
+
+ if (config->pretty) {
+ if (v->book != prev_book || v->chapter != prev_chapter) {
+ if (rendered_count > 0) {
+ fputc('\n', out);
+ }
+ const char *ch_label = (v->book == 21) ? "Salmo" : "";
+ if (config->highlighting) {
+ if (ch_label[0]) {
+ fprintf(out, "%s%s — %s %d%s\n\n", ESC_BOLD_START, bname, ch_label, v->chapter, ESC_BOLD_END);
+ } else {
+ fprintf(out, "%s%s %d%s\n\n", ESC_BOLD_START, bname, v->chapter, ESC_BOLD_END);
+ }
+ } else {
+ if (ch_label[0]) {
+ fprintf(out, "%s — %s %d\n\n", bname, ch_label, v->chapter);
+ } else {
+ fprintf(out, "%s %d\n\n", bname, v->chapter);
+ }
+ }
+ } else if (prev_verse != -1 && v->verse > prev_verse + 1) {
+ fprintf(out, " ...\n");
+ }
+
+ char vprefix[64];
+ int p_cols = snprintf(vprefix, sizeof(vprefix), " %d:%d ", v->chapter, v->verse);
+ if (config->highlighting) {
+ fprintf(out, " %s%d:%d%s ", ESC_VERSE_START, v->chapter, v->verse, ESC_VERSE_END);
+ } else {
+ fputs(vprefix, out);
+ }
+ print_wrapped(out, v_text, p_cols, 6, config->maximum_line_length);
+ } else {
+ char vprefix[128];
+ snprintf(vprefix, sizeof(vprefix), "%s %d:%d\t", bname, v->chapter, v->verse);
+ int p_cols = (int)pms_utf8_char_len(vprefix, strlen(vprefix)) + 4;
+ if (config->highlighting) {
+ fprintf(out, "%s%s %d:%d%s\t", ESC_VERSE_START, bname, v->chapter, v->verse, ESC_VERSE_END);
+ } else {
+ fputs(vprefix, out);
+ }
+ print_wrapped(out, v_text, p_cols, 0, config->maximum_line_length);
+ }
+
+ free(v_text);
+
+ if (printed_notes) {
+ render_verse_notes(v, next_book, next_chap, next_vnum, config, out, printed_notes);
+ }
+
+ prev_book = v->book;
+ prev_chapter = v->chapter;
+ prev_verse = v->verse;
+ rendered_count++;
+ idx = next_idx;
+ }
+
+ free(printed_notes);
+ return rendered_count;
+}
diff --git a/src/pms_render.h b/src/pms_render.h
new file mode 100644
index 0000000..0639488
--- /dev/null
+++ b/src/pms_render.h
@@ -0,0 +1,10 @@
+#ifndef PMS_RENDER_H
+#define PMS_RENDER_H
+
+#include "pms_config.h"
+#include "pms_ref.h"
+#include <stdio.h>
+
+int pms_render(const pms_ref *ref, const pms_config *config, FILE *out);
+
+#endif /* PMS_RENDER_H */
diff --git a/src/pms_util.c b/src/pms_util.c
new file mode 100644
index 0000000..635fa3c
--- /dev/null
+++ b/src/pms_util.c
@@ -0,0 +1,89 @@
+#include "pms_util.h"
+#include <ctype.h>
+#include <string.h>
+
+static char fold_latin1_c3(unsigned char b2) {
+ if ((b2 >= 0x80 && b2 <= 0x85) || (b2 >= 0xA0 && b2 <= 0xA5)) return 'a';
+ if (b2 == 0x87 || b2 == 0xA7) return 'c';
+ if ((b2 >= 0x88 && b2 <= 0x8B) || (b2 >= 0xA8 && b2 <= 0xAB)) return 'e';
+ if ((b2 >= 0x8C && b2 <= 0x8F) || (b2 >= 0xAC && b2 <= 0xAF)) return 'i';
+ if (b2 == 0x91 || b2 == 0xB1) return 'n';
+ if ((b2 >= 0x92 && b2 <= 0x96) || (b2 >= 0xB2 && b2 <= 0xB6)) return 'o';
+ if ((b2 >= 0x99 && b2 <= 0x9C) || (b2 >= 0xB9 && b2 <= 0xBC)) return 'u';
+ if (b2 == 0x9D || b2 == 0xBD || b2 == 0xBF) return 'y';
+ return 0;
+}
+
+size_t pms_fold_utf8(const char *src, char *dst, size_t dst_size, int *byte_map) {
+ if (!src || !dst || dst_size == 0) {
+ return 0;
+ }
+ size_t i = 0;
+ size_t out = 0;
+ while (src[i] != '\0' && out + 1 < dst_size) {
+ unsigned char c = (unsigned char)src[i];
+ if (c < 0x80) {
+ if (byte_map) byte_map[out] = (int)i;
+ dst[out++] = (char)tolower(c);
+ i += 1;
+ } else if (c == 0xC3 && src[i + 1] != '\0') {
+ unsigned char b2 = (unsigned char)src[i + 1];
+ char folded = fold_latin1_c3(b2);
+ if (byte_map) byte_map[out] = (int)i;
+ dst[out++] = folded ? folded : '?';
+ i += 2;
+ } else if ((c & 0xE0) == 0xC0 && src[i + 1] != '\0') {
+ if (byte_map) byte_map[out] = (int)i;
+ dst[out++] = ' ';
+ i += 2;
+ } else if ((c & 0xF0) == 0xE0 && src[i + 1] != '\0' && src[i + 2] != '\0') {
+ /* Handle curly apostrophes U+2018 / U+2019 (E2 80 98 / E2 80 99) and dashes */
+ unsigned char b2 = (unsigned char)src[i + 1];
+ unsigned char b3 = (unsigned char)src[i + 2];
+ char repl = ' ';
+ if (c == 0xE2 && b2 == 0x80 && (b3 == 0x98 || b3 == 0x99)) {
+ repl = '\'';
+ } else if (c == 0xE2 && b2 == 0x80 && (b3 == 0x93 || b3 == 0x94)) {
+ repl = '-';
+ }
+ if (byte_map) byte_map[out] = (int)i;
+ dst[out++] = repl;
+ i += 3;
+ } else if ((c & 0xF8) == 0xF0 && src[i + 1] != '\0' && src[i + 2] != '\0' && src[i + 3] != '\0') {
+ if (byte_map) byte_map[out] = (int)i;
+ dst[out++] = ' ';
+ i += 4;
+ } else {
+ i += 1;
+ }
+ }
+ if (byte_map) {
+ byte_map[out] = (int)strlen(src);
+ }
+ dst[out] = '\0';
+ return out;
+}
+
+size_t pms_normalize_book_key(const char *src, char *dst, size_t dst_size) {
+ char folded[256];
+ pms_fold_utf8(src, folded, sizeof(folded), NULL);
+ size_t out = 0;
+ for (size_t i = 0; folded[i] != '\0' && out + 1 < dst_size; i++) {
+ unsigned char c = (unsigned char)folded[i];
+ if (isalnum(c)) {
+ dst[out++] = (char)c;
+ }
+ }
+ dst[out] = '\0';
+ return out;
+}
+
+size_t pms_utf8_char_len(const char *s, size_t byte_len) {
+ size_t count = 0;
+ for (size_t i = 0; i < byte_len && s[i] != '\0'; i++) {
+ if (((unsigned char)s[i] & 0xC0) != 0x80) {
+ count++;
+ }
+ }
+ return count;
+}
diff --git a/src/pms_util.h b/src/pms_util.h
new file mode 100644
index 0000000..03ae8ba
--- /dev/null
+++ b/src/pms_util.h
@@ -0,0 +1,27 @@
+#ifndef PMS_UTIL_H
+#define PMS_UTIL_H
+
+#include <stddef.h>
+
+/*
+ * Fold a UTF-8 string to lowercase ASCII (stripping Portuguese diacritics).
+ * Writes at most dst_size - 1 characters + NUL to dst.
+ * If byte_map is non-NULL (must have at least dst_size ints), byte_map[i]
+ * stores the byte offset in `src` corresponding to `dst[i]`, and
+ * byte_map[folded_len] stores strlen(src).
+ * Returns the length of the folded string in `dst`.
+ */
+size_t pms_fold_utf8(const char *src, char *dst, size_t dst_size, int *byte_map);
+
+/*
+ * Normalize a book name/abbreviation query into lowercase alphanumeric ASCII
+ * (stripping spaces, dots, hyphens, parentheses, and accents).
+ */
+size_t pms_normalize_book_key(const char *src, char *dst, size_t dst_size);
+
+/*
+ * Count display columns (UTF-8 codepoints) in a NUL-terminated string or slice.
+ */
+size_t pms_utf8_char_len(const char *s, size_t byte_len);
+
+#endif /* PMS_UTIL_H */