diff --git a/.gitignore b/.gitignore index bbdd8dd..de3edd7 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,6 @@ target .venv __pycache__ *.pyc -.nox \ No newline at end of file +.nox +*.g4 +.antlr \ No newline at end of file diff --git a/server/src/common/formatter.py b/server/src/common/formatter.py new file mode 100644 index 0000000..ff9469b --- /dev/null +++ b/server/src/common/formatter.py @@ -0,0 +1,58 @@ +import re + + +def format_tcl(src: str, indent_str=" ") -> str: + """ + Simple Tcl formatter with special handling for: + 1) Single-line 'if {cond} {action}' blocks remain on one line. + 2) Combined closing-and-opening lines like '} else {' dedent then re-indent. + 3) Lines like '} Tag' stay on the same line: '} Tag'. + 4) Standard multi-line blocks for 'if', 'elseif', 'else', '{', '}'. + """ + level = 0 + out_lines = [] + + for raw_line in src.splitlines(): + stripped = raw_line.strip() + + # 1) Single-line 'if {cond} {action}' → no indent change + if re.match(r"^(if|elseif)\s*\{[^}]+\}\s*\{[^}]+\}$", stripped): + out_lines.append(indent_str * level + stripped) + continue + + # 2) Combined '} else {' → dedent, print, then indent + if re.match(r"^\}\s*(elseif|else)\b.*\{$", stripped): + level = max(level - 1, 0) + out_lines.append(indent_str * level + stripped) + level += 1 + continue + + # 3) SPECIAL: closing brace plus tag on same line: '} Tag' + m = re.match(r"^\}\s+(\w+)$", stripped) + if m: + # close one block + level = max(level - 1, 0) + # stay on one line: "} Tag" + out_lines.append(f"{indent_str * level}}} {m.group(1)}") + continue + + # 4) Pure '}' → dedent then print + if stripped == "}": + level = max(level - 1, 0) + out_lines.append(f"{indent_str * level}{stripped}") + continue + + # 5) 'elseif' or 'else' alone → align with matching 'if' + if re.match(r"^(elseif|else)\b(?!.*\{)", stripped): + level = max(level - 1, 0) + out_lines.append(f"{indent_str * level}{stripped}") + continue + + # 6) Default: print at current indent + out_lines.append(f"{indent_str * level}{stripped}") + + # 7) Open a new block on lines ending with '{' + if re.match(r"^(if|elseif)\b.*\{$", stripped) or stripped.endswith("{"): + level += 1 + + return "\n".join(out_lines) diff --git a/server/src/common/tokens.py b/server/src/common/tokens.py new file mode 100644 index 0000000..b2cea98 --- /dev/null +++ b/server/src/common/tokens.py @@ -0,0 +1,81 @@ +# tokens.py +from typing import List +import attrs +import enum +from lark import Tree, Token + +# Legend must match the client +SEMANTIC_TOKEN_TYPES = { + "keyword": 0, + "variable": 2, + "string": 3, +} +SEMANTIC_TOKEN_MODIFIERS = { + "declaration": 1 << 0, +} + + +class TokenModifier(enum.IntFlag): + deprecated = enum.auto() + readonly = enum.auto() + defaultLibrary = enum.auto() + definition = enum.auto() + + +@attrs.define +class TokenData: + line: int + offset: int + text: str + + tok_type: str = "" + tok_modifiers: List[TokenModifier] = attrs.field(factory=list) + + +TokenTypes = ["keyword", "variable", "function", "operator", "parameter", "type"] + + +def collect_semantic_tokens(tree: Tree) -> list[int]: + """ + Walk the parse tree and return a flat LSP semanticTokens/full array: + [line, char, length, tokenType, tokenModifiers, …] + """ + data = [] + for tok in tree.scan_values(lambda v: isinstance(v, Token)): + # 'set' keyword + if tok.type == "SET": + data.append( + [ + tok.line - 1, + tok.column - 1, + len(tok.value), + SEMANTIC_TOKEN_TYPES["keyword"], + 0, + ] + ) + # variable being declared + elif tok.type == "NAME": + data.append( + [ + tok.line - 1, + tok.column - 1, + len(tok.value), + SEMANTIC_TOKEN_TYPES["variable"], + SEMANTIC_TOKEN_MODIFIERS["declaration"], + ] + ) + # string literal + elif tok.type == "STRING": + data.append( + [ + tok.line - 1, + tok.column - 1, + len(tok.value), + SEMANTIC_TOKEN_TYPES["string"], + 0, + ] + ) + + # sort by position and flatten + data.sort(key=lambda x: (x[0], x[1])) + return [p for token in data for p in token] diff --git a/server/src/lsp_server.py b/server/src/lsp_server.py index 288306d..c49b3ea 100644 --- a/server/src/lsp_server.py +++ b/server/src/lsp_server.py @@ -13,7 +13,6 @@ import sys import sysconfig import traceback from typing import Any, Optional, Sequence -from parser.parser import parse_tcl, format_tree # ********************************************************** @@ -43,6 +42,8 @@ import lsp_utils as utils import lsprotocol.types as lsp from pygls import server, uris, workspace from common.load_data import standard_items +from common.formatter import format_tcl + WORKSPACE_SETTINGS = {} GLOBAL_SETTINGS = {} @@ -105,71 +106,11 @@ def on_completion(params: lsp.CompletionParams) -> list[lsp.CompletionItem]: return lsp.CompletionList(is_incomplete=False, items=items) -# TODO: If your linter outputs in a known format like JSON, then parse -# accordingly. But incase you need to parse the output using RegEx here -# is a helper you can work with. -# flake8 example: -# If you use following format argument with flake8 you can use the regex below to parse it. -# TOOL_ARGS += ["--format='%(row)d,%(col)d,%(code).1s,%(code)s:%(text)s'"] -# DIAGNOSTIC_RE = -# r"(?P\d+),(?P-?\d+),(?P\w+),(?P\w+\d+):(?P[^\r\n]*)" -DIAGNOSTIC_RE = re.compile(r"") - - -def _parse_output_using_regex(content: str) -> list[lsp.Diagnostic]: - lines: list[str] = content.splitlines() - diagnostics: list[lsp.Diagnostic] = [] - - # TODO: Determine if your linter reports line numbers starting at 1 (True) or 0 (False). - line_at_1 = True - # TODO: Determine if your linter reports column numbers starting at 1 (True) or 0 (False). - column_at_1 = True - - line_offset = 1 if line_at_1 else 0 - col_offset = 1 if column_at_1 else 0 - for line in lines: - if line.startswith("'") and line.endswith("'"): - line = line[1:-1] - match = DIAGNOSTIC_RE.match(line) - if match: - data = match.groupdict() - position = lsp.Position( - line=max([int(data["line"]) - line_offset, 0]), - character=int(data["column"]) - col_offset, - ) - diagnostic = lsp.Diagnostic( - range=lsp.Range( - start=position, - end=position, - ), - message=data.get("message"), - severity=_get_severity(data["code"], data["type"]), - code=data["code"], - source=TOOL_MODULE, - ) - diagnostics.append(diagnostic) - - return diagnostics - - -# TODO: if you want to handle setting specific severity for your linter -# in a user configurable way, then look at look at how it is implemented -# for `pylint` extension from our team. -# Pylint: https://github.com/microsoft/vscode-pylint -# Follow the flow of severity from the settings in package.json to the server. -def _get_severity(*_codes: list[str]) -> lsp.DiagnosticSeverity: - # TODO: All reported issues from linter are treated as warning. - # change it as appropriate for your linter. - return lsp.DiagnosticSeverity.Warning - - # ********************************************************** # Linting features end here # ********************************************************** -# TODO: If your tool is a formatter then update this section. -# Delete "Formatting features" section if your tool is NOT a -# formatter. + # ********************************************************** # Formatting features start here # ********************************************************** @@ -177,74 +118,31 @@ def _get_severity(*_codes: list[str]) -> lsp.DiagnosticSeverity: # Black: https://github.com/microsoft/vscode-black-formatter/blob/main/bundled/tool -@LSP_SERVER.feature(lsp.TEXT_DOCUMENT_FORMATTING) -def formatting(params: lsp.DocumentFormattingParams) -> list[lsp.TextEdit] | None: - """LSP handler for textDocument/formatting request.""" - text = LSP_SERVER.workspace.get_text_document(params.text_document.uri) - tree, _ = parse_tcl(text) - if not tree: - return [] - new_text = format_tree(tree) - return [ - { - "range": { - "start": {"line": 0, "character": 0}, - "end": {"line": len(text.splitlines()), "character": 0}, - }, - "newText": new_text, - } - ] - - -def _formatting_helper(document: workspace.Document) -> list[lsp.TextEdit] | None: - # TODO: For formatting on save support the formatter you use must support - # formatting via stdin. - # Read, and update_run_tool_on_document and _run_tool functions as needed - # for your formatter. - result = _run_tool_on_document(document, use_stdin=True) - if result.stdout: - new_source = _match_line_endings(document, result.stdout) - return [ - lsp.TextEdit( - range=lsp.Range( - start=lsp.Position(line=0, character=0), - end=lsp.Position(line=len(document.lines), character=0), - ), - new_text=new_source, - ) - ] - return None - - -def _get_line_endings(lines: list[str]) -> str: - """Returns line endings used in the text.""" - try: - if lines[0][-2:] == "\r\n": - return "\r\n" - return "\n" - except Exception: # pylint: disable=broad-except - return None - - -def _match_line_endings(document: workspace.Document, text: str) -> str: - """Ensures that the edited text line endings matches the document line endings.""" - expected = _get_line_endings(document.source.splitlines(keepends=True)) - actual = _get_line_endings(text.splitlines(keepends=True)) - if actual == expected or actual is None or expected is None: - return text - return text.replace(actual, expected) - - # ********************************************************** # Formatting features ends here # ********************************************************** +@LSP_SERVER.feature(lsp.TEXT_DOCUMENT_FORMATTING) +def formatting(params: lsp.DocumentFormattingParams) -> list[lsp.TextEdit] | None: + """LSP handler for textDocument/formatting request.""" + doc = LSP_SERVER.workspace.get_text_document(params.text_document.uri) + text = doc.source + + formatted = format_tcl(text) + + last_line = len(text.splitlines()) + full_range = lsp.Range( + start=lsp.Position(line=0, character=0), + end=lsp.Position(line=last_line, character=0), + ) + edit = lsp.TextEdit(range=full_range, new_text=formatted) + return [edit] # ********************************************************** # Required Language Server Initialization and Exit handlers. # ********************************************************** @LSP_SERVER.feature(lsp.INITIALIZE) -def initialize(params: lsp.InitializeParams) -> None: +def initialize(params: lsp.InitializeParams) -> lsp.InitializeResult: """LSP handler for initialize request.""" log_to_output(f"CWD Server: {os.getcwd()}") @@ -261,6 +159,11 @@ def initialize(params: lsp.InitializeParams) -> None: log_to_output( f"Global settings:\r\n{json.dumps(GLOBAL_SETTINGS, indent=4, ensure_ascii=False)}\r\n" ) + return lsp.InitializeResult( + capabilities=lsp.ServerCapabilities( + document_formatting_provider=True, + ) + ) @LSP_SERVER.feature(lsp.EXIT) @@ -350,213 +253,6 @@ def _get_settings_by_document(document: workspace.Document | None): return WORKSPACE_SETTINGS[str(key)] -# ***************************************************** -# Internal execution APIs. -# ***************************************************** -def _run_tool_on_document( - document: workspace.Document, - use_stdin: bool = False, - extra_args: Optional[Sequence[str]] = None, -) -> utils.RunResult | None: - """Runs tool on the given document. - - if use_stdin is true then contents of the document is passed to the - tool via stdin. - """ - if extra_args is None: - extra_args = [] - if str(document.uri).startswith("vscode-notebook-cell"): - # TODO: Decide on if you want to skip notebook cells. - # Skip notebook cells - return None - - if utils.is_stdlib_file(document.path): - # TODO: Decide on if you want to skip standard library files. - # Skip standard library python files. - return None - - # deep copy here to prevent accidentally updating global settings. - settings = copy.deepcopy(_get_settings_by_document(document)) - - code_workspace = settings["workspaceFS"] - cwd = settings["cwd"] - - use_path = False - use_rpc = False - if settings["path"]: - # 'path' setting takes priority over everything. - use_path = True - argv = settings["path"] - elif settings["interpreter"] and not utils.is_current_interpreter( - settings["interpreter"][0] - ): - # If there is a different interpreter set use JSON-RPC to the subprocess - # running under that interpreter. - argv = [TOOL_MODULE] - use_rpc = True - else: - # if the interpreter is same as the interpreter running this - # process then run as module. - argv = [TOOL_MODULE] - - argv += TOOL_ARGS + settings["args"] + extra_args - - if use_stdin: - # TODO: update these to pass the appropriate arguments to provide document contents - # to tool via stdin. - # For example, for pylint args for stdin looks like this: - # pylint --from-stdin - # Here `--from-stdin` path is used by pylint to make decisions on the file contents - # that are being processed. Like, applying exclusion rules. - # It should look like this when you pass it: - # argv += ["--from-stdin", document.path] - # Read up on how your tool handles contents via stdin. If stdin is not supported use - # set use_stdin to False, or provide path, what ever is appropriate for your tool. - argv += [] - else: - argv += [document.path] - - if use_path: - # This mode is used when running executables. - log_to_output(" ".join(argv)) - log_to_output(f"CWD Server: {cwd}") - result = utils.run_path( - argv=argv, - use_stdin=use_stdin, - cwd=cwd, - source=document.source.replace("\r\n", "\n"), - ) - if result.stderr: - log_to_output(result.stderr) - elif use_rpc: - # This mode is used if the interpreter running this server is different from - # the interpreter used for running this server. - log_to_output(" ".join(settings["interpreter"] + ["-m"] + argv)) - log_to_output(f"CWD Linter: {cwd}") - - result = jsonrpc.run_over_json_rpc( - workspace=code_workspace, - interpreter=settings["interpreter"], - module=TOOL_MODULE, - argv=argv, - use_stdin=use_stdin, - cwd=cwd, - source=document.source, - ) - if result.exception: - log_error(result.exception) - result = utils.RunResult(result.stdout, result.stderr) - elif result.stderr: - log_to_output(result.stderr) - else: - # In this mode the tool is run as a module in the same process as the language server. - log_to_output(" ".join([sys.executable, "-m"] + argv)) - log_to_output(f"CWD Linter: {cwd}") - # This is needed to preserve sys.path, in cases where the tool modifies - # sys.path and that might not work for this scenario next time around. - with utils.substitute_attr(sys, "path", sys.path[:]): - try: - # TODO: `utils.run_module` is equivalent to running `python -m `. - # If your tool supports a programmatic API then replace the function below - # with code for your tool. You can also use `utils.run_api` helper, which - # handles changing working directories, managing io streams, etc. - # Also update `_run_tool` function and `utils.run_module` in `lsp_runner.py`. - result = utils.run_module( - module=TOOL_MODULE, - argv=argv, - use_stdin=use_stdin, - cwd=cwd, - source=document.source, - ) - except Exception: - log_error(traceback.format_exc(chain=True)) - raise - if result.stderr: - log_to_output(result.stderr) - - log_to_output(f"{document.uri} :\r\n{result.stdout}") - return result - - -def _run_tool(extra_args: Sequence[str]) -> utils.RunResult: - """Runs tool.""" - # deep copy here to prevent accidentally updating global settings. - settings = copy.deepcopy(_get_settings_by_document(None)) - - code_workspace = settings["workspaceFS"] - cwd = settings["workspaceFS"] - - use_path = False - use_rpc = False - if len(settings["path"]) > 0: - # 'path' setting takes priority over everything. - use_path = True - argv = settings["path"] - elif len(settings["interpreter"]) > 0 and not utils.is_current_interpreter( - settings["interpreter"][0] - ): - # If there is a different interpreter set use JSON-RPC to the subprocess - # running under that interpreter. - argv = [TOOL_MODULE] - use_rpc = True - else: - # if the interpreter is same as the interpreter running this - # process then run as module. - argv = [TOOL_MODULE] - - argv += extra_args - - if use_path: - # This mode is used when running executables. - log_to_output(" ".join(argv)) - log_to_output(f"CWD Server: {cwd}") - result = utils.run_path(argv=argv, use_stdin=True, cwd=cwd) - if result.stderr: - log_to_output(result.stderr) - elif use_rpc: - # This mode is used if the interpreter running this server is different from - # the interpreter used for running this server. - log_to_output(" ".join(settings["interpreter"] + ["-m"] + argv)) - log_to_output(f"CWD Linter: {cwd}") - result = jsonrpc.run_over_json_rpc( - workspace=code_workspace, - interpreter=settings["interpreter"], - module=TOOL_MODULE, - argv=argv, - use_stdin=True, - cwd=cwd, - ) - if result.exception: - log_error(result.exception) - result = utils.RunResult(result.stdout, result.stderr) - elif result.stderr: - log_to_output(result.stderr) - else: - # In this mode the tool is run as a module in the same process as the language server. - log_to_output(" ".join([sys.executable, "-m"] + argv)) - log_to_output(f"CWD Linter: {cwd}") - # This is needed to preserve sys.path, in cases where the tool modifies - # sys.path and that might not work for this scenario next time around. - with utils.substitute_attr(sys, "path", sys.path[:]): - try: - # TODO: `utils.run_module` is equivalent to running `python -m `. - # If your tool supports a programmatic API then replace the function below - # with code for your tool. You can also use `utils.run_api` helper, which - # handles changing working directories, managing io streams, etc. - # Also update `_run_tool_on_document` function and `utils.run_module` in `lsp_runner.py`. - result = utils.run_module( - module=TOOL_MODULE, argv=argv, use_stdin=True, cwd=cwd - ) - except Exception: - log_error(traceback.format_exc(chain=True)) - raise - if result.stderr: - log_to_output(result.stderr) - - log_to_output(f"\r\n{result.stdout}\r\n") - return result - - # ***************************************************** # Logging and notification. # ***************************************************** diff --git a/server/src/parser/parser.py b/server/src/parser/parser.py index 0481917..4442313 100644 --- a/server/src/parser/parser.py +++ b/server/src/parser/parser.py @@ -1,39 +1,28 @@ from pathlib import Path -from lark import Lark, Tree, Token, UnexpectedInput +from lark import Lark -tcl_parser = Lark.open( - Path(__file__).parent.joinpath("tcl.lark"), parser="lalr", propagate_positions=True -) +# Grammar einlesen +with open(Path(__file__).parent.joinpath("tcl.lark"), encoding="utf-8") as f: + grammar = f.read() + +# LALR-Parser instanziieren +parser = Lark(grammar, parser="lalr", propagate_positions=True) -def parse_tcl(source: str): - try: - tree = tcl_parser.parse(source) - return tree, [] - except UnexpectedInput as e: - # e.line, e.column enthalten Position - diagnostic = { - "range": { - "start": {"line": e.line - 1, "character": e.column - 1}, - "end": {"line": e.line - 1, "character": e.column}, - }, - "message": f"Syntaxfehler: {e}", - "severity": 1, # Error - } - return None, [diagnostic] +def parse(source: str): + return parser.parse(source) -def format_tree(tree): - def fmt(node, indent=0): - if isinstance(node, Token): - return node.value - elif isinstance(node, Tree): - if node.data == "cmd": - parts = [] - for child in node.children: - parts.append(fmt(child, indent)) - return " ".join(parts) + ";\n" - # weitere Node-Typen … - return "" - - return "".join(fmt(tree)) +if __name__ == "__main__": + samples = [ + "set x 42", + "set myArray(1) 123", + "set myArray2(1,2,3,$string) 123", + "proc myProc {} {}", + "proc myProc {arg} {}", + "proc myProc {arg arg2} {}", + "proc myProc {arg {arg2 test}} {}", + ] + for s in samples: + tree = parse(s) + print(tree.pretty()) diff --git a/server/src/parser/tcl.lark b/server/src/parser/tcl.lark index 83734cd..0097c91 100644 --- a/server/src/parser/tcl.lark +++ b/server/src/parser/tcl.lark @@ -1,250 +1,61 @@ -// tcl.lark +// Lark grammar for Tcl scripts with proc definitions and optional args + +%import common.CNAME -> IDENTIFIER +%import common.INT -> INT +%import common.FLOAT -> FLOAT +%import common.ESCAPED_STRING -> STRING_LITERAL +%import common.WS_INLINE -> WS_INLINE +%import common.NEWLINE -> NEWLINE + +// Ignore only spaces, tabs; treat newlines as separators +%ignore WS_INLINE +// Tcl comments +%ignore COMMENT start: script script: statement* -statement: function_declaration - | top_level +statement: command (";" | NEWLINE)? -// --- Function declarations (zero or more) --- -function_declaration: ("proc" IDENTIFIER "{" function_args "}" "{" function_body "}")* +command: set_command + | proc_command + | r_break + | r_continue -// --- Function arguments (0..n blocks) --- -function_args: ("{" IDENTIFIER "}")* +proc_command: "proc" IDENTIFIER "{" argument_list "}" body_block -// --- Function body: zero or more statements --- -function_body: (assignment_statement - | print_statement - | input_statement ";" - | if_statement - | for_statement - | while_statement - | switch_statement - | return_statement - | grouping ";" - )* +argument_list: [argument (argument)*] -// --- If / elseif / else in functions --- -if_statement: if_header function_body "}" elseif_clause +argument: IDENTIFIER -> required_arg + | "{" IDENTIFIER (value)? "}" -> optional_arg -elseif_clause: elseif_header function_body "}" elseif_clause - | else_clause -else_clause: else_header function_body "}" - | /* empty */ -// --- Switch in functions --- -switch_statement: switch_header case_clause "}" - -case_clause: case_header function_body "}" case_clauses - -case_clauses: case_header function_body "}" case_clauses - | default_clause - -default_clause: default_header function_body "}" - | /* empty */ - -// --- For / While in functions --- -for_statement: for_header loop_body_in_function "}" -while_statement: while_header loop_body_in_function "}" - -loop_body_in_function: (break_statement - | continue_statement - | assignment_statement - | input_statement ";" - | print_statement - | return_statement - | if_loop_statement - | switch_loop_statement - | for_statement - | while_statement - | grouping ";" - )* - -if_loop_statement: if_header loop_body_in_function "}" elseif_loop_clause - -elseif_loop_clause: elseif_header loop_body_in_function "}" elseif_loop_clause - | else_loop_clause - -else_loop_clause: else_header loop_body_in_function "}" - | /* empty */ - -switch_loop_statement: switch_header case_loop_clause "}" - -case_loop_clause: case_header loop_body_in_function "}" case_loop_clauses - -case_loop_clauses: case_header loop_body_in_function "}" case_loop_clauses - | default_loop_clause - -default_loop_clause: default_header loop_body_in_function "}" - | /* empty */ - -// --- Top-level (module) statements (zero or more) --- -top_level: (assignment_statement - | for_main - | input_statement ";" - | if_main - | print_statement - | while_main - | switch_main - | grouping ";" - )* - -// --- If / Else at top level --- -if_main: if_header main_body "}" elseif_main_clause - -elseif_main_clause: elseif_header main_body "}" elseif_main_clause - | else_main_clause - -else_main_clause: else_header main_body "}" - | /* empty */ - -main_body: (assignment_statement - | for_main - | input_statement ";" - | if_main - | print_statement - | while_main - | switch_main - | grouping ";" - )* - -// --- Switch at top level --- -switch_main: switch_header main_case_clause "}" - -main_case_clause: case_header main_body "}" main_case_clauses - -main_case_clauses: case_header main_body "}" main_case_clauses - | default_main_clause - -default_main_clause: default_header main_body "}" - | /* empty */ - -// --- For / While at top level --- -for_main: for_header main_loop_body "}" -while_main: while_header main_loop_body "}" - -main_loop_body: (assignment_statement - | for_main - | input_statement ";" - | if_loop_main - | print_statement - | while_main - | switch_loop_main - | break_statement - | continue_statement - | grouping ";" - )* - -if_loop_main: if_header main_loop_body "}" elseif_loop_main - -elseif_loop_main: elseif_header main_loop_body "}" elseif_loop_main - | else_loop_main - -else_loop_main: else_header main_loop_body "}" - | /* empty */ - -switch_loop_main: switch_header case_loop_main "}" - -case_loop_main: case_header main_loop_body "}" case_loop_main_clauses - -case_loop_main_clauses: case_header main_loop_body "}" case_loop_main_clauses - | default_loop_main - -default_loop_main: default_header main_loop_body "}" - | /* empty */ - -// --- Primitive statements --- -print_statement: "puts" assignment ";" -input_statement: "gets" "stdin" -assignment_statement: "set" IDENTIFIER index access_value ";" - -grouping: "[" grouping_aux "]" -grouping_aux: expression - | IDENTIFIER function_param - | input_statement - | "array" array_aux - -array_aux: "size" IDENTIFIER - | "exists" IDENTIFIER - -function_param: "{" param_aux "}" -param_aux: assignment_value - | expression - -assignment_value: value - | "$" IDENTIFIER index - | grouping - -index: "(" index_value ")" -index_value: value - | grouping - | "$" IDENTIFIER index - -value: STRING_LITERAL - | INT +value: STRING_LITERAL | FLOAT + | INT + | IDENTIFIER + | list -increment: INT? +list: LBRACE [value (WS_INLINE value)*] RBRACE -break_statement: "break" ";" -continue_statement: "continue" ";" -return_statement: "return" return_value ";" -return_value: assignment_value? +body_block: LBRACE body_content RBRACE -> body +body_content: ( /[^{}]+/ | body_block )* // allow nested braces -expr_command: "expr" "{" expression "}" -for_init: INT - | "$" IDENTIFIER index - | expr_command +set_command: "set" IDENTIFIER index? value +// Index: parentheses with comma-separated items +index: "(" index_item ("," index_item)* ")" +index_item: INT -> index_int + | STRING_VAR -> index_string + | "$" IDENTIFIER index? -> nested_index -// --- Headers for control structures --- -if_header: "if" "{" expression "}" "then" "{" -elseif_header: "elseif" "{" expression "}" "then" "{" -else_header: "else" "{" -switch_header: "switch" "$" IDENTIFIER index "{" -case_header: "case" INT "{" -default_header: "default" "{" -for_header: "for" "{" for_declaration "}" "{" expression "}" "{" "incr" IDENTIFIER increment "}" "{" -for_declaration: "set" IDENTIFIER for_init -while_header: "while" "{" expression "}" "{" -// --- Expression grammar --- -expression: or_expression -or_expression: or_expression "||" and_expression - | and_expression -and_expression: and_expression "&&" equality_expression - | equality_expression -equality_expression: equality_expression eq_ops relational_expression - | relational_expression -relational_expression: relational_expression rel_ops additive_expression - | additive_expression -additive_expression: additive_expression add_ops multiplicative_expression - | multiplicative_expression -multiplicative_expression: multiplicative_expression mul_ops power_expression - | power_expression -power_expression: power_expression "**" unary_expression - | unary_expression -unary_expression: unary_ops unary_expression - | term -term: "$" IDENTIFIER index - | grouping - | value - | "(" or_expression ")" +r_break: "break" +r_continue: "continue" -eq_ops: "eq" | "==" | "ne" | "!=" -rel_ops: ">" | "<" | ">=" | "<=" -add_ops: "+" | "-" -mul_ops: "*" | "/" | "%" -unary_ops: "-" | "!" +LBRACE: "{" +RBRACE: "}" -// --- Terminals --- -IDENTIFIER: /[a-zA-Z_][a-zA-Z0-9_]*/ -INT: /-?[0-9]+/ -FLOAT: /[0-9]+\.[0-9]+/ -STRING_LITERAL: /"([^"\\]|\\.)*"/ +STRING_VAR: /(?:[^"\\]|\\.)+/ COMMENT: /#[^\r\n]*/ - -%ignore /[ \t]+/ // spaces & tabs -%ignore COMMENT -%ignore _NL \ No newline at end of file