Skip to content

Commit 69e4a35

Browse files
authored
Merge pull request #309 from Maxteabag/feat/issue-278-query-format
Add query formatting from the leader menu
2 parents 4ab8bf4 + 90d3fb5 commit 69e4a35

12 files changed

Lines changed: 348 additions & 3 deletions

File tree

‎config/keymap.template.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@
245245
"cancel_operation": "z",
246246
"change_theme": "t",
247247
"edit_query_in_editor": "o",
248+
"format_query": "p",
248249
"show_help": "h",
249250
"telescope": "space",
250251
"telescope_filter": "/"

‎sqlit/core/keymap.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,13 @@ def _build_leader_commands(self) -> list[LeaderCommandDef]:
203203
"Open in editor",
204204
"Actions",
205205
),
206+
LeaderCommandDef(
207+
"p",
208+
"format_query",
209+
"Format Query",
210+
"Actions",
211+
guard="query_focused",
212+
),
206213
LeaderCommandDef("h", "show_help", "Help", "Actions"),
207214
LeaderCommandDef("k", "show_keybinding_editor", "Edit Keybindings", "Actions"),
208215
LeaderCommandDef("space", "telescope", "Telescope", "Actions"),
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""SQL formatting helpers for the query editor."""
2+
3+
from __future__ import annotations
4+
5+
import sqlparse
6+
7+
from sqlit.domains.query.app.multi_statement import split_statements
8+
9+
10+
def _format_fragment(sql: str) -> str:
11+
return sqlparse.format(
12+
sql,
13+
reindent=True,
14+
keyword_case="upper",
15+
use_space_around_operators=True,
16+
indent_width=4,
17+
).strip()
18+
19+
20+
def format_sql(sql: str) -> str:
21+
"""Format SQL without changing Sqlit's implicit statement boundaries."""
22+
if not sql.strip():
23+
return sql
24+
25+
statements = split_statements(sql)
26+
semicolon_statements = sqlparse.split(sql)
27+
if len(statements) > 1 and len(semicolon_statements) == 1:
28+
# Sqlit also supports two blank lines as an implicit separator. Format
29+
# each statement independently so sqlparse cannot collapse that boundary.
30+
return "\n\n\n".join(_format_fragment(statement) for statement in statements)
31+
return _format_fragment(sql)
32+
33+
34+
def _location_to_offset(text: str, location: tuple[int, int]) -> int:
35+
row, column = location
36+
lines = text.splitlines(keepends=True)
37+
if row >= len(lines):
38+
return len(text)
39+
return min(sum(len(line) for line in lines[:row]) + column, len(text))
40+
41+
42+
def _offset_to_location(text: str, offset: int) -> tuple[int, int]:
43+
prefix = text[: max(0, min(offset, len(text)))]
44+
row = prefix.count("\n")
45+
last_newline = prefix.rfind("\n")
46+
column = len(prefix) if last_newline < 0 else len(prefix) - last_newline - 1
47+
return row, column
48+
49+
50+
def remap_cursor_after_format(
51+
original: str,
52+
formatted: str,
53+
cursor: tuple[int, int],
54+
) -> tuple[int, int]:
55+
"""Keep the cursor beside the same non-whitespace token after formatting."""
56+
original_offset = _location_to_offset(original, cursor)
57+
token_count = sum(not char.isspace() for char in original[:original_offset])
58+
if token_count == 0:
59+
return 0, 0
60+
61+
seen = 0
62+
formatted_offset = len(formatted)
63+
for index, char in enumerate(formatted):
64+
if not char.isspace():
65+
seen += 1
66+
if seen == token_count:
67+
formatted_offset = index + 1
68+
break
69+
return _offset_to_location(formatted, formatted_offset)

‎sqlit/domains/query/state/query_normal.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ def _setup_actions(self) -> None:
2727
"edit_query_in_editor",
2828
help="Open current query in your terminal editor",
2929
)
30+
self.allows("format_query", help="Format the current query")
3031
# Vim cursor movement
3132
self.allows("cursor_left", help="Move cursor left")
3233
self.allows("cursor_right", help="Move cursor right")

