diff --git a/configure.ac b/configure.ac
index 5bdc626a87..a1f64185af 100644
--- a/configure.ac
+++ b/configure.ac
@@ -698,6 +698,7 @@ tests/src/Makefile
tests/src/filemanager/Makefile
tests/src/editor/Makefile
tests/src/editor/edit_complete_word_cmd_test_data.txt
+tests/src/viewer/Makefile
tests/src/vfs/Makefile
tests/src/vfs/extfs/Makefile
tests/src/vfs/extfs/helpers-list/Makefile
diff --git a/doc/hints/mc.hint b/doc/hints/mc.hint
index 079bc1e3b4..142b93a17a 100644
--- a/doc/hints/mc.hint
+++ b/doc/hints/mc.hint
@@ -62,6 +62,8 @@ Hint: To look at the output of a command in the viewer, use M-!
Hint: F13 (or Shift-F3) invokes the viewer in raw mode.
+Hint: Press Shift-F8 in the viewer for syntax highlighting. 's' chooses the backend.
+
Hint: You may specify the editor for F4 with the shell variable EDITOR.
Hint: You may specify the external viewer with the shell vars VIEWER or PAGER.
diff --git a/doc/man/mc.1.in b/doc/man/mc.1.in
index c57c3f22ca..1689a1b5ec 100644
--- a/doc/man/mc.1.in
+++ b/doc/man/mc.1.in
@@ -2933,6 +2933,30 @@ Some character sequences, which appear most often in preformatted manual
pages, are displayed bold and underlined, thus making a pretty display
of your files.
.PP
+The viewer supports syntax highlighting via an external highlighter.
+Press
+.B Shift\-F8
+to toggle syntax highlighting. Press
+.B s
+to choose the highlighting backend.
+Five backends are supported:
+.BR bat ,
+.BR chroma ,
+.BR highlight ,
+.BR pygmentize ,
+and
+.BR source\-highlight .
+A custom command can also be configured via the backend chooser dialog.
+The command template must contain
+.B %s
+as a placeholder for the filename.
+The viewer pipes the file through the chosen highlighter and renders
+the ANSI\-colored output. The backend and file extension are shown in
+the status bar, e.g.
+.IR [bat:py] .
+The syntax mode and backend choice are persisted in
+.IR ~/.config/mc/ini .
+.PP
When in hex mode, the search function accepts text in quotes and
constant numbers. Text in quotes is matched exactly after removing
the quotes. Each number matches one byte. You can mix quoted text
@@ -3025,6 +3049,14 @@ Jump to the next file.
.B C\-b
Jump to the previous file.
.TP
+.B Shift\-F8
+Toggle syntax highlighting. Requires an external highlighter
+.RB ( bat ", " chroma ", " highlight ", " pygmentize ", or " source\-highlight ).
+.TP
+.B s
+Open the syntax highlighting backend chooser dialog. Installed
+backends and a "Custom..." option for arbitrary commands are shown.
+.TP
.B Alt\-r
Toggle the ruler.
.TP
diff --git a/lib/keybind.c b/lib/keybind.c
index 194f3ecbc7..887ab3c7dd 100644
--- a/lib/keybind.c
+++ b/lib/keybind.c
@@ -350,6 +350,8 @@ static name_keymap_t command_names[] = {
ADD_KEYMAP_NAME (HexMode),
ADD_KEYMAP_NAME (MagicMode),
ADD_KEYMAP_NAME (NroffMode),
+ ADD_KEYMAP_NAME (SyntaxMode),
+ ADD_KEYMAP_NAME (SyntaxOptions),
ADD_KEYMAP_NAME (BookmarkGoto),
ADD_KEYMAP_NAME (Ruler),
ADD_KEYMAP_NAME (SearchForward),
diff --git a/lib/keybind.h b/lib/keybind.h
index 28be23559e..6b74fa2d5c 100644
--- a/lib/keybind.h
+++ b/lib/keybind.h
@@ -319,6 +319,8 @@ enum
CK_WrapMode = 600L,
CK_MagicMode,
CK_NroffMode,
+ CK_SyntaxMode,
+ CK_SyntaxOptions,
CK_HexMode,
CK_HexEditMode,
CK_BookmarkGoto,
diff --git a/misc/mc.default.keymap b/misc/mc.default.keymap
index 7e107805e6..d262ba5944 100644
--- a/misc/mc.default.keymap
+++ b/misc/mc.default.keymap
@@ -419,6 +419,8 @@ HalfPageDown = d
HalfPageUp = u
Top = home; ctrl-home; ctrl-pgup; a1; alt-lt; g
Bottom = end; ctrl-end; ctrl-pgdn; c1; alt-gt; shift-g
+SyntaxMode = f18
+SyntaxOptions = s
BookmarkGoto = m
Bookmark = r
FileNext = ctrl-f
diff --git a/misc/mc.emacs.keymap b/misc/mc.emacs.keymap
index b03fc8e68c..494aca90f3 100644
--- a/misc/mc.emacs.keymap
+++ b/misc/mc.emacs.keymap
@@ -421,6 +421,8 @@ HalfPageDown = d
HalfPageUp = u
Top = home; ctrl-home; ctrl-pgup; a1; alt-lt; g
Bottom = end; ctrl-end; ctrl-pgdn; c1; alt-gt; shift-g
+SyntaxMode = f18
+SyntaxOptions = s
BookmarkGoto = m
Bookmark = r
FileNext = ctrl-f
diff --git a/misc/mc.vim.keymap b/misc/mc.vim.keymap
index e790b3c6c1..f2cd9c599f 100644
--- a/misc/mc.vim.keymap
+++ b/misc/mc.vim.keymap
@@ -288,6 +288,8 @@ HalfPageDown = d
HalfPageUp = u
Top = home; ctrl-home; ctrl-pgup; a1; alt-lt; g
Bottom = end; ctrl-end; ctrl-pgdn; c1; alt-gt; shift-g
+SyntaxMode = f18
+SyntaxOptions = s
BookmarkGoto = m
Bookmark = r
FileNext = ctrl-f
diff --git a/src/keymap.c b/src/keymap.c
index 35ef2759eb..1713363a2a 100644
--- a/src/keymap.c
+++ b/src/keymap.c
@@ -563,6 +563,8 @@ static const global_keymap_ini_t default_viewer_keymap[] = {
{ "SearchBackwardContinue", "ctrl-r" },
{ "SearchOppositeContinue", "shift-n" },
{ "History", "alt-shift-e" },
+ { "SyntaxMode", "s" },
+ { "SyntaxOptions", "shift-s" },
{
NULL,
NULL,
diff --git a/src/setup.c b/src/setup.c
index aaa32bd6ad..abdb219e47 100644
--- a/src/setup.c
+++ b/src/setup.c
@@ -68,6 +68,7 @@
#endif
#include "src/viewer/mcviewer.h" // For the externs
+#include "src/viewer/syntax.h" // mcview_syntax_command
#include "setup.h"
@@ -310,6 +311,7 @@ static const struct
{ "mouse_close_dialog", &mouse_close_dialog },
{ "drop_menus", &drop_menus },
{ "wrap_mode", &mcview_global_flags.wrap },
+ { "syntax_mode", &mcview_global_flags.syntax },
{ "old_esc_mode", &old_esc_mode },
{ "cd_symlinks", &mc_global.vfs.cd_symlinks },
{ "show_all_if_ambiguous", &mc_global.widget.show_all_if_ambiguous },
@@ -418,6 +420,7 @@ static const struct
{ "editor_stop_format_chars", &edit_options.stop_format_chars, "-+*\\,.;:&>" },
#endif
{ "mcview_eof", &mcview_show_eof, "" },
+ { "syntax_command", &mcview_syntax_command, MCVIEW_SYNTAX_DEFAULT_CMD },
{ NULL, NULL, NULL },
};
diff --git a/src/viewer/Makefile.am b/src/viewer/Makefile.am
index 9bf1648408..0f9c3ead68 100644
--- a/src/viewer/Makefile.am
+++ b/src/viewer/Makefile.am
@@ -3,6 +3,7 @@ noinst_LTLIBRARIES = libmcviewer.la
libmcviewer_la_SOURCES = \
actions_cmd.c \
+ ansi.c ansi.h \
ascii.c \
coord_cache.c \
datasource.c \
@@ -16,6 +17,7 @@ libmcviewer_la_SOURCES = \
mcviewer.h \
move.c \
nroff.c \
- search.c
+ search.c \
+ syntax.c syntax.h
AM_CPPFLAGS = -I$(top_srcdir) $(GLIB_CFLAGS)
diff --git a/src/viewer/actions_cmd.c b/src/viewer/actions_cmd.c
index c51c80727a..856bf123fd 100644
--- a/src/viewer/actions_cmd.c
+++ b/src/viewer/actions_cmd.c
@@ -67,6 +67,7 @@
#include "src/keymap.h"
#include "internal.h"
+#include "syntax.h"
/*** global variables ****************************************************************************/
@@ -156,7 +157,14 @@ mcview_hook (void *v)
if (fe == NULL)
return;
- mcview_done (view);
+ // save global flags — quick view panel refresh must not clobber
+ // flags set by the standalone viewer (e.g. syntax mode toggled via F3)
+ {
+ mcview_mode_flags_t saved_global = mcview_global_flags;
+
+ mcview_done (view);
+ mcview_global_flags = saved_global;
+ }
mcview_init (view);
mcview_load (view, 0, fe->fname->str, 0, 0, 0);
mcview_display (view);
@@ -466,6 +474,31 @@ mcview_execute_cmd (WView *view, long command)
case CK_NroffMode:
mcview_toggle_nroff_mode (view);
break;
+ case CK_SyntaxMode:
+ // warn if trying to enable syntax but highlighter is missing
+ if (!view->mode_flags.syntax && !mcview_syntax_command_available ())
+ {
+ message (D_ERROR, _ ("Syntax Highlighting"), "%s",
+ _ ("No syntax highlighting tool found.\n"
+ "Please install one of:\n"
+ " bat, chroma, highlight,\n"
+ " pygmentize, source-highlight\n"
+ "or press Shift-S to set a custom command."));
+ break;
+ }
+ mcview_toggle_syntax_mode (view);
+ break;
+ case CK_SyntaxOptions:
+ if (mcview_syntax_options_dialog ())
+ {
+ // backend changed — re-toggle to reload with new command
+ if (view->mode_flags.syntax)
+ {
+ view->mode_flags.syntax = FALSE;
+ mcview_toggle_syntax_mode (view);
+ }
+ }
+ break;
case CK_Home:
mcview_moveto_bol (view);
break;
diff --git a/src/viewer/ansi.c b/src/viewer/ansi.c
new file mode 100644
index 0000000000..1679502418
--- /dev/null
+++ b/src/viewer/ansi.c
@@ -0,0 +1,376 @@
+/*
+ lib/viewer - ANSI SGR escape sequence parser
+
+ Copyright (C) 2026
+ Free Software Foundation, Inc.
+
+ This file is part of the Midnight Commander.
+
+ The Midnight Commander 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 3 of the License,
+ or (at your option) any later version.
+
+ The Midnight Commander 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.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+ */
+
+/** \file ansi.c
+ * \brief Source: ANSI SGR escape sequence parser for mcview
+ *
+ * Parses ANSI escape sequences (ESC[...m) from byte stream and extracts
+ * foreground/background color, bold, and underline attributes.
+ *
+ * State machine: NORMAL --ESC--> ESCAPE --[--> CSI --digit/;--> CSI
+ * --m--> apply SGR, back to NORMAL
+ * --letter--> consume, back to NORMAL
+ * ESCAPE --non-[--> consume, back to NORMAL
+ */
+
+#include
+
+#include "lib/global.h"
+
+#include "ansi.h"
+
+/*** global variables ****************************************************************************/
+
+/*** file scope macro definitions ****************************************************************/
+
+#define ESC_CHAR '\033'
+
+/*** file scope type declarations ****************************************************************/
+
+/*** forward declarations (file scope functions) *************************************************/
+
+static int mcview_ansi_color_cube_index (int v);
+static int mcview_ansi_rgb_to_256 (int r, int g, int b);
+static void mcview_ansi_csi_finalize_param (mcview_ansi_state_t *state);
+static void mcview_ansi_apply_sgr (mcview_ansi_state_t *state);
+static void mcview_ansi_apply_one_sgr_param (mcview_ansi_state_t *state, int idx);
+
+/*** file scope variables ************************************************************************/
+
+/* --------------------------------------------------------------------------------------------- */
+
+/*** file scope functions ************************************************************************/
+
+/* --------------------------------------------------------------------------------------------- */
+
+/**
+ * Map an RGB component (0-255) to the nearest 6x6x6 cube index (0-5).
+ * Cube component values: 0, 95, 135, 175, 215, 255.
+ * Thresholds are midpoints between adjacent values.
+ */
+static int
+mcview_ansi_color_cube_index (int v)
+{
+ if (v < 48)
+ return 0;
+ if (v < 115)
+ return 1;
+ if (v < 155)
+ return 2;
+ if (v < 195)
+ return 3;
+ if (v < 235)
+ return 4;
+ return 5;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+/**
+ * Convert 24-bit RGB to the nearest xterm 256-color palette index.
+ * Uses the 6x6x6 color cube (indices 16-231).
+ */
+static int
+mcview_ansi_rgb_to_256 (int r, int g, int b)
+{
+ r = CLAMP (r, 0, 255);
+ g = CLAMP (g, 0, 255);
+ b = CLAMP (b, 0, 255);
+
+ return 16 + 36 * mcview_ansi_color_cube_index (r) + 6 * mcview_ansi_color_cube_index (g)
+ + mcview_ansi_color_cube_index (b);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+/**
+ * Push the current parameter (if any) into params[] array.
+ */
+static void
+mcview_ansi_csi_finalize_param (mcview_ansi_state_t *state)
+{
+ if (state->param_count < MCVIEW_ANSI_MAX_PARAMS)
+ {
+ state->params[state->param_count] = state->current_param;
+ state->is_colon_sep[state->param_count] = state->next_is_colon;
+ state->param_count++;
+ }
+
+ state->current_param = 0;
+ state->has_current_param = FALSE;
+ state->next_is_colon = FALSE;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+/**
+ * Apply one SGR parameter at index idx.
+ * Handles extended color sequences (38;5;N and 48;5;N) by consuming
+ * additional parameters from the params[] array.
+ */
+static void
+mcview_ansi_apply_one_sgr_param (mcview_ansi_state_t *state, int idx)
+{
+ int code;
+
+ if (idx >= state->param_count)
+ return;
+
+ code = state->params[idx];
+
+ if (code == 0)
+ {
+ // reset all
+ state->fg = MCVIEW_ANSI_COLOR_DEFAULT;
+ state->bg = MCVIEW_ANSI_COLOR_DEFAULT;
+ state->bold = FALSE;
+ state->italic = FALSE;
+ state->underline = FALSE;
+ state->blink = FALSE;
+ state->reverse = FALSE;
+ }
+ else if (code == 1)
+ state->bold = TRUE;
+ else if (code == 3)
+ state->italic = TRUE;
+ else if (code == 4)
+ state->underline = TRUE;
+ else if (code == 5)
+ state->blink = TRUE;
+ else if (code == 7)
+ state->reverse = TRUE;
+ else if (code == 21)
+ // double underline — map to regular underline (ncurses/slang limitation)
+ state->underline = TRUE;
+ else if (code == 22)
+ state->bold = FALSE;
+ else if (code == 23)
+ state->italic = FALSE;
+ else if (code == 24)
+ state->underline = FALSE;
+ else if (code == 25)
+ state->blink = FALSE;
+ else if (code == 27)
+ state->reverse = FALSE;
+ else if (code >= 30 && code <= 37)
+ state->fg = code - 30;
+ else if (code == 38)
+ {
+ if (idx + 2 < state->param_count && state->params[idx + 1] == 5)
+ // extended foreground 256-color: 38;5;N or 38:5:N
+ state->fg = state->params[idx + 2];
+ else if (idx + 4 < state->param_count && state->params[idx + 1] == 2)
+ {
+ // truecolor foreground → approximate to 256-color
+ if (idx + 5 < state->param_count && state->is_colon_sep[idx + 1])
+ // de jure colon notation: 38:2:CS:R:G:B — skip color space
+ state->fg = mcview_ansi_rgb_to_256 (state->params[idx + 3], state->params[idx + 4],
+ state->params[idx + 5]);
+ else
+ // de facto semicolon notation: 38;2;R;G;B
+ state->fg = mcview_ansi_rgb_to_256 (state->params[idx + 2], state->params[idx + 3],
+ state->params[idx + 4]);
+ }
+ }
+ else if (code == 39)
+ state->fg = MCVIEW_ANSI_COLOR_DEFAULT;
+ else if (code >= 40 && code <= 47)
+ state->bg = code - 40;
+ else if (code == 48)
+ {
+ if (idx + 2 < state->param_count && state->params[idx + 1] == 5)
+ // extended background 256-color: 48;5;N or 48:5:N
+ state->bg = state->params[idx + 2];
+ else if (idx + 4 < state->param_count && state->params[idx + 1] == 2)
+ {
+ // truecolor background → approximate to 256-color
+ if (idx + 5 < state->param_count && state->is_colon_sep[idx + 1])
+ // de jure colon notation: 48:2:CS:R:G:B — skip color space
+ state->bg = mcview_ansi_rgb_to_256 (state->params[idx + 3], state->params[idx + 4],
+ state->params[idx + 5]);
+ else
+ // de facto semicolon notation: 48;2;R;G;B
+ state->bg = mcview_ansi_rgb_to_256 (state->params[idx + 2], state->params[idx + 3],
+ state->params[idx + 4]);
+ }
+ }
+ else if (code == 49)
+ state->bg = MCVIEW_ANSI_COLOR_DEFAULT;
+ else if (code >= 90 && code <= 97)
+ state->fg = code - 90 + 8;
+ else if (code >= 100 && code <= 107)
+ state->bg = code - 100 + 8;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+/**
+ * Apply all accumulated SGR parameters to the color state.
+ * Called when 'm' terminates a CSI sequence.
+ */
+static void
+mcview_ansi_apply_sgr (mcview_ansi_state_t *state)
+{
+ int i;
+
+ // ESC[m with no params is equivalent to ESC[0m (reset)
+ if (state->param_count == 0)
+ {
+ state->fg = MCVIEW_ANSI_COLOR_DEFAULT;
+ state->bg = MCVIEW_ANSI_COLOR_DEFAULT;
+ state->bold = FALSE;
+ state->italic = FALSE;
+ state->underline = FALSE;
+ state->blink = FALSE;
+ state->reverse = FALSE;
+ return;
+ }
+
+ for (i = 0; i < state->param_count; i++)
+ {
+ int code;
+
+ code = state->params[i];
+
+ // skip colon-separated sub-parameters (they belong to the preceding parameter)
+ if (state->is_colon_sep[i])
+ continue;
+
+ // skip sub-parameters consumed by extended color sequences
+ if (code == 38 || code == 48)
+ {
+ mcview_ansi_apply_one_sgr_param (state, i);
+
+ if (i + 1 < state->param_count && state->is_colon_sep[i + 1])
+ {
+ // colon notation: skip all colon-separated sub-params
+ while (i + 1 < state->param_count && state->is_colon_sep[i + 1])
+ i++;
+ }
+ else if (i + 2 < state->param_count && state->params[i + 1] == 5)
+ i += 2; // 256-color: 38;5;N — skip 2
+ else if (i + 4 < state->param_count && state->params[i + 1] == 2)
+ i += 4; // truecolor: 38;2;R;G;B — skip 4
+ }
+ else
+ mcview_ansi_apply_one_sgr_param (state, i);
+ }
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+/*** public functions ****************************************************************************/
+
+/* --------------------------------------------------------------------------------------------- */
+
+void
+mcview_ansi_state_init (mcview_ansi_state_t *state)
+{
+ state->fg = MCVIEW_ANSI_COLOR_DEFAULT;
+ state->bg = MCVIEW_ANSI_COLOR_DEFAULT;
+ state->bold = FALSE;
+ state->italic = FALSE;
+ state->underline = FALSE;
+ state->blink = FALSE;
+ state->reverse = FALSE;
+ state->in_escape = FALSE;
+ state->in_csi = FALSE;
+ state->param_count = 0;
+ state->current_param = 0;
+ state->has_current_param = FALSE;
+ state->next_is_colon = FALSE;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+mcview_ansi_result_t
+mcview_ansi_parse_char (mcview_ansi_state_t *state, int ch)
+{
+ // State: just saw ESC, waiting for '['
+ if (state->in_escape)
+ {
+ state->in_escape = FALSE;
+
+ if (ch == '[')
+ {
+ // enter CSI mode
+ state->in_csi = TRUE;
+ state->param_count = 0;
+ state->current_param = 0;
+ state->has_current_param = FALSE;
+ return ANSI_RESULT_CONSUMED;
+ }
+
+ // ESC followed by non-'[': consume the char (e.g., ESC c = RIS)
+ return ANSI_RESULT_CONSUMED;
+ }
+
+ // State: inside CSI sequence (ESC[...)
+ if (state->in_csi)
+ {
+ if (ch >= '0' && ch <= '9')
+ {
+ // accumulate digit into current parameter (clamped to prevent overflow)
+ if (state->current_param <= 65535)
+ state->current_param = state->current_param * 10 + (ch - '0');
+ state->has_current_param = TRUE;
+ return ANSI_RESULT_CONSUMED;
+ }
+
+ if (ch == ';' || ch == ':')
+ {
+ // parameter separator (; is standard, : is sub-parameter separator)
+ mcview_ansi_csi_finalize_param (state);
+ if (ch == ':')
+ state->next_is_colon = TRUE;
+ return ANSI_RESULT_CONSUMED;
+ }
+
+ // any letter (0x40-0x7E) terminates CSI
+ if (ch >= 0x40 && ch <= 0x7E)
+ {
+ mcview_ansi_csi_finalize_param (state);
+ state->in_csi = FALSE;
+
+ if (ch == 'm')
+ mcview_ansi_apply_sgr (state);
+
+ // non-'m' terminators: consume without applying to colors
+ return ANSI_RESULT_CONSUMED;
+ }
+
+ // intermediate bytes (0x20-0x3F excluding digits and ';'): consume
+ return ANSI_RESULT_CONSUMED;
+ }
+
+ // Normal state: check for ESC
+ if (ch == ESC_CHAR)
+ {
+ state->in_escape = TRUE;
+ return ANSI_RESULT_CONSUMED;
+ }
+
+ // regular displayable character
+ return ANSI_RESULT_CHAR;
+}
+
+/* --------------------------------------------------------------------------------------------- */
diff --git a/src/viewer/ansi.h b/src/viewer/ansi.h
new file mode 100644
index 0000000000..446784559d
--- /dev/null
+++ b/src/viewer/ansi.h
@@ -0,0 +1,64 @@
+/** \file ansi.h
+ * \brief Header: ANSI SGR escape sequence parser for mcview
+ */
+
+#ifndef MC__VIEWER_ANSI_H
+#define MC__VIEWER_ANSI_H
+
+#include "lib/global.h"
+
+/*** typedefs(not structures) and defined constants **********************************************/
+
+/** Maximum number of CSI parameters we track */
+#define MCVIEW_ANSI_MAX_PARAMS 16
+
+/** Default (unset) color value */
+#define MCVIEW_ANSI_COLOR_DEFAULT (-1)
+
+/*** enums ***************************************************************************************/
+
+/** Result of feeding one byte to the ANSI parser */
+typedef enum
+{
+ ANSI_RESULT_CHAR, /**< regular character — render with current color */
+ ANSI_RESULT_CONSUMED /**< escape sequence byte — skip, don't render */
+} mcview_ansi_result_t;
+
+/*** structures declarations (and typedefs of structures)*****************************************/
+
+/** ANSI SGR parser state */
+typedef struct
+{
+ /* --- public color state (read by renderer) --- */
+ int fg; /**< foreground: 0-7 base, 8-15 bright, -1 default */
+ int bg; /**< background: 0-7 base, 8-15 bright, -1 default */
+ gboolean bold;
+ gboolean italic;
+ gboolean underline;
+ gboolean blink;
+ gboolean reverse;
+
+ /* --- internal parser state --- */
+ gboolean in_escape; /**< seen ESC, waiting for '[' */
+ gboolean in_csi; /**< inside CSI sequence (ESC[...) */
+ int params[MCVIEW_ANSI_MAX_PARAMS];
+ gboolean is_colon_sep[MCVIEW_ANSI_MAX_PARAMS]; /**< TRUE if preceded by ':' */
+ int param_count;
+ int current_param; /**< parameter being accumulated */
+ gboolean has_current_param; /**< whether current_param has digits */
+ gboolean next_is_colon; /**< next param preceded by ':' */
+} mcview_ansi_state_t;
+
+/*** declarations of public functions ************************************************************/
+
+/** Initialize parser state to defaults (no color, not in escape) */
+void mcview_ansi_state_init (mcview_ansi_state_t *state);
+
+/** Feed one byte to the parser.
+ * Returns ANSI_RESULT_CHAR if the byte is a displayable character.
+ * Returns ANSI_RESULT_CONSUMED if the byte is part of an escape sequence. */
+mcview_ansi_result_t mcview_ansi_parse_char (mcview_ansi_state_t *state, int ch);
+
+/*** inline functions ****************************************************************************/
+
+#endif /* MC__VIEWER_ANSI_H */
diff --git a/src/viewer/ascii.c b/src/viewer/ascii.c
index fa0773bc84..7ff0c93db7 100644
--- a/src/viewer/ascii.c
+++ b/src/viewer/ascii.c
@@ -148,6 +148,7 @@
#include "lib/global.h"
#include "lib/tty/tty.h"
+#include "lib/tty/color-internal.h" // tty_color_get_name_by_index()
#include "lib/skin.h"
#include "lib/util.h" // is_printable()
#include "lib/charsets.h"
@@ -334,6 +335,137 @@ mcview_get_next_char (WView *view, mcview_state_machine_t *state, int *c)
return TRUE;
}
+/* --------------------------------------------------------------------------------------------- */
+/**
+ * Convert ANSI parser color state to a tty color pair index.
+ *
+ * Maps the fg/bg/bold/underline from the ANSI parser into MC's color system.
+ * When all attributes are default, returns VIEWER_NORMAL_COLOR to avoid
+ * unnecessary color pair allocation.
+ */
+static int
+mcview_ansi_get_color (const mcview_ansi_state_t *ansi)
+{
+ tty_color_pair_t color;
+ tty_color_pair_t *viewer_skin;
+ char fg_buf[16], bg_buf[16], attr_buf[64];
+ const char *fg_name;
+ const char *bg_name;
+ gboolean has_attrs;
+
+ has_attrs = ansi->bold || ansi->italic || ansi->underline || ansi->blink || ansi->reverse;
+
+ // all defaults → use the skin's normal viewer color
+ if (ansi->fg == MCVIEW_ANSI_COLOR_DEFAULT && ansi->bg == MCVIEW_ANSI_COLOR_DEFAULT
+ && !has_attrs)
+ return VIEWER_NORMAL_COLOR;
+
+ // bold-only and underline-only map to existing skin colors (no other attrs active)
+ if (ansi->fg == MCVIEW_ANSI_COLOR_DEFAULT && ansi->bg == MCVIEW_ANSI_COLOR_DEFAULT
+ && !ansi->italic && !ansi->blink && !ansi->reverse)
+ {
+ if (ansi->bold && ansi->underline)
+ return VIEWER_BOLD_UNDERLINED_COLOR;
+ if (ansi->bold)
+ return VIEWER_BOLD_COLOR;
+ if (ansi->underline)
+ return VIEWER_UNDERLINED_COLOR;
+ }
+
+ // Retrieve viewer skin colors so that ANSI-colored text inherits the
+ // viewer's fg/bg rather than the terminal's "default" colors.
+ viewer_skin =
+ (tty_color_pair_t *) g_hash_table_lookup (mc_skin__default.colors, "viewer._default_");
+
+ // build fg color name
+ if (ansi->fg != MCVIEW_ANSI_COLOR_DEFAULT)
+ {
+ fg_name = tty_color_get_name_by_index (ansi->fg);
+ g_strlcpy (fg_buf, fg_name, sizeof (fg_buf));
+ color.fg = fg_buf;
+ }
+ else
+ color.fg = (viewer_skin != NULL) ? viewer_skin->fg : NULL;
+
+ // build bg color name
+ if (ansi->bg != MCVIEW_ANSI_COLOR_DEFAULT)
+ {
+ bg_name = tty_color_get_name_by_index (ansi->bg);
+ g_strlcpy (bg_buf, bg_name, sizeof (bg_buf));
+ color.bg = bg_buf;
+ }
+ else
+ color.bg = (viewer_skin != NULL) ? viewer_skin->bg : NULL;
+
+ // build attributes string dynamically
+ if (has_attrs)
+ {
+ attr_buf[0] = '\0';
+ if (ansi->bold)
+ g_strlcat (attr_buf, "bold+", sizeof (attr_buf));
+ if (ansi->italic)
+ g_strlcat (attr_buf, "italic+", sizeof (attr_buf));
+ if (ansi->underline)
+ g_strlcat (attr_buf, "underline+", sizeof (attr_buf));
+ if (ansi->blink)
+ g_strlcat (attr_buf, "blink+", sizeof (attr_buf));
+ if (ansi->reverse)
+ g_strlcat (attr_buf, "reverse+", sizeof (attr_buf));
+ // remove trailing '+'
+ attr_buf[strlen (attr_buf) - 1] = '\0';
+ color.attrs = attr_buf;
+ }
+ else
+ color.attrs = NULL;
+
+ color.pair_index = 0;
+
+ return tty_try_alloc_color_pair (&color, TRUE);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+/**
+ * ANSI escape sequence processing layer.
+ *
+ * Feeds characters through the ANSI SGR parser. Escape sequence bytes are
+ * consumed (skipped), and displayable characters are returned with the
+ * accumulated ANSI color. This layer sits between raw byte reading and
+ * nroff processing.
+ *
+ * Normally: stores c and color, updates state, returns TRUE.
+ * At EOF: state is unchanged, c and color are undefined, returns FALSE.
+ *
+ * color can be NULL if the caller doesn't care.
+ */
+static gboolean
+mcview_get_next_maybe_ansi_char (WView *view, mcview_state_machine_t *state, int *c, int *color)
+{
+ while (TRUE)
+ {
+ mcview_state_machine_t state_saved;
+ mcview_ansi_result_t result;
+
+ state_saved = *state;
+
+ if (!mcview_get_next_char (view, state, c))
+ {
+ *state = state_saved;
+ return FALSE;
+ }
+
+ result = mcview_ansi_parse_char (&state->ansi, *c);
+
+ if (result == ANSI_RESULT_CHAR)
+ {
+ if (color != NULL)
+ *color = mcview_ansi_get_color (&state->ansi);
+ return TRUE;
+ }
+
+ // ANSI_RESULT_CONSUMED — escape sequence byte, skip and read next
+ }
+}
+
/* --------------------------------------------------------------------------------------------- */
/**
* This function parses the next nroff character and gives it to you along with its desired color,
@@ -364,7 +496,7 @@ mcview_get_next_maybe_nroff_char (WView *view, mcview_state_machine_t *state, in
*color = VIEWER_NORMAL_COLOR;
if (!view->mode_flags.nroff)
- return mcview_get_next_char (view, state, c);
+ return mcview_get_next_maybe_ansi_char (view, state, c, color);
if (!mcview_get_next_char (view, state, c))
return FALSE;
@@ -522,6 +654,31 @@ mcview_next_combining_char_sequence (WView *view, mcview_state_machine_t *state,
return i;
}
+/* --------------------------------------------------------------------------------------------- */
+/**
+ * In syntax mode, fill remaining columns on a visible row with spaces
+ * using the given color, extending the line's background to the viewport edge.
+ */
+static void
+mcview_fill_line_remaining (WView *view, int row, int col, int fill_color, off_t dpy_text_column)
+{
+ const WRect *r = &view->data_area;
+ int scr_col;
+ int x;
+
+ if (!view->mode_flags.syntax || row < 0 || row >= r->lines)
+ return;
+
+ scr_col = ((off_t) col > dpy_text_column) ? (int) ((off_t) col - dpy_text_column) : 0;
+ if (scr_col >= r->cols)
+ return;
+
+ tty_setcolor (fill_color);
+ widget_gotoyx (view, r->y + row, r->x + scr_col);
+ for (x = scr_col; x < r->cols; x++)
+ tty_print_char (' ');
+}
+
/* --------------------------------------------------------------------------------------------- */
/**
* Parse, format and possibly display one visual line of text.
@@ -554,6 +711,7 @@ mcview_display_line (WView *view, mcview_state_machine_t *state, int row, gboole
const WRect *r = &view->data_area;
off_t dpy_text_column = view->mode_flags.wrap ? 0 : view->dpy_text_column;
int col = 0;
+ int fill_color = view->syntax_fill_color;
int cs[1 + MAX_COMBINING_CHARS];
char str[(1 + MAX_COMBINING_CHARS) * MB_LEN_MAX + 1];
int i, j;
@@ -586,6 +744,7 @@ mcview_display_line (WView *view, mcview_state_machine_t *state, int row, gboole
n = mcview_next_combining_char_sequence (view, state, cs, 1 + MAX_COMBINING_CHARS, &color);
if (n == 0)
{
+ mcview_fill_line_remaining (view, row, col, fill_color, dpy_text_column);
if (linewidth != NULL)
*linewidth = col;
return (col > 0) ? 1 : 0;
@@ -596,6 +755,13 @@ mcview_display_line (WView *view, mcview_state_machine_t *state, int row, gboole
if (cs[0] == '\n')
{
+ // For empty lines (col==0), use the newline's own color which may
+ // carry the syntax highlighter's theme background from ANSI state.
+ // For non-empty lines, use the last drawn character's color.
+ int line_fill = (col > 0) ? fill_color : color;
+
+ mcview_fill_line_remaining (view, row, col, line_fill, dpy_text_column);
+
// New line: reset all formatting state for the next paragraph.
mcview_state_machine_init (state, state->offset);
if (linewidth != NULL)
@@ -695,6 +861,9 @@ mcview_display_line (WView *view, mcview_state_machine_t *state, int row, gboole
tty_print_anychar ((cs[0] == '\t') ? ' ' : PARTIAL_CJK_AT_RIGHT_MARGIN);
}
}
+
+ fill_color = color;
+ view->syntax_fill_color = color;
}
col += charwidth;
@@ -1022,6 +1191,7 @@ mcview_state_machine_init (mcview_state_machine_t *state, off_t offset)
memset (state, 0, sizeof (*state));
state->offset = offset;
state->print_lonely_combining = TRUE;
+ mcview_ansi_state_init (&state->ansi);
}
/* --------------------------------------------------------------------------------------------- */
diff --git a/src/viewer/display.c b/src/viewer/display.c
index 1284b10f3b..fe8916043b 100644
--- a/src/viewer/display.c
+++ b/src/viewer/display.c
@@ -49,6 +49,8 @@
#include "src/keymap.h"
#include "internal.h"
+#include "ansi.h" // mcview_ansi_parse_char()
+#include "syntax.h" // mcview_syntax_get_short_name()
/*** global variables ****************************************************************************/
@@ -69,6 +71,35 @@ static enum ruler_type { RULER_NONE, RULER_TOP, RULER_BOTTOM } ruler = RULER_NON
/*** file scope functions ************************************************************************/
/* --------------------------------------------------------------------------------------------- */
+/**
+ * Count content (non-ANSI) bytes in the highlighted stream from 0 to end_offset.
+ * This gives the exact byte position in the original file.
+ */
+static off_t
+mcview_syntax_content_bytes (WView *view, off_t end_offset)
+{
+ mcview_ansi_state_t parser;
+ off_t i;
+ off_t count = 0;
+
+ mcview_ansi_state_init (&parser);
+
+ for (i = 0; i < end_offset; i++)
+ {
+ int byte_val;
+
+ if (!mcview_get_byte (view, i, &byte_val))
+ break;
+
+ if (mcview_ansi_parse_char (&parser, byte_val) == ANSI_RESULT_CHAR)
+ count++;
+ }
+
+ return count;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
/** Define labels and handlers for functional keys */
static void
@@ -163,6 +194,59 @@ mcview_display_status (WView *view)
widget_gotoyx (view, r->y, r->cols - 32);
if (view->mode_flags.hex)
tty_printf ("0x%08" PRIxMAX, (uintmax_t) view->hex_cursor);
+ else if (view->mode_flags.syntax && view->filename_vpath != NULL)
+ {
+ // In syntax mode, byte offsets are inflated by ANSI escape codes.
+ // Use cached file size and content byte count to avoid repeated
+ // mc_stat() calls and O(N) scans on every status bar repaint.
+
+ // cache file size (doesn't change while viewing)
+ if (view->syntax_file_size == 0)
+ {
+ struct stat st;
+
+ if (mc_stat (view->filename_vpath, &st) == 0)
+ view->syntax_file_size = st.st_size;
+ }
+
+ if (view->syntax_file_size > 0)
+ {
+ char buffer[BUF_TRUNC_LEN + 1];
+ off_t original_pos;
+ int percent;
+ int right;
+
+ // cache content byte scan (only rescan when scroll position changes)
+ if (view->dpy_end != view->syntax_content_cache_end)
+ {
+ view->syntax_content_cache_bytes =
+ mcview_syntax_content_bytes (view, view->dpy_end);
+ view->syntax_content_cache_end = view->dpy_end;
+ }
+ original_pos = view->syntax_content_cache_bytes;
+
+ size_trunc_len (buffer, BUF_TRUNC_LEN, view->syntax_file_size, 0,
+ panels_options.kilobyte_si);
+ tty_printf ("%9" PRIuMAX "/%s ", (uintmax_t) original_pos, buffer);
+
+ // draw percent at the standard position
+ if (r->cols > 26)
+ {
+ right = r->x + r->cols;
+
+ if (view->syntax_file_size == 0 || original_pos >= view->syntax_file_size)
+ percent = 100;
+ else if (original_pos > (INT_MAX / 100))
+ percent = (int) (original_pos / (view->syntax_file_size / 100));
+ else
+ percent = (int) (original_pos * 100 / view->syntax_file_size);
+
+ widget_gotoyx (view, r->y, right - 4);
+ tty_printf ("%3d%%", percent);
+ widget_gotoyx (view, r->y, right - 1);
+ }
+ }
+ }
else
{
char buffer[BUF_TRUNC_LEN + 1];
@@ -177,11 +261,52 @@ mcview_display_status (WView *view)
}
widget_gotoyx (view, r->y, r->x);
if (r->cols > 40)
- tty_print_string (str_fit_to_term (file_label, r->cols - 34, J_LEFT_FIT));
+ {
+ int file_width = r->cols - 34;
+
+ if (view->mode_flags.syntax && file_width > 20)
+ {
+ const char *ext = NULL;
+ const char *backend;
+ int tag_width;
+
+ // extract file extension to show as syntax hint
+ if (view->filename_vpath != NULL)
+ {
+ const char *fname;
+
+ fname = vfs_path_get_last_path_str (view->filename_vpath);
+ if (fname != NULL)
+ ext = strrchr (fname, '.');
+ }
+
+ if (ext != NULL)
+ ext++; // skip the dot
+ else
+ ext = "txt";
+
+ backend = mcview_syntax_get_short_name ();
+
+ // format: " [backend:ext]"
+ tag_width = (int) strlen (backend) + (int) strlen (ext) + 4;
+ file_width -= tag_width;
+ if (file_width < 1)
+ file_width = 1;
+ tty_print_string (str_fit_to_term (file_label, file_width, J_LEFT_FIT));
+ tty_printf (" [%s:%s]", backend, ext);
+ }
+ else
+ tty_print_string (str_fit_to_term (file_label, file_width, J_LEFT_FIT));
+ }
else
tty_print_string (str_fit_to_term (file_label, r->cols - 5, J_LEFT_FIT));
- if (r->cols > 26)
+ if (r->cols > 26 && !view->mode_flags.syntax)
mcview_display_percent (view, view->mode_flags.hex ? view->hex_cursor : view->dpy_end);
+ else if (view->mode_flags.syntax)
+ {
+ // percent was drawn earlier; park cursor at end of status bar
+ widget_gotoyx (view, r->y, r->x + r->cols - 1);
+ }
}
/* --------------------------------------------------------------------------------------------- */
@@ -363,6 +488,7 @@ mcview_display_clean (WView *view)
widget_erase (w);
if (mcview_is_in_panel (view))
mcview_display_frame (view);
+ view->syntax_fill_color = VIEWER_NORMAL_COLOR;
}
/* --------------------------------------------------------------------------------------------- */
diff --git a/src/viewer/growbuf.c b/src/viewer/growbuf.c
index e843f8afcb..9288002d40 100644
--- a/src/viewer/growbuf.c
+++ b/src/viewer/growbuf.c
@@ -182,17 +182,23 @@ mcview_growbuf_read_until (WView *view, off_t ofs)
*/
view->pipe_first_err_msg = FALSE;
- mcview_show_error (view, NULL, sp->err.buf);
-
- /* when switch from parse to raw mode and back,
- * do not close the already closed pipe (see call to mcview_growbuf_done below).
- * return from here since (sp == view->ds_stdio_pipe) would now be invalid.
- * NOTE: this check was removed by ticket #4103 but the above call to
- * mcview_show_error triggers the stdio pipe handle to be closed:
- * mcview_close_datasource -> mcview_growbuf_done
- */
- if (view->ds_stdio_pipe == NULL)
- return;
+ // In syntax mode, don't show error dialog here — mcview_load() handles
+ // the fallback message after detecting empty output. For non-syntax pipes,
+ // show the raw stderr message as before.
+ if (!view->mode_flags.syntax)
+ {
+ mcview_show_error (view, NULL, sp->err.buf);
+
+ /* when switch from parse to raw mode and back,
+ * do not close the already closed pipe (see call to mcview_growbuf_done below).
+ * return from here since (sp == view->ds_stdio_pipe) would now be invalid.
+ * NOTE: this check was removed by ticket #4103 but the above call to
+ * mcview_show_error triggers the stdio pipe handle to be closed:
+ * mcview_close_datasource -> mcview_growbuf_done
+ */
+ if (view->ds_stdio_pipe == NULL)
+ return;
+ }
}
if (sp->out.len > 0)
diff --git a/src/viewer/internal.h b/src/viewer/internal.h
index 819c678fd6..3440fc3e53 100644
--- a/src/viewer/internal.h
+++ b/src/viewer/internal.h
@@ -17,6 +17,7 @@
#include "src/filemanager/dir.h" // dir_list
#include "mcviewer.h"
+#include "ansi.h"
/*** typedefs(not structures) and defined constants **********************************************/
@@ -85,6 +86,7 @@ typedef struct
gboolean nroff_underscore_is_underlined; // whether _\b_ is underlined rather than bold
gboolean
print_lonely_combining; // whether lonely combining marks are printed on a dotted circle
+ mcview_ansi_state_t ansi; // ANSI SGR escape sequence parser state
} mcview_state_machine_t;
struct mcview_nroff_struct;
@@ -154,6 +156,10 @@ struct WView
* text mode */
int cursor_col; // Cursor column
int cursor_row; // Cursor row
+ int syntax_fill_color; // Last drawn char color, for filling empty lines in syntax mode
+ off_t syntax_file_size; // Cached st_size for syntax status bar (0 = not yet cached)
+ off_t syntax_content_cache_end; // dpy_end used for cached content bytes (-1 = invalid)
+ off_t syntax_content_cache_bytes; // Cached result of content byte scan
struct hexedit_change_node *change_list; // Linked list of changes
WRect status_area; // Where the status line is displayed
WRect ruler_area; // Where the ruler is displayed
@@ -287,6 +293,7 @@ void mcview_enqueue_change (struct hexedit_change_node **head, struct hexedit_ch
void mcview_toggle_magic_mode (WView *view);
void mcview_toggle_wrap_mode (WView *view);
void mcview_toggle_nroff_mode (WView *view);
+void mcview_toggle_syntax_mode (WView *view);
void mcview_toggle_hex_mode (WView *view);
void mcview_init (WView *view);
void mcview_done (WView *view);
diff --git a/src/viewer/lib.c b/src/viewer/lib.c
index 5de9313c96..5d780a731e 100644
--- a/src/viewer/lib.c
+++ b/src/viewer/lib.c
@@ -49,6 +49,7 @@
#include "src/util.h" // file_error_message()
#include "internal.h"
+#include "syntax.h"
/*** global variables ****************************************************************************/
@@ -119,6 +120,77 @@ mcview_toggle_nroff_mode (WView *view)
/* --------------------------------------------------------------------------------------------- */
+void
+mcview_toggle_syntax_mode (WView *view)
+{
+ char *filename;
+ dir_list *dir;
+ int *dir_idx;
+ off_t saved_line, saved_col;
+
+ // save position as line number (byte offsets differ between file and pipe)
+ mcview_offset_to_coord (view, &saved_line, &saved_col, view->dpy_start);
+
+ view->mode_flags.syntax = !view->mode_flags.syntax;
+ mcview_altered_flags.syntax = TRUE;
+
+ // syntax and nroff are mutually exclusive
+ if (view->mode_flags.syntax && view->mode_flags.nroff)
+ {
+ view->mode_flags.nroff = FALSE;
+ mcview_altered_flags.nroff = TRUE;
+ }
+
+ // reinit view
+ filename = g_strdup (vfs_path_as_str (view->filename_vpath));
+ dir = view->dir;
+ dir_idx = view->dir_idx;
+ view->dir = NULL;
+ view->dir_idx = NULL;
+ mcview_done (view);
+ mcview_init (view);
+
+ if (view->mode_flags.syntax && filename != NULL && filename[0] != '\0')
+ {
+ const char *cmd_template;
+ char *syntax_cmd;
+
+ cmd_template =
+ mcview_syntax_command != NULL ? mcview_syntax_command : MCVIEW_SYNTAX_DEFAULT_CMD;
+ syntax_cmd = mcview_syntax_build_command (cmd_template, filename);
+ if (syntax_cmd != NULL)
+ {
+ mcview_load (view, syntax_cmd, filename, 0, 0, 0);
+ g_free (syntax_cmd);
+
+ // highlighter failed (stderr or no output) -- fall back to plain view
+ if (view->datasource == DS_NONE)
+ {
+ view->mode_flags.syntax = FALSE;
+ mcview_load (view, NULL, filename, 0, 0, 0);
+ }
+ }
+ else
+ mcview_load (view, NULL, filename, 0, 0, 0);
+ }
+ else
+ mcview_load (view, NULL, filename, 0, 0, 0);
+
+ // restore position by line number
+ mcview_coord_to_offset (view, &view->dpy_start, saved_line, 0);
+ view->dpy_start = mcview_bol (view, view->dpy_start, 0);
+ view->dpy_wrap_dirty = TRUE;
+
+ view->dir = dir;
+ view->dir_idx = dir_idx;
+ g_free (filename);
+
+ view->dpy_bbar_dirty = TRUE;
+ view->dirty++;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
void
mcview_toggle_hex_mode (WView *view)
{
@@ -175,6 +247,9 @@ mcview_init (WView *view)
view->cursor_col = 0;
view->cursor_row = 0;
view->change_list = NULL;
+ view->syntax_file_size = 0;
+ view->syntax_content_cache_end = -1;
+ view->syntax_content_cache_bytes = 0;
// {status,ruler,data}_area are left uninitialized
diff --git a/src/viewer/mcviewer.c b/src/viewer/mcviewer.c
index bef8977bc0..9f63108cf9 100644
--- a/src/viewer/mcviewer.c
+++ b/src/viewer/mcviewer.c
@@ -46,15 +46,16 @@
#include "src/filemanager/filemanager.h" // the_menubar
#include "internal.h"
+#include "syntax.h"
/*** global variables ****************************************************************************/
mcview_mode_flags_t mcview_global_flags = {
- .wrap = TRUE, .hex = FALSE, .magic = TRUE, .nroff = FALSE
+ .wrap = TRUE, .hex = FALSE, .magic = TRUE, .nroff = FALSE, .syntax = FALSE
};
mcview_mode_flags_t mcview_altered_flags = {
- .wrap = FALSE, .hex = FALSE, .magic = FALSE, .nroff = FALSE
+ .wrap = FALSE, .hex = FALSE, .magic = FALSE, .nroff = FALSE, .syntax = FALSE
};
gboolean mcview_remember_file_position = FALSE;
@@ -80,6 +81,42 @@ char *mcview_show_eof = NULL;
/*** file scope functions ************************************************************************/
/* --------------------------------------------------------------------------------------------- */
+/**
+ * Drain a syntax highlighter pipe so the total size is known and percentage works.
+ * Highlighters process a finite file, so this always terminates.
+ */
+static void
+mcview_drain_pipe (WView *view)
+{
+ off_t ofs;
+
+ if (!view->growbuf_in_use || view->growbuf_finished)
+ return;
+
+ ofs = mcview_get_filesize (view);
+ while (!view->growbuf_finished)
+ {
+ ofs += 8192;
+ mcview_growbuf_read_until (view, ofs);
+ }
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+/**
+ * Show "falling back to plain view" message and disable syntax mode.
+ */
+static void
+mcview_syntax_fallback (WView *view)
+{
+ message (D_NORMAL, _ ("Syntax Highlighting"), "%s",
+ _ ("Syntax highlighter could not process this file.\n"
+ "Falling back to plain view."));
+ view->mode_flags.syntax = FALSE;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
static void
mcview_mouse_callback (Widget *w, mouse_msg_t msg, mouse_event_t *event)
{
@@ -219,14 +256,25 @@ mcview_new (const WRect *r, gboolean is_panel)
mcview_init (view);
- if (mcview_global_flags.hex)
- mcview_toggle_hex_mode (view);
- if (mcview_global_flags.nroff)
- mcview_toggle_nroff_mode (view);
- if (mcview_global_flags.wrap)
- mcview_toggle_wrap_mode (view);
- if (mcview_global_flags.magic)
- mcview_toggle_magic_mode (view);
+ // save global flags — toggle functions (especially mcview_toggle_magic_mode)
+ // call mcview_done() internally, which overwrites mcview_global_flags
+ // with the view's current mode_flags before all flags have been applied
+ {
+ mcview_mode_flags_t saved_global = mcview_global_flags;
+
+ if (saved_global.hex)
+ mcview_toggle_hex_mode (view);
+ if (saved_global.nroff)
+ mcview_toggle_nroff_mode (view);
+ if (saved_global.wrap)
+ mcview_toggle_wrap_mode (view);
+ if (saved_global.magic)
+ mcview_toggle_magic_mode (view);
+ if (saved_global.syntax)
+ view->mode_flags.syntax = TRUE;
+
+ mcview_global_flags = saved_global;
+ }
return view;
}
@@ -287,6 +335,7 @@ mcview_load (WView *view, const char *command, const char *file, int start_line,
{
gboolean retval = FALSE;
vfs_path_t *vpath = NULL;
+ char *syntax_cmd = NULL;
g_assert (view->bytes_per_line != 0);
@@ -323,9 +372,76 @@ mcview_load (WView *view, const char *command, const char *file, int start_line,
mcview_set_codeset (view);
- if (command != NULL && (view->mode_flags.magic || file == NULL || file[0] == '\0'))
+ // build syntax highlight command if syntax mode is on and no explicit command given
+ if (command == NULL && view->mode_flags.syntax && file != NULL && file[0] != '\0')
+ {
+ struct stat syntax_st;
+
+ // skip syntax highlighting for files larger than the threshold
+ if (mc_stat (view->filename_vpath, &syntax_st) == 0
+ && syntax_st.st_size > MCVIEW_SYNTAX_MAX_FILE_SIZE)
+ {
+ view->mode_flags.syntax = FALSE;
+ }
+ else
+ {
+ const char *cmd_template;
+
+ cmd_template =
+ mcview_syntax_command != NULL ? mcview_syntax_command : MCVIEW_SYNTAX_DEFAULT_CMD;
+ syntax_cmd = mcview_syntax_build_command (cmd_template, file);
+ }
+ }
+
+ if (syntax_cmd != NULL)
+ {
+ gboolean cmd_ok;
+
+ cmd_ok = mcview_load_command_output (view, syntax_cmd);
+ g_free (syntax_cmd);
+
+ if (view->datasource != DS_NONE)
+ {
+ mcview_drain_pipe (view);
+
+ // If highlighter produced output, use it; otherwise fall back to plain view.
+ if (view->growbuf_in_use && mcview_growbuf_filesize (view) > 0)
+ retval = TRUE;
+ else
+ {
+ mcview_syntax_fallback (view);
+ mcview_close_datasource (view);
+ }
+ }
+ else if (cmd_ok)
+ {
+ // Pipe opened but produced no output (e.g. chroma --fail on unrecognized file).
+ mcview_syntax_fallback (view);
+ }
+ else
+ {
+ // Pipe failed to open -- mcview_load_command_output already showed error.
+ view->mode_flags.syntax = FALSE;
+ }
+ }
+
+ if (!retval && command != NULL
+ && (view->mode_flags.magic || view->mode_flags.syntax || file == NULL || file[0] == '\0'))
+ {
retval = mcview_load_command_output (view, command);
- else if (file != NULL && file[0] != '\0')
+
+ // If syntax command produced no output, show message and fall back to plain view.
+ if (retval && view->mode_flags.syntax && view->datasource == DS_NONE)
+ {
+ mcview_syntax_fallback (view);
+ retval = FALSE;
+ }
+
+ if (retval && view->mode_flags.syntax)
+ mcview_drain_pipe (view);
+ }
+
+ if (!retval && file != NULL && file[0] != '\0')
{
int fd;
char tmp[BUF_MEDIUM];
diff --git a/src/viewer/mcviewer.h b/src/viewer/mcviewer.h
index 29af9174d4..a5d2fb040a 100644
--- a/src/viewer/mcviewer.h
+++ b/src/viewer/mcviewer.h
@@ -19,10 +19,11 @@ typedef struct WView WView;
typedef struct
{
- gboolean wrap; // Wrap text lines to fit them on the screen
- gboolean hex; // Plainview or hexview
- gboolean magic; // Preprocess the file using external programs
- gboolean nroff; // Nroff-style highlighting
+ gboolean wrap; // Wrap text lines to fit them on the screen
+ gboolean hex; // Plainview or hexview
+ gboolean magic; // Preprocess the file using external programs
+ gboolean nroff; // Nroff-style highlighting
+ gboolean syntax; // Syntax highlighting via external command
} mcview_mode_flags_t;
/*** global variables defined in .c file *********************************************************/
diff --git a/src/viewer/syntax.c b/src/viewer/syntax.c
new file mode 100644
index 0000000000..e9b11e137c
--- /dev/null
+++ b/src/viewer/syntax.c
@@ -0,0 +1,372 @@
+/*
+ Internal file viewer for the Midnight Commander
+ Syntax highlighting via configurable external command
+
+ Copyright (C) 2026
+ Free Software Foundation, Inc.
+
+ This file is part of the Midnight Commander.
+
+ The Midnight Commander 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 3 of the License,
+ or (at your option) any later version.
+
+ The Midnight Commander 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.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+ */
+
+/** \file syntax.c
+ * \brief Source: syntax highlighting via external command for mcview
+ */
+
+#include
+
+#include // strstr(), strlen()
+
+#include "lib/global.h"
+#include "lib/widget.h" // quick_dialog(), message()
+
+#include "syntax.h"
+
+/*** global variables ****************************************************************************/
+
+char *mcview_syntax_command = NULL;
+
+/*** file scope macro definitions ****************************************************************/
+
+/*** file scope type declarations ****************************************************************/
+
+typedef struct
+{
+ const char *name; // display name for dialog
+ const char *short_name; // short name for status bar (e.g. "bat", "srchi")
+ const char *binary; // binary to search in PATH
+ const char *cmd; // command template (%s = filename)
+} mcview_syntax_backend_t;
+
+/*** forward declarations (file scope functions) *************************************************/
+
+/*** file scope variables ************************************************************************/
+
+// Supported syntax highlighting backends, in alphabetical order.
+static const mcview_syntax_backend_t syntax_backends[] = {
+ { "bat", "bat", "bat", "bat --color=always --style=plain --paging=never %s" },
+ { "chroma", "chrm", "chroma", "chroma --formatter terminal256 --fail %s" },
+ { "highlight", "hi", "highlight", "highlight -O xterm256 --force %s" },
+ { "pygmentize", "pyg", "pygmentize", "pygmentize -f terminal256 -O stripnl=False %s" },
+ { "source-highlight", "src-hl", "source-highlight",
+ "source-highlight --failsafe --out-format=esc -i %s" },
+};
+
+static const size_t syntax_backends_num = G_N_ELEMENTS (syntax_backends);
+
+/* --------------------------------------------------------------------------------------------- */
+/*** file scope functions ************************************************************************/
+/* --------------------------------------------------------------------------------------------- */
+
+static gboolean
+mcview_syntax_binary_available (const char *binary)
+{
+ char *path;
+
+ if (binary == NULL || binary[0] == '\0')
+ return FALSE;
+
+ if (binary[0] == '/')
+ return g_file_test (binary, G_FILE_TEST_IS_EXECUTABLE);
+
+ path = g_find_program_in_path (binary);
+ if (path == NULL)
+ return FALSE;
+
+ g_free (path);
+ return TRUE;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+// return the effective syntax command (user-configured or default)
+static const char *
+mcview_syntax_effective_command (void)
+{
+ if (mcview_syntax_command != NULL && mcview_syntax_command[0] != '\0')
+ return mcview_syntax_command;
+ return MCVIEW_SYNTAX_DEFAULT_CMD;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+// find the backend index matching the current mcview_syntax_command, or -1
+static int
+mcview_syntax_find_current_backend (void)
+{
+ const char *current;
+ size_t i;
+
+ current = mcview_syntax_effective_command ();
+
+ for (i = 0; i < syntax_backends_num; i++)
+ if (strcmp (current, syntax_backends[i].cmd) == 0)
+ return (int) i;
+
+ return -1;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+/*** public functions ****************************************************************************/
+/* --------------------------------------------------------------------------------------------- */
+
+char *
+mcview_syntax_extract_binary (const char *cmd_template)
+{
+ const char *p;
+ size_t len;
+
+ if (cmd_template == NULL || cmd_template[0] == '\0')
+ return NULL;
+
+ // find the first space — everything before it is the binary
+ p = strchr (cmd_template, ' ');
+ if (p != NULL)
+ len = (size_t) (p - cmd_template);
+ else
+ len = strlen (cmd_template);
+
+ return g_strndup (cmd_template, len);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+char *
+mcview_syntax_build_command (const char *cmd_template, const char *filename)
+{
+ const char *p;
+ char *quoted_filename;
+ GString *result;
+
+ if (cmd_template == NULL || filename == NULL)
+ return NULL;
+
+ // check at least one %s exists
+ if (strstr (cmd_template, "%s") == NULL)
+ return NULL;
+
+ quoted_filename = g_shell_quote (filename);
+ result = g_string_new ("");
+
+ // substitute all %s occurrences with the shell-escaped filename
+ p = cmd_template;
+ while (*p != '\0')
+ {
+ const char *placeholder;
+
+ placeholder = strstr (p, "%s");
+ if (placeholder == NULL)
+ {
+ g_string_append (result, p);
+ break;
+ }
+
+ g_string_append_len (result, p, placeholder - p);
+ g_string_append (result, quoted_filename);
+ p = placeholder + 2;
+ }
+
+ g_free (quoted_filename);
+
+ return g_string_free (result, FALSE);
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+gboolean
+mcview_syntax_command_available (void)
+{
+ char *binary;
+ gboolean available;
+
+ binary = mcview_syntax_extract_binary (mcview_syntax_effective_command ());
+ if (binary == NULL)
+ return FALSE;
+
+ available = mcview_syntax_binary_available (binary);
+ g_free (binary);
+
+ return available;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+const char *
+mcview_syntax_get_short_name (void)
+{
+ int idx;
+
+ idx = mcview_syntax_find_current_backend ();
+ if (idx >= 0)
+ return syntax_backends[idx].short_name;
+
+ return "ext";
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+gboolean
+mcview_syntax_options_dialog (void)
+{
+ const char **radio_items;
+ int *backend_map;
+ int total_items = 0;
+ int installed_count = 0;
+ int custom_radio_idx;
+ int selected = 0;
+ int current_idx;
+ int qd_result;
+ gboolean ret = FALSE;
+ size_t i;
+
+ // count installed backends
+ for (i = 0; i < syntax_backends_num; i++)
+ if (mcview_syntax_binary_available (syntax_backends[i].binary))
+ installed_count++;
+
+ // total = installed backends + "Custom..." entry
+ total_items = installed_count + 1;
+ custom_radio_idx = installed_count;
+
+ // build radio items: installed backends + "Custom..."
+ radio_items = g_new (const char *, total_items);
+ backend_map = g_new (int, total_items);
+
+ current_idx = mcview_syntax_find_current_backend ();
+
+ {
+ int j = 0;
+
+ for (i = 0; i < syntax_backends_num; i++)
+ {
+ if (!mcview_syntax_binary_available (syntax_backends[i].binary))
+ continue;
+
+ radio_items[j] = syntax_backends[i].name;
+ backend_map[j] = (int) i;
+
+ if ((int) i == current_idx)
+ selected = j;
+
+ j++;
+ }
+
+ // add "Custom..." entry
+ radio_items[j] = _ ("Custom...");
+ backend_map[j] = -1;
+
+ // select "Custom..." if current command doesn't match any backend
+ if (current_idx < 0 && mcview_syntax_command != NULL && mcview_syntax_command[0] != '\0')
+ selected = j;
+ }
+
+ {
+ quick_widget_t quick_widgets[] = {
+ // clang-format off
+ QUICK_RADIO (total_items, radio_items, &selected, NULL),
+ QUICK_BUTTONS_OK_CANCEL,
+ QUICK_END,
+ // clang-format on
+ };
+
+ WRect r = { -1, -1, 0, 46 };
+
+ quick_dialog_t qdlg = {
+ .rect = r,
+ .title = _ ("Syntax Backend"),
+ .help = "[Internal File Viewer]",
+ .widgets = quick_widgets,
+ .callback = NULL,
+ .mouse_callback = NULL,
+ };
+
+ qd_result = quick_dialog (&qdlg);
+ }
+
+ if (qd_result != B_ENTER)
+ goto cleanup;
+
+ if (selected == custom_radio_idx)
+ {
+ // "Custom..." selected -- prompt for command template
+ char *custom_cmd = NULL;
+ const char *initial;
+
+ initial = (mcview_syntax_command != NULL) ? mcview_syntax_command : "";
+
+ {
+ quick_widget_t custom_widgets[] = {
+ // clang-format off
+ QUICK_LABELED_INPUT (_ ("Command (%s = filename):"), input_label_above,
+ initial, "syntax-custom-cmd", &custom_cmd,
+ NULL, FALSE, FALSE, INPUT_COMPLETE_FILENAMES),
+ QUICK_BUTTONS_OK_CANCEL,
+ QUICK_END,
+ // clang-format on
+ };
+
+ WRect cr = { -1, -1, 0, 60 };
+
+ quick_dialog_t custom_dlg = {
+ .rect = cr,
+ .title = _ ("Custom Syntax Command"),
+ .help = "[Internal File Viewer]",
+ .widgets = custom_widgets,
+ .callback = NULL,
+ .mouse_callback = NULL,
+ };
+
+ qd_result = quick_dialog (&custom_dlg);
+ }
+
+ if (qd_result != B_ENTER || custom_cmd == NULL || custom_cmd[0] == '\0')
+ {
+ g_free (custom_cmd);
+ goto cleanup;
+ }
+
+ if (strstr (custom_cmd, "%s") == NULL)
+ {
+ message (D_ERROR, _ ("Syntax Highlighting"), "%s",
+ _ ("Command must contain %%s placeholder\n"
+ "for the filename."));
+ g_free (custom_cmd);
+ goto cleanup;
+ }
+
+ g_free (mcview_syntax_command);
+ mcview_syntax_command = custom_cmd;
+ ret = TRUE;
+ }
+ else
+ {
+ int chosen;
+
+ g_assert (selected >= 0 && selected < total_items);
+ chosen = backend_map[selected];
+ g_assert (chosen >= 0 && (size_t) chosen < syntax_backends_num);
+
+ g_free (mcview_syntax_command);
+ mcview_syntax_command = g_strdup (syntax_backends[chosen].cmd);
+ ret = TRUE;
+ }
+
+cleanup:
+ g_free (radio_items);
+ g_free (backend_map);
+ return ret;
+}
+
+/* --------------------------------------------------------------------------------------------- */
diff --git a/src/viewer/syntax.h b/src/viewer/syntax.h
new file mode 100644
index 0000000000..96d17902f2
--- /dev/null
+++ b/src/viewer/syntax.h
@@ -0,0 +1,52 @@
+/** \file syntax.h
+ * \brief Header: syntax highlighting via external command for mcview
+ */
+
+#ifndef MC__VIEWER_SYNTAX_H
+#define MC__VIEWER_SYNTAX_H
+
+#include "lib/global.h"
+
+/*** typedefs(not structures) and defined constants **********************************************/
+
+/** Default syntax highlighting command */
+#define MCVIEW_SYNTAX_DEFAULT_CMD "source-highlight --failsafe --out-format=esc -i %s"
+
+/** Maximum file size (in bytes) for syntax highlighting.
+ * Files larger than this are displayed without highlighting to avoid
+ * long processing times from external highlighters. */
+#define MCVIEW_SYNTAX_MAX_FILE_SIZE (2 * 1024 * 1024)
+
+/*** enums ***************************************************************************************/
+
+/*** structures declarations (and typedefs of structures)*****************************************/
+
+/*** global variables defined in .c file *********************************************************/
+
+extern char *mcview_syntax_command;
+
+/*** declarations of public functions ************************************************************/
+
+/** Extract the binary name (first word) from a command template.
+ * Returns a newly allocated string, or NULL if template is empty/NULL. */
+char *mcview_syntax_extract_binary (const char *cmd_template);
+
+/** Build a shell command by substituting %s with the shell-escaped filename.
+ * Returns a newly allocated string, or NULL on error (no %s, NULL args). */
+char *mcview_syntax_build_command (const char *cmd_template, const char *filename);
+
+/** Check if the configured syntax highlighter binary is available.
+ * Returns TRUE if found in PATH (or is an absolute path that exists). */
+gboolean mcview_syntax_command_available (void);
+
+/** Get the short name of the current backend (e.g. "bat", "srchi").
+ * Returns a static string, never NULL. */
+const char *mcview_syntax_get_short_name (void);
+
+/** Show a dialog to choose the syntax highlighting backend.
+ * Only installed backends are shown. Returns TRUE if selection changed. */
+gboolean mcview_syntax_options_dialog (void);
+
+/*** inline functions ****************************************************************************/
+
+#endif /* MC__VIEWER_SYNTAX_H */
diff --git a/tests/src/Makefile.am b/tests/src/Makefile.am
index abba5ad1f2..62c49148d1 100644
--- a/tests/src/Makefile.am
+++ b/tests/src/Makefile.am
@@ -1,6 +1,6 @@
PACKAGE_STRING = "/src"
-SUBDIRS = . filemanager vfs
+SUBDIRS = . filemanager vfs viewer
if USE_INTERNAL_EDIT
SUBDIRS += editor
diff --git a/tests/src/viewer/Makefile.am b/tests/src/viewer/Makefile.am
new file mode 100644
index 0000000000..b36a1f2ab5
--- /dev/null
+++ b/tests/src/viewer/Makefile.am
@@ -0,0 +1,26 @@
+PACKAGE_STRING = "/src/viewer"
+
+AM_CPPFLAGS = \
+ $(GLIB_CFLAGS) \
+ -I$(top_srcdir) \
+ @CHECK_CFLAGS@
+
+LIBS = @CHECK_LIBS@ \
+ $(top_builddir)/src/libinternal.la \
+ $(top_builddir)/lib/libmc.la
+
+if ENABLE_MCLIB
+LIBS += $(GLIB_LIBS)
+endif
+
+TESTS = \
+ ansi_parser \
+ syntax_cmd
+
+check_PROGRAMS = $(TESTS)
+
+ansi_parser_SOURCES = \
+ ansi_parser.c
+
+syntax_cmd_SOURCES = \
+ syntax_cmd.c
diff --git a/tests/src/viewer/ansi_parser.c b/tests/src/viewer/ansi_parser.c
new file mode 100644
index 0000000000..3b6283db5a
--- /dev/null
+++ b/tests/src/viewer/ansi_parser.c
@@ -0,0 +1,813 @@
+/*
+ src/viewer - ANSI SGR parser tests
+
+ Copyright (C) 2026
+ Free Software Foundation, Inc.
+
+ This file is part of the Midnight Commander.
+
+ The Midnight Commander 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 3 of the License,
+ or (at your option) any later version.
+
+ The Midnight Commander 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.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+ */
+
+#define TEST_SUITE_NAME "/src/viewer"
+
+#include "tests/mctest.h"
+
+#include "src/viewer/ansi.h"
+
+/* --------------------------------------------------------------------------------------------- */
+
+/* helper: feed a string through the parser and collect displayable chars */
+static GString *
+parse_and_collect (mcview_ansi_state_t *state, const char *input)
+{
+ GString *result;
+ const char *p;
+
+ result = g_string_new ("");
+
+ for (p = input; *p != '\0'; p++)
+ {
+ mcview_ansi_result_t r;
+
+ r = mcview_ansi_parse_char (state, (unsigned char) *p);
+ if (r == ANSI_RESULT_CHAR)
+ g_string_append_c (result, *p);
+ }
+
+ return result;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: init sets default state */
+
+START_TEST (test_ansi_init_defaults)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ // when
+ mcview_ansi_state_init (&state);
+
+ // then
+ ck_assert_int_eq (state.fg, MCVIEW_ANSI_COLOR_DEFAULT);
+ ck_assert_int_eq (state.bg, MCVIEW_ANSI_COLOR_DEFAULT);
+ mctest_assert_false (state.bold);
+ mctest_assert_false (state.italic);
+ mctest_assert_false (state.underline);
+ mctest_assert_false (state.blink);
+ mctest_assert_false (state.reverse);
+ mctest_assert_false (state.in_escape);
+ mctest_assert_false (state.in_csi);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: plain text passes through unchanged */
+
+START_TEST (test_ansi_plain_text_passthrough)
+{
+ // given
+ mcview_ansi_state_t state;
+ GString *result;
+
+ mcview_ansi_state_init (&state);
+
+ // when
+ result = parse_and_collect (&state, "Hello, World!");
+
+ // then
+ mctest_assert_str_eq (result->str, "Hello, World!");
+ g_string_free (result, TRUE);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: ESC[ m (reset) is consumed, no visible output */
+
+START_TEST (test_ansi_reset_consumed)
+{
+ // given
+ mcview_ansi_state_t state;
+ GString *result;
+
+ mcview_ansi_state_init (&state);
+
+ // when — ESC[m between text
+ result = parse_and_collect (&state, "ab\033[mcd");
+
+ // then — only "abcd" visible
+ mctest_assert_str_eq (result->str, "abcd");
+ g_string_free (result, TRUE);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: ESC[0m explicit reset restores defaults */
+
+START_TEST (test_ansi_explicit_reset)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — set red, then reset
+ g_string_free (parse_and_collect (&state, "\033[31m"), TRUE);
+ ck_assert_int_eq (state.fg, 1);
+
+ g_string_free (parse_and_collect (&state, "\033[0m"), TRUE);
+
+ // then — back to defaults
+ ck_assert_int_eq (state.fg, MCVIEW_ANSI_COLOR_DEFAULT);
+ ck_assert_int_eq (state.bg, MCVIEW_ANSI_COLOR_DEFAULT);
+ mctest_assert_false (state.bold);
+ mctest_assert_false (state.italic);
+ mctest_assert_false (state.underline);
+ mctest_assert_false (state.blink);
+ mctest_assert_false (state.reverse);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: foreground color SGR codes 30-37 */
+
+START_TEST (test_ansi_foreground_colors)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — ESC[31m = red foreground
+ g_string_free (parse_and_collect (&state, "\033[31m"), TRUE);
+
+ // then
+ ck_assert_int_eq (state.fg, 1);
+ ck_assert_int_eq (state.bg, MCVIEW_ANSI_COLOR_DEFAULT);
+
+ // when — ESC[34m = blue foreground
+ g_string_free (parse_and_collect (&state, "\033[34m"), TRUE);
+
+ // then
+ ck_assert_int_eq (state.fg, 4);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: background color SGR codes 40-47 */
+
+START_TEST (test_ansi_background_colors)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — ESC[42m = green background
+ g_string_free (parse_and_collect (&state, "\033[42m"), TRUE);
+
+ // then
+ ck_assert_int_eq (state.bg, 2);
+ ck_assert_int_eq (state.fg, MCVIEW_ANSI_COLOR_DEFAULT);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: bold SGR code 1 */
+
+START_TEST (test_ansi_bold)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — ESC[1m = bold
+ g_string_free (parse_and_collect (&state, "\033[1m"), TRUE);
+
+ // then
+ mctest_assert_true (state.bold);
+ mctest_assert_false (state.underline);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: underline SGR code 4 */
+
+START_TEST (test_ansi_underline)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — ESC[4m = underline
+ g_string_free (parse_and_collect (&state, "\033[4m"), TRUE);
+
+ // then
+ mctest_assert_false (state.bold);
+ mctest_assert_true (state.underline);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: combined parameters ESC[01;34m = bold + blue */
+
+START_TEST (test_ansi_combined_params)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — ESC[01;34m = bold blue (source-highlight typical output)
+ g_string_free (parse_and_collect (&state, "\033[01;34m"), TRUE);
+
+ // then
+ mctest_assert_true (state.bold);
+ ck_assert_int_eq (state.fg, 4);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: bright foreground colors 90-97 */
+
+START_TEST (test_ansi_bright_foreground)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — ESC[91m = bright red
+ g_string_free (parse_and_collect (&state, "\033[91m"), TRUE);
+
+ // then — bright colors map to 8-15
+ ck_assert_int_eq (state.fg, 9);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: bright background colors 100-107 */
+
+START_TEST (test_ansi_bright_background)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — ESC[101m = bright red background
+ g_string_free (parse_and_collect (&state, "\033[101m"), TRUE);
+
+ // then — bright bg colors map to 8-15
+ ck_assert_int_eq (state.bg, 9);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: color change without reset — state accumulates */
+
+START_TEST (test_ansi_color_change_without_reset)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — set red, then set bold without resetting red
+ g_string_free (parse_and_collect (&state, "\033[31m"), TRUE);
+ g_string_free (parse_and_collect (&state, "\033[1m"), TRUE);
+
+ // then — both red and bold active
+ ck_assert_int_eq (state.fg, 1);
+ mctest_assert_true (state.bold);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: default foreground SGR 39, default background SGR 49 */
+
+START_TEST (test_ansi_default_color_codes)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — set colors then reset individually
+ g_string_free (parse_and_collect (&state, "\033[31;42m"), TRUE);
+ ck_assert_int_eq (state.fg, 1);
+ ck_assert_int_eq (state.bg, 2);
+
+ g_string_free (parse_and_collect (&state, "\033[39m"), TRUE);
+
+ // then — fg reset, bg preserved
+ ck_assert_int_eq (state.fg, MCVIEW_ANSI_COLOR_DEFAULT);
+ ck_assert_int_eq (state.bg, 2);
+
+ // when — reset bg
+ g_string_free (parse_and_collect (&state, "\033[49m"), TRUE);
+
+ // then
+ ck_assert_int_eq (state.bg, MCVIEW_ANSI_COLOR_DEFAULT);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: non-SGR CSI sequence (e.g., cursor movement) is consumed but doesn't affect colors */
+
+START_TEST (test_ansi_non_sgr_csi_ignored)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // set a known color first
+ g_string_free (parse_and_collect (&state, "\033[31m"), TRUE);
+ ck_assert_int_eq (state.fg, 1);
+
+ // when — ESC[2J = clear screen (not SGR)
+ g_string_free (parse_and_collect (&state, "\033[2J"), TRUE);
+
+ // then — color unchanged, sequence consumed
+ ck_assert_int_eq (state.fg, 1);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: incomplete escape at end of input — chars consumed, no crash */
+
+START_TEST (test_ansi_incomplete_escape)
+{
+ // given
+ mcview_ansi_state_t state;
+ GString *result;
+
+ mcview_ansi_state_init (&state);
+
+ // when — ESC[ without terminator, then normal text in next call
+ result = parse_and_collect (&state, "ab\033[");
+ mctest_assert_str_eq (result->str, "ab");
+ g_string_free (result, TRUE);
+
+ // parser should be in CSI state
+ mctest_assert_true (state.in_csi);
+
+ // when — continue with normal text (CSI aborted by non-param/non-terminator)
+ result = parse_and_collect (&state, "31mX");
+
+ // then — the "31m" completes the CSI, "X" is displayable
+ mctest_assert_str_eq (result->str, "X");
+ ck_assert_int_eq (state.fg, 1);
+ g_string_free (result, TRUE);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: 256-color foreground ESC[38;5;Nm */
+
+START_TEST (test_ansi_256_color_foreground)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — ESC[38;5;196m = 256-color red
+ g_string_free (parse_and_collect (&state, "\033[38;5;196m"), TRUE);
+
+ // then — fg set to 196
+ ck_assert_int_eq (state.fg, 196);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: 256-color background ESC[48;5;Nm */
+
+START_TEST (test_ansi_256_color_background)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — ESC[48;5;82m = 256-color green bg
+ g_string_free (parse_and_collect (&state, "\033[48;5;82m"), TRUE);
+
+ // then — bg set to 82
+ ck_assert_int_eq (state.bg, 82);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: real source-highlight output pattern */
+
+START_TEST (test_ansi_source_highlight_pattern)
+{
+ // given
+ mcview_ansi_state_t state;
+ GString *result;
+
+ mcview_ansi_state_init (&state);
+
+ // when — typical source-highlight output: ESC[01;34m keyword ESC[m
+ result = parse_and_collect (&state, "\033[01;34mif\033[m (x)");
+
+ // then — visible text is "if (x)"
+ mctest_assert_str_eq (result->str, "if (x)");
+ // after reset, colors should be defaults
+ ck_assert_int_eq (state.fg, MCVIEW_ANSI_COLOR_DEFAULT);
+ mctest_assert_false (state.bold);
+ g_string_free (result, TRUE);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: ESC followed by non-'[' is consumed (not CSI, just ESC + char) */
+
+START_TEST (test_ansi_esc_non_csi)
+{
+ // given
+ mcview_ansi_state_t state;
+ GString *result;
+
+ mcview_ansi_state_init (&state);
+
+ // when — ESC followed by 'c' (not '[') — this is "RIS" reset
+ result = parse_and_collect (&state, "a\033cb");
+
+ // then — ESC and 'c' consumed, only 'a' and 'b' visible
+ mctest_assert_str_eq (result->str, "ab");
+ g_string_free (result, TRUE);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: truecolor foreground ESC[38;2;R;G;Bm → approximate to 256-color */
+
+START_TEST (test_ansi_truecolor_foreground)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — ESC[38;2;255;0;0m = truecolor red → should map to 196
+ // cube: r=5 g=0 b=0 → 16 + 180 + 0 + 0 = 196
+ g_string_free (parse_and_collect (&state, "\033[38;2;255;0;0m"), TRUE);
+
+ // then
+ ck_assert_int_eq (state.fg, 196);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: truecolor background ESC[48;2;R;G;Bm */
+
+START_TEST (test_ansi_truecolor_background)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — ESC[48;2;0;0;255m = truecolor blue bg → should map to 21
+ // cube: r=0 g=0 b=5 → 16 + 0 + 0 + 5 = 21
+ g_string_free (parse_and_collect (&state, "\033[48;2;0;0;255m"), TRUE);
+
+ // then
+ ck_assert_int_eq (state.bg, 21);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: truecolor combined with bold in one sequence */
+
+START_TEST (test_ansi_truecolor_combined)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — ESC[1;38;2;255;128;0m = bold + truecolor orange fg
+ // cube: r=5 g=2 b=0 → 16 + 180 + 12 + 0 = 208
+ g_string_free (parse_and_collect (&state, "\033[1;38;2;255;128;0m"), TRUE);
+
+ // then
+ mctest_assert_true (state.bold);
+ ck_assert_int_eq (state.fg, 208);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: truecolor R;G;B values not misinterpreted as SGR codes */
+
+START_TEST (test_ansi_truecolor_no_misparse)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — ESC[38;2;100;150;200m — R=100 must NOT trigger bright-bg (100-107)
+ // cube: r=1 g=2 b=4 → 16 + 36 + 12 + 4 = 68
+ g_string_free (parse_and_collect (&state, "\033[38;2;100;150;200m"), TRUE);
+
+ // then — bg must stay default (not affected by R=100)
+ ck_assert_int_eq (state.bg, MCVIEW_ANSI_COLOR_DEFAULT);
+ ck_assert_int_eq (state.fg, 68);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: italic SGR code 3 */
+
+START_TEST (test_ansi_italic)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — ESC[3m = italic
+ g_string_free (parse_and_collect (&state, "\033[3m"), TRUE);
+
+ // then
+ mctest_assert_true (state.italic);
+ mctest_assert_false (state.bold);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: blink SGR code 5 */
+
+START_TEST (test_ansi_blink)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — ESC[5m = blink
+ g_string_free (parse_and_collect (&state, "\033[5m"), TRUE);
+
+ // then
+ mctest_assert_true (state.blink);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: reverse SGR code 7 */
+
+START_TEST (test_ansi_reverse)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — ESC[7m = reverse
+ g_string_free (parse_and_collect (&state, "\033[7m"), TRUE);
+
+ // then
+ mctest_assert_true (state.reverse);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: individual off codes 23, 25, 27 */
+
+START_TEST (test_ansi_individual_off_codes)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — enable italic, blink, reverse then disable each individually
+ g_string_free (parse_and_collect (&state, "\033[3;5;7m"), TRUE);
+ mctest_assert_true (state.italic);
+ mctest_assert_true (state.blink);
+ mctest_assert_true (state.reverse);
+
+ g_string_free (parse_and_collect (&state, "\033[23m"), TRUE);
+ mctest_assert_false (state.italic);
+ mctest_assert_true (state.blink);
+
+ g_string_free (parse_and_collect (&state, "\033[25m"), TRUE);
+ mctest_assert_false (state.blink);
+ mctest_assert_true (state.reverse);
+
+ g_string_free (parse_and_collect (&state, "\033[27m"), TRUE);
+ mctest_assert_false (state.reverse);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: SGR 21 = double underline → mapped to regular underline */
+
+START_TEST (test_ansi_double_underline)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — ESC[21m = double underline
+ g_string_free (parse_and_collect (&state, "\033[21m"), TRUE);
+
+ // then — mapped to regular underline
+ mctest_assert_true (state.underline);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: empty parameter treated as 0 (reset): ESC[1;;3m = bold, reset, italic */
+
+START_TEST (test_ansi_empty_param_is_zero)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — ESC[1;;3m → 1=bold, empty=0=reset all, 3=italic
+ g_string_free (parse_and_collect (&state, "\033[1;;3m"), TRUE);
+
+ // then — bold was reset by the 0, italic is on
+ mctest_assert_false (state.bold);
+ mctest_assert_true (state.italic);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: colon notation for 256-color: ESC[38:5:196m */
+
+START_TEST (test_ansi_colon_256_color)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — colon notation
+ g_string_free (parse_and_collect (&state, "\033[38:5:196m"), TRUE);
+
+ // then — fg = 196
+ ck_assert_int_eq (state.fg, 196);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: colon notation does NOT leak into SGR: ESC[38:5:4:7m → 7 is NOT reverse */
+
+START_TEST (test_ansi_colon_nested_no_leak)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — all colon-separated, 7 belongs to the color group
+ g_string_free (parse_and_collect (&state, "\033[38:5:4:7m"), TRUE);
+
+ // then — fg = 4 (from 38:5:4), reverse must NOT be set
+ ck_assert_int_eq (state.fg, 4);
+ mctest_assert_false (state.reverse);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: semicolon flat notation: ESC[38;5;4;7m → color 4 + reverse */
+
+START_TEST (test_ansi_semicolon_flat_with_reverse)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — semicolon notation, 7 is a separate SGR param
+ g_string_free (parse_and_collect (&state, "\033[38;5;4;7m"), TRUE);
+
+ // then — fg = 4, reverse IS set
+ ck_assert_int_eq (state.fg, 4);
+ mctest_assert_true (state.reverse);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: de jure truecolor with colon and color space: ESC[38:2::255:0:0m */
+
+START_TEST (test_ansi_colon_truecolor_with_colorspace)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — de jure: 38:2:CS:R:G:B with empty CS (=0)
+ // cube: r=5 g=0 b=0 → 16 + 180 + 0 + 0 = 196
+ g_string_free (parse_and_collect (&state, "\033[38:2::255:0:0m"), TRUE);
+
+ // then
+ ck_assert_int_eq (state.fg, 196);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: reset clears all new attributes (italic, blink, reverse) */
+
+START_TEST (test_ansi_reset_clears_all_attrs)
+{
+ // given
+ mcview_ansi_state_t state;
+
+ mcview_ansi_state_init (&state);
+
+ // when — set everything, then reset
+ g_string_free (parse_and_collect (&state, "\033[1;3;4;5;7;31;42m"), TRUE);
+ mctest_assert_true (state.bold);
+ mctest_assert_true (state.italic);
+ mctest_assert_true (state.underline);
+ mctest_assert_true (state.blink);
+ mctest_assert_true (state.reverse);
+
+ g_string_free (parse_and_collect (&state, "\033[0m"), TRUE);
+
+ // then — all cleared
+ mctest_assert_false (state.bold);
+ mctest_assert_false (state.italic);
+ mctest_assert_false (state.underline);
+ mctest_assert_false (state.blink);
+ mctest_assert_false (state.reverse);
+ ck_assert_int_eq (state.fg, MCVIEW_ANSI_COLOR_DEFAULT);
+ ck_assert_int_eq (state.bg, MCVIEW_ANSI_COLOR_DEFAULT);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+
+int
+main (void)
+{
+ TCase *tc_core;
+
+ tc_core = tcase_create ("Core");
+
+ // Add new tests here: ***************
+ tcase_add_test (tc_core, test_ansi_init_defaults);
+ tcase_add_test (tc_core, test_ansi_plain_text_passthrough);
+ tcase_add_test (tc_core, test_ansi_reset_consumed);
+ tcase_add_test (tc_core, test_ansi_explicit_reset);
+ tcase_add_test (tc_core, test_ansi_foreground_colors);
+ tcase_add_test (tc_core, test_ansi_background_colors);
+ tcase_add_test (tc_core, test_ansi_bold);
+ tcase_add_test (tc_core, test_ansi_underline);
+ tcase_add_test (tc_core, test_ansi_combined_params);
+ tcase_add_test (tc_core, test_ansi_bright_foreground);
+ tcase_add_test (tc_core, test_ansi_bright_background);
+ tcase_add_test (tc_core, test_ansi_color_change_without_reset);
+ tcase_add_test (tc_core, test_ansi_default_color_codes);
+ tcase_add_test (tc_core, test_ansi_non_sgr_csi_ignored);
+ tcase_add_test (tc_core, test_ansi_incomplete_escape);
+ tcase_add_test (tc_core, test_ansi_256_color_foreground);
+ tcase_add_test (tc_core, test_ansi_256_color_background);
+ tcase_add_test (tc_core, test_ansi_source_highlight_pattern);
+ tcase_add_test (tc_core, test_ansi_esc_non_csi);
+ tcase_add_test (tc_core, test_ansi_truecolor_foreground);
+ tcase_add_test (tc_core, test_ansi_truecolor_background);
+ tcase_add_test (tc_core, test_ansi_truecolor_combined);
+ tcase_add_test (tc_core, test_ansi_truecolor_no_misparse);
+ tcase_add_test (tc_core, test_ansi_italic);
+ tcase_add_test (tc_core, test_ansi_blink);
+ tcase_add_test (tc_core, test_ansi_reverse);
+ tcase_add_test (tc_core, test_ansi_individual_off_codes);
+ tcase_add_test (tc_core, test_ansi_double_underline);
+ tcase_add_test (tc_core, test_ansi_empty_param_is_zero);
+ tcase_add_test (tc_core, test_ansi_colon_256_color);
+ tcase_add_test (tc_core, test_ansi_colon_nested_no_leak);
+ tcase_add_test (tc_core, test_ansi_semicolon_flat_with_reverse);
+ tcase_add_test (tc_core, test_ansi_colon_truecolor_with_colorspace);
+ tcase_add_test (tc_core, test_ansi_reset_clears_all_attrs);
+ // ***********************************
+
+ return mctest_run_all (tc_core);
+}
+
+/* --------------------------------------------------------------------------------------------- */
diff --git a/tests/src/viewer/syntax_cmd.c b/tests/src/viewer/syntax_cmd.c
new file mode 100644
index 0000000000..b4e9b04b39
--- /dev/null
+++ b/tests/src/viewer/syntax_cmd.c
@@ -0,0 +1,263 @@
+/*
+ src/viewer - syntax highlighting command tests
+
+ Copyright (C) 2026
+ Free Software Foundation, Inc.
+
+ This file is part of the Midnight Commander.
+
+ The Midnight Commander 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 3 of the License,
+ or (at your option) any later version.
+
+ The Midnight Commander 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.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+ */
+
+#define TEST_SUITE_NAME "/src/viewer"
+
+#include "tests/mctest.h"
+
+#include "src/viewer/syntax.h"
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: extract binary from simple command */
+
+START_TEST (test_syntax_extract_binary_simple)
+{
+ // given
+ const char *cmd = "source-highlight --failsafe --out-format=esc -i %s";
+ char *binary;
+
+ // when
+ binary = mcview_syntax_extract_binary (cmd);
+
+ // then
+ mctest_assert_str_eq (binary, "source-highlight");
+ g_free (binary);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: extract binary from command with absolute path */
+
+START_TEST (test_syntax_extract_binary_absolute_path)
+{
+ // given
+ const char *cmd = "/usr/bin/source-highlight --failsafe -i %s";
+ char *binary;
+
+ // when
+ binary = mcview_syntax_extract_binary (cmd);
+
+ // then
+ mctest_assert_str_eq (binary, "/usr/bin/source-highlight");
+ g_free (binary);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: extract binary from command with only binary name */
+
+START_TEST (test_syntax_extract_binary_name_only)
+{
+ // given
+ const char *cmd = "pygmentize";
+ char *binary;
+
+ // when
+ binary = mcview_syntax_extract_binary (cmd);
+
+ // then
+ mctest_assert_str_eq (binary, "pygmentize");
+ g_free (binary);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: extract binary returns NULL for empty string */
+
+START_TEST (test_syntax_extract_binary_empty)
+{
+ // given / when
+ char *binary = mcview_syntax_extract_binary ("");
+
+ // then
+ ck_assert_ptr_eq (binary, NULL);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: extract binary returns NULL for NULL */
+
+START_TEST (test_syntax_extract_binary_null)
+{
+ // given / when
+ char *binary = mcview_syntax_extract_binary (NULL);
+
+ // then
+ ck_assert_ptr_eq (binary, NULL);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: build command with simple filename substitution */
+
+START_TEST (test_syntax_build_command_simple)
+{
+ // given
+ const char *tmpl = "source-highlight --failsafe --out-format=esc -i %s";
+ const char *filename = "test.c";
+ char *cmd;
+
+ // when
+ cmd = mcview_syntax_build_command (tmpl, filename);
+
+ // then
+ mctest_assert_str_eq (cmd, "source-highlight --failsafe --out-format=esc -i 'test.c'");
+ g_free (cmd);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: build command shell-escapes filename with spaces */
+
+START_TEST (test_syntax_build_command_spaces_in_filename)
+{
+ // given
+ const char *tmpl = "source-highlight -i %s";
+ const char *filename = "my file.c";
+ char *cmd;
+
+ // when
+ cmd = mcview_syntax_build_command (tmpl, filename);
+
+ // then
+ mctest_assert_str_eq (cmd, "source-highlight -i 'my file.c'");
+ g_free (cmd);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: build command shell-escapes filename with quotes */
+
+START_TEST (test_syntax_build_command_quotes_in_filename)
+{
+ // given
+ const char *tmpl = "highlight -O xterm256 %s";
+ const char *filename = "it's a test.c";
+ char *cmd;
+
+ // when
+ cmd = mcview_syntax_build_command (tmpl, filename);
+
+ // then
+ mctest_assert_str_eq (cmd, "highlight -O xterm256 'it'\\''s a test.c'");
+ g_free (cmd);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: build command returns NULL when template has no %s */
+
+START_TEST (test_syntax_build_command_no_placeholder)
+{
+ // given
+ const char *tmpl = "source-highlight --failsafe";
+ const char *filename = "test.c";
+
+ // when
+ char *cmd = mcview_syntax_build_command (tmpl, filename);
+
+ // then
+ ck_assert_ptr_eq (cmd, NULL);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: build command returns NULL for NULL template */
+
+START_TEST (test_syntax_build_command_null_template)
+{
+ // given / when
+ char *cmd = mcview_syntax_build_command (NULL, "test.c");
+
+ // then
+ ck_assert_ptr_eq (cmd, NULL);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: build command returns NULL for NULL filename */
+
+START_TEST (test_syntax_build_command_null_filename)
+{
+ // given / when
+ char *cmd = mcview_syntax_build_command ("highlight %s", NULL);
+
+ // then
+ ck_assert_ptr_eq (cmd, NULL);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+
+/* --------------------------------------------------------------------------------------------- */
+/* Test: build command returns NULL for empty filename */
+
+START_TEST (test_syntax_build_command_empty_filename)
+{
+ // given / when
+ char *cmd = mcview_syntax_build_command ("highlight %s", "");
+
+ // then — empty filename is valid, produces empty quoted string
+ ck_assert_ptr_ne (cmd, NULL);
+ mctest_assert_str_eq (cmd, "highlight ''");
+ g_free (cmd);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+
+int
+main (void)
+{
+ Suite *s;
+ SRunner *sr;
+ TCase *tc_extract;
+ TCase *tc_build;
+ int number_failed;
+
+ tc_extract = tcase_create ("syntax-extract-binary");
+ tcase_add_test (tc_extract, test_syntax_extract_binary_simple);
+ tcase_add_test (tc_extract, test_syntax_extract_binary_absolute_path);
+ tcase_add_test (tc_extract, test_syntax_extract_binary_name_only);
+ tcase_add_test (tc_extract, test_syntax_extract_binary_empty);
+ tcase_add_test (tc_extract, test_syntax_extract_binary_null);
+
+ tc_build = tcase_create ("syntax-build-command");
+ tcase_add_test (tc_build, test_syntax_build_command_simple);
+ tcase_add_test (tc_build, test_syntax_build_command_spaces_in_filename);
+ tcase_add_test (tc_build, test_syntax_build_command_quotes_in_filename);
+ tcase_add_test (tc_build, test_syntax_build_command_no_placeholder);
+ tcase_add_test (tc_build, test_syntax_build_command_null_template);
+ tcase_add_test (tc_build, test_syntax_build_command_null_filename);
+ tcase_add_test (tc_build, test_syntax_build_command_empty_filename);
+
+ s = suite_create (TEST_SUITE_NAME);
+ suite_add_tcase (s, tc_extract);
+ suite_add_tcase (s, tc_build);
+ sr = srunner_create (s);
+ srunner_set_log (sr, "-");
+ srunner_run_all (sr, CK_ENV);
+ number_failed = srunner_ntests_failed (sr);
+ srunner_free (sr);
+
+ return (number_failed == 0) ? EXIT_SUCCESS : EXIT_FAILURE;
+}