From 00fca2dabd483633abdd47d1a3518b6884ff23af Mon Sep 17 00:00:00 2001 From: Michael Stapelberg Date: Sun, 7 Oct 2012 16:32:50 +0200 Subject: [PATCH] add first bits of a (custom) config parser --- include/all.h | 2 + include/config_directives.h | 29 +++ parser-specs/config.spec | 60 +++++ src/config_directives.c | 56 ++++ src/config_parser.c | 446 ++++++++++++++++++++++++++++++++ src/i3.mk | 15 +- testcases/Makefile.PL | 1 + testcases/t/201-config-parser.t | 79 ++++++ 8 files changed, 687 insertions(+), 1 deletion(-) create mode 100644 include/config_directives.h create mode 100644 parser-specs/config.spec create mode 100644 src/config_directives.c create mode 100644 src/config_parser.c create mode 100644 testcases/t/201-config-parser.t diff --git a/include/all.h b/include/all.h index 48ca6621..9ac6a54f 100644 --- a/include/all.h +++ b/include/all.h @@ -79,6 +79,8 @@ #include "scratchpad.h" #include "commands.h" #include "commands_parser.h" +#include "config_directives.h" +//#include "config_parser.h" #include "fake_outputs.h" #include "display_version.h" diff --git a/include/config_directives.h b/include/config_directives.h new file mode 100644 index 00000000..5922144f --- /dev/null +++ b/include/config_directives.h @@ -0,0 +1,29 @@ +/* + * vim:ts=4:sw=4:expandtab + * + * i3 - an improved dynamic tiling window manager + * © 2009-2012 Michael Stapelberg and contributors (see also: LICENSE) + * + * commands.c: all command functions (see commands_parser.c) + * + */ +#ifndef I3_CONFIG_DIRECTIVES_H +#define I3_CONFIG_DIRECTIVES_H + +//#include "config_parser.h" + +/** The beginning of the prototype for every cmd_ function. */ +#define I3_CFG Match *current_match, struct CommandResult *cmd_output + +/** + * + */ +void cfg_font(I3_CFG, const char *font); + +void cfg_mode_binding(I3_CFG, const char *bindtype, const char *modifiers, const char *key, const char *command); + +void cfg_enter_mode(I3_CFG, const char *mode); + +void cfg_exec(I3_CFG, const char *exectype, const char *no_startup_id, const char *command); + +#endif diff --git a/parser-specs/config.spec b/parser-specs/config.spec new file mode 100644 index 00000000..75c07232 --- /dev/null +++ b/parser-specs/config.spec @@ -0,0 +1,60 @@ +# vim:ts=2:sw=2:expandtab +# +# i3 - an improved dynamic tiling window manager +# © 2009-2012 Michael Stapelberg and contributors (see also: LICENSE) +# +# parser-specs/config.spec: Specification file for generate-command-parser.pl +# which will generate the appropriate header files for our C parser. +# +# Use :source highlighting.vim in vim to get syntax highlighting +# for this file. + +# TODO: get it to parse the default config :) +# TODO: comment handling (on their own line, at the end of a line) + +state INITIAL: + # We have an end token here for all the commands which just call some + # function without using an explicit 'end' token. + end -> + #'[' -> call cmd_criteria_init(); CRITERIA + 'font' -> FONT + 'mode' -> MODENAME + exectype = 'exec_always', 'exec' + -> EXEC + +# [--no-startup-id] command +state EXEC: + no_startup_id = '--no-startup-id' + -> + command = string + -> call cfg_exec($exectype, $no_startup_id, $command) + +state MODENAME: + modename = word + -> call cfg_enter_mode($modename); MODEBRACE + +state MODEBRACE: + '{' + -> MODE + +state MODE: + bindtype = 'bindsym', 'bindcode' + -> MODE_BINDING + '}' + -> INITIAL + +state MODE_BINDING: + modifiers = 'Mod1', 'Mod2', 'Mod3', 'Mod4', 'Mod5', 'Shift', 'Control' + -> + '+' + -> + key = word + -> MODE_BINDCOMMAND + +state MODE_BINDCOMMAND: + command = string + -> call cfg_mode_binding($bindtype, $modifiers, $key, $command); MODE + +state FONT: + font = string + -> call cfg_font($font) diff --git a/src/config_directives.c b/src/config_directives.c new file mode 100644 index 00000000..9660866e --- /dev/null +++ b/src/config_directives.c @@ -0,0 +1,56 @@ +#undef I3__FILE__ +#define I3__FILE__ "config_directives.c" +/* + * vim:ts=4:sw=4:expandtab + * + * i3 - an improved dynamic tiling window manager + * © 2009-2012 Michael Stapelberg and contributors (see also: LICENSE) + * + * config_directives.c: all command functions (see config_parser.c) + * + */ +#include +#include + +#include "all.h" + +// Macros to make the YAJL API a bit easier to use. +#define y(x, ...) yajl_gen_ ## x (cmd_output->json_gen, ##__VA_ARGS__) +#define ystr(str) yajl_gen_string(cmd_output->json_gen, (unsigned char*)str, strlen(str)) +#define ysuccess(success) do { \ + y(map_open); \ + ystr("success"); \ + y(bool, success); \ + y(map_close); \ +} while (0) + +static char *font_pattern; + +void cfg_font(I3_CFG, const char *font) { + config.font = load_font(font, true); + set_font(&config.font); + + /* Save the font pattern for using it as bar font later on */ + FREE(font_pattern); + font_pattern = sstrdup(font); +} + +void cfg_mode_binding(I3_CFG, const char *bindtype, const char *modifiers, const char *key, const char *command) { + printf("cfg_mode_binding: got bindtype\n"); +} + +void cfg_enter_mode(I3_CFG, const char *mode) { + // TODO: error handling: if mode == '{', the mode name is missing + printf("mode name: %s\n", mode); +} + +void cfg_exec(I3_CFG, const char *exectype, const char *no_startup_id, const char *command) { + struct Autostart *new = smalloc(sizeof(struct Autostart)); + new->command = sstrdup(command); + new->no_startup_id = (no_startup_id != NULL); + if (strcmp(exectype, "exec") == 0) { + TAILQ_INSERT_TAIL(&autostarts, new, autostarts); + } else { + TAILQ_INSERT_TAIL(&autostarts_always, new, autostarts_always); + } +} diff --git a/src/config_parser.c b/src/config_parser.c new file mode 100644 index 00000000..19d1d168 --- /dev/null +++ b/src/config_parser.c @@ -0,0 +1,446 @@ +#undef I3__FILE__ +#define I3__FILE__ "config_parser.c" +/* + * vim:ts=4:sw=4:expandtab + * + * i3 - an improved dynamic tiling window manager + * © 2009-2012 Michael Stapelberg and contributors (see also: LICENSE) + * + * config_parser.c: hand-written parser to parse configuration directives. + * + * See also src/commands_parser.c for rationale on why we use a custom parser. + * + */ +#include +#include +#include +#include +#include +#include + +#include "all.h" + +// Macros to make the YAJL API a bit easier to use. +#define y(x, ...) yajl_gen_ ## x (command_output.json_gen, ##__VA_ARGS__) +#define ystr(str) yajl_gen_string(command_output.json_gen, (unsigned char*)str, strlen(str)) + +/******************************************************************************* + * The data structures used for parsing. Essentially the current state and a + * list of tokens for that state. + * + * The GENERATED_* files are generated by generate-commands-parser.pl with the + * input parser-specs/configs.spec. + ******************************************************************************/ + +#include "GENERATED_config_enums.h" + +typedef struct token { + char *name; + char *identifier; + /* This might be __CALL */ + cmdp_state next_state; + union { + uint16_t call_identifier; + } extra; +} cmdp_token; + +typedef struct tokenptr { + cmdp_token *array; + int n; +} cmdp_token_ptr; + +#include "GENERATED_config_tokens.h" + +/******************************************************************************* + * The (small) stack where identified literals are stored during the parsing + * of a single command (like $workspace). + ******************************************************************************/ + +struct stack_entry { + /* Just a pointer, not dynamically allocated. */ + const char *identifier; + char *str; +}; + +/* 10 entries should be enough for everybody. */ +static struct stack_entry stack[10]; + +/* + * Pushes a string (identified by 'identifier') on the stack. We simply use a + * single array, since the number of entries we have to store is very small. + * + */ +static void push_string(const char *identifier, char *str) { + for (int c = 0; c < 10; c++) { + if (stack[c].identifier != NULL && + strcmp(stack[c].identifier, identifier) != 0) + continue; + if (stack[c].identifier == NULL) { + /* Found a free slot, let’s store it here. */ + stack[c].identifier = identifier; + stack[c].str = str; + } else { + /* Append the value. */ + sasprintf(&(stack[c].str), "%s,%s", stack[c].str, str); + } + return; + } + + /* When we arrive here, the stack is full. This should not happen and + * means there’s either a bug in this parser or the specification + * contains a command with more than 10 identified tokens. */ + fprintf(stderr, "BUG: commands_parser stack full. This means either a bug " + "in the code, or a new command which contains more than " + "10 identified tokens.\n"); + exit(1); +} + +// XXX: ideally, this would be const char. need to check if that works with all +// called functions. +static char *get_string(const char *identifier) { + for (int c = 0; c < 10; c++) { + if (stack[c].identifier == NULL) + break; + if (strcmp(identifier, stack[c].identifier) == 0) + return stack[c].str; + } + return NULL; +} + +static void clear_stack(void) { + for (int c = 0; c < 10; c++) { + if (stack[c].str != NULL) + free(stack[c].str); + stack[c].identifier = NULL; + stack[c].str = NULL; + } +} + +// TODO: remove this if it turns out we don’t need it for testing. +#if 0 +/******************************************************************************* + * A dynamically growing linked list which holds the criteria for the current + * command. + ******************************************************************************/ + +typedef struct criterion { + char *type; + char *value; + + TAILQ_ENTRY(criterion) criteria; +} criterion; + +static TAILQ_HEAD(criteria_head, criterion) criteria = + TAILQ_HEAD_INITIALIZER(criteria); + +/* + * Stores the given type/value in the list of criteria. + * Accepts a pointer as first argument, since it is 'call'ed by the parser. + * + */ +static void push_criterion(void *unused_criteria, const char *type, + const char *value) { + struct criterion *criterion = malloc(sizeof(struct criterion)); + criterion->type = strdup(type); + criterion->value = strdup(value); + TAILQ_INSERT_TAIL(&criteria, criterion, criteria); +} + +/* + * Clears the criteria linked list. + * Accepts a pointer as first argument, since it is 'call'ed by the parser. + * + */ +static void clear_criteria(void *unused_criteria) { + struct criterion *criterion; + while (!TAILQ_EMPTY(&criteria)) { + criterion = TAILQ_FIRST(&criteria); + free(criterion->type); + free(criterion->value); + TAILQ_REMOVE(&criteria, criterion, criteria); + free(criterion); + } +} +#endif + +/******************************************************************************* + * The parser itself. + ******************************************************************************/ + +static cmdp_state state; +#ifndef TEST_PARSER +static Match current_match; +#endif +static struct CommandResult subcommand_output; +static struct CommandResult command_output; + +#include "GENERATED_config_call.h" + + +static void next_state(const cmdp_token *token) { + //printf("token = name %s identifier %s\n", token->name, token->identifier); + //printf("next_state = %d\n", token->next_state); + if (token->next_state == __CALL) { + subcommand_output.json_gen = command_output.json_gen; + subcommand_output.needs_tree_render = false; + GENERATED_call(token->extra.call_identifier, &subcommand_output); + /* If any subcommand requires a tree_render(), we need to make the + * whole parser result request a tree_render(). */ + if (subcommand_output.needs_tree_render) + command_output.needs_tree_render = true; + clear_stack(); + return; + } + + state = token->next_state; + if (state == INITIAL) { + clear_stack(); + } +} + +struct CommandResult *parse_config(const char *input) { + DLOG("COMMAND: *%s*\n", input); + state = INITIAL; + +/* A YAJL JSON generator used for formatting replies. */ +#if YAJL_MAJOR >= 2 + command_output.json_gen = yajl_gen_alloc(NULL); +#else + command_output.json_gen = yajl_gen_alloc(NULL, NULL); +#endif + + y(array_open); + command_output.needs_tree_render = false; + + const char *walk = input; + const size_t len = strlen(input); + int c; + const cmdp_token *token; + bool token_handled; + + // TODO: make this testable +#ifndef TEST_PARSER + cmd_criteria_init(¤t_match, &subcommand_output); +#endif + + /* The "<=" operator is intentional: We also handle the terminating 0-byte + * explicitly by looking for an 'end' token. */ + while ((walk - input) <= len) { + /* skip whitespace and newlines before every token */ + while ((*walk == ' ' || *walk == '\t' || + *walk == '\r' || *walk == '\n') && *walk != '\0') + walk++; + + //printf("remaining input: %s\n", walk); + + cmdp_token_ptr *ptr = &(tokens[state]); + token_handled = false; + for (c = 0; c < ptr->n; c++) { + token = &(ptr->array[c]); + + /* A literal. */ + if (token->name[0] == '\'') { + if (strncasecmp(walk, token->name + 1, strlen(token->name) - 1) == 0) { + if (token->identifier != NULL) + push_string(token->identifier, sstrdup(token->name + 1)); + walk += strlen(token->name) - 1; + next_state(token); + token_handled = true; + break; + } + continue; + } + + if (strcmp(token->name, "string") == 0 || + strcmp(token->name, "word") == 0) { + const char *beginning = walk; + /* Handle quoted strings (or words). */ + if (*walk == '"') { + beginning++; + walk++; + while (*walk != '\0' && (*walk != '"' || *(walk-1) == '\\')) + walk++; + } else { + if (token->name[0] == 's') { + /* For a string (starting with 's'), the delimiters are + * comma (,) and semicolon (;) which introduce a new + * operation or command, respectively. Also, newlines + * end a command. */ + while (*walk != ';' && *walk != ',' && + *walk != '\0' && *walk != '\r' && + *walk != '\n') + walk++; + } else { + /* For a word, the delimiters are white space (' ' or + * '\t'), closing square bracket (]), comma (,) and + * semicolon (;). */ + while (*walk != ' ' && *walk != '\t' && + *walk != ']' && *walk != ',' && + *walk != ';' && *walk != '\r' && + *walk != '\n' && *walk != '\0') + walk++; + } + } + if (walk != beginning) { + char *str = scalloc(walk-beginning + 1); + /* We copy manually to handle escaping of characters. */ + int inpos, outpos; + for (inpos = 0, outpos = 0; + inpos < (walk-beginning); + inpos++, outpos++) { + /* We only handle escaped double quotes to not break + * backwards compatibility with people using \w in + * regular expressions etc. */ + if (beginning[inpos] == '\\' && beginning[inpos+1] == '"') + inpos++; + str[outpos] = beginning[inpos]; + } + if (token->identifier) + push_string(token->identifier, str); + /* If we are at the end of a quoted string, skip the ending + * double quote. */ + if (*walk == '"') + walk++; + next_state(token); + token_handled = true; + break; + } + } + + if (strcmp(token->name, "end") == 0) { + if (*walk == '\0' || *walk == ',' || *walk == ';') { + next_state(token); + token_handled = true; + /* To make sure we start with an appropriate matching + * datastructure for commands which do *not* specify any + * criteria, we re-initialize the criteria system after + * every command. */ + // TODO: make this testable +#ifndef TEST_PARSER + if (*walk == '\0' || *walk == ';') + cmd_criteria_init(¤t_match, &subcommand_output); +#endif + walk++; + break; + } + } + } + + if (!token_handled) { + /* Figure out how much memory we will need to fill in the names of + * all tokens afterwards. */ + int tokenlen = 0; + for (c = 0; c < ptr->n; c++) + tokenlen += strlen(ptr->array[c].name) + strlen("'', "); + + /* Build up a decent error message. We include the problem, the + * full input, and underline the position where the parser + * currently is. */ + char *errormessage; + char *possible_tokens = smalloc(tokenlen + 1); + char *tokenwalk = possible_tokens; + for (c = 0; c < ptr->n; c++) { + token = &(ptr->array[c]); + if (token->name[0] == '\'') { + /* A literal is copied to the error message enclosed with + * single quotes. */ + *tokenwalk++ = '\''; + strcpy(tokenwalk, token->name + 1); + tokenwalk += strlen(token->name + 1); + *tokenwalk++ = '\''; + } else { + /* Any other token is copied to the error message enclosed + * with angle brackets. */ + *tokenwalk++ = '<'; + strcpy(tokenwalk, token->name); + tokenwalk += strlen(token->name); + *tokenwalk++ = '>'; + } + if (c < (ptr->n - 1)) { + *tokenwalk++ = ','; + *tokenwalk++ = ' '; + } + } + *tokenwalk = '\0'; + sasprintf(&errormessage, "Expected one of these tokens: %s", + possible_tokens); + free(possible_tokens); + + /* Contains the same amount of characters as 'input' has, but with + * the unparseable part highlighted using ^ characters. */ + char *position = smalloc(len + 1); + for (const char *copywalk = input; *copywalk != '\0'; copywalk++) + position[(copywalk - input)] = (copywalk >= walk ? '^' : ' '); + position[len] = '\0'; + + ELOG("%s\n", errormessage); + ELOG("Your command: %s\n", input); + ELOG(" %s\n", position); + + /* Format this error message as a JSON reply. */ + y(map_open); + ystr("success"); + y(bool, false); + /* We set parse_error to true to distinguish this from other + * errors. i3-nagbar is spawned upon keypresses only for parser + * errors. */ + ystr("parse_error"); + y(bool, true); + ystr("error"); + ystr(errormessage); + ystr("input"); + ystr(input); + ystr("errorposition"); + ystr(position); + y(map_close); + + free(position); + free(errormessage); + clear_stack(); + break; + } + } + + y(array_close); + + return &command_output; +} + +/******************************************************************************* + * Code for building the stand-alone binary test.commands_parser which is used + * by t/187-commands-parser.t. + ******************************************************************************/ + +#ifdef TEST_PARSER + +/* + * Logs the given message to stdout while prefixing the current time to it, + * but only if debug logging was activated. + * This is to be called by DLOG() which includes filename/linenumber + * + */ +void debuglog(char *fmt, ...) { + va_list args; + + va_start(args, fmt); + fprintf(stdout, "# "); + vfprintf(stdout, fmt, args); + va_end(args); +} + +void errorlog(char *fmt, ...) { + va_list args; + + va_start(args, fmt); + vfprintf(stderr, fmt, args); + va_end(args); +} + +int main(int argc, char *argv[]) { + if (argc < 2) { + fprintf(stderr, "Syntax: %s \n", argv[0]); + return 1; + } + parse_config(argv[1]); +} +#endif diff --git a/src/i3.mk b/src/i3.mk index 94a988ff..fd4afdb7 100644 --- a/src/i3.mk +++ b/src/i3.mk @@ -53,11 +53,24 @@ src/commands_parser.o: src/commands_parser.c $(i3_HEADERS_DEP) i3-command-parser $(CC) $(I3_CPPFLAGS) $(XCB_CPPFLAGS) $(CPPFLAGS) $(i3_CFLAGS) $(I3_CFLAGS) $(CFLAGS) $(I3_LDFLAGS) $(LDFLAGS) -DTEST_PARSER -o test.commands_parser $< $(LIBS) $(i3_LIBS) $(CC) $(I3_CPPFLAGS) $(XCB_CPPFLAGS) $(CPPFLAGS) $(i3_CFLAGS) $(I3_CFLAGS) $(CFLAGS) -c -o $@ ${canonical_path}/$< +# This target compiles the command parser twice: +# Once with -DTEST_PARSER, creating a stand-alone executable used for tests, +# and once as an object file for i3. +src/config_parser.o: src/config_parser.c $(i3_HEADERS_DEP) i3-config-parser.stamp + echo "[i3] CC $<" + $(CC) $(I3_CPPFLAGS) $(XCB_CPPFLAGS) $(CPPFLAGS) $(i3_CFLAGS) $(I3_CFLAGS) $(CFLAGS) $(I3_LDFLAGS) $(LDFLAGS) -DTEST_PARSER -o test.config_parser $< $(LIBS) $(i3_LIBS) + $(CC) $(I3_CPPFLAGS) $(XCB_CPPFLAGS) $(CPPFLAGS) $(i3_CFLAGS) $(I3_CFLAGS) $(CFLAGS) -c -o $@ ${canonical_path}/$< + i3-command-parser.stamp: generate-command-parser.pl parser-specs/commands.spec echo "[i3] Generating command parser" (cd include; ../generate-command-parser.pl --input=../parser-specs/commands.spec --prefix=commands) touch $@ +i3-config-parser.stamp: generate-command-parser.pl parser-specs/config.spec + echo "[i3] Generating config parser" + (cd include; ../generate-command-parser.pl --input=../parser-specs/config.spec --prefix=config) + touch $@ + i3: libi3.a $(i3_OBJECTS) echo "[i3] Link i3" $(CC) $(I3_LDFLAGS) $(LDFLAGS) -o $@ $(filter-out libi3.a,$^) $(LIBS) $(i3_LIBS) @@ -82,4 +95,4 @@ install-i3: i3 clean-i3: echo "[i3] Clean" - rm -f $(i3_OBJECTS) $(i3_SOURCES_GENERATED) $(i3_HEADERS_CMDPARSER) include/loglevels.h loglevels.tmp include/all.h.pch i3-command-parser.stamp i3 src/*.gcno src/cfgparse.{output,dot,tab.h,y.o} src/cmdparse.* + rm -f $(i3_OBJECTS) $(i3_SOURCES_GENERATED) $(i3_HEADERS_CMDPARSER) include/loglevels.h loglevels.tmp include/all.h.pch i3-command-parser.stamp i3-config-parser.stamp i3 src/*.gcno src/cfgparse.{output,dot,tab.h,y.o} src/cmdparse.* diff --git a/testcases/Makefile.PL b/testcases/Makefile.PL index b522fc30..28036d1f 100755 --- a/testcases/Makefile.PL +++ b/testcases/Makefile.PL @@ -13,6 +13,7 @@ WriteMakefile( 'Inline' => 0, 'ExtUtils::PkgConfig' => 0, 'Test::More' => '0.94', + 'IPC::Run' => 0, }, PM => {}, # do not install any files from this directory clean => { diff --git a/testcases/t/201-config-parser.t b/testcases/t/201-config-parser.t new file mode 100644 index 00000000..74ea9538 --- /dev/null +++ b/testcases/t/201-config-parser.t @@ -0,0 +1,79 @@ +#!perl +# vim:ts=4:sw=4:expandtab +# +# Please read the following documents before working on tests: +# • http://build.i3wm.org/docs/testsuite.html +# (or docs/testsuite) +# +# • http://build.i3wm.org/docs/lib-i3test.html +# (alternatively: perldoc ./testcases/lib/i3test.pm) +# +# • http://build.i3wm.org/docs/ipc.html +# (or docs/ipc) +# +# • http://onyxneon.com/books/modern_perl/modern_perl_a4.pdf +# (unless you are already familiar with Perl) +# +# Tests the standalone parser binary to see if it calls the right code when +# confronted with various commands, if it prints proper error messages for +# wrong commands and if it terminates in every case. +# +use i3test i3_autostart => 0; +use IPC::Run qw(run); + +sub parser_calls { + my ($command) = @_; + + my $stdout; + run [ '../test.config_parser', $command ], + '>&-', + '2>', \$stdout; + # TODO: use a timeout, so that we can error out if it doesn’t terminate + + # Filter out all debugging output. + my @lines = split("\n", $stdout); + @lines = grep { not /^# / } @lines; + + ## The criteria management calls are irrelevant and not what we want to test + ## in the first place. + #@lines = grep { !(/cmd_criteria_init()/ || /cmd_criteria_match_windows/) } @lines; + return join("\n", @lines) . "\n"; +} + +my $config = <<'EOT'; +mode "meh" { + bindsym Mod1 + Shift + x resize grow + bindcode Mod1+44 resize shrink +} +EOT + +my $expected = <<'EOT'; +cfg_enter_mode(meh) +cfg_mode_binding(bindsym, Mod1,Shift, x, resize grow) +cfg_mode_binding(bindcode, Mod1, 44, resize shrink) +EOT + +is(parser_calls($config), + $expected, + 'single number (move workspace 3) ok'); + +$config = <<'EOT'; +exec geeqie +exec --no-startup-id /tmp/foo.sh +exec_always firefox +exec_always --no-startup-id /tmp/bar.sh +EOT + +$expected = <<'EOT'; +cfg_exec(exec, (null), geeqie) +cfg_exec(exec, --no-startup-id, /tmp/foo.sh) +cfg_exec(exec_always, (null), firefox) +cfg_exec(exec_always, --no-startup-id, /tmp/bar.sh) +EOT + +is(parser_calls($config), + $expected, + 'single number (move workspace 3) ok'); + + +done_testing; -- 2.39.2