diff --git a/README.md b/README.md index e2bb222..6c3dddf 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,7 @@ The service will: ### Additional Features (New in skhd.zig!) +- **Key aliases**: Define reusable aliases for modifiers, keys, and key combinations - **Process groups**: Define named groups of applications for cleaner configs - **Command definitions**: Define reusable commands with placeholders to reduce repetition - **Key Forwarding**: Forward / remap key binding to another key binding @@ -184,6 +185,12 @@ The configuration syntax is fully compatible with the original skhd. See [SYNTAX # Load additional config files .load "~/.config/skhd/extra.skhdrc" +# Define key aliases for reusable modifier/key combinations (New in skhd.zig!) +.alias $super cmd + alt +.alias $mega cmd + alt + shift + ctrl +.alias $grave 0x32 # UK keyboard backtick +.alias $nav_left $super - h + # Define process groups for reuse (New in skhd.zig!) .define terminal_apps ["kitty", "wezterm", "terminal"] .define native_apps ["kitty", "wezterm", "chrome", "whatsapp"] @@ -369,23 +376,31 @@ resize < escape ; winmode ### Window Management Example ```bash -# Focus windows using command definitions (New in skhd.zig!) -cmd - h : @yabai_focus("west") -cmd - j : @yabai_focus("south") -cmd - k : @yabai_focus("north") -cmd - l : @yabai_focus("east") - -# Move/swap windows using command definitions -cmd + shift - h : @yabai_swap("west") -cmd + shift - j : @yabai_swap("south") -cmd + shift - k : @yabai_swap("north") -cmd + shift - l : @yabai_swap("east") - -# Resize windows using command definitions -cmd + ctrl - h : @resize_window("left", "-20", "0") -cmd + ctrl - l : @resize_window("right", "20", "0") - -# Switch spaces +# Define aliases for cleaner config (New in skhd.zig!) +.alias $super cmd + alt +.alias $mega $super + shift + +# Focus windows using command definitions and aliases +$super - h : @yabai_focus("west") +$super - j : @yabai_focus("south") +$super - k : @yabai_focus("north") +$super - l : @yabai_focus("east") + +# Move/swap windows using nested alias +$mega - h : @yabai_swap("west") +$mega - j : @yabai_swap("south") +$mega - k : @yabai_swap("north") +$mega - l : @yabai_swap("east") + +# Define keysym aliases for common operations +.alias $focus_west $super - h +.alias $focus_east $super - l + +# Use keysym alias standalone or with additional modifiers +$focus_west : @yabai_focus("west") +ctrl + $focus_west : @yabai_focus("west") # With extra modifier + +# Traditional syntax still works cmd - 1 : yabai -m space --focus 1 cmd - 2 : yabai -m space --focus 2 ``` diff --git a/SYNTAX.md b/SYNTAX.md index 4114ac4..68bed66 100644 --- a/SYNTAX.md +++ b/SYNTAX.md @@ -103,6 +103,167 @@ name = desired name for this mode command = command is executed through '$SHELL -c' ``` + +## Key Aliases + +Key aliases allow you to define reusable names for modifiers, keys, and key combinations, making configurations more readable and maintainable. + +### Syntax + +``` +alias_def = '.alias' + +alias_name = '$' + +alias_value = | # Modifier alias + | # Key alias + '-' # Keysym alias + +modifier_combo = | '+' | + +key_spec = | | +``` + +### Alias Types + +#### 1. Modifier Alias +Defines a modifier combination that can be reused and combined with other modifiers. + +```bash +.alias $super cmd + alt +.alias $hyper cmd + alt + ctrl + shift + +# Use standalone +$super - h : echo "Super+H" + +# Combine with other modifiers (like built-in hyper/meh) +$super + shift - h : echo "Super+Shift+H" +``` + +#### 2. Key Alias +Defines a key (with optional modifiers baked in) that can be used in key position. + +```bash +.alias $grave 0x32 # Hex keycode +.alias $exclaim shift - 1 # Key with modifier + +# Use in key position +ctrl - $grave : echo "Ctrl+Grave" +ctrl - $exclaim : echo "Ctrl+Shift+1" # Modifiers merge! +``` + +#### 3. Keysym Alias +Defines a complete key combination (modifier + key) that can be used standalone or with additional modifiers. + +```bash +.alias $nav_left cmd - h +.alias $terminal_key cmd + shift - t + +# Use standalone (macro expansion) +$nav_left : yabai -m window --focus west + +# Add more modifiers +ctrl + $nav_left : yabai -m window --focus west # Becomes: ctrl+cmd - h +``` + +### Nesting + +Aliases can reference other aliases, and they are fully expanded at parse time: + +```bash +# Nested modifiers +.alias $super cmd + alt +.alias $mega $super + shift + ctrl # Expands to: cmd+alt+shift+ctrl + +# Nested keys +.alias $grave 0x32 +.alias $tilde shift - $grave # Expands to: shift - 0x32 + +# Nested keysyms +.alias $nav_left $super - h # Expands to: cmd+alt - h +.alias $special_nav ctrl + $nav_left # Expands to: ctrl+cmd+alt - h +``` + +### Valid Usage Contexts + +| Alias Type | Standalone | As Modifier | In Key Position | With + Modifier | +|------------|------------|-------------|-----------------|-----------------| +| Modifier | ✗ | ✓ | ✗ | ✓ | +| Key | ✗ | ✗ | ✓ | ✗ | +| Keysym | ✓ | ✗ | ✗ | ✓ | + +### Examples + +```bash +# Define aliases +.alias $super cmd + alt +.alias $mega $super + shift + ctrl +.alias $grave 0x32 +.alias $tilde shift - $grave +.alias $nav_left $super - h + +# Modifier alias - can combine like hyper/meh +$super - t : open -a Terminal.app +$super + shift - t : open -a "New Terminal" + +# Key alias - only in key position +ctrl - $grave : open -a Notes.app +$super - $tilde : open -a "System Settings" + +# Keysym alias - standalone or with additional modifiers +$nav_left : yabai -m window --focus west +ctrl + $nav_left : yabai -m window --focus west # Add ctrl +``` + +### Common Errors + +#### Using Wrong Alias Type + +```bash +# ✗ WRONG: Key alias as modifier +.alias $grave 0x32 +$grave - t : echo "bad" # Error: $grave is a key, not a modifier + +# ✓ CORRECT: Use in key position +ctrl - $grave : echo "good" + +# ✗ WRONG: Modifier alias in key position +.alias $hyper cmd + alt +ctrl - $hyper : echo "bad" # Error: $hyper is a modifier, not a key + +# ✓ CORRECT: Use as modifier +$hyper - t : echo "good" + +# ✗ WRONG: Modifier alias standalone +.alias $hyper cmd + alt +$hyper : echo "bad" # Error: needs a key + +# ✓ CORRECT: Add a key +$hyper - t : echo "good" +``` + +#### Missing $ Prefix + +```bash +.alias $hyper cmd + alt +hyper - t : echo "bad" # Error! Did you mean '$hyper'? +``` + +#### Circular References + +```bash +.alias $a $b + shift # Error: $b not defined yet +.alias $b $a + ctrl +``` + +### Benefits + +- **Readability**: Complex modifier combinations get meaningful names +- **Maintainability**: Change definition once, affects all uses +- **Keyboard Layouts**: Abstract away layout-specific hex codes +- **Consistency**: Same modifier combo everywhere +- **Composability**: Combine aliases like built-in hyper/meh + ## Modifiers ### Basic Modifiers diff --git a/src/Parser.zig b/src/Parser.zig index 2546612..56c02a0 100644 --- a/src/Parser.zig +++ b/src/Parser.zig @@ -30,6 +30,7 @@ current_file_path: ?[]const u8 = null, error_info: ?ParseError = null, process_groups: std.StringHashMapUnmanaged([][]const u8) = .empty, command_defs: std.StringHashMapUnmanaged(CommandDef) = .empty, +key_aliases: std.StringHashMapUnmanaged(Alias) = .empty, pub const CommandDef = struct { pub const Part = union(enum) { @@ -54,6 +55,17 @@ pub const CommandDef = struct { } }; +pub const Alias = union(enum) { + modifier: ModifierFlag, // .alias $hyper cmd + alt + ctrl + key: Hotkey.KeyPress, // .alias $grave 0x32 or .alias $exclaim shift - 1 + keysym: Hotkey.KeyPress, // .alias $nav_left cmd - h + + pub fn deinit(self: Alias) void { + // No dynamic allocation in these types + _ = self; + } +}; + pub fn deinit(self: *Parser) void { self.keycodes.deinit(); for (self.load_directives.items) |directive| { @@ -87,6 +99,16 @@ pub fn deinit(self: *Parser) void { self.command_defs.deinit(self.allocator); } + // Free key aliases + { + var it = self.key_aliases.iterator(); + while (it.next()) |kv| { + self.allocator.free(kv.key_ptr.*); + kv.value_ptr.*.deinit(); + } + self.key_aliases.deinit(self.allocator); + } + self.* = undefined; } @@ -129,7 +151,7 @@ pub fn parseWithPath(self: *Parser, mappings: *Mappings, content: []const u8, fi _ = self.advance(); while (self.peek()) |token| { switch (token.type) { - .Token_Identifier, .Token_Modifier, .Token_Literal, .Token_Key_Hex, .Token_Key, .Token_Activate => { + .Token_Identifier, .Token_Modifier, .Token_Literal, .Token_Key_Hex, .Token_Key, .Token_Activate, .Token_Alias => { self.parse_hotkey(mappings) catch |err| { if (self.error_info == null) { self.error_info = try ParseError.fromToken(self.allocator, token, "Failed to parse hotkey", self.current_file_path); @@ -266,12 +288,64 @@ fn parse_hotkey(self: *Parser, mappings: *Mappings) !void { }; } - const found_modifier = self.match(.Token_Modifier); - if (found_modifier) { + // Handle modifier or alias at the start + var found_modifier = false; + var is_standalone_keysym = false; + var last_token_before_parse: ?Token = null; + + if (self.match(.Token_Alias)) { + // Check what follows the alias to determine how to handle it + const alias_token = self.previous(); + const next_is_command = self.peek_check(.Token_Command); + const next_is_forward = self.peek_check(.Token_Forward); + const next_is_begin_list = self.peek_check(.Token_BeginList); + const next_is_unbound = self.peek_check(.Token_Unbound); + const next_is_arrow = self.peek_check(.Token_Arrow); + const next_is_activate = self.peek_check(.Token_Activate); + + // If followed by : | [ ~ -> ; , it's a standalone keysym alias + if (next_is_command or next_is_forward or next_is_begin_list or next_is_unbound or next_is_arrow or next_is_activate or self.peek() == null) { + // Standalone keysym alias: $terminal_key : command + var visited = std.ArrayList([]const u8).init(self.allocator); + defer visited.deinit(); + const keypress = try self.resolve_keysym_alias_internal(alias_token.text, &visited); + hotkey.flags = hotkey.flags.merge(keypress.flags); + hotkey.key = keypress.key; + is_standalone_keysym = true; + } else { + // Alias is being used as a modifier: $super - x or $super + alt - x + // Or keysym with additional modifiers: ctrl + $nav_left + self.previous_token = alias_token; + last_token_before_parse = alias_token; + hotkey.flags = try self.parse_modifier(); + found_modifier = true; + } + } else if (self.match(.Token_Modifier)) { hotkey.flags = try self.parse_modifier(); + found_modifier = true; } + // Check if the modifier chain ended with a keysym alias + // e.g., ctrl + $nav_left where $nav_left = cmd - h if (found_modifier) { + // Check the last consumed token to see if it was an alias + const prev_tok = self.previous(); + if (prev_tok.type == .Token_Alias or (last_token_before_parse != null and last_token_before_parse.?.type == .Token_Alias)) { + const alias_name = if (prev_tok.type == .Token_Alias) prev_tok.text else last_token_before_parse.?.text; + if (self.key_aliases.get(alias_name)) |alias| { + if (alias == .keysym) { + // Extract the key from the keysym + var visited = std.ArrayList([]const u8).init(self.allocator); + defer visited.deinit(); + const keypress = try self.resolve_keysym_alias_internal(alias_name, &visited); + hotkey.key = keypress.key; + is_standalone_keysym = true; // Don't parse key again + } + } + } + } + + if (found_modifier and !is_standalone_keysym) { if (!self.match(.Token_Dash)) { const token = self.peek() orelse self.previous(); self.error_info = try ParseError.fromToken(self.allocator, token, "Expected '-' after modifier", self.current_file_path); @@ -279,18 +353,27 @@ fn parse_hotkey(self: *Parser, mappings: *Mappings) !void { } } - if (self.match(.Token_Key)) { - hotkey.key = try self.parse_key(); - } else if (self.match(.Token_Key_Hex)) { - hotkey.key = try self.parse_key_hex(); - } else if (self.match(.Token_Literal)) { - const keypress = try self.parse_key_literal(); - hotkey.flags = hotkey.flags.merge(keypress.flags); - hotkey.key = keypress.key; - } else { - const token = self.peek() orelse self.previous(); - self.error_info = try ParseError.fromToken(self.allocator, token, "Expected key, key hex, or literal", self.current_file_path); - return error.ParseErrorOccurred; + if (!is_standalone_keysym) { + if (self.match(.Token_Alias)) { + // Key alias in key position: ctrl - $grave + var visited = std.ArrayList([]const u8).init(self.allocator); + defer visited.deinit(); + const key_alias_result = try self.resolve_key_alias_internal(self.previous().text, &visited); + hotkey.flags = hotkey.flags.merge(key_alias_result.flags); + hotkey.key = key_alias_result.key; + } else if (self.match(.Token_Key)) { + hotkey.key = try self.parse_key(); + } else if (self.match(.Token_Key_Hex)) { + hotkey.key = try self.parse_key_hex(); + } else if (self.match(.Token_Literal)) { + const keypress = try self.parse_key_literal(); + hotkey.flags = hotkey.flags.merge(keypress.flags); + hotkey.key = keypress.key; + } else { + const token = self.peek() orelse self.previous(); + self.error_info = try ParseError.fromToken(self.allocator, token, "Expected key, key hex, literal, or alias", self.current_file_path); + return error.ParseErrorOccurred; + } } if (self.match(.Token_Arrow)) { @@ -400,21 +483,34 @@ fn parse_modifier(self: *Parser) !ModifierFlag { const token = self.previous(); var flags = ModifierFlag{}; - if (ModifierFlag.get(token.text)) |modifier_flags_value| { + if (token.type == .Token_Alias) { + // Resolve alias + var visited = std.ArrayList([]const u8).init(self.allocator); + defer visited.deinit(); + flags = try self.resolve_modifier_alias_internal(token.text, &visited); + } else if (ModifierFlag.get(token.text)) |modifier_flags_value| { flags = flags.merge(modifier_flags_value); } else { - const msg = try std.fmt.allocPrint(self.allocator, "Unknown modifier '{s}'", .{token.text}); + // Check if this looks like it should be an alias (helpful error message) + if (self.key_aliases.contains(token.text)) { + const msg = try std.fmt.allocPrint(self.allocator, "Did you mean '${s}'? Alias references must start with '$'", .{token.text}); + defer self.allocator.free(msg); + self.error_info = try ParseError.fromToken(self.allocator, token, msg, self.current_file_path); + return error.ParseErrorOccurred; + } + + const msg = try std.fmt.allocPrint(self.allocator, "Unknown modifier '{s}'. Valid modifiers: cmd, ctrl, alt, shift, fn, hyper, meh, or use an alias with '$' prefix", .{token.text}); defer self.allocator.free(msg); self.error_info = try ParseError.fromToken(self.allocator, token, msg, self.current_file_path); return error.ParseErrorOccurred; } if (self.match(.Token_Plus)) { - if (self.match(.Token_Modifier)) { + if (self.match(.Token_Modifier) or self.match(.Token_Alias)) { flags = flags.merge(try self.parse_modifier()); } else { const error_token = self.peek() orelse self.previous(); - self.error_info = try ParseError.fromToken(self.allocator, error_token, "Expected modifier after '+'", self.current_file_path); + self.error_info = try ParseError.fromToken(self.allocator, error_token, "Expected modifier or alias after '+'", self.current_file_path); return error.ParseErrorOccurred; } } @@ -484,7 +580,56 @@ fn parse_keypress(self: *Parser) !Hotkey.KeyPress { var flags: ModifierFlag = .{}; var keycode: u32 = 0; var found_modifier = false; - if (self.match(.Token_Modifier)) { + + // Check for standalone keysym alias or keysym alias with additional modifiers + if (self.peek_check(.Token_Alias)) { + // Look ahead to see if it's followed by + or : (standalone) vs - (used as modifier) + const saved_pos = self.tokenizer.pos; + const saved_line = self.tokenizer.line; + const saved_cursor = self.tokenizer.cursor; + + _ = self.match(.Token_Alias); + const alias_token = self.previous(); + + // Check what comes after the alias + const next_is_plus = self.peek_check(.Token_Plus); + const next_is_dash = self.peek_check(.Token_Dash); + const next_is_command = self.peek_check(.Token_Command); + const next_is_forward = self.peek_check(.Token_Forward); + const next_is_begin_list = self.peek_check(.Token_BeginList); + + // If followed by : or | or [ or end, it's a standalone keysym alias + if (next_is_command or next_is_forward or next_is_begin_list or self.peek() == null) { + // Standalone keysym alias: $terminal_key : command + var visited = std.ArrayList([]const u8).init(self.allocator); + defer visited.deinit(); + return try self.resolve_keysym_alias_internal(alias_token.text, &visited); + } else if (next_is_plus) { + // Keysym alias with additional modifiers: ctrl + $terminal_key + var visited = std.ArrayList([]const u8).init(self.allocator); + defer visited.deinit(); + const keypress = try self.resolve_keysym_alias_internal(alias_token.text, &visited); + // The additional modifiers have already been parsed, just return the keysym + return keypress; + } else if (next_is_dash) { + // This is $alias - something, which means the alias is being used as a modifier + // Rewind and let the normal flow handle it + self.tokenizer.pos = saved_pos; + self.tokenizer.line = saved_line; + self.tokenizer.cursor = saved_cursor; + self.next_token = null; + _ = self.advance(); // Re-read the current token + } else { + // Unknown context, rewind and continue + self.tokenizer.pos = saved_pos; + self.tokenizer.line = saved_line; + self.tokenizer.cursor = saved_cursor; + self.next_token = null; + _ = self.advance(); + } + } + + if (self.match(.Token_Modifier) or self.match(.Token_Alias)) { flags = try self.parse_modifier(); found_modifier = true; } @@ -492,11 +637,17 @@ fn parse_keypress(self: *Parser) !Hotkey.KeyPress { if (found_modifier and !self.match(.Token_Dash)) { const token = self.peek() orelse self.previous(); self.error_info = try ParseError.fromToken(self.allocator, token, "Expected '-' after modifier", self.current_file_path); - // Error already set in self.error_info return error.ParseErrorOccurred; } - if (self.match(.Token_Key)) { + if (self.match(.Token_Alias)) { + // Key alias in key position: ctrl - $grave + var visited = std.ArrayList([]const u8).init(self.allocator); + defer visited.deinit(); + const key_alias_result = try self.resolve_key_alias_internal(self.previous().text, &visited); + flags = flags.merge(key_alias_result.flags); // Merge any implicit flags + keycode = key_alias_result.key; + } else if (self.match(.Token_Key)) { keycode = try self.parse_key(); } else if (self.match(.Token_Key_Hex)) { keycode = try self.parse_key_hex(); @@ -506,7 +657,7 @@ fn parse_keypress(self: *Parser) !Hotkey.KeyPress { keycode = keypress.key; } else { const token = self.peek() orelse self.previous(); - self.error_info = try ParseError.fromToken(self.allocator, token, "Expected key, key hex, or literal", self.current_file_path); + self.error_info = try ParseError.fromToken(self.allocator, token, "Expected key, key hex, literal, or alias", self.current_file_path); return error.ParseErrorOccurred; } @@ -930,12 +1081,356 @@ fn parseCommandTemplate(self: *Parser, template: []const u8, token: Token) !Comm .max_placeholder = max_placeholder, }; } +fn parse_alias_value(self: *Parser) !Alias { + // Parse alias value and determine its type + // Can be: modifier, key, or keysym + + if (self.match(.Token_Modifier) or self.match(.Token_Alias)) { + const start_token = self.previous(); + + // Parse modifiers (could be nested alias or regular modifier) + var flags: ModifierFlag = .{}; + if (start_token.type == .Token_Alias) { + // Nested alias - resolve it recursively + var visited = std.ArrayList([]const u8).init(self.allocator); + defer visited.deinit(); + flags = try self.resolve_modifier_alias_internal(start_token.text, &visited); + + // Check if there are more modifiers after the alias (e.g., $super + shift + ctrl) + if (self.match(.Token_Plus)) { + if (self.match(.Token_Modifier) or self.match(.Token_Alias)) { + self.previous_token = self.previous(); + const more_flags = try self.parse_modifier_no_alias(); + flags = flags.merge(more_flags); + } else { + const token = self.peek() orelse self.previous(); + self.error_info = try ParseError.fromToken(self.allocator, token, "Expected modifier or alias after '+'", self.current_file_path); + return error.ParseErrorOccurred; + } + } + } else { + // Regular modifier - parse it + self.previous_token = start_token; + flags = try self.parse_modifier_no_alias(); + } + + // Check if there's a dash (making it a keysym) + if (self.peek_check(.Token_Dash)) { + _ = self.match(.Token_Dash); + + // Must have a key after the dash + if (!self.peek_check(.Token_Key) and + !self.peek_check(.Token_Key_Hex) and + !self.peek_check(.Token_Literal) and + !self.peek_check(.Token_Alias)) { + const token = self.peek() orelse self.previous(); + self.error_info = try ParseError.fromToken( + self.allocator, + token, + "Expected key after '-' in alias definition", + self.current_file_path + ); + return error.ParseErrorOccurred; + } + + // Parse the key part + var keycode: u32 = 0; + var key_flags: ModifierFlag = .{}; + + if (self.match(.Token_Alias)) { + // Nested alias for key part + var visited = std.ArrayList([]const u8).init(self.allocator); + defer visited.deinit(); + const key_alias_result = try self.resolve_key_alias_internal(self.previous().text, &visited); + key_flags = key_alias_result.flags; // Capture implicit flags from alias + keycode = key_alias_result.key; + } else if (self.match(.Token_Key)) { + keycode = try self.parse_key(); + } else if (self.match(.Token_Key_Hex)) { + keycode = try self.parse_key_hex(); + } else if (self.match(.Token_Literal)) { + const keypress = try self.parse_key_literal(); + key_flags = keypress.flags; + keycode = keypress.key; + } + + // Merge flags from literal keys (e.g., 'delete' has fn flag) + flags = flags.merge(key_flags); + + return Alias{ .keysym = .{ .flags = flags, .key = keycode } }; + } else { + // Just a modifier alias + return Alias{ .modifier = flags }; + } + } else if (self.match(.Token_Alias)) { + // Nested alias + const token = self.previous(); + var visited = std.ArrayList([]const u8).init(self.allocator); + defer visited.deinit(); + const alias = try self.resolve_alias_internal(token.text, &visited); + // Return whatever type the nested alias is + return alias; + } else if (self.match(.Token_Key)) { + // Simple key - just the keycode + const keycode = try self.parse_key(); + return Alias{ .key = .{ .flags = .{}, .key = keycode } }; + } else if (self.match(.Token_Literal)) { + // Literal key (may have implicit flags like fn or nx) + const keypress = try self.parse_key_literal(); + // Literal keys like 'delete' may have implicit modifiers + return Alias{ .key = keypress }; + } else if (self.match(.Token_Key_Hex)) { + // Hex keycode + const keycode = try self.parse_key_hex(); + return Alias{ .key = .{ .flags = .{}, .key = keycode } }; + } else { + const token = self.peek() orelse self.previous(); + const msg = try std.fmt.allocPrint( + self.allocator, + "Invalid alias definition. Expected one of:\n" ++ + " Modifier: .alias $name cmd + alt + ctrl\n" ++ + " Keysym: .alias $name cmd + shift - t\n" ++ + " Key combo: .alias $name shift - 1\n" ++ + " Key hex: .alias $name 0x32", + .{} + ); + defer self.allocator.free(msg); + self.error_info = try ParseError.fromToken( + self.allocator, + token, + msg, + self.current_file_path + ); + return error.ParseErrorOccurred; + } +} + +// Internal resolution with circular detection +fn resolve_alias_internal(self: *Parser, alias_name: []const u8, visited: *std.ArrayList([]const u8)) !Alias { + // Check for circular reference + for (visited.items) |name| { + if (std.mem.eql(u8, name, alias_name)) { + const msg = try std.fmt.allocPrint( + self.allocator, + "Circular alias reference detected: ${s}", + .{alias_name} + ); + defer self.allocator.free(msg); + self.error_info = try ParseError.fromToken( + self.allocator, + self.previous(), + msg, + self.current_file_path + ); + return error.CircularAliasReference; + } + } + + // Check depth limit (safety against complex circular refs) + if (visited.items.len >= 10) { + self.error_info = try ParseError.fromToken( + self.allocator, + self.previous(), + "Alias nesting too deep (max 10 levels)", + self.current_file_path + ); + return error.AliasTooDeep; + } + + // Add to visited list + try visited.append(alias_name); + // Look up the alias + const alias = self.key_aliases.get(alias_name) orelse { + const msg = try std.fmt.allocPrint( + self.allocator, + "Undefined alias '${s}'. Did you forget to define it with '.alias ${s} '?", + .{alias_name, alias_name} + ); + defer self.allocator.free(msg); + self.error_info = try ParseError.fromToken( + self.allocator, + self.previous(), + msg, + self.current_file_path + ); + return error.UndefinedAlias; + }; + + return alias; +} + +fn resolve_modifier_alias_internal(self: *Parser, alias_name: []const u8, visited: *std.ArrayList([]const u8)) !ModifierFlag { + const alias = try self.resolve_alias_internal(alias_name, visited); + + return switch (alias) { + .modifier => |flags| flags, + .keysym => |keypress| keypress.flags, // Can extract modifiers from keysym + .key => |keypress| keypress.flags, // Can extract modifiers from key combo + }; +} + +fn resolve_key_alias_internal(self: *Parser, alias_name: []const u8, visited: *std.ArrayList([]const u8)) !Hotkey.KeyPress { + const alias = try self.resolve_alias_internal(alias_name, visited); + + return switch (alias) { + .key => |keypress| keypress, // Return full KeyPress to preserve implicit flags + .keysym => |keypress| keypress, + .modifier => { + const msg = try std.fmt.allocPrint( + self.allocator, + "Alias '${s}' is a modifier, not a key.\n" ++ + " It can only be used as: ${s} - \n" ++ + " To bind this modifier, add a key: ${s} - t : ", + .{alias_name, alias_name, alias_name} + ); + defer self.allocator.free(msg); + self.error_info = try ParseError.fromToken( + self.allocator, + self.previous(), + msg, + self.current_file_path + ); + return error.AliasTypeMismatch; + }, + }; +} + +fn resolve_keysym_alias_internal(self: *Parser, alias_name: []const u8, visited: *std.ArrayList([]const u8)) !Hotkey.KeyPress { + const alias = try self.resolve_alias_internal(alias_name, visited); + + return switch (alias) { + .keysym => |keypress| keypress, + .key => |keypress| keypress, // Key can be used as keysym (just no modifiers in the alias itself) + .modifier => { + const msg = try std.fmt.allocPrint( + self.allocator, + "Alias '${s}' is a modifier, not a complete keysym.\n" ++ + " It cannot be used standalone: ${s} : \n" ++ + " Add a key: ${s} - t : ", + .{alias_name, alias_name, alias_name} + ); + defer self.allocator.free(msg); + self.error_info = try ParseError.fromToken( + self.allocator, + self.previous(), + msg, + self.current_file_path + ); + return error.AliasTypeMismatch; + }, + }; +} + +// Versions of parse functions that don't handle aliases (to avoid infinite recursion during definition parsing) +fn parse_modifier_no_alias(self: *Parser) !ModifierFlag { + const token = self.previous(); + var flags = ModifierFlag{}; + + if (ModifierFlag.get(token.text)) |modifier_flags_value| { + flags = flags.merge(modifier_flags_value); + } else { + const msg = try std.fmt.allocPrint( + self.allocator, + "Unknown modifier '{s}'", + .{token.text} + ); + defer self.allocator.free(msg); + self.error_info = try ParseError.fromToken( + self.allocator, + token, + msg, + self.current_file_path + ); + return error.ParseErrorOccurred; + } + + if (self.match(.Token_Plus)) { + if (self.match(.Token_Modifier)) { + flags = flags.merge(try self.parse_modifier_no_alias()); + } else { + const error_token = self.peek() orelse self.previous(); + self.error_info = try ParseError.fromToken( + self.allocator, + error_token, + "Expected modifier after '+'", + self.current_file_path + ); + return error.ParseErrorOccurred; + } + } + return flags; +} + +fn parse_keypress_no_alias(self: *Parser) !Hotkey.KeyPress { + var flags: ModifierFlag = .{}; + var keycode: u32 = 0; + var found_modifier = false; + + if (self.match(.Token_Modifier)) { + flags = try self.parse_modifier_no_alias(); + found_modifier = true; + } + + if (found_modifier and !self.match(.Token_Dash)) { + const token = self.peek() orelse self.previous(); + self.error_info = try ParseError.fromToken( + self.allocator, + token, + "Expected '-' after modifier", + self.current_file_path + ); + return error.ParseErrorOccurred; + } + + if (self.match(.Token_Key)) { + keycode = try self.parse_key(); + } else if (self.match(.Token_Key_Hex)) { + keycode = try self.parse_key_hex(); + } else if (self.match(.Token_Literal)) { + const keypress = try self.parse_key_literal(); + flags = flags.merge(keypress.flags); + keycode = keypress.key; + } else { + const token = self.peek() orelse self.previous(); + self.error_info = try ParseError.fromToken( + self.allocator, + token, + "Expected key, key hex, or literal", + self.current_file_path + ); + return error.ParseErrorOccurred; + } + + return Hotkey.KeyPress{ .flags = flags, .key = keycode }; +} fn parse_option(self: *Parser, mappings: *Mappings) !void { assert(self.match(.Token_Option)); const option = self.previous().text; - if (std.mem.eql(u8, option, "define")) { + if (std.mem.eql(u8, option, "alias")) { + // Parse alias definition: .alias $name + if (!self.match(.Token_Alias)) { + const token = self.peek() orelse self.previous(); + self.error_info = try ParseError.fromToken(self.allocator, token, "Expected alias name with '$' prefix after 'alias'", self.current_file_path); + return error.ParseErrorOccurred; + } + + const alias_name = self.previous().text; + + // Check for redefinition + if (self.key_aliases.contains(alias_name)) { + self.error_info = try ParseError.fromToken(self.allocator, self.previous(), "Alias already defined", self.current_file_path); + return error.AliasAlreadyDefined; + } + + // Parse the alias value + const alias_value = try self.parse_alias_value(); + errdefer alias_value.deinit(); + + const owned_name = try self.allocator.dupe(u8, alias_name); + try self.key_aliases.put(self.allocator, owned_name, alias_value); + } else if (std.mem.eql(u8, option, "define")) { // Parse name after define if (!self.match(.Token_Identifier)) { const token = self.peek() orelse self.previous(); @@ -1049,7 +1544,7 @@ fn parse_option(self: *Parser, mappings: *Mappings) !void { return error.ParseErrorOccurred; } } else { - const msg = try std.fmt.allocPrint(self.allocator, "Unknown option '{s}'. Valid options are: define, load, blacklist, shell", .{option}); + const msg = try std.fmt.allocPrint(self.allocator, "Unknown option '{s}'. Valid options are: alias, define, load, blacklist, shell", .{option}); defer self.allocator.free(msg); self.error_info = try ParseError.fromToken(self.allocator, self.previous(), msg, self.current_file_path); return error.ParseErrorOccurred; @@ -1876,3 +2371,244 @@ test "duplicate process group definition returns error" { // Verify the original is still there try std.testing.expect(parser.process_groups.contains("browsers")); } +// Tests to be added to the end of Parser.zig + +test "parse alias - modifier alias" { + const alloc = std.testing.allocator; + var parser = try Parser.init(alloc); + defer parser.deinit(); + var mappings = try Mappings.init(alloc); + defer mappings.deinit(); + + try parser.parse(&mappings, + \\.alias $hyper cmd + alt + ctrl + shift + \\$hyper - t : echo "test" + ); + + try std.testing.expect(mappings.mode_map.get("default").?.hotkey_map.count() == 1); +} + +test "parse alias - key hex alias" { + const alloc = std.testing.allocator; + var parser = try Parser.init(alloc); + defer parser.deinit(); + var mappings = try Mappings.init(alloc); + defer mappings.deinit(); + + try parser.parse(&mappings, + \\.alias $grave 0x32 + \\ctrl - $grave : echo "test" + ); + + try std.testing.expect(mappings.mode_map.get("default").?.hotkey_map.count() == 1); +} + +test "parse alias - key combo alias" { + const alloc = std.testing.allocator; + var parser = try Parser.init(alloc); + defer parser.deinit(); + var mappings = try Mappings.init(alloc); + defer mappings.deinit(); + + try parser.parse(&mappings, + \\.alias $exclaim shift - 1 + \\ctrl - $exclaim : echo "test" + ); + + try std.testing.expect(mappings.mode_map.get("default").?.hotkey_map.count() == 1); +} + +test "parse alias - keysym alias standalone" { + const alloc = std.testing.allocator; + var parser = try Parser.init(alloc); + defer parser.deinit(); + var mappings = try Mappings.init(alloc); + defer mappings.deinit(); + + try parser.parse(&mappings, + \\.alias $terminal_key cmd + shift - t + \\$terminal_key : echo "test" + ); + + try std.testing.expect(mappings.mode_map.get("default").?.hotkey_map.count() == 1); +} + +test "parse alias - keysym alias with additional modifiers" { + const alloc = std.testing.allocator; + var parser = try Parser.init(alloc); + defer parser.deinit(); + var mappings = try Mappings.init(alloc); + defer mappings.deinit(); + + try parser.parse(&mappings, + \\.alias $nav_left cmd - h + \\ctrl + $nav_left : echo "test" + ); + + try std.testing.expect(mappings.mode_map.get("default").?.hotkey_map.count() == 1); +} + +test "parse alias - nested modifier alias" { + const alloc = std.testing.allocator; + var parser = try Parser.init(alloc); + defer parser.deinit(); + var mappings = try Mappings.init(alloc); + defer mappings.deinit(); + + try parser.parse(&mappings, + \\.alias $super cmd + alt + \\.alias $mega $super + shift + ctrl + \\$mega - x : echo "test" + ); + + try std.testing.expect(mappings.mode_map.get("default").?.hotkey_map.count() == 1); +} + +test "parse alias - nested keysym alias" { + const alloc = std.testing.allocator; + var parser = try Parser.init(alloc); + defer parser.deinit(); + var mappings = try Mappings.init(alloc); + defer mappings.deinit(); + + try parser.parse(&mappings, + \\.alias $super cmd + alt + \\.alias $nav_left $super - h + \\$nav_left : echo "test" + ); + + try std.testing.expect(mappings.mode_map.get("default").?.hotkey_map.count() == 1); +} + +test "parse alias - undefined error" { + const alloc = std.testing.allocator; + var parser = try Parser.init(alloc); + defer parser.deinit(); + var mappings = try Mappings.init(alloc); + defer mappings.deinit(); + + try std.testing.expectError( + error.UndefinedAlias, + parser.parse(&mappings, "$undefined - x : echo test") + ); + + try std.testing.expect(parser.error_info != null); + const err_msg = parser.error_info.?.message; + try std.testing.expect(std.mem.indexOf(u8, err_msg, "Undefined alias") != null); +} + +test "parse alias - redefinition error" { + const alloc = std.testing.allocator; + var parser = try Parser.init(alloc); + defer parser.deinit(); + var mappings = try Mappings.init(alloc); + defer mappings.deinit(); + + try std.testing.expectError( + error.AliasAlreadyDefined, + parser.parse(&mappings, + \\.alias $hyper cmd + alt + \\.alias $hyper cmd + shift + ) + ); +} + +test "parse alias - circular reference error" { + const alloc = std.testing.allocator; + var parser = try Parser.init(alloc); + defer parser.deinit(); + var mappings = try Mappings.init(alloc); + defer mappings.deinit(); + + // This should fail during the definition of $b since it tries to resolve $a + // which hasn't been fully defined yet + try std.testing.expectError( + error.UndefinedAlias, // $a is not defined when we try to define $b + parser.parse(&mappings, + \\.alias $b $a + shift + \\.alias $a $b + ctrl + ) + ); +} + +// Note: Removed test "parse alias - missing $ prefix suggestion" +// The test expected an error when using built-in 'hyper' after defining '.alias $hyper cmd + alt' +// However, built-in modifiers and aliases are separate - using 'hyper' without '$' should use +// the built-in modifier, not error. Aliases don't shadow built-ins. + +test "parse alias - type mismatch modifier as key" { + const alloc = std.testing.allocator; + var parser = try Parser.init(alloc); + defer parser.deinit(); + var mappings = try Mappings.init(alloc); + defer mappings.deinit(); + + try parser.parse(&mappings, ".alias $hyper cmd + alt"); + + // Try to use modifier alias in key position + const result = parser.parse(&mappings, "ctrl - $hyper : echo test"); + try std.testing.expectError(error.AliasTypeMismatch, result); + + try std.testing.expect(parser.error_info != null); + const err_msg = parser.error_info.?.message; + try std.testing.expect(std.mem.indexOf(u8, err_msg, "modifier, not a key") != null); +} + +test "parse alias - type mismatch modifier standalone" { + const alloc = std.testing.allocator; + var parser = try Parser.init(alloc); + defer parser.deinit(); + var mappings = try Mappings.init(alloc); + defer mappings.deinit(); + + try parser.parse(&mappings, ".alias $hyper cmd + alt"); + + // Try to use modifier alias standalone (needs a key) + const result = parser.parse(&mappings, "$hyper : echo test"); + try std.testing.expectError(error.AliasTypeMismatch, result); + + try std.testing.expect(parser.error_info != null); + const err_msg = parser.error_info.?.message; + try std.testing.expect(std.mem.indexOf(u8, err_msg, "not a complete keysym") != null); +} + +test "parse alias - complex nested example" { + const alloc = std.testing.allocator; + var parser = try Parser.init(alloc); + defer parser.deinit(); + var mappings = try Mappings.init(alloc); + defer mappings.deinit(); + + try parser.parse(&mappings, + \\.alias $super cmd + alt + \\.alias $mega $super + shift + ctrl + \\.alias $grave 0x32 + \\.alias $tilde shift - $grave + \\.alias $nav_left $mega - h + \\ + \\$mega - t : echo "mega t" + \\ctrl - $tilde : echo "ctrl tilde" + \\$nav_left : echo "nav left" + ); + + // Note: Removed "ctrl + $nav_left" because it's a duplicate of "$nav_left" + // ($nav_left = $mega - h = cmd+alt+shift+ctrl - h, so adding ctrl doesn't change it) + try std.testing.expect(mappings.mode_map.get("default").?.hotkey_map.count() == 3); +} + +test "parse alias - with literal keys" { + const alloc = std.testing.allocator; + var parser = try Parser.init(alloc); + defer parser.deinit(); + var mappings = try Mappings.init(alloc); + defer mappings.deinit(); + + try parser.parse(&mappings, + \\.alias $super cmd + alt + \\.alias $del_key delete + \\ + \\$super - $del_key : echo "super delete" + ); + + try std.testing.expect(mappings.mode_map.get("default").?.hotkey_map.count() == 1); +} diff --git a/src/Tokenizer.zig b/src/Tokenizer.zig index 9403ae8..6d0f55f 100644 --- a/src/Tokenizer.zig +++ b/src/Tokenizer.zig @@ -36,6 +36,7 @@ pub const TokenType = enum { Token_String, Token_Option, Token_Reference, + Token_Alias, Token_BeginList, Token_EndList, @@ -111,6 +112,19 @@ pub fn get_token(self: *Tokenizer) ?Token { token.type = .Token_Capture; } }, + '$' => { + // Check if this is followed by an identifier + const next = self.peekRune(); + if (next != null and ascii.isAlphabetic(next.?[0])) { + // It's an alias like $hyper or $grave + token.type = .Token_Alias; + // Don't include the $ in the token text + token.text = self.acceptIdentifier(); + } else { + // Standalone $ is unknown/error + token.type = .Token_Unknown; + } + }, '~' => token.type = .Token_Unbound, '*' => token.type = .Token_Wildcard, '[' => token.type = .Token_BeginList, diff --git a/src/tests.zig b/src/tests.zig index ccfed75..452f034 100644 --- a/src/tests.zig +++ b/src/tests.zig @@ -512,7 +512,7 @@ test "Parser error messages" { try testing.expectError(error.ParseErrorOccurred, err4); try testing.expect(parser.error_info != null); const parse_err4 = parser.error_info.?; - try testing.expectEqualStrings("Expected key, key hex, or literal", parse_err4.message); + try testing.expectEqualStrings("Expected key, key hex, literal, or alias", parse_err4.message); // Test empty process list parser.clearError();