Skip to content

Commit cd67e56

Browse files
committed
feat(command): add robust EditRequest parser
Implement a comprehensive EditRequest parsing system with support for multiple formats (YAML, advanced, legacy) and structured error handling. The new parser provides better validation, cleaner error messages, and properly handles escape sequences in code strings
1 parent 84b308e commit cd67e56

File tree

5 files changed

+598
-128
lines changed

5 files changed

+598
-128
lines changed

build.gradle.kts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,12 @@ configure(subprojects - project(":exts")) {
108108
targetCompatibility = VERSION_17
109109
}
110110

111+
tasks.withType<KotlinCompile> {
112+
kotlinOptions {
113+
jvmTarget = "17"
114+
}
115+
}
116+
111117
tasks {
112118
prepareSandbox { enabled = false }
113119
}

core/src/main/kotlin/cc/unitmesh/devti/command/EditFileCommand.kt

Lines changed: 2 additions & 128 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,11 @@ import com.intellij.openapi.vfs.VirtualFile
1414
import com.intellij.openapi.diff.impl.patch.TextFilePatch
1515
import kotlinx.serialization.SerialName
1616
import kotlinx.serialization.Serializable
17-
import org.yaml.snakeyaml.LoaderOptions
18-
import org.yaml.snakeyaml.Yaml
19-
import org.yaml.snakeyaml.constructor.SafeConstructor
2017
import java.util.concurrent.CompletableFuture
2118

