chore(repo): baseline du fork input-remapper (upstream v2.2.1)
Le dépôt ne contenait que README.md ; ajout de l'intégralité du code fonctionnel du fork comme socle stable de la branche de release. L'état runtime d'orchestration (.ideai/) est exclu du suivi. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
476
inputremapper/injection/macros/parse.py
Normal file
476
inputremapper/injection/macros/parse.py
Normal file
@ -0,0 +1,476 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
"""Parse macro code"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Optional, Any, Type, TYPE_CHECKING, Dict, List
|
||||
|
||||
from inputremapper.configs.validation_errors import MacroError
|
||||
from inputremapper.injection.macros.macro import Macro
|
||||
from inputremapper.injection.macros.raw_value import RawValue
|
||||
from inputremapper.injection.macros.task import Task
|
||||
from inputremapper.injection.macros.tasks.add import AddTask
|
||||
from inputremapper.injection.macros.tasks.event import EventTask
|
||||
from inputremapper.injection.macros.tasks.hold import HoldTask
|
||||
from inputremapper.injection.macros.tasks.hold_keys import HoldKeysTask
|
||||
from inputremapper.injection.macros.tasks.if_eq import IfEqTask
|
||||
from inputremapper.injection.macros.tasks.if_led import IfNumlockTask, IfCapslockTask
|
||||
from inputremapper.injection.macros.tasks.if_single import IfSingleTask
|
||||
from inputremapper.injection.macros.tasks.if_tap import IfTapTask
|
||||
from inputremapper.injection.macros.tasks.ifeq import DeprecatedIfEqTask
|
||||
from inputremapper.injection.macros.tasks.key import KeyTask
|
||||
from inputremapper.injection.macros.tasks.key_down import KeyDownTask
|
||||
from inputremapper.injection.macros.tasks.key_up import KeyUpTask
|
||||
from inputremapper.injection.macros.tasks.mod_tap import ModTapTask
|
||||
from inputremapper.injection.macros.tasks.modify import ModifyTask
|
||||
from inputremapper.injection.macros.tasks.mouse import MouseTask
|
||||
from inputremapper.injection.macros.tasks.parallel import ParallelTask
|
||||
from inputremapper.injection.macros.tasks.mouse_xy import MouseXYTask
|
||||
from inputremapper.injection.macros.tasks.repeat import RepeatTask
|
||||
from inputremapper.injection.macros.tasks.set import SetTask
|
||||
from inputremapper.injection.macros.tasks.toggle import ToggleTask
|
||||
from inputremapper.injection.macros.tasks.wait import WaitTask
|
||||
from inputremapper.injection.macros.tasks.wheel import WheelTask
|
||||
from inputremapper.logging.logger import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from inputremapper.injection.context import Context
|
||||
from inputremapper.configs.mapping import Mapping
|
||||
|
||||
|
||||
class Parser:
|
||||
TASK_CLASSES: dict[str, type[Task]] = {
|
||||
"modify": ModifyTask,
|
||||
"repeat": RepeatTask,
|
||||
"toggle": ToggleTask,
|
||||
"key": KeyTask,
|
||||
"key_down": KeyDownTask,
|
||||
"key_up": KeyUpTask,
|
||||
"event": EventTask,
|
||||
"wait": WaitTask,
|
||||
"hold": HoldTask,
|
||||
"hold_keys": HoldKeysTask,
|
||||
"mouse": MouseTask,
|
||||
"mouse_xy": MouseXYTask,
|
||||
"wheel": WheelTask,
|
||||
"if_eq": IfEqTask,
|
||||
"if_numlock": IfNumlockTask,
|
||||
"if_capslock": IfCapslockTask,
|
||||
"set": SetTask,
|
||||
"if_tap": IfTapTask,
|
||||
"if_single": IfSingleTask,
|
||||
"add": AddTask,
|
||||
"mod_tap": ModTapTask,
|
||||
"parallel": ParallelTask,
|
||||
# Those are only kept for backwards compatibility with old macros. The space for
|
||||
# writing macro was very constrained in the past, so shorthands were introduced:
|
||||
"m": ModifyTask,
|
||||
"r": RepeatTask,
|
||||
"k": KeyTask,
|
||||
"e": EventTask,
|
||||
"w": WaitTask,
|
||||
"h": HoldTask,
|
||||
# It was not possible to adjust ifeq to support variables without breaking old
|
||||
# macros, so this function is deprecated and if_eq introduced. Kept for backwards
|
||||
# compatibility:
|
||||
"ifeq": DeprecatedIfEqTask,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def is_this_a_macro(output: Any):
|
||||
"""Figure out if this is a macro."""
|
||||
if not isinstance(output, str):
|
||||
return False
|
||||
|
||||
if "+" in output.strip():
|
||||
# for example "a + b"
|
||||
return True
|
||||
|
||||
return "(" in output and ")" in output and len(output) >= 4
|
||||
|
||||
@staticmethod
|
||||
def _extract_args(inner: str):
|
||||
"""Extract parameters from the inner contents of a call.
|
||||
|
||||
This does not parse them.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
inner
|
||||
for example '1, r, r(2, k(a))' should result in ['1', 'r', 'r(2, k(a))']
|
||||
"""
|
||||
inner = inner.strip()
|
||||
brackets = 0
|
||||
params = []
|
||||
start = 0
|
||||
string = False
|
||||
for position, char in enumerate(inner):
|
||||
# ignore anything between string quotes
|
||||
if char == '"':
|
||||
string = not string
|
||||
if string:
|
||||
continue
|
||||
|
||||
# ignore commas inside child macros
|
||||
if char == "(":
|
||||
brackets += 1
|
||||
if char == ")":
|
||||
brackets -= 1
|
||||
if char == "," and brackets == 0:
|
||||
# , potentially starts another parameter, but only if
|
||||
# the current brackets are all closed.
|
||||
params.append(inner[start:position].strip())
|
||||
# skip the comma
|
||||
start = position + 1
|
||||
|
||||
# one last parameter
|
||||
params.append(inner[start:].strip())
|
||||
|
||||
return params
|
||||
|
||||
@staticmethod
|
||||
def _count_brackets(macro):
|
||||
"""Find where the first opening bracket closes."""
|
||||
openings = macro.count("(")
|
||||
closings = macro.count(")")
|
||||
if openings != closings:
|
||||
raise MacroError(
|
||||
macro, f"Found {openings} opening and {closings} closing brackets"
|
||||
)
|
||||
|
||||
brackets = 0
|
||||
position = 0
|
||||
for char in macro:
|
||||
position += 1
|
||||
if char == "(":
|
||||
brackets += 1
|
||||
continue
|
||||
|
||||
if char == ")":
|
||||
brackets -= 1
|
||||
if brackets == 0:
|
||||
# the closing bracket of the call
|
||||
break
|
||||
|
||||
return position
|
||||
|
||||
@staticmethod
|
||||
def _split_keyword_arg(param):
|
||||
"""Split "foo=bar" into "foo" and "bar".
|
||||
|
||||
If not a keyward param, return None and the param.
|
||||
"""
|
||||
if re.match(r"[a-zA-Z_][a-zA-Z_\d]*=.+", param):
|
||||
split = param.split("=", 1)
|
||||
return split[0], split[1]
|
||||
|
||||
return None, param
|
||||
|
||||
@staticmethod
|
||||
def _validate_keyword_argument_names(
|
||||
keyword_args: Dict[str, Any],
|
||||
task_class: Type[Task],
|
||||
) -> None:
|
||||
for keyword_arg in keyword_args:
|
||||
for argument in task_class.argument_configs:
|
||||
if argument.name == keyword_arg:
|
||||
break
|
||||
else:
|
||||
raise MacroError(msg=f"Unknown keyword argument {keyword_arg}")
|
||||
|
||||
@staticmethod
|
||||
def _parse_recurse(
|
||||
code: str,
|
||||
context: Optional[Context],
|
||||
mapping: Mapping,
|
||||
verbose: bool,
|
||||
macro_instance: Optional[Macro] = None,
|
||||
depth: int = 0,
|
||||
) -> RawValue:
|
||||
"""Handle a subset of the macro, e.g. one parameter or function call.
|
||||
|
||||
Not using eval for security reasons.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
code
|
||||
Just like parse. A single parameter or the complete macro as string.
|
||||
Comments and redundant whitespace characters are expected to be removed already.
|
||||
Example:
|
||||
- "parallel(key(a),key(b).key($foo))"
|
||||
- "key(a)"
|
||||
- "a"
|
||||
- "key(b).key($foo)"
|
||||
- "b"
|
||||
- "key($foo)"
|
||||
- "$foo"
|
||||
context : Context
|
||||
macro_instance
|
||||
A macro instance to add tasks to. This is the output of the parser, and is
|
||||
organized like a tree.
|
||||
depth
|
||||
For logging porposes
|
||||
"""
|
||||
assert isinstance(code, str)
|
||||
assert isinstance(depth, int)
|
||||
|
||||
def debug(*args, **kwargs):
|
||||
if verbose:
|
||||
logger.debug(*args, **kwargs)
|
||||
|
||||
space = " " * depth
|
||||
|
||||
code = code.strip()
|
||||
|
||||
# is it another macro?
|
||||
task_call_match = re.match(r"^(\w+)\(", code)
|
||||
task_name = task_call_match[1] if task_call_match else None
|
||||
|
||||
if task_name is None:
|
||||
# It is probably either a key name like KEY_A or a variable name as in `set(var,1)`,
|
||||
# both won't contain special characters that can break macro syntax so they don't
|
||||
# have to be wrapped in quotes. The argument configuration of the tasks will
|
||||
# detemrine how to parse it.
|
||||
debug("%svalue %s", space, code)
|
||||
return RawValue(value=code)
|
||||
|
||||
if macro_instance is None:
|
||||
# start a new chain
|
||||
macro_instance = Macro(code, context, mapping)
|
||||
else:
|
||||
# chain this call to the existing instance
|
||||
assert isinstance(macro_instance, Macro)
|
||||
|
||||
task_class = Parser.TASK_CLASSES.get(task_name)
|
||||
if task_class is None:
|
||||
raise MacroError(code, f"Unknown function {task_name}")
|
||||
|
||||
# get all the stuff inbetween
|
||||
closing_bracket_position = Parser._count_brackets(code) - 1
|
||||
inner = code[code.index("(") + 1 : closing_bracket_position]
|
||||
debug("%scalls %s with %s", space, task_name, inner)
|
||||
|
||||
# split "3, foo=a(2, k(a).w(10))" into arguments
|
||||
raw_string_args = Parser._extract_args(inner)
|
||||
|
||||
# parse and sort the params
|
||||
positional_args: List[RawValue] = []
|
||||
keyword_args: Dict[str, RawValue] = {}
|
||||
for param in raw_string_args:
|
||||
key, value = Parser._split_keyword_arg(param)
|
||||
parsed = Parser._parse_recurse(
|
||||
value.strip(),
|
||||
context,
|
||||
mapping,
|
||||
verbose,
|
||||
None,
|
||||
depth + 1,
|
||||
)
|
||||
if key is None:
|
||||
if len(keyword_args) > 0:
|
||||
msg = f'Positional argument "{key}" follows keyword argument'
|
||||
raise MacroError(code, msg)
|
||||
positional_args.append(parsed)
|
||||
else:
|
||||
if key in keyword_args:
|
||||
raise MacroError(code, f'The "{key}" argument was specified twice')
|
||||
keyword_args[key] = parsed
|
||||
|
||||
debug(
|
||||
"%sadd call to %s with %s, %s",
|
||||
space,
|
||||
task_name,
|
||||
positional_args,
|
||||
keyword_args,
|
||||
)
|
||||
|
||||
Parser._validate_keyword_argument_names(
|
||||
keyword_args,
|
||||
task_class,
|
||||
)
|
||||
Parser._validate_num_args(
|
||||
code,
|
||||
task_name,
|
||||
task_class,
|
||||
raw_string_args,
|
||||
)
|
||||
|
||||
try:
|
||||
task = task_class(
|
||||
positional_args,
|
||||
keyword_args,
|
||||
context,
|
||||
mapping,
|
||||
)
|
||||
macro_instance.add_task(task)
|
||||
except TypeError as exception:
|
||||
raise MacroError(msg=str(exception)) from exception
|
||||
|
||||
# is after this another call? Chain it to the macro_instance
|
||||
more_code_exists = len(code) > closing_bracket_position + 1
|
||||
if more_code_exists:
|
||||
next_char = code[closing_bracket_position + 1]
|
||||
statement_closed = next_char == "."
|
||||
|
||||
if statement_closed:
|
||||
# skip over the ")."
|
||||
chain = code[closing_bracket_position + 2 :]
|
||||
debug("%sfollowed by %s", space, chain)
|
||||
Parser._parse_recurse(
|
||||
chain,
|
||||
context,
|
||||
mapping,
|
||||
verbose,
|
||||
macro_instance,
|
||||
depth,
|
||||
)
|
||||
elif re.match(r"[a-zA-Z_]", next_char):
|
||||
# something like foo()bar
|
||||
raise MacroError(
|
||||
code,
|
||||
f'Expected a "." to follow after '
|
||||
f"{code[:closing_bracket_position + 1]}",
|
||||
)
|
||||
|
||||
return RawValue(value=macro_instance)
|
||||
|
||||
@staticmethod
|
||||
def _validate_num_args(
|
||||
code: str,
|
||||
task_name: str,
|
||||
task_class: Type[Task],
|
||||
raw_string_args: List[str],
|
||||
) -> None:
|
||||
min_args, max_args = task_class.get_num_parameters()
|
||||
num_provided_args = len(raw_string_args)
|
||||
if num_provided_args < min_args or num_provided_args > max_args:
|
||||
if min_args != max_args:
|
||||
msg = (
|
||||
f"{task_name} takes between {min_args} and {max_args}, "
|
||||
f"not {num_provided_args} parameters"
|
||||
)
|
||||
else:
|
||||
msg = (
|
||||
f"{task_name} takes {min_args}, not {num_provided_args} parameters"
|
||||
)
|
||||
|
||||
raise MacroError(code, msg)
|
||||
|
||||
@staticmethod
|
||||
def handle_plus_syntax(macro):
|
||||
"""Transform a + b + c to hold_keys(a,b,c)."""
|
||||
if "+" not in macro:
|
||||
return macro
|
||||
|
||||
if "(" in macro or ")" in macro:
|
||||
raise MacroError(macro, f'Mixing "+" and macros is unsupported: "{ macro}"')
|
||||
|
||||
chunks = [chunk.strip() for chunk in macro.split("+")]
|
||||
|
||||
if "" in chunks:
|
||||
raise MacroError(f'Invalid syntax for "{macro}"')
|
||||
|
||||
output = f"hold_keys({','.join(chunks)})"
|
||||
|
||||
logger.debug('Transformed "%s" to "%s"', macro, output)
|
||||
return output
|
||||
|
||||
@staticmethod
|
||||
def remove_whitespaces(macro, delimiter='"'):
|
||||
"""Remove whitespaces, tabs, newlines and such outside of string quotes."""
|
||||
result = ""
|
||||
for i, chunk in enumerate(macro.split(delimiter)):
|
||||
# every second chunk is inside string quotes
|
||||
if i % 2 == 0:
|
||||
result += re.sub(r"\s", "", chunk)
|
||||
else:
|
||||
result += chunk
|
||||
result += delimiter
|
||||
|
||||
# one extra delimiter was added
|
||||
return result[: -len(delimiter)]
|
||||
|
||||
@staticmethod
|
||||
def remove_comments(macro):
|
||||
"""Remove comments from the macro and return the resulting code."""
|
||||
# keep hashtags inside quotes intact
|
||||
result = ""
|
||||
|
||||
for i, line in enumerate(macro.split("\n")):
|
||||
for j, chunk in enumerate(line.split('"')):
|
||||
if j > 0:
|
||||
# add back the string quote
|
||||
chunk = f'"{chunk}'
|
||||
|
||||
# every second chunk is inside string quotes
|
||||
if j % 2 == 0 and "#" in chunk:
|
||||
# everything from now on is a comment and can be ignored
|
||||
result += chunk.split("#")[0]
|
||||
break
|
||||
else:
|
||||
result += chunk
|
||||
|
||||
if i < macro.count("\n"):
|
||||
result += "\n"
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def clean(code):
|
||||
"""Remove everything irrelevant for the macro."""
|
||||
return Parser.remove_whitespaces(
|
||||
Parser.remove_comments(code),
|
||||
'"',
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def parse(macro: str, context=None, mapping=None, verbose: bool = True) -> Macro:
|
||||
"""Parse and generate a Macro that can be run as often as you want.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
macro
|
||||
"repeat(3, key(a).wait(10))"
|
||||
"repeat(2, key(a).key(KEY_A)).key(b)"
|
||||
"wait(1000).modify(Shift_L, repeat(2, k(a))).wait(10, 20).key(b)"
|
||||
context : Context, or None for use in Frontend
|
||||
mapping
|
||||
the mapping for the macro, or None for use in Frontend
|
||||
verbose
|
||||
log the parsing True by default
|
||||
"""
|
||||
# TODO pass mapping in frontend and do the target check for keys?
|
||||
logger.debug("parsing macro %s", macro.replace("\n", ""))
|
||||
macro = Parser.clean(macro)
|
||||
macro = Parser.handle_plus_syntax(macro)
|
||||
|
||||
macro_obj = Parser._parse_recurse(
|
||||
macro,
|
||||
context,
|
||||
mapping,
|
||||
verbose,
|
||||
).value
|
||||
if not isinstance(macro_obj, Macro):
|
||||
raise MacroError(macro, "The provided code was not a macro")
|
||||
|
||||
return macro_obj
|
||||
Reference in New Issue
Block a user