Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
Upcoming (TBD)
==============

Features
--------
* Add completion on leading-abbreviated pathname elements.


2.21.1 (2026/09/07)
==============

Expand Down
60 changes: 60 additions & 0 deletions mycli/packages/filepaths.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,66 @@ def suggest_path(root_dir: str, *, sql_only: bool = True) -> list[str]:
return list_path(root_dir, sql_only=sql_only)


def suggest_path_by_prefix(root_dir: str, *, sql_only: bool = True) -> list[str]:
"""Complete each slash-separated component of a pathname by prefix."""
drive, path = os.path.splitdrive(root_dir)
if drive and path.startswith('/'):
search_root = f'{drive}{os.sep}'
display_root = f'{drive}/'
path = path[1:]
elif path.startswith('/'):
search_root = os.path.abspath(os.sep)
display_root = '/'
path = path[1:]
elif path.startswith('~/'):
search_root = os.path.expanduser('~')
display_root = '~/'
path = path[2:]
else:
parent_prefix = ''
while path.startswith('../'):
parent_prefix += '../'
path = path[3:]
if parent_prefix:
search_root = parent_prefix.rstrip('/')
display_root = parent_prefix
elif path.startswith('./'):
search_root = os.curdir
display_root = './'
path = path[2:]
else:
search_root = os.curdir
display_root = ''

components = [component for component in path.split('/') if component]
if not components or path.endswith('/'):
components.append('')

locations = [(search_root, display_root)]
for component in components[:-1]:
next_locations = []
for directory, display_prefix in locations:
for name in list_path(directory, sql_only=sql_only):
if name.endswith('/') and name[:-1].startswith(component):
next_locations.append((
os.path.join(directory, name[:-1]),
f'{display_prefix}{name}',
))
locations = next_locations
if not locations:
return []

last_component = components[-1]
files: list[str] = []
dirs: list[str] = []
for directory, display_prefix in locations:
for name in list_path(directory, sql_only=sql_only):
if name.rstrip('/').startswith(last_component):
suggestion = f'{display_prefix}{name}'
(dirs if name.endswith('/') else files).append(suggestion)
return files + dirs


def dir_path_exists(path: str) -> bool:
"""Check if the directory path exists for a given file.

Expand Down
23 changes: 12 additions & 11 deletions mycli/sqlcompleter.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

from mycli.compat import WIN
from mycli.packages.completion_engine import is_inside_quotes, suggest_type
from mycli.packages.filepaths import complete_path, parse_path, suggest_path
from mycli.packages.filepaths import complete_path, parse_path, suggest_path, suggest_path_by_prefix
from mycli.packages.polars_completion import complete_polars_transform
from mycli.packages.ptoolkit.history import frecency_score
from mycli.packages.special import llm
Expand All @@ -40,8 +40,9 @@ class Fuzziness(IntEnum):
PERFECT = 0
REGEX = 1
UNDER_WORDS = 2
CAMEL_CASE = 3
RAPIDFUZZ = 4
SLASH_WORDS = 3
CAMEL_CASE = 4
RAPIDFUZZ = 5


class SQLCompleter(Completer):
Expand Down Expand Up @@ -1455,7 +1456,7 @@ def get_completions(
word_before_cursor = document.get_word_before_cursor(WORD=True)
last_for_len = last_word(word_before_cursor, include="most_punctuations")
text_for_len = last_for_len.lower()
last_for_len_paths = last_word(word_before_cursor, include='alphanum_underscore')
path_for_len = word_before_cursor
frecency = self.frecency_provider() if self.frecency_provider is not None else {}

if smart_completion is None:
Expand Down Expand Up @@ -1762,13 +1763,9 @@ def get_completions(
partial_path = source_filename[1:] if quote else source_filename
if quote and partial_path.endswith(quote):
partial_path = partial_path[:-1]
base_path, _last_path, _position = parse_path(partial_path)
file_names_m = (
(
self._quote_source_path(
os.path.join(base_path, path) if base_path and not path.startswith('~') else path,
quote,
),
self._quote_source_path(path, quote),
fuzziness,
)
for path, fuzziness in self.find_files(partial_path)
Expand Down Expand Up @@ -1865,7 +1862,7 @@ def completion_sort_key(item: tuple[str, int, int], text_for_len: str):
return (
Completion(
x,
-len(last_for_len_paths),
-len(path_for_len),
display=f'{x}{self.indexed_column_suffix}' if x in indexed_column_candidates else None,
display_meta=self.special_command_snippets.get(x) if x in special_command_candidates else None,
style=_INDEXED_COLUMN_STYLE if x in indexed_column_candidates else '',
Expand All @@ -1891,8 +1888,12 @@ def find_files(self, word: str, *, sql_only: bool = True) -> Generator[tuple[str
:return: iterable

"""
if '/' in word:
for path in suggest_path_by_prefix(word, sql_only=sql_only):
yield (path, Fuzziness.SLASH_WORDS)
return

