[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
$ 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
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):
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<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):
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()

View File

@ -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
@ -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]
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)