diff --git a/server/src/e2e/suite/TestParser.ts b/server/src/e2e/suite/TestParser.ts index 771cdbf4..9e504690 100644 --- a/server/src/e2e/suite/TestParser.ts +++ b/server/src/e2e/suite/TestParser.ts @@ -2,10 +2,11 @@ import * as fs from "node:fs" export interface TestCase { name: string - properties: Record + properties: Map input: string expected: string result?: string + propertiesOrder: string[] } enum ParserState { @@ -25,7 +26,10 @@ export class TestParser { const lines = content.trim().replace(/\r\n/g, "\n").split("\n") let state = ParserState.WaitingForTestStart - let currentTest: Partial = {} + let currentTest: Partial = { + properties: new Map(), + propertiesOrder: [], + } let currentContent = "" for (const l of lines) { @@ -35,14 +39,29 @@ export class TestParser { case ParserState.WaitingForTestStart: if (line === SEPARATOR) { state = ParserState.ReadingProperties - currentTest = {properties: {}} + currentTest = { + properties: new Map(), + propertiesOrder: [], + } } break case ParserState.ReadingProperties: - if (line.startsWith("@") && currentTest.properties) { - const [key, value] = line.substring(1).split(" ") - currentTest.properties[key] = value + if ( + line.startsWith("@") && + currentTest.properties && + currentTest.propertiesOrder + ) { + const propertyLine = line.substring(1) // remove @ + const spaceIndex = propertyLine.indexOf(" ") + if (spaceIndex !== -1) { + const key = propertyLine.substring(0, spaceIndex) + currentTest.properties.set( + key, + propertyLine.substring(spaceIndex + 1).trim(), + ) + currentTest.propertiesOrder.push(key) + } } else { currentTest.name = line state = ParserState.ReadingName @@ -71,7 +90,10 @@ export class TestParser { currentTest.expected = currentContent.trim() tests.push(currentTest as TestCase) state = ParserState.ReadingProperties - currentTest = {properties: {}} + currentTest = { + properties: new Map(), + propertiesOrder: [], + } currentContent = "" } else { currentContent += line + "\n" @@ -80,7 +102,6 @@ export class TestParser { } } - // Добавляем последний тест if (currentTest.name && currentContent) { currentTest.expected = currentContent.trim() tests.push(currentTest as TestCase) @@ -103,8 +124,8 @@ export class TestParser { newContent.push(SEPARATOR) - for (const [key, value] of Object.entries(test.properties)) { - newContent.push(`@${key} ${value}`) + for (const key of test.propertiesOrder) { + newContent.push(`@${key} ${test.properties.get(key)}`) } newContent.push(test.name) diff --git a/server/src/e2e/suite/intentions.test.ts b/server/src/e2e/suite/intentions.test.ts index e5ab2b73..600e7777 100644 --- a/server/src/e2e/suite/intentions.test.ts +++ b/server/src/e2e/suite/intentions.test.ts @@ -6,16 +6,31 @@ import {TestCase} from "./TestParser" suite("Intentions Test Suite", () => { const testSuite = new (class extends BaseTestSuite { async getCodeActions(input: string): Promise { - const textWithoutCaret = input.replace("", "") - await this.replaceDocumentText(textWithoutCaret) + const selectionStart = input.indexOf("") + const selectionEnd = input.indexOf("") - const caretIndex = input.indexOf("") - if (caretIndex === -1) { - throw new Error("No marker found in input") - } + let range: vscode.Range + let textWithoutMarkers: string + + if (selectionStart !== -1 && selectionEnd !== -1) { + textWithoutMarkers = input.replace("", "").replace("", "") + await this.replaceDocumentText(textWithoutMarkers) + + const startPos = this.document.positionAt(selectionStart) + const endPos = this.document.positionAt(selectionEnd - "".length) + range = new vscode.Range(startPos, endPos) + } else { + textWithoutMarkers = input.replace("", "") + await this.replaceDocumentText(textWithoutMarkers) + + const caretIndex = input.indexOf("") + if (caretIndex === -1) { + throw new Error("No or markers found in input") + } - const position = this.document.positionAt(caretIndex) - const range = new vscode.Range(position, position) + const position = this.document.positionAt(caretIndex) + range = new vscode.Range(position, position) + } return vscode.commands.executeCommand( "vscode.executeCodeActionProvider", @@ -41,9 +56,21 @@ suite("Intentions Test Suite", () => { return } - assert.ok(actions.length > 0, "No code actions available") + let selectedAction = actions[0] + + const intentionName = testCase.properties.get("intention") + if (intentionName) { + const found = actions.find(action => action.title === intentionName) + assert.ok( + found, + `Intention "${intentionName}" not found. Available intentions: ${actions + .map(a => a.title) + .join(", ")}`, + ) + selectedAction = found + } - const command = actions[0].command + const command = selectedAction.command if (!command || !command.arguments) throw new Error("No intention command") await vscode.commands.executeCommand( diff --git a/server/src/e2e/suite/testcases/intentions/WrapSelected..test b/server/src/e2e/suite/testcases/intentions/WrapSelected..test new file mode 100644 index 00000000..b07cadfc --- /dev/null +++ b/server/src/e2e/suite/testcases/intentions/WrapSelected..test @@ -0,0 +1,242 @@ +======================================================================== +@intention Wrap selected to "try" +Wrap multiple statements in try +======================================================================== +contract Test { + fun test() { + let a = 1; + let b = 2; + let c = 3; + } +} +------------------------------------------------------------------------ +contract Test { + fun test() { + try { + let a = 1; + let b = 2; + let c = 3; + } + } +} + +======================================================================== +@intention Wrap selected to "try-catch" +Wrap multiple statements in try-catch +======================================================================== +contract Test { + fun test() { + let a = 1; + let b = 2; + } +} +------------------------------------------------------------------------ +contract Test { + fun test() { + try { + let a = 1; + let b = 2; + } catch(e) { + + } + } +} + +======================================================================== +@intention Wrap selected to "repeat" +Wrap multiple statements in repeat +======================================================================== +contract Test { + fun test() { + let a = 1; + let b = 2; + } +} +------------------------------------------------------------------------ +contract Test { + fun test() { + repeat(10) { + let a = 1; + let b = 2; + } + } +} + +======================================================================== +No intentions on empty selection +======================================================================== +contract Test { + fun test() { + + } +} +------------------------------------------------------------------------ +No intentions + +======================================================================== +@intention Wrap selected to "repeat" +Wrap single statement in repeat +======================================================================== +contract Test { + fun test() { + let a = 1; + } +} +------------------------------------------------------------------------ +contract Test { + fun test() { + repeat(10) { + let a = 1; + } + } +} + +======================================================================== +@intention Wrap selected to "try" +Wrap single statement +======================================================================== +contract Test { + fun test() { + let a = 1; + } +} +------------------------------------------------------------------------ +contract Test { + fun test() { + try { + let a = 1; + } + } +} + +======================================================================== +@intention Wrap selected to "try" +Wrap mixed statements +======================================================================== +contract Test { + fun test() { + let a = 1; + return a + 1; + self.value = 10; + a += 1; + do { + a = 2; + } until (a > 10); + } +} +------------------------------------------------------------------------ +contract Test { + fun test() { + try { + let a = 1; + return a + 1; + self.value = 10; + a += 1; + do { + a = 2; + } until (a > 10); + } + } +} + +======================================================================== +@intention Wrap selected to "try-catch" +Wrap complex control flow statements +======================================================================== +contract Test { + fun test() { + if (true) { + let x = 1; + } + while (x < 10) { + x += 1; + } + repeat(5) { + x = x * 2; + } + foreach(k, v in map) { + sum += v; + } + } +} +------------------------------------------------------------------------ +contract Test { + fun test() { + try { + if (true) { + let x = 1; + } + while (x < 10) { + x += 1; + } + repeat(5) { + x = x * 2; + } + foreach(k, v in map) { + sum += v; + } + } catch(e) { + + } + } +} + +======================================================================== +@intention Wrap selected to "repeat" +Wrap nested control flow +======================================================================== +contract Test { + fun test() { + try { + if (x > 0) { + while(true) { + x -= 1; + } + } + } catch(e) { + return 0; + } + } +} +------------------------------------------------------------------------ +contract Test { + fun test() { + repeat(10) { + try { + if (x > 0) { + while(true) { + x -= 1; + } + } + } catch(e) { + return 0; + } + } + } +} + +======================================================================== +@intention Wrap selected to "try" +Wrap expressions and assignments +======================================================================== +contract Test { + fun test() { + send(); + x = 1; + y += 2; + z *= 3; + w |= 4; + } +} +------------------------------------------------------------------------ +contract Test { + fun test() { + try { + send(); + x = 1; + y += 2; + z *= 3; + w |= 4; + } + } +} diff --git a/server/src/intentions/Intention.ts b/server/src/intentions/Intention.ts index c47d605c..3ebad9af 100644 --- a/server/src/intentions/Intention.ts +++ b/server/src/intentions/Intention.ts @@ -1,14 +1,18 @@ import {WorkspaceEdit} from "vscode-languageserver" import {File} from "@server/psi/File" import {Position} from "vscode-languageclient" +import {Range} from "vscode-languageserver-textdocument" export interface IntentionContext { file: File + range: Range position: Position + noSelection: boolean } export interface IntentionArguments { fileUri: string + range: Range position: Position } diff --git a/server/src/intentions/WrapSelected.ts b/server/src/intentions/WrapSelected.ts new file mode 100644 index 00000000..3b6327f8 --- /dev/null +++ b/server/src/intentions/WrapSelected.ts @@ -0,0 +1,181 @@ +import {Intention, IntentionContext} from "@server/intentions/Intention" +import {WorkspaceEdit} from "vscode-languageserver" +import {FileDiff} from "@server/utils/FileDiff" +import {Node as SyntaxNode} from "web-tree-sitter" +import {RecursiveVisitor} from "@server/psi/visitor" + +export class WrapSelected implements Intention { + id: string + name: string + snippet: string + + statementsWithSemicolon: string[] = [ + "let_statement", + "return_statement", + "expression_statement", + "assignment_statement", + "augmented_assignment_statement", + "do_until_statement", + ] + + statementTypes: string[] = [ + ...this.statementsWithSemicolon, + + "block_statement", + "if_statement", + "while_statement", + "repeat_statement", + "try_statement", + "foreach_statement", + ] + + constructor(to: string, snippet: string) { + this.id = `tact.wrap-selected-${to}` + this.name = `Wrap selected to "${to}"` + this.snippet = snippet + } + + findStatements(ctx: IntentionContext): SyntaxNode[] { + if (ctx.noSelection) return [] + + const statements: SyntaxNode[] = [] + const startLine = ctx.range.start.line + const endLine = ctx.range.end.line + + // Find all top-level statements in selection + RecursiveVisitor.visit(ctx.file.rootNode, (node): boolean => { + if (!this.statementTypes.includes(node.type)) return true + + const nodeStartLine = node.startPosition.row + const nodeEndLine = node.endPosition.row + + // Check if node is within selection + if (nodeStartLine >= startLine && nodeEndLine <= endLine) { + // Check if node's parent is not a statement or is outside selection + const parent = node.parent + if ( + !parent || + !this.statementTypes.includes(parent.type) || + parent.startPosition.row < startLine || + parent.endPosition.row > endLine + ) { + statements.push(node) + // Skip visiting children since we don't want nested statements + return false + } + } + return true + }) + + return statements.filter(statement => !this.statementTypes.includes(statement.text)) + } + + is_available(ctx: IntentionContext): boolean { + return this.findStatements(ctx).length > 0 + } + + private findIndent(ctx: IntentionContext, node: SyntaxNode) { + const lines = ctx.file.content.split(/\r?\n/) + const line = lines[node.startPosition.row] + const lineTrim = line.trimStart() + return line.indexOf(lineTrim) + } + + private indentStatement(stmt: SyntaxNode, baseIndent: string): string { + const needSemicolon = this.statementsWithSemicolon.includes(stmt.type) + const lines = stmt.text.split("\n") + if (lines.length === 1) { + if (needSemicolon) { + return baseIndent + stmt.text + ";" + } + + return baseIndent + stmt.text + } + + return lines + .map((line, i) => { + if (i === 0) return baseIndent + line + if (i === lines.length - 1 && needSemicolon) return " " + line + ";" + return " " + line + }) + .join("\n") + } + + invoke(ctx: IntentionContext): WorkspaceEdit | null { + const statements = this.findStatements(ctx) + if (statements.length === 0) return null + + const diff = FileDiff.forFile(ctx.file.uri) + const firstStmt = statements[0] + const lastStmt = statements[statements.length - 1] + + const indentCount = this.findIndent(ctx, firstStmt) + const indent = " ".repeat(indentCount) + + const statementsText = statements + .map((stmt, i) => { + if (i === 0) return this.indentStatement(stmt, "") + return this.indentStatement(stmt, indent + " ") + }) + .join("\n") + + const result = this.snippet + .trimStart() + .replace(/\$stmts/, statementsText) + .replace(/\$indent/g, indent) + + diff.replace( + { + start: { + line: firstStmt.startPosition.row, + character: firstStmt.startPosition.column, + }, + end: { + line: lastStmt.endPosition.row, + character: lastStmt.endPosition.column + 1, + }, + }, + result, + ) + + return diff.toWorkspaceEdit() + } +} + +export class WrapSelectedToTry extends WrapSelected { + constructor() { + super( + "try", + ` +try { + $indent$stmts +$indent}`, + ) + } +} + +export class WrapSelectedToTryCatch extends WrapSelected { + constructor() { + super( + "try-catch", + ` +try { + $indent$stmts +$indent} catch(e) { + +$indent}`, + ) + } +} + +export class WrapSelectedToRepeat extends WrapSelected { + constructor() { + super( + "repeat", + ` +repeat(10) { + $indent$stmts +$indent}`, + ) + } +} diff --git a/server/src/server.ts b/server/src/server.ts index 1c069e74..81e536b4 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -100,6 +100,11 @@ import {FillAllStructInit, FillRequiredStructInit} from "@server/intentions/Fill import {generateInitDoc, generateReceiverDoc} from "@server/documentation/receivers_documentation" import {AsKeywordCompletionProvider} from "@server/completion/providers/AsKeywordCompletionProvider" import {AddFieldInitialization} from "@server/intentions/AddFieldInitialization" +import { + WrapSelectedToRepeat, + WrapSelectedToTry, + WrapSelectedToTryCatch, +} from "@server/intentions/WrapSelected" /** * Whenever LS is initialized. @@ -1168,6 +1173,9 @@ connection.onInitialize(async (params: lsp.InitializeParams): Promise