diff --git a/Makefile b/Makefile index d3c1a52b5..d9d975448 100644 --- a/Makefile +++ b/Makefile @@ -366,6 +366,7 @@ TIG_OBJS = \ src/grep.o \ src/ui.o \ src/apps.o \ + src/ansi.o \ $(GRAPH_OBJS) \ $(COMPAT_OBJS) @@ -374,10 +375,13 @@ src/tig: $(TIG_OBJS) TEST_GRAPH_OBJS = test/tools/test-graph.o src/string.o src/util.o src/io.o $(GRAPH_OBJS) $(COMPAT_OBJS) test/tools/test-graph: $(TEST_GRAPH_OBJS) +TEST_ANSI_OBJS = test/tools/test-ansi.o src/ansi.o $(COMPAT_OBJS) +test/tools/test-ansi: $(TEST_ANSI_OBJS) + DOC_GEN_OBJS = tools/doc-gen.o src/string.o src/types.o src/util.o src/request.o $(COMPAT_OBJS) tools/doc-gen: $(DOC_GEN_OBJS) -OBJS = $(sort $(TIG_OBJS) $(TEST_GRAPH_OBJS) $(DOC_GEN_OBJS)) +OBJS = $(sort $(TIG_OBJS) $(TEST_GRAPH_OBJS) $(TEST_ANSI_OBJS) $(DOC_GEN_OBJS)) DEPS_CFLAGS ?= -MMD -MP -MF .deps/$*.d diff --git a/doc/tigrc.5.adoc b/doc/tigrc.5.adoc index fa9458b6b..ef528c056 100644 --- a/doc/tigrc.5.adoc +++ b/doc/tigrc.5.adoc @@ -291,6 +291,23 @@ The following variables can be set: regions are governed by `color diff-add-highlight` and `color diff-del-highlight`. +'syntax-highlight' (mixed):: + + Whether to syntax-highlight file contents in diff, stage, blob, and + pager views using an external highlighter. Defaults to false. When set + to true then 'bat' is used, else the option value is used as the path + to the highlighter command. Requires a terminal with 256-color support + (TERM=xterm-256color or equivalent); silently disabled on terminals + with fewer than 256 colors. + + + + When enabled, diff views show syntax-colored code on dark green + (additions) or dark red (deletions) backgrounds. Can be combined with + 'diff-highlight' for intra-line change emphasis. Long lines are wrapped + with a 2-space indent on continuation lines. + + + + The highlighter must accept `--color=always --style=plain --paging=never + --file-name= -` arguments and read from stdin. + 'diff-indicator' (bool):: Show +/- signs in the diff view. On by default. diff --git a/include/tig/ansi.h b/include/tig/ansi.h new file mode 100644 index 000000000..64cbcb138 --- /dev/null +++ b/include/tig/ansi.h @@ -0,0 +1,65 @@ +/* Copyright (c) 2006-2026 Jonas Fonseca + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + */ + +#ifndef TIG_ANSI_H +#define TIG_ANSI_H + +#include "tig/tig.h" + +#define ANSI_MAX_SPANS 512 + +enum ansi_color_type { + ANSI_COLOR_DEFAULT, + ANSI_COLOR_BASIC, /* 0-7: standard ANSI colors */ + ANSI_COLOR_256, /* 0-255: extended palette */ + ANSI_COLOR_RGB /* 24-bit truecolor */ +}; + +struct ansi_color { + enum ansi_color_type type; + union { + int index; + struct { unsigned char r, g, b; } rgb; + }; +}; + +struct ansi_span { + struct ansi_color fg; + struct ansi_color bg; + int attr; /* ncurses attributes: A_BOLD, A_UNDERLINE, etc. */ + size_t offset; /* byte offset in stripped text */ + size_t length; /* byte length of this span */ +}; + +/* + * Parse a line containing ANSI escape sequences. + * + * Strips escape codes from `raw` and writes plain text to `stripped`. + * Records color/attribute spans in `spans`. + * + * Returns the number of spans written, or -1 on error. + */ +int ansi_parse_line(const char *raw, char *stripped, size_t stripped_size, + struct ansi_span *spans, int max_spans); + +/* + * Returns true if the string contains ANSI escape sequences. + */ +static inline bool +ansi_has_escapes(const char *text) +{ + return text && strchr(text, 0x1b) != NULL; +} + +#endif +/* vim: set ts=8 sw=8 noexpandtab: */ diff --git a/include/tig/apps.h b/include/tig/apps.h index 0daef3f63..a45cdcedd 100644 --- a/include/tig/apps.h +++ b/include/tig/apps.h @@ -33,6 +33,12 @@ struct app_external { struct app_external *app_diff_highlight_load(const char *query); +/* + * syntax-highlight (bat) + */ + +struct app_external *app_syntax_highlight_load(const char *query, const char *filename); + #endif /* vim: set ts=8 sw=8 noexpandtab: */ diff --git a/include/tig/diff.h b/include/tig/diff.h index dee60bb7a..a34bd8da4 100644 --- a/include/tig/diff.h +++ b/include/tig/diff.h @@ -30,6 +30,12 @@ struct diff_state { unsigned int lineno; struct position pos; struct io view_io; + /* Syntax highlighting state */ + bool syntax_highlight; + char syntax_file[SIZEOF_STR]; /* Current file from diff +++ header */ + pid_t syntax_pid; /* Persistent bat process */ + int syntax_write_fd; /* Write content to bat stdin */ + FILE *syntax_read_fp; /* Read highlighted output from bat stdout */ }; enum request diff_common_edit(struct view *view, enum request request, struct line *line); @@ -41,6 +47,8 @@ void diff_save_line(struct view *view, struct diff_state *state, enum open_flags void diff_restore_line(struct view *view, struct diff_state *state); enum status_code diff_init_highlight(struct view *view, struct diff_state *state); bool diff_done_highlight(struct diff_state *state); +void diff_init_syntax_highlight(struct diff_state *state); +void diff_done_syntax_highlight(struct diff_state *state); unsigned int diff_get_lineno(struct view *view, struct line *line, bool old); const char *diff_get_pathname(struct view *view, struct line *line, bool old); diff --git a/include/tig/line.h b/include/tig/line.h index 939dc3cd8..b2e78e55e 100644 --- a/include/tig/line.h +++ b/include/tig/line.h @@ -154,5 +154,12 @@ get_line_attr(const char *prefix, enum line_type type) return COLOR_PAIR(COLOR_ID(info->color_pair)) | info->attr; } +/* + * Dynamic color pair allocation for syntax highlighting. + */ +struct ansi_color; +int ansi_color_to_ncurses(const struct ansi_color *color); +int get_dynamic_color_pair(const struct ansi_color *fg, const struct ansi_color *bg); + #endif /* vim: set ts=8 sw=8 noexpandtab: */ diff --git a/include/tig/options.h b/include/tig/options.h index 6c1088d82..ff6166e32 100644 --- a/include/tig/options.h +++ b/include/tig/options.h @@ -84,6 +84,7 @@ typedef struct view_column *view_settings; _(status_show_untracked_dirs, bool, VIEW_STATUS_LIKE) \ _(status_show_untracked_files, bool, VIEW_STATUS_LIKE) \ _(status_view, view_settings, VIEW_NO_FLAGS) \ + _(syntax_highlight, const char *, VIEW_NO_FLAGS) \ _(tab_size, int, VIEW_NO_FLAGS) \ _(tree_view, view_settings, VIEW_NO_FLAGS) \ _(truncation_delimiter, const char *, VIEW_NO_FLAGS) \ diff --git a/include/tig/view.h b/include/tig/view.h index 1460d8da9..196c43b5d 100644 --- a/include/tig/view.h +++ b/include/tig/view.h @@ -28,6 +28,9 @@ struct view_ops; struct box_cell { enum line_type type; size_t length; + unsigned int direct : 1; /* If set, use color_pair/attr directly */ + int color_pair; /* Dynamic ncurses color pair ID */ + int attr; /* A_BOLD | A_UNDERLINE | ... */ }; struct box { diff --git a/src/ansi.c b/src/ansi.c new file mode 100644 index 000000000..d0cbe27a0 --- /dev/null +++ b/src/ansi.c @@ -0,0 +1,240 @@ +/* Copyright (c) 2006-2026 Jonas Fonseca + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + */ + +#include "tig/ansi.h" + +#ifdef HAVE_NCURSESW_CURSES_H +#include +#elif defined(HAVE_NCURSES_CURSES_H) +#include +#elif defined(HAVE_NCURSES_H) +#include +#elif defined(HAVE_CURSES_H) +#include +#endif + +/* + * Parse a semicolon-separated list of SGR parameters. + * Updates the current fg, bg, and attr state. + */ +static void +ansi_parse_sgr(const char *params, size_t len, + struct ansi_color *fg, struct ansi_color *bg, int *attr) +{ + int codes[16]; + int ncodes = 0; + int val = 0; + bool has_val = false; + size_t i; + + /* Parse semicolon-separated integers */ + for (i = 0; i < len && ncodes < 16; i++) { + if (params[i] >= '0' && params[i] <= '9') { + val = val * 10 + (params[i] - '0'); + has_val = true; + } else if (params[i] == ';') { + codes[ncodes++] = has_val ? val : 0; + val = 0; + has_val = false; + } + } + if (has_val && ncodes < 16) + codes[ncodes++] = val; + + /* Empty CSI m is equivalent to CSI 0 m (reset) */ + if (ncodes == 0) { + codes[0] = 0; + ncodes = 1; + } + + for (i = 0; i < (size_t) ncodes; i++) { + int code = codes[i]; + + switch (code) { + case 0: /* Reset */ + fg->type = ANSI_COLOR_DEFAULT; + bg->type = ANSI_COLOR_DEFAULT; + *attr = A_NORMAL; + break; + + case 1: + *attr |= A_BOLD; + break; + case 3: +#ifdef A_ITALIC + *attr |= A_ITALIC; +#endif + break; + case 4: + *attr |= A_UNDERLINE; + break; + case 7: + *attr |= A_REVERSE; + break; + case 22: + *attr &= ~A_BOLD; + break; + case 23: +#ifdef A_ITALIC + *attr &= ~A_ITALIC; +#endif + break; + case 24: + *attr &= ~A_UNDERLINE; + break; + case 27: + *attr &= ~A_REVERSE; + break; + + /* Standard foreground colors 30-37 */ + case 30: case 31: case 32: case 33: + case 34: case 35: case 36: case 37: + fg->type = ANSI_COLOR_BASIC; + fg->index = code - 30; + break; + + /* Default foreground */ + case 39: + fg->type = ANSI_COLOR_DEFAULT; + break; + + /* Standard background colors 40-47 */ + case 40: case 41: case 42: case 43: + case 44: case 45: case 46: case 47: + bg->type = ANSI_COLOR_BASIC; + bg->index = code - 40; + break; + + /* Default background */ + case 49: + bg->type = ANSI_COLOR_DEFAULT; + break; + + /* Bright foreground colors 90-97 */ + case 90: case 91: case 92: case 93: + case 94: case 95: case 96: case 97: + fg->type = ANSI_COLOR_256; + fg->index = code - 90 + 8; + break; + + /* Bright background colors 100-107 */ + case 100: case 101: case 102: case 103: + case 104: case 105: case 106: case 107: + bg->type = ANSI_COLOR_256; + bg->index = code - 100 + 8; + break; + + /* Extended color: 38;5;N (256-color) or 38;2;R;G;B (truecolor) */ + case 38: + if (i + 1 < (size_t) ncodes && codes[i + 1] == 5 + && i + 2 < (size_t) ncodes) { + fg->type = ANSI_COLOR_256; + fg->index = codes[i + 2]; + i += 2; + } else if (i + 1 < (size_t) ncodes && codes[i + 1] == 2 + && i + 4 < (size_t) ncodes) { + fg->type = ANSI_COLOR_RGB; + fg->rgb.r = codes[i + 2]; + fg->rgb.g = codes[i + 3]; + fg->rgb.b = codes[i + 4]; + i += 4; + } + break; + + /* Extended color: 48;5;N or 48;2;R;G;B */ + case 48: + if (i + 1 < (size_t) ncodes && codes[i + 1] == 5 + && i + 2 < (size_t) ncodes) { + bg->type = ANSI_COLOR_256; + bg->index = codes[i + 2]; + i += 2; + } else if (i + 1 < (size_t) ncodes && codes[i + 1] == 2 + && i + 4 < (size_t) ncodes) { + bg->type = ANSI_COLOR_RGB; + bg->rgb.r = codes[i + 2]; + bg->rgb.g = codes[i + 3]; + bg->rgb.b = codes[i + 4]; + i += 4; + } + break; + } + } +} + +int +ansi_parse_line(const char *raw, char *stripped, size_t stripped_size, + struct ansi_span *spans, int max_spans) +{ + struct ansi_color cur_fg = { ANSI_COLOR_DEFAULT, { .index = 0 } }; + struct ansi_color cur_bg = { ANSI_COLOR_DEFAULT, { .index = 0 } }; + int cur_attr = A_NORMAL; + size_t out = 0; /* position in stripped output */ + size_t span_start = 0; /* start of current span in stripped */ + int nspans = 0; + const char *p = raw; + + while (*p && out < stripped_size - 1) { + if (*p == 0x1b && *(p + 1) == '[') { + const char *seq_start = p + 2; + const char *seq_end = seq_start; + struct ansi_color prev_fg = cur_fg; + struct ansi_color prev_bg = cur_bg; + int prev_attr = cur_attr; + + /* Find the end of the CSI sequence (terminated by a letter) */ + while (*seq_end && ((*seq_end >= '0' && *seq_end <= '9') + || *seq_end == ';')) + seq_end++; + + if (*seq_end == 'm') { + /* Finish current span if it has content */ + if (out > span_start && nspans < max_spans) { + spans[nspans].fg = prev_fg; + spans[nspans].bg = prev_bg; + spans[nspans].attr = prev_attr; + spans[nspans].offset = span_start; + spans[nspans].length = out - span_start; + nspans++; + } + + ansi_parse_sgr(seq_start, seq_end - seq_start, + &cur_fg, &cur_bg, &cur_attr); + span_start = out; + p = seq_end + 1; + continue; + } + + /* Not an SGR sequence; skip the ESC[ but copy rest */ + p = seq_start; + continue; + } + + stripped[out++] = *p++; + } + + stripped[out] = '\0'; + + /* Finish the last span */ + if (out > span_start && nspans < max_spans) { + spans[nspans].fg = cur_fg; + spans[nspans].bg = cur_bg; + spans[nspans].attr = cur_attr; + spans[nspans].offset = span_start; + spans[nspans].length = out - span_start; + nspans++; + } + + return nspans; +} + +/* vim: set ts=8 sw=8 noexpandtab: */ diff --git a/src/apps.c b/src/apps.c index 9d433100f..f366e375b 100644 --- a/src/apps.c +++ b/src/apps.c @@ -125,4 +125,52 @@ struct app_external return &dhlt_app; } +/* + * syntax-highlight (bat) + */ + +struct app_external +*app_syntax_highlight_load(const char *query, const char *filename) +{ + static struct app_external bat_app = { { NULL }, { NULL } }; + static char bat_path[SIZEOF_STR]; + static char file_arg[SIZEOF_STR]; + + bat_app.argv[0] = NULL; + + if (!query || !*query) + return &bat_app; + + /* If query contains a path separator, use it directly */ + if (strchr(query, '/') || strchr(query, '~')) { + if (strchr(query, '~')) + path_expand(bat_path, sizeof(bat_path), query); + else + string_ncopy(bat_path, query, strlen(query)); + } else { + const char *env_path = getenv("PATH"); + + if (!env_path || !*env_path) + env_path = _PATH_DEFPATH; + if (!path_search(bat_path, sizeof(bat_path), query, env_path, X_OK)) + return &bat_app; + } + + bat_app.argv[0] = bat_path; + bat_app.argv[1] = "--color=always"; + bat_app.argv[2] = "--style=plain"; + bat_app.argv[3] = "--paging=never"; + if (filename && *filename) { + string_format(file_arg, "--file-name=%s", filename); + bat_app.argv[4] = file_arg; + bat_app.argv[5] = "-"; /* Explicitly read from stdin */ + bat_app.argv[6] = NULL; + } else { + bat_app.argv[4] = "-"; + bat_app.argv[5] = NULL; + } + + return &bat_app; +} + /* vim: set ts=8 sw=8 noexpandtab: */ diff --git a/src/bat.orig b/src/bat.orig new file mode 100755 index 000000000..be3555f93 Binary files /dev/null and b/src/bat.orig differ diff --git a/src/blob.c b/src/blob.c index a8f80076f..f11d96197 100644 --- a/src/blob.c +++ b/src/blob.c @@ -20,10 +20,14 @@ #include "tig/pager.h" #include "tig/tree.h" #include "tig/blob.h" +#include "tig/apps.h" +#include "tig/ansi.h" struct blob_state { char commit[SIZEOF_REF]; const char *file; + bool highlight; + struct io view_io; }; void @@ -60,6 +64,41 @@ open_blob_view(struct view *prev, enum open_flags flags) } } +static enum status_code +blob_init_highlight(struct view *view, struct blob_state *state) +{ + struct app_external *app; + struct io io; + const char *filename; + + if (!opt_syntax_highlight || !*opt_syntax_highlight || COLORS < 256) + return SUCCESS; + + filename = state->file ? state->file : view->env->file; + app = app_syntax_highlight_load(opt_syntax_highlight, filename); + + if (!app->argv[0] || !*app->argv[0]) + return SUCCESS; + + if (!io_exec(&io, IO_RP, view->dir, app->env, app->argv, view->io.pipe)) + return SUCCESS; + + state->view_io = view->io; + view->io = io; + state->highlight = true; + + return SUCCESS; +} + +static bool +blob_done_highlight(struct blob_state *state) +{ + if (!state->highlight) + return true; + io_kill(&state->view_io); + return io_done(&state->view_io); +} + static enum status_code blob_open(struct view *view, enum open_flags flags) { @@ -100,13 +139,58 @@ blob_open(struct view *view, enum open_flags flags) else string_copy_rev(view->ref, view->ops->id); - return begin_update(view, NULL, argv, flags); + state->highlight = false; + + { + enum status_code code = begin_update(view, NULL, argv, flags); + if (code != SUCCESS) + return code; + } + + return blob_init_highlight(view, state); +} + +static bool +blob_add_highlighted_line(struct view *view, const char *stripped, + struct ansi_span *spans, int nspans) +{ + struct line *line; + struct box *box; + struct ansi_color default_bg = { ANSI_COLOR_DEFAULT, { .index = 0 } }; + int i; + + line = add_line_text_at(view, view->lines, stripped, LINE_DEFAULT, nspans); + if (!line) + return false; + + box = line->data; + + /* Overwrite the single cell that add_line_text_at created + * with our per-span cells (same pattern as diff_common_add_line) */ + for (i = 0; i < nspans; i++) { + memset(&box->cell[i], 0, sizeof(box->cell[i])); + box->cell[i].type = LINE_DEFAULT; + box->cell[i].length = spans[i].length; + box->cell[i].direct = 1; + box->cell[i].color_pair = get_dynamic_color_pair(&spans[i].fg, &default_bg); + box->cell[i].attr = spans[i].attr; + } + box->cells = nspans; + + return true; } static bool blob_read(struct view *view, struct buffer *buf, bool force_stop) { + struct blob_state *state = view->private; + if (!buf) { + if (!blob_done_highlight(state)) { + if (!force_stop) + report("Failed to run syntax highlighter: %s", opt_syntax_highlight); + return false; + } if (view->env->goto_lineno > 0) { select_view_line(view, view->env->goto_lineno); view->env->goto_lineno = 0; @@ -114,6 +198,17 @@ blob_read(struct view *view, struct buffer *buf, bool force_stop) return true; } + if (state->highlight && ansi_has_escapes(buf->data)) { + char stripped[SIZEOF_STR]; + struct ansi_span spans[ANSI_MAX_SPANS]; + int nspans; + + nspans = ansi_parse_line(buf->data, stripped, sizeof(stripped), + spans, ANSI_MAX_SPANS); + if (nspans > 0) + return blob_add_highlighted_line(view, stripped, spans, nspans); + } + return pager_common_read(view, buf->data, LINE_DEFAULT, NULL); } diff --git a/src/diff.c b/src/diff.c index 6c670e4b4..51c104dfb 100644 --- a/src/diff.c +++ b/src/diff.c @@ -11,6 +11,7 @@ * GNU General Public License for more details. */ +#include #include "tig/argv.h" #include "tig/refdb.h" #include "tig/repo.h" @@ -21,6 +22,7 @@ #include "tig/diff.h" #include "tig/draw.h" #include "tig/apps.h" +#include "tig/ansi.h" static enum status_code diff_open(struct view *view, enum open_flags flags) @@ -40,6 +42,8 @@ diff_open(struct view *view, enum open_flags flags) if (code != SUCCESS) return code; + diff_init_syntax_highlight(view->private); + return diff_init_highlight(view, view->private); } @@ -80,6 +84,545 @@ diff_done_highlight(struct diff_state *state) return io_done(&state->view_io); } +/* + * Syntax highlighting for diff content lines. + * Composes syntax foreground colors with diff background colors. + */ + +void +diff_init_syntax_highlight(struct diff_state *state) +{ + state->syntax_highlight = opt_syntax_highlight && *opt_syntax_highlight + && COLORS >= 256; + state->syntax_file[0] = '\0'; + state->syntax_pid = 0; + state->syntax_write_fd = -1; + state->syntax_read_fp = NULL; +} + +static void +syntax_pipe_close(struct diff_state *state) +{ + if (state->syntax_write_fd >= 0) { + close(state->syntax_write_fd); + state->syntax_write_fd = -1; + } + if (state->syntax_read_fp) { + fclose(state->syntax_read_fp); + state->syntax_read_fp = NULL; + } + if (state->syntax_pid > 0) { + kill(state->syntax_pid, SIGTERM); + waitpid(state->syntax_pid, NULL, 0); + state->syntax_pid = 0; + } +} + +static bool +syntax_pipe_open(struct diff_state *state, const char *filename) +{ + int stdin_pipe[2], stdout_pipe[2]; + pid_t pid; + char bat_path[SIZEOF_STR]; + const char *env_path; + + /* Close existing pipe if any */ + syntax_pipe_close(state); + + /* Find bat */ + env_path = getenv("PATH"); + if (!env_path || !*env_path) + env_path = _PATH_DEFPATH; + if (!path_search(bat_path, sizeof(bat_path), opt_syntax_highlight, env_path, X_OK)) + return false; + + if (pipe(stdin_pipe) < 0) + return false; + if (pipe(stdout_pipe) < 0) { + close(stdin_pipe[0]); + close(stdin_pipe[1]); + return false; + } + + pid = fork(); + if (pid < 0) { + close(stdin_pipe[0]); close(stdin_pipe[1]); + close(stdout_pipe[0]); close(stdout_pipe[1]); + return false; + } + + if (pid == 0) { + /* Child: bat process */ + char file_arg[SIZEOF_STR]; + + close(stdin_pipe[1]); + close(stdout_pipe[0]); + dup2(stdin_pipe[0], STDIN_FILENO); + dup2(stdout_pipe[1], STDOUT_FILENO); + close(stdin_pipe[0]); + close(stdout_pipe[1]); + + string_format(file_arg, "--file-name=%s", filename); + execlp("stdbuf", "stdbuf", "-oL", + bat_path, "--color=always", "--style=plain", + "--paging=never", file_arg, "-", NULL); + /* If stdbuf not available, try without */ + execlp(bat_path, bat_path, "--color=always", "--style=plain", + "--paging=never", file_arg, "-", NULL); + _exit(127); + } + + /* Parent */ + close(stdin_pipe[0]); + close(stdout_pipe[1]); + + state->syntax_pid = pid; + state->syntax_write_fd = stdin_pipe[1]; + state->syntax_read_fp = fdopen(stdout_pipe[0], "r"); + if (!state->syntax_read_fp) { + syntax_pipe_close(state); + return false; + } + + return true; +} + +void +diff_done_syntax_highlight(struct diff_state *state) +{ + syntax_pipe_close(state); +} + +static void +diff_track_filename(struct diff_state *state, const char *data, enum line_type type) +{ + /* Extract filename from "+++ b/path" header lines */ + if (type == LINE_DIFF_ADD_FILE && !state->reading_diff_chunk) { + const char *path = data; + + /* Skip "+++ " prefix */ + if (!prefixcmp(path, "+++ ")) + path += 4; + /* Skip "b/" prefix from git diffs */ + if (!prefixcmp(path, "b/")) + path += 2; + + /* Open new bat pipe if file changed */ + if (strcmp(state->syntax_file, path)) { + string_ncopy(state->syntax_file, path, strlen(path)); + syntax_pipe_open(state, state->syntax_file); + } + } +} + +static int +diff_bg_color_for_type(enum line_type type) +{ + /* Map diff line types to background color indices. + * Use dark 256-color shades so syntax fg colors remain readable. + * 22 = dark green, 52 = dark red */ + switch (type) { + case LINE_DIFF_ADD: + case LINE_DIFF_ADD2: + return 22; /* dark green background */ + case LINE_DIFF_DEL: + case LINE_DIFF_DEL2: + return 52; /* dark red background */ + default: + return -1; /* default background */ + } +} + +static int +diff_bg_emphasis_for_type(enum line_type type) +{ + /* Brighter background for diff-highlight emphasized regions. + * 28 = medium green, 88 = medium red */ + switch (type) { + case LINE_DIFF_ADD: + case LINE_DIFF_ADD2: + return 65; /* muted olive-green background */ + case LINE_DIFF_DEL: + case LINE_DIFF_DEL2: + return 88; /* medium red background */ + default: + return -1; + } +} + +static int +diff_prefix_fg_for_type(enum line_type type) +{ + /* Bright green/red for the +/- prefix characters */ + switch (type) { + case LINE_DIFF_ADD: + case LINE_DIFF_ADD2: + return 2; /* COLOR_GREEN */ + case LINE_DIFF_DEL: + case LINE_DIFF_DEL2: + return 1; /* COLOR_RED */ + default: + return -1; + } +} + +static bool +diff_syntax_highlight_line(struct view *view, const char *data, + enum line_type type, struct diff_state *state) +{ + char highlighted[SIZEOF_STR * 2] = ""; + char stripped[SIZEOF_STR]; + char full_line[SIZEOF_STR]; + struct ansi_span spans[ANSI_MAX_SPANS]; + int nspans, i; + int diff_bg; + struct ansi_color bg_color; + struct line *line; + struct box *box; + size_t prefix_len = 0; + const char *content = data; + int total_cells; + + if (!state->syntax_read_fp || state->syntax_write_fd < 0) + return false; + + /* Detect and strip the +/- prefix before sending to bat */ + if (type == LINE_DIFF_ADD || type == LINE_DIFF_ADD2 || + type == LINE_DIFF_DEL || type == LINE_DIFF_DEL2) { + prefix_len = state->parents; + content = data + prefix_len; + } else if (data[0] == ' ') { + /* Context line */ + prefix_len = state->parents; + content = data + prefix_len; + } + + /* Empty content (e.g. bare "+" or "-" line): skip bat, + * just create a line with the prefix and background fill */ + if (!*content) { + diff_bg = diff_bg_color_for_type(type); + bg_color.type = (diff_bg >= 0) ? ANSI_COLOR_256 : ANSI_COLOR_DEFAULT; + bg_color.index = diff_bg; + total_cells = 1; + + line = add_line_text_at(view, view->lines, data, type, total_cells); + if (!line) + return false; + box = line->data; + + if (prefix_len > 0) { + int pfx_fg = diff_prefix_fg_for_type(type); + struct ansi_color pfx_fg_color; + + pfx_fg_color.type = (pfx_fg >= 0) ? ANSI_COLOR_BASIC : ANSI_COLOR_DEFAULT; + pfx_fg_color.index = pfx_fg; + + memset(&box->cell[0], 0, sizeof(box->cell[0])); + box->cell[0].type = type; + box->cell[0].length = prefix_len; + box->cell[0].direct = 1; + box->cell[0].color_pair = get_dynamic_color_pair(&pfx_fg_color, &bg_color); + box->cell[0].attr = A_BOLD; + } else { + memset(&box->cell[0], 0, sizeof(box->cell[0])); + box->cell[0].type = type; + box->cell[0].length = strlen(data); + box->cell[0].direct = 1; + box->cell[0].color_pair = get_dynamic_color_pair( + &(struct ansi_color){ ANSI_COLOR_DEFAULT, { .index = 0 } }, &bg_color); + } + box->cells = total_cells; + return true; + } + + /* If diff-highlight added reverse-video codes, strip them before + * sending to bat, but record which byte ranges are emphasized */ + { + char clean_content[SIZEOF_STR]; + struct ansi_span dh_spans[ANSI_MAX_SPANS]; + int dh_nspans = 0; + bool has_emphasis = ansi_has_escapes(content); + + if (has_emphasis) { + dh_nspans = ansi_parse_line(content, clean_content, + sizeof(clean_content), + dh_spans, ANSI_MAX_SPANS); + content = clean_content; + } + + /* Write clean content (no ANSI) to bat's stdin */ + { + size_t content_len = strlen(content); + + if (write(state->syntax_write_fd, content, content_len) < 0 || + write(state->syntax_write_fd, "\n", 1) < 0) { + syntax_pipe_close(state); + return false; + } + } + + /* Read highlighted line from bat's stdout */ + if (!fgets(highlighted, sizeof(highlighted), state->syntax_read_fp)) { + syntax_pipe_close(state); + return false; + } + + /* Strip trailing newline */ + { + size_t len = strlen(highlighted); + if (len > 0 && highlighted[len - 1] == '\n') + highlighted[len - 1] = '\0'; + } + + /* Parse ANSI from bat's output */ + nspans = ansi_parse_line(highlighted, stripped, sizeof(stripped), + spans, ANSI_MAX_SPANS); + if (nspans <= 0) + return false; + + /* Merge diff-highlight emphasis: split syntax spans at + * emphasis boundaries so only the changed characters + * get the brighter background */ + if (has_emphasis && dh_nspans > 0) { + int emph_bg = diff_bg_emphasis_for_type(type); + struct ansi_span merged[ANSI_MAX_SPANS]; + int nmerged = 0; + + /* Build a reverse-video bitmap */ + char is_emph[SIZEOF_STR] = {0}; + int j; + + for (j = 0; j < dh_nspans; j++) { + if (!(dh_spans[j].attr & A_REVERSE)) + continue; + size_t k; + for (k = dh_spans[j].offset; + k < dh_spans[j].offset + dh_spans[j].length + && k < sizeof(is_emph); k++) + is_emph[k] = 1; + } + + /* Split each syntax span at emphasis boundaries */ + for (i = 0; i < nspans && nmerged < ANSI_MAX_SPANS - 1; i++) { + size_t pos = spans[i].offset; + size_t end = pos + spans[i].length; + + while (pos < end && nmerged < ANSI_MAX_SPANS - 1) { + bool emph = pos < sizeof(is_emph) && is_emph[pos]; + size_t run = pos; + + /* Find run of same emphasis state */ + while (run < end && run < sizeof(is_emph) + && (is_emph[run] ? 1 : 0) == emph) + run++; + if (run >= sizeof(is_emph)) + run = end; + + merged[nmerged] = spans[i]; + merged[nmerged].offset = pos; + merged[nmerged].length = run - pos; + if (emph && emph_bg >= 0) { + merged[nmerged].bg.type = ANSI_COLOR_256; + merged[nmerged].bg.index = emph_bg; + } + nmerged++; + pos = run; + } + } + + memcpy(spans, merged, sizeof(spans[0]) * nmerged); + nspans = nmerged; + } + } + + /* Reconstruct full line: prefix + highlighted content */ + if (prefix_len > 0) { + memcpy(full_line, data, prefix_len); + memcpy(full_line + prefix_len, stripped, strlen(stripped) + 1); + /* Adjust span offsets to account for prefix */ + for (i = 0; i < nspans; i++) + spans[i].offset += prefix_len; + } else { + memcpy(full_line, stripped, strlen(stripped) + 1); + } + + /* Determine background color for this diff line type */ + diff_bg = diff_bg_color_for_type(type); + bg_color.type = (diff_bg >= 0) ? ANSI_COLOR_256 : ANSI_COLOR_DEFAULT; + bg_color.index = diff_bg; + + /* + * Build cells and wrap long lines GitHub-style: + * - First line: prefix (+/-) + content, up to view width + * - Continuation lines: 2-space indent + content, marked as wrapped + */ + { + const char *text_ptr = full_line; + size_t text_remaining = strlen(full_line); + int wrap_indent = 2; /* spaces for continuation indent */ + bool first_line = true; + bool has_first = false; + unsigned int lineno = 0; + + /* Pre-compute color pairs for all spans + prefix */ + struct box_cell all_cells[ANSI_MAX_SPANS + 1]; + int ncells = 0; + + if (prefix_len > 0) { + int pfx_fg = diff_prefix_fg_for_type(type); + struct ansi_color pfx_fg_color; + + pfx_fg_color.type = (pfx_fg >= 0) ? ANSI_COLOR_BASIC : ANSI_COLOR_DEFAULT; + pfx_fg_color.index = pfx_fg; + + memset(&all_cells[0], 0, sizeof(all_cells[0])); + all_cells[0].type = type; + all_cells[0].length = prefix_len; + all_cells[0].direct = 1; + all_cells[0].color_pair = get_dynamic_color_pair(&pfx_fg_color, &bg_color); + all_cells[0].attr = A_BOLD; + ncells = 1; + } + + for (i = 0; i < nspans; i++) { + memset(&all_cells[ncells], 0, sizeof(all_cells[0])); + all_cells[ncells].type = type; + all_cells[ncells].length = spans[i].length; + all_cells[ncells].direct = 1; + all_cells[ncells].color_pair = get_dynamic_color_pair(&spans[i].fg, + spans[i].bg.type != ANSI_COLOR_DEFAULT ? &spans[i].bg : &bg_color); + all_cells[ncells].attr = spans[i].attr; + ncells++; + } + + /* Check total text width */ + { + int total_width = 0; + const char *p = full_line; + while (*p) { + if (*p == '\t') + total_width += opt_tab_size - (total_width % opt_tab_size); + else + total_width++; + p++; + } + + /* If it fits in one line, just create a single line */ + if (total_width <= view->width) { + line = add_line_text_at(view, view->lines, full_line, type, ncells); + if (!line) + return false; + box = line->data; + memcpy(box->cell, all_cells, sizeof(all_cells[0]) * ncells); + box->cells = ncells; + return true; + } + } + + /* Wrap: split text into chunks that fit view width */ + int cell_idx = 0; + size_t cell_offset = 0; /* bytes consumed within current cell */ + + while (text_remaining > 0 && cell_idx < ncells) { + int avail_width = view->width; + char line_buf[SIZEOF_STR]; + size_t line_len = 0; + int line_width = 0; + struct box_cell line_cell_buf[ANSI_MAX_SPANS + 2]; + int line_ncells = 0; + + /* On continuation lines, add a colored indent + * (we handle this ourselves instead of using + * line->wrapped to keep the diff background) */ + if (!first_line) { + int indent = wrap_indent < avail_width ? wrap_indent : 1; + + /* Leading spaces for indent */ + memset(line_buf, ' ', indent); + line_len = indent; + line_width = indent; + + /* Indent cell with diff background */ + memset(&line_cell_buf[0], 0, sizeof(line_cell_buf[0])); + line_cell_buf[0].type = type; + line_cell_buf[0].length = indent; + line_cell_buf[0].direct = 1; + line_cell_buf[0].color_pair = get_dynamic_color_pair( + &(struct ansi_color){ ANSI_COLOR_DEFAULT, { .index = 0 } }, + &bg_color); + line_ncells = 1; + } + + /* Fill line with text from cells until we hit the width */ + while (cell_idx < ncells && line_width < avail_width) { + size_t cell_remaining = all_cells[cell_idx].length - cell_offset; + const char *cell_text = text_ptr; + size_t bytes_to_take = 0; + int width_taken = 0; + + /* Measure how much of this cell fits */ + for (size_t b = 0; b < cell_remaining && line_width + width_taken < avail_width; b++) { + if (cell_text[b] == '\t') + width_taken += opt_tab_size - ((line_width + width_taken) % opt_tab_size); + else + width_taken++; + bytes_to_take = b + 1; + } + + if (bytes_to_take > 0 && line_len + bytes_to_take < sizeof(line_buf) - 1) { + memcpy(line_buf + line_len, cell_text, bytes_to_take); + line_len += bytes_to_take; + line_width += width_taken; + text_ptr += bytes_to_take; + text_remaining -= bytes_to_take; + + /* Add a cell for this chunk */ + memset(&line_cell_buf[line_ncells], 0, sizeof(line_cell_buf[0])); + line_cell_buf[line_ncells].type = all_cells[cell_idx].type; + line_cell_buf[line_ncells].length = bytes_to_take; + line_cell_buf[line_ncells].direct = 1; + line_cell_buf[line_ncells].color_pair = all_cells[cell_idx].color_pair; + line_cell_buf[line_ncells].attr = all_cells[cell_idx].attr; + line_ncells++; + + cell_offset += bytes_to_take; + if (cell_offset >= all_cells[cell_idx].length) { + cell_idx++; + cell_offset = 0; + } + } else { + break; + } + } + + if (line_len == 0) + break; + + line_buf[line_len] = '\0'; + + /* Create the view line */ + line = add_line_text_at_(view, view->lines, line_buf, line_len, + type, line_ncells, false); + if (!line) + return false; + + box = line->data; + memcpy(box->cell, line_cell_buf, sizeof(line_cell_buf[0]) * line_ncells); + box->cells = line_ncells; + + if (!has_first) { + has_first = true; + lineno = line->lineno; + } + + line->lineno = lineno; + first_line = false; + } + + return has_first; + } +} + struct diff_stat_context { const char *text; enum line_type type; @@ -100,6 +643,7 @@ diff_common_add_cell(struct diff_stat_context *context, size_t length, bool allo } if (context->skip && !argv_appendn(&context->cell_text, context->text, length)) return false; + memset(&context->cell[context->cells], 0, sizeof(context->cell[0])); context->cell[context->cells].length = length; context->cell[context->cells].type = context->type; context->cells++; @@ -305,7 +849,20 @@ diff_common_highlight(struct view *view, const char *text, enum line_type type) bool diff_common_read(struct view *view, const char *data, struct diff_state *state) { - enum line_type type = get_line_type(data); + char stripped_buf[SIZEOF_STR]; + const char *clean_data = data; + enum line_type type; + + /* If data contains ANSI codes (from bat pipe), strip them for + * line type detection, but keep original for rendering. */ + if (state->syntax_highlight && ansi_has_escapes(data)) { + struct ansi_span spans[1]; + + ansi_parse_line(data, stripped_buf, sizeof(stripped_buf), spans, 0); + clean_data = stripped_buf; + } + + type = get_line_type(clean_data); /* ADD2 and DEL2 are only valid in combined diff hunks */ if (!state->combined_diff && (type == LINE_DIFF_ADD2 || type == LINE_DIFF_DEL2)) @@ -324,11 +881,11 @@ diff_common_read(struct view *view, const char *data, struct diff_state *state) /* combined diffs lack LINE_DIFF_START and we don't know * if this is a combined diff until we see a "@@@" */ - if (!state->after_diff && data[0] == ' ' && data[1] != ' ') + if (!state->after_diff && clean_data[0] == ' ' && clean_data[1] != ' ') state->reading_diff_stat = true; if (state->reading_diff_stat) { - if (diff_common_add_diff_stat(view, data, 0)) + if (diff_common_add_diff_stat(view, clean_data, 0)) return true; state->reading_diff_stat = false; @@ -336,8 +893,8 @@ diff_common_read(struct view *view, const char *data, struct diff_state *state) state->reading_diff_stat = true; } - if (!state->after_commit_title && !prefixcmp(data, " ")) { - struct line *line = add_line_text(view, data, LINE_DEFAULT); + if (!state->after_commit_title && !prefixcmp(clean_data, " ")) { + struct line *line = add_line_text(view, clean_data, LINE_DEFAULT); if (line) line->commit_title = 1; @@ -345,15 +902,19 @@ diff_common_read(struct view *view, const char *data, struct diff_state *state) return line != NULL; } + /* Track filename from +++ headers for syntax highlighting */ + if (state->syntax_highlight && !state->reading_diff_chunk) + diff_track_filename(state, clean_data, type); + if (type == LINE_DIFF_HEADER) { state->after_diff = true; state->reading_diff_chunk = false; } else if (type == LINE_DIFF_CHUNK) { - const unsigned int len = chunk_header_marker_length(data); - const char *context = strstr(data + len, "@@"); + const unsigned int len = chunk_header_marker_length(clean_data); + const char *context = strstr(clean_data + len, "@@"); struct line *line = - context ? add_line_text_at(view, view->lines, data, LINE_DIFF_CHUNK, len) + context ? add_line_text_at(view, view->lines, clean_data, LINE_DIFF_CHUNK, len) : NULL; struct box *box; @@ -361,7 +922,7 @@ diff_common_read(struct view *view, const char *data, struct diff_state *state) return false; box = line->data; - box->cell[0].length = (context + len) - data; + box->cell[0].length = (context + len) - clean_data; box->cell[1].length = strlen(context + len); box->cell[box->cells++].type = LINE_DIFF_STAT; state->combined_diff = (len > 2); @@ -377,16 +938,29 @@ diff_common_read(struct view *view, const char *data, struct diff_state *state) if (opt_word_diff && state->reading_diff_chunk && /* combined diff format is not using word diff */ !state->combined_diff) - return diff_common_read_diff_wdiff(view, data); + return diff_common_read_diff_wdiff(view, clean_data); if (!opt_diff_indicator && state->reading_diff_chunk && - !state->stage) + !state->stage) { data += state->parents; + clean_data += state->parents; + } + + /* Syntax highlight content lines via bat. + * Pass original data (not clean_data) so diff-highlight's + * reverse-video codes can be detected and preserved. */ + if (state->syntax_highlight && state->reading_diff_chunk + && (type == LINE_DIFF_ADD || type == LINE_DIFF_DEL || type == LINE_DEFAULT) + && diff_syntax_highlight_line(view, data, type, state)) + return true; - if (state->highlight && strchr(data, 0x1b)) - return diff_common_highlight(view, data, type); + if (strchr(data, 0x1b)) { + /* diff-highlight: parse reverse-video codes */ + if (state->highlight) + return diff_common_highlight(view, data, type); + } - return pager_common_read(view, data, type, NULL); + return pager_common_read(view, clean_data, type, NULL); } static bool @@ -529,6 +1103,7 @@ diff_read(struct view *view, struct buffer *buf, bool force_stop) return diff_read_describe(view, buf, state); if (!buf) { + diff_done_syntax_highlight(state); if (!diff_done_highlight(state)) { if (!force_stop) report("Failed to run the diff-highlight program: %s", opt_diff_highlight); diff --git a/src/draw.c b/src/draw.c index 39600c4fe..e01cdd968 100644 --- a/src/draw.c +++ b/src/draw.c @@ -600,12 +600,90 @@ view_column_draw(struct view *view, struct line *line, unsigned int lineno) indent = 0; } - if (draw_textn(view, cell->type, text, length)) + if (cell->direct && !view->curline->selected) { + int pair_attr = COLOR_PAIR(COLOR_ID(cell->color_pair)) | cell->attr; + int max_width = VIEW_MAX_LEN(view); + + (void) wattrset(view->win, pair_attr); + view->curtype = LINE_NONE; + + if (max_width > 0 && length > 0) { + /* Expand tabs relative to current screen + * column so alignment matches normal tig. */ + char expanded[1024]; + size_t esize = 0; + int pos; + int cur_col = view->col; + int col = 0; + int trimmed = false; + size_t skip = view->pos.col > view->col + ? view->pos.col - view->col : 0; + const char *s; + int len; + + for (pos = 0; pos < length && esize < sizeof(expanded) - 1; pos++) { + if (text[pos] == '\t') { + int exp = opt_tab_size - ((cur_col + esize) % opt_tab_size); + if (esize + exp >= sizeof(expanded) - 1) + exp = sizeof(expanded) - 1 - esize; + memset(expanded + esize, ' ', exp); + esize += exp; + } else { + expanded[esize++] = text[pos]; + } + } + expanded[esize] = '\0'; + + s = expanded; + len = utf8_length(&s, esize, skip, + &col, max_width, &trimmed, + false, opt_tab_size); + if (len > 0) + waddnstr(view->win, s, len); + view->col += col; + } + + if (VIEW_MAX_LEN(view) <= 0) { + text += length; + break; + } + } else if (draw_textn(view, cell->type, text, length)) { return true; + } text += length; } + /* Fill rest of line with the last direct cell's + * background color for full-width diff bars. + * Skip when selected — the cursor bar already + * extends to end of line via wchgat(-1). */ + if (box->cells > 0 && box->cell[0].direct + && !view->curline->selected) { + int remaining = VIEW_MAX_LEN(view); + + if (remaining > 0) { + int pair = box->cell[box->cells - 1].color_pair; + int attr = COLOR_PAIR(COLOR_ID(pair)); + static const char spaces[] = + " " + " " + " " + " " + " " + " " + " " + " "; + int fill = remaining < (int) sizeof(spaces) - 1 + ? remaining : (int) sizeof(spaces) - 1; + + (void) wattrset(view->win, attr); + view->curtype = LINE_NONE; + waddnstr(view->win, spaces, fill); + view->col += fill; + } + } + } else if (draw_text(view, type, text)) { return true; } diff --git a/src/line.c b/src/line.c index e4c0581d1..b16cc44be 100644 --- a/src/line.c +++ b/src/line.c @@ -15,6 +15,7 @@ #include "tig/types.h" #include "tig/refdb.h" #include "tig/line.h" +#include "tig/ansi.h" #include "tig/util.h" static struct line_rule *line_rule; @@ -251,4 +252,122 @@ init_colors(void) } } +/* + * Dynamic color pair allocation for syntax highlighting. + * + * Maps arbitrary (fg, bg) ncurses color indices to color pair IDs, + * allocating new pairs on demand via init_pair(). + */ + +#define DYN_PAIR_BUCKETS 256 + +struct dyn_pair_entry { + int fg; + int bg; + int pair_id; + struct dyn_pair_entry *next; +}; + +static struct dyn_pair_entry *dyn_pair_table[DYN_PAIR_BUCKETS]; +static int dyn_pair_next_id; +static bool dyn_pair_initialized; + +static unsigned int +dyn_pair_hash(int fg, int bg) +{ + unsigned int h = (unsigned int)(fg * 257 + bg); + return h % DYN_PAIR_BUCKETS; +} + +/* + * Convert an RGB color to the nearest xterm-256 color index. + * Uses the 6x6x6 color cube (indices 16-231) and grayscale ramp (232-255). + */ +static int +rgb_to_256(unsigned char r, unsigned char g, unsigned char b) +{ + int ri, gi, bi, ci; + int gray, gray_idx; + int cube_r, cube_g, cube_b; + int cube_dist, gray_dist; + + /* Map to 6x6x6 cube */ + ri = (r < 48) ? 0 : (r < 115) ? 1 : (r - 35) / 40; + gi = (g < 48) ? 0 : (g < 115) ? 1 : (g - 35) / 40; + bi = (b < 48) ? 0 : (b < 115) ? 1 : (b - 35) / 40; + ci = 16 + 36 * ri + 6 * gi + bi; + + /* Cube color values for comparison */ + cube_r = ri ? 55 + ri * 40 : 0; + cube_g = gi ? 55 + gi * 40 : 0; + cube_b = bi ? 55 + bi * 40 : 0; + cube_dist = (r - cube_r) * (r - cube_r) + + (g - cube_g) * (g - cube_g) + + (b - cube_b) * (b - cube_b); + + /* Check grayscale ramp (232-255, values 8,18,28,...,238) */ + gray = (r + g + b) / 3; + gray_idx = (gray < 4) ? 0 : (gray > 243) ? 23 : (gray - 4) / 10; + gray = 8 + gray_idx * 10; + gray_dist = (r - gray) * (r - gray) + + (g - gray) * (g - gray) + + (b - gray) * (b - gray); + + return (gray_dist < cube_dist) ? 232 + gray_idx : ci; +} + +int +ansi_color_to_ncurses(const struct ansi_color *color) +{ + switch (color->type) { + case ANSI_COLOR_DEFAULT: + return -1; /* COLOR_DEFAULT in ncurses */ + case ANSI_COLOR_BASIC: + return color->index; /* 0-7 maps directly to ncurses COLOR_* */ + case ANSI_COLOR_256: + return color->index; + case ANSI_COLOR_RGB: + return rgb_to_256(color->rgb.r, color->rgb.g, color->rgb.b); + } + return -1; +} + +int +get_dynamic_color_pair(const struct ansi_color *fg, const struct ansi_color *bg) +{ + int ncurses_fg = ansi_color_to_ncurses(fg); + int ncurses_bg = ansi_color_to_ncurses(bg); + unsigned int bucket = dyn_pair_hash(ncurses_fg, ncurses_bg); + struct dyn_pair_entry *entry; + + if (!dyn_pair_initialized) { + /* Start dynamic IDs after the static ones */ + dyn_pair_next_id = color_pairs; + dyn_pair_initialized = true; + } + + /* Look up in hash table */ + for (entry = dyn_pair_table[bucket]; entry; entry = entry->next) { + if (entry->fg == ncurses_fg && entry->bg == ncurses_bg) + return entry->pair_id; + } + + /* Allocate a new pair if we haven't exhausted them */ + if (COLOR_ID(dyn_pair_next_id) >= COLOR_PAIRS) + return 0; /* fallback to default pair */ + + entry = calloc(1, sizeof(*entry)); + if (!entry) + return 0; + + entry->fg = ncurses_fg; + entry->bg = ncurses_bg; + entry->pair_id = dyn_pair_next_id++; + entry->next = dyn_pair_table[bucket]; + dyn_pair_table[bucket] = entry; + + init_pair(COLOR_ID(entry->pair_id), ncurses_fg, ncurses_bg); + return entry->pair_id; +} + /* vim: set ts=8 sw=8 noexpandtab: */ diff --git a/src/options.c b/src/options.c index f83a46555..a0c40146f 100644 --- a/src/options.c +++ b/src/options.c @@ -745,6 +745,18 @@ parse_option(struct option_info *option, const char *prefix, const char *arg) } } + if (option->value == &opt_syntax_highlight) { + bool enabled = false; + + if (parse_bool(&enabled, arg) == SUCCESS) { + if (!enabled) { + *value = NULL; + return SUCCESS; + } + arg = "bat"; + } + } + if (strlen(arg)) { if (arg[0] == '"' && arg[strlen(arg) - 1] == '"') alloc = strndup(arg + 1, strlen(arg + 1) - 1); diff --git a/src/pager.c b/src/pager.c index b7f0bf5f2..eb95fe333 100644 --- a/src/pager.c +++ b/src/pager.c @@ -141,6 +141,7 @@ pager_read(struct view *view, struct buffer *buf, bool force_stop) do_scroll_view(view, 1); if (!buf) { + diff_done_syntax_highlight(view->private); if (!diff_done_highlight(view->private)) { if (!force_stop) report("Failed to run the diff-highlight program: %s", opt_diff_highlight); @@ -212,6 +213,7 @@ pager_open(struct view *view, enum open_flags flags) if (code != SUCCESS) return code; + diff_init_syntax_highlight(view->private); return diff_init_highlight(view, view->private); } diff --git a/src/stage.c b/src/stage.c index 9b4ddec60..5266fa4dd 100644 --- a/src/stage.c +++ b/src/stage.c @@ -778,8 +778,10 @@ stage_open(struct view *view, enum open_flags flags) view->vid[0] = 0; code = begin_update(view, repo.exec_dir, argv, flags | OPEN_WITH_STDERR); - if (code == SUCCESS && stage_line_type != LINE_STAT_UNTRACKED) + if (code == SUCCESS && stage_line_type != LINE_STAT_UNTRACKED) { + diff_init_syntax_highlight(&state->diff); return diff_init_highlight(view, &state->diff); + } return code; } @@ -796,6 +798,7 @@ stage_read(struct view *view, struct buffer *buf, bool force_stop) return pager_common_read(view, buf ? buf->data : NULL, LINE_DEFAULT, NULL); if (!buf) { + diff_done_syntax_highlight(&state->diff); if (!diff_done_highlight(&state->diff)) { if (!force_stop) report("Failed to run the diff-highlight program: %s", opt_diff_highlight); diff --git a/test/tools/test-ansi b/test/tools/test-ansi new file mode 100755 index 000000000..d8047b800 Binary files /dev/null and b/test/tools/test-ansi differ diff --git a/test/tools/test-ansi.c b/test/tools/test-ansi.c new file mode 100644 index 000000000..a5c7c0c0e --- /dev/null +++ b/test/tools/test-ansi.c @@ -0,0 +1,617 @@ +/* Copyright (c) 2006-2026 Jonas Fonseca + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + */ + +#include +#include +#include +#include "tig/ansi.h" + +static int tests_run = 0; +static int tests_failed = 0; + +#define ASSERT_EQ(msg, got, expected) do { \ + tests_run++; \ + if ((got) != (expected)) { \ + fprintf(stderr, "FAIL: %s: expected %d, got %d\n", \ + msg, (int)(expected), (int)(got)); \ + tests_failed++; \ + } \ +} while (0) + +#define ASSERT_STR(msg, got, expected) do { \ + tests_run++; \ + if (strcmp((got), (expected)) != 0) { \ + fprintf(stderr, "FAIL: %s: expected '%s', got '%s'\n", \ + msg, expected, got); \ + tests_failed++; \ + } \ +} while (0) + +static void +test_plain_text(void) +{ + char stripped[256]; + struct ansi_span spans[16]; + int n; + + n = ansi_parse_line("hello world", stripped, sizeof(stripped), spans, 16); + ASSERT_EQ("plain: nspans", n, 1); + ASSERT_STR("plain: stripped", stripped, "hello world"); + ASSERT_EQ("plain: span0.offset", spans[0].offset, 0); + ASSERT_EQ("plain: span0.length", spans[0].length, 11); + ASSERT_EQ("plain: span0.fg.type", spans[0].fg.type, ANSI_COLOR_DEFAULT); + ASSERT_EQ("plain: span0.bg.type", spans[0].bg.type, ANSI_COLOR_DEFAULT); +} + +static void +test_basic_fg_color(void) +{ + char stripped[256]; + struct ansi_span spans[16]; + int n; + + /* Red foreground: \x1b[31m */ + n = ansi_parse_line("\x1b[31mhello\x1b[0m", stripped, sizeof(stripped), spans, 16); + ASSERT_EQ("basic_fg: nspans", n, 1); + ASSERT_STR("basic_fg: stripped", stripped, "hello"); + ASSERT_EQ("basic_fg: span0.fg.type", spans[0].fg.type, ANSI_COLOR_BASIC); + ASSERT_EQ("basic_fg: span0.fg.index", spans[0].fg.index, 1); /* red */ + ASSERT_EQ("basic_fg: span0.length", spans[0].length, 5); +} + +static void +test_256_color(void) +{ + char stripped[256]; + struct ansi_span spans[16]; + int n; + + /* 256-color fg: \x1b[38;5;149m */ + n = ansi_parse_line("\x1b[38;5;149mgreen\x1b[0m", stripped, sizeof(stripped), spans, 16); + ASSERT_EQ("256: nspans", n, 1); + ASSERT_STR("256: stripped", stripped, "green"); + ASSERT_EQ("256: span0.fg.type", spans[0].fg.type, ANSI_COLOR_256); + ASSERT_EQ("256: span0.fg.index", spans[0].fg.index, 149); +} + +static void +test_truecolor(void) +{ + char stripped[256]; + struct ansi_span spans[16]; + int n; + + /* Truecolor fg: \x1b[38;2;255;128;0m */ + n = ansi_parse_line("\x1b[38;2;255;128;0morange\x1b[0m", stripped, sizeof(stripped), spans, 16); + ASSERT_EQ("truecolor: nspans", n, 1); + ASSERT_STR("truecolor: stripped", stripped, "orange"); + ASSERT_EQ("truecolor: span0.fg.type", spans[0].fg.type, ANSI_COLOR_RGB); + ASSERT_EQ("truecolor: span0.fg.r", spans[0].fg.rgb.r, 255); + ASSERT_EQ("truecolor: span0.fg.g", spans[0].fg.rgb.g, 128); + ASSERT_EQ("truecolor: span0.fg.b", spans[0].fg.rgb.b, 0); +} + +static void +test_multiple_spans(void) +{ + char stripped[256]; + struct ansi_span spans[16]; + int n; + + /* Two color spans: red "he" + green "llo" */ + n = ansi_parse_line("\x1b[31mhe\x1b[32mllo\x1b[0m", stripped, sizeof(stripped), spans, 16); + ASSERT_EQ("multi: nspans", n, 2); + ASSERT_STR("multi: stripped", stripped, "hello"); + ASSERT_EQ("multi: span0.fg.index", spans[0].fg.index, 1); /* red */ + ASSERT_EQ("multi: span0.offset", spans[0].offset, 0); + ASSERT_EQ("multi: span0.length", spans[0].length, 2); + ASSERT_EQ("multi: span1.fg.index", spans[1].fg.index, 2); /* green */ + ASSERT_EQ("multi: span1.offset", spans[1].offset, 2); + ASSERT_EQ("multi: span1.length", spans[1].length, 3); +} + +static void +test_bold_and_color(void) +{ + char stripped[256]; + struct ansi_span spans[16]; + int n; + + /* Bold + blue: \x1b[1;34m */ + n = ansi_parse_line("\x1b[1;34mbold blue\x1b[0m", stripped, sizeof(stripped), spans, 16); + ASSERT_EQ("bold: nspans", n, 1); + ASSERT_STR("bold: stripped", stripped, "bold blue"); + ASSERT_EQ("bold: span0.fg.type", spans[0].fg.type, ANSI_COLOR_BASIC); + ASSERT_EQ("bold: span0.fg.index", spans[0].fg.index, 4); /* blue */ + ASSERT_EQ("bold: span0.attr has A_BOLD", !!(spans[0].attr & A_BOLD), 1); +} + +static void +test_reset_mid_line(void) +{ + char stripped[256]; + struct ansi_span spans[16]; + int n; + + /* Color then reset then plain: red "ab" + default "cd" */ + n = ansi_parse_line("\x1b[31mab\x1b[0mcd", stripped, sizeof(stripped), spans, 16); + ASSERT_EQ("reset: nspans", n, 2); + ASSERT_STR("reset: stripped", stripped, "abcd"); + ASSERT_EQ("reset: span0.fg.type", spans[0].fg.type, ANSI_COLOR_BASIC); + ASSERT_EQ("reset: span0.fg.index", spans[0].fg.index, 1); + ASSERT_EQ("reset: span0.length", spans[0].length, 2); + ASSERT_EQ("reset: span1.fg.type", spans[1].fg.type, ANSI_COLOR_DEFAULT); + ASSERT_EQ("reset: span1.length", spans[1].length, 2); +} + +static void +test_bg_color(void) +{ + char stripped[256]; + struct ansi_span spans[16]; + int n; + + /* Background: \x1b[48;5;22m */ + n = ansi_parse_line("\x1b[38;5;231;48;5;22mtext\x1b[0m", stripped, sizeof(stripped), spans, 16); + ASSERT_EQ("bg: nspans", n, 1); + ASSERT_STR("bg: stripped", stripped, "text"); + ASSERT_EQ("bg: span0.fg.type", spans[0].fg.type, ANSI_COLOR_256); + ASSERT_EQ("bg: span0.fg.index", spans[0].fg.index, 231); + ASSERT_EQ("bg: span0.bg.type", spans[0].bg.type, ANSI_COLOR_256); + ASSERT_EQ("bg: span0.bg.index", spans[0].bg.index, 22); +} + +static void +test_bright_colors(void) +{ + char stripped[256]; + struct ansi_span spans[16]; + int n; + + /* Bright red fg: \x1b[91m (should map to 256-color index 9) */ + n = ansi_parse_line("\x1b[91mbright\x1b[0m", stripped, sizeof(stripped), spans, 16); + ASSERT_EQ("bright: nspans", n, 1); + ASSERT_EQ("bright: span0.fg.type", spans[0].fg.type, ANSI_COLOR_256); + ASSERT_EQ("bright: span0.fg.index", spans[0].fg.index, 9); /* bright red = 8+1 */ +} + +static void +test_reverse_video(void) +{ + char stripped[256]; + struct ansi_span spans[16]; + int n; + + /* Reverse video (used by diff-highlight): \x1b[7m ... \x1b[27m */ + n = ansi_parse_line("ab\x1b[7mcd\x1b[27mef", stripped, sizeof(stripped), spans, 16); + ASSERT_EQ("reverse: nspans", n, 3); + ASSERT_STR("reverse: stripped", stripped, "abcdef"); + ASSERT_EQ("reverse: span0.length", spans[0].length, 2); + ASSERT_EQ("reverse: span0.attr & A_REVERSE", !!(spans[0].attr & A_REVERSE), 0); + ASSERT_EQ("reverse: span1.length", spans[1].length, 2); + ASSERT_EQ("reverse: span1.attr & A_REVERSE", !!(spans[1].attr & A_REVERSE), 1); + ASSERT_EQ("reverse: span2.length", spans[2].length, 2); + ASSERT_EQ("reverse: span2.attr & A_REVERSE", !!(spans[2].attr & A_REVERSE), 0); +} + +static void +test_empty_string(void) +{ + char stripped[256]; + struct ansi_span spans[16]; + int n; + + n = ansi_parse_line("", stripped, sizeof(stripped), spans, 16); + ASSERT_EQ("empty: nspans", n, 0); + ASSERT_STR("empty: stripped", stripped, ""); +} + +static void +test_only_escapes(void) +{ + char stripped[256]; + struct ansi_span spans[16]; + int n; + + /* Just a color set and reset, no visible text */ + n = ansi_parse_line("\x1b[31m\x1b[0m", stripped, sizeof(stripped), spans, 16); + ASSERT_EQ("only_esc: nspans", n, 0); + ASSERT_STR("only_esc: stripped", stripped, ""); +} + +static void +test_bat_real_output(void) +{ + char stripped[256]; + struct ansi_span spans[16]; + int n; + + /* Real bat output for C: #include + * \x1b[38;5;203m#include\x1b[0m\x1b[38;5;141m \x1b[0m\x1b[38;5;186m\x1b[0m */ + n = ansi_parse_line( + "\x1b[38;5;203m#include\x1b[0m\x1b[38;5;141m \x1b[0m\x1b[38;5;186m\x1b[0m", + stripped, sizeof(stripped), spans, 16); + ASSERT_EQ("bat: nspans", n, 3); + ASSERT_STR("bat: stripped", stripped, "#include "); + + ASSERT_EQ("bat: span0.fg.type", spans[0].fg.type, ANSI_COLOR_256); + ASSERT_EQ("bat: span0.fg.index", spans[0].fg.index, 203); + ASSERT_EQ("bat: span0.length", spans[0].length, 8); /* #include */ + + ASSERT_EQ("bat: span1.fg.index", spans[1].fg.index, 141); + ASSERT_EQ("bat: span1.length", spans[1].length, 1); /* space */ + + ASSERT_EQ("bat: span2.fg.index", spans[2].fg.index, 186); + ASSERT_EQ("bat: span2.length", spans[2].length, 9); /* */ +} + +/* + * Integration tests: simulate the four setting combos for diff views. + * + * These test the ANSI parse + merge pipeline that diff_syntax_highlight_line + * uses, without needing ncurses or bat. + */ + +/* Helper: simulate diff-highlight stripping + emphasis detection. + * Input: line with reverse-video codes from diff-highlight. + * Output: clean text + emphasis bitmap. */ +static void +strip_diff_highlight(const char *input, char *clean, size_t clean_size, + char *emph_map, size_t emph_size) +{ + struct ansi_span dh_spans[64]; + int n, i; + size_t j; + + memset(emph_map, 0, emph_size); + n = ansi_parse_line(input, clean, clean_size, dh_spans, 64); + for (i = 0; i < n; i++) { + if (!(dh_spans[i].attr & A_REVERSE)) + continue; + for (j = dh_spans[i].offset; + j < dh_spans[i].offset + dh_spans[i].length && j < emph_size; + j++) + emph_map[j] = 1; + } +} + +/* Helper: simulate bat syntax highlighting. + * Input: clean text. Output: ANSI with 256-color fg spans. + * For testing, we fake bat output with known colors. */ +static int +fake_bat_highlight(const char *text, struct ansi_span *spans, int max_spans) +{ + /* Simulate: keyword "int" gets color 203, rest gets color 231 */ + const char *p = strstr(text, "int"); + int n = 0; + + if (p && p == text && max_spans >= 2) { + spans[0].fg.type = ANSI_COLOR_256; + spans[0].fg.index = 203; + spans[0].bg.type = ANSI_COLOR_DEFAULT; + spans[0].attr = 0; + spans[0].offset = 0; + spans[0].length = 3; + + spans[1].fg.type = ANSI_COLOR_256; + spans[1].fg.index = 231; + spans[1].bg.type = ANSI_COLOR_DEFAULT; + spans[1].attr = 0; + spans[1].offset = 3; + spans[1].length = strlen(text) - 3; + n = 2; + } else if (max_spans >= 1) { + spans[0].fg.type = ANSI_COLOR_256; + spans[0].fg.index = 231; + spans[0].bg.type = ANSI_COLOR_DEFAULT; + spans[0].attr = 0; + spans[0].offset = 0; + spans[0].length = strlen(text); + n = 1; + } + return n; +} + +/* Helper: merge emphasis bitmap into syntax spans (same logic as diff.c) */ +static int +merge_emphasis(struct ansi_span *spans, int nspans, + const char *emph_map, size_t emph_size, int emph_bg) +{ + struct ansi_span merged[256]; + int nmerged = 0; + int i; + + for (i = 0; i < nspans && nmerged < 255; i++) { + size_t pos = spans[i].offset; + size_t end = pos + spans[i].length; + + while (pos < end && nmerged < 255) { + bool emph = pos < emph_size && emph_map[pos]; + size_t run = pos; + + while (run < end && run < emph_size + && (emph_map[run] ? 1 : 0) == emph) + run++; + if (run >= emph_size) + run = end; + + merged[nmerged] = spans[i]; + merged[nmerged].offset = pos; + merged[nmerged].length = run - pos; + if (emph && emph_bg >= 0) { + merged[nmerged].bg.type = ANSI_COLOR_256; + merged[nmerged].bg.index = emph_bg; + } + nmerged++; + pos = run; + } + } + + memcpy(spans, merged, sizeof(spans[0]) * nmerged); + return nmerged; +} + +/* + * Combo 1: Neither syntax-highlight nor diff-highlight. + * Data is plain text. No ANSI parsing needed. Tig uses its default + * LINE_DIFF_ADD / LINE_DIFF_DEL coloring. + */ +static void +test_combo_neither(void) +{ + const char *line = "int x = 1;"; + + /* No ANSI escapes present */ + ASSERT_EQ("neither: no escapes", ansi_has_escapes(line), 0); +} + +/* + * Combo 2: diff-highlight only (no syntax-highlight). + * Data has reverse-video codes. Tig's existing diff_common_highlight + * handles this — we just verify the ANSI parser extracts reverse-video. + */ +static void +test_combo_diff_highlight_only(void) +{ + char stripped[256]; + struct ansi_span spans[16]; + int n; + + /* diff-highlight output: "int x = \x1b[7m1\x1b[27m;" */ + n = ansi_parse_line("int x = \x1b[7m1\x1b[27m;", stripped, sizeof(stripped), spans, 16); + ASSERT_EQ("dh_only: nspans", n, 3); + ASSERT_STR("dh_only: stripped", stripped, "int x = 1;"); + ASSERT_EQ("dh_only: span0 no reverse", !!(spans[0].attr & A_REVERSE), 0); + ASSERT_EQ("dh_only: span0.length", spans[0].length, 8); /* "int x = " */ + ASSERT_EQ("dh_only: span1 has reverse", !!(spans[1].attr & A_REVERSE), 1); + ASSERT_EQ("dh_only: span1.length", spans[1].length, 1); /* "1" */ + ASSERT_EQ("dh_only: span2 no reverse", !!(spans[2].attr & A_REVERSE), 0); + ASSERT_EQ("dh_only: span2.length", spans[2].length, 1); /* ";" */ +} + +/* + * Combo 3: syntax-highlight only (no diff-highlight). + * Data is plain text. We highlight via bat (faked here) and get syntax spans. + * No emphasis merging needed. + */ +static void +test_combo_syntax_only(void) +{ + const char *content = "int x = 1;"; + struct ansi_span spans[16]; + int nspans; + + /* No diff-highlight codes in input */ + ASSERT_EQ("syn_only: no escapes", ansi_has_escapes(content), 0); + + /* Fake bat highlighting */ + nspans = fake_bat_highlight(content, spans, 16); + ASSERT_EQ("syn_only: nspans", nspans, 2); + ASSERT_EQ("syn_only: span0 'int' color", spans[0].fg.index, 203); + ASSERT_EQ("syn_only: span0.length", spans[0].length, 3); + ASSERT_EQ("syn_only: span1 rest color", spans[1].fg.index, 231); + ASSERT_EQ("syn_only: span1.length", spans[1].length, 7); + + /* No emphasis to merge — bg stays default */ + ASSERT_EQ("syn_only: span0.bg default", spans[0].bg.type, ANSI_COLOR_DEFAULT); + ASSERT_EQ("syn_only: span1.bg default", spans[1].bg.type, ANSI_COLOR_DEFAULT); +} + +/* + * Combo 4: Both syntax-highlight AND diff-highlight. + * Data has reverse-video from diff-highlight. We strip it, highlight via bat, + * then merge emphasis by splitting spans at emphasis boundaries. + */ +static void +test_combo_both(void) +{ + /* diff-highlight marked "1" as changed: "int x = \x1b[7m1\x1b[27m;" */ + const char *dh_input = "int x = \x1b[7m1\x1b[27m;"; + char clean[256]; + char emph_map[256]; + struct ansi_span spans[32]; + int nspans; + int emph_bg = 65; /* emphasis background color */ + + /* Step 1: Strip diff-highlight, record emphasis */ + strip_diff_highlight(dh_input, clean, sizeof(clean), emph_map, sizeof(emph_map)); + ASSERT_STR("both: clean text", clean, "int x = 1;"); + ASSERT_EQ("both: emph[7] (space)", emph_map[7], 0); + ASSERT_EQ("both: emph[8] (1)", emph_map[8], 1); /* '1' emphasized */ + ASSERT_EQ("both: emph[9] (;)", emph_map[9], 0); + + /* Step 2: Fake bat highlighting on clean text */ + nspans = fake_bat_highlight(clean, spans, 32); + ASSERT_EQ("both: bat nspans", nspans, 2); + /* span0: "int" fg=203, span1: " x = 1;" fg=231 */ + + /* Step 3: Merge emphasis — should split span1 at emphasis boundary */ + nspans = merge_emphasis(spans, nspans, emph_map, sizeof(emph_map), emph_bg); + + /* Expected after merge: + * span0: "int" (0-3) fg=203, bg=default (no emphasis) + * span1: " x = " (3-8) fg=231, bg=default + * span2: "1" (8-9) fg=231, bg=65 (emphasized!) + * span3: ";" (9-10) fg=231, bg=default + */ + ASSERT_EQ("both: merged nspans", nspans, 4); + + ASSERT_EQ("both: span0 offset", spans[0].offset, 0); + ASSERT_EQ("both: span0 length", spans[0].length, 3); + ASSERT_EQ("both: span0 fg", spans[0].fg.index, 203); + ASSERT_EQ("both: span0 bg default", spans[0].bg.type, ANSI_COLOR_DEFAULT); + + ASSERT_EQ("both: span1 offset", spans[1].offset, 3); + ASSERT_EQ("both: span1 length", spans[1].length, 5); + ASSERT_EQ("both: span1 fg", spans[1].fg.index, 231); + ASSERT_EQ("both: span1 bg default", spans[1].bg.type, ANSI_COLOR_DEFAULT); + + ASSERT_EQ("both: span2 offset", spans[2].offset, 8); + ASSERT_EQ("both: span2 length", spans[2].length, 1); + ASSERT_EQ("both: span2 fg", spans[2].fg.index, 231); + ASSERT_EQ("both: span2 bg emphasis", spans[2].bg.type, ANSI_COLOR_256); + ASSERT_EQ("both: span2 bg color", spans[2].bg.index, emph_bg); + + ASSERT_EQ("both: span3 offset", spans[3].offset, 9); + ASSERT_EQ("both: span3 length", spans[3].length, 1); + ASSERT_EQ("both: span3 bg default", spans[3].bg.type, ANSI_COLOR_DEFAULT); +} + +/* + * Combo 4 variant: emphasis at start and end of syntax span. + */ +static void +test_combo_both_edge_emphasis(void) +{ + /* "int" is emphasized: "\x1b[7mint\x1b[27m x = 1;" */ + const char *dh_input = "\x1b[7mint\x1b[27m x = 1;"; + char clean[256]; + char emph_map[256]; + struct ansi_span spans[32]; + int nspans; + + strip_diff_highlight(dh_input, clean, sizeof(clean), emph_map, sizeof(emph_map)); + ASSERT_STR("edge: clean text", clean, "int x = 1;"); + ASSERT_EQ("edge: emph[0]", emph_map[0], 1); + ASSERT_EQ("edge: emph[2]", emph_map[2], 1); + ASSERT_EQ("edge: emph[3]", emph_map[3], 0); + + nspans = fake_bat_highlight(clean, spans, 32); + nspans = merge_emphasis(spans, nspans, emph_map, sizeof(emph_map), 65); + + /* Expected: + * span0: "int" (0-3) fg=203, bg=65 (keyword AND emphasized) + * span1: " x = 1;" (3-10) fg=231, bg=default + */ + ASSERT_EQ("edge: nspans", nspans, 2); + ASSERT_EQ("edge: span0 bg emphasis", spans[0].bg.type, ANSI_COLOR_256); + ASSERT_EQ("edge: span0 bg color", spans[0].bg.index, 65); + ASSERT_EQ("edge: span1 bg default", spans[1].bg.type, ANSI_COLOR_DEFAULT); +} + +/* + * Real bat integration test: pipes C code through bat and verifies + * that the ANSI output parses into meaningful syntax spans. + * Skipped (not failed) if bat is not on PATH. + */ +static void +test_bat_pipe_integration(void) +{ + FILE *fp; + char line[SIZEOF_STR * 2]; + char stripped[SIZEOF_STR]; + struct ansi_span spans[64]; + int n; + bool has_color = false; + + fp = popen("printf '%s' 'int main() { return 0; }' | bat --color=always --style=plain --paging=never --file-name=test.c - 2>/dev/null", "r"); + if (!fp) { + fprintf(stderr, "SKIP: bat_pipe: popen failed\n"); + return; + } + + if (!fgets(line, sizeof(line), fp)) { + pclose(fp); + fprintf(stderr, "SKIP: bat_pipe: no output (bat not installed?)\n"); + return; + } + pclose(fp); + + /* Strip trailing newline */ + { + size_t len = strlen(line); + if (len > 0 && line[len - 1] == '\n') + line[len - 1] = '\0'; + } + + /* Must have ANSI escapes */ + ASSERT_EQ("bat_pipe: has escapes", ansi_has_escapes(line), 1); + + /* Parse the ANSI output */ + n = ansi_parse_line(line, stripped, sizeof(stripped), spans, 64); + + /* Stripped text must match the input */ + ASSERT_STR("bat_pipe: stripped text", stripped, "int main() { return 0; }"); + + /* Must produce multiple spans (at minimum "int" gets a different color) */ + ASSERT_EQ("bat_pipe: nspans > 1", n > 1, 1); + + /* All spans should have 256-color or basic fg (not default for everything) */ + { + int i; + for (i = 0; i < n; i++) { + if (spans[i].fg.type != ANSI_COLOR_DEFAULT) { + has_color = true; + break; + } + } + } + ASSERT_EQ("bat_pipe: has syntax colors", has_color, 1); + + /* First span should cover "int" keyword */ + ASSERT_EQ("bat_pipe: span0 is keyword length", spans[0].length <= 4, 1); + ASSERT_EQ("bat_pipe: span0 has color", spans[0].fg.type != ANSI_COLOR_DEFAULT, 1); +} + +int +main(int argc, const char *argv[]) +{ + test_plain_text(); + test_basic_fg_color(); + test_256_color(); + test_truecolor(); + test_multiple_spans(); + test_bold_and_color(); + test_reset_mid_line(); + test_bg_color(); + test_bright_colors(); + test_reverse_video(); + test_empty_string(); + test_only_escapes(); + test_bat_real_output(); + + /* Real bat integration */ + test_bat_pipe_integration(); + + /* Integration: four setting combos */ + test_combo_neither(); + test_combo_diff_highlight_only(); + test_combo_syntax_only(); + test_combo_both(); + test_combo_both_edge_emphasis(); + + printf("%d tests, %d passed, %d failed\n", + tests_run, tests_run - tests_failed, tests_failed); + + return tests_failed ? 1 : 0; +} diff --git a/tigrc b/tigrc index e0ade6aef..4ab1e40f1 100644 --- a/tigrc +++ b/tigrc @@ -124,6 +124,9 @@ set show-notes = yes # When non-bool passed as `--show-notes=...` (diff) #set diff-options = -C # User-defined options for `tig show` (git-diff) #set diff-highlight = yes # String (or bool): Path to diff-highlight script, # defaults to `diff-highlight`. +#set syntax-highlight = yes # String (or bool): Path to syntax highlighter, + # defaults to `bat`. Requires + # 256-color terminal support. set diff-indicator = yes # Show diff +/- signs? #set blame-options = -C -C -C # User-defined options for `tig blame` (git-blame) set log-options = --cc --stat # User-defined options for `tig log` (git-log)