2219
class EditFileCommand(private val project: Project) {
2320
private val editApply = EditApply()
21+
private val parser = EditRequestParser()
2422

2523
fun executeEdit(editRequest: EditRequest): EditResult {
2624
val projectDir = project.guessProjectDir() ?: return EditResult.error("Project directory not found")
@@ -70,131 +68,7 @@ class EditFileCommand(private val project: Project) {
7068
}
7169

7270
fun parseEditRequest(content: String): EditRequest? {
73-
return try {
74-
parseAsYaml(content) ?: parseAsAdvancedFormat(content) ?: parseAsLegacyFormat(content)
75-
} catch (e: Exception) {
76-
parseAsAdvancedFormat(content) ?: parseAsLegacyFormat(content)
77-
}
78-
}
79-
80-
private fun parseAsYaml(content: String): EditRequest? {
81-
return try {
82-
val yaml = Yaml(SafeConstructor(LoaderOptions()))
83-
val data = yaml.load<Map<String, Any>>(content) ?: return null
84-
85-
val targetFile = data["target_file"] as? String ?: return null
86-
val instructions = data["instructions"] as? String ?: ""
87-
val codeEdit = data["code_edit"] as? String ?: return null
88-
89-
EditRequest(
90-
targetFile = targetFile,
91-
instructions = instructions,
92-
codeEdit = codeEdit
93-
)
94-
} catch (e: Exception) {
95-
null
96-
}
97-
}
98-
99-
private fun parseAsAdvancedFormat(content: String): EditRequest? {
100-
return try {
101-
val targetFileRegex = """target_file\s*:\s*["']?([^"'\n]+)["']?""".toRegex()
102-
val instructionsRegex = """instructions\s*:\s*["']?([^"'\n]*?)["']?""".toRegex()
103-
104-
val blockScalarPattern = """code_edit\s*:\s*\|\s*\n(.*?)(?=\n\S|\n*$)""".toRegex(RegexOption.DOT_MATCHES_ALL)
105-
106-
val quotedStringPattern = """code_edit\s*:\s*["'](.*?)["']""".toRegex(RegexOption.DOT_MATCHES_ALL)
107-
108-
val targetFileMatch = targetFileRegex.find(content)
109-
val instructionsMatch = instructionsRegex.find(content)
110-
111-
val codeEditMatch = blockScalarPattern.find(content) ?: quotedStringPattern.find(content)
112-
113-
if (targetFileMatch != null && codeEditMatch != null) {
114-
val codeEditContent = if (blockScalarPattern.matches(codeEditMatch.value)) {
115-
codeEditMatch.groupValues[1].trimEnd()
116-
} else {
117-
// Handle quoted string - process escape sequences
118-
codeEditMatch.groupValues[1]
119-
.replace("\\n", "\n")
120-
.replace("\\\"", "\"")
121-
.replace("\\'", "'")
122-
}
123-
124-
EditRequest(
125-
targetFile = targetFileMatch.groupValues[1].trim(),
126-
instructions = instructionsMatch?.groupValues?.get(1)?.trim() ?: "",
127-
codeEdit = codeEditContent
128-
)
129-
} else {
130-
null
131-
}
132-
} catch (e: Exception) {
133-
null
134-
}
135-
}
136-
137-
private fun parseAsLegacyFormat(content: String): EditRequest? {
138-
return try {
139-
val targetFileRegex = """target_file["\s]*[:=]["\s]*["']([^"']+)["']""".toRegex()
140-
val instructionsRegex =
141-
"""instructions["\s]*[:=]["\s]*["']([^"']*?)["']""".toRegex(RegexOption.DOT_MATCHES_ALL)
142-
143-
val targetFileMatch = targetFileRegex.find(content)
144-
val instructionsMatch = instructionsRegex.find(content)
145-
146-
// Extract code_edit content more carefully to handle nested quotes
147-
val codeEditContent = extractCodeEditContent(content)
148-
149-
if (targetFileMatch != null && codeEditContent != null) {
150-
EditRequest(
151-
targetFile = targetFileMatch.groupValues[1],
152-
instructions = instructionsMatch?.groupValues?.get(1) ?: "",
153-
codeEdit = codeEditContent
154-
)
155-
} else {
156-
null
157-
}
158-
} catch (e: Exception) {
159-
null
160-
}
161-
}
162-
163-
private fun extractCodeEditContent(content: String): String? {
164-
// Look for code_edit field
165-
val codeEditStart = """code_edit["\s]*[:=]["\s]*["']""".toRegex().find(content) ?: return null
166-
val startIndex = codeEditStart.range.last + 1
167-
168-
if (startIndex >= content.length) return null
169-
170-
// Determine the quote type used to open the string
171-
val openingQuote = content[startIndex - 1]
172-
173-
// Find the matching closing quote, handling escaped quotes
174-
var index = startIndex
175-
var escapeNext = false
176-
177-
while (index < content.length) {
178-
val char = content[index]
179-
180-
if (escapeNext) {
181-
escapeNext = false
182-
} else if (char == '\\') {
183-
escapeNext = true
184-
} else if (char == openingQuote) {
185-
// Found the closing quote
186-
val extractedContent = content.substring(startIndex, index)
187-
return extractedContent
188-
.replace("\\n", "\n") // Handle escaped newlines
189-
.replace("\\\"", "\"") // Handle escaped quotes
190-
.replace("\\'", "'") // Handle escaped single quotes
191-
.replace("\\\\", "\\") // Handle escaped backslashes
192-
}
193-
194-
index++
195-
}
196-
197-
return null
71+
return parser.parse(content)
19872
}
19973
}
20074

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
package cc.unitmesh.devti.command
2+
3+
import org.yaml.snakeyaml.LoaderOptions
4+
import org.yaml.snakeyaml.Yaml
5+
import org.yaml.snakeyaml.constructor.SafeConstructor
6+
7+
/**
8+
* Parser for EditRequest objects from various text formats
9+
* Supports YAML, advanced format, and legacy format parsing
10+
*/
11+
class EditRequestParser {
12+
13+
/**
14+
* Parse content into EditRequest, trying multiple formats
15+
* @param content The content to parse
16+
* @return EditRequest if parsing succeeds, null if all formats fail
17+
* @throws ParseException if parsing fails with specific error information
18+
*/
19+
fun parse(content: String): EditRequest? {
20+
if (content.isBlank()) {
21+
return null
22+
}
23+
24+
val errors = mutableListOf<ParseException>()
25+
26+
// Try YAML format first
27+
try {
28+
parseAsYaml(content)?.let { return it }
29+
} catch (e: ParseException) {
30+
errors.add(e)
31+
}
32+
33+
// Try advanced format
34+
try {
35+
parseAsAdvancedFormat(content)?.let { return it }
36+
} catch (e: ParseException) {
37+
errors.add(e)
38+
}
39+
40+
// Try legacy format
41+
try {
42+
parseAsLegacyFormat(content)?.let { return it }
43+
} catch (e: ParseException) {
44+
errors.add(e)
45+
}
46+
47+
// If we have specific errors, we can log them for debugging
48+
// For now, return null to maintain backward compatibility
49+
return null
50+
}
51+
52+
/**
53+
* Parse content as YAML format
54+
*/
55+
fun parseAsYaml(content: String): EditRequest? {
56+
return try {
57+
val yaml = Yaml(SafeConstructor(LoaderOptions()))
58+
val data = yaml.load<Map<String, Any>>(content)
59+
?: throw ParseException.YamlParseException("YAML content is null or empty")
60+
61+
val targetFile = data["target_file"] as? String
62+
?: throw ParseException.MissingFieldException("target_file")
63+
64+
val instructions = data["instructions"] as? String ?: ""
65+
66+
val codeEdit = data["code_edit"] as? String
67+
?: throw ParseException.MissingFieldException("code_edit")
68+
69+
// Process escape sequences for YAML quoted strings
70+
val processedCodeEdit = processEscapeSequences(codeEdit)
71+
72+
validateEditRequest(targetFile, processedCodeEdit)
73+
74+
EditRequest(
75+
targetFile = targetFile,
76+
instructions = instructions,
77+
codeEdit = processedCodeEdit
78+
)
79+
} catch (e: ParseException) {
80+
throw e
81+
} catch (e: Exception) {
82+
throw ParseException.YamlParseException("Failed to parse YAML: ${e.message}", e)
83+
}
84+
}
85+
86+
/**
87+
* Parse content as advanced format (YAML-like with regex)
88+
*/
89+
fun parseAsAdvancedFormat(content: String): EditRequest? {
90+
return try {
91+
val targetFileRegex = """target_file\s*:\s*["']?([^"'\n]+)["']?""".toRegex()
92+
val instructionsRegex = """instructions\s*:\s*["']?([^"'\n]*?)["']?""".toRegex()
93+
val blockScalarPattern = """code_edit\s*:\s*\|\s*\n(.*?)(?=\n\S|\n*$)""".toRegex(RegexOption.DOT_MATCHES_ALL)
94+
val quotedStringPattern = """code_edit\s*:\s*["'](.*?)["']""".toRegex(RegexOption.DOT_MATCHES_ALL)
95+
96+
val targetFileMatch = targetFileRegex.find(content)
97+
?: throw ParseException.MissingFieldException("target_file")
98+
99+
val instructionsMatch = instructionsRegex.find(content)
100+
val codeEditMatch = blockScalarPattern.find(content) ?: quotedStringPattern.find(content)
101+
?: throw ParseException.MissingFieldException("code_edit")
102+
103+
val codeEditContent = if (blockScalarPattern.matches(codeEditMatch.value)) {
104+
codeEditMatch.groupValues[1].trimEnd()
105+
} else {
106+
// Handle quoted string - process escape sequences
107+
processEscapeSequences(codeEditMatch.groupValues[1])
108+
}
109+
110+
val targetFile = targetFileMatch.groupValues[1].trim()
111+
val instructions = instructionsMatch?.groupValues?.get(1)?.trim() ?: ""
112+
113+
validateEditRequest(targetFile, codeEditContent)
114+
115+
EditRequest(
116+
targetFile = targetFile,
117+
instructions = instructions,
118+
codeEdit = codeEditContent
119+
)
120+
} catch (e: ParseException) {
121+
throw e
122+
} catch (e: Exception) {
123+
throw ParseException.RegexParseException("Failed to parse advanced format: ${e.message}", e)
124+
}
125+
}
126+
127+
/**
128+
* Parse content as legacy format
129+
*/
130+
fun parseAsLegacyFormat(content: String): EditRequest? {
131+
return try {
132+
val targetFileRegex = """target_file["\s]*[:=]["\s]*["']([^"']+)["']""".toRegex()
133+
val instructionsRegex = """instructions["\s]*[:=]["\s]*["']([^"']*?)["']""".toRegex(RegexOption.DOT_MATCHES_ALL)
134+
135+
val targetFileMatch = targetFileRegex.find(content)
136+
?: throw ParseException.MissingFieldException("target_file")
137+
138+
val instructionsMatch = instructionsRegex.find(content)
139+
val codeEditContent = extractCodeEditContent(content)
140+
?: throw ParseException.MissingFieldException("code_edit")
141+
142+
val targetFile = targetFileMatch.groupValues[1]
143+
val instructions = instructionsMatch?.groupValues?.get(1) ?: ""
144+
145+
validateEditRequest(targetFile, codeEditContent)
146+
147+
EditRequest(
148+
targetFile = targetFile,
149+
instructions = instructions,
150+
codeEdit = codeEditContent
151+
)
152+
} catch (e: ParseException) {
153+
throw e
154+
} catch (e: Exception) {
155+
throw ParseException.RegexParseException("Failed to parse legacy format: ${e.message}", e)
156+
}
157+
}
158+
159+
/**
160+
* Extract code_edit content from legacy format, handling nested quotes
161+
*/
162+
private fun extractCodeEditContent(content: String): String? {
163+
return try {
164+
val codeEditStart = """code_edit["\s]*[:=]["\s]*["']""".toRegex().find(content) ?: return null
165+
val startIndex = codeEditStart.range.last + 1
166+
167+
if (startIndex >= content.length) return null
168+
169+
val openingQuote = content[startIndex - 1]
170+
var index = startIndex
171+
var escapeNext = false
172+
173+
while (index < content.length) {
174+
val char = content[index]
175+
176+
if (escapeNext) {
177+
escapeNext = false
178+
} else if (char == '\\') {
179+
escapeNext = true
180+
} else if (char == openingQuote) {
181+
val extractedContent = content.substring(startIndex, index)
182+
return processEscapeSequences(extractedContent)
183+
}
184+
185+
index++
186+
}
187+
188+
throw ParseException.QuoteParseException("Unclosed quote in code_edit field")
189+
} catch (e: ParseException) {
190+
throw e
191+
} catch (e: Exception) {
192+
throw ParseException.QuoteParseException("Failed to extract code_edit content: ${e.message}", e)
193+
}
194+
}
195+
196+
/**
197+
* Process escape sequences in strings
198+
*/
199+
private fun processEscapeSequences(content: String): String {
200+
return content
201+
.replace("\\n", "\n")
202+
.replace("\\\"", "\"")
203+
.replace("\\'", "'")
204+
.replace("\\\\", "\\")
205+
}
206+
207+
/**
208+
* Validate the parsed EditRequest fields
209+
*/
210+
private fun validateEditRequest(targetFile: String, codeEdit: String) {
211+
if (targetFile.isBlank()) {
212+
throw ParseException.InvalidFieldException("target_file", targetFile, "cannot be blank")
213+
}
214+
215+
if (codeEdit.isBlank()) {
216+
throw ParseException.InvalidFieldException("code_edit", codeEdit, "cannot be blank")
217+
}
218+
219+
// Additional validation can be added here
220+
if (targetFile.contains("..")) {
221+
throw ParseException.InvalidFieldException("target_file", targetFile, "path traversal not allowed")
222+
}
223+
}
224+
}

0 commit comments

Comments
 (0)