[tui][tsmin] added value search & fixed get_usages

:Release Notes:
- fixed get_usages bug. query awaited python, why value was installed to
  Python
- added new method in minimal single file example, to search every value
  installed to some field
- in TUI removed redundant input handler

:Detailed Notes:
-

:Testing Performed:
- manual testing

:QA Notes:
-

:Issues Addressed:
-
This commit is contained in:
Artur Mukhamadiev 2026-07-12 22:02:50 +03:00
parent 5088ccb03c
commit 00ddf1ee33
3 changed files with 186 additions and 61 deletions

View File

@ -14,3 +14,29 @@ $ pip install -r requirements.txt
$ source .venv/bin/activate $ source .venv/bin/activate
$ python3 src/ts-example $ 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 <node_type> <field_name>` 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 <function>`.

View File

@ -1,15 +1,17 @@
import tempfile import tempfile
from typing import Any 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 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()) PY_LANGUAGE = Language(tspython.language())
CPP_LANGUAGE = Language(tscpp.language()) CPP_LANGUAGE = Language(tscpp.language())
def arguments(): def arguments():
global arg_parser global arg_parser
subparsers = arg_parser.add_subparsers( subparsers = arg_parser.add_subparsers(
@ -19,31 +21,147 @@ def arguments():
module_parser = subparsers.add_parser("minimal") 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) module_parser.add_argument("--file", "-f", help="File to parse", required=True)
logger.debug("Added Tree Sitter Minimal Example Arguments to the list") logger.debug("Added Tree Sitter Minimal Example Arguments to the list")
class SingleFileParser(ICommandRunner): class SingleFileParser(ICommandRunner):
def __init__ (self, args): def __init__(self, args):
self.normalized_language: Language
self.tree: Tree self.tree: Tree
self.parser: Parser self.parser: Parser
if args.language == 'Python': language = args.language.casefold()
self.parser = Parser(PY_LANGUAGE) if language in {"c++", "cpp"}:
elif args.language == 'C++':
self.parser = Parser(CPP_LANGUAGE) self.parser = Parser(CPP_LANGUAGE)
self.normalized_language = CPP_LANGUAGE
self.language_name = "cpp"
else: else:
self.parser = Parser(PY_LANGUAGE) self.parser = Parser(PY_LANGUAGE)
args.language = 'Python' self.normalized_language = PY_LANGUAGE
logger.info(f"Parser is installed to {args.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): def final_callee_name(self, callee) -> str:
pass """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<int>()``) 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): def read_file(self, filename: str):
with open(filename, 'rb') as file: with open(filename, "rb") as file:
source_code = file.read() self.source_code = file.read()
self.tree = self.parser.parse(source_code) self.tree = self.parser.parse(self.source_code)
def dump_tree(self): def dump_tree(self):
with tempfile.TemporaryFile(mode="w+b", buffering=0) as output: with tempfile.TemporaryFile(mode="w+b", buffering=0) as output:
@ -61,26 +179,34 @@ class SingleFileParser(ICommandRunner):
self.read_file(args.file) self.read_file(args.file)
self.dump_tree() self.dump_tree()
tokens = command.split() tokens = command.split()
if tokens[0] == 'read_file': if tokens[0] == "read_file":
logger.debug(f"{tokens[0]} called with {tokens[1]}") logger.debug(f"{tokens[0]} called with {tokens[1]}")
self.read_file(tokens[1]) self.read_file(tokens[1])
if tokens[0] == 'dump_tree': if tokens[0] == "dump_tree":
self.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]: 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]]: def get_positional_path_args(self) -> dict[str, set[int]]:
return { return {"read_file": {0}}
"read_file": {0}
}
def get_path_options(self) -> set[str]: def get_path_options(self) -> set[str]:
return { return {
"read_file", "read_file",
} }
def simple_file(): def simple_file():
global arg_parser global arg_parser
args = arg_parser.parse_args() args = arg_parser.parse_args()

View File

@ -4,6 +4,7 @@ from pathlib import Path
from typing import Any from typing import Any
from command_runner import ICommandRunner from command_runner import ICommandRunner
from log_conf import logger
from textual import on from textual import on
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.geometry import Offset, Region, Spacing from textual.geometry import Offset, Region, Spacing
@ -248,7 +249,7 @@ class CommandAutoComplete(AutoCompleteAbove):
state = self._get_target_state() state = self._get_target_state()
if self._is_path_context(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 # After "src/", show the contents of src even though
# the current path segment is empty. # the current path segment is empty.
@ -257,15 +258,11 @@ class CommandAutoComplete(AutoCompleteAbove):
return super().should_show_dropdown(search_string) return super().should_show_dropdown(search_string)
def post_completion(self) -> None: def post_completion(self) -> None:
state = self._get_target_state() state = self._get_target_state()
text_before_cursor = state.text[:state.cursor_position] text_before_cursor = state.text[: state.cursor_position]
if ( if self._is_path_context(state) and text_before_cursor.endswith(("/", "\\")):
self._is_path_context(state)
and text_before_cursor.endswith(("/", "\\"))
):
# apply_completion suppresses Input.Changed, so explicitly # apply_completion suppresses Input.Changed, so explicitly
# rebuild candidates for the newly selected directory. # rebuild candidates for the newly selected directory.
search_string = self.get_search_string(state) search_string = self.get_search_string(state)
@ -318,6 +315,7 @@ class CommandAutoComplete(AutoCompleteAbove):
self.target.cursor_position = start + len(new_token) self.target.cursor_position = start + len(new_token)
class LogLine(Message): class LogLine(Message):
"""A line to display in the TUI output pane.""" """A line to display in the TUI output pane."""
@ -401,35 +399,10 @@ class ConsoleApp(App[None]):
self.runner = runner self.runner = runner
def on_mount(self) -> None: def on_mount(self) -> None:
self._log_handler = RichLogHandler(self) logger.info("Application started")
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")
def on_unmount(self) -> None: def on_unmount(self) -> None:
# Restore the application's previous logging configuration. logger.info("on_unmount")
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)
@on(LogLine) @on(LogLine)
def display_log_line(self, message: LogLine) -> None: def display_log_line(self, message: LogLine) -> None:
@ -452,7 +425,7 @@ class ConsoleApp(App[None]):
try: try:
self.run_command(command) self.run_command(command)
except Exception: except Exception:
logging.exception("Command failed") logger.exception("Command failed")
def get_commands(self) -> dict[str, Any]: def get_commands(self) -> dict[str, Any]:
return {"help": {}, "error": {}, "quit": {}, "exit": {}} return {"help": {}, "error": {}, "quit": {}, "exit": {}}
@ -461,11 +434,11 @@ class ConsoleApp(App[None]):
"""Replace this with your application logic.""" """Replace this with your application logic."""
if command == "help": if command == "help":
logging.info("Commands: help, error, quit") logger.info("Commands: help, error, quit")
elif command == "error": elif command == "error":
raise RuntimeError("Example failure") raise RuntimeError("Example failure")
else: else:
logging.debug(f"Delegated {command} to runner") logger.debug(f"Delegated {command} to runner")
self.runner.run_command(command) self.runner.run_command(command)