Skip to content

Commit 232aea9

Browse files
author
williamp44
committed
feat: add cljfmt :partial mode to format only replaced forms (#154)
When :cljfmt is set to :partial in config, clojure_edit formats only the replacement form in isolation (via cljfmt) and re-indents it to the correct column position, rather than reformatting the entire file. This prevents collateral formatting changes to unrelated code. - Add re-indent-to-column and format-form-in-isolation pure functions - Add capture-form-position and format-new-source-partial pipeline steps - Update format-source to skip whole-file formatting when partial pre-formatting ran - sexp-edit-pipeline falls back to full-file formatting in :partial mode - Update schema to accept [:or :boolean [:= :partial]] - 298 tests, 2153 assertions, 0 failures
1 parent 203944d commit 232aea9

8 files changed

Lines changed: 551 additions & 17 deletions

File tree

CONFIG.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,17 @@ These basic settings affect how clojure-mcp interacts with your local system.
1414
Controls which directories the MCP tools can access for security. Paths can be relative (resolved from project root) or absolute.
1515

1616
### `:cljfmt`
17-
Boolean flag to enable/disable cljfmt formatting in editing pipelines (default: `true`). When disabled, file edits preserve the original formatting without applying cljfmt.
17+
Controls cljfmt formatting behavior in editing pipelines (default: `true`). Determines whether and how formatting is applied when editing Clojure forms.
1818

1919
**Available values:**
20-
- `true` (default) - Applies cljfmt formatting to all edited files
21-
- `false` - Disables formatting, preserving exact whitespace and formatting
20+
- `true` (default) - Applies cljfmt formatting to the entire file after each edit
21+
- `:partial` - Formats only the replaced/inserted form in isolation, preserving surrounding formatting
22+
- `false` - Disables formatting entirely, preserving exact whitespace and formatting
2223

2324
**When to use each setting:**
24-
- `true` - Best for maintaining consistent code style across your project
25-
- `false` - Useful when working with files that have specific formatting requirements or when you want to preserve manual formatting
25+
- `true` - Best for maintaining consistent code style across your project. Note: this reformats the entire file after each edit, which may introduce formatting changes to code you did not intend to modify.
26+
- `:partial` - Recommended for most workflows. Formats only the form being edited (fixing indentation, spacing, etc.) without touching the rest of the file. Prevents collateral formatting changes to unrelated code.
27+
- `false` - Useful when working with files that have specific formatting requirements or when you want to preserve manual formatting completely
2628

2729
### `:write-file-guard`
2830
Controls the file timestamp tracking behavior (default: `:partial-read`). This setting determines when file editing is allowed based on read operations.

PROJECT_SUMMARY.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,8 +135,9 @@ your-project/
135135
- `:partial-read` - Both full and collapsed reads update timestamps (default)
136136
- `:full-read` - Only full reads update timestamps (safest)
137137
- `false` - Disables timestamp checking entirely
138-
- `cljfmt`: Boolean flag to enable/disable cljfmt formatting in editing pipelines (default: `true`)
139-
- `true` - Applies cljfmt formatting to edited files (default behavior)
138+
- `cljfmt`: Controls cljfmt formatting in editing pipelines (default: `true`)
139+
- `true` - Applies cljfmt formatting to entire file after edits (default behavior)
140+
- `:partial` - Formats only the replaced/inserted form, preserving surrounding formatting
140141
- `false` - Disables formatting, preserving exact whitespace and formatting
141142
- `bash-over-nrepl`: Boolean flag to control bash command execution mode (default: `true`)
142143
- `true` - Execute bash commands over nREPL connection (default behavior)

src/clojure_mcp/config.clj

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,9 @@
144144
distinct
145145
vec))
146146
(some? (:cljfmt config))
147-
(assoc :cljfmt (boolean (:cljfmt config)))
147+
(assoc :cljfmt (if (= :partial (:cljfmt config))
148+
:partial
149+
(boolean (:cljfmt config))))
148150
(some? (:bash-over-nrepl config))
149151
(assoc :bash-over-nrepl (boolean (:bash-over-nrepl config)))
150152
(some? (:nrepl-env-type config))
@@ -224,7 +226,12 @@
224226
(defn get-nrepl-user-dir [nrepl-client-map]
225227
(get-config nrepl-client-map :nrepl-user-dir))
226228

