mruby-bin-mirb: add syntax highlighting for keywords and strings

Add syntax highlighting to mirb's multi-line editor with support for:
- keywords (def, if, class, end, etc.) in magenta
- strings ("...", '...', %q{...}) in green
- comments (#...) in gray
- numbers (42, 3.14, 0xff) in cyan
- symbols (:foo) in yellow
- constants (Array, Foo) in bold yellow
- instance variables (@var) in blue
- global variables ($var) in bold blue

Features:
- auto-detects light/dark theme via COLORFGBG env var
- MIRB_THEME=light/dark for explicit override
- method calls like obj.class correctly not highlighted as keywords
- enabled automatically when terminal supports color

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Yukihiro "Matz" Matsumoto
2025-12-29 08:06:59 +09:00
parent a866a5b0e6
commit 624272b15d
4 changed files with 512 additions and 1 deletions
@@ -340,6 +340,7 @@ mirb_editor_init(mirb_editor *ed)
ed->prompt_cont_fmt = NULL;
ed->line_num_base = 1;
ed->use_color = FALSE;
mirb_highlight_init(&ed->highlight, FALSE);
ed->initialized = TRUE;
return TRUE;
@@ -573,6 +574,7 @@ void
mirb_editor_set_color(mirb_editor *ed, mrb_bool enable)
{
ed->use_color = enable;
mirb_highlight_init(&ed->highlight, enable);
}
/*
@@ -657,10 +659,13 @@ refresh_display(mirb_editor *ed)
mirb_term_cursor_col(1);
mirb_term_clear_below();
/* Reset highlight state for fresh scan */
mirb_highlight_reset(&ed->highlight);
/* Redraw all lines */
for (size_t i = 0; i < ed->buf.line_count; i++) {
print_prompt(ed, i);
printf("%s", mirb_buffer_line_at(&ed->buf, i));
mirb_highlight_print_line(&ed->highlight, mirb_buffer_line_at(&ed->buf, i));
if (i < ed->buf.line_count - 1) {
printf("\r\n");
@@ -11,6 +11,7 @@
#include "mirb_term.h"
#include "mirb_buffer.h"
#include "mirb_history.h"
#include "mirb_highlight.h"
/*
* Editor result codes
@@ -72,6 +73,8 @@ typedef struct mirb_editor {
mrb_bool initialized; /* editor is initialized */
mrb_bool use_color; /* use colored output */
mirb_highlighter highlight; /* syntax highlighting state */
} mirb_editor;
/*
@@ -0,0 +1,425 @@
/*
** mirb_highlight.c - Syntax highlighting for mirb
**
** See Copyright Notice in mruby.h
*/
#include "mirb_highlight.h"
#include <string.h>
#include <stdlib.h>
#include <ctype.h>
/* ANSI color codes - using standard 16-color palette for compatibility */
/* Dark theme colors (bright/light colors on dark background) */
#define DARK_KEYWORD "\033[1;35m" /* bold magenta */
#define DARK_STRING "\033[32m" /* green */
#define DARK_COMMENT "\033[90m" /* bright black (gray) */
#define DARK_NUMBER "\033[36m" /* cyan */
#define DARK_SYMBOL "\033[33m" /* yellow */
#define DARK_CONSTANT "\033[1;33m" /* bold yellow */
#define DARK_IVAR "\033[34m" /* blue */
#define DARK_GVAR "\033[1;34m" /* bold blue */
#define DARK_REGEXP "\033[31m" /* red */
/* Light theme colors (dark colors on light background) */
#define LIGHT_KEYWORD "\033[35m" /* magenta */
#define LIGHT_STRING "\033[32m" /* green */
#define LIGHT_COMMENT "\033[37m" /* white (light gray) */
#define LIGHT_NUMBER "\033[36m" /* cyan */
#define LIGHT_SYMBOL "\033[33m" /* yellow */
#define LIGHT_CONSTANT "\033[33m" /* yellow */
#define LIGHT_IVAR "\033[34m" /* blue */
#define LIGHT_GVAR "\033[34m" /* blue */
#define LIGHT_REGEXP "\033[31m" /* red */
#define COLOR_RESET "\033[0m"
/* Keyword list - must be sorted alphabetically for bsearch */
static const char *keywords[] = {
"BEGIN", "END", "__ENCODING__", "__FILE__", "__LINE__",
"alias", "and", "begin", "break", "case", "class", "def",
"defined?", "do", "else", "elsif", "end", "ensure", "false",
"for", "if", "in", "module", "next", "nil", "not", "or",
"redo", "rescue", "retry", "return", "self", "super", "then",
"true", "undef", "unless", "until", "when", "while", "yield"
};
#define NUM_KEYWORDS (sizeof(keywords) / sizeof(keywords[0]))
static int
keyword_cmp(const void *a, const void *b)
{
return strcmp((const char *)a, *(const char **)b);
}
static mrb_bool
is_keyword(const char *word, size_t len)
{
char buf[32];
if (len >= sizeof(buf)) return FALSE;
memcpy(buf, word, len);
buf[len] = '\0';
return bsearch(buf, keywords, NUM_KEYWORDS, sizeof(keywords[0]), keyword_cmp) != NULL;
}
static mrb_bool
is_word_char(char c)
{
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') || c == '_';
}
static mrb_bool
is_word_start(char c)
{
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_';
}
static mrb_bool
is_upper(char c)
{
return c >= 'A' && c <= 'Z';
}
/*
* Get color code for token type based on theme
*/
static const char *
get_color(mirb_highlighter *hl, mirb_token_type type)
{
if (!hl->enabled) return "";
if (hl->theme == MIRB_THEME_DARK) {
switch (type) {
case MIRB_TOK_KEYWORD: return DARK_KEYWORD;
case MIRB_TOK_STRING: return DARK_STRING;
case MIRB_TOK_COMMENT: return DARK_COMMENT;
case MIRB_TOK_NUMBER: return DARK_NUMBER;
case MIRB_TOK_SYMBOL: return DARK_SYMBOL;
case MIRB_TOK_CONSTANT: return DARK_CONSTANT;
case MIRB_TOK_IVAR: return DARK_IVAR;
case MIRB_TOK_GVAR: return DARK_GVAR;
case MIRB_TOK_REGEXP: return DARK_REGEXP;
default: return "";
}
}
else {
switch (type) {
case MIRB_TOK_KEYWORD: return LIGHT_KEYWORD;
case MIRB_TOK_STRING: return LIGHT_STRING;
case MIRB_TOK_COMMENT: return LIGHT_COMMENT;
case MIRB_TOK_NUMBER: return LIGHT_NUMBER;
case MIRB_TOK_SYMBOL: return LIGHT_SYMBOL;
case MIRB_TOK_CONSTANT: return LIGHT_CONSTANT;
case MIRB_TOK_IVAR: return LIGHT_IVAR;
case MIRB_TOK_GVAR: return LIGHT_GVAR;
case MIRB_TOK_REGEXP: return LIGHT_REGEXP;
default: return "";
}
}
}
static const char *
get_reset(mirb_highlighter *hl)
{
return hl->enabled ? COLOR_RESET : "";
}
/*
* Print n characters with specified color
*/
static void
print_colored(mirb_highlighter *hl, const char *start, size_t len, mirb_token_type type)
{
const char *color = get_color(hl, type);
const char *reset = get_reset(hl);
if (*color) printf("%s", color);
fwrite(start, 1, len, stdout);
if (*color) printf("%s", reset);
}
/*
* Detect theme from environment
*/
mirb_theme
mirb_highlight_detect_theme(void)
{
const char *env;
/* Check explicit MIRB_THEME first */
env = getenv("MIRB_THEME");
if (env) {
if (strcmp(env, "light") == 0) return MIRB_THEME_LIGHT;
if (strcmp(env, "dark") == 0) return MIRB_THEME_DARK;
}
/* Check COLORFGBG (format: "fg;bg" where bg > 6 usually means light) */
env = getenv("COLORFGBG");
if (env) {
const char *semi = strchr(env, ';');
if (semi) {
int bg = atoi(semi + 1);
/* Background colors 7, 15, or high values typically mean light theme */
if (bg == 7 || bg == 15 || (bg >= 230 && bg <= 255)) {
return MIRB_THEME_LIGHT;
}
}
}
/* Default to dark theme (more common in terminals) */
return MIRB_THEME_DARK;
}
void
mirb_highlight_init(mirb_highlighter *hl, mrb_bool enabled)
{
memset(hl, 0, sizeof(*hl));
hl->enabled = enabled;
if (enabled) {
hl->theme = mirb_highlight_detect_theme();
}
}
void
mirb_highlight_set_theme(mirb_highlighter *hl, mirb_theme theme)
{
hl->theme = theme;
}
void
mirb_highlight_reset(mirb_highlighter *hl)
{
hl->in_string = FALSE;
hl->string_quote = 0;
hl->in_heredoc = FALSE;
hl->in_regexp = FALSE;
}
/*
* Print a line with syntax highlighting
*/
void
mirb_highlight_print_line(mirb_highlighter *hl, const char *line)
{
const char *p = line;
const char *token_start;
if (!hl->enabled) {
printf("%s", line);
return;
}
/* Handle continuation of multi-line string */
if (hl->in_string) {
token_start = p;
while (*p) {
if (*p == '\\' && p[1]) {
p += 2;
continue;
}
if (*p == hl->string_quote) {
p++;
print_colored(hl, token_start, (size_t)(p - token_start), MIRB_TOK_STRING);
hl->in_string = FALSE;
break;
}
p++;
}
if (hl->in_string) {
/* String continues to next line */
print_colored(hl, token_start, (size_t)(p - token_start), MIRB_TOK_STRING);
return;
}
}
while (*p) {
/* Comment - rest of line */
if (*p == '#') {
print_colored(hl, p, strlen(p), MIRB_TOK_COMMENT);
return;
}
/* Strings */
if (*p == '"' || *p == '\'') {
char quote = *p;
token_start = p++;
while (*p) {
if (*p == '\\' && p[1]) {
p += 2;
continue;
}
if (*p == quote) {
p++;
break;
}
p++;
}
if (p[-1] == quote) {
print_colored(hl, token_start, (size_t)(p - token_start), MIRB_TOK_STRING);
}
else {
/* Unterminated string - continues to next line */
print_colored(hl, token_start, (size_t)(p - token_start), MIRB_TOK_STRING);
hl->in_string = TRUE;
hl->string_quote = quote;
return;
}
continue;
}
/* Percent strings: %q{...}, %Q{...}, %w{...}, etc. */
if (*p == '%' && p[1] && strchr("qQwWiIxsr", p[1])) {
char open = p[2];
char close = 0;
int depth = 1;
token_start = p;
if (open == '(' || open == '{' || open == '[' || open == '<') {
close = (open == '(') ? ')' : (open == '{') ? '}' : (open == '[') ? ']' : '>';
p += 3;
while (*p && depth > 0) {
if (*p == '\\' && p[1]) {
p += 2;
continue;
}
if (*p == open) depth++;
else if (*p == close) depth--;
p++;
}
print_colored(hl, token_start, (size_t)(p - token_start), MIRB_TOK_STRING);
continue;
}
else if (open) {
/* Non-paired delimiter like %q!...! */
p += 3;
while (*p && *p != open) {
if (*p == '\\' && p[1]) {
p += 2;
continue;
}
p++;
}
if (*p == open) p++;
print_colored(hl, token_start, (size_t)(p - token_start), MIRB_TOK_STRING);
continue;
}
/* Not a percent string, fall through */
}
/* Symbols: :symbol or :"string" */
if (*p == ':' && p[1] && (is_word_start(p[1]) || p[1] == '"' || p[1] == '\'')) {
token_start = p++;
if (*p == '"' || *p == '\'') {
/* Quoted symbol */
char quote = *p++;
while (*p && *p != quote) {
if (*p == '\\' && p[1]) {
p += 2;
continue;
}
p++;
}
if (*p == quote) p++;
}
else {
/* Regular symbol */
while (*p && (is_word_char(*p) || *p == '?' || *p == '!')) p++;
}
print_colored(hl, token_start, (size_t)(p - token_start), MIRB_TOK_SYMBOL);
continue;
}
/* Instance variables: @var */
if (*p == '@') {
token_start = p++;
if (*p == '@') p++; /* @@class_var */
while (*p && is_word_char(*p)) p++;
print_colored(hl, token_start, (size_t)(p - token_start), MIRB_TOK_IVAR);
continue;
}
/* Global variables: $var */
if (*p == '$') {
token_start = p++;
/* Special globals like $!, $?, $1, etc. */
if (*p && !is_word_char(*p) && *p != ' ') {
p++;
}
else {
while (*p && is_word_char(*p)) p++;
}
print_colored(hl, token_start, (size_t)(p - token_start), MIRB_TOK_GVAR);
continue;
}
/* Numbers */
if ((*p >= '0' && *p <= '9') ||
(*p == '-' && p[1] >= '0' && p[1] <= '9' && (p == line || !is_word_char(p[-1])))) {
token_start = p;
if (*p == '-') p++;
if (*p == '0' && (p[1] == 'x' || p[1] == 'X')) {
/* Hex */
p += 2;
while ((*p >= '0' && *p <= '9') || (*p >= 'a' && *p <= 'f') ||
(*p >= 'A' && *p <= 'F') || *p == '_') p++;
}
else if (*p == '0' && (p[1] == 'b' || p[1] == 'B')) {
/* Binary */
p += 2;
while (*p == '0' || *p == '1' || *p == '_') p++;
}
else if (*p == '0' && (p[1] == 'o' || p[1] == 'O')) {
/* Octal */
p += 2;
while ((*p >= '0' && *p <= '7') || *p == '_') p++;
}
else {
/* Decimal or float */
while ((*p >= '0' && *p <= '9') || *p == '_') p++;
if (*p == '.' && p[1] >= '0' && p[1] <= '9') {
p++;
while ((*p >= '0' && *p <= '9') || *p == '_') p++;
}
if (*p == 'e' || *p == 'E') {
p++;
if (*p == '+' || *p == '-') p++;
while ((*p >= '0' && *p <= '9') || *p == '_') p++;
}
}
/* Suffix like 'i' for complex or 'r' for rational */
if (*p == 'i' || *p == 'r') p++;
print_colored(hl, token_start, (size_t)(p - token_start), MIRB_TOK_NUMBER);
continue;
}
/* Identifiers and keywords */
if (is_word_start(*p)) {
token_start = p;
mrb_bool is_const = is_upper(*p);
/* Check if preceded by dot (method call like obj.class) */
mrb_bool after_dot = (token_start > line && token_start[-1] == '.');
while (*p && (is_word_char(*p) || *p == '?' || *p == '!')) p++;
size_t len = (size_t)(p - token_start);
if (is_const) {
print_colored(hl, token_start, len, MIRB_TOK_CONSTANT);
}
else if (!after_dot && is_keyword(token_start, len)) {
print_colored(hl, token_start, len, MIRB_TOK_KEYWORD);
}
else {
fwrite(token_start, 1, len, stdout);
}
continue;
}
/* Regular expression (simple heuristic: after =~, !~ or at line start after if/unless/when) */
/* This is tricky - for now just output as-is */
/* Default: just output character */
putchar(*p++);
}
}
@@ -0,0 +1,78 @@
/*
** mirb_highlight.h - Syntax highlighting for mirb
**
** See Copyright Notice in mruby.h
*/
#ifndef MIRB_HIGHLIGHT_H
#define MIRB_HIGHLIGHT_H
#include <mruby.h>
#include <stdio.h>
/*
* Token types for syntax highlighting
*/
typedef enum mirb_token_type {
MIRB_TOK_DEFAULT, /* default text */
MIRB_TOK_KEYWORD, /* if, else, def, class, end, etc. */
MIRB_TOK_STRING, /* "..." or '...' */
MIRB_TOK_COMMENT, /* # to end of line */
MIRB_TOK_NUMBER, /* integers, floats */
MIRB_TOK_SYMBOL, /* :symbol */
MIRB_TOK_CONSTANT, /* Uppercase identifiers */
MIRB_TOK_IVAR, /* @instance_var */
MIRB_TOK_GVAR, /* $global_var */
MIRB_TOK_REGEXP, /* /regexp/ */
MIRB_TOK_MAX
} mirb_token_type;
/*
* Color theme
*/
typedef enum mirb_theme {
MIRB_THEME_DARK, /* light text on dark background (default) */
MIRB_THEME_LIGHT /* dark text on light background */
} mirb_theme;
/*
* Highlighter state
*/
typedef struct mirb_highlighter {
mirb_theme theme;
mrb_bool enabled;
/* Multi-line state tracking */
mrb_bool in_string;
char string_quote; /* '"' or '\'' */
mrb_bool in_heredoc;
mrb_bool in_regexp;
} mirb_highlighter;
/*
* Initialize highlighter with auto-detected or specified theme
*/
void mirb_highlight_init(mirb_highlighter *hl, mrb_bool enabled);
/*
* Set theme explicitly
*/
void mirb_highlight_set_theme(mirb_highlighter *hl, mirb_theme theme);
/*
* Detect theme from environment variables
* Returns MIRB_THEME_DARK if cannot detect
*/
mirb_theme mirb_highlight_detect_theme(void);
/*
* Print a line with syntax highlighting
* Handles multi-line strings/comments by tracking state
*/
void mirb_highlight_print_line(mirb_highlighter *hl, const char *line);
/*
* Reset multi-line state (call when starting new input)
*/
void mirb_highlight_reset(mirb_highlighter *hl);
#endif /* MIRB_HIGHLIGHT_H */