diff --git a/README.md b/README.md index 6323b33..0e66071 100644 --- a/README.md +++ b/README.md @@ -14,3 +14,29 @@ $ pip install -r requirements.txt $ source .venv/bin/activate $ python3 src/ts-example ``` + +## TUI usage + +Start the TUI (the required `--file` argument can be the file you intend to +inspect): + +```bash +$ python3 src/ts-example --tui minimal --file path/to/file.py +``` + +At the prompt, load a file and then inspect grammar fields: + +```text +> read_file path/to/file.py +> find_field_values function_definition name +``` + +`find_field_values ` walks every matching node and +prints the named field and its location. Node and field names are +language-grammar-specific. For example, C++ class names can be found with: + +```text +> find_field_values class_specifier name +``` + +Other available commands include `dump_tree` and `get_usages `. diff --git a/src/ts-example/ts_minimal.py b/src/ts-example/ts_minimal.py index bcf5e16..e43a362 100644 --- a/src/ts-example/ts_minimal.py +++ b/src/ts-example/ts_minimal.py @@ -1,15 +1,17 @@ import tempfile from typing import Any -from command_runner import ICommandRunner -from arg_conf import parser as arg_parser -from log_conf import logger -import tree_sitter_python as tspython + import tree_sitter_cpp as tscpp -from tree_sitter import Language, Parser, Tree +import tree_sitter_python as tspython +from arg_conf import parser as arg_parser +from command_runner import ICommandRunner +from log_conf import logger +from tree_sitter import Language, Parser, Query, QueryCursor, Tree PY_LANGUAGE = Language(tspython.language()) CPP_LANGUAGE = Language(tscpp.language()) + def arguments(): global arg_parser subparsers = arg_parser.add_subparsers( @@ -19,31 +21,147 @@ def arguments(): module_parser = subparsers.add_parser("minimal") - module_parser.add_argument("--language", help="Setup parsing language", default="Python") + module_parser.add_argument( + "--language", help="Setup parsing language", default="Python" + ) module_parser.add_argument("--file", "-f", help="File to parse", required=True) logger.debug("Added Tree Sitter Minimal Example Arguments to the list") + class SingleFileParser(ICommandRunner): - def __init__ (self, args): + def __init__(self, args): + self.normalized_language: Language self.tree: Tree self.parser: Parser - if args.language == 'Python': - self.parser = Parser(PY_LANGUAGE) - elif args.language == 'C++': + language = args.language.casefold() + if language in {"c++", "cpp"}: self.parser = Parser(CPP_LANGUAGE) + self.normalized_language = CPP_LANGUAGE + self.language_name = "cpp" else: self.parser = Parser(PY_LANGUAGE) - args.language = 'Python' - logger.info(f"Parser is installed to {args.language}") + self.normalized_language = PY_LANGUAGE + self.language_name = "python" + logger.info(f"Parser is installed to {self.language_name}") + def node_text(self, node, source: bytes) -> str: + return source[node.start_byte : node.end_byte].decode( + "utf-8", + errors="replace", + ) - def get_usages(self): - pass + def final_callee_name(self, callee) -> str: + """Return the last named component of a call expression. + + Selecting this from the syntax tree is more reliable than splitting + source text: template arguments (``obj.foo()``) and Python + subscriptions (``obj.foo[T]()``) may themselves contain ``.`` or + ``::``. + """ + # These are grammar field names used by Python attributes/subscripts + # and C++ field expressions, qualified identifiers, and templates. + for field_name in ("attribute", "field", "name", "value", "argument"): + child = callee.child_by_field_name(field_name) + if child is not None: + return self.final_callee_name(child) + return self.node_text(callee, self.source_code) + + def get_usages(self, function: str): + query_source: str = "" + if self.language_name == "python": + query_source = """ + (call + function: (_) @callee + arguments: (argument_list) @arguments + ) @call + """ + elif self.language_name == "cpp": + query_source = """ + (call_expression + function: (_) @callee + arguments: (argument_list) @arguments + ) @call + """ + + query = Query( + self.normalized_language, + query_source, + ) + + cursor = QueryCursor(query) + found = False + for _, captures in cursor.matches(self.tree.root_node): + call = captures["call"][0] + callee = captures["callee"][0] + arguments = captures["arguments"][0] + + callee_text = self.node_text(callee, self.source_code) + + final_name = self.final_callee_name(callee) + + if function not in {callee_text, final_name}: + continue + + found = True + + logger.info( + "Call found: %s", + { + "call": self.node_text(call, self.source_code), + "callee": callee_text, + "arguments": self.node_text( + arguments, + self.source_code, + ), + "line": call.start_point.row + 1, + "column": call.start_point.column + 1, + }, + ) + + if not found: + logger.info("No usages found for %r", function) + + def find_field_values(self, node_type: str, field_name: str): + """Find the value of ``field_name`` on every node of ``node_type``. + + Unlike a query capture, this demonstrates navigating a grammar field + directly with Node.child_by_field_name(). + """ + found = False + nodes = [self.tree.root_node] + + while nodes: + node = nodes.pop() + nodes.extend(reversed(node.children)) + + if node.type != node_type: + continue + + field_value = node.child_by_field_name(field_name) + if field_value is None: + continue + + found = True + logger.info( + "%s.%s: %s (line %d, column %d)", + node_type, + field_name, + self.node_text(field_value, self.source_code), + field_value.start_point.row + 1, + field_value.start_point.column + 1, + ) + + if not found: + logger.info( + "No %r fields found on %r nodes", + field_name, + node_type, + ) def read_file(self, filename: str): - with open(filename, 'rb') as file: - source_code = file.read() - self.tree = self.parser.parse(source_code) + with open(filename, "rb") as file: + self.source_code = file.read() + self.tree = self.parser.parse(self.source_code) def dump_tree(self): with tempfile.TemporaryFile(mode="w+b", buffering=0) as output: @@ -61,26 +179,34 @@ class SingleFileParser(ICommandRunner): self.read_file(args.file) self.dump_tree() tokens = command.split() - if tokens[0] == 'read_file': + if tokens[0] == "read_file": logger.debug(f"{tokens[0]} called with {tokens[1]}") self.read_file(tokens[1]) - if tokens[0] == 'dump_tree': + if tokens[0] == "dump_tree": self.dump_tree() - + if tokens[0] == "get_usages": + self.get_usages(tokens[1]) + if tokens[0] == "find_field_values": + self.find_field_values(tokens[1], tokens[2]) def get_commands(self) -> dict[str, Any]: - return {"simple_file":{}, "read_file": {}, "dump_tree":{}} + return { + "simple_file": {}, + "read_file": {}, + "dump_tree": {}, + "get_usages": {}, + "find_field_values": {}, + } def get_positional_path_args(self) -> dict[str, set[int]]: - return { - "read_file": {0} - } + return {"read_file": {0}} def get_path_options(self) -> set[str]: return { "read_file", } + def simple_file(): global arg_parser args = arg_parser.parse_args() diff --git a/src/ts-example/tui.py b/src/ts-example/tui.py index 1806913..5b02d57 100644 --- a/src/ts-example/tui.py +++ b/src/ts-example/tui.py @@ -4,6 +4,7 @@ from pathlib import Path from typing import Any from command_runner import ICommandRunner +from log_conf import logger from textual import on from textual.app import App, ComposeResult from textual.geometry import Offset, Region, Spacing @@ -248,7 +249,7 @@ class CommandAutoComplete(AutoCompleteAbove): state = self._get_target_state() if self._is_path_context(state): - text_before_cursor = state.text[:state.cursor_position] + text_before_cursor = state.text[: state.cursor_position] # After "src/", show the contents of src even though # the current path segment is empty. @@ -257,15 +258,11 @@ class CommandAutoComplete(AutoCompleteAbove): return super().should_show_dropdown(search_string) - def post_completion(self) -> None: state = self._get_target_state() - text_before_cursor = state.text[:state.cursor_position] + text_before_cursor = state.text[: state.cursor_position] - if ( - self._is_path_context(state) - and text_before_cursor.endswith(("/", "\\")) - ): + if self._is_path_context(state) and text_before_cursor.endswith(("/", "\\")): # apply_completion suppresses Input.Changed, so explicitly # rebuild candidates for the newly selected directory. search_string = self.get_search_string(state) @@ -318,6 +315,7 @@ class CommandAutoComplete(AutoCompleteAbove): self.target.cursor_position = start + len(new_token) + class LogLine(Message): """A line to display in the TUI output pane.""" @@ -401,35 +399,10 @@ class ConsoleApp(App[None]): self.runner = runner def on_mount(self) -> None: - self._log_handler = RichLogHandler(self) - self._log_handler.setFormatter( - logging.Formatter( - "%(asctime)s %(levelname)s %(message)s", - datefmt="%H:%M:%S", - ) - ) - - # Replace terminal-based logging handlers while the TUI is running. - self._root_logger = logging.getLogger() - self._previous_handlers = self._root_logger.handlers[:] - self._previous_level = self._root_logger.level - - for handler in self._previous_handlers: - self._root_logger.removeHandler(handler) - - self._root_logger.addHandler(self._log_handler) - self._root_logger.setLevel(logging.INFO) - - logging.info("Application started") + logger.info("Application started") def on_unmount(self) -> None: - # Restore the application's previous logging configuration. - self._root_logger.removeHandler(self._log_handler) - - for handler in self._previous_handlers: - self._root_logger.addHandler(handler) - - self._root_logger.setLevel(self._previous_level) + logger.info("on_unmount") @on(LogLine) def display_log_line(self, message: LogLine) -> None: @@ -452,7 +425,7 @@ class ConsoleApp(App[None]): try: self.run_command(command) except Exception: - logging.exception("Command failed") + logger.exception("Command failed") def get_commands(self) -> dict[str, Any]: return {"help": {}, "error": {}, "quit": {}, "exit": {}} @@ -461,11 +434,11 @@ class ConsoleApp(App[None]): """Replace this with your application logic.""" if command == "help": - logging.info("Commands: help, error, quit") + logger.info("Commands: help, error, quit") elif command == "error": raise RuntimeError("Example failure") else: - logging.debug(f"Delegated {command} to runner") + logger.debug(f"Delegated {command} to runner") self.runner.run_command(command)