gnodet-bot commented on code in PR #26750:
URL: https://github.com/apache/camel/pull/26750#discussion_r4075779697


##########
dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java:
##########
@@ -364,6 +384,274 @@ public static JsonObject validate(ToolContext ctx, Path 
dir, String file, String
     /** How long a write waits for the running integration's reload record 
before answering without it. */
     static final long RELOAD_WAIT_MILLIS = 8000;
 
+    /**
+     * Replaces one snippet of a file and writes the result through {@link 
#writeFile}, so a change to an existing file
+     * does not rewrite every line of it: a model that re-emits a whole file 
corrupts the lines it did not mean to touch
+     * (CAMEL-24909). The snippet must occur exactly once; the answer says 
what was replaced.
+     */
+    /** How long a file may be to be handed back when an edit misses, in 
lines. */
+    private static final int MAX_EDIT_ECHO_LINES = 400;
+
+    public static JsonObject editFile(ToolContext ctx, Path dir, String file, 
String find, String replace) {
+        JsonObject edit = editedContent(dir, file, find, replace);
+        String content = edit.getString("content");
+        if (content == null) {
+            return edit; // not-found or ambiguous: the answer says what to do 
instead
+        }
+        JsonObject result = writeFile(ctx, dir, file, content, true);
+        if (!"invalid".equals(result.getString("status"))) {
+            result.put("status", "edited");
+            result.put("editedAtLine", edit.getInteger("editedAtLine"));
+            result.put("replacedLines", edit.getInteger("replacedLines"));
+        } else {
+            result.put("message", "The file was not changed: the result has 
validation errors. Fix them and call"
+                                  + " camel_edit_file again.");
+        }
+        return result;
+    }
+
+    /**
+     * The content of the file with the snippet replaced, in {@code content}, 
with the line it changed and how many
+     * lines it replaced; or the answer of a miss (not-found, with the nearest 
lines) or of an ambiguous snippet. The
+     * TUI writes that content itself, so an edit is confirmed and replayed in 
the editor like a write.
+     */
+    public static JsonObject editedContent(Path dir, String file, String find, 
String replace) {
+        Path path = resolveFile(dir, file);
+        if (!Files.isRegularFile(path)) {
+            throw new ToolExecutionException(file + " does not exist: write 
the whole file with camel_write_file");
+        }
+        String content;
+        try {
+            content = Files.readString(path, StandardCharsets.UTF_8);
+        } catch (IOException e) {
+            throw new ToolExecutionException("Failed to read " + path + ": " + 
e.getMessage());
+        }
+        if (find == null || find.isEmpty()) {
+            throw new ToolExecutionException("find is required: the text to 
replace, as it stands in the file");
+        }
+        String wanted = find;
+        String put = replace;
+        boolean trimmedMatch = false;
+        int first = content.indexOf(wanted);
+        int length = wanted.length();
+        if (first < 0) {
+            // the same lines with different indentation or trailing spaces: a 
model composes the snippet from the
+            // shape it has in mind rather than from the file (CAMEL-24909), 
so match on the trimmed lines when that
+            // names exactly one place
+            int[] window = uniqueTrimmedWindow(content, wanted);
+            if (window != null) {
+                first = window[0];
+                length = window[1] - window[0];
+                trimmedMatch = true;
+            }
+        }
+        if (first < 0 && hasLiteralEscapes(wanted)) {
+            // the snippet was built as a JSON string and its escapes were 
left in it, so the text holds a literal
+            // \n where the file has a newline: read it the way it was meant 
(CAMEL-24909)
+            String unescaped = unescapeLiterals(wanted);
+            int retry = content.indexOf(unescaped);
+            int retryLength = unescaped.length();
+            if (retry < 0) {
+                int[] window = uniqueTrimmedWindow(content, unescaped);
+                if (window != null) {
+                    retry = window[0];
+                    retryLength = window[1] - window[0];
+                    trimmedMatch = true;
+                }
+            }
+            if (retry >= 0) {
+                first = retry;
+                length = retryLength;
+                wanted = unescaped;
+                put = unescapeLiterals(put);
+            }
+        }
+        JsonObject result = new JsonObject();
+        result.put("file", file);
+        if (first < 0) {
+            result.put("status", "not-found");
+            String nearest = nearestBlock(content, wanted);
+            String message = "The text to find is not in the file as given; 
copy the lines from the file"
+                             + (nearest != null ? ", which has there:\n" + 
nearest : "");
+            if (nearest != null) {
+                result.put("nearest", nearest);
+            }
+            // a model that misses twice is writing the snippet from memory, 
so hand it the file it is editing
+            // instead of sending it back to camel_get_files (CAMEL-24909)
+            if (content.lines().count() <= MAX_EDIT_ECHO_LINES) {
+                result.put("fileContent", content);
+                message += nearest != null
+                        ? ". The whole file is in fileContent: copy the text 
to find from there"
+                        : ". The file as it stands is in fileContent: copy the 
text to find from there";
+            } else if (nearest == null) {
+                message += " (camel_get_files reads it)";
+            }
+            result.put("message", message);
+            return result;
+        }
+        if (content.indexOf(wanted, first + wanted.length()) >= 0) {
+            result.put("status", "ambiguous");
+            result.put("occurrences", count(content, wanted));
+            result.put("message", "The text to find occurs more than once: 
include the lines around it so it names one"
+                                  + " place, or write the whole file with 
camel_write_file");
+            return result;
+        }
+        if (trimmedMatch) {
+            // the snippet was written at another indentation than the file 
has: put the replacement in at the
+            // file's indentation, or the result is valid text at the wrong 
depth (CAMEL-24909)
+            put = reindent(put, indentOf(wanted), 
indentOf(content.substring(first)));
+        }
+        int line = (int) content.substring(0, first).lines().count()
+                   + (first > 0 && content.charAt(first - 1) == '\n' ? 1 : 0);
+        result.put("content", content.substring(0, first) + put + 
content.substring(first + length));
+        result.put("editedAtLine", Math.max(1, line));
+        // the lines actually replaced: with the trimmed match that is the 
window in the file, which can be shorter
+        // than find when it ends in blank lines (CAMEL-24909)
+        result.put("replacedLines", (int) content.substring(first, first + 
length).lines().count());
+        return result;
+    }
+
+    /** The leading whitespace of the first line of the text that has 
something on it. */
+    private static String indentOf(String text) {
+        for (String line : text.split("\n", -1)) {
+            if (!line.isBlank()) {
+                int i = 0;
+                while (i < line.length() && 
Character.isWhitespace(line.charAt(i))) {
+                    i++;
+                }
+                return line.substring(0, i);
+            }
+        }
+        return "";
+    }
+
+    /** Moves the text from the indentation it was written at to the one the 
file has at that place. */
+    private static String reindent(String text, String from, String to) {
+        int delta = to.length() - from.length();
+        if (delta == 0 || text.isEmpty()) {
+            return text;
+        }
+        StringBuilder sb = new StringBuilder(text.length() + Math.abs(delta) * 
8);
+        String[] lines = text.split("\n", -1);
+        for (int i = 0; i < lines.length; i++) {
+            String line = lines[i];
+            if (!line.isBlank()) {
+                if (delta > 0) {
+                    line = " ".repeat(delta) + line;
+                } else {
+                    int strip = 0;
+                    while (strip < -delta && strip < line.length() && 
line.charAt(strip) == ' ') {
+                        strip++;
+                    }
+                    line = line.substring(strip);
+                }
+            }
+            sb.append(line);
+            if (i < lines.length - 1) {
+                sb.append('\n');
+            }
+        }
+        return sb.toString();
+    }
+
+    /** Whether the text carries JSON escapes that were never turned back into 
the characters they stand for. */
+    private static boolean hasLiteralEscapes(String text) {
+        return text != null && (text.contains("\\n") || 
text.contains("\\r\\n") || text.contains("\\t"));
+    }
+
+    /** Reads {@code \n}, {@code \r\n} and {@code \t} as the characters they 
stand for. */
+    private static String unescapeLiterals(String text) {
+        return text == null ? null : text.replace("\\r\\n", 
"\n").replace("\\n", "\n").replace("\\t", "\t");
+    }
+
+    /**
+     * The one place where the file's lines match the wanted lines once their 
leading and trailing whitespace is
+     * removed, as start and end offset in the content, or null when there is 
no such place or more than one.
+     */
+    private static int[] uniqueTrimmedWindow(String content, String find) {
+        List<String> wanted = find.lines().map(String::strip).toList();
+        while (!wanted.isEmpty() && wanted.get(wanted.size() - 1).isEmpty()) {
+            wanted = wanted.subList(0, wanted.size() - 1);
+        }
+        if (wanted.isEmpty()) {
+            return null;
+        }
+        String[] lines = content.split("\n", -1);
+        int[] offsets = lineOffsets(content, lines);
+        int[] found = null;
+        for (int i = 0; i + wanted.size() <= lines.length; i++) {
+            boolean match = true;
+            for (int j = 0; j < wanted.size(); j++) {
+                if (!lines[i + j].strip().equals(wanted.get(j))) {
+                    match = false;
+                    break;
+                }
+            }
+            if (match) {
+                if (found != null) {
+                    return null; // more than one place: the caller must name 
one
+                }
+                int end = offsets[i + wanted.size() - 1] + lines[i + 
wanted.size() - 1].length();
+                found = new int[] { offsets[i], Math.min(end + 1, 
content.length()) };

Review Comment:
   ⚠️ **Bug — trimmed-match window over-extends by one byte, losing the newline 
separator when `replace` has no trailing newline.**
   
   `end + 1` positions the window past the `\n` that terminates the last 
matched line:
   ```
   content[first : first+length] = "    log: two\n"   // includes \n
   content[first+length :]       = "route_c\n"        // immediately next line
   ```
   The substitution is `content.sub(0, first) + put + content.sub(first + 
length)`. When `put` (the replacement) does not end with `\n` — which is the 
common case; models write snippet text, not file terminators — 
`content.sub(first + length)` starts right after the consumed `\n`, so the next 
line runs directly onto the last line of `put`:
   ```
   "route_a\n    log: one\n\nroute_b\nlog: THREEroute_c\n    done\n"
                                                     ^^^^^^^^^^^^ glued
   ```
   The current tests only exercise trimmed matches at the very end of the file 
(`message: "two"` is the last content line in `ROUTE`), so `content.sub(first + 
length)` is either empty or a single trailing `\n`, masking the issue.
   
   The fix: exclude the `\n` from the window so the file's own newline is 
preserved:
   ```suggestion
                   found = new int[] { offsets[i], Math.min(end, 
content.length()) };
   ```
   With this change, `content.sub(first + length)` starts at the `\n` itself, 
which appears in the output as the separator between the replacement and the 
following content. `replacedLines` is unaffected: `"B\nC".lines().count()` and 
`"B\nC\n".lines().count()` both return 2.
   
   A test to lock it in:
   ```java
   @Test
   void trimmedMatchInTheMiddleOfTheFilePreservesTheNewlineSeparator(@TempDir 
Path dir) throws IOException {
       // ROUTE has two routes; this replaces a line in the FIRST route,
       // so the second route must still begin on its own line afterwards.
       Files.writeString(dir.resolve("demo.camel.yaml"), ROUTE);
   
       // model writes the snippet flat — trimmed match, non-terminal position
       JsonObject result = edit(dir, "- log:\n    message: \"one\"", "- log:\n  
  message: \"ONE\"");
   
       assertThat(result.getString("status")).isEqualTo("edited");
       String after = Files.readString(dir.resolve("demo.camel.yaml"));
       // the second route must start on its own line, not glued to the 
replacement
       assertThat(after).contains("message: \"ONE\"\n");
       assertThat(after.lines().count()).isEqualTo(ROUTE.lines().count());
   }
   ```



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to