diff --git a/changelog.md b/changelog.md index 765fa085..ef605033 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,11 @@ +Upcoming (TBD) +============== + +Features +-------- +* Add completion on leading-abbreviated pathname elements. + + 2.21.1 (2026/09/07) ============== diff --git a/mycli/packages/filepaths.py b/mycli/packages/filepaths.py index 60753a1b..c81adc30 100644 --- a/mycli/packages/filepaths.py +++ b/mycli/packages/filepaths.py @@ -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. diff --git a/mycli/sqlcompleter.py b/mycli/sqlcompleter.py index 21588feb..a039209e 100644 --- a/mycli/sqlcompleter.py +++ b/mycli/sqlcompleter.py @@ -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 @@ -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): @@ -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: @@ -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) @@ -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 '', @@ -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: diff --git a/test/pytests/test_filepaths.py b/test/pytests/test_filepaths.py index a96ea84d..272d04eb 100644 --- a/test/pytests/test_filepaths.py +++ b/test/pytests/test_filepaths.py @@ -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() diff --git a/test/pytests/test_smart_completion_public_schema_only.py b/test/pytests/test_smart_completion_public_schema_only.py index 1edc43a3..ce44f8c7 100644 --- a/test/pytests/test_smart_completion_public_schema_only.py +++ b/test/pytests/test_smart_completion_public_schema_only.py @@ -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", ], @@ -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)], @@ -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)]), ], @@ -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', + '\\. ', + '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) diff --git a/test/pytests/test_sqlcompleter.py b/test/pytests/test_sqlcompleter.py index e487d144..1aa5e5ff 100644 --- a/test/pytests/test_sqlcompleter.py +++ b/test/pytests/test_sqlcompleter.py @@ -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',