212 lines
6.3 KiB
Python
Executable File
212 lines
6.3 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
import re
|
|
import sys
|
|
import argparse
|
|
import os
|
|
import stat
|
|
from dataclasses import dataclass
|
|
from typing import List, Union
|
|
|
|
TOKEN_SPECIFICATION = [
|
|
('IMPORT', r'import'),
|
|
('EVAL', r'eval'),
|
|
('OPTION', r'option'),
|
|
('DMENU', r'dmenu'),
|
|
('LPAREN', r'\('),
|
|
('RPAREN', r'\)'),
|
|
('STRING', r'"(?:\\.|[^"\\])*"|\'[^\']*\'|`[^`]*`'),
|
|
('SKIP', r'[\n \t]+'),
|
|
('MISMATCH', r'.'),
|
|
]
|
|
|
|
TOKEN_REGEX = '|'.join(f'(?P<{name}>{pattern})' for name, pattern in TOKEN_SPECIFICATION)
|
|
|
|
|
|
@dataclass
|
|
class Token:
|
|
type: str
|
|
value: str
|
|
pos: int
|
|
|
|
def __repr__(self):
|
|
return f'Token({self.type}, {self.value}, pos={self.pos})'
|
|
|
|
|
|
def tokenize(code):
|
|
tokens = []
|
|
for mo in re.finditer(TOKEN_REGEX, code):
|
|
kind = mo.lastgroup
|
|
value = mo.group()
|
|
pos = mo.start()
|
|
if kind == 'SKIP':
|
|
continue
|
|
elif kind == 'MISMATCH':
|
|
raise RuntimeError(f'Unexpected token {value!r} at position {pos}')
|
|
else:
|
|
tokens.append(Token(kind, value, pos))
|
|
return tokens
|
|
|
|
|
|
def restore_string(string_tkn_val: str):
|
|
if string_tkn_val[0] == '"':
|
|
return string_tkn_val[1:-1].replace("\\\"", "\"").replace("\\\\", "\\")
|
|
else:
|
|
return string_tkn_val[1:-1]
|
|
|
|
|
|
class ActionDmenu:
|
|
...
|
|
|
|
|
|
@dataclass
|
|
class OptionInDmenu:
|
|
label: str
|
|
action: Union[ActionDmenu, str]
|
|
|
|
def __repr__(self):
|
|
if isinstance(self.action, ActionDmenu):
|
|
return f"option {self.label} {self.action}"
|
|
else:
|
|
return f"option {self.label} eval {self.action}"
|
|
|
|
|
|
@dataclass
|
|
class ActionDmenu:
|
|
prompt: str
|
|
options: List[OptionInDmenu]
|
|
|
|
def __repr__(self):
|
|
return f"dmenu {self.prompt} ( {' ; '.join(map(str, self.options))} )"
|
|
|
|
|
|
class Parser:
|
|
def __init__(self, tokens):
|
|
self.tokens = tokens
|
|
self.pos = 0
|
|
|
|
def current(self):
|
|
if self.pos < len(self.tokens):
|
|
return self.tokens[self.pos]
|
|
return None
|
|
|
|
def eat(self, token_type) -> Token:
|
|
token = self.current()
|
|
if token is None:
|
|
raise RuntimeError("Unexpected end of input")
|
|
if token.type == token_type:
|
|
self.pos += 1
|
|
return token
|
|
raise RuntimeError(f"Expected token {token_type} at position {token.pos} but got {token.type}")
|
|
|
|
# Grammar production: <option> ::= option <string> ( eval <string> | <dmenu> )
|
|
def parse_option(self):
|
|
self.eat('OPTION')
|
|
label_tkn = self.eat('STRING')
|
|
label = restore_string(label_tkn.value)
|
|
if '\t' in label or '\n' in label or '\000' in label:
|
|
raise RuntimeError(f"Label of option at {label_tkn.pos} contains illegal character")
|
|
current = self.current()
|
|
if current is None:
|
|
raise RuntimeError("Expected end-of-file after option")
|
|
if current.type == 'EVAL':
|
|
self.eat('EVAL')
|
|
action_command_tkn = self.eat('STRING').value
|
|
action = restore_string(action_command_tkn)
|
|
if '\n' in action or '\000' in action:
|
|
raise RuntimeError(f"Command at {action_command_tkn.pos} contains illegal character")
|
|
elif current.type == 'DMENU':
|
|
action = self.parse_dmenu()
|
|
else:
|
|
raise RuntimeError(f"Unexpected token {current.type} in option at position {current.pos}")
|
|
return OptionInDmenu(label, action)
|
|
|
|
# Grammar production: <dmenu> ::= dmenu <string> \( <option> * \)
|
|
def parse_dmenu(self):
|
|
self.eat('DMENU')
|
|
prompt = restore_string(self.eat('STRING').value)
|
|
self.eat('LPAREN')
|
|
options = []
|
|
while self.current() is not None and self.current().type != 'RPAREN':
|
|
options.append(self.parse_option())
|
|
self.eat('RPAREN')
|
|
return ActionDmenu(prompt, options)
|
|
|
|
def parse_file_as_dmenu(self):
|
|
node = self.parse_dmenu()
|
|
if self.current() is not None:
|
|
raise RuntimeError("Extra tokens after valid dmenu")
|
|
return node
|
|
|
|
|
|
def shell_escape(s: str) -> str:
|
|
return "'" + s.replace("'", "'\\''") + "'"
|
|
|
|
|
|
def write_to_file(path: str, text: str) -> None:
|
|
with open(path, "w", encoding="utf-8") as f:
|
|
f.write(text)
|
|
|
|
def make_file_executable(path: str):
|
|
old = os.stat(path).st_mode
|
|
os.chmod(path, old | stat.S_IXUSR | stat.S_IXGRP)
|
|
|
|
|
|
def generate_tsv_file(dpath: str, cur_tsv_name, options: List[OptionInDmenu]):
|
|
def option_to_line(i: int) -> str:
|
|
opt = options[i]
|
|
if isinstance(opt.action, ActionDmenu):
|
|
child_tsv_name = f"{cur_tsv_name}-{i}"
|
|
generate_tsv_file(dpath, child_tsv_name, opt.action.options)
|
|
return f"{opt.label}\t<{shell_escape(os.path.join(dpath, child_tsv_name))} dmenu " + \
|
|
f"-p {shell_escape(opt.action.prompt)} | tail -n 1 | sh"
|
|
else:
|
|
return f"{opt.label}\t{opt.action}"
|
|
|
|
write_to_file(
|
|
os.path.join(dpath, cur_tsv_name),
|
|
"\n".join(map(option_to_line, range(len(options))))
|
|
)
|
|
|
|
|
|
def compile_dmenuctree(dpath: str, parsed_tree: ActionDmenu):
|
|
print(f"dmenu tabfiles root is {dpath}")
|
|
main_script = os.path.join(dpath, "invoke.sh")
|
|
cur_tsv_name = "tsv"
|
|
invoke_snippet = f"#!/bin/sh\n" + \
|
|
f"<{shell_escape(os.path.join(dpath, cur_tsv_name))} dmenu -p {shell_escape(parsed_tree.prompt)} " + \
|
|
f"| tail -n 1 | sh"
|
|
write_to_file(main_script, invoke_snippet)
|
|
make_file_executable(main_script)
|
|
generate_tsv_file(dpath, cur_tsv_name, parsed_tree.options)
|
|
|
|
|
|
def main():
|
|
parser_arg = argparse.ArgumentParser(description="Parse a file using custom dmenu syntax")
|
|
parser_arg.add_argument("input_file", help="Path to the input file")
|
|
parser_arg.add_argument("dest_dir", help="Destination directory path")
|
|
args = parser_arg.parse_args()
|
|
|
|
if not os.path.exists(args.input_file):
|
|
print("Input file does not exist.", file=sys.stderr)
|
|
sys.exit(1)
|
|
if not os.path.isdir(args.dest_dir):
|
|
os.mkdir(args.dest_dir)
|
|
|
|
with open(args.input_file, 'r') as f:
|
|
code = f.read()
|
|
|
|
try:
|
|
tokens = tokenize(code)
|
|
parsed_tree = Parser(tokens).parse_file_as_dmenu()
|
|
except Exception as e:
|
|
print(f"Error during parsing: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
compile_dmenuctree(os.path.realpath(args.dest_dir), parsed_tree)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|
|
|