From 8d90e3c4d268634004685b3a1fe818b6e64e020d Mon Sep 17 00:00:00 2001 From: Jules Aguillon Date: Sat, 22 Feb 2025 12:19:51 +0100 Subject: [PATCH] New syntax for macros Macros can now be written in a more elegant syntax: symbol:key1,key2,.. The symbol cannot contain a : character. 'key1', 'key2', etc.. are: - 'String with \' escaping' The key will generate the specified string. - keyevent:123 The key will send a keyevent. - The name of any special key --- srcs/juloo.keyboard2/KeyValue.java | 34 +- srcs/juloo.keyboard2/KeyValueParser.java | 345 ++++++++++++------ .../prefs/CustomExtraKeysPreference.java | 2 +- test/juloo.keyboard2/KeyValueParserTest.java | 129 +++++-- 4 files changed, 341 insertions(+), 169 deletions(-) diff --git a/srcs/juloo.keyboard2/KeyValue.java b/srcs/juloo.keyboard2/KeyValue.java index c5e4d8c..b2ef5db 100644 --- a/srcs/juloo.keyboard2/KeyValue.java +++ b/srcs/juloo.keyboard2/KeyValue.java @@ -479,30 +479,21 @@ public final class KeyValue implements Comparable return new KeyValue("", Kind.Modifier, mod.ordinal(), 0); } - public static KeyValue parseKeyDefinition(String str) - { - if (str.length() < 2 || str.charAt(0) != ':') - return makeStringKey(str); - try - { - return KeyValueParser.parse(str); - } - catch (KeyValueParser.ParseError _e) - { - return makeStringKey(str); - } - } - - /** - * Return a key by its name. If the given name doesn't correspond to a key - * defined in this function, it is passed to [parseStringKey] as a fallback. - */ + /** Return a key by its name. If the given name doesn't correspond to any + special key, it is parsed with [KeyValueParser]. */ public static KeyValue getKeyByName(String name) { KeyValue k = getSpecialKeyByName(name); - if (k == null) - return parseKeyDefinition(name); - return k; + if (k != null) + return k; + try + { + return KeyValueParser.parse(name); + } + catch (KeyValueParser.ParseError _e) + { + return makeStringKey(name); + } } public static KeyValue getSpecialKeyByName(String name) @@ -821,6 +812,7 @@ public final class KeyValue implements Comparable public String toString() { return _symbol; } + @Override public int compareTo(Macro snd) { int d = keys.length - snd.keys.length; diff --git a/srcs/juloo.keyboard2/KeyValueParser.java b/srcs/juloo.keyboard2/KeyValueParser.java index f46b1da..ec49a01 100644 --- a/srcs/juloo.keyboard2/KeyValueParser.java +++ b/srcs/juloo.keyboard2/KeyValueParser.java @@ -6,10 +6,17 @@ import java.util.regex.Pattern; /** Parse a key definition. The syntax for a key definition is: +- [(symbol):(key_action)] - [:(kind) (attributes):(payload)]. - If [str] doesn't start with a [:] character, it is interpreted as an arbitrary string key. +[key_action] is: +- ['Arbitrary string'] +- [(key_action),(key_action),...] +- [keyevent:(code)] +- [(key_name)] + For the different kinds and attributes, see doc/Possible-key-values.md. Examples: @@ -19,154 +26,265 @@ Examples: */ public final class KeyValueParser { - static Pattern START_PAT; - static Pattern ATTR_PAT; + static Pattern KEYDEF_TOKEN; static Pattern QUOTED_PAT; - static Pattern PAYLOAD_START_PAT; static Pattern WORD_PAT; - static Pattern COMMA_PAT; - static Pattern END_OF_INPUT_PAT; - static public KeyValue parse(String str) throws ParseError + static public KeyValue parse(String input) throws ParseError { + int symbol_ends = 0; + final int input_len = input.length(); + while (symbol_ends < input_len && input.charAt(symbol_ends) != ':') + symbol_ends++; + if (symbol_ends == 0) // Old syntax + return Starting_with_colon.parse(input); + if (symbol_ends == input_len) // String key + return KeyValue.makeStringKey(input); + String symbol = input.substring(0, symbol_ends); + ArrayList keydefs = new ArrayList(); init(); - Matcher m = START_PAT.matcher(str); - KeyValue k = parseKeyValue(m); - if (!match(m, END_OF_INPUT_PAT)) - parseError("Unexpected character", m); - return k; + Matcher m = KEYDEF_TOKEN.matcher(input); + m.region(symbol_ends + 1, input_len); + do { keydefs.add(parse_key_def(m)); } + while (parse_comma(m)); + for (KeyValue k : keydefs) + if (k == null) + parseError("Contains null key", m); + return KeyValue.makeMacro(symbol, keydefs.toArray(new KeyValue[]{}), 0); } static void init() { - if (START_PAT != null) + if (KEYDEF_TOKEN != null) return; - START_PAT = Pattern.compile(":(\\w+)"); - ATTR_PAT = Pattern.compile("\\s*(\\w+)\\s*="); - QUOTED_PAT = Pattern.compile("'(([^'\\\\]+|\\\\')*)'"); - PAYLOAD_START_PAT = Pattern.compile("\\s*:"); + KEYDEF_TOKEN = Pattern.compile("'|,|keyevent:|(?:[^\\\\',]+|\\\\.)+"); + QUOTED_PAT = Pattern.compile("((?:[^'\\\\]+|\\\\')*)'"); WORD_PAT = Pattern.compile("[a-zA-Z0-9_]+|."); - COMMA_PAT = Pattern.compile(","); - END_OF_INPUT_PAT = Pattern.compile("$"); } - static KeyValue parseKeyValue(Matcher m) throws ParseError + static KeyValue key_by_name_or_str(String str) { - if (match(m, START_PAT)) - return parseComplexKeyValue(m, m.group(1)); - // Key doesn't start with ':', accept either a char key or a key name. - if (!match(m, WORD_PAT)) - parseError("Expected key, for example \":str ...\".", m); - String key = m.group(0); - KeyValue k = KeyValue.getSpecialKeyByName(key); - if (k == null) - return KeyValue.makeStringKey(key); - return k; + KeyValue k = KeyValue.getSpecialKeyByName(str); + if (k != null) + return k; + return KeyValue.makeStringKey(str); } - static KeyValue parseComplexKeyValue(Matcher m, String kind) throws ParseError + static KeyValue parse_key_def(Matcher m) throws ParseError { - // Attributes - String symbol = null; - int flags = 0; - while (true) + if (!match(m, KEYDEF_TOKEN)) + parseError("Expected key definition", m); + String token = m.group(0); + switch (token) { - if (!match(m, ATTR_PAT)) - break; - String attr_name = m.group(1); - String attr_value = parseSingleQuotedString(m); - switch (attr_name) - { - case "flags": - flags = parseFlags(attr_value, m); - break; - case "symbol": - symbol = attr_value; - break; - - default: - parseError("Unknown attribute "+attr_name, m); - } + case "'": return parse_string_keydef(m); + case ",": parseError("Unexpected comma", m); return null; + case "keyevent:": return parse_keyevent_keydef(m); + default: return key_by_name_or_str(remove_escaping(token)); } - // Payload - if (!match(m, PAYLOAD_START_PAT)) - parseError("Unexpected character", m); - String payload; - switch (kind) - { - case "str": - payload = parseSingleQuotedString(m); - if (symbol == null) - return KeyValue.makeStringKey(payload, flags); - return KeyValue.makeStringKeyWithSymbol(payload, symbol, flags); - - case "char": - payload = parsePayloadWord(m); - if (payload.length() != 1) - parseError("Expected a single character payload", m); - return KeyValue.makeCharKey(payload.charAt(0), symbol, flags); - - case "keyevent": - payload = parsePayloadWord(m); - int eventcode = 0; - try { eventcode = Integer.parseInt(payload); } - catch (Exception _e) - { parseError("Expected an integer payload", m); } - if (symbol == null) - symbol = String.valueOf(eventcode); - return KeyValue.keyeventKey(symbol, eventcode, flags); - - case "macro": - // :macro symbol='copy':ctrl,a,ctrl,c - // :macro symbol='acute':compose,' - KeyValue[] macro = parseKeyValueList(m); - if (symbol == null) - symbol = "macro"; - return KeyValue.makeMacro(symbol, macro, flags); - - default: break; - } - parseError("Unknown kind '"+kind+"'", m, 1); - return null; // Unreachable } - static String parseSingleQuotedString(Matcher m) throws ParseError + static KeyValue parse_string_keydef(Matcher m) throws ParseError { if (!match(m, QUOTED_PAT)) - parseError("Expected quoted string", m); - return m.group(1).replace("\\'", "'"); + parseError("Unterminated quoted string", m); + return KeyValue.makeStringKey(remove_escaping(m.group(1))); } - static String parsePayloadWord(Matcher m) throws ParseError + static KeyValue parse_keyevent_keydef(Matcher m) throws ParseError { if (!match(m, WORD_PAT)) - parseError("Expected a word after ':' made of [a-zA-Z0-9_]", m); - return m.group(0); + parseError("Expected keyevent code", m); + int eventcode = 0; + try { eventcode = Integer.parseInt(m.group(0)); } + catch (Exception _e) + { parseError("Expected an integer payload", m); } + return KeyValue.keyeventKey("", eventcode, 0); } - static int parseFlags(String s, Matcher m) throws ParseError + /** Returns [true] if the next token is a comma, [false] if it is the end of the input. Throws an error otherwise. */ + static boolean parse_comma(Matcher m) throws ParseError { - int flags = 0; - for (String f : s.split(",")) - { - switch (f) + if (!match(m, KEYDEF_TOKEN)) + return false; + String token = m.group(0); + if (!token.equals(",")) + parseError("Expected comma instead of '"+ token + "'", m); + return true; + } + + static String remove_escaping(String s) + { + if (!s.contains("\\")) + return s; + StringBuilder out = new StringBuilder(s.length()); + final int len = s.length(); + int prev = 0, i = 0; + for (; i < len; i++) + if (s.charAt(i) == '\\') { - case "dim": flags |= KeyValue.FLAG_SECONDARY; break; - case "small": flags |= KeyValue.FLAG_SMALLER_FONT; break; - default: parseError("Unknown flag "+f, m); + out.append(s, prev, i); + prev = i + 1; } - } - return flags; + out.append(s, prev, i); + return out.toString(); } - // Parse keys separated by comas - static KeyValue[] parseKeyValueList(Matcher m) throws ParseError + /** + Parse a key definition starting with a [:]. This is the old syntax and is + kept for compatibility. + */ + final static class Starting_with_colon { - ArrayList out = new ArrayList(); - out.add(parseKeyValue(m)); - while (match(m, COMMA_PAT)) + static Pattern START_PAT; + static Pattern ATTR_PAT; + static Pattern QUOTED_PAT; + static Pattern PAYLOAD_START_PAT; + static Pattern WORD_PAT; + static Pattern COMMA_PAT; + static Pattern END_OF_INPUT_PAT; + + static public KeyValue parse(String str) throws ParseError + { + init(); + Matcher m = START_PAT.matcher(str); + KeyValue k = parseKeyValue(m); + if (!match(m, END_OF_INPUT_PAT)) + parseError("Unexpected character", m); + return k; + } + + static void init() + { + if (START_PAT != null) + return; + START_PAT = Pattern.compile(":(\\w+)"); + ATTR_PAT = Pattern.compile("\\s*(\\w+)\\s*="); + QUOTED_PAT = Pattern.compile("'(([^'\\\\]+|\\\\')*)'"); + PAYLOAD_START_PAT = Pattern.compile("\\s*:"); + WORD_PAT = Pattern.compile("[a-zA-Z0-9_]+|."); + COMMA_PAT = Pattern.compile(","); + END_OF_INPUT_PAT = Pattern.compile("$"); + } + + static KeyValue parseKeyValue(Matcher m) throws ParseError + { + if (match(m, START_PAT)) + return parseComplexKeyValue(m, m.group(1)); + // Key doesn't start with ':', accept either a char key or a key name. + if (!match(m, WORD_PAT)) + parseError("Expected key, for example \":str ...\".", m); + String key = m.group(0); + KeyValue k = KeyValue.getSpecialKeyByName(key); + if (k == null) + return KeyValue.makeStringKey(key); + return k; + } + + static KeyValue parseComplexKeyValue(Matcher m, String kind) throws ParseError + { + // Attributes + String symbol = null; + int flags = 0; + while (true) + { + if (!match(m, ATTR_PAT)) + break; + String attr_name = m.group(1); + String attr_value = parseSingleQuotedString(m); + switch (attr_name) + { + case "flags": + flags = parseFlags(attr_value, m); + break; + case "symbol": + symbol = attr_value; + break; + + default: + parseError("Unknown attribute "+attr_name, m); + } + } + // Payload + if (!match(m, PAYLOAD_START_PAT)) + parseError("Unexpected character", m); + String payload; + switch (kind) + { + case "str": + payload = parseSingleQuotedString(m); + if (symbol == null) + return KeyValue.makeStringKey(payload, flags); + return KeyValue.makeStringKeyWithSymbol(payload, symbol, flags); + + case "char": + payload = parsePayloadWord(m); + if (payload.length() != 1) + parseError("Expected a single character payload", m); + return KeyValue.makeCharKey(payload.charAt(0), symbol, flags); + + case "keyevent": + payload = parsePayloadWord(m); + int eventcode = 0; + try { eventcode = Integer.parseInt(payload); } + catch (Exception _e) + { parseError("Expected an integer payload", m); } + if (symbol == null) + symbol = String.valueOf(eventcode); + return KeyValue.keyeventKey(symbol, eventcode, flags); + + case "macro": + // :macro symbol='copy':ctrl,a,ctrl,c + // :macro symbol='acute':compose,' + KeyValue[] macro = parseKeyValueList(m); + if (symbol == null) + symbol = "macro"; + return KeyValue.makeMacro(symbol, macro, flags); + + default: break; + } + parseError("Unknown kind '"+kind+"'", m, 1); + return null; // Unreachable + } + + static String parseSingleQuotedString(Matcher m) throws ParseError + { + if (!match(m, QUOTED_PAT)) + parseError("Expected quoted string", m); + return m.group(1).replace("\\'", "'"); + } + + static String parsePayloadWord(Matcher m) throws ParseError + { + if (!match(m, WORD_PAT)) + parseError("Expected a word after ':' made of [a-zA-Z0-9_]", m); + return m.group(0); + } + + static int parseFlags(String s, Matcher m) throws ParseError + { + int flags = 0; + for (String f : s.split(",")) + { + switch (f) + { + case "dim": flags |= KeyValue.FLAG_SECONDARY; break; + case "small": flags |= KeyValue.FLAG_SMALLER_FONT; break; + default: parseError("Unknown flag "+f, m); + } + } + return flags; + } + + // Parse keys separated by comas + static KeyValue[] parseKeyValueList(Matcher m) throws ParseError + { + ArrayList out = new ArrayList(); out.add(parseKeyValue(m)); - return out.toArray(new KeyValue[]{}); + while (match(m, COMMA_PAT)) + out.add(parseKeyValue(m)); + return out.toArray(new KeyValue[]{}); + } } static boolean match(Matcher m, Pattern pat) @@ -186,8 +304,7 @@ public final class KeyValueParser StringBuilder msg_ = new StringBuilder("Syntax error"); try { - char c = m.group(0).charAt(0); - msg_.append(" at character '").append(c).append("'"); + msg_.append(" at token '").append(m.group(0)).append("'"); } catch (IllegalStateException _e) {} msg_.append(" at position "); msg_.append(i); diff --git a/srcs/juloo.keyboard2/prefs/CustomExtraKeysPreference.java b/srcs/juloo.keyboard2/prefs/CustomExtraKeysPreference.java index 6f4cf41..253e074 100644 --- a/srcs/juloo.keyboard2/prefs/CustomExtraKeysPreference.java +++ b/srcs/juloo.keyboard2/prefs/CustomExtraKeysPreference.java @@ -41,7 +41,7 @@ public class CustomExtraKeysPreference extends ListGroupPreference if (key_names != null) { for (String key_name : key_names) - kvs.put(KeyValue.parseKeyDefinition(key_name), KeyboardData.PreferredPos.DEFAULT); + kvs.put(KeyValue.getKeyByName(key_name), KeyboardData.PreferredPos.DEFAULT); } return kvs; } diff --git a/test/juloo.keyboard2/KeyValueParserTest.java b/test/juloo.keyboard2/KeyValueParserTest.java index d1f467b..a041e8a 100644 --- a/test/juloo.keyboard2/KeyValueParserTest.java +++ b/test/juloo.keyboard2/KeyValueParserTest.java @@ -10,7 +10,100 @@ public class KeyValueParserTest public KeyValueParserTest() {} @Test - public void parseStr() throws Exception + public void parse_key_value() throws Exception + { + Utils.parse("'", KeyValue.makeStringKey("'")); + Utils.parse("\\'", KeyValue.makeStringKey("\\'")); + Utils.parse("\\,", KeyValue.makeStringKey("\\,")); + Utils.parse("a\\'b", KeyValue.makeStringKey("a\\'b")); + Utils.parse("a\\,b", KeyValue.makeStringKey("a\\,b")); + Utils.parse("a", KeyValue.makeStringKey("a")); + Utils.parse("abc", KeyValue.makeStringKey("abc")); + Utils.parse("shift", KeyValue.getSpecialKeyByName("shift")); + Utils.parse("'a", KeyValue.makeStringKey("'a")); + } + + @Test + public void parse_macro() throws Exception + { + Utils.parse("symbol:abc", KeyValue.makeMacro("symbol", new KeyValue[]{ + KeyValue.makeStringKey("abc") + }, 0)); + Utils.parse("copy:ctrl,a,ctrl,c", KeyValue.makeMacro("copy", new KeyValue[]{ + KeyValue.getSpecialKeyByName("ctrl"), + KeyValue.makeStringKey("a"), + KeyValue.getSpecialKeyByName("ctrl"), + KeyValue.makeStringKey("c") + }, 0)); + Utils.parse("macro:abc,\\'", KeyValue.makeMacro("macro", new KeyValue[]{ + KeyValue.makeStringKey("abc"), + KeyValue.makeStringKey("'") + }, 0)); + Utils.parse("macro:abc,\\,", KeyValue.makeMacro("macro", new KeyValue[]{ + KeyValue.makeStringKey("abc"), + KeyValue.makeStringKey(",") + }, 0)); + Utils.parse("<2:ctrl,backspace", KeyValue.makeMacro("<2", new KeyValue[]{ + KeyValue.getSpecialKeyByName("ctrl"), + KeyValue.getSpecialKeyByName("backspace") + }, 0)); + Utils.expect_error("symbol:"); + Utils.expect_error("unterminated_string:'"); + Utils.expect_error("unterminated_string:abc,'"); + Utils.expect_error("unexpected_quote:abc,,"); + Utils.expect_error("unexpected_quote:,"); + } + + @Test + public void parse_string_key() throws Exception + { + Utils.parse("symbol:'str'", KeyValue.makeMacro("symbol", new KeyValue[]{ + KeyValue.makeStringKey("str") + }, 0)); + Utils.parse("symbol:'str\\''", KeyValue.makeMacro("symbol", new KeyValue[]{ + KeyValue.makeStringKey("str'") + }, 0)); + Utils.parse("macro:'str',abc", KeyValue.makeMacro("macro", new KeyValue[]{ + KeyValue.makeStringKey("str"), + KeyValue.makeStringKey("abc") + }, 0)); + Utils.parse("macro:abc,'str'", KeyValue.makeMacro("macro", new KeyValue[]{ + KeyValue.makeStringKey("abc"), + KeyValue.makeStringKey("str") + }, 0)); + Utils.parse("macro:\\',\\,", KeyValue.makeMacro("macro", new KeyValue[]{ + KeyValue.makeStringKey("'"), + KeyValue.makeStringKey(","), + }, 0)); + Utils.parse("macro:a\\'b,a\\,b,a\\xb", KeyValue.makeMacro("macro", new KeyValue[]{ + KeyValue.makeStringKey("a'b"), + KeyValue.makeStringKey("a,b"), + KeyValue.makeStringKey("axb") + }, 0)); + Utils.expect_error("symbol:'"); + Utils.expect_error("symbol:'foo"); + } + + @Test + public void parse_key_event() throws Exception + { + Utils.parse("symbol:keyevent:85", KeyValue.makeMacro("symbol", new KeyValue[]{ + KeyValue.keyeventKey("", 85, 0) + }, 0)); + Utils.parse("macro:keyevent:85,abc", KeyValue.makeMacro("macro", new KeyValue[]{ + KeyValue.keyeventKey("", 85, 0), + KeyValue.makeStringKey("abc") + }, 0)); + Utils.parse("macro:abc,keyevent:85", KeyValue.makeMacro("macro", new KeyValue[]{ + KeyValue.makeStringKey("abc"), + KeyValue.keyeventKey("", 85, 0) + }, 0)); + Utils.expect_error("symbol:keyevent:"); + Utils.expect_error("symbol:keyevent:85a"); + } + + @Test + public void parse_old_syntax() throws Exception { Utils.parse(":str:'Foo'", KeyValue.makeStringKey("Foo")); Utils.parse(":str flags='dim':'Foo'", KeyValue.makeStringKey("Foo", KeyValue.FLAG_SECONDARY)); @@ -32,47 +125,17 @@ public class KeyValueParserTest Utils.expect_error(":str flags='' "); Utils.expect_error(":str flags='':"); Utils.expect_error(":str flags='':'"); - } - - @Test - public void parseChar() throws Exception - { + // Char Utils.parse(":char symbol='a':b", KeyValue.makeCharKey('b', "a", 0)); Utils.parse(":char:b", KeyValue.makeCharKey('b', "b", 0)); } - @Test - public void parseKeyValue() throws Exception - { - Utils.parse("\'", KeyValue.makeStringKey("\'")); - Utils.parse("a", KeyValue.makeStringKey("a")); - Utils.parse("abc", KeyValue.makeStringKey("abc")); - Utils.parse("shift", KeyValue.getSpecialKeyByName("shift")); - Utils.expect_error("\'a"); - } - - @Test - public void parseMacro() throws Exception - { - Utils.parse(":macro symbol='copy':ctrl,a,ctrl,c", KeyValue.makeMacro("copy", new KeyValue[]{ - KeyValue.getSpecialKeyByName("ctrl"), - KeyValue.makeStringKey("a"), - KeyValue.getSpecialKeyByName("ctrl"), - KeyValue.makeStringKey("c") - }, 0)); - Utils.parse(":macro:abc,'", KeyValue.makeMacro("macro", new KeyValue[]{ - KeyValue.makeStringKey("abc"), - KeyValue.makeStringKey("'") - }, 0)); - Utils.expect_error(":macro:"); - } - /** JUnit removes these functions from stacktraces. */ static class Utils { static void parse(String key_descr, KeyValue ref) throws Exception { - assertEquals(ref, KeyValueParser.parse(key_descr)); + assertEquals(ref, KeyValue.getKeyByName(key_descr)); } static void expect_error(String key_descr)