227-
(defn get-cljfmt [nrepl-client-map]
229+
(defn get-cljfmt
230+
"Returns the cljfmt setting: true (default), false, or :partial.
231+
- true: full-file formatting via cljfmt after edits
232+
- false: no formatting
233+
- :partial: format only the replaced form in isolation, preserving surrounding formatting"
234+
[nrepl-client-map]
228235
(let [value (get-config nrepl-client-map :cljfmt)]
229236
(if (nil? value)
230237
true ; Default to true when not specified

src/clojure_mcp/config/schema.clj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,7 @@
261261
;; Core configuration
262262
[:allowed-directories {:optional true} [:sequential Path]]
263263
[:write-file-guard {:optional true} [:enum :full-read :partial-read false]]
264-
[:cljfmt {:optional true} :boolean]
264+
[:cljfmt {:optional true} [:or :boolean [:= :partial]]]
265265
[:bash-over-nrepl {:optional true} :boolean]
266266
[:nrepl-env-type {:optional true} [:enum :clj :bb :basilisp :scittle]]
267267
[:start-nrepl-cmd {:optional true} [:sequential NonBlankString]]

src/clojure_mcp/tools/form_edit/core.clj

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
[rewrite-clj.node :as n]
1111
[rewrite-clj.parser :as p]
1212
[rewrite-clj.zip :as z]
13-
[clojure-mcp.utils.file :as file-utils]))
13+
[clojure-mcp.utils.file :as file-utils]
14+
[taoensso.timbre :as log]))
1415

1516
;; Form identification and location functions
1617

@@ -276,6 +277,52 @@
276277
[source-str formatting-options]
277278
(fmt/reformat-string source-str formatting-options))
278279

280+
;; Partial formatting helpers
281+
282+
(defn re-indent-to-column
283+
"Re-indents a formatted form string so that:
284+
- The first line is unchanged (it will be placed at the correct column by rewrite-clj)
285+
- Subsequent lines are indented to align with the target column position.
286+
287+
Arguments:
288+
- form-str: The formatted form string (formatted in isolation starting at column 1)
289+
- target-col: The 1-based column where the form starts in the file
290+
291+
Returns:
292+
- The re-indented form string"
293+
[form-str target-col]
294+
(if (<= target-col 1)
295+
form-str
296+
(let [lines (str/split form-str #"\n" -1)
297+
indent-str (apply str (repeat (dec target-col) " "))
298+
re-indented (cons (first lines)
299+
(map (fn [line]
300+
(if (str/blank? line)
301+
line
302+
(str indent-str line)))
303+
(rest lines)))]
304+
(str/join "\n" re-indented))))
305+
306+
(defn format-form-in-isolation
307+
"Formats a form string in isolation using cljfmt, then re-indents it
308+
to match the target column position. This avoids reformatting the
309+
entire file when only one form is being replaced.
310+
311+
Arguments:
312+
- form-str: The form source code to format
313+
- target-col: The 1-based column where the form starts in the file
314+
- formatting-options: cljfmt formatting options
315+
316+
Returns:
317+
- The formatted and re-indented form string, or the original on failure"
318+
[form-str target-col formatting-options]
319+
(try
320+
(let [formatted (fmt/reformat-string form-str formatting-options)]
321+
(re-indent-to-column formatted target-col))
322+
(catch Exception e
323+
(log/debug "Partial formatting failed, using original form:" (.getMessage e))
324+
form-str)))
325+
279326
;; File operations
280327

281328
(defn load-file-content

src/clojure_mcp/tools/form_edit/pipeline.clj

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,16 @@
4242

4343
(s/def ::dry-run (s/nilable #{"diff" "new-source"}))
4444

45+
(s/def ::form-col pos-int?)
46+
4547
(s/def ::context
4648
(s/keys :req [::file-path]
4749
:opt [::source ::old-content ::new-source-code ::top-level-def-name
4850
::top-level-def-type ::edit-type ::error ::message
4951
::zloc ::offsets ::lint-result ::docstring
5052
::comment-substring ::new-content ::expand-symbols
51-
::diff ::type ::output-source ::nrepl-client-atom ::dry-run]))
53+
::diff ::type ::output-source ::nrepl-client-atom ::dry-run
54+
::form-col]))
5255

5356
;; Pipeline helper functions
5457

@@ -229,6 +232,43 @@
229232
(str error-msg suggestion-msg)
230233
error-msg)}))))
231234