# todo position is ignored, but may need to be used
# todo fuzzy matches for filenames
base_path, last_path, position = parse_path(word)
paths = suggest_path(word, sql_only=sql_only)
for name in paths:
Expand Down
66 changes: 66 additions & 0 deletions test/pytests/test_filepaths.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,72 @@ def test_suggest_path_branches(tmp_path: Path, monkeypatch: pytest.MonkeyPatch)
assert filepaths.suggest_path('nested/') == ['inside.sql', 'child/']


def test_suggest_path_by_prefix_completes_each_component(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
nested = tmp_path / 'directory' / 'subdirectory'
nested.mkdir(parents=True)
(nested / 'example.sql').touch()
(nested / 'example.csv').touch()

assert filepaths.suggest_path_by_prefix('./dir/sub/exa') == [
'./directory/subdirectory/example.sql',
]
assert filepaths.suggest_path_by_prefix('./dir/sub/exa', sql_only=False) == [
'./directory/subdirectory/example.csv',
'./directory/subdirectory/example.sql',
]
assert filepaths.suggest_path_by_prefix('./missing/sub/exa') == []


def test_suggest_path_by_prefix_returns_ambiguous_directories(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
(tmp_path / 'directory').mkdir()
(tmp_path / 'dirt').mkdir()

assert filepaths.suggest_path_by_prefix('./dir') == ['./directory/', './dirt/']


def test_suggest_path_by_prefix_preserves_path_anchors(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
nested = tmp_path / 'directory' / 'subdirectory'
nested.mkdir(parents=True)
(nested / 'example.sql').touch()

absolute_prefix = f'{tmp_path}/dir/sub/exa'
assert filepaths.suggest_path_by_prefix(absolute_prefix) == [
f'{tmp_path}/directory/subdirectory/example.sql',
]

home = tmp_path / 'home'
home_nested = home / 'directory' / 'subdirectory'
home_nested.mkdir(parents=True)
(home_nested / 'example.sql').touch()
monkeypatch.setattr(os.path, 'expanduser', lambda path: str(home) if path == '~' else path)
assert filepaths.suggest_path_by_prefix('~/dir/sub/exa') == [
'~/directory/subdirectory/example.sql',
]

child = tmp_path / 'child'
child.mkdir()
monkeypatch.chdir(child)
assert filepaths.suggest_path_by_prefix('../dir/sub/exa') == [
'../directory/subdirectory/example.sql',
]
assert filepaths.suggest_path_by_prefix('../dir//sub/exa') == [
'../directory/subdirectory/example.sql',
]


def test_suggest_path_by_prefix_preserves_windows_drive(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(os.path, 'splitdrive', lambda path: ('C:', '/dir'))
monkeypatch.setattr(
filepaths,
'list_path',
lambda root_dir, *, sql_only: ['directory/'] if root_dir == f'C:{os.sep}' else [],
)

assert filepaths.suggest_path_by_prefix('C:/dir') == ['C:/directory/']


def test_dir_path_exists(tmp_path: Path) -> None:
existing = tmp_path / 'logs' / 'mycli.log'
existing.parent.mkdir()
Expand Down
51 changes: 45 additions & 6 deletions test/pytests/test_smart_completion_public_schema_only.py
Original file line number Diff line number Diff line change
Expand Up @@ -707,12 +707,12 @@ def test_numbers_no_completion(completer, complete_event):
def dummy_list_path(dir_name, *, sql_only=True):
dirs = {
"/": [
"dir1",
"dir1/",
"file1.sql",
"file2.sql",
],
"/dir1": [
"subdir1",
"subdir1/",
"subfile1.sql",
"subfile2.sql",
],
Expand Down Expand Up @@ -769,9 +769,9 @@ def dummy_list_path(dir_name, *, sql_only=True):
'source --throttle=0.25 ',
[('--special', 0), ('--show', 0), ('--page', 0), ('--help', 0), ('/', 0), ('~', 0), ('.', 0), ('..', 0)],
),
("source /", [("/dir1", -1), ("/file1.sql", -1), ("/file2.sql", -1)]),
('source --special /', [('/dir1', -1), ('/file1.sql', -1), ('/file2.sql', -1)]),
('source --show /', [('/dir1', -1), ('/file1.sql', -1), ('/file2.sql', -1)]),
("source /", [("/file1.sql", -1), ("/file2.sql", -1), ("/dir1/", -1)]),
('source --special /', [('/file1.sql', -1), ('/file2.sql', -1), ('/dir1/', -1)]),
('source --show /', [('/file1.sql', -1), ('/file2.sql', -1), ('/dir1/', -1)]),
(
'source file.sql ',
[('--special', 0), ('--show', 0), ('--page', 0), ('--throttle', 0), ('--help', 0)],
Expand All @@ -783,7 +783,7 @@ def dummy_list_path(dir_name, *, sql_only=True):
('source -- ', [('/', 0), ('~', 0), ('.', 0), ('..', 0)]),
(
"source /dir1/",
[("/dir1/subdir1", -6), ("/dir1/subfile1.sql", -6), ("/dir1/subfile2.sql", -6)],
[("/dir1/subfile1.sql", -6), ("/dir1/subfile2.sql", -6), ("/dir1/subdir1/", -6)],
),
("source /dir1/subdir1/", [("/dir1/subdir1/lastfile.sql", -14)]),
],
Expand Down Expand Up @@ -880,6 +880,45 @@ def test_source_completion_advances_into_nested_directories(completer, complete_
assert result == [Completion(text='doc/nested/query.sql', start_position=-11)]


@pytest.mark.parametrize(
('command', 'expected'),
[
('source', ['./directory/subdirectory/example.sql']),
('/edit', ['./directory/subdirectory/example.sql']),
('/tee', ['./directory/subdirectory/example.csv', './directory/subdirectory/example.sql']),
('/once', ['./directory/subdirectory/example.csv', './directory/subdirectory/example.sql']),
('/o', ['./directory/subdirectory/example.csv', './directory/subdirectory/example.sql']),
],
)
def test_file_commands_complete_slash_separated_prefixes(
completer,
complete_event,
tmp_path,
monkeypatch,
command,
expected,
):
monkeypatch.chdir(tmp_path)
nested = tmp_path / 'directory' / 'subdirectory'
nested.mkdir(parents=True)
(nested / 'example.sql').touch()
(nested / 'example.csv').touch()
if command == 'source':
special.register_special_command(
...,
'source',
'\\. <file>',
'Execute commands from file.',
aliases=[special.SpecialCommandAlias('\\.', case_sensitive=False)],
)

path_prefix = './dir/sub/exa'
text = f'{command} {path_prefix}'
result = list(completer.get_completions(Document(text=text, cursor_position=len(text)), complete_event))

assert result == [Completion(text=candidate, start_position=-len(path_prefix)) for candidate in expected]


@pytest.mark.skipif(os.name == 'nt', reason='POSIX quoting expectations')
def test_source_completion_quotes_paths_with_spaces(completer, complete_event, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
Expand Down
2 changes: 1 addition & 1 deletion test/pytests/test_sqlcompleter.py
Original file line number Diff line number Diff line change
Expand Up @@ -777,7 +777,7 @@ def test_find_files_populate_scoped_cols_and_enum_helpers(monkeypatch) -> None:
)
monkeypatch.setattr(mycli.sqlcompleter, 'complete_path', lambda name, last_path: name if name == 'file.sql' else None)

assert list(completer.find_files('./fi')) == [('file.sql', Fuzziness.PERFECT)]
assert list(completer.find_files('fi')) == [('file.sql', Fuzziness.PERFECT)]
assert completer.populate_scoped_cols([(None, 'select', None), (None, 'orders_view', None), (None, 'missing', None)]) == [
'id',
'view_id',
Expand Down
Loading