davsclaus commented on code in PR #26750:
URL: https://github.com/apache/camel/pull/26750#discussion_r4076573119


##########
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:
   Confirmed, and you are right that my tests masked it: both trimmed-match 
tests edit the last content line of the fixture, where the window reaches the 
end of the file. I reproduced the arithmetic on its own before changing 
anything - a mid-file replacement whose text does not end in a newline came out 
as `message: "ONE"      - to:`.
   
   I did not take the suggested `end` though, because I checked that variant 
too and it breaks removal: with `replace: ""` the window then leaves the empty 
line behind where the block was. The window keeps the newline, so a removal 
still takes whole lines, and a non-empty replacement that does not end in one 
gets it back:
   
   ```java
   if (!put.isEmpty() && !put.endsWith("\\n") && content.charAt(first + length 
- 1) == '\\n') {
       put = put + "\\n";
   }
   ```
   
   Two tests added: 
`aTrimmedMatchInTheMiddleOfTheFileKeepsTheLineAfterItOnItsOwnLine` and 
`removingABlockWithATrimmedMatchLeavesNoEmptyLine`.



-- 
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