‎sqlit/domains/query/ui/mixins/query.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from .query_editing_comments import QueryEditingCommentsMixin
1313
from .query_editing_common import QueryEditingCommonMixin
1414
from .query_editing_cursor import QueryEditingCursorMixin
15+
from .query_editing_format import QueryEditingFormatMixin
1516
from .query_editing_operators import QueryEditingOperatorsMixin
1617
from .query_editing_selection import QueryEditingSelectionMixin
1718
from .query_editing_undo import QueryEditingUndoMixin
@@ -29,6 +30,7 @@ class QueryMixin(
2930
QueryEditingSelectionMixin,
3031
QueryEditingOperatorsMixin,
3132
QueryEditingClipboardMixin,
33+
QueryEditingFormatMixin,
3234
QueryEditingCommentsMixin,
3335
QueryEditingCursorMixin,
3436
QueryExecutionMixin,
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""Query formatting action."""
2+
3+
from __future__ import annotations
4+
5+
from sqlit.shared.ui.protocols import QueryMixinHost
6+
7+
8+
class QueryEditingFormatMixin:
9+
"""Format the complete query buffer with sqlparse."""
10+
11+
def action_format_query(self: QueryMixinHost) -> None:
12+
from sqlit.domains.query.editing.formatting import (
13+
format_sql,
14+
remap_cursor_after_format,
15+
)
16+
17+
original = self.query_input.text
18+
if not original.strip():
19+
self.notify("Nothing to format", severity="warning")
20+
return
21+
22+
formatted = format_sql(original)
23+
if formatted == original:
24+
self.notify("Query is already formatted")
25+
return
26+
27+
cursor = remap_cursor_after_format(
28+
original,
29+
formatted,
30+
self.query_input.cursor_location,
31+
)
32+
self._push_undo_state()
33+
self.query_input.text = formatted
34+
self.query_input.cursor_location = cursor
35+
self.notify("Query formatted")

‎sqlit/domains/shell/state/machine.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@
3434
QueryFocusedState,
3535
QueryInsertModeState,
3636
QueryNormalModeState,
37-
QueryVisualModeState,
3837
QueryVisualLineModeState,
38+
QueryVisualModeState,
3939
)
4040
from sqlit.domains.results.state import (
4141
ResultsFilterActiveState,
@@ -50,7 +50,6 @@
5050
from sqlit.domains.shell.state.modal_active import ModalActiveState
5151
from sqlit.domains.shell.state.root import RootState
5252

53-
5453
STATE_TO_HELP_SECTION: dict[str, str] = {
5554
"QueryInsertModeState": "query_insert",
5655
"AutocompleteActiveState": "query_insert",
@@ -238,6 +237,7 @@ def lk(action: str, menu: str, fallback: str) -> str:
238237
s.binding(f"{g_key}{lk('execute_query_atomic', 'g', 't')}", "Execute as transaction")
239238
s.binding(k("show_history", "<backspace>"), "Query history")
240239
s.binding(k("new_query", "N"), "New query (clear)")
240+
s.binding(f"{leader_key}{lk('format_query', 'leader', 'p')}", "Format query")
241241
s.binding(k("undo", "u"), "Undo")
242242
s.binding(k("redo", "^r"), "Redo")
243243
sections.append(s)

‎sqlit/domains/shell/ui/mixins/ui_leader.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,9 @@ def action_leader_change_theme(self: UINavigationMixinHost) -> None:
109109
def action_leader_edit_query_in_editor(self: UINavigationMixinHost) -> None:
110110
self._execute_leader_command("edit_query_in_editor")
111111

112+
def action_leader_format_query(self: UINavigationMixinHost) -> None:
113+
self._execute_leader_command("format_query")
114+
112115
def action_leader_toggle_process_worker(self: UINavigationMixinHost) -> None:
113116
self._execute_leader_command("toggle_process_worker")
114117

‎tests/ui/keybindings/test_keymap_provider.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ def test_default_keymap_has_expected_leader_commands(self):
2626
assert keymap.leader("show_help") is not None
2727
assert keymap.leader("toggle_explorer") is not None
2828
assert keymap.leader("change_theme") is not None
29+
assert keymap.leader("format_query") == "p"
2930

3031
def test_default_keymap_has_expected_action_keys(self):
3132
"""Default keymap should have standard action keys."""

‎tests/ui/keybindings/test_state_machine.py‎

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ def test_footer_shows_cancel_when_executing(self):
5858
sm = UIStateMachine()
5959
ctx = make_context(query_executing=True)
6060

61-
left, right = sm.get_display_bindings(ctx)
61+
left, _right = sm.get_display_bindings(ctx)
6262
actions = [b.action for b in left]
6363
assert "cancel_operation" in actions
6464

@@ -245,3 +245,45 @@ def test_allowed_when_results_focused(self):
245245
leader_menu="leader",
246246
)
247247
assert sm.check_action(ctx, "leader_edit_query_in_editor") is True
248+
249+
250+
class TestFormatQueryLeaderCommand:
251+
def test_allowed_when_query_focused(self):
252+
sm = UIStateMachine()
253+
ctx = make_context(
254+
focus="query",
255+
leader_pending=True,
256+
leader_menu="leader",
257+
)
258+
assert sm.check_action(ctx, "leader_format_query") is True
259+
260+
def test_blocked_when_explorer_focused(self):
261+
sm = UIStateMachine()
262+
ctx = make_context(
263+
focus="explorer",
264+
leader_pending=True,
265+
leader_menu="leader",
266+
)
267+
assert sm.check_action(ctx, "leader_format_query") is False
268+
269+
def test_blocked_when_results_focused(self):
270+
sm = UIStateMachine()
271+
ctx = make_context(
272+
focus="results",
273+
leader_pending=True,
274+
leader_menu="leader",
275+
)
276+
assert sm.check_action(ctx, "leader_format_query") is False
277+
278+
def test_help_lists_space_p_format_binding(self):
279+
sm = UIStateMachine()
280+
query_help = next(
281+
section for section in sm.generate_help_sections() if section.id == "query_normal"
282+
)
283+
284+
assert any(
285+
item.kind == "binding"
286+
and item.key == "<space>p"
287+
and item.description == "Format query"
288+
for item in query_help.items
289+
)

0 commit comments

Comments
 (0)