235+
(defn capture-form-position
236+
"Captures the column position of the found form for partial formatting.
237+
Must be called after find-form, before edit-form.
238+
Saves ::form-col (1-based column) to the context when position is available.
239+
Leaves ::form-col absent when z/position returns nil so downstream code
240+
can fall back to full-file formatting rather than mis-indenting.
241+
242+
Requires ::zloc in the context (pointing to the found form)."
243+
[ctx]
244+
(let [zloc (::zloc ctx)]
245+
(if-let [pos (z/position zloc)]
246+
(assoc ctx ::form-col (second pos)) ;; [row col] -> col
247+
ctx))) ;; leave ::form-col absent — downstream falls back to full-file formatting
248+
249+
(defn format-new-source-partial
250+
"Formats the new source code in isolation when cljfmt is set to :partial
251+
and position tracking succeeded (::form-col is present in context).
252+
Formats ::new-source-code using cljfmt, then re-indents to match the
253+
target column position. This is done BEFORE insertion so the formatted
254+
content replaces the old form without touching the rest of the file.
255+
256+
When ::form-col is absent (position tracking unavailable), passes through
257+
unchanged so format-source falls back to full-file formatting.
258+
259+
Requires ::new-source-code, ::form-col, and ::nrepl-client-atom in context."
260+
[ctx]
261+
(let [nrepl-client-map (some-> ctx ::nrepl-client-atom deref)
262+
cljfmt-setting (config/get-cljfmt nrepl-client-map)]
263+
(if (and (= :partial cljfmt-setting) (::form-col ctx))
264+
(let [new-source (::new-source-code ctx)
265+
target-col (::form-col ctx)
266+
formatting-options (core/project-formatting-options nrepl-client-map)
267+
formatted (core/format-form-in-isolation new-source target-col formatting-options)]
268+
(assoc ctx ::new-source-code formatted))
269+
;; Not :partial mode, or position unavailable — pass through
270+
ctx)))
271+
232272
(defn edit-form
233273
"Edits the form according to the specified edit type.
234274
Requires ::zloc, ::top-level-def-type, ::top-level-def-name,
@@ -269,13 +309,25 @@
269309
"Formats the source code using the formatter.
270310
If formatting fails but the source is syntactically valid,
271311
returns the original source unchanged.
272-
312+
313+
When cljfmt is :partial AND the form was already formatted in isolation
314+
(indicated by ::form-col in context), skips whole-file formatting.
315+
Otherwise falls back to full-file formatting (e.g., for sexp-edit-pipeline
316+
which doesn't do pre-insertion partial formatting).
317+
273318
Requires ::output-source in the context.
274319
Updates ::output-source with the formatted code (or unchanged if formatting fails)."
275320
[ctx]
276321
(let [nrepl-client-map (some-> ctx ::nrepl-client-atom deref)
277-
cljfmt-enabled (config/get-cljfmt nrepl-client-map)]
278-
(if cljfmt-enabled
322+
cljfmt-setting (config/get-cljfmt nrepl-client-map)]
323+
(cond
324+
;; :partial mode with pre-formatted form - skip whole-file formatting
325+
;; ::form-col presence means format-new-source-partial already ran
326+
(and (= :partial cljfmt-setting) (::form-col ctx))
327+
ctx
328+
329+
;; true (or :partial without pre-formatting) - full-file formatting
330+
cljfmt-setting
279331
(try
280332
(let [source (::output-source ctx)
281333
formatting-options (core/project-formatting-options nrepl-client-map)
@@ -289,7 +341,9 @@
289341
;; Only propagate error if we don't have valid source
290342
{::error true
291343
::message (str "Failed to format source: " (.getMessage e))})))
292-
;; If cljfmt is disabled, return ctx unchanged
344+
345+
;; false - formatting disabled, return ctx unchanged
346+
:else
293347
ctx)))
294348

295349
(defn determine-file-type
@@ -425,6 +479,8 @@
425479
enhance-defmethod-name
426480
parse-source
427481
find-form
482+
capture-form-position
483+
format-new-source-partial
428484
edit-form
429485
zloc->output-source
430486
format-source

test/clojure_mcp/config/schema_test.clj

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,14 @@
8484

8585
(testing "Config with all nrepl-env-type values"
8686
(doseq [env-type [:clj :bb :basilisp :scittle]]
87-
(is (schema/valid? {:nrepl-env-type env-type})))))
87+
(is (schema/valid? {:nrepl-env-type env-type}))))
88+
89+
(testing "Config with cljfmt :partial"
90+
(is (schema/valid? {:cljfmt :partial})))
91+
92+
(testing "Config with all cljfmt values"
93+
(doseq [v [true false :partial]]
94+
(is (schema/valid? {:cljfmt v})))))
8895

8996
;; ==============================================================================
9097
;; Invalid Configuration Tests

0 commit comments

Comments
